From 8c726361d43490a752fd717a5f095722a3eae7dd Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 14 Sep 2026 01:25:49 -0700 Subject: [PATCH 1/3] perf(app): throttle workspace layout writes and skip no-op persists Zustand persist calls setItem after every set, even when the updater returns the same state. The layout store re-normalized every workspace tree, re-validated the envelope, stringified it, and wrote to AsyncStorage on each agent status change because reconcileTabs runs from an effect keyed on agent visibility. The draft store's throttled writer moves to storage/ as a generic throttled persist storage, gains a referential short-circuit for a value whose partialized state was already accepted, and now backs both the draft and layout stores. The layout store's partialize reuses its previous projection while every persisted slice is the same reference, and the store is flushed alongside drafts when the app leaves the foreground. Before: 50 no-op reconciles produced 50 writes. After: 0 writes; 50 real sets inside one interval produce 2 writes (leading and trailing). Fixes #4814 Co-Authored-By: Claude Fable 5.1 --- packages/app/src/app/_layout.tsx | 2 + .../storage/throttled-persist-storage.test.ts | 144 +++++++++++++++ .../throttled-persist-storage.ts} | 47 ++--- packages/app/src/stores/draft-store/index.ts | 8 +- .../stores/draft-store/persistence.test.ts | 109 ------------ .../src/stores/workspace-layout-store.test.ts | 165 +++++++++++++++--- .../app/src/stores/workspace-layout-store.ts | 108 ++++++++---- 7 files changed, 391 insertions(+), 192 deletions(-) create mode 100644 packages/app/src/storage/throttled-persist-storage.test.ts rename packages/app/src/{stores/draft-store/persistence.ts => storage/throttled-persist-storage.ts} (59%) delete mode 100644 packages/app/src/stores/draft-store/persistence.test.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 2d6525b27f..e166d23a4c 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -114,6 +114,7 @@ import { import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; +import { flushWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme"; import { useSessionStore } from "@/stores/session-store"; import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher"; @@ -994,6 +995,7 @@ export default function RootLayout() { const subscription = AppState.addEventListener("change", (nextState) => { if (nextState !== "active") { void flushDraftPersistStorage(); + void flushWorkspaceLayoutStore(); } }); return () => subscription.remove(); diff --git a/packages/app/src/storage/throttled-persist-storage.test.ts b/packages/app/src/storage/throttled-persist-storage.test.ts new file mode 100644 index 0000000000..4073f9e485 --- /dev/null +++ b/packages/app/src/storage/throttled-persist-storage.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import type { PersistStorage, StorageValue } from "zustand/middleware"; +import { + createThrottledPersistStorage, + type PersistenceScheduler, +} from "./throttled-persist-storage"; + +interface TextState { + text: string; +} + +const INTERVAL_MS = 200; + +function createThrottledPersistence() { + let nowMs = 0; + let saved: StorageValue | null = null; + let writes = 0; + let scheduled: { callback: () => void; dueAt: number } | null = null; + const storage: PersistStorage = { + getItem: () => saved, + setItem: (_name, value) => { + writes += 1; + saved = value; + }, + removeItem: () => { + saved = null; + }, + }; + const scheduler: PersistenceScheduler = { + now: () => nowMs, + schedule: (callback, delayMs) => (scheduled = { callback, dueAt: nowMs + delayMs }), + cancel: () => { + scheduled = null; + }, + }; + const throttled = createThrottledPersistStorage(storage, { intervalMs: INTERVAL_MS, scheduler }); + + return { + save(state: TextState) { + throttled.setItem("text", { state }); + }, + remove() { + throttled.removeItem("text"); + }, + flush() { + return throttled.flush(); + }, + advance(ms: number) { + nowMs += ms; + if (scheduled && scheduled.dueAt <= nowMs) { + const { callback } = scheduled; + scheduled = null; + callback(); + } + }, + text() { + return saved?.state.text ?? null; + }, + writes() { + return writes; + }, + }; +} + +describe("throttled persist storage", () => { + it("writes the first change and the latest change in each interval", () => { + const persistence = createThrottledPersistence(); + + persistence.save({ text: "a" }); + persistence.save({ text: "ab" }); + persistence.save({ text: "abc" }); + expect(persistence.text()).toBe("a"); + + persistence.advance(INTERVAL_MS - 1); + expect(persistence.text()).toBe("a"); + + persistence.advance(1); + expect(persistence.text()).toBe("abc"); + expect(persistence.writes()).toBe(2); + }); + + it("does not restore a pending value after storage is cleared", () => { + const persistence = createThrottledPersistence(); + + persistence.save({ text: "first checkpoint" }); + persistence.save({ text: "pending checkpoint" }); + persistence.remove(); + persistence.advance(INTERVAL_MS); + + expect(persistence.text()).toBeNull(); + }); + + it("continues writing the latest change across consecutive intervals", () => { + const persistence = createThrottledPersistence(); + + persistence.save({ text: "first" }); + persistence.save({ text: "first interval" }); + persistence.advance(INTERVAL_MS); + expect(persistence.text()).toBe("first interval"); + + persistence.save({ text: "second" }); + persistence.save({ text: "second interval" }); + persistence.advance(INTERVAL_MS); + expect(persistence.text()).toBe("second interval"); + }); + + it("flushes the latest pending change before the interval ends", async () => { + const persistence = createThrottledPersistence(); + + persistence.save({ text: "first checkpoint" }); + persistence.save({ text: "pending checkpoint" }); + await persistence.flush(); + + expect(persistence.text()).toBe("pending checkpoint"); + }); + + it("drops a value whose state is the one already accepted", () => { + const persistence = createThrottledPersistence(); + const state = { text: "same" }; + + persistence.save(state); + persistence.advance(INTERVAL_MS); + persistence.save(state); + persistence.save(state); + persistence.advance(INTERVAL_MS); + + expect(persistence.writes()).toBe(1); + + persistence.save({ text: "same" }); + expect(persistence.writes()).toBe(2); + }); + + it("writes the same state again after storage is cleared", () => { + const persistence = createThrottledPersistence(); + const state = { text: "kept" }; + + persistence.save(state); + persistence.remove(); + persistence.advance(INTERVAL_MS); + persistence.save(state); + + expect(persistence.text()).toBe("kept"); + }); +}); diff --git a/packages/app/src/stores/draft-store/persistence.ts b/packages/app/src/storage/throttled-persist-storage.ts similarity index 59% rename from packages/app/src/stores/draft-store/persistence.ts rename to packages/app/src/storage/throttled-persist-storage.ts index f56b671307..2cb5f82928 100644 --- a/packages/app/src/stores/draft-store/persistence.ts +++ b/packages/app/src/storage/throttled-persist-storage.ts @@ -1,16 +1,20 @@ -import type { PersistStorage } from "zustand/middleware"; +import type { PersistStorage, StorageValue } from "zustand/middleware"; -export const DRAFT_PERSIST_INTERVAL_MS = 200; export interface PersistenceScheduler { now: () => number; schedule: (callback: () => void, delayMs: number) => unknown; cancel: (handle: unknown) => void; } -export interface DraftPersistStorage extends PersistStorage { +export interface ThrottledPersistStorage extends PersistStorage { flush: () => Promise; } +interface ThrottledPersistOptions { + intervalMs: number; + scheduler?: PersistenceScheduler; +} + let nextSystemTimerId = 0; const systemTimers = new Map>(); const systemScheduler: PersistenceScheduler = { @@ -35,23 +39,19 @@ const systemScheduler: PersistenceScheduler = { }, }; -export function createDraftPersistStorage( +/** + * Writes the first change immediately and the latest change once per interval. Zustand's + * persist middleware calls setItem after every set, including ones that leave the state + * untouched, so a value whose partialized state is the reference already accepted is dropped + * before it reaches the schema or the backing store. + */ +export function createThrottledPersistStorage( storage: PersistStorage, - scheduler?: PersistenceScheduler, -): DraftPersistStorage; -export function createDraftPersistStorage( - storage: PersistStorage | undefined, - scheduler?: PersistenceScheduler, -): DraftPersistStorage | undefined; -export function createDraftPersistStorage( - storage: PersistStorage | undefined, - scheduler: PersistenceScheduler = systemScheduler, -): DraftPersistStorage | undefined { - if (!storage) { - return undefined; - } - - let pending: { name: string; value: Parameters[1] } | null = null; + options: ThrottledPersistOptions, +): ThrottledPersistStorage { + const scheduler = options.scheduler ?? systemScheduler; + let pending: { name: string; value: StorageValue } | null = null; + let accepted: StorageValue | null = null; let timer: unknown = null; let lastWriteAt = -Infinity; @@ -72,15 +72,19 @@ export function createDraftPersistStorage( try { await storage.setItem(write.name, write.value); } catch (error) { - console.warn("[DraftStore] Failed to persist draft checkpoint", error); + console.warn("[PersistStorage] Failed to persist checkpoint", { name: write.name, error }); } }; return { getItem: (name) => storage.getItem(name), setItem: (name, value) => { + if (accepted && accepted.state === value.state && accepted.version === value.version) { + return; + } + accepted = value; pending = { name, value }; - const delay = DRAFT_PERSIST_INTERVAL_MS - (scheduler.now() - lastWriteAt); + const delay = options.intervalMs - (scheduler.now() - lastWriteAt); if (delay <= 0) { return flush(); } @@ -91,6 +95,7 @@ export function createDraftPersistStorage( removeItem: (name) => { cancelTimer(); pending = null; + accepted = null; lastWriteAt = scheduler.now(); return storage.removeItem(name); }, diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 9836d3dfec..0879fae242 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -34,7 +34,7 @@ import { type MigrateLegacyImages, PersistedDraftStoreSchema, } from "./migration"; -import { createDraftPersistStorage } from "./persistence"; +import { createThrottledPersistStorage } from "@/storage/throttled-persist-storage"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DraftInput, DraftLifecycleState } from "./state"; @@ -65,12 +65,14 @@ interface DraftStoreRuntimeState { type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; let gcScheduled = false; -const draftPersistStorage = createDraftPersistStorage( +const DRAFT_PERSIST_INTERVAL_MS = 200; +const draftPersistStorage = createThrottledPersistStorage( createValidatedPersistStorage(AsyncStorage, PersistedDraftStoreSchema), + { intervalMs: DRAFT_PERSIST_INTERVAL_MS }, ); export function flushDraftPersistStorage(): Promise { - return draftPersistStorage?.flush() ?? Promise.resolve(); + return draftPersistStorage.flush(); } function createDraftRecord(input: { diff --git a/packages/app/src/stores/draft-store/persistence.test.ts b/packages/app/src/stores/draft-store/persistence.test.ts deleted file mode 100644 index bae660abc1..0000000000 --- a/packages/app/src/stores/draft-store/persistence.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { PersistStorage, StorageValue } from "zustand/middleware"; -import { - createDraftPersistStorage, - DRAFT_PERSIST_INTERVAL_MS, - type PersistenceScheduler, -} from "./persistence"; - -interface DraftState { - text: string; -} - -function createDraftPersistence() { - let nowMs = 0; - let saved: StorageValue | null = null; - let scheduled: { callback: () => void; dueAt: number } | null = null; - const storage: PersistStorage = { - getItem: () => saved, - setItem: (_name, value) => { - saved = value; - }, - removeItem: () => { - saved = null; - }, - }; - const scheduler: PersistenceScheduler = { - now: () => nowMs, - schedule: (callback, delayMs) => (scheduled = { callback, dueAt: nowMs + delayMs }), - cancel: () => { - scheduled = null; - }, - }; - const drafts = createDraftPersistStorage(storage, scheduler); - - return { - save(text: string) { - drafts.setItem("drafts", { state: { text } }); - }, - remove() { - drafts.removeItem("drafts"); - }, - flush() { - return drafts.flush(); - }, - advance(ms: number) { - nowMs += ms; - if (scheduled && scheduled.dueAt <= nowMs) { - const { callback } = scheduled; - scheduled = null; - callback(); - } - }, - text() { - return saved?.state.text ?? null; - }, - }; -} - -describe("draft persistence", () => { - it("checkpoints the first change and the latest change in each interval", () => { - const drafts = createDraftPersistence(); - - drafts.save("a"); - drafts.save("ab"); - drafts.save("abc"); - expect(drafts.text()).toBe("a"); - - drafts.advance(DRAFT_PERSIST_INTERVAL_MS - 1); - expect(drafts.text()).toBe("a"); - - drafts.advance(1); - expect(drafts.text()).toBe("abc"); - }); - - it("does not restore a pending draft after storage is cleared", () => { - const drafts = createDraftPersistence(); - - drafts.save("first checkpoint"); - drafts.save("pending checkpoint"); - drafts.remove(); - drafts.advance(DRAFT_PERSIST_INTERVAL_MS); - - expect(drafts.text()).toBeNull(); - }); - - it("continues checkpointing the latest change across consecutive intervals", () => { - const drafts = createDraftPersistence(); - - drafts.save("first"); - drafts.save("first interval"); - drafts.advance(DRAFT_PERSIST_INTERVAL_MS); - expect(drafts.text()).toBe("first interval"); - - drafts.save("second"); - drafts.save("second interval"); - drafts.advance(DRAFT_PERSIST_INTERVAL_MS); - expect(drafts.text()).toBe("second interval"); - }); - - it("flushes the latest pending change before the interval ends", async () => { - const drafts = createDraftPersistence(); - - drafts.save("first checkpoint"); - drafts.save("pending checkpoint"); - await drafts.flush(); - - expect(drafts.text()).toBe("pending checkpoint"); - }); -}); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index fc973f442d..30b2aced7f 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -20,6 +20,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync"; +import type { StateStorage } from "zustand/middleware"; +import type { PersistenceScheduler } from "@/storage/throttled-persist-storage"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { defaultChangesState, type ChangesState } from "@/panels/changes/state"; import { defaultFileState, type FileState } from "@/panels/file/state"; @@ -44,6 +46,7 @@ import { stripEphemeralTabsFromLayout, type SplitNode, type SplitPane, + type WorkspaceLayout, } from "@/stores/workspace-layout-store"; const SERVER_ID = "server-1"; @@ -77,7 +80,7 @@ function createDeterministicWorkspaceLayoutIds() { } const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds(); -const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds); +const workspaceLayoutStore = createWorkspaceLayoutStore({ ids: workspaceLayoutIds }); it("observes open chats across unmounted workspaces until their tabs close", () => { const store = createWorkspaceLayoutStore(workspaceLayoutIds); @@ -205,6 +208,51 @@ function createPane(input: { }; } +function createLayoutPersistence() { + let nowMs = 0; + let writes = 0; + let scheduled: { callback: () => void; dueAt: number } | null = null; + const values = new Map(); + const storage: StateStorage = { + getItem: (name) => values.get(name) ?? null, + setItem: (name, value) => { + writes += 1; + values.set(name, value); + }, + removeItem: (name) => { + values.delete(name); + }, + }; + const scheduler: PersistenceScheduler = { + now: () => nowMs, + schedule: (callback, delayMs) => (scheduled = { callback, dueAt: nowMs + delayMs }), + cancel: () => { + scheduled = null; + }, + }; + return { + storage, + scheduler, + writes: () => writes, + advance(ms: number) { + nowMs += ms; + if (scheduled && scheduled.dueAt <= nowMs) { + const { callback } = scheduled; + scheduled = null; + callback(); + } + }, + storedAgentTabCount(workspaceKey: string): number { + const raw = values.get("workspace-layout-state") ?? "{}"; + const parsed = JSON.parse(raw) as { + state: { layoutByWorkspace: Record }; + }; + const tabs = collectAllTabs(parsed.state.layoutByWorkspace[workspaceKey].root); + return tabs.filter((tab) => tab.target.kind === "agent").length; + }, + }; +} + function createWorkspaceKey(): string { const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, @@ -265,7 +313,7 @@ describe("workspace-layout-store helpers", () => { version: 1, }), ); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); const restoredTabs = collectAllTabs(restored.getState().layoutByWorkspace.workspace.root); @@ -516,7 +564,7 @@ describe("workspace-layout-store version 2 migration", () => { "replaces Explorer once and preserves persisted side content when upgrading from %s", async (source) => { await persistVersionOneLayout(source); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); @@ -532,7 +580,7 @@ describe("workspace-layout-store version 2 migration", () => { it("reuses an ordinary side pane already created by v0.6", async () => { await persistVersionOneLayout("v0.6", { withSidePane: true }); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); @@ -544,7 +592,7 @@ describe("workspace-layout-store version 2 migration", () => { it("writes version 2 preserving the existing Explorer key", async () => { await persistVersionOneLayout("v0.6"); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); @@ -568,7 +616,7 @@ describe("workspace-layout-store version 2 migration", () => { it("does not replace Explorer again when the migrated version 2 layout reloads", async () => { await persistVersionOneLayout("v0.6"); - const migrated = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const migrated = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await migrated.persist.rehydrate(); expectMigratedLayout(migrated); const explorerPaneId = migrated.getState().explorerSidebarPaneIdByWorkspace[workspaceKey]; @@ -583,11 +631,8 @@ describe("workspace-layout-store version 2 migration", () => { }); expect(addedTabId).toBeTruthy(); - await vi.waitFor(async () => { - const persisted = JSON.parse((await AsyncStorage.getItem("workspace-layout-state")) ?? "{}"); - expect(persisted.version).toBe(2); - }); - const reloaded = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await migrated.flushPersistence(); + const reloaded = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await reloaded.persist.rehydrate(); const reloadedState = reloaded.getState(); @@ -1299,7 +1344,7 @@ describe("workspace-layout-store actions", () => { version: 1, }), ); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); @@ -1332,7 +1377,7 @@ describe("workspace-layout-store actions", () => { version: 1, }), ); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); @@ -1344,7 +1389,7 @@ describe("workspace-layout-store actions", () => { it("persists first-class pane targets, visibility, and focus", async () => { await AsyncStorage.removeItem("workspace-layout-state"); const workspaceKey = createWorkspaceKey(); - const source = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const source = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await source.persist.rehydrate(); source.getState().openTab({ @@ -1370,11 +1415,9 @@ describe("workspace-layout-store actions", () => { }); source.getState().hideExplorerSidebar(workspaceKey); - await vi.waitFor(async () => { - expect(await AsyncStorage.getItem("workspace-layout-state")).not.toBeNull(); - }); + await source.flushPersistence(); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); const state = restored.getState(); const layout = state.layoutByWorkspace[workspaceKey]; @@ -1395,7 +1438,7 @@ describe("workspace-layout-store actions", () => { it("persists and rehydrates independent Changes state through validated storage", async () => { await AsyncStorage.removeItem("workspace-layout-state"); const workspaceKey = createWorkspaceKey(); - const source = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const source = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await source.persist.rehydrate(); const first = source .getState() @@ -1419,14 +1462,12 @@ describe("workspace-layout-store actions", () => { source.getState().setTabState(workspaceKey, first, firstState); source.getState().setTabState(workspaceKey, second, secondState); - await vi.waitFor(async () => { - const persisted = await AsyncStorage.getItem("workspace-layout-state"); - expect(persisted).not.toBeNull(); - const root = JSON.parse(persisted ?? "{}").state.layoutByWorkspace[workspaceKey].root; - expect(collectTabIds(root)).toEqual([first, second, "files", "changes_tree"]); - }); + await source.flushPersistence(); + const persisted = await AsyncStorage.getItem("workspace-layout-state"); + const root = JSON.parse(persisted ?? "{}").state.layoutByWorkspace[workspaceKey].root; + expect(collectTabIds(root)).toEqual([first, second, "files", "changes_tree"]); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); const tabs = collectAllTabs(restored.getState().layoutByWorkspace[workspaceKey].root); expect(tabs.find((tab) => tab.tabId === first)?.state).toEqual(firstState); @@ -1956,7 +1997,7 @@ describe("workspace-layout-store actions", () => { version: 1, }), ); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); @@ -3974,7 +4015,8 @@ describe("workspace-layout-store actions", () => { intent: "reveal", pin: true, }); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await workspaceLayoutStore.flushPersistence(); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); restored.getState().reconcileTabs(workspaceKey, { agentsHydrated: true, @@ -4419,6 +4461,71 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "main")?.tabIds).toEqual([agentTabId]); expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); }); + + it("does not write the layout when reconciliation is a no-op", async () => { + const persistence = createLayoutPersistence(); + const workspaceKey = createWorkspaceKey(); + const store = createWorkspaceLayoutStore({ + ids: createDeterministicWorkspaceLayoutIds(), + storage: persistence.storage, + scheduler: persistence.scheduler, + }); + await store.persist.rehydrate(); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + await store.flushPersistence(); + const writesBefore = persistence.writes(); + const snapshot = { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], + knownTerminalIds: [], + standaloneTerminalIds: [], + }; + const layoutBefore = store.getState().layoutByWorkspace[workspaceKey]; + + for (let index = 0; index < 50; index += 1) { + store.getState().reconcileTabs(workspaceKey, snapshot); + } + persistence.advance(1_000); + await store.flushPersistence(); + + expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore); + expect(persistence.writes()).toBe(writesBefore); + }); + + it("coalesces a burst of layout changes into one write per interval", async () => { + const persistence = createLayoutPersistence(); + const workspaceKey = createWorkspaceKey(); + const store = createWorkspaceLayoutStore({ + ids: createDeterministicWorkspaceLayoutIds(), + storage: persistence.storage, + scheduler: persistence.scheduler, + }); + await store.persist.rehydrate(); + + for (let index = 0; index < 50; index += 1) { + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: `agent-${index}` }, + intent: "reveal", + }); + } + expect(persistence.writes()).toBe(1); + expect(persistence.storedAgentTabCount(workspaceKey)).toBe(1); + + persistence.advance(199); + expect(persistence.writes()).toBe(1); + persistence.advance(1); + await store.flushPersistence(); + + expect(persistence.writes()).toBe(2); + expect(persistence.storedAgentTabCount(workspaceKey)).toBe(50); + }); }); it("persists the once-only PR add after closing, and clears it when purging the workspace", async () => { @@ -4448,7 +4555,7 @@ describe("compact workspace sheet", () => { const fileTarget = { kind: "file" as const, path: "src/index.ts" }; function createCompactStore() { - const store = createWorkspaceLayoutStore(workspaceLayoutIds); + const store = createWorkspaceLayoutStore({ ids: workspaceLayoutIds }); store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} }); store.getState().setLayoutMode("compact"); store.getState().openTab({ diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index ba186b655a..3b0a530b54 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,7 +1,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { persist, type StateStorage } from "zustand/middleware"; import type { z } from "zod"; import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; @@ -65,6 +65,10 @@ import { } from "@/workspace-tabs/identity"; import { isSheetTabModified, resolveCompactSheetPolicy } from "@/workspace-tabs/compact-sheet"; import { panelResourceKey, type CompactSheetPolicy } from "@/panels/panel-manifest"; +import { + createThrottledPersistStorage, + type PersistenceScheduler, +} from "@/storage/throttled-persist-storage"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations"; @@ -222,6 +226,8 @@ const MAX_TREE_DEPTH = 5; const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440; const WORKSPACE_LAYOUT_PERSIST_VERSION = 2; +const WORKSPACE_LAYOUT_PERSIST_INTERVAL_MS = 200; +type WorkspaceLayoutPersistedState = z.infer; function convertLegacyExplorerSidebarRatios( ratiosByWorkspace: Record, @@ -937,10 +943,70 @@ function createExplorerSidebarPane( return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null; } -export function createWorkspaceLayoutStore( - ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds, -) { - return create()( +const PERSISTED_SLICES = [ + "layoutByWorkspace", + "pinnedAgentIdsByWorkspace", + "splitSizesByWorkspace", + "explorerSidebarWidthByWorkspace", + "explorerSidebarPaneIdByWorkspace", + "sidePaneIdByWorkspace", + "pullRequestTabAutoOpenedByWorkspace", +] as const; + +// Zustand persist re-runs partialize after every set, including ones that return the state +// untouched. Reusing the previous projection while every persisted slice is the same +// reference lets the throttled storage drop those writes without inspecting the layout. +function createPersistedStateProjection() { + let previous: { source: WorkspaceLayoutStore; state: WorkspaceLayoutPersistedState } | null = + null; + return (source: WorkspaceLayoutStore): WorkspaceLayoutPersistedState => { + const last = previous; + if (last && PERSISTED_SLICES.every((slice) => last.source[slice] === source[slice])) { + return last.state; + } + const layoutByWorkspace: Record = {}; + for (const key in source.layoutByWorkspace) { + // Strip ephemeral (commit diff) tabs before persisting so they are + // dropped on reload rather than restored pointing at a rebased SHA. + layoutByWorkspace[key] = stripEphemeralTabsFromLayout( + normalizeLayout(source.layoutByWorkspace[key]), + ); + } + const state: WorkspaceLayoutPersistedState = { + layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(source.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [ + key, + Array.from(agentIds), + ]), + ), + splitSizesByWorkspace: source.splitSizesByWorkspace, + explorerSidebarWidthByWorkspace: source.explorerSidebarWidthByWorkspace, + explorerPaneIdByWorkspace: source.explorerSidebarPaneIdByWorkspace, + sidePaneIdByWorkspace: source.sidePaneIdByWorkspace, + pullRequestTabAutoOpenedByWorkspace: source.pullRequestTabAutoOpenedByWorkspace, + }; + previous = { source, state }; + return state; + }; +} + +interface WorkspaceLayoutStoreOptions { + ids?: WorkspaceLayoutIdSource; + storage?: StateStorage; + scheduler?: PersistenceScheduler; +} + +export function createWorkspaceLayoutStore(options: WorkspaceLayoutStoreOptions = {}) { + const ids = options.ids ?? defaultWorkspaceLayoutIds; + const persistStorage = createThrottledPersistStorage( + createValidatedPersistStorage( + options.storage ?? AsyncStorage, + WorkspaceLayoutPersistedStateSchema, + ), + { intervalMs: WORKSPACE_LAYOUT_PERSIST_INTERVAL_MS, scheduler: options.scheduler }, + ); + const store = create()( persist( (set, get) => ({ autoOpenPullRequestTab: (workspaceKey, resolvePlacement) => { @@ -2114,33 +2180,10 @@ export function createWorkspaceLayoutStore( { name: "workspace-layout-state", version: WORKSPACE_LAYOUT_PERSIST_VERSION, - storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema), + storage: persistStorage, migrate: (persistedState, version) => migrateWorkspaceLayoutPersistedState(persistedState, version, ids), - partialize: (state) => { - const layoutByWorkspace: Record = {}; - for (const key in state.layoutByWorkspace) { - // Strip ephemeral (commit diff) tabs before persisting so they are - // dropped on reload rather than restored pointing at a rebased SHA. - layoutByWorkspace[key] = stripEphemeralTabsFromLayout( - normalizeLayout(state.layoutByWorkspace[key]), - ); - } - return { - layoutByWorkspace, - pinnedAgentIdsByWorkspace: Object.fromEntries( - Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [ - key, - Array.from(agentIds), - ]), - ), - splitSizesByWorkspace: state.splitSizesByWorkspace, - explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace, - explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace, - sidePaneIdByWorkspace: state.sidePaneIdByWorkspace, - pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace, - }; - }, + partialize: createPersistedStateProjection(), merge: (persistedState, currentState) => { const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState); if (!result.success) { @@ -2198,6 +2241,7 @@ export function createWorkspaceLayoutStore( }, ), ); + return Object.assign(store, { flushPersistence: () => persistStorage.flush() }); } export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); @@ -2250,6 +2294,10 @@ export function observeOpenWorkspaceAgentIds( return unsubscribe; } +export function flushWorkspaceLayoutStore(): Promise { + return useWorkspaceLayoutStore.flushPersistence(); +} + export function useWorkspaceLayoutStoreHydrated(): boolean { const [hasHydrated, setHasHydrated] = useState(() => useWorkspaceLayoutStore.persist.hasHydrated(), From f6b33151d12ccac1da906ba88058eb27548eb777 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 18 Sep 2026 18:15:03 -0700 Subject: [PATCH 2/3] test(app): build layout stores with options and flush before rehydrating Six call sites still passed the id factories positionally, which this branch replaced with an options object, so those stores silently used default ids. The once-only PR test also rehydrated a second store before the throttled write landed; flush the source first. Co-Authored-By: Claude Opus 5 --- .../app/src/stores/workspace-layout-store.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 30b2aced7f..3121819990 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -83,7 +83,7 @@ const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds(); const workspaceLayoutStore = createWorkspaceLayoutStore({ ids: workspaceLayoutIds }); it("observes open chats across unmounted workspaces until their tabs close", () => { - const store = createWorkspaceLayoutStore(workspaceLayoutIds); + const store = createWorkspaceLayoutStore({ ids: workspaceLayoutIds }); store.setState({ layoutByWorkspace: {} }); const received: string[][] = []; const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store); @@ -116,7 +116,7 @@ it("observes open chats across unmounted workspaces until their tabs close", () }); it("feeds restored layout to timeline sync as a release signal, not a subscription source", async () => { - const store = createWorkspaceLayoutStore(workspaceLayoutIds); + const store = createWorkspaceLayoutStore({ ids: workspaceLayoutIds }); store.setState({ layoutByWorkspace: {} }); // Launch: layout rehydrates tabs the user opened in earlier sessions. const restored = store.getState().openTab({ @@ -1277,7 +1277,7 @@ describe("workspace-layout-store actions", () => { }, }), ); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); const state = restored.getState(); const layout = state.layoutByWorkspace[workspaceKey]; @@ -1324,7 +1324,7 @@ describe("workspace-layout-store actions", () => { }, }), ); - const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + const restored = createWorkspaceLayoutStore({ ids: createDeterministicWorkspaceLayoutIds() }); await restored.persist.rehydrate(); const layout = restored.getState().layoutByWorkspace[workspaceKey]; expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["pane_saved_ordinary"]); @@ -4530,13 +4530,14 @@ describe("workspace-layout-store actions", () => { it("persists the once-only PR add after closing, and clears it when purging the workspace", async () => { await AsyncStorage.removeItem("workspace-layout-state"); - const source = createWorkspaceLayoutStore(workspaceLayoutIds); + const source = createWorkspaceLayoutStore({ ids: workspaceLayoutIds }); await source.persist.rehydrate(); const workspaceKey = "server-1:pr-once"; const placement = () => ({ placement: { mode: "prefer" as const, paneId: "explorer" } }); source.getState().autoOpenPullRequestTab(workspaceKey, placement); source.getState().closeTab(workspaceKey, "pull_request"); - const restored = createWorkspaceLayoutStore(workspaceLayoutIds); + await source.flushPersistence(); + const restored = createWorkspaceLayoutStore({ ids: workspaceLayoutIds }); await restored.persist.rehydrate(); expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBe(true); expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBeNull(); From 44c3e264df791f8e73c51b2077a73b9c6cddef47 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 27 Sep 2026 21:23:02 -0700 Subject: [PATCH 3/3] fix(app): avoid shadowing layout options --- packages/app/src/stores/workspace-layout-store.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 3b0a530b54..de6f7a3fc3 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -997,14 +997,14 @@ interface WorkspaceLayoutStoreOptions { scheduler?: PersistenceScheduler; } -export function createWorkspaceLayoutStore(options: WorkspaceLayoutStoreOptions = {}) { - const ids = options.ids ?? defaultWorkspaceLayoutIds; +export function createWorkspaceLayoutStore(storeOptions: WorkspaceLayoutStoreOptions = {}) { + const ids = storeOptions.ids ?? defaultWorkspaceLayoutIds; const persistStorage = createThrottledPersistStorage( createValidatedPersistStorage( - options.storage ?? AsyncStorage, + storeOptions.storage ?? AsyncStorage, WorkspaceLayoutPersistedStateSchema, ), - { intervalMs: WORKSPACE_LAYOUT_PERSIST_INTERVAL_MS, scheduler: options.scheduler }, + { intervalMs: WORKSPACE_LAYOUT_PERSIST_INTERVAL_MS, scheduler: storeOptions.scheduler }, ); const store = create()( persist(