Stabilize agent history sync after app backgrounding

This commit is contained in:
Mohamed Boudra
2026-02-09 15:44:29 +07:00
parent 8403ece4ed
commit 4fa8859d14
8 changed files with 198 additions and 105 deletions

View File

@@ -85,11 +85,6 @@ export interface AgentStreamViewProps {
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
/**
* 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<FlatList<StreamItem>>(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
? <SyncingIndicator />
: showWorkingIndicator
? <WorkingIndicator />
: null;
const leftContent = showWorkingIndicator ? <WorkingIndicator /> : null;
return (
<View style={stylesheet.contentWrapper}>
@@ -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 (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={stylesheet.emptyStateText}>Catching up</Text>
<Text style={stylesheet.emptyStateText}>Working</Text>
</View>
);
}
@@ -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 (
<View style={stylesheet.syncingIndicator}>
<ActivityIndicator size="small" color={stylesheet.syncingIndicatorText.color} />
<Text style={stylesheet.syncingIndicatorText}>Catching up</Text>
</View>
);
}
// Permission Request Card Component
function PermissionRequestCard({
permission,

View File

@@ -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(

View File

@@ -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<Date>(new Date());
const appVisibleRef = useRef(AppState.currentState === "active");
const appVisibilityChangedAtRef = useRef<Date>(new Date());
const backgroundedAtMsRef = useRef<number | null>(
AppState.currentState === "active" ? null : Date.now()
);
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(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(() => {

View File

@@ -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<StreamItem[]>(() => {
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 = (
<View style={styles.outerContainer}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing || shouldBlockForHistorySync}>
<View style={styles.container}>
{/* Header */}
<MenuHeader
@@ -815,7 +789,7 @@ function AgentScreenContent({
<DropdownMenuItem
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
disabled={isInitializing}
disabled={isInitializing || shouldBlockForHistorySync}
trailing={
isInitializing ? (
<ActivityIndicator
@@ -837,25 +811,36 @@ function AgentScreenContent({
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
<AgentStreamView
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={
shouldUseOptimisticStream ? mergedStreamItems : streamItems
}
pendingPermissions={pendingPermissions}
isSyncingHistory={isHistorySyncing && !shouldUseOptimisticStream}
/>
</ReanimatedAnimated.View>
{shouldBlockForHistorySync ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.loadingText}>Loading agent...</Text>
</View>
) : (
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
<AgentStreamView
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={
shouldUseOptimisticStream ? mergedStreamItems : streamItems
}
pendingPermissions={pendingPermissions}
/>
</ReanimatedAnimated.View>
)}
</View>
{/* Agent Input Area */}
{agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
{agent && resolvedAgentId && !shouldBlockForHistorySync && (
<AgentInputArea
agentId={resolvedAgentId}
serverId={serverId}
autoFocus
onAddImages={handleAddImagesCallback}
/>
)}
</View>

View File

@@ -184,6 +184,8 @@ export interface SessionState {
// Stream state (head/tail model)
agentStreamTail: Map<string, StreamItem[]>;
agentStreamHead: Map<string, StreamItem[]>;
historySyncGeneration: number;
agentHistorySyncGeneration: Map<string, number>;
// Initializing agents (used for UI loading state)
initializingAgents: Map<string, boolean>;
@@ -236,6 +238,8 @@ interface SessionStoreActions {
setAgentStreamTail: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
setAgentStreamHead: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
clearAgentStreamHead: (serverId: string, agentId: string) => void;
bumpHistorySyncGeneration: (serverId: string) => void;
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
// Initializing agents
setInitializingAgents: (serverId: string, state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>)) => 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<SessionStore>()(
});
},
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) => {

View File

@@ -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,
});
}

View File

@@ -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<string, () => 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,
};
}

View File

@@ -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({