From 4fa8859d149000ec2a4ca9efc736c4a31a2e5a01 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Feb 2026 15:44:29 +0700 Subject: [PATCH] Stabilize agent history sync after app backgrounding --- .../app/src/components/agent-stream-view.tsx | 33 +----- packages/app/src/contexts/session-context.tsx | 30 ++++- packages/app/src/hooks/use-client-activity.ts | 48 ++++++-- .../src/screens/agent/agent-ready-screen.tsx | 111 ++++++++---------- packages/app/src/stores/session-store.ts | 59 ++++++++++ packages/server/src/client/daemon-client.ts | 2 + packages/server/src/server/session.ts | 19 ++- packages/server/src/shared/messages.ts | 1 + 8 files changed, 198 insertions(+), 105 deletions(-) diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index da150eca8..12bc4e265 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -85,11 +85,6 @@ export interface AgentStreamViewProps { agent: Agent; streamItems: StreamItem[]; pendingPermissions: Map; - /** - * True when we expect a history snapshot to arrive shortly (e.g. after opening an agent - * or resuming after connectivity changes) and should avoid showing the "empty" state. - */ - isSyncingHistory?: boolean; } export function AgentStreamView({ @@ -98,7 +93,6 @@ export function AgentStreamView({ agent, streamItems, pendingPermissions, - isSyncingHistory = false, }: AgentStreamViewProps) { const flatListRef = useRef>(null); const { theme } = useUnistyles(); @@ -521,9 +515,8 @@ export function AgentStreamView({ }); }, [agentId, pendingPermissionItems.length, streamHead, streamItems]); - const showSyncingIndicator = isSyncingHistory; const showWorkingIndicator = agent.status === "running"; - const showBottomBar = showSyncingIndicator || showWorkingIndicator || isVoiceMode; + const showBottomBar = showWorkingIndicator || isVoiceMode; const listHeaderComponent = useMemo(() => { const hasPermissions = pendingPermissionItems.length > 0; @@ -533,11 +526,7 @@ export function AgentStreamView({ return null; } - const leftContent = showSyncingIndicator - ? - : showWorkingIndicator - ? - : null; + const leftContent = showWorkingIndicator ? : null; return ( @@ -587,7 +576,6 @@ export function AgentStreamView({ ); }, [ pendingPermissionItems, - showSyncingIndicator, showWorkingIndicator, client, streamHead, @@ -629,17 +617,16 @@ export function AgentStreamView({ return null; } - const shouldShowSyncing = - isSyncingHistory || agent.status === "running"; + const shouldShowWorking = agent.status === "running"; - if (shouldShowSyncing) { + if (shouldShowWorking) { return ( - Catching up… + Working… ); } @@ -653,7 +640,6 @@ export function AgentStreamView({ ); }, [ agent.status, - isSyncingHistory, pendingPermissionItems.length, streamHead, theme.colors.foregroundMuted, @@ -870,15 +856,6 @@ function WorkingIndicator() { ); } -function SyncingIndicator() { - return ( - - - Catching up… - - ); -} - // Permission Request Card Component function PermissionRequestCard({ permission, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 5fc230894..46acb4ec9 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -66,6 +66,7 @@ const derivePendingPermissionKey = ( }; const NOTIFICATION_PREVIEW_LIMIT = 220; +const HISTORY_STALE_AFTER_MS = 60_000; const normalizeNotificationText = (text: string): string => text.replace(/\s+/g, " ").trim(); @@ -304,6 +305,12 @@ export function SessionProvider({ const setInitializingAgents = useSessionStore( (state) => state.setInitializingAgents ); + const bumpHistorySyncGeneration = useSessionStore( + (state) => state.bumpHistorySyncGeneration + ); + const markAgentHistorySynchronized = useSessionStore( + (state) => state.markAgentHistorySynchronized + ); const setHasHydratedAgents = useSessionStore( (state) => state.setHasHydratedAgents ); @@ -329,8 +336,18 @@ export function SessionProvider({ (state) => state.sessions[serverId]?.focusedAgentId ?? null ); + const handleAppResumed = useCallback( + (awayMs: number) => { + if (awayMs < HISTORY_STALE_AFTER_MS) { + return; + } + bumpHistorySyncGeneration(serverId); + }, + [bumpHistorySyncGeneration, serverId] + ); + // Client activity tracking (heartbeat, push token registration) - useClientActivity({ client, focusedAgentId }); + useClientActivity({ client, focusedAgentId, onAppResumed: handleAppResumed }); usePushTokenRegistration({ client, serverId }); // State for voice detection flags (will be set by RealtimeContext) @@ -458,6 +475,15 @@ export function SessionProvider({ return unsubscribe; }, [client]); + const wasConnectedRef = useRef(client.isConnected); + useEffect(() => { + const wasConnected = wasConnectedRef.current; + if (!wasConnected && connectionSnapshot.isConnected) { + bumpHistorySyncGeneration(serverId); + } + wasConnectedRef.current = connectionSnapshot.isConnected; + }, [serverId, connectionSnapshot.isConnected, bumpHistorySyncGeneration]); + useEffect(() => { updateSessionConnection(serverId, connectionSnapshot); }, [serverId, connectionSnapshot, updateSessionConnection]); @@ -994,6 +1020,7 @@ export function SessionProvider({ // Resolve the initialization promise (even for empty history) resolveInitDeferred(initKey); + markAgentHistorySynchronized(serverId, agentId); } ); @@ -1436,6 +1463,7 @@ export function SessionProvider({ clearDraftInput, notifyAgentAttention, applyAgentUpdatePayload, + markAgentHistorySynchronized, ]); const initializeAgent = useCallback( diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 1a0192f6f..d10d43e07 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -8,6 +8,7 @@ const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; interface ClientActivityOptions { client: DaemonClient; focusedAgentId: string | null; + onAppResumed?: (awayMs: number) => void; } /** @@ -16,9 +17,17 @@ interface ClientActivityOptions { * - App visibility tracking * - Records lastActivityAt only on real user activity (not on heartbeat) */ -export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void { +export function useClientActivity({ + client, + focusedAgentId, + onAppResumed, +}: ClientActivityOptions): void { const lastActivityAtRef = useRef(new Date()); const appVisibleRef = useRef(AppState.currentState === "active"); + const appVisibilityChangedAtRef = useRef(new Date()); + const backgroundedAtMsRef = useRef( + AppState.currentState === "active" ? null : Date.now() + ); const heartbeatIntervalRef = useRef | null>(null); const prevFocusedAgentIdRef = useRef(focusedAgentId); const lastImmediateHeartbeatAtRef = useRef(0); @@ -36,9 +45,34 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti focusedAgentId, lastActivityAt: lastActivityAtRef.current.toISOString(), appVisible: appVisibleRef.current, + appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(), }); }, [client, deviceType, focusedAgentId]); + const setAppVisible = useCallback( + (nextVisible: boolean) => { + const previousVisible = appVisibleRef.current; + if (previousVisible === nextVisible) { + return; + } + appVisibleRef.current = nextVisible; + appVisibilityChangedAtRef.current = new Date(); + + if (!nextVisible) { + backgroundedAtMsRef.current = Date.now(); + return; + } + + const backgroundedAt = backgroundedAtMsRef.current; + backgroundedAtMsRef.current = null; + if (backgroundedAt !== null) { + onAppResumed?.(Math.max(0, Date.now() - backgroundedAt)); + } + recordUserActivity(); + }, + [onAppResumed, recordUserActivity] + ); + const maybeSendImmediateHeartbeat = useCallback(() => { if (!client.isConnected) return; const now = Date.now(); @@ -52,16 +86,13 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti // Track app visibility useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { - appVisibleRef.current = nextState === "active"; - if (nextState === "active") { - recordUserActivity(); - } + setAppVisible(nextState === "active"); // Send immediately on visibility changes so the server can adapt streaming behavior. sendHeartbeat(); }); return () => subscription.remove(); - }, [recordUserActivity, sendHeartbeat]); + }, [sendHeartbeat, setAppVisible]); // Track user activity on web for accurate staleness. useEffect(() => { @@ -75,9 +106,8 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti const handleVisibilityChange = () => { const visible = document.visibilityState === "visible"; - appVisibleRef.current = visible; + setAppVisible(visible); if (visible) { - recordUserActivity(); maybeSendImmediateHeartbeat(); } }; @@ -97,7 +127,7 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti window.removeEventListener("wheel", handleUserActivity); window.removeEventListener("touchstart", handleUserActivity); }; - }, [maybeSendImmediateHeartbeat, recordUserActivity]); + }, [maybeSendImmediateHeartbeat, recordUserActivity, setAppVisible]); // Send heartbeat on focused agent change useEffect(() => { diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 557d3fc9e..abb9001ee 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -7,7 +7,6 @@ import { ScrollView, Platform, BackHandler, - AppState, } from "react-native"; import { useRouter } from "expo-router"; import * as Clipboard from "expo-clipboard"; @@ -333,6 +332,14 @@ function AgentScreenContent({ ? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false : false ); + const historySyncGeneration = useSessionStore( + (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 + ); + const agentHistorySyncGeneration = useSessionStore((state) => + resolvedAgentId + ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1 + : -1 + ); // Select raw pending permissions - filter with useMemo to avoid new Map on every render const allPendingPermissions = useSessionStore( @@ -409,6 +416,12 @@ function AgentScreenContent({ const initKey = getInitKey(serverId, resolvedAgentId); return Boolean(getInitDeferred(initKey)); }, [resolvedAgentId, isInitializing, serverId]); + const needsAuthoritativeSync = useMemo(() => { + if (!resolvedAgentId) { + return false; + } + return agentHistorySyncGeneration < historySyncGeneration; + }, [agentHistorySyncGeneration, historySyncGeneration, resolvedAgentId]); const optimisticStreamItems = useMemo(() => { if (!isPendingCreateForRoute || !pendingCreate) { @@ -439,6 +452,7 @@ function AgentScreenContent({ }, [optimisticStreamItems, streamItems]); const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0; + const shouldBlockForHistorySync = !shouldUseOptimisticStream && (needsAuthoritativeSync || isHistorySyncing); const placeholderAgent: Agent | null = useMemo(() => { if (!shouldUseOptimisticStream || !resolvedAgentId) { @@ -515,60 +529,20 @@ function AgentScreenContent({ return; } - // Skip if not connected - will re-run when connection is established if (!isConnected) { return; } + if (!needsAuthoritativeSync) { + return; + } - // ensureAgentIsInitialized handles deduplication via module-level promises map - // If already initialized or in-flight, returns resolved/pending promise immediately ensureAgentIsInitialized(resolvedAgentId).catch((error) => { console.warn("[AgentScreen] Agent initialization failed", { agentId: resolvedAgentId, error, }); }); - }, [resolvedAgentId, ensureAgentIsInitialized, isConnected]); - - // When the app comes back to the foreground, re-sync history for the focused agent. - // This covers cases where the OS/backgrounding caused us to miss stream events. - const lastAppStateRef = useRef(AppState.currentState); - const lastResumeSyncAtRef = useRef(0); - useEffect(() => { - if (!resolvedAgentId) { - return; - } - - const subscription = AppState.addEventListener("change", (nextState) => { - const prev = lastAppStateRef.current; - lastAppStateRef.current = nextState; - - if (nextState !== "active" || prev === "active") { - return; - } - if (!isConnected) { - return; - } - - const now = Date.now(); - // Avoid accidental double-syncs on rapid transitions. - if (now - lastResumeSyncAtRef.current < 2000) { - return; - } - lastResumeSyncAtRef.current = now; - - ensureAgentIsInitialized(resolvedAgentId).catch((error) => { - console.warn("[AgentScreen] Agent initialization failed on resume", { - agentId: resolvedAgentId, - error, - }); - }); - }); - - return () => { - subscription.remove(); - }; - }, [ensureAgentIsInitialized, isConnected, resolvedAgentId]); + }, [resolvedAgentId, ensureAgentIsInitialized, isConnected, needsAuthoritativeSync]); useEffect(() => { if (Platform.OS !== "web") { @@ -638,7 +612,7 @@ function AgentScreenContent({ const mainContent = ( - + {/* Header */} } - disabled={isInitializing} + disabled={isInitializing || shouldBlockForHistorySync} trailing={ isInitializing ? ( - - - + {shouldBlockForHistorySync ? ( + + + Loading agent... + + ) : ( + + + + )} {/* Agent Input Area */} - {agent && resolvedAgentId && ( - + {agent && resolvedAgentId && !shouldBlockForHistorySync && ( + )} diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index fc8de8bfb..795235712 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -184,6 +184,8 @@ export interface SessionState { // Stream state (head/tail model) agentStreamTail: Map; agentStreamHead: Map; + historySyncGeneration: number; + agentHistorySyncGeneration: Map; // Initializing agents (used for UI loading state) initializingAgents: Map; @@ -236,6 +238,8 @@ interface SessionStoreActions { setAgentStreamTail: (serverId: string, state: Map | ((prev: Map) => Map)) => void; setAgentStreamHead: (serverId: string, state: Map | ((prev: Map) => Map)) => void; clearAgentStreamHead: (serverId: string, agentId: string) => void; + bumpHistorySyncGeneration: (serverId: string) => void; + markAgentHistorySynchronized: (serverId: string, agentId: string) => void; // Initializing agents setInitializingAgents: (serverId: string, state: Map | ((prev: Map) => Map)) => void; @@ -318,6 +322,8 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), + historySyncGeneration: 0, + agentHistorySyncGeneration: new Map(), initializingAgents: new Map(), agents: new Map(), pendingPermissions: new Map(), @@ -599,6 +605,59 @@ export const useSessionStore = create()( }); }, + bumpHistorySyncGeneration: (serverId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const nextGeneration = session.historySyncGeneration + 1; + logSessionStoreUpdate("bumpHistorySyncGeneration", serverId, { + generation: nextGeneration, + }); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + historySyncGeneration: nextGeneration, + }, + }, + }; + }); + }, + + markAgentHistorySynchronized: (serverId, agentId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const currentGeneration = session.historySyncGeneration; + const previousGeneration = session.agentHistorySyncGeneration.get(agentId); + if (previousGeneration === currentGeneration) { + return prev; + } + const nextMap = new Map(session.agentHistorySyncGeneration); + nextMap.set(agentId, currentGeneration); + logSessionStoreUpdate("markAgentHistorySynchronized", serverId, { + agentId, + generation: currentGeneration, + }); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentHistorySyncGeneration: nextMap, + }, + }, + }; + }); + }, + // Initializing agents setInitializingAgents: (serverId, state) => { set((prev) => { diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index b94df1d38..322afb169 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -772,6 +772,7 @@ export class DaemonClient { focusedAgentId: string | null; lastActivityAt: string; appVisible: boolean; + appVisibilityChangedAt?: string; }): void { this.sendSessionMessage({ type: "client_heartbeat", @@ -779,6 +780,7 @@ export class DaemonClient { focusedAgentId: params.focusedAgentId, lastActivityAt: params.lastActivityAt, appVisible: params.appVisible, + appVisibilityChangedAt: params.appVisibilityChangedAt, }); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4afe08043..0d3cd9913 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -375,7 +375,9 @@ export class Session { focusedAgentId: string | null; lastActivityAt: Date; appVisible: boolean; + appVisibilityChangedAt: Date; } | null = null; + private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; private terminalSubscriptions: Map void> = new Map(); private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; @@ -474,6 +476,7 @@ export class Session { focusedAgentId: string | null; lastActivityAt: Date; appVisible: boolean; + appVisibilityChangedAt: Date; } | null { return this.clientActivity; } @@ -646,21 +649,24 @@ export class Session { } // Reduce bandwidth/CPU on mobile: only forward high-frequency agent stream events - // while the app is visible and the user is focused on that agent. + // for the focused agent, with a short grace window while backgrounded. // // History catch-up is handled via explicit `initialize_agent_request` which emits a // batched `agent_stream_snapshot`. const activity = this.clientActivity; if (activity?.deviceType === "mobile") { - if (!activity.appVisible) { - return; - } if (!activity.focusedAgentId) { return; } if (activity.focusedAgentId !== event.agentId) { return; } + if (!activity.appVisible) { + const hiddenForMs = Date.now() - activity.appVisibilityChangedAt.getTime(); + if (hiddenForMs >= this.MOBILE_BACKGROUND_STREAM_GRACE_MS) { + return; + } + } } const serializedEvent = serializeAgentStreamEvent(event.event); @@ -2643,12 +2649,17 @@ export class Session { focusedAgentId: string | null; lastActivityAt: string; appVisible: boolean; + appVisibilityChangedAt?: string; }): void { + const appVisibilityChangedAt = msg.appVisibilityChangedAt + ? new Date(msg.appVisibilityChangedAt) + : new Date(msg.lastActivityAt); this.clientActivity = { deviceType: msg.deviceType, focusedAgentId: msg.focusedAgentId, lastActivityAt: new Date(msg.lastActivityAt), appVisible: msg.appVisible, + appVisibilityChangedAt, }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 39ade982d..411bc9d19 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -864,6 +864,7 @@ export const ClientHeartbeatMessageSchema = z.object({ focusedAgentId: z.string().nullable(), lastActivityAt: z.string(), appVisible: z.boolean(), + appVisibilityChangedAt: z.string().optional(), }); export const PingMessageSchema = z.object({