From d128a32d2f9f053d01ce2c4869c99156a243f3e7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Feb 2026 10:37:59 +0700 Subject: [PATCH] Unify app connection state around host runtime --- packages/app/src/components/agent-list.tsx | 10 ++- .../components/multi-daemon-session-host.tsx | 2 +- .../app/src/components/sidebar-agent-list.tsx | 15 ++-- packages/app/src/components/terminal-pane.tsx | 7 +- .../contexts/daemon-connections-context.tsx | 33 +++++++- packages/app/src/contexts/session-context.tsx | 29 +++---- packages/app/src/contexts/voice-context.tsx | 10 ++- .../app/src/hooks/use-agent-autocomplete.ts | 6 +- .../app/src/hooks/use-agent-commands-query.ts | 9 +-- .../app/src/hooks/use-agent-form-state.ts | 8 +- .../app/src/hooks/use-agent-initialization.ts | 14 +++- packages/app/src/hooks/use-all-agents-list.ts | 4 +- .../app/src/hooks/use-checkout-diff-query.ts | 9 +-- .../src/hooks/use-checkout-pr-status-query.ts | 9 +-- .../src/hooks/use-checkout-status-query.ts | 13 +-- .../app/src/hooks/use-project-icon-query.ts | 9 +-- .../src/hooks/use-sidebar-agents-grouped.ts | 4 +- packages/app/src/runtime/host-runtime.ts | 8 ++ .../src/screens/agent/agent-ready-screen.tsx | 79 ++++++++++++------- .../src/screens/agent/draft-agent-screen.tsx | 27 +++---- packages/app/src/screens/settings-screen.tsx | 8 +- packages/app/src/stores/session-store.ts | 51 ------------ 22 files changed, 173 insertions(+), 191 deletions(-) diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index cf10fd742..2d66212a9 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -18,6 +18,10 @@ import { shortenPath } from "@/utils/shorten-path"; import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; +import { + getHostRuntimeStore, + isHostRuntimeConnected, +} from "@/runtime/host-runtime"; import { AgentStatusDot } from "@/components/agent-status-dot"; import { CHECKOUT_STATUS_STALE_TIME, @@ -154,9 +158,9 @@ export function AgentList({ continue; } - const session = useSessionStore.getState().sessions[agent.serverId]; - const client = session?.client ?? null; - const isConnected = session?.connection.isConnected ?? false; + const runtime = getHostRuntimeStore(); + const client = runtime.getClient(agent.serverId); + const isConnected = isHostRuntimeConnected(runtime.getSnapshot(agent.serverId)); if (!client || !isConnected) { continue; } diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index 98bffd36c..e1842dfa9 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -24,7 +24,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { ); }, [daemon.serverId, hasConnectionRecord, snapshot, updateConnectionStatus]); - if (!snapshot?.activeConnection || !client) { + if (!client) { return null; } diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index b470dd211..b71144aba 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -28,7 +28,10 @@ import { DraggableList, type DraggableRenderItemInfo, } from "./draggable-list"; -import { useSessionStore } from "@/stores/session-store"; +import { + getHostRuntimeStore, + isHostRuntimeConnected, +} from "@/runtime/host-runtime"; import { buildAgentNavigationKey, startNavigationTiming, @@ -490,7 +493,6 @@ export function SidebarAgentList({ }, [projectOptions, selectedProjectKeys]); const showProjectFilters = projectOptions.length > 0; - const sessions = useSessionStore((state) => state.sessions); const projectIconRequests = useMemo(() => { const unique = new Map(); for (const option of projectOptions) { @@ -517,8 +519,7 @@ export function SidebarAgentList({ queries: projectIconRequests.map((request) => ({ queryKey: projectIconQueryKey(request.serverId, request.cwd), queryFn: async () => { - const client = - useSessionStore.getState().sessions[request.serverId]?.client ?? null; + const client = getHostRuntimeStore().getClient(request.serverId); if (!client) { return null; } @@ -526,8 +527,10 @@ export function SidebarAgentList({ return result.icon; }, enabled: Boolean( - sessions[request.serverId]?.client && - sessions[request.serverId]?.connection.isConnected && + getHostRuntimeStore().getClient(request.serverId) && + isHostRuntimeConnected( + getHostRuntimeStore().getSnapshot(request.serverId) + ) && request.cwd ), staleTime: Infinity, diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 2886cec74..b8f450956 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -17,7 +17,7 @@ import Svg, { } from "react-native-svg"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import type { ListTerminalsResponse } from "@server/shared/messages"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { hasPendingTerminalModifiers, normalizeTerminalTransportKey, @@ -135,10 +135,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const queryClient = useQueryClient(); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ); + const { client, isConnected } = useHostRuntimeSession(serverId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); diff --git a/packages/app/src/contexts/daemon-connections-context.tsx b/packages/app/src/contexts/daemon-connections-context.tsx index a3cf2f283..7219aab19 100644 --- a/packages/app/src/contexts/daemon-connections-context.tsx +++ b/packages/app/src/contexts/daemon-connections-context.tsx @@ -18,7 +18,7 @@ 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; agentListReady?: boolean } + | { 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 }; @@ -30,6 +30,7 @@ interface DaemonConnectionsContextValue { connectionStates: Map; isLoading: boolean; updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void; + markAgentListReady: (serverId: string, ready: boolean) => void; } const DaemonConnectionsContext = createContext(null); @@ -69,7 +70,8 @@ function resolveNextConnectionState( hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false, }; case "online": - const currentAgentListReady = update.agentListReady ?? (existing.status === "online" ? existing.agentListReady : false); + const currentAgentListReady = + existing.status === "online" ? existing.agentListReady : false; return { status: "online", @@ -173,10 +175,37 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode }) [] ); + 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 value: DaemonConnectionsContextValue = { connectionStates, isLoading: registryLoading, updateConnectionStatus, + markAgentListReady, }; return ( diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index b0c5014c0..f0f598f2c 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -33,7 +33,6 @@ import { useSessionStore, type Agent, type SessionState, - type DaemonConnectionSnapshot, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; @@ -74,6 +73,12 @@ const derivePendingPermissionKey = ( 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]; @@ -87,7 +92,7 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => { const mapConnectionState = ( state: ConnectionState, lastError: string | null -): DaemonConnectionSnapshot => ({ +): SessionConnectionSnapshot => ({ isConnected: state.status === "connected", isConnecting: state.status === "connecting", lastError: state.status === "disconnected" ? state.reason ?? lastError : null, @@ -264,10 +269,10 @@ function SessionProviderInternal({ }: SessionProviderClientProps) { const queryClient = useQueryClient(); const [connectionSnapshot, setConnectionSnapshot] = - useState(() => + useState(() => mapConnectionState(client.getConnectionState(), client.lastError) ); - const { updateConnectionStatus } = useDaemonConnections(); + const { markAgentListReady } = useDaemonConnections(); // Zustand store actions const initializeSession = useSessionStore((state) => state.initializeSession); @@ -312,9 +317,6 @@ function SessionProviderInternal({ const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); - const updateSessionConnection = useSessionStore( - (state) => state.updateSessionConnection - ); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); // Track focused agent for heartbeat @@ -470,10 +472,6 @@ function SessionProviderInternal({ wasConnectedRef.current = connectionSnapshot.isConnected; }, [serverId, connectionSnapshot.isConnected, bumpHistorySyncGeneration]); - useEffect(() => { - updateSessionConnection(serverId, connectionSnapshot); - }, [serverId, connectionSnapshot, updateSessionConnection]); - // Initialize session in store useEffect(() => { initializeSession(serverId, client, audioPlayer); @@ -855,11 +853,7 @@ function SessionProviderInternal({ setPendingPermissions(serverId, nextPendingPermissions); setInitializingAgents(serverId, new Map()); setHasHydratedAgents(serverId, true); - updateConnectionStatus(serverId, { - status: "online", - lastOnlineAt: new Date().toISOString(), - agentListReady: true, - }); + markAgentListReady(serverId, true); } catch (err) { if (cancelled) { return; @@ -885,7 +879,7 @@ function SessionProviderInternal({ setHasHydratedAgents, setInitializingAgents, setPendingPermissions, - updateConnectionStatus, + markAgentListReady, ]); // Daemon message handlers - directly update Zustand store @@ -1420,7 +1414,6 @@ function SessionProviderInternal({ setPendingPermissions, setFileExplorer, setHasHydratedAgents, - updateConnectionStatus, clearDraftInput, notifyAgentAttention, applyAgentUpdatePayload, diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 8a75afd8f..a160ee7b7 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useState, ReactNode, useCallback, useEffect, import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio"; import type { SessionState } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config"; @@ -45,6 +46,9 @@ export function VoiceProvider({ children }: VoiceProviderProps) { const getSession = useSessionStore((state) => state.getSession); const [activeServerId, setActiveServerId] = useState(null); const [activeAgentId, setActiveAgentId] = useState(null); + const { client: runtimeClient, isConnected: activeRuntimeConnected } = useHostRuntimeSession( + activeServerId ?? "" + ); const activeSession = useSessionStore( useCallback( (state: ReturnType) => { @@ -274,8 +278,8 @@ export function VoiceProvider({ children }: VoiceProviderProps) { }, [isVoiceMode, realtimeAudio.isDetecting, realtimeAudio.isSpeaking]); useEffect(() => { - const connected = activeSession?.connection.isConnected ?? false; - const client = activeSession?.client ?? null; + const connected = activeRuntimeConnected; + const client = runtimeClient; if (!connected) { voiceTransportReadyRef.current = false; } @@ -313,7 +317,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) { } wasVoiceSocketConnectedRef.current = connected; - }, [activeAgentId, activeServerId, activeSession?.client, activeSession?.connection.isConnected, isVoiceMode]); + }, [activeAgentId, activeRuntimeConnected, activeServerId, isVoiceMode, runtimeClient]); const isPlayingAudio = activeSession?.isPlayingAudio ?? false; diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index e9d72abd3..521eb8b11 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -5,6 +5,7 @@ import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-comm import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils' import { useAutocomplete } from './use-autocomplete' import { useSessionStore } from '@/stores/session-store' +import { useHostRuntimeSession } from '@/runtime/host-runtime' import { applyFileMentionReplacement, findActiveFileMention, @@ -137,10 +138,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut return queryDraftConfig?.cwd ?? '' }, [agentCwd, isRealAgent, queryDraftConfig]) - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ) + const { client, isConnected } = useHostRuntimeSession(serverId) const mode: 'command' | 'file' | null = showFileAutocomplete ? 'file' diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 1d7e05708..e77ab7d34 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute @@ -48,12 +48,7 @@ export function useAgentCommandsQuery({ enabled = true, draftConfig, }: UseAgentCommandsQueryOptions) { - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ); + const { client, isConnected } = useHostRuntimeSession(serverId); const query = useQuery({ queryKey: commandsQueryKey(serverId, agentId, draftConfig), diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 4d19a5960..63ece2c50 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -9,8 +9,8 @@ import type { AgentModelDefinition, AgentProvider, } from "@server/server/agent/agent-sdk-types"; -import { useSessionStore } from "@/stores/session-store"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useFormPreferences, type FormPreferences } from "./use-form-preferences"; // Explicit overrides from URL params or "New Agent" button @@ -364,11 +364,7 @@ export function useAgentFormState( }, [isVisible]); // Session state for provider model listing - const sessionState = useSessionStore((state) => - formState.serverId ? state.sessions[formState.serverId] : undefined - ); - const client = sessionState?.client ?? null; - const isConnected = sessionState?.connection?.isConnected ?? false; + const { client, isConnected } = useHostRuntimeSession(formState.serverId ?? ""); const availableProvidersQuery = useQuery({ queryKey: ["availableProviders", formState.serverId], diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 669b86f04..61c0e2da1 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,6 +1,9 @@ import { useCallback } from "react"; import { useSessionStore } from "@/stores/session-store"; -import type { FetchAgentTimelineOptions } from "@server/client/daemon-client"; +import type { + DaemonClient, + FetchAgentTimelineOptions, +} from "@server/client/daemon-client"; import { attachInitTimeout, createInitDeferred, @@ -37,8 +40,13 @@ function buildInitialTimelineRequest( }; } -export function useAgentInitialization(serverId: string) { - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); +export function useAgentInitialization({ + serverId, + client, +}: { + serverId: string; + client: DaemonClient | null; +}) { const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const setAgentInitializing = useCallback( (agentId: string, initializing: boolean) => { diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index bbea4aa94..483a1b811 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -2,6 +2,7 @@ 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"; @@ -45,8 +46,7 @@ export function useAllAgentsList(options?: { const session = useSessionStore((state) => serverId ? state.sessions[serverId] : undefined ); - const client = session?.client ?? null; - const isConnected = session?.connection.isConnected ?? false; + const { client, isConnected } = useHostRuntimeSession(serverId ?? ""); const liveAgents = session?.agents ?? null; const canFetch = Boolean(serverId && client && isConnected); diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index 5f7a45652..d5d0c57ee 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -1,8 +1,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useId, useMemo } from "react"; import { UnistylesRuntime } from "react-native-unistyles"; -import { useSessionStore } from "@/stores/session-store"; import { usePanelStore } from "@/stores/panel-store"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; import { orderCheckoutDiffFiles } from "./checkout-diff-order"; @@ -56,12 +56,7 @@ export function useCheckoutDiffQuery({ enabled = true, }: UseCheckoutDiffQueryOptions) { const queryClient = useQueryClient(); - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ); + const { client, isConnected } = useHostRuntimeSession(serverId); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index 08681a301..e4283505d 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse } from "@server/shared/messages"; const CHECKOUT_PR_STATUS_STALE_TIME = 20_000; @@ -21,12 +21,7 @@ export function useCheckoutPrStatusQuery({ cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions) { - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ); + const { client, isConnected } = useHostRuntimeSession(serverId); const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index 838d83656..dea647ea3 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { UnistylesRuntime } from "react-native-unistyles"; -import { useSessionStore } from "@/stores/session-store"; import { usePanelStore } from "@/stores/panel-store"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import type { CheckoutStatusResponse } from "@server/shared/messages"; import { checkoutStatusRevalidationKey, @@ -30,12 +30,7 @@ function fetchCheckoutStatus( } export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ); + const { client, isConnected } = useHostRuntimeSession(serverId); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); @@ -92,9 +87,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery * only the visible agents. */ export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); + const { client } = useHostRuntimeSession(serverId); return useQuery({ queryKey: checkoutStatusQueryKey(serverId, cwd), diff --git a/packages/app/src/hooks/use-project-icon-query.ts b/packages/app/src/hooks/use-project-icon-query.ts index 6c3b94696..f34d98e42 100644 --- a/packages/app/src/hooks/use-project-icon-query.ts +++ b/packages/app/src/hooks/use-project-icon-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; import type { ProjectIcon } from "@server/shared/messages"; export function projectIconQueryKey(serverId: string, cwd: string) { @@ -12,12 +12,7 @@ interface UseProjectIconQueryOptions { } export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) { - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ); + const { client, isConnected } = useHostRuntimeSession(serverId); const query = useQuery({ queryKey: projectIconQueryKey(serverId, cwd), diff --git a/packages/app/src/hooks/use-sidebar-agents-grouped.ts b/packages/app/src/hooks/use-sidebar-agents-grouped.ts index c9208faa7..c0d37d0e2 100644 --- a/packages/app/src/hooks/use-sidebar-agents-grouped.ts +++ b/packages/app/src/hooks/use-sidebar-agents-grouped.ts @@ -2,6 +2,7 @@ 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 } from "@/hooks/use-aggregated-agents"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { @@ -170,9 +171,8 @@ export function useSidebarAgentsGrouped(options?: { const session = useSessionStore((state) => serverId ? state.sessions[serverId] : undefined ); - const client = session?.client ?? null; + const { client, isConnected } = useHostRuntimeSession(serverId ?? ""); const liveAgents = session?.agents ?? null; - const isConnected = session?.connection.isConnected ?? false; const canFetch = Boolean(serverId && client && isConnected); const agentsQuery = useQuery({ diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 60401913e..56edd75c7 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -32,6 +32,12 @@ export type HostRuntimeSnapshot = { clientGeneration: number; }; +export function isHostRuntimeConnected( + snapshot: HostRuntimeSnapshot | null +): boolean { + return snapshot?.connectionStatus === "online"; +} + export type HostRuntimeControllerDeps = { createClient: (input: { host: HostProfile; @@ -564,6 +570,7 @@ export function useHostRuntimeSnapshot( export function useHostRuntimeSession(serverId: string): { snapshot: HostRuntimeSnapshot | null; client: DaemonClient | null; + isConnected: boolean; } { const store = getHostRuntimeStore(); const snapshot = useHostRuntimeSnapshot(serverId); @@ -571,6 +578,7 @@ export function useHostRuntimeSession(serverId: string): { () => ({ snapshot, client: store.getClient(serverId), + isConnected: isHostRuntimeConnected(snapshot), }), [serverId, snapshot, store] ); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 336a7b145..ac8ea68af 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -41,8 +41,11 @@ import { } from "@/contexts/explorer-sidebar-animation-context"; import { usePanelStore } from "@/stores/panel-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import { useSessionStore } from "@/stores/session-store"; +import { + useHostRuntimeSession, + type HostRuntimeConnectionStatus, +} from "@/runtime/host-runtime"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { Agent } from "@/contexts/session-context"; import type { StreamItem } from "@/types/stream"; @@ -75,7 +78,7 @@ import { } from "@/utils/agent-snapshots"; import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; -import type { FetchAgentsEntry } from "@server/client/daemon-client"; +import type { DaemonClient, FetchAgentsEntry } from "@server/client/daemon-client"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { DropdownMenu, @@ -109,11 +112,12 @@ export function AgentReadyScreen({ const resolvedAgentId = agentId?.trim() || undefined; const resolvedServerId = serverId?.trim() || undefined; const { connectionStates } = useDaemonConnections(); - - // Check if session exists for this serverId - const hasSession = useSessionStore((state) => - resolvedServerId ? !!state.sessions[resolvedServerId] : false - ); + const runtimeServerId = resolvedServerId ?? ""; + const { + snapshot: runtimeSnapshot, + client: runtimeClient, + isConnected: runtimeIsConnected, + } = useHostRuntimeSession(runtimeServerId); const connectionServerId = resolvedServerId ?? null; const connection = connectionServerId @@ -122,9 +126,11 @@ export function AgentReadyScreen({ const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected host"; const isUnknownDaemon = Boolean(connectionServerId && !connection); - const connectionStatus = - connection?.status ?? (isUnknownDaemon ? "offline" : "idle"); - const lastConnectionError = connection?.lastError ?? null; + const connectionStatus: HostRuntimeConnectionStatus = + runtimeSnapshot?.connectionStatus ?? + (isUnknownDaemon ? "offline" : "idle"); + const lastConnectionError = runtimeSnapshot?.lastError ?? null; + const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient); const handleBackToHome = useCallback(() => { const targetServerId = resolvedServerId; @@ -154,7 +160,9 @@ export function AgentReadyScreen({ }, [resolvedAgentId, resolvedServerId, router]); const focusServerId = resolvedServerId; - const navigationStatus = hasSession ? "ready" : "session_unavailable"; + const navigationStatus = isRuntimeSessionAvailable + ? "ready" + : "session_unavailable"; useFocusEffect( useCallback(() => { @@ -172,7 +180,7 @@ export function AgentReadyScreen({ }, [focusServerId, navigationStatus, resolvedAgentId]) ); - if (!hasSession || !resolvedServerId) { + if (!resolvedServerId || !runtimeClient) { return ( @@ -198,7 +208,9 @@ export function AgentReadyScreen({ type AgentScreenContentProps = { serverId: string; agentId?: string; - connectionStatus: ConnectionStatus; + client: DaemonClient; + isConnected: boolean; + connectionStatus: HostRuntimeConnectionStatus; }; function toErrorMessage(error: unknown): string { @@ -215,6 +227,8 @@ function isNotFoundErrorMessage(message: string): boolean { function AgentScreenContent({ serverId, agentId, + client, + isConnected, connectionStatus, }: AgentScreenContentProps) { const { theme } = useUnistyles(); @@ -465,16 +479,16 @@ function AgentScreenContent({ return filtered; }, [allPendingPermissions, resolvedAgentId]); - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false + const hasSession = useSessionStore( + (state) => Boolean(state.sessions[serverId]) ); const focusedAgentId = useSessionStore( (state) => state.sessions[serverId]?.focusedAgentId ?? null ); - const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId); + const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization({ + serverId, + client: hasSession ? client : null, + }); const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", }); @@ -529,7 +543,7 @@ function AgentScreenContent({ useFocusEffect( useCallback(() => { - if (!resolvedAgentId || !isConnected) { + if (!resolvedAgentId || !isConnected || !hasSession) { return; } ensureAgentIsInitialized(resolvedAgentId).catch((error) => { @@ -538,7 +552,7 @@ function AgentScreenContent({ error, }); }); - }, [ensureAgentIsInitialized, isConnected, resolvedAgentId]) + }, [ensureAgentIsInitialized, hasSession, isConnected, resolvedAgentId]) ); const isGitCheckout = activeExplorerCheckout?.isGit ?? false; @@ -745,7 +759,7 @@ function AgentScreenContent({ return; } - if (!isConnected) { + if (!isConnected || !hasSession) { return; } // On native clients, daemon stream forwarding is focused-agent only, so switching @@ -764,6 +778,7 @@ function AgentScreenContent({ }, [ resolvedAgentId, ensureAgentIsInitialized, + hasSession, isConnected, needsAuthoritativeSync, ]); @@ -784,7 +799,7 @@ function AgentScreenContent({ } return; } - if (!isConnected) { + if (!isConnected || !hasSession) { return; } if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { @@ -855,6 +870,7 @@ function AgentScreenContent({ agent, client, ensureAgentIsInitialized, + hasSession, isConnected, missingAgentState.kind, resolvedAgentId, @@ -898,13 +914,13 @@ function AgentScreenContent({ ]); const handleRefreshAgent = useCallback(() => { - if (!resolvedAgentId) { + if (!resolvedAgentId || !hasSession) { return; } void refreshAgent(resolvedAgentId).catch((error) => { console.warn("[AgentScreen] refreshAgent failed", { agentId: resolvedAgentId, error }); }); - }, [resolvedAgentId, refreshAgent]); + }, [hasSession, refreshAgent, resolvedAgentId]); const handleCopyMeta = useCallback( async (label: string, value: string | null | undefined) => { @@ -1262,7 +1278,7 @@ function AgentSessionUnavailableState({ }: { onBack: () => void; serverLabel: string; - connectionStatus: ConnectionStatus; + connectionStatus: HostRuntimeConnectionStatus; lastError: string | null; isUnknownDaemon?: boolean; }) { @@ -1285,19 +1301,24 @@ function AgentSessionUnavailableState({ } const isConnecting = connectionStatus === "connecting"; + const isPreparingSession = connectionStatus === "online"; return ( - {isConnecting ? ( + {isConnecting || isPreparingSession ? ( <> - Connecting to {serverLabel}... + {isPreparingSession + ? `Preparing ${serverLabel} session...` + : `Connecting to ${serverLabel}...`} - We will show this agent once the host is online. + {isPreparingSession + ? "We will show this agent in a moment." + : "We will show this agent once the host is online."} ) : ( diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 14388cd29..134059059 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -35,6 +35,7 @@ import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-sugg import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture' import { useSessionStore } from '@/stores/session-store' import { useCreateFlowStore } from '@/stores/create-flow-store' +import { useHostRuntimeSession } from '@/runtime/host-runtime' import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store' import { MAX_CONTENT_WIDTH } from '@/constants/layout' @@ -368,18 +369,16 @@ function DraftAgentScreenContent({ return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources]) }, [allAgents, sessionAgents]) - const sessionClient = useSessionStore((state) => - selectedServerId ? (state.sessions[selectedServerId]?.client ?? null) : null - ) - const isConnected = useSessionStore((state) => - selectedServerId ? (state.sessions[selectedServerId]?.connection.isConnected ?? false) : false + const { client: runtimeClient, isConnected: isHostOnline } = useHostRuntimeSession( + selectedServerId ?? '' ) + const sessionClient = runtimeClient const trimmedWorkingDir = workingDir.trim() const shouldInspectRepo = trimmedWorkingDir.length > 0 const daemonAvailabilityError = - !selectedServerId || hostEntry?.status !== 'online' ? 'Host is offline' : null + !selectedServerId || !isHostOnline ? 'Host is offline' : null const repoAvailabilityError = - shouldInspectRepo && (!hostEntry || hostEntry.status !== 'online' || !isConnected) + shouldInspectRepo && !isHostOnline ? (daemonAvailabilityError ?? 'Repository details will load automatically once the selected host is back online.') : null @@ -398,7 +397,7 @@ function DraftAgentScreenContent({ Boolean(trimmedWorkingDir) && !repoAvailabilityError && Boolean(sessionClient) && - isConnected, + isHostOnline, retry: false, staleTime: CHECKOUT_STATUS_STALE_TIME, refetchOnMount: 'always', @@ -461,7 +460,7 @@ function DraftAgentScreenContent({ Boolean(worktreeListRoot) && !repoAvailabilityError && Boolean(sessionClient) && - isConnected && + isHostOnline && !isNonGitDirectory, retry: false, staleTime: 0, @@ -526,7 +525,7 @@ function DraftAgentScreenContent({ Boolean(trimmedWorkingDir) && !repoAvailabilityError && Boolean(sessionClient) && - isConnected, + isHostOnline, retry: false, staleTime: 15_000, }) @@ -559,7 +558,7 @@ function DraftAgentScreenContent({ Boolean(selectedServerId) && !daemonAvailabilityError && Boolean(sessionClient) && - isConnected, + isHostOnline, retry: false, staleTime: 15_000, }) @@ -625,7 +624,7 @@ function DraftAgentScreenContent({ Boolean(baseBranch) && Boolean(trimmedWorkingDir) && Boolean(sessionClient) && - isConnected, + isHostOnline, retry: false, staleTime: 30_000, }) @@ -810,9 +809,7 @@ function DraftAgentScreenContent({ }) }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]) - const createAgentClient = useSessionStore((state) => - selectedServerId ? (state.sessions[selectedServerId]?.client ?? null) : null - ) + const createAgentClient = sessionClient const draftCommandConfig = useMemo(() => { const cwd = ( isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 2cf3a87fc..cd80d536e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -22,6 +22,7 @@ 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 { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; @@ -1035,10 +1036,11 @@ function HostDetailModal({ ); // Restart logic (moved from DaemonCard) - const daemonClient = useSessionStore((state) => host ? (state.sessions[host.serverId]?.client ?? null) : null); - const daemonConnection = useSessionStore((state) => host ? (state.sessions[host.serverId]?.connection ?? null) : null); + const { client: runtimeClient, isConnected } = useHostRuntimeSession( + host?.serverId ?? "" + ); + const daemonClient = runtimeClient; const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null); - const isConnected = daemonConnection?.isConnected ?? false; const isConnectedRef = useRef(isConnected); const [isRestarting, setIsRestarting] = useState(false); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index cdc6cf8d9..990d89ef1 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -147,12 +147,6 @@ export interface AgentFileExplorerState { selectedEntryPath: string | null; } -export interface DaemonConnectionSnapshot { - isConnected: boolean; - isConnecting: boolean; - lastError: string | null; -} - export type DaemonServerInfo = { serverId: string; hostname: string | null; @@ -173,9 +167,6 @@ export interface SessionState { // Daemon client (immutable reference) client: DaemonClient | null; - // Connection snapshot (mutable) - connection: DaemonConnectionSnapshot; - // Server metadata (from server_info handshake) serverInfo: DaemonServerInfo | null; @@ -233,7 +224,6 @@ interface SessionStoreActions { clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; updateSessionClient: (serverId: string, client: DaemonClient) => void; - updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void; updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void; // Audio state @@ -309,25 +299,11 @@ function logSessionStoreUpdate( }); } - -function createDefaultConnectionSnapshot(client?: DaemonClient | null): DaemonConnectionSnapshot { - if (!client) { - return { isConnected: false, isConnecting: false, lastError: null }; - } - const state = client.getConnectionState(); - return { - isConnected: state.status === "connected", - isConnecting: state.status === "connecting", - lastError: state.status === "disconnected" ? state.reason ?? client.lastError ?? null : null, - }; -} - // Helper to create initial session state function createInitialSessionState(serverId: string, client: DaemonClient, audioPlayer: ReturnType): SessionState { return { serverId, client, - connection: createDefaultConnectionSnapshot(client), serverInfo: null, audioPlayer, hasHydratedAgents: false, @@ -420,33 +396,6 @@ export const useSessionStore = create()( }); }, - updateSessionConnection: (serverId, connection) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - if ( - session.connection.isConnected === connection.isConnected && - session.connection.isConnecting === connection.isConnecting && - session.connection.lastError === connection.lastError - ) { - return prev; - } - logSessionStoreUpdate("updateSessionConnection", serverId, connection); - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { - ...session, - connection, - }, - }, - }; - }); - }, - updateSessionServerInfo: (serverId, info) => { set((prev) => { const session = prev.sessions[serverId];