feat: implement daemon connectivity updates and relay protocol versioning

This commit is contained in:
Mohamed Boudra
2026-02-20 19:27:56 +07:00
parent 6dad3212da
commit 0350f00840
49 changed files with 4146 additions and 1351 deletions
@@ -29,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Shortcut } from '@/components/ui/shortcut'
import { Autocomplete } from '@/components/ui/autocomplete'
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
type QueuedMessage = {
id: string
@@ -77,10 +78,14 @@ export function AgentInputArea({
(s) => s.clearMessageInputActionRequest
)
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const toast = useToast()
const voice = useVoiceOptional()
const isConnected = client?.isConnected ?? false
const isDictationReady =
isConnected &&
(snapshot?.agentDirectoryStatus === 'ready' ||
snapshot?.agentDirectoryStatus === 'revalidating' ||
snapshot?.agentDirectoryStatus === 'error_after_ready')
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
@@ -672,6 +677,7 @@ export function AgentInputArea({
onAddImages={addImages}
onRemoveImage={handleRemoveImage}
client={client}
isReadyForDictation={isDictationReady}
placeholder="Message agent..."
autoFocus={autoFocus}
disabled={isSubmitLoading}
+14 -12
View File
@@ -14,7 +14,7 @@ import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -94,22 +94,24 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState<
string[]
>([]);
const {
entries,
projectOptions,
projectFilterOptions,
hasMoreEntries,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({
} = useSidebarAgentsList({
isOpen,
serverId: activeServerId,
selectedProjectKeys,
selectedProjectFilterKeys,
});
useEffect(() => {
setSelectedProjectKeys([]);
setSelectedProjectFilterKeys([]);
}, [activeServerId]);
const {
translateX,
@@ -373,9 +375,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : (
<SidebarAgentList
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
@@ -499,9 +501,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : (
<SidebarAgentList
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
@@ -55,6 +55,8 @@ export interface MessageInputProps {
onAddImages?: (images: ImageAttachment[]) => void
onRemoveImage?: (index: number) => void
client: DaemonClient | null
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean
placeholder?: string
autoFocus?: boolean
disabled?: boolean
@@ -121,6 +123,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onAddImages,
onRemoveImage,
client,
isReadyForDictation,
placeholder = 'Message...',
autoFocus = false,
disabled = false,
@@ -262,14 +265,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const canStartDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false
return socketConnected && !disabled && !dictationUnavailableMessage
}, [client, disabled, dictationUnavailableMessage])
const readyForDictation = isReadyForDictation ?? socketConnected
return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage
}, [client, disabled, dictationUnavailableMessage, isReadyForDictation])
const canConfirmDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false
return socketConnected
}, [client])
const isConnected = client?.isConnected ?? false
const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled
const {
isRecording: isDictating,
@@ -719,7 +724,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleVoicePress}
disabled={!isConnected || disabled}
disabled={!isDictationStartEnabled}
accessibilityRole="button"
accessibilityLabel={
isRealtimeVoiceForCurrentAgent
@@ -732,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}
style={[
styles.voiceButton,
(!isConnected || disabled) && styles.buttonDisabled,
(!isDictationStartEnabled) && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
]}
>
@@ -1,28 +1,13 @@
import { useEffect } from "react";
import { AppState } from "react-native";
import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import {
getHostRuntimeStore,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import { toDaemonConnectionUpdateFromRuntime } from "@/runtime/host-runtime-bridge";
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { snapshot, client } = useHostRuntimeSession(daemon.serverId);
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
const hasConnectionRecord = connectionStates.has(daemon.serverId);
useEffect(() => {
if (!hasConnectionRecord) {
return;
}
updateConnectionStatus(
daemon.serverId,
toDaemonConnectionUpdateFromRuntime(snapshot)
);
}, [daemon.serverId, hasConnectionRecord, snapshot, updateConnectionStatus]);
const { client } = useHostRuntimeSession(daemon.serverId);
if (!client) {
return null;
@@ -47,20 +32,6 @@ export function MultiDaemonSessionHost() {
runtime.syncHosts(daemons);
}, [daemons]);
useEffect(() => {
const runtime = getHostRuntimeStore();
const subscription = AppState.addEventListener("change", (nextState) => {
if (nextState !== "active") {
return;
}
runtime.ensureConnectedAll();
void runtime.runProbeCycleNow();
});
return () => {
subscription.remove();
};
}, []);
if (daemons.length === 0) {
return null;
}
@@ -43,8 +43,8 @@ import {
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import {
type SidebarAgentListEntry,
type SidebarProjectOption,
} from "@/hooks/use-sidebar-agents-grouped";
type SidebarProjectFilterOption,
} from "@/hooks/use-sidebar-agents-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getIsTauri } from "@/constants/layout";
import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -59,9 +59,9 @@ type EntryData = SidebarAgentListEntry;
interface SidebarAgentListProps {
entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[];
selectedProjectKeys: string[];
onSelectedProjectKeysChange: (keys: string[]) => void;
projectFilterOptions: SidebarProjectFilterOption[];
selectedProjectFilterKeys: string[];
onSelectedProjectFilterKeysChange: (keys: string[]) => void;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
@@ -72,7 +72,7 @@ interface SidebarAgentListProps {
}
interface ProjectFilterOptionRowProps {
option: SidebarProjectOption;
option: SidebarProjectFilterOption;
selected: boolean;
iconDataUri: string | null;
displayName: string;
@@ -381,15 +381,15 @@ function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) {
}
function resolveSelectedProjectLabel(input: {
selectedProjectKeys: string[];
projectOptions: SidebarProjectOption[];
selectedProjectFilterKeys: string[];
projectFilterOptions: SidebarProjectFilterOption[];
}): string {
if (input.selectedProjectKeys.length === 0) {
if (input.selectedProjectFilterKeys.length === 0) {
return "Project";
}
if (input.selectedProjectKeys.length === 1) {
const selected = input.projectOptions.find(
(option) => option.projectKey === input.selectedProjectKeys[0]
if (input.selectedProjectFilterKeys.length === 1) {
const selected = input.projectFilterOptions.find(
(option) => option.projectKey === input.selectedProjectFilterKeys[0]
);
if (selected) {
return deriveProjectDisplayName({
@@ -399,7 +399,7 @@ function resolveSelectedProjectLabel(input: {
});
}
return deriveProjectDisplayName({
projectKey: input.selectedProjectKeys[0] ?? "",
projectKey: input.selectedProjectFilterKeys[0] ?? "",
projectName: "",
remoteUrl: null,
});
@@ -444,9 +444,9 @@ function deriveProjectDisplayName(input: {
export function SidebarAgentList({
entries,
projectOptions,
selectedProjectKeys,
onSelectedProjectKeysChange,
projectFilterOptions,
selectedProjectFilterKeys,
onSelectedProjectFilterKeysChange,
isRefreshing = false,
onRefresh,
selectedAgentId,
@@ -479,23 +479,23 @@ export function SidebarAgentList({
);
const selectedProjectLabel = useMemo(
() => resolveSelectedProjectLabel({ selectedProjectKeys, projectOptions }),
[projectOptions, selectedProjectKeys]
() => resolveSelectedProjectLabel({ selectedProjectFilterKeys, projectFilterOptions }),
[projectFilterOptions, selectedProjectFilterKeys]
);
const selectedProjectOption = useMemo(() => {
if (selectedProjectKeys.length !== 1) {
if (selectedProjectFilterKeys.length !== 1) {
return null;
}
return (
projectOptions.find((option) => option.projectKey === selectedProjectKeys[0]) ?? null
projectFilterOptions.find((option) => option.projectKey === selectedProjectFilterKeys[0]) ?? null
);
}, [projectOptions, selectedProjectKeys]);
const showProjectFilters = projectOptions.length > 0;
}, [projectFilterOptions, selectedProjectFilterKeys]);
const showProjectFilters = projectFilterOptions.length > 0;
const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const option of projectOptions) {
for (const option of projectFilterOptions) {
if (!option.serverId || !option.workingDir) {
continue;
}
@@ -513,7 +513,7 @@ export function SidebarAgentList({
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
}
return Array.from(unique.values());
}, [entries, projectOptions]);
}, [entries, projectFilterOptions]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
@@ -559,7 +559,7 @@ export function SidebarAgentList({
const projectIconByProjectKey = useMemo(() => {
const map = new Map<string, string | null>();
for (const option of projectOptions) {
for (const option of projectFilterOptions) {
map.set(
option.projectKey,
projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null
@@ -577,7 +577,7 @@ export function SidebarAgentList({
);
}
return map;
}, [entries, projectIconByQueryKey, projectOptions]);
}, [entries, projectIconByQueryKey, projectFilterOptions]);
const selectedProjectIconUri = useMemo(() => {
if (!selectedProjectOption) {
@@ -588,20 +588,20 @@ export function SidebarAgentList({
const handleToggleProject = useCallback(
(projectKey: string) => {
const next = new Set(selectedProjectKeys);
const next = new Set(selectedProjectFilterKeys);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
onSelectedProjectKeysChange(Array.from(next));
onSelectedProjectFilterKeysChange(Array.from(next));
},
[onSelectedProjectKeysChange, selectedProjectKeys]
[onSelectedProjectFilterKeysChange, selectedProjectFilterKeys]
);
const handleClearProjectFilter = useCallback(() => {
onSelectedProjectKeysChange([]);
}, [onSelectedProjectKeysChange]);
onSelectedProjectFilterKeysChange([]);
}, [onSelectedProjectFilterKeysChange]);
const handleAgentPress = useCallback(
(entry: SidebarAgentListEntry) => {
@@ -755,7 +755,7 @@ export function SidebarAgentList({
ref={projectFilterAnchorRef}
style={({ hovered = false, pressed }) => [
styles.filterTrigger,
(selectedProjectKeys.length > 0 || hovered || pressed) &&
(selectedProjectFilterKeys.length > 0 || hovered || pressed) &&
styles.filterTriggerActive,
]}
onPress={() => setIsProjectFilterOpen(true)}
@@ -763,20 +763,20 @@ export function SidebarAgentList({
{({ hovered = false, pressed }) => {
const isInteracting = hovered || pressed;
const showActiveForeground =
selectedProjectKeys.length > 0 || isInteracting;
selectedProjectFilterKeys.length > 0 || isInteracting;
return (
<>
{selectedProjectKeys.length === 1 && selectedProjectIconUri ? (
{selectedProjectFilterKeys.length === 1 && selectedProjectIconUri ? (
<Image
source={{
uri: selectedProjectIconUri,
}}
style={styles.selectedProjectIcon}
/>
) : selectedProjectKeys.length > 1 ? (
) : selectedProjectFilterKeys.length > 1 ? (
<View style={styles.projectCountBadge}>
<Text style={styles.projectCountBadgeText}>
{selectedProjectKeys.length}
{selectedProjectFilterKeys.length}
</Text>
</View>
) : null}
@@ -802,7 +802,7 @@ export function SidebarAgentList({
}}
</Pressable>
{selectedProjectKeys.length > 0 ? (
{selectedProjectFilterKeys.length > 0 ? (
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
<Text style={styles.clearFilterText}>Clear</Text>
</Pressable>
@@ -821,14 +821,14 @@ export function SidebarAgentList({
anchorRef={projectFilterAnchorRef}
>
<View style={styles.filterOptionsList}>
{projectOptions.length === 0 ? (
{projectFilterOptions.length === 0 ? (
<Text style={styles.filterEmptyText}>No projects</Text>
) : (
projectOptions.map((option) => (
projectFilterOptions.map((option) => (
<ProjectFilterOptionRow
key={option.projectKey}
option={option}
selected={selectedProjectKeys.includes(option.projectKey)}
selected={selectedProjectFilterKeys.includes(option.projectKey)}
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
displayName={deriveProjectDisplayName({
projectKey: option.projectKey,
@@ -1,212 +1,93 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import {
getHostRuntimeStore,
type HostRuntimeAgentDirectoryStatus,
type HostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import { useDaemonRegistry, type HostProfile } from "./daemon-registry-context";
export type ActiveConnection =
| { type: "direct"; endpoint: string; display: string }
| { type: "relay"; endpoint: string; display: "relay" };
export type ConnectionState =
| { status: "idle"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: false }
| { status: "connecting"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
| { status: "online"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string; agentListReady: boolean; hasEverReceivedAgentList: boolean }
| { status: "offline"; activeConnection: ActiveConnection | null; lastError: string | null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
| { status: "error"; activeConnection: ActiveConnection | null; lastError: string; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean };
export type ConnectionStatus = ConnectionState["status"];
export type ConnectionStateUpdate =
| { status: "idle" }
| { status: "connecting"; activeConnection?: ActiveConnection | null; lastOnlineAt?: string | null }
| { status: "online"; activeConnection?: ActiveConnection | null; lastOnlineAt: string }
| { status: "offline"; activeConnection?: ActiveConnection | null; lastError?: string | null; lastOnlineAt?: string | null }
| { status: "error"; activeConnection?: ActiveConnection | null; lastError: string; lastOnlineAt?: string | null };
export type ConnectionStatus = HostRuntimeConnectionStatus;
export type DaemonConnectionRecord = {
daemon: HostProfile;
} & ConnectionState;
status: HostRuntimeConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
lastOnlineAt: string | null;
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
agentDirectoryError: string | null;
hasEverLoadedAgentDirectory: boolean;
};
interface DaemonConnectionsContextValue {
connectionStates: Map<string, DaemonConnectionRecord>;
isLoading: boolean;
updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void;
markAgentListReady: (serverId: string, ready: boolean) => void;
}
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
function createDefaultConnectionState(): ConnectionState {
return {
status: "idle",
activeConnection: null,
lastError: null,
lastOnlineAt: null,
agentListReady: false,
hasEverReceivedAgentList: false,
};
}
function buildConnectionStates(input: {
daemons: HostProfile[];
runtime: ReturnType<typeof getHostRuntimeStore>;
}): Map<string, DaemonConnectionRecord> {
const { daemons, runtime } = input;
const next = new Map<string, DaemonConnectionRecord>();
function resolveNextConnectionState(
existing: ConnectionState,
update: ConnectionStateUpdate
): ConnectionState {
switch (update.status) {
case "idle":
return {
status: "idle",
activeConnection: existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: false,
};
case "connecting":
return {
status: "connecting",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
case "online":
const currentAgentListReady =
existing.status === "online" ? existing.agentListReady : false;
return {
status: "online",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: update.lastOnlineAt,
agentListReady: currentAgentListReady,
hasEverReceivedAgentList:
currentAgentListReady || existing.hasEverReceivedAgentList || false,
};
case "offline":
return {
status: "offline",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: update.lastError ?? null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
case "error":
return {
status: "error",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: update.lastError,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
for (const daemon of daemons) {
const snapshot = runtime.getSnapshot(daemon.serverId);
next.set(daemon.serverId, {
daemon,
status: snapshot?.connectionStatus ?? "connecting",
activeConnection: snapshot?.activeConnection ?? null,
lastError: snapshot?.lastError ?? null,
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
agentDirectoryStatus: snapshot?.agentDirectoryStatus ?? "initial_loading",
agentDirectoryError: snapshot?.agentDirectoryError ?? null,
hasEverLoadedAgentDirectory:
snapshot?.hasEverLoadedAgentDirectory ?? false,
});
}
}
function logConnectionLifecycle(daemon: HostProfile, previous: ConnectionState, next: ConnectionState) {
const severity: "info" | "warn" = next.status === "error" ? "warn" : "info";
const logger = severity === "warn" ? console.warn : console.info;
const logPayload = {
event: "daemon_connection_state",
serverId: daemon.serverId,
label: daemon.label,
from: previous.status,
to: next.status,
lastError: next.lastError ?? null,
lastOnlineAt: next.lastOnlineAt ?? null,
timestamp: new Date().toISOString(),
severity,
};
logger("[DaemonConnection]", logPayload);
return next;
}
export function useDaemonConnections(): DaemonConnectionsContextValue {
const ctx = useContext(DaemonConnectionsContext);
if (!ctx) {
throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider");
throw new Error(
"useDaemonConnections must be used within DaemonConnectionsProvider"
);
}
return ctx;
}
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
const runtime = getHostRuntimeStore();
// Ensure connection states stay in sync with registry entries
useEffect(() => {
setConnectionStates((prev) => {
const next = new Map<string, DaemonConnectionRecord>();
for (const daemon of daemons) {
const existing = prev.get(daemon.serverId);
next.set(daemon.serverId, {
daemon,
...(existing ?? createDefaultConnectionState()),
});
}
return next;
});
}, [daemons]);
const updateConnectionStatus = useCallback(
(serverId: string, update: ConnectionStateUpdate) => {
setConnectionStates((prev) => {
const existing = prev.get(serverId);
if (!existing) {
return prev;
}
const nextState = resolveNextConnectionState(existing, update);
const hasChanged =
existing.status !== nextState.status ||
existing.lastError !== nextState.lastError ||
existing.lastOnlineAt !== nextState.lastOnlineAt;
if (hasChanged) {
logConnectionLifecycle(existing.daemon, existing, nextState);
}
const next = new Map(prev);
next.set(serverId, { daemon: existing.daemon, ...nextState });
return next;
});
},
[]
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion()
);
const markAgentListReady = useCallback((serverId: string, ready: boolean) => {
setConnectionStates((prev) => {
const existing = prev.get(serverId);
if (!existing) {
return prev;
}
if (existing.status !== "online") {
return prev;
}
if (
existing.agentListReady === ready &&
(ready ? existing.hasEverReceivedAgentList : true)
) {
return prev;
}
const next = new Map(prev);
next.set(serverId, {
...existing,
agentListReady: ready,
hasEverReceivedAgentList:
ready || existing.hasEverReceivedAgentList,
});
return next;
});
}, []);
const connectionStates = useMemo(
() => buildConnectionStates({ daemons, runtime }),
[daemons, runtime, runtimeVersion]
);
const value: DaemonConnectionsContextValue = {
connectionStates,
isLoading: registryLoading,
updateConnectionStatus,
markAgentListReady,
};
const value = useMemo<DaemonConnectionsContextValue>(
() => ({
connectionStates,
isLoading: registryLoading,
}),
[connectionStates, registryLoading]
);
return (
<DaemonConnectionsContext.Provider value={value}>
+37 -219
View File
@@ -1,7 +1,6 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { AppState, Platform } from "react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
@@ -15,7 +14,6 @@ import {
} from "@/types/stream";
import type {
ActivityLogPayload,
AgentSnapshotPayload,
AgentStreamEventPayload,
SessionOutboundMessage,
} from "@server/shared/messages";
@@ -26,9 +24,9 @@ import {
type NotificationPermissionRequest,
} from "@server/shared/agent-attention-notification";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import type { DaemonClient } from "@server/client/daemon-client";
import { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
useSessionStore,
type Agent,
@@ -39,6 +37,11 @@ import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications";
import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -53,32 +56,8 @@ export type {
AgentFileExplorerState,
} from "@/stores/session-store";
const derivePendingPermissionKey = (
agentId: string,
request: NotificationPermissionRequest
) => {
const fallbackId =
request.id ||
(typeof request.metadata?.id === "string"
? request.metadata.id
: undefined) ||
request.name ||
request.title ||
`${request.kind}:${JSON.stringify(
request.input ?? request.metadata ?? {}
)}`;
return `${agentId}:${fallbackId}`;
};
const HISTORY_STALE_AFTER_MS = 60_000;
type SessionConnectionSnapshot = {
isConnected: boolean;
isConnecting: boolean;
lastError: string | null;
};
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
@@ -89,15 +68,6 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
return null;
};
const mapConnectionState = (
state: ConnectionState,
lastError: string | null
): SessionConnectionSnapshot => ({
isConnected: state.status === "connected",
isConnecting: state.status === "connecting",
lastError: state.status === "disconnected" ? state.reason ?? lastError : null,
});
const getLatestPermissionRequest = (
session: SessionState | undefined,
agentId: string
@@ -142,51 +112,6 @@ type AgentUpdatePayload = Extract<
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
update.kind === "remove" ? update.agentId : update.agent.id;
function normalizeAgentSnapshot(
snapshot: AgentSnapshotPayload,
serverId: string
) {
const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt
? new Date(snapshot.lastUserMessageAt)
: null;
const attentionTimestamp = snapshot.attentionTimestamp
? new Date(snapshot.attentionTimestamp)
: null;
const archivedAt = snapshot.archivedAt
? new Date(snapshot.archivedAt)
: null;
return {
serverId,
id: snapshot.id,
provider: snapshot.provider,
status: snapshot.status as AgentLifecycleStatus,
createdAt,
updatedAt,
lastUserMessageAt,
lastActivityAt: updatedAt,
capabilities: snapshot.capabilities,
currentModeId: snapshot.currentModeId,
availableModes: snapshot.availableModes ?? [],
pendingPermissions: snapshot.pendingPermissions ?? [],
persistence: snapshot.persistence ?? null,
runtimeInfo: snapshot.runtimeInfo,
lastUsage: snapshot.lastUsage,
lastError: snapshot.lastError ?? null,
title: snapshot.title ?? null,
cwd: snapshot.cwd,
model: snapshot.model ?? null,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp,
archivedAt,
labels: snapshot.labels,
projectPlacement: null,
};
}
const createExplorerState = () => ({
directories: new Map(),
files: new Map(),
@@ -213,32 +138,11 @@ interface SessionProviderSharedProps {
serverId: string;
}
interface SessionProviderUrlProps extends SessionProviderSharedProps {
serverUrl: string;
daemonPublicKeyB64?: string;
}
interface SessionProviderClientProps extends SessionProviderSharedProps {
client: DaemonClient;
}
export type SessionProviderProps = SessionProviderUrlProps | SessionProviderClientProps;
function SessionProviderWithUrl({
children,
serverId,
serverUrl,
daemonPublicKeyB64,
}: SessionProviderUrlProps) {
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
return (
<SessionProviderInternal
children={children}
serverId={serverId}
client={client}
/>
);
}
export type SessionProviderProps = SessionProviderClientProps;
function SessionProviderWithClient({
children,
@@ -256,10 +160,7 @@ function SessionProviderWithClient({
// SessionProvider: Daemon client message handler that updates Zustand store
export function SessionProvider(props: SessionProviderProps) {
if ("client" in props) {
return <SessionProviderWithClient {...props} />;
}
return <SessionProviderWithUrl {...props} />;
return <SessionProviderWithClient {...props} />;
}
function SessionProviderInternal({
@@ -268,11 +169,7 @@ function SessionProviderInternal({
client,
}: SessionProviderClientProps) {
const queryClient = useQueryClient();
const [connectionSnapshot, setConnectionSnapshot] =
useState<SessionConnectionSnapshot>(() =>
mapConnectionState(client.getConnectionState(), client.lastError)
);
const { markAgentListReady } = useDaemonConnections();
const { isConnected } = useHostRuntimeSession(serverId);
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -323,6 +220,9 @@ function SessionProviderInternal({
const focusedAgentId = useSessionStore(
(state) => state.sessions[serverId]?.focusedAgentId ?? null
);
const sessionAgents = useSessionStore(
(state) => state.sessions[serverId]?.agents
);
const handleAppResumed = useCallback(
(awayMs: number) => {
@@ -359,8 +259,6 @@ function SessionProviderInternal({
) => Promise<void>)
| null
>(null);
const hasBootstrappedAgentUpdatesRef = useRef(false);
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
@@ -380,6 +278,18 @@ function SessionProviderInternal({
};
}, []);
useEffect(() => {
if (!sessionAgents) {
previousAgentStatusRef.current.clear();
return;
}
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
}, [sessionAgents]);
const notifyAgentAttention = useCallback(
(params: {
agentId: string;
@@ -456,22 +366,6 @@ function SessionProviderInternal({
}
const audioChunkBuffersRef = useRef<Map<string, AudioChunk[]>>(new Map());
useEffect(() => {
const unsubscribe = client.subscribeConnectionStatus((state) => {
setConnectionSnapshot(mapConnectionState(state, client.lastError));
});
return unsubscribe;
}, [client]);
const wasConnectedRef = useRef(client.isConnected);
useEffect(() => {
const wasConnected = wasConnectedRef.current;
if (!wasConnected && connectionSnapshot.isConnected) {
bumpHistorySyncGeneration(serverId);
}
wasConnectedRef.current = connectionSnapshot.isConnected;
}, [serverId, connectionSnapshot.isConnected, bumpHistorySyncGeneration]);
// Initialize session in store
useEffect(() => {
initializeSession(serverId, client, audioPlayer);
@@ -483,11 +377,11 @@ function SessionProviderInternal({
// If the client drops mid-initialization, clear pending flags
useEffect(() => {
if (!connectionSnapshot.isConnected) {
if (!isConnected) {
pendingAgentUpdatesRef.current.clear();
setInitializingAgents(serverId, new Map());
}
}, [serverId, connectionSnapshot.isConnected, setInitializingAgents]);
}, [serverId, isConnected, setInitializingAgents]);
const applyAgentUpdatePayload = useCallback(
(update: AgentUpdatePayload) => {
@@ -542,9 +436,13 @@ function SessionProviderInternal({
return;
}
const normalized = normalizeAgentSnapshot(update.agent, serverId);
const agent = {
...normalizeAgentSnapshot(update.agent, serverId),
projectPlacement: update.project,
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: update.project,
cwd: normalized.cwd,
}),
};
console.log("[Session] Agent update:", agent.id, agent.status);
@@ -796,91 +694,11 @@ function SessionProviderInternal({
);
useEffect(() => {
if (!connectionSnapshot.isConnected) {
hasBootstrappedAgentUpdatesRef.current = false;
pendingAgentUpdatesRef.current.clear();
agentUpdatesSubscriptionIdRef.current = null;
if (isConnected) {
return;
}
if (hasBootstrappedAgentUpdatesRef.current) {
return;
}
hasBootstrappedAgentUpdatesRef.current = true;
let cancelled = false;
const requestedSubscriptionId = `app:${serverId}`;
const bootstrapAgentDirectory = async () => {
try {
const payload = await client.fetchAgents({
filter: { labels: { ui: "true" } },
subscribe: { subscriptionId: requestedSubscriptionId },
});
if (cancelled) {
return;
}
agentUpdatesSubscriptionIdRef.current =
payload.subscriptionId ?? requestedSubscriptionId;
const nextAgents = new Map<string, Agent>();
const nextPendingPermissions = new Map<
string,
{ key: string; agentId: string; request: NotificationPermissionRequest }
>();
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const entry of payload.entries) {
const agent = {
...normalizeAgentSnapshot(entry.agent, serverId),
projectPlacement: entry.project,
};
nextAgents.set(agent.id, agent);
nextStatuses.set(agent.id, agent.status);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
nextPendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
previousAgentStatusRef.current = nextStatuses;
pendingAgentUpdatesRef.current.clear();
setAgents(serverId, nextAgents);
for (const agent of nextAgents.values()) {
setAgentLastActivity(agent.id, agent.lastActivityAt);
}
setPendingPermissions(serverId, nextPendingPermissions);
setInitializingAgents(serverId, new Map());
setHasHydratedAgents(serverId, true);
markAgentListReady(serverId, true);
} catch (err) {
if (cancelled) {
return;
}
hasBootstrappedAgentUpdatesRef.current = false;
pendingAgentUpdatesRef.current.clear();
agentUpdatesSubscriptionIdRef.current = null;
console.error("[Session] fetchAgents bootstrap failed", { serverId, err });
}
};
void bootstrapAgentDirectory();
return () => {
cancelled = true;
};
}, [
connectionSnapshot.isConnected,
client,
serverId,
setAgentLastActivity,
setAgents,
setHasHydratedAgents,
setInitializingAgents,
setPendingPermissions,
markAgentListReady,
]);
pendingAgentUpdatesRef.current.clear();
}, [isConnected]);
// Daemon message handlers - directly update Zustand store
useEffect(() => {
@@ -4,8 +4,13 @@ import {
deriveAgentScreenViewState,
type AgentScreenMachineInput,
type AgentScreenMachineMemory,
type AgentScreenViewState,
} from "./use-agent-screen-state-machine";
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z");
return {
@@ -52,15 +57,49 @@ function createBaseInput(): AgentScreenMachineInput {
isHistorySyncing: false,
needsAuthoritativeSync: false,
shouldUseOptimisticStream: false,
hasHydratedHistoryBefore: false,
};
}
function createBaseMemory(
overrides: Partial<AgentScreenMachineMemory> = {}
): AgentScreenMachineMemory {
return {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
...overrides,
};
}
function expectReadyState(state: AgentScreenViewState): ReadyState {
expect(state.tag).toBe("ready");
if (state.tag !== "ready") {
throw new Error("expected ready state");
}
return state;
}
function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
expect(state.sync.status).toBe("catching_up");
if (state.sync.status !== "catching_up") {
throw new Error("expected catching_up sync state");
}
return state.sync;
}
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
expect(state.sync.status).toBe("sync_error");
if (state.sync.status !== "sync_error") {
throw new Error("expected sync_error sync state");
}
return state.sync;
}
describe("deriveAgentScreenViewState", () => {
it("returns boot loading before first interactive paint", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory });
@@ -74,87 +113,174 @@ describe("deriveAgentScreenViewState", () => {
});
it("stays ready after first paint even if agent is temporarily missing", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("stale");
expect(result.state.syncStatus).toBe("idle");
expect(result.state.agent.id).toBe("agent-1");
expect(ready.source).toBe("stale");
expect(ready.sync.status).toBe("idle");
expect(ready.agent.id).toBe("agent-1");
});
it("shows reconnecting sync status without blocking after first paint", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
isConnected: false,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.syncStatus).toBe("reconnecting");
expect(ready.sync.status).toBe("reconnecting");
});
it("shows non-blocking catching-up state after first paint", () => {
const memory: AgentScreenMachineMemory = {
it("shows overlay catching-up state for first open while loading history", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.syncStatus).toBe("catching_up");
expect(sync.ui).toBe("overlay");
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("uses toast catching-up state for already-hydrated agents", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("keeps sync errors non-blocking once the screen was ready", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const errorInput: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "error", message: "network timeout" },
};
const errorResult = deriveAgentScreenViewState({
input: errorInput,
memory: initialMemory,
});
const errorReady = expectReadyState(errorResult.state);
const errorSync = expectSyncErrorSync(errorReady);
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
const retryInput: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "idle" },
};
const retryResult = deriveAgentScreenViewState({
input: retryInput,
memory: errorResult.memory,
});
const retryReady = expectReadyState(retryResult.state);
const retrySync = expectCatchingUpSync(retryReady);
expect(retrySync.ui).toBe("silent");
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
});
it("keeps ready with sync_error when refresh fails after first paint", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("stale");
expect(result.state.syncStatus).toBe("sync_error");
expect(result.state.agent.id).toBe("agent-1");
expect(ready.source).toBe("stale");
expect(ready.agent.id).toBe("agent-1");
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("emits sync error toast only on transition into sync_error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectSyncErrorSync(firstReady);
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectSyncErrorSync(secondReady);
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
});
it("returns blocking error before first paint when refresh fails", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
@@ -170,10 +296,10 @@ describe("deriveAgentScreenViewState", () => {
});
it("returns not_found when resolver confirms missing agent", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "not_found", message: "agent missing" },
@@ -189,10 +315,7 @@ describe("deriveAgentScreenViewState", () => {
});
it("promotes optimistic source while placeholder is used", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
placeholderAgent: createAgent("draft-agent"),
@@ -200,19 +323,14 @@ describe("deriveAgentScreenViewState", () => {
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("optimistic");
expect(ready.source).toBe("optimistic");
expect(ready.sync.status).toBe("idle");
});
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
const initialMemory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const initialMemory = createBaseMemory();
const optimisticInput: AgentScreenMachineInput = {
...createBaseInput(),
placeholderAgent: createAgent("draft-agent"),
@@ -223,11 +341,8 @@ describe("deriveAgentScreenViewState", () => {
input: optimisticInput,
memory: initialMemory,
});
expect(optimistic.state.tag).toBe("ready");
if (optimistic.state.tag !== "ready") {
throw new Error("expected optimistic ready state");
}
expect(optimistic.state.source).toBe("optimistic");
const optimisticReady = expectReadyState(optimistic.state);
expect(optimisticReady.source).toBe("optimistic");
const handoffInput: AgentScreenMachineInput = {
...createBaseInput(),
@@ -236,12 +351,94 @@ describe("deriveAgentScreenViewState", () => {
input: handoffInput,
memory: optimistic.memory,
});
const handoffReady = expectReadyState(handoff.state);
expect(handoff.state.tag).toBe("ready");
if (handoff.state.tag !== "ready") {
throw new Error("expected handoff ready state");
}
expect(handoff.state.source).toBe("stale");
expect(handoff.state.agent.id).toBe("draft-agent");
expect(handoffReady.source).toBe("stale");
expect(handoffReady.agent.id).toBe("draft-agent");
});
it("emits history refresh toast only on transition into toast catch-up state", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectCatchingUpSync(firstReady);
expect(firstSync.ui).toBe("toast");
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectCatchingUpSync(secondReady);
expect(secondSync.ui).toBe("toast");
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
const baseInput: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
};
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const firstCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: initialMemory,
});
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
expect(firstCatchingUpSync.ui).toBe("toast");
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
const idle = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: false },
memory: firstCatchingUp.memory,
});
const idleReady = expectReadyState(idle.state);
expect(idleReady.sync.status).toBe("idle");
const secondCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: idle.memory,
});
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
expect(secondCatchingUpSync.ui).toBe("toast");
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("clears initial sync failure memory after history is hydrated", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
hadInitialSyncFailure: true,
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
needsAuthoritativeSync: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(result.memory.hadInitialSyncFailure).toBe(false);
});
});
@@ -16,18 +16,35 @@ export interface AgentScreenMachineInput {
isHistorySyncing: boolean;
needsAuthoritativeSync: boolean;
shouldUseOptimisticStream: boolean;
hasHydratedHistoryBefore: boolean;
}
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: Agent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
export type AgentScreenSyncStatus =
| "idle"
| "catching_up"
| "reconnecting"
| "sync_error";
export type AgentScreenReadySyncState =
| { status: "idle" }
| { status: "reconnecting" }
| {
status: "catching_up";
ui: "overlay" | "silent";
shouldEmitHistoryRefreshToast: false;
}
| {
status: "catching_up";
ui: "toast";
shouldEmitHistoryRefreshToast: boolean;
}
| {
status: "sync_error";
shouldEmitSyncErrorToast: boolean;
};
export type AgentScreenViewState =
| {
@@ -47,7 +64,7 @@ export type AgentScreenViewState =
tag: "ready";
agent: Agent;
source: "authoritative" | "optimistic" | "stale";
syncStatus: AgentScreenSyncStatus;
sync: AgentScreenReadySyncState;
isArchiving: boolean;
};
@@ -61,8 +78,21 @@ export function deriveAgentScreenViewState({
const nextMemory: AgentScreenMachineMemory = {
hasRenderedReady: memory.hasRenderedReady,
lastReadyAgent: memory.lastReadyAgent,
activeToastLatch: memory.activeToastLatch,
hadInitialSyncFailure: memory.hadInitialSyncFailure,
};
if (input.hasHydratedHistoryBefore) {
nextMemory.hadInitialSyncFailure = false;
}
if (
input.missingAgentState.kind === "error" &&
!input.hasHydratedHistoryBefore
) {
nextMemory.hadInitialSyncFailure = true;
}
const candidateAgent = input.agent ?? input.placeholderAgent;
if (candidateAgent) {
nextMemory.hasRenderedReady = true;
@@ -108,13 +138,49 @@ export function deriveAgentScreenViewState({
? "optimistic"
: "stale";
let syncStatus: AgentScreenSyncStatus = "idle";
let sync: AgentScreenReadySyncState;
if (!input.isConnected) {
syncStatus = "reconnecting";
nextMemory.activeToastLatch = "none";
sync = { status: "reconnecting" };
} else if (input.missingAgentState.kind === "error") {
syncStatus = "sync_error";
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
nextMemory.activeToastLatch = "sync_error";
sync = {
status: "sync_error",
shouldEmitSyncErrorToast,
};
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
syncStatus = "catching_up";
let ui: "overlay" | "toast" | "silent";
if (input.shouldUseOptimisticStream) {
ui = "silent";
} else if (input.hasHydratedHistoryBefore) {
ui = "toast";
} else if (nextMemory.hadInitialSyncFailure) {
ui = "silent";
} else {
ui = "overlay";
}
if (ui === "toast") {
const shouldEmitHistoryRefreshToast =
memory.activeToastLatch !== "history_refresh";
nextMemory.activeToastLatch = "history_refresh";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast,
};
} else {
nextMemory.activeToastLatch = "none";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast: false,
};
}
} else {
nextMemory.activeToastLatch = "none";
sync = { status: "idle" };
}
return {
@@ -122,7 +188,7 @@ export function deriveAgentScreenViewState({
tag: "ready",
agent: displayAgent,
source,
syncStatus,
sync,
isArchiving: input.isArchivingCurrentAgent,
},
memory: nextMemory,
@@ -140,6 +206,8 @@ export function useAgentScreenStateMachine({
const memoryRef = useRef<AgentScreenMachineMemory>({
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
});
if (routeKeyRef.current !== routeKey) {
@@ -147,6 +215,8 @@ export function useAgentScreenStateMachine({
memoryRef.current = {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
};
}
+12 -76
View File
@@ -4,7 +4,7 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -21,6 +21,7 @@ export interface AggregatedAgentsResult {
export function useAggregatedAgents(): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections();
const runtime = getHostRuntimeStore();
const sessionAgents = useSessionStore(
useShallow((state) => {
@@ -32,53 +33,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
})
);
const sessionClients = useSessionStore(
useShallow((state) => {
const result: Record<string, NonNullable<typeof state.sessions[string]["client"]> | null> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.client ?? null;
}
return result;
})
);
const refreshAll = useCallback(() => {
for (const [serverId, client] of Object.entries(sessionClients)) {
if (!client) {
continue;
}
void (async () => {
try {
const agentsList = await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
const agents = new Map();
const pendingPermissions = new Map();
const agentLastActivity = new Map();
for (const { agent: snapshot } of agentsList.entries) {
const agent = normalizeAgentSnapshot(snapshot, serverId);
agents.set(agent.id, agent);
agentLastActivity.set(agent.id, agent.lastActivityAt);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
const store = useSessionStore.getState();
store.setAgents(serverId, agents);
for (const [agentId, timestamp] of agentLastActivity.entries()) {
store.setAgentLastActivity(agentId, timestamp);
}
store.setPendingPermissions(serverId, pendingPermissions);
} catch (error) {
console.warn("[useAggregatedAgents] Failed to refresh session", { serverId, error });
}
})();
}
}, [sessionClients]);
runtime.refreshAllAgentDirectories();
}, [runtime]);
const result = useMemo(() => {
const allAgents: AggregatedAgent[] = [];
@@ -127,35 +84,14 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
// Check if we have any cached data
const hasAnyData = allAgents.length > 0;
// Check if any connection is currently loading
const isConnecting = Array.from(connectionStates.entries()).some(([, c]) => {
// First-time connection (never received agent list)
if (c.status === 'connecting' && !c.hasEverReceivedAgentList) {
return true;
}
if (c.status === 'online' && !c.hasEverReceivedAgentList) {
return true;
}
// Reconnecting (have received agent list before)
if (c.status === 'connecting' && c.hasEverReceivedAgentList) {
return true;
}
if (c.status === 'online' && !c.agentListReady && c.hasEverReceivedAgentList) {
return true;
}
return false;
});
// isInitialLoad: Loading for the first time (no cached data)
const isInitialLoad = isConnecting && !hasAnyData;
// isRevalidating: Loading but we have cached data (reconnecting)
const isRevalidating = isConnecting && hasAnyData;
// isLoading: Generic loading flag (either initial or revalidating)
const isLoading = isConnecting;
// Align list loading with the runtime directory-sync machine.
const isLoading = Array.from(connectionStates.values()).some(
(connection) =>
connection.agentDirectoryStatus === "initial_loading" ||
connection.agentDirectoryStatus === "revalidating"
);
const isInitialLoad = isLoading && !hasAnyData;
const isRevalidating = isLoading && hasAnyData;
return {
agents: allAgents,
+27 -43
View File
@@ -1,15 +1,18 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const ALL_AGENTS_STALE_TIME = 60_000;
import {
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import type {
AggregatedAgent,
AggregatedAgentsResult,
} from "@/hooks/use-aggregated-agents";
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent {
@@ -35,7 +38,8 @@ export function useAllAgentsList(options?: {
serverId?: string | null;
}): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
@@ -46,54 +50,35 @@ export function useAllAgentsList(options?: {
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected);
const agentsQuery = useQuery({
queryKey: ["allAgents", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
},
enabled: canFetch,
staleTime: ALL_AGENTS_STALE_TIME,
refetchOnMount: "always" as const,
});
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const refreshAll = useCallback(() => {
if (!serverId) {
if (!serverId || snapshot?.connectionStatus !== "online") {
return;
}
void queryClient.invalidateQueries({
queryKey: ["allAgents", serverId],
});
}, [queryClient, serverId]);
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, snapshot?.connectionStatus]);
const agents = useMemo(() => {
if (!serverId) {
if (!serverId || !liveAgents) {
return [];
}
const data = agentsQuery.data?.entries ?? [];
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const list: AggregatedAgent[] = [];
for (const entry of data) {
const snapshot = entry.agent;
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id);
for (const agent of liveAgents.values()) {
const aggregated = toAggregatedAgent({
source: live ?? normalized,
source: agent,
serverId,
serverLabel,
});
if (aggregated.archivedAt) {
continue;
}
if (aggregated.labels.ui !== "true") {
continue;
}
list.push(aggregated);
}
@@ -110,16 +95,15 @@ export function useAllAgentsList(options?: {
});
return list;
}, [agentsQuery.data, connectionStates, liveAgents, serverId]);
}, [connectionStates, liveAgents, serverId]);
const isFetching =
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && agents.length === 0;
const isRevalidating = isFetching && agents.length > 0;
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
const isInitialLoad = isDirectoryLoading && agents.length === 0;
const isRevalidating = isDirectoryLoading && agents.length > 0;
return {
agents,
isLoading: isFetching,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
@@ -1,62 +0,0 @@
import { useEffect, useMemo } from "react";
import { AppState } from "react-native";
import { DaemonClient } from "@server/client/daemon-client";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
function runDaemonRequest(label: string, promise: Promise<unknown>): void {
void promise.catch((error) => {
console.warn(`[DaemonClient] ${label} failed`, error);
});
}
type DaemonClientOptions = {
daemonPublicKeyB64?: string;
};
export function useDaemonClient(
url: string,
options: DaemonClientOptions = {}
): DaemonClient {
const client = useMemo(
() => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
return new DaemonClient({
url,
suppressSendErrors: true,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
e2ee: options.daemonPublicKeyB64
? {
enabled: true,
daemonPublicKeyB64: options.daemonPublicKeyB64,
}
: undefined,
});
},
[options.daemonPublicKeyB64, url]
);
useEffect(() => {
runDaemonRequest("connect", client.connect());
return () => {
runDaemonRequest("close", client.close());
};
}, [client]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
if (nextState !== "active") {
return;
}
client.ensureConnected();
});
return () => {
subscription.remove();
};
}, [client]);
return client;
}
@@ -1,21 +1,22 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
deriveSidebarStateBucket,
isSidebarActiveAgent,
} from "@/utils/sidebar-agent-state";
import type { ProjectPlacementPayload } from "@server/shared/messages";
import { resolveProjectPlacement } from "@/utils/project-placement";
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
const SIDEBAR_DONE_FILL_TARGET = 50;
export interface SidebarProjectOption {
export interface SidebarProjectFilterOption {
projectKey: string;
projectName: string;
activeCount: number;
@@ -29,9 +30,9 @@ export interface SidebarAgentListEntry {
project: ProjectPlacementPayload;
}
export interface SidebarAgentsGroupedResult {
export interface SidebarAgentsListResult {
entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[];
projectFilterOptions: SidebarProjectFilterOption[];
hasMoreEntries: boolean;
isLoading: boolean;
isInitialLoad: boolean;
@@ -60,7 +61,6 @@ function compareByTitleAsc(
return titleCmp;
}
// Deterministic tie-breaker so running rows stay stable while status updates stream.
return left.agent.id.localeCompare(right.agent.id, undefined, {
numeric: true,
sensitivity: "base",
@@ -121,7 +121,7 @@ function applySidebarDefaultOrdering(
}
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent & { createdAt: Date } {
@@ -144,63 +144,41 @@ function toAggregatedAgent(params: {
};
}
export function useSidebarAgentsGrouped(options?: {
export function useSidebarAgentsList(options?: {
isOpen?: boolean;
serverId?: string | null;
selectedProjectKeys?: string[];
}): SidebarAgentsGroupedResult {
selectedProjectFilterKeys?: string[];
}): SidebarAgentsListResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
const isOpen = options?.isOpen ?? true;
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}, [options?.serverId]);
const selectedProjectKeys = useMemo(
const selectedProjectFilterKeys = useMemo(
() =>
new Set(
(options?.selectedProjectKeys ?? [])
(options?.selectedProjectFilterKeys ?? [])
.map((item) => item.trim())
.filter((item) => item.length > 0)
),
[options?.selectedProjectKeys]
[options?.selectedProjectFilterKeys]
);
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected);
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const agentsQuery = useQuery({
queryKey: ["sidebarAgentsList", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
sort: [
{ key: "status_priority", direction: "asc" },
{ key: "updated_at", direction: "desc" },
],
});
},
enabled: canFetch,
staleTime: SIDEBAR_AGENTS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
refetchIntervalInBackground: isOpen,
refetchOnMount: "always" as const,
});
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
if (!serverId) {
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
if (!serverId || !liveAgents) {
return {
entries: [] as SidebarAgentListEntry[],
projectOptions: [] as SidebarProjectOption[],
projectFilterOptions: [] as SidebarProjectFilterOption[],
hasAnyData: false,
hasMoreEntries: false,
};
@@ -208,7 +186,7 @@ export function useSidebarAgentsGrouped(options?: {
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const seenAgentIds = new Set<string>();
const byProject = new Map<string, SidebarProjectOption>();
const byProject = new Map<string, SidebarProjectFilterOption>();
const mergedEntries: SidebarAgentListEntry[] = [];
const pushEntry = (entry: SidebarAgentListEntry): void => {
@@ -246,43 +224,26 @@ export function useSidebarAgentsGrouped(options?: {
});
};
const fetchedEntries = agentsQuery.data?.entries ?? [];
for (const fetchedEntry of fetchedEntries) {
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
const live = liveAgents?.get(fetchedEntry.agent.id);
const project = live?.projectPlacement ?? fetchedEntry.project;
if (!project) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
}
const project = resolveProjectPlacement({
projectPlacement: live.projectPlacement ?? null,
cwd: live.cwd,
});
const agent = toAggregatedAgent({
source: live ?? normalized,
source: live,
serverId,
serverLabel,
});
pushEntry({ agent, project });
}
if (liveAgents) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
}
if (!live.projectPlacement) {
continue;
}
const agent = toAggregatedAgent({
source: live,
serverId,
serverLabel,
});
pushEntry({ agent, project: live.projectPlacement });
}
}
const filteredEntries =
selectedProjectKeys.size > 0
selectedProjectFilterKeys.size > 0
? mergedEntries.filter((entry) =>
selectedProjectKeys.has(entry.project.projectKey)
selectedProjectFilterKeys.has(entry.project.projectKey)
)
: mergedEntries;
@@ -296,37 +257,28 @@ export function useSidebarAgentsGrouped(options?: {
return {
entries: ordered.entries,
projectOptions: options,
projectFilterOptions: options,
hasAnyData: ordered.entries.length > 0,
hasMoreEntries: ordered.hasMore,
};
}, [
agentsQuery.data?.entries,
connectionStates,
liveAgents,
selectedProjectKeys,
serverId,
]);
}, [connectionStates, liveAgents, selectedProjectFilterKeys, serverId]);
const refreshAll = useCallback(() => {
if (!serverId) {
if (!serverId || snapshot?.connectionStatus !== "online") {
return;
}
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", serverId],
});
}, [queryClient, serverId]);
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, snapshot?.connectionStatus]);
const isFetching =
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && !hasAnyData;
const isRevalidating = isFetching && hasAnyData;
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
const isInitialLoad = isDirectoryLoading && !hasAnyData;
const isRevalidating = isDirectoryLoading && hasAnyData;
return {
entries,
projectOptions,
projectFilterOptions,
hasMoreEntries,
isLoading: isFetching,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest";
import type { HostRuntimeSnapshot } from "./host-runtime";
import { toDaemonConnectionUpdateFromRuntime } from "./host-runtime-bridge";
const DIRECT_CONNECTION = {
type: "direct" as const,
endpoint: "lan:6767",
display: "lan:6767",
};
function makeSnapshot(
input: Partial<HostRuntimeSnapshot>
): HostRuntimeSnapshot {
return {
serverId: "srv_test",
activeConnectionId: "direct:lan:6767",
activeConnection: DIRECT_CONNECTION,
connectionStatus: "idle",
lastError: null,
lastOnlineAt: null,
probeByConnectionId: new Map(),
clientGeneration: 0,
...input,
};
}
describe("toDaemonConnectionUpdateFromRuntime", () => {
it("maps online snapshots to online updates with the same active connection", () => {
const snapshot = makeSnapshot({
connectionStatus: "online",
lastOnlineAt: "2026-02-19T20:00:00.000Z",
});
const update = toDaemonConnectionUpdateFromRuntime(snapshot);
expect(update).toEqual({
status: "online",
activeConnection: DIRECT_CONNECTION,
lastOnlineAt: "2026-02-19T20:00:00.000Z",
});
});
it("maps error snapshots to error updates with the same error message", () => {
const snapshot = makeSnapshot({
connectionStatus: "error",
lastError: "transport closed",
lastOnlineAt: "2026-02-19T20:00:00.000Z",
});
const update = toDaemonConnectionUpdateFromRuntime(snapshot);
expect(update).toEqual({
status: "error",
activeConnection: DIRECT_CONNECTION,
lastError: "transport closed",
lastOnlineAt: "2026-02-19T20:00:00.000Z",
});
});
it("maps missing runtime snapshots to offline updates", () => {
const update = toDaemonConnectionUpdateFromRuntime(null);
expect(update).toEqual({
status: "offline",
activeConnection: null,
lastError: null,
lastOnlineAt: null,
});
});
});
@@ -1,47 +0,0 @@
import type { ConnectionStateUpdate } from "@/contexts/daemon-connections-context";
import type { HostRuntimeSnapshot } from "./host-runtime";
export function toDaemonConnectionUpdateFromRuntime(
snapshot: HostRuntimeSnapshot | null
): ConnectionStateUpdate {
if (!snapshot) {
return {
status: "offline",
activeConnection: null,
lastError: null,
lastOnlineAt: null,
};
}
const activeConnection = snapshot.activeConnection;
switch (snapshot.connectionStatus) {
case "idle":
return { status: "idle" };
case "connecting":
return {
status: "connecting",
activeConnection,
lastOnlineAt: snapshot.lastOnlineAt,
};
case "online":
return {
status: "online",
activeConnection,
lastOnlineAt: snapshot.lastOnlineAt ?? new Date().toISOString(),
};
case "offline":
return {
status: "offline",
activeConnection,
lastError: null,
lastOnlineAt: snapshot.lastOnlineAt,
};
case "error":
return {
status: "error",
activeConnection,
lastError: snapshot.lastError ?? "Connection error",
lastOnlineAt: snapshot.lastOnlineAt,
};
}
}
+539 -2
View File
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import { describe, expect, it, vi } from "vitest";
import type {
DaemonClient,
ConnectionState,
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
import {
HostRuntimeController,
HostRuntimeStore,
type HostRuntimeControllerDeps,
} from "./host-runtime";
@@ -13,6 +18,7 @@ class FakeDaemonClient {
public connectCalls = 0;
public closeCalls = 0;
public ensureConnectedCalls = 0;
public fetchAgentsCalls: FetchAgentsOptions[] = [];
async connect(): Promise<void> {
this.connectCalls += 1;
@@ -49,6 +55,31 @@ class FakeDaemonClient {
return this.error;
}
async fetchAgents(
options?: FetchAgentsOptions
): Promise<{
entries: [];
pageInfo: {
hasMoreBefore: false;
hasMoreAfter: false;
beforeCursor: null;
afterCursor: null;
};
subscriptionId: string | null;
}> {
this.fetchAgentsCalls.push(options ?? {});
return {
entries: [],
pageInfo: {
hasMoreBefore: false,
hasMoreAfter: false,
beforeCursor: null,
afterCursor: null,
},
subscriptionId: options?.subscribe?.subscriptionId ?? null,
};
}
setConnectionState(next: ConnectionState): void {
this.state = next;
if (next.status === "disconnected") {
@@ -103,10 +134,90 @@ function makeDeps(
}
return value;
},
getClientSessionKey: async () => "clsk_test_runtime",
};
}
function createDeferred<T>() {
let resolve: ((value: T | PromiseLike<T>) => void) | null = null;
let reject: ((reason?: unknown) => void) | null = null;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: (value: T | PromiseLike<T>) => resolve?.(value),
reject: (reason?: unknown) => reject?.(reason),
};
}
describe("HostRuntimeController", () => {
it("keeps known hosts in connecting when client reports idle during connect", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const idleClient = new FakeDaemonClient();
const deps: HostRuntimeControllerDeps = {
createClient: () => idleClient as unknown as DaemonClient,
measureLatency: async () => {
throw new Error("probe unavailable");
},
getClientSessionKey: async () => "clsk_test_runtime",
};
const controller = new HostRuntimeController({
host,
deps,
});
idleClient.connect = async () => {
idleClient.connectCalls += 1;
// Intentionally do not emit a connected state; stay in idle.
};
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading");
});
it("passes resolved client session key into created active clients", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const seenSessionKeys: string[] = [];
const fakeClient = new FakeDaemonClient();
const controller = new HostRuntimeController({
host,
deps: {
createClient: ({ clientSessionKey }) => {
seenSessionKeys.push(clientSessionKey);
return fakeClient as unknown as DaemonClient;
},
measureLatency: async () => 10,
getClientSessionKey: async () => "clsk_runtime_stable",
},
});
await controller.start({ autoProbe: false });
expect(seenSessionKeys).toEqual(["clsk_runtime_stable"]);
expect(controller.getSnapshot().connectionStatus).toBe("online");
});
it("selects the lowest-latency connection on startup", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
@@ -250,4 +361,430 @@ describe("HostRuntimeController", () => {
expect(latest?.lastError).toBe("transport closed");
unsubscribe();
});
it("logs typed reason codes for connection transitions", async () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
try {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const clients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: makeDeps(
{
"direct:lan:6767": 12,
},
clients
),
});
await controller.start({ autoProbe: false });
clients[0]?.setConnectionState({
status: "disconnected",
reason: "transport closed",
});
const transitionPayloads = infoSpy.mock.calls
.filter((call) => call[0] === "[HostRuntimeTransition]")
.map((call) => call[1] as { reasonCode?: string | null });
const lastTransition =
transitionPayloads[transitionPayloads.length - 1] ?? null;
expect(lastTransition?.reasonCode).toBe("transport_error");
} finally {
infoSpy.mockRestore();
}
});
it("marks directory loading on first connection before any directory sync succeeds", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
const snapshot = controller.getSnapshot();
expect(snapshot.connectionStatus).toBe("online");
expect(snapshot.hasEverLoadedAgentDirectory).toBe(false);
expect(snapshot.agentDirectoryStatus).toBe("initial_loading");
});
it("keeps directory ready through reconnects after the first successful directory load", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
controller.markAgentDirectorySyncReady();
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
expect(controller.getSnapshot().hasEverLoadedAgentDirectory).toBe(true);
clients[0]?.setConnectionState({
status: "disconnected",
reason: "client_closed",
});
expect(controller.getSnapshot().connectionStatus).toBe("offline");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
clients[0]?.setConnectionState({ status: "connected" });
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
});
it("stores directory sync errors as non-blocking after a successful directory load", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
controller.markAgentDirectorySyncReady();
controller.markAgentDirectorySyncError("bootstrap failed");
const snapshot = controller.getSnapshot();
expect(snapshot.agentDirectoryStatus).toBe("error_after_ready");
expect(snapshot.agentDirectoryError).toBe("bootstrap failed");
expect(snapshot.hasEverLoadedAgentDirectory).toBe(true);
});
it("keeps online snapshots coupled to a live client reference", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
const observed = new Array<ReturnType<typeof controller.getSnapshot>>();
const unsubscribe = controller.subscribe(() => {
observed.push(controller.getSnapshot());
});
await controller.start({ autoProbe: false });
for (const snapshot of observed) {
if (snapshot.connectionStatus === "online") {
expect(snapshot.client).toBeTruthy();
}
}
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().client).toBeTruthy();
unsubscribe();
});
it("ignores stale switch failures after a newer connection is already online", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
{
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test",
},
],
});
const firstConnectGate = createDeferred<void>();
const createdClients: FakeDaemonClient[] = [];
const deps: HostRuntimeControllerDeps = {
createClient: ({ connection }) => {
const client = new FakeDaemonClient();
if (connection.id === "direct:lan:6767") {
client.connect = async () => {
client.connectCalls += 1;
await firstConnectGate.promise;
throw new Error("stale direct connect failed");
};
}
createdClients.push(client);
return client as unknown as DaemonClient;
},
measureLatency: async () => 10,
getClientSessionKey: async () => "clsk_test_runtime",
};
const controller = new HostRuntimeController({
host,
deps,
});
const waitUntil = async (
predicate: () => boolean,
timeoutMs = 200
): Promise<void> => {
const timeoutAt = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= timeoutAt) {
throw new Error("timed out waiting for predicate");
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
};
const switchDirect = (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "direct:lan:6767" });
await waitUntil(() => {
const snapshot = controller.getSnapshot();
return (
createdClients.length === 1 &&
snapshot.activeConnectionId === "direct:lan:6767" &&
snapshot.connectionStatus === "connecting"
);
});
const switchRelay = (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "relay:relay.paseo.sh:443" });
await waitUntil(() => {
const snapshot = controller.getSnapshot();
return (
snapshot.activeConnectionId === "relay:relay.paseo.sh:443" &&
snapshot.connectionStatus === "online"
);
});
firstConnectGate.resolve();
await Promise.allSettled([switchDirect, switchRelay]);
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(snapshot.connectionStatus).toBe("online");
expect(snapshot.lastError).toBeNull();
expect(createdClients).toHaveLength(2);
expect(createdClients[0]?.closeCalls).toBe(1);
});
it("ignores stale probe results when overlapping probe cycles finish out of order", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
let probeCalls = 0;
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
measureLatency: async () => {
probeCalls += 1;
if (probeCalls === 1) {
return await slowProbe.promise;
}
if (probeCalls === 2) {
return await fastProbe.promise;
}
throw new Error("unexpected probe call");
},
getClientSessionKey: async () => "clsk_test_runtime",
},
});
const first = controller.runProbeCycleNow();
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
await second;
const probeAfterSecond = controller
.getSnapshot()
.probeByConnectionId.get("direct:lan:6767");
expect(probeAfterSecond).toEqual({
status: "available",
latencyMs: 12,
});
slowProbe.resolve(900);
await first;
const probeAfterFirstSettles = controller
.getSnapshot()
.probeByConnectionId.get("direct:lan:6767");
expect(probeAfterFirstSettles).toEqual({
status: "available",
latencyMs: 12,
});
});
it("keeps active client generation stable while overlapping probes run", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
const createdClients: FakeDaemonClient[] = [];
let probeCalls = 0;
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
const client = new FakeDaemonClient();
createdClients.push(client);
return client as unknown as DaemonClient;
},
measureLatency: async () => {
probeCalls += 1;
if (probeCalls === 1) {
return 10;
}
if (probeCalls === 2) {
return await slowProbe.promise;
}
if (probeCalls === 3) {
return await fastProbe.promise;
}
return 10;
},
getClientSessionKey: async () => "clsk_test_runtime",
},
});
await controller.start({ autoProbe: false });
const activeClientBeforeProbes = controller.getSnapshot().client;
const generationBeforeProbes = controller.getSnapshot().clientGeneration;
const first = controller.runProbeCycleNow();
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
await second;
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
expect(controller.getSnapshot().clientGeneration).toBe(
generationBeforeProbes
);
slowProbe.resolve(999);
await first;
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
expect(controller.getSnapshot().clientGeneration).toBe(
generationBeforeProbes
);
expect(createdClients).toHaveLength(1);
expect(createdClients[0]?.closeCalls).toBe(0);
});
});
describe("HostRuntimeStore", () => {
it("bootstraps agent directory subscription when host transitions online", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
getClientSessionKey: async () => "clsk_test_runtime",
},
});
store.syncHosts([host]);
const timeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { labels: { ui: "true" } },
subscribe: { subscriptionId: "app:srv_test" },
});
const snapshot = store.getSnapshot(host.serverId);
expect(snapshot?.agentDirectoryStatus).toBe("ready");
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
store.syncHosts([]);
});
it("surfaces startup failures as error instead of leaving host idle", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const store = new HostRuntimeStore({
deps: {
createClient: () => {
throw new Error("create client failed");
},
measureLatency: async () => {
throw new Error("probe unavailable");
},
getClientSessionKey: async () => "clsk_test_runtime",
},
});
store.syncHosts([host]);
let snapshot = store.getSnapshot(host.serverId);
const timeoutAt = Date.now() + 100;
while (snapshot?.connectionStatus !== "error" && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
snapshot = store.getSnapshot(host.serverId);
}
expect(snapshot?.connectionStatus).toBe("error");
expect(snapshot?.lastError).toBe("create client failed");
});
});
File diff suppressed because it is too large Load Diff
@@ -76,9 +76,10 @@ import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient, FetchAgentsEntry } from "@server/client/daemon-client";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import {
DropdownMenu,
@@ -128,7 +129,7 @@ export function AgentReadyScreen({
const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus: HostRuntimeConnectionStatus =
runtimeSnapshot?.connectionStatus ??
(isUnknownDaemon ? "offline" : "idle");
(isUnknownDaemon ? "offline" : "connecting");
const lastConnectionError = runtimeSnapshot?.lastError ?? null;
const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient);
@@ -278,7 +279,6 @@ function AgentScreenContent({
const checkout = checkoutStatusQuery.status;
const resolveCachedCheckoutIsGit = useCallback(
(params: {
agentId?: string | null;
cwd?: string | null;
projectPlacementIsGit?: boolean;
checkoutStatusIsGit?: boolean;
@@ -287,19 +287,6 @@ function AgentScreenContent({
return params.projectPlacementIsGit;
}
const agentId = params.agentId?.trim();
if (agentId) {
const sidebarAgents = queryClient.getQueryData<{
entries: FetchAgentsEntry[];
}>(["sidebarAgentsList", serverId]);
const sidebarIsGit = sidebarAgents?.entries.find(
(entry) => entry.agent.id === agentId
)?.project?.checkout?.isGit;
if (typeof sidebarIsGit === "boolean") {
return sidebarIsGit;
}
}
const cwd = params.cwd?.trim();
if (!cwd) {
return null;
@@ -327,7 +314,6 @@ function AgentScreenContent({
?.agents?.get(resolvedAgentId);
const cwd = currentAgent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
@@ -394,7 +380,6 @@ function AgentScreenContent({
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
const cwd = agent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
@@ -458,6 +443,7 @@ function AgentScreenContent({
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
: -1
);
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
const allPendingPermissions = useSessionStore(
@@ -517,6 +503,38 @@ function AgentScreenContent({
};
});
const handleHistorySyncFailure = useCallback(
({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => {
if (resolvedAgentId) {
console.warn("[AgentScreen] history sync failed", {
origin,
agentId: resolvedAgentId,
error,
});
}
const message = toErrorMessage(error);
setMissingAgentState((prev) => {
if (prev.kind === "error" && prev.message === message) {
return prev;
}
return { kind: "error", message };
});
},
[resolvedAgentId]
);
const ensureInitializedWithSyncErrorHandling = useCallback(
(origin: "focus" | "entry") => {
if (!resolvedAgentId) {
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
handleHistorySyncFailure({ origin, error });
});
},
[ensureAgentIsInitialized, handleHistorySyncFailure, resolvedAgentId]
);
useEffect(() => {
if (connectionStatus === "online") {
reconnectToastArmedRef.current = false;
@@ -534,25 +552,18 @@ function AgentScreenContent({
}
}, [connectionStatus, toast]);
useEffect(() => {
if (missingAgentState.kind !== "error") {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [missingAgentState.kind, toast]);
useFocusEffect(
useCallback(() => {
if (!resolvedAgentId || !isConnected || !hasSession) {
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] focus sync failed", {
agentId: resolvedAgentId,
error,
});
});
}, [ensureAgentIsInitialized, hasSession, isConnected, resolvedAgentId])
ensureInitializedWithSyncErrorHandling("focus");
}, [
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
resolvedAgentId,
])
);
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
@@ -688,6 +699,7 @@ function AgentScreenContent({
isHistorySyncing,
needsAuthoritativeSync,
shouldUseOptimisticStream,
hasHydratedHistoryBefore,
},
});
@@ -769,15 +781,10 @@ function AgentScreenContent({
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] Agent initialization failed", {
agentId: resolvedAgentId,
error,
});
});
ensureInitializedWithSyncErrorHandling("entry");
}, [
resolvedAgentId,
ensureAgentIsInitialized,
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
needsAuthoritativeSync,
@@ -831,21 +838,28 @@ function AgentScreenContent({
return;
}
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const hydrated = {
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: null,
cwd: normalized.cwd,
}),
};
setAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(normalized.id, normalized);
next.set(hydrated.id, hydrated);
return next;
});
setPendingPermissions(serverId, (prev) => {
const next = new Map(prev);
for (const [key, pending] of next.entries()) {
if (pending.agentId === normalized.id) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of normalized.pendingPermissions) {
const key = derivePendingPermissionKey(normalized.id, request);
next.set(key, { key, agentId: normalized.id, request });
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
@@ -940,14 +954,31 @@ function AgentScreenContent({
[theme.colors.primary, toast]
);
const syncBannerMessage =
viewState.tag !== "ready"
? null
: viewState.syncStatus === "catching_up"
? "Refreshing agent history..."
: viewState.syncStatus === "sync_error"
? "Refresh delayed. Retrying in background."
: null;
const shouldEmitHistoryRefreshToast =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.shouldEmitHistoryRefreshToast;
const shouldEmitSyncErrorToast =
viewState.tag === "ready" &&
viewState.sync.status === "sync_error" &&
viewState.sync.shouldEmitSyncErrorToast;
useEffect(() => {
if (!shouldEmitHistoryRefreshToast) {
return;
}
toast.show("Refreshing agent history...", {
durationMs: 2200,
testID: "agent-history-refresh-toast",
});
}, [shouldEmitHistoryRefreshToast, toast]);
useEffect(() => {
if (!shouldEmitSyncErrorToast) {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [shouldEmitSyncErrorToast, toast]);
if (viewState.tag === "not_found") {
return (
@@ -1188,12 +1219,6 @@ function AgentScreenContent({
}
/>
{syncBannerMessage ? (
<View style={styles.syncBanner} testID="agent-sync-banner">
<Text style={styles.syncBannerText}>{syncBannerMessage}</Text>
</View>
) : null}
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
@@ -1221,6 +1246,14 @@ function AgentScreenContent({
/>
)}
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</FileDropZone>
@@ -1357,24 +1390,20 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
overflow: "hidden",
},
syncBanner: {
marginHorizontal: theme.spacing[4],
marginTop: theme.spacing[1],
marginBottom: theme.spacing[2],
borderRadius: theme.borderRadius.full,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
syncBannerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
content: {
flex: 1,
},
historySyncOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.colors.surface0,
alignItems: "center",
justifyContent: "center",
zIndex: 40,
},
archivingOverlay: {
position: "absolute",
top: 0,
+33 -59
View File
@@ -12,17 +12,18 @@ import { router, useLocalSearchParams } from "expo-router";
import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useQueries } from "@tanstack/react-query";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
@@ -457,7 +458,6 @@ export default function SettingsScreen() {
removeHost,
removeConnection,
} = useDaemonRegistry();
const { connectionStates } = useDaemonConnections();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
@@ -723,17 +723,10 @@ export default function SettingsScreen() {
</View>
) : (
daemons.map((daemon) => {
const connection = connectionStates.get(daemon.serverId);
const connectionStatus = connection?.status ?? "idle";
const activeConnection = connection?.activeConnection ?? null;
const lastConnectionError = connection?.lastError ?? null;
return (
<DaemonCard
key={daemon.serverId}
daemon={daemon}
connectionStatus={connectionStatus}
activeConnection={activeConnection}
lastError={lastConnectionError}
onOpenSettings={handleEditDaemon}
/>
);
@@ -865,9 +858,6 @@ export default function SettingsScreen() {
<HostDetailModal
visible={Boolean(editingDaemonLive)}
host={editingDaemonLive}
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
isSaving={isSavingEdit}
onClose={handleCloseEditDaemon}
onSave={(label) => void handleSaveEditDaemon(label)}
@@ -983,9 +973,6 @@ export default function SettingsScreen() {
interface HostDetailModalProps {
visible: boolean;
host: HostProfile | null;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
isSaving: boolean;
onClose: () => void;
onSave: (label: string) => void;
@@ -1000,9 +987,6 @@ interface HostDetailModalProps {
function HostDetailModal({
visible,
host,
connectionStatus,
activeConnection,
lastError,
isSaving,
onClose,
onSave,
@@ -1018,45 +1002,37 @@ function HostDetailModal({
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
// Latency probes for each connection
// Read per-connection probes from host runtime snapshots.
const connections = host?.connections ?? [];
const latencyQueries = useQueries({
queries: connections.map((conn) => ({
queryKey: ["connection-latency", conn.id],
queryFn: () => measureConnectionLatency(conn, { serverId: host?.serverId }),
enabled: visible,
refetchInterval: 5_000,
staleTime: 4_000,
gcTime: 60_000,
retry: 1,
})),
});
const latencyByConnectionId = new Map(
connections.map((conn, i) => [conn.id, latencyQueries[i]] as const)
);
// Restart logic (moved from DaemonCard)
const { client: runtimeClient, isConnected } = useHostRuntimeSession(
const { snapshot: runtimeSnapshot, client: runtimeClient, isConnected } = useHostRuntimeSession(
host?.serverId ?? ""
);
const runtime = getHostRuntimeStore();
const daemonClient = runtimeClient;
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
const isConnectedRef = useRef(isConnected);
const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map();
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const activeConnection = runtimeSnapshot?.activeConnection ?? null;
const lastError = runtimeSnapshot?.lastError ?? null;
const [isRestarting, setIsRestarting] = useState(false);
useEffect(() => {
isConnectedRef.current = isConnected;
}, [isConnected]);
const isHostConnected = useCallback(() => {
if (!host) {
return false;
}
return isHostRuntimeConnected(runtime.getSnapshot(host.serverId));
}, [host, runtime]);
const waitForDaemonRestart = useCallback(async () => {
const disconnectTimeoutMs = 7000;
const reconnectTimeoutMs = 30000;
if (isConnectedRef.current) {
await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs);
if (isHostConnected()) {
await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
}
const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs);
const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
if (isScreenMountedRef.current) {
setIsRestarting(false);
@@ -1067,12 +1043,12 @@ function HostDetailModal({
);
}
}
}, [host, isScreenMountedRef, waitForCondition]);
}, [host, isHostConnected, isScreenMountedRef, waitForCondition]);
const beginServerRestart = useCallback(() => {
if (!daemonClient || !host) return;
if (!isConnectedRef.current) {
if (!isHostConnected()) {
Alert.alert(
"Host offline",
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting."
@@ -1094,7 +1070,7 @@ function HostDetailModal({
});
void waitForDaemonRestart();
}, [daemonClient, host, isScreenMountedRef, waitForDaemonRestart]);
}, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]);
const handleRestartPress = useCallback(() => {
if (!daemonClient || !host) {
@@ -1225,14 +1201,14 @@ function HostDetailModal({
<Text style={styles.label}>Connections</Text>
<View style={{ gap: 8 }}>
{host.connections.map((conn) => {
const latency = latencyByConnectionId.get(conn.id);
const probe = probeByConnectionId.get(conn.id);
return (
<ConnectionRow
key={conn.id}
connection={conn}
latencyMs={latency?.data ?? undefined}
latencyLoading={latency?.isLoading ?? false}
latencyError={latency?.isError ?? false}
latencyMs={probe?.status === "available" ? probe.latencyMs : undefined}
latencyLoading={!probe || probe.status === "pending"}
latencyError={probe?.status === "unavailable"}
onRemove={() => {
const title =
conn.type === "relay"
@@ -1272,7 +1248,7 @@ function HostDetailModal({
leading={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..."
disabled={!daemonClient || !isConnectedRef.current}
disabled={!daemonClient || !isConnected}
>
Restart daemon
</DropdownMenuItem>
@@ -1475,20 +1451,18 @@ function ConnectionRow({
interface DaemonCardProps {
daemon: HostProfile;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
onOpenSettings: (daemon: HostProfile) => void;
}
function DaemonCard({
daemon,
connectionStatus,
activeConnection,
lastError,
onOpenSettings,
}: DaemonCardProps) {
const { theme } = useUnistyles();
const { snapshot } = useHostRuntimeSession(daemon.serverId);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null;
const lastError = snapshot?.lastError ?? null;
const daemonVersion = useSessionStore(
useCallback(
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,
@@ -0,0 +1,60 @@
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
type PendingPermissionEntry = {
key: string;
agentId: string;
request: Agent["pendingPermissions"][number];
};
export function buildAgentDirectoryState(input: {
serverId: string;
entries: FetchAgentsEntry[];
}): {
agents: Map<string, Agent>;
pendingPermissions: Map<string, PendingPermissionEntry>;
} {
const agents = new Map<string, Agent>();
const pendingPermissions = new Map<string, PendingPermissionEntry>();
for (const entry of input.entries) {
const normalized = normalizeAgentSnapshot(entry.agent, input.serverId);
const projectPlacement = resolveProjectPlacement({
projectPlacement: entry.project,
cwd: normalized.cwd,
});
const agent: Agent = {
...normalized,
projectPlacement,
};
agents.set(agent.id, agent);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
return { agents, pendingPermissions };
}
export function applyFetchedAgentDirectory(input: {
serverId: string;
entries: FetchAgentsEntry[];
}): { agents: Map<string, Agent> } {
const { agents, pendingPermissions } = buildAgentDirectoryState(input);
const store = useSessionStore.getState();
store.setAgents(input.serverId, agents);
for (const agent of agents.values()) {
store.setAgentLastActivity(agent.id, agent.lastActivityAt);
}
store.setPendingPermissions(input.serverId, pendingPermissions);
store.setInitializingAgents(input.serverId, new Map());
store.setHasHydratedAgents(input.serverId, true);
return { agents };
}
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("client-session-key", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("returns stored client session key when present", async () => {
asyncStorageMock.getItem.mockResolvedValue("clsk_existing");
const mod = await import("./client-session-key");
const key = await mod.getOrCreateClientSessionKey();
expect(key).toBe("clsk_existing");
expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1);
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
it("creates and persists a client session key when missing", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab");
const mod = await import("./client-session-key");
const key = await mod.getOrCreateClientSessionKey();
expect(key).toBe("clsk_123456781234123412341234567890ab");
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:client-session-key-v1",
"clsk_123456781234123412341234567890ab"
);
});
});
@@ -0,0 +1,54 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const CLIENT_SESSION_KEY_STORAGE_KEY = "@paseo:client-session-key-v1";
let cachedClientSessionKey: string | null = null;
let inFlightClientSessionKey: Promise<string> | null = null;
function generateClientSessionKey(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `clsk_${randomUuid}`;
}
function normalizeStoredClientSessionKey(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function getOrCreateClientSessionKey(): Promise<string> {
if (cachedClientSessionKey) {
return cachedClientSessionKey;
}
if (inFlightClientSessionKey) {
return inFlightClientSessionKey;
}
inFlightClientSessionKey = (async () => {
const storedValue = await AsyncStorage.getItem(CLIENT_SESSION_KEY_STORAGE_KEY);
const existing = normalizeStoredClientSessionKey(storedValue);
if (existing) {
cachedClientSessionKey = existing;
return existing;
}
const nextValue = generateClientSessionKey();
await AsyncStorage.setItem(CLIENT_SESSION_KEY_STORAGE_KEY, nextValue);
cachedClientSessionKey = nextValue;
return nextValue;
})();
try {
return await inFlightClientSessionKey;
} finally {
inFlightClientSessionKey = null;
}
}
+5 -1
View File
@@ -30,6 +30,10 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
return JSON.parse(json) as unknown;
}
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
export function buildRelayWebSocketUrl(params: {
endpoint: string;
serverId: string;
clientSessionKey?: string;
}): string {
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
deriveProjectPlacementFromCwd,
resolveProjectPlacement,
} from "./project-placement";
describe("project-placement", () => {
it("derives fallback placement from cwd", () => {
const placement = deriveProjectPlacementFromCwd("/Users/test/repo");
expect(placement.projectKey).toBe("/Users/test/repo");
expect(placement.projectName).toBe("repo");
expect(placement.checkout.cwd).toBe("/Users/test/repo");
expect(placement.checkout.isGit).toBe(false);
});
it("normalizes paseo worktree paths into the parent repo key", () => {
const placement = deriveProjectPlacementFromCwd(
"/Users/test/repo/.paseo/worktrees/feature-x"
);
expect(placement.projectKey).toBe("/Users/test/repo");
expect(placement.projectName).toBe("repo");
expect(placement.checkout.cwd).toBe(
"/Users/test/repo/.paseo/worktrees/feature-x"
);
});
it("prefers an existing placement when present", () => {
const existing = {
projectKey: "remote:github.com/acme/repo",
projectName: "acme/repo",
checkout: {
cwd: "/Users/test/repo",
isGit: true as const,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
},
};
const resolved = resolveProjectPlacement({
projectPlacement: existing,
cwd: "/Users/test/repo",
});
expect(resolved).toBe(existing);
});
});
@@ -0,0 +1,32 @@
import type { ProjectPlacementPayload } from "@server/shared/messages";
import { deriveProjectKey, deriveProjectName } from "@/utils/agent-grouping";
function normalizeWorkingDirectory(cwd: string): string {
const trimmed = cwd.trim();
return trimmed.length > 0 ? trimmed : ".";
}
export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayload {
const normalizedCwd = normalizeWorkingDirectory(cwd);
const projectKey = deriveProjectKey(normalizedCwd);
return {
projectKey,
projectName: deriveProjectName(projectKey),
checkout: {
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
};
}
export function resolveProjectPlacement(input: {
projectPlacement: ProjectPlacementPayload | null | undefined;
cwd: string;
}): ProjectPlacementPayload {
return input.projectPlacement ?? deriveProjectPlacementFromCwd(input.cwd);
}
@@ -0,0 +1,100 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientSessionKey?: string; url?: string }> = [];
class MockDaemonClient {
private statusHandlers = new Set<
(
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
>();
public lastError: string | null = null;
constructor(config: { clientSessionKey?: string; url?: string }) {
createdConfigs.push(config);
}
subscribeConnectionStatus(): () => void {
return () => undefined;
}
on(
event: "status",
handler: (
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
): () => void {
if (event === "status") {
this.statusHandlers.add(handler);
}
return () => {
this.statusHandlers.delete(handler);
};
}
async connect(): Promise<void> {
const message = {
type: "status" as const,
payload: {
status: "server_info",
serverId: "srv_probe_test",
hostname: "probe-host",
},
};
for (const handler of this.statusHandlers) {
handler(message);
}
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 42 };
}
async close(): Promise<void> {
return;
}
}
return {
MockDaemonClient,
createdConfigs,
};
});
vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient,
}));
describe("test-daemon-connection probe client identity", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
});
it("uses isolated probe clientSessionKey values for direct latency probes", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
});
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
});
const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientSessionKey).toMatch(/^clsk_probe_/);
expect(second?.clientSessionKey).toMatch(/^clsk_probe_/);
expect(first?.clientSessionKey).not.toBe(second?.clientSessionKey);
});
});
@@ -4,6 +4,16 @@ import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function createProbeClientSessionKey(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `clsk_probe_${randomUuid}`;
}
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
@@ -41,15 +51,23 @@ export class DaemonConnectionTestError extends Error {
}
}
function buildClientConfig(connection: HostConnection, serverId?: string): DaemonClientConfig {
async function buildClientConfig(
connection: HostConnection,
serverId?: string
): Promise<DaemonClientConfig> {
const clientSessionKey = createProbeClientSessionKey();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = {
clientSessionKey,
suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
};
if (connection.type === "direct") {
return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint) };
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
};
}
if (!serverId) {
@@ -58,7 +76,11 @@ function buildClientConfig(connection: HostConnection, serverId?: string): Daemo
return {
...base,
url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId }),
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
serverId,
clientSessionKey,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
};
}
@@ -142,7 +164,7 @@ export async function probeConnection(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ serverId: string; hostname: string | null }> {
const config = buildClientConfig(connection, options?.serverId);
const config = await buildClientConfig(connection, options?.serverId);
const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options));
await client.close().catch(() => undefined);
return { serverId, hostname };
@@ -152,7 +174,7 @@ export async function measureConnectionLatency(
connection: HostConnection,
options?: ProbeOptions,
): Promise<number> {
const config = buildClientConfig(connection, options?.serverId);
const config = await buildClientConfig(connection, options?.serverId);
const { client } = await connectAndProbe(config, resolveTimeout(connection, options));
try {
const { rttMs } = await client.ping({ timeoutMs: 5000 });