Unify app connection state around host runtime

This commit is contained in:
Mohamed Boudra
2026-02-20 10:37:59 +07:00
parent 6b23509b96
commit d128a32d2f
22 changed files with 173 additions and 191 deletions
+7 -3
View File
@@ -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;
}
@@ -24,7 +24,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
);
}, [daemon.serverId, hasConnectionRecord, snapshot, updateConnectionStatus]);
if (!snapshot?.activeConnection || !client) {
if (!client) {
return null;
}
@@ -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<string, { serverId: string; cwd: string }>();
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,
@@ -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]);
@@ -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<string, DaemonConnectionRecord>;
isLoading: boolean;
updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void;
markAgentListReady: (serverId: string, ready: boolean) => void;
}
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(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 (
+11 -18
View File
@@ -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<DaemonConnectionSnapshot>(() =>
useState<SessionConnectionSnapshot>(() =>
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,
+7 -3
View File
@@ -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<string | null>(null);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const { client: runtimeClient, isConnected: activeRuntimeConnected } = useHostRuntimeSession(
activeServerId ?? ""
);
const activeSession = useSessionStore(
useCallback(
(state: ReturnType<typeof useSessionStore.getState>) => {
@@ -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;
@@ -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'
@@ -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),
@@ -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],
@@ -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) => {
@@ -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);
@@ -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);
@@ -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),
@@ -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),
@@ -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),
@@ -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({
+8
View File
@@ -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]
);
@@ -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 (
<AgentSessionUnavailableState
onBack={handleBackToHome}
@@ -189,6 +197,8 @@ export function AgentReadyScreen({
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
client={runtimeClient}
isConnected={runtimeIsConnected}
connectionStatus={connectionStatus}
/>
</ExplorerSidebarAnimationProvider>
@@ -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<AgentScreenMissingState>({
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 (
<View style={styles.container}>
<BackHeader title="Agent" onBack={onBack} />
<View style={styles.centerState}>
{isConnecting ? (
{isConnecting || isPreparingSession ? (
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>
Connecting to {serverLabel}...
{isPreparingSession
? `Preparing ${serverLabel} session...`
: `Connecting to ${serverLabel}...`}
</Text>
<Text style={styles.statusText}>
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."}
</Text>
</>
) : (
@@ -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<DraftCommandConfig | undefined>(() => {
const cwd = (
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
+5 -3
View File
@@ -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);
-51
View File
@@ -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<typeof useAudioPlayer>): SessionState {
return {
serverId,
client,
connection: createDefaultConnectionSnapshot(client),
serverInfo: null,
audioPlayer,
hasHydratedAgents: false,
@@ -420,33 +396,6 @@ export const useSessionStore = create<SessionStore>()(
});
},
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];