From 33cf6cf3378f732cf5f5d53841989fe8153dcb7d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 22 Aug 2026 20:56:53 +0200 Subject: [PATCH] perf(app): defer replica persistence until user inactivity Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal. --- packages/app/src/app/_layout.tsx | 11 +++++- packages/app/src/contexts/session-context.tsx | 8 +++- .../src/hooks/client-activity-tracker.test.ts | 14 +++++++ .../app/src/hooks/client-activity-tracker.ts | 4 +- packages/app/src/hooks/use-client-activity.ts | 3 ++ packages/app/src/runtime/host-runtime.ts | 4 ++ .../src/runtime/replica-cache/index.test.ts | 33 +++++++++++++++- .../app/src/runtime/replica-cache/index.ts | 39 +++++++++++++------ 8 files changed, 100 insertions(+), 16 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 53ae0c6be2..beb17ac702 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -953,9 +953,18 @@ function RootProviders({ children }: { children: ReactNode }) { ); } +function recordUserActivity(): void { + getHostRuntimeStore().recordUserActivity(); +} + function RootAppTree() { return ( - + diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 6cf80708e9..fad663a53a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -408,7 +408,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); // Client activity tracking (heartbeat, push token registration) - useClientActivity({ client, focusedAgentId, focusedTerminalId, onAppResumed: handleAppResumed }); + useClientActivity({ + client, + focusedAgentId, + focusedTerminalId, + onUserActivity: () => getHostRuntimeStore().recordUserActivity(), + onAppResumed: handleAppResumed, + }); useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]); const notifyAgentAttention = useCallback( diff --git a/packages/app/src/hooks/client-activity-tracker.test.ts b/packages/app/src/hooks/client-activity-tracker.test.ts index db6f2e5476..a919258cbd 100644 --- a/packages/app/src/hooks/client-activity-tracker.test.ts +++ b/packages/app/src/hooks/client-activity-tracker.test.ts @@ -68,12 +68,26 @@ function buildTracker( initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null, initialAppVisible: overrides.initialAppVisible ?? true, now: clock.now, + onUserActivity: overrides.onUserActivity ?? (() => undefined), onAppResumed: overrides.onAppResumed, }); return { tracker, client, clock }; } describe("client activity tracker", () => { + it("notifies local consumers about user activity", () => { + let notifications = 0; + const { tracker } = buildTracker({ + onUserActivity: () => { + notifications += 1; + }, + }); + + tracker.recordUserActivity(); + + expect(notifications).toBe(1); + }); + it("includes the latest user-activity time in the next heartbeat", () => { const { tracker, client, clock } = buildTracker(); diff --git a/packages/app/src/hooks/client-activity-tracker.ts b/packages/app/src/hooks/client-activity-tracker.ts index d00b92c111..c33e83246a 100644 --- a/packages/app/src/hooks/client-activity-tracker.ts +++ b/packages/app/src/hooks/client-activity-tracker.ts @@ -23,6 +23,7 @@ export interface ClientActivityTrackerInput { initialFocusedTerminalId: string | null; initialAppVisible: boolean; now: () => number; + onUserActivity: () => void; onAppResumed?: (awayMs: number) => void; } @@ -39,7 +40,7 @@ export interface ClientActivityTracker { export function createClientActivityTracker( input: ClientActivityTrackerInput, ): ClientActivityTracker { - const { client, deviceType, now, onAppResumed } = input; + const { client, deviceType, now, onUserActivity, onAppResumed } = input; let lastActivityAtMs = now(); let appVisible = input.initialAppVisible; let appVisibilityChangedAtMs = now(); @@ -62,6 +63,7 @@ export function createClientActivityTracker( function recordUserActivity(): void { lastActivityAtMs = now(); + onUserActivity(); } function maybeSendImmediateHeartbeat(): void { diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 65d8bf4067..2307ec5cb3 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -15,6 +15,7 @@ interface ClientActivityOptions { client: DaemonClient; focusedAgentId: string | null; focusedTerminalId: string | null; + onUserActivity: () => void; onAppResumed?: (awayMs: number) => void; } @@ -28,6 +29,7 @@ export function useClientActivity({ client, focusedAgentId, focusedTerminalId, + onUserActivity, onAppResumed, }: ClientActivityOptions): void { const onAppResumedRef = useRef(onAppResumed); @@ -42,6 +44,7 @@ export function useClientActivity({ initialFocusedTerminalId: focusedTerminalId, initialAppVisible: AppState.currentState === "active", now: () => Date.now(), + onUserActivity, onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs), }); } diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 4d27f200c4..4043b02edb 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1390,6 +1390,10 @@ export class HostRuntimeStore { return this.hostRegistryStatus; } + recordUserActivity(): void { + this.replicaCache.recordUserActivity(); + } + subscribeHostList(listener: () => void): () => void { this.hostListListeners.add(listener); return () => { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 3d27070fa1..73cad3a9a2 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -209,6 +209,35 @@ afterEach(() => { }); describe("ReplicaCache", () => { + it("persists after user inactivity even while replica changes continue", async () => { + vi.useFakeTimers(); + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage); + cache.setHosts([SERVER_ID]); + seedSession(); + await cache.flush(); + cache.start(); + const writesBeforeChange = storage.writes; + + useSessionStore + .getState() + .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("first", "First")]]])); + await vi.advanceTimersByTimeAsync(4_000); + cache.recordUserActivity(); + await vi.advanceTimersByTimeAsync(1_000); + useSessionStore + .getState() + .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("second", "Second")]]])); + await vi.advanceTimersByTimeAsync(3_999); + + expect(storage.writes).toBe(writesBeforeChange); + + await vi.advanceTimersByTimeAsync(1); + + expect(storage.writes).toBe(writesBeforeChange + 1); + cache.setHosts([]); + }); + it("persists focused replica changes without writing transient stream head updates", async () => { vi.useFakeTimers(); const storage = new MemoryStorage(); @@ -222,14 +251,14 @@ describe("ReplicaCache", () => { useSessionStore .getState() .setAgentStreamHead(SERVER_ID, new Map([["agent-1", [message("live", "Streaming")]]])); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(5_000); expect(storage.writes).toBe(writesBeforeStream); useSessionStore .getState() .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("saved", "Committed")]]])); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(5_000); expect(storage.writes).toBe(writesBeforeStream + 1); cache.setHosts([]); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index ddbaef8484..a22ef07abc 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,4 +1,5 @@ import { Buffer } from "buffer"; +import { Keyboard } from "react-native"; import { z } from "zod"; import { AgentStatusSchema, @@ -23,7 +24,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; const STORAGE_KEY = "@paseo:replica-cache"; const CACHE_VERSION = 6; -const PERSIST_DELAY_MS = 750; +const PERSIST_AFTER_USER_INACTIVITY_MS = 5_000; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); @@ -805,17 +806,19 @@ export class ReplicaCache { } } + recordUserActivity(): void { + if (!this.persistTimer) return; + clearTimeout(this.persistTimer); + this.persistTimer = null; + this.schedulePersist(); + } + start(): void { if (this.unsubscribe) return; const changedBeforeSubscription = this.captureSessions(); - this.unsubscribe = useSessionStore.subscribe((state) => { + this.unsubscribe = useSessionStore.subscribe(() => { if (this.activeServerIds.size === 0) return; - let changed = false; - for (const serverId of this.activeServerIds) { - const session = state.sessions[serverId]; - if (session && this.captureHost(serverId, session)) changed = true; - } - if (changed) this.schedulePersist(); + this.schedulePersist(); }); if (changedBeforeSubscription || this.needsPersist) this.schedulePersist(); } @@ -856,15 +859,29 @@ export class ReplicaCache { } this.storedHosts.delete(serverId); this.storedHosts.set(serverId, { ...stored, directorySync: checkpoint }); + this.needsPersist = true; this.schedulePersist(); } async flush(): Promise { + await this.persist(false); + } + + private async flushPending(): Promise { + if (Keyboard.isVisible()) { + this.schedulePersist(); + return; + } + await this.persist(true); + } + + private async persist(skipUnchanged: boolean): Promise { if (this.persistTimer) { clearTimeout(this.persistTimer); this.persistTimer = null; } - this.captureSessions(); + const changed = this.captureSessions(); + if (skipUnchanged && !changed && !this.needsPersist) return; const bounded = this.buildBoundedPayload(); this.needsPersist = false; if (!bounded) { @@ -952,7 +969,7 @@ export class ReplicaCache { if (this.persistTimer) return; this.persistTimer = setTimeout(() => { this.persistTimer = null; - void this.flush(); - }, PERSIST_DELAY_MS); + void this.flushPending(); + }, PERSIST_AFTER_USER_INACTIVITY_MS); } }