From 603e95de099618662b75dcf6ad2f5a795dd4b11d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 19 Feb 2026 21:17:51 +0700 Subject: [PATCH] refactor(agent-screen): extract state machine for agent loading flow --- packages/app/src/components/message-input.tsx | 3 +- .../use-agent-screen-state-machine.test.ts | 247 +++++++++++++++ .../hooks/use-agent-screen-state-machine.ts | 159 ++++++++++ .../src/screens/agent/agent-ready-screen.tsx | 297 +++++++----------- 4 files changed, 519 insertions(+), 187 deletions(-) create mode 100644 packages/app/src/hooks/use-agent-screen-state-machine.test.ts create mode 100644 packages/app/src/hooks/use-agent-screen-state-machine.ts diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index bcf0624f5..029d54f63 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -290,7 +290,8 @@ export const MessageInput = forwardRef(funct onError: handleDictationError, canStart: canStartDictation, canConfirm: canConfirmDictation, - autoStopWhenHidden: { isVisible: isScreenFocused }, + autoStopWhenHidden: + Platform.OS === 'web' ? undefined : { isVisible: isScreenFocused }, enableDuration: true, }) diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts new file mode 100644 index 000000000..e8c721bc4 --- /dev/null +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@/contexts/session-context"; +import { + deriveAgentScreenViewState, + type AgentScreenMachineInput, + type AgentScreenMachineMemory, +} from "./use-agent-screen-state-machine"; + +function createAgent(id: string): Agent { + const now = new Date("2026-02-19T00:00:00.000Z"); + return { + serverId: "server-1", + id, + provider: "claude", + status: "running", + createdAt: now, + updatedAt: now, + lastUserMessageAt: now, + lastActivityAt: now, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: "claude", + sessionId: "session-1", + model: null, + modeId: null, + }, + title: "Agent", + cwd: "/repo", + model: null, + labels: {}, + }; +} + +function createBaseInput(): AgentScreenMachineInput { + return { + agent: null, + placeholderAgent: null, + missingAgentState: { kind: "idle" }, + isConnected: true, + isArchivingCurrentAgent: false, + isHistorySyncing: false, + needsAuthoritativeSync: false, + shouldUseOptimisticStream: false, + }; +} + +describe("deriveAgentScreenViewState", () => { + it("returns boot loading before first interactive paint", () => { + const memory: AgentScreenMachineMemory = { + hasRenderedReady: false, + lastReadyAgent: null, + }; + const input = createBaseInput(); + + const result = deriveAgentScreenViewState({ input, memory }); + + expect(result.state.tag).toBe("boot"); + if (result.state.tag !== "boot") { + throw new Error("expected boot state"); + } + expect(result.state.reason).toBe("loading"); + expect(result.state.source).toBe("none"); + }); + + it("stays ready after first paint even if agent is temporarily missing", () => { + const memory: AgentScreenMachineMemory = { + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }; + const input = createBaseInput(); + + const result = deriveAgentScreenViewState({ input, memory }); + + 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"); + }); + + it("shows reconnecting sync status without blocking after first paint", () => { + const memory: AgentScreenMachineMemory = { + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }; + const input: AgentScreenMachineInput = { + ...createBaseInput(), + isConnected: false, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + + expect(result.state.tag).toBe("ready"); + if (result.state.tag !== "ready") { + throw new Error("expected ready state"); + } + expect(result.state.syncStatus).toBe("reconnecting"); + }); + + it("shows non-blocking catching-up state after first paint", () => { + const memory: AgentScreenMachineMemory = { + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }; + const input: AgentScreenMachineInput = { + ...createBaseInput(), + needsAuthoritativeSync: true, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + + expect(result.state.tag).toBe("ready"); + if (result.state.tag !== "ready") { + throw new Error("expected ready state"); + } + expect(result.state.syncStatus).toBe("catching_up"); + }); + + it("keeps ready with sync_error when refresh fails after first paint", () => { + const memory: AgentScreenMachineMemory = { + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }; + const input: AgentScreenMachineInput = { + ...createBaseInput(), + missingAgentState: { kind: "error", message: "network timeout" }, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + + 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"); + }); + + it("returns blocking error before first paint when refresh fails", () => { + const memory: AgentScreenMachineMemory = { + hasRenderedReady: false, + lastReadyAgent: null, + }; + const input: AgentScreenMachineInput = { + ...createBaseInput(), + missingAgentState: { kind: "error", message: "network timeout" }, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + + expect(result.state.tag).toBe("error"); + if (result.state.tag !== "error") { + throw new Error("expected error state"); + } + expect(result.state.message).toContain("network timeout"); + }); + + it("returns not_found when resolver confirms missing agent", () => { + const memory: AgentScreenMachineMemory = { + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }; + const input: AgentScreenMachineInput = { + ...createBaseInput(), + missingAgentState: { kind: "not_found", message: "agent missing" }, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + + expect(result.state.tag).toBe("not_found"); + if (result.state.tag !== "not_found") { + throw new Error("expected not_found state"); + } + expect(result.state.message).toContain("missing"); + }); + + it("promotes optimistic source while placeholder is used", () => { + const memory: AgentScreenMachineMemory = { + hasRenderedReady: false, + lastReadyAgent: null, + }; + const input: AgentScreenMachineInput = { + ...createBaseInput(), + placeholderAgent: createAgent("draft-agent"), + shouldUseOptimisticStream: true, + }; + + const result = deriveAgentScreenViewState({ input, memory }); + + expect(result.state.tag).toBe("ready"); + if (result.state.tag !== "ready") { + throw new Error("expected ready state"); + } + expect(result.state.source).toBe("optimistic"); + }); + + it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => { + const initialMemory: AgentScreenMachineMemory = { + hasRenderedReady: false, + lastReadyAgent: null, + }; + const optimisticInput: AgentScreenMachineInput = { + ...createBaseInput(), + placeholderAgent: createAgent("draft-agent"), + shouldUseOptimisticStream: true, + }; + + const optimistic = 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 handoffInput: AgentScreenMachineInput = { + ...createBaseInput(), + }; + const handoff = deriveAgentScreenViewState({ + input: handoffInput, + memory: optimistic.memory, + }); + + 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"); + }); +}); diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts new file mode 100644 index 000000000..f6cc18567 --- /dev/null +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -0,0 +1,159 @@ +import { useRef } from "react"; +import type { Agent } from "@/contexts/session-context"; + +export type AgentScreenMissingState = + | { kind: "idle" } + | { kind: "resolving" } + | { kind: "not_found"; message: string } + | { kind: "error"; message: string }; + +export interface AgentScreenMachineInput { + agent: Agent | null; + placeholderAgent: Agent | null; + missingAgentState: AgentScreenMissingState; + isConnected: boolean; + isArchivingCurrentAgent: boolean; + isHistorySyncing: boolean; + needsAuthoritativeSync: boolean; + shouldUseOptimisticStream: boolean; +} + +export interface AgentScreenMachineMemory { + hasRenderedReady: boolean; + lastReadyAgent: Agent | null; +} + +export type AgentScreenSyncStatus = + | "idle" + | "catching_up" + | "reconnecting" + | "sync_error"; + +export type AgentScreenViewState = + | { + tag: "boot"; + reason: "loading" | "resolving"; + source: "none"; + } + | { + tag: "not_found"; + message: string; + } + | { + tag: "error"; + message: string; + } + | { + tag: "ready"; + agent: Agent; + source: "authoritative" | "optimistic" | "stale"; + syncStatus: AgentScreenSyncStatus; + isArchiving: boolean; + }; + +export function deriveAgentScreenViewState({ + input, + memory, +}: { + input: AgentScreenMachineInput; + memory: AgentScreenMachineMemory; +}): { state: AgentScreenViewState; memory: AgentScreenMachineMemory } { + const nextMemory: AgentScreenMachineMemory = { + hasRenderedReady: memory.hasRenderedReady, + lastReadyAgent: memory.lastReadyAgent, + }; + + const candidateAgent = input.agent ?? input.placeholderAgent; + if (candidateAgent) { + nextMemory.hasRenderedReady = true; + nextMemory.lastReadyAgent = candidateAgent; + } + + if (input.missingAgentState.kind === "not_found") { + return { + state: { + tag: "not_found", + message: input.missingAgentState.message, + }, + memory: nextMemory, + }; + } + + if (input.missingAgentState.kind === "error" && !nextMemory.hasRenderedReady) { + return { + state: { + tag: "error", + message: input.missingAgentState.message, + }, + memory: nextMemory, + }; + } + + const displayAgent = + candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null); + if (!displayAgent) { + return { + state: { + tag: "boot", + reason: input.missingAgentState.kind === "resolving" ? "resolving" : "loading", + source: "none", + }, + memory: nextMemory, + }; + } + + const source: "authoritative" | "optimistic" | "stale" = input.agent + ? "authoritative" + : input.shouldUseOptimisticStream + ? "optimistic" + : "stale"; + + let syncStatus: AgentScreenSyncStatus = "idle"; + if (!input.isConnected) { + syncStatus = "reconnecting"; + } else if (input.missingAgentState.kind === "error") { + syncStatus = "sync_error"; + } else if (input.needsAuthoritativeSync || input.isHistorySyncing) { + syncStatus = "catching_up"; + } + + return { + state: { + tag: "ready", + agent: displayAgent, + source, + syncStatus, + isArchiving: input.isArchivingCurrentAgent, + }, + memory: nextMemory, + }; +} + +export function useAgentScreenStateMachine({ + routeKey, + input, +}: { + routeKey: string; + input: AgentScreenMachineInput; +}): AgentScreenViewState { + const routeKeyRef = useRef(routeKey); + const memoryRef = useRef({ + hasRenderedReady: false, + lastReadyAgent: null, + }); + + if (routeKeyRef.current !== routeKey) { + routeKeyRef.current = routeKey; + memoryRef.current = { + hasRenderedReady: false, + lastReadyAgent: null, + }; + } + + const result = deriveAgentScreenViewState({ + input, + memory: memoryRef.current, + }); + memoryRef.current = result.memory; + return result.state; +} diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 65b512756..336a7b145 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -63,6 +63,10 @@ import { } from "@/hooks/use-checkout-status-query"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { + useAgentScreenStateMachine, + type AgentScreenMissingState, +} from "@/hooks/use-agent-screen-state-machine"; import { useToast } from "@/contexts/toast-context"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { @@ -85,8 +89,6 @@ import type { ExplorerCheckoutContext } from "@/stores/panel-store"; const DROPDOWN_WIDTH = 220; const EMPTY_STREAM_ITEMS: StreamItem[] = []; -const RECONNECT_NOTICE_DELAY_MS = 10_000; -const CONNECTED_NOTICE_DURATION_MS = 2_500; const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); function logAgentExplorer(event: string, details: Record): void { @@ -199,12 +201,6 @@ type AgentScreenContentProps = { connectionStatus: ConnectionStatus; }; -type MissingAgentState = - | { kind: "idle" } - | { kind: "resolving" } - | { kind: "not_found"; message: string } - | { kind: "error"; message: string }; - function toErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -479,14 +475,10 @@ function AgentScreenContent({ (state) => state.sessions[serverId]?.focusedAgentId ?? null ); const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId); - const [missingAgentState, setMissingAgentState] = useState({ + const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", }); - const [showReconnectNotice, setShowReconnectNotice] = useState(false); - const [dismissedReconnectNotice, setDismissedReconnectNotice] = useState(false); - const [showConnectedNotice, setShowConnectedNotice] = useState(false); - const reconnectNoticeTimeoutRef = useRef | null>(null); - const connectedNoticeTimeoutRef = useRef | null>(null); + const reconnectToastArmedRef = useRef(false); const initAttemptTokenRef = useRef(0); const setFocusedAgentId = useCallback( (agentId: string | null) => { @@ -511,58 +503,43 @@ function AgentScreenContent({ }; }); - const agentModel = extractAgentModel(agent); - const modelDisplayValue = agentModel ?? "Unknown"; - useEffect(() => { - if (reconnectNoticeTimeoutRef.current) { - clearTimeout(reconnectNoticeTimeoutRef.current); - reconnectNoticeTimeoutRef.current = null; - } - if (connectionStatus === "online") { - if (showReconnectNotice || dismissedReconnectNotice) { - setShowConnectedNotice(true); - } - setShowReconnectNotice(false); - setDismissedReconnectNotice(false); + reconnectToastArmedRef.current = false; return; } - - setShowConnectedNotice(false); - if (!showReconnectNotice && !dismissedReconnectNotice) { - reconnectNoticeTimeoutRef.current = setTimeout(() => { - setShowReconnectNotice(true); - }, RECONNECT_NOTICE_DELAY_MS); + if (connectionStatus === "idle") { + return; } - - return () => { - if (reconnectNoticeTimeoutRef.current) { - clearTimeout(reconnectNoticeTimeoutRef.current); - reconnectNoticeTimeoutRef.current = null; - } - }; - }, [connectionStatus, dismissedReconnectNotice, showReconnectNotice]); + if (!reconnectToastArmedRef.current) { + reconnectToastArmedRef.current = true; + toast.show("Reconnecting...", { + durationMs: 2200, + testID: "agent-reconnecting-toast", + }); + } + }, [connectionStatus, toast]); useEffect(() => { - if (!showConnectedNotice) { - if (connectedNoticeTimeoutRef.current) { - clearTimeout(connectedNoticeTimeoutRef.current); - connectedNoticeTimeoutRef.current = null; - } + if (missingAgentState.kind !== "error") { return; } - connectedNoticeTimeoutRef.current = setTimeout(() => { - setShowConnectedNotice(false); - connectedNoticeTimeoutRef.current = null; - }, CONNECTED_NOTICE_DURATION_MS); - return () => { - if (connectedNoticeTimeoutRef.current) { - clearTimeout(connectedNoticeTimeoutRef.current); - connectedNoticeTimeoutRef.current = null; + toast.error("Failed to refresh agent. Retrying in background."); + }, [missingAgentState.kind, toast]); + + useFocusEffect( + useCallback(() => { + if (!resolvedAgentId || !isConnected) { + return; } - }; - }, [showConnectedNotice]); + ensureAgentIsInitialized(resolvedAgentId).catch((error) => { + console.warn("[AgentScreen] focus sync failed", { + agentId: resolvedAgentId, + error, + }); + }); + }, [ensureAgentIsInitialized, isConnected, resolvedAgentId]) + ); const isGitCheckout = activeExplorerCheckout?.isGit ?? false; const isArchivingCurrentAgent = Boolean( @@ -646,7 +623,6 @@ function AgentScreenContent({ }, [optimisticStreamItems, streamItems]); const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0; - const shouldBlockForHistorySync = !shouldUseOptimisticStream && (needsAuthoritativeSync || isHistorySyncing); const placeholderAgent: Agent | null = useMemo(() => { if (!shouldUseOptimisticStream || !resolvedAgentId) { @@ -687,9 +663,26 @@ function AgentScreenContent({ }; }, [resolvedAgentId, serverId, shouldUseOptimisticStream]); - const effectiveAgent = agent ?? placeholderAgent; - const providerLabel = (effectiveAgent?.provider ?? "Provider").replace(/^\w/, (m) => - m.toUpperCase() + const viewState = useAgentScreenStateMachine({ + routeKey: `${serverId}:${resolvedAgentId ?? ""}`, + input: { + agent: agent ?? null, + placeholderAgent, + missingAgentState, + isConnected, + isArchivingCurrentAgent, + isHistorySyncing, + needsAuthoritativeSync, + shouldUseOptimisticStream, + }, + }); + + const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; + const agentModel = extractAgentModel(effectiveAgent ?? agent); + const modelDisplayValue = agentModel ?? "Unknown"; + const providerLabel = (effectiveAgent?.provider ?? "Provider").replace( + /^\w/, + (m) => m.toUpperCase() ); const providerSessionId = effectiveAgent?.runtimeInfo?.sessionId ?? effectiveAgent?.persistence?.sessionId ?? null; @@ -931,34 +924,44 @@ function AgentScreenContent({ [theme.colors.primary, toast] ); - if (!effectiveAgent) { - if (missingAgentState.kind === "not_found") { - return ( - - - - Agent not found - - - ); - } - if (missingAgentState.kind === "error") { - return ( - - - - Failed to load agent - {missingAgentState.message} - - - ); - } + const syncBannerMessage = + viewState.tag !== "ready" + ? null + : viewState.syncStatus === "catching_up" + ? "Refreshing agent history..." + : viewState.syncStatus === "sync_error" + ? "Refresh delayed. Retrying in background." + : null; + if (viewState.tag === "not_found") { + return ( + + + + Agent not found + + + ); + } + + if (viewState.tag === "error") { + return ( + + + + Failed to load agent + {viewState.message} + + + ); + } + + if (viewState.tag === "boot" || !effectiveAgent) { return ( - + ); @@ -968,7 +971,7 @@ function AgentScreenContent({ {/* Header */} @@ -1149,7 +1152,7 @@ function AgentScreenContent({ } - disabled={isInitializing || shouldBlockForHistorySync} + disabled={isInitializing} trailing={ isInitializing ? ( - {(showReconnectNotice || showConnectedNotice) && ( - - {showConnectedNotice ? ( - - ) : null} - - {showReconnectNotice ? "Reconnecting..." : "Connected"} - - {showReconnectNotice ? ( - { - setShowReconnectNotice(false); - setDismissedReconnectNotice(true); - }} - style={styles.connectionNoticeDismiss} - accessibilityRole="button" - accessibilityLabel="Dismiss reconnecting notice" - > - Dismiss - - ) : null} + {syncBannerMessage ? ( + + {syncBannerMessage} - )} + ) : null} {/* Content Area with Keyboard Animation */} - {shouldBlockForHistorySync ? ( - - - - ) : ( - - - - )} + + + {/* Agent Input Area */} - {agent && - resolvedAgentId && - !shouldBlockForHistorySync && - !isArchivingCurrentAgent && ( + {resolvedAgentId && !isArchivingCurrentAgent && ( ({ flex: 1, overflow: "hidden", }, - connectionNotice: { + syncBanner: { marginHorizontal: theme.spacing[4], marginTop: theme.spacing[1], marginBottom: theme.spacing[2], - minHeight: 32, borderRadius: theme.borderRadius.full, + paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - connectionNoticeReconnecting: { - backgroundColor: `${theme.colors.palette.yellow[400]}22`, borderWidth: theme.borderWidth[1], - borderColor: theme.colors.palette.yellow[400], + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, }, - connectionNoticeConnected: { - backgroundColor: theme.colors.palette.green[100], - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.palette.green[400], - }, - connectionNoticeText: { + syncBannerText: { fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - connectionNoticeTextReconnecting: { - color: theme.colors.foreground, - }, - connectionNoticeTextConnected: { - color: theme.colors.palette.green[800], - }, - connectionNoticeDismiss: { - marginLeft: "auto", - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface0, - }, - connectionNoticeDismissText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, }, content: { flex: 1, }, - loadingContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - gap: 16, - }, archivingOverlay: { position: "absolute", top: 0,