diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7e3db4e3a62..a4eaa7ccd54 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -34,7 +34,7 @@ import { } from './runtime/sync-runtime-graph' import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' -import { buildWorkspaceSessionPayload } from './lib/workspace-session' +import { buildWorkspaceSessionPayload, SESSION_RELEVANT_FIELDS } from './lib/workspace-session' import { countWorkingAgents, getWorkingAgentsPerWorktree } from './lib/agent-status' import { activateAndRevealWorktree } from './lib/worktree-activation' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' @@ -385,10 +385,37 @@ function App(): React.JSX.Element { // App's render cycle, eliminating re-renders on every tab/file/browser change. useEffect(() => { let timer: number | null = null + // Why: the subscriber fires on every store update (agent status, usage + // refreshes, runtime title ticks, …). Without this gate each fire reset + // the debounce, and when it finally expired buildWorkspaceSessionPayload + // crossed 70-110ms with many tabs, tripping setTimeout violations. Compare + // each session-feeding field by reference against the prior snapshot and + // skip both the timer reset and the rebuild when none changed. `null` + // sentinel guarantees the very first fire always proceeds. + let prev: Record | null = null const unsub = useAppStore.subscribe((state) => { if (!state.workspaceSessionReady) { return } + let changed = false + if (prev === null) { + changed = true + } else { + for (const key of SESSION_RELEVANT_FIELDS) { + if (prev[key] !== state[key]) { + changed = true + break + } + } + } + if (!changed) { + return + } + const next: Record = {} + for (const key of SESSION_RELEVANT_FIELDS) { + next[key] = state[key] + } + prev = next if (timer) { window.clearTimeout(timer) } diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index b7643733f24..43a754128e4 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildWorkspaceSessionPayload } from './workspace-session' +import { + buildWorkspaceSessionPayload, + SESSION_RELEVANT_FIELDS, + type WorkspaceSessionSnapshot +} from './workspace-session' import type { AppState } from '../store' function createSnapshot(overrides: Partial = {}): AppState { @@ -140,3 +144,48 @@ describe('buildWorkspaceSessionPayload', () => { expect(payload.activeTabTypeByWorktree).toEqual({ 'wt-2': 'terminal' }) }) }) + +describe('SESSION_RELEVANT_FIELDS', () => { + // Why: this list gates the App-level session-write debounce subscriber. + // If a future field is added to WorkspaceSessionSnapshot but not to the + // gate, the subscriber would silently stop noticing changes to that field + // and persist stale data. Listing every key here as a fixture and asserting + // the gate covers them catches the drift at test time. The compile-time + // _exhaustive check in workspace-session.ts is the primary line of defense; + // this test is the runtime backstop. + const fixture: Record = { + activeRepoId: true, + activeWorktreeId: true, + activeTabId: true, + tabsByWorktree: true, + terminalLayoutsByTabId: true, + activeTabIdByWorktree: true, + openFiles: true, + activeFileIdByWorktree: true, + activeTabTypeByWorktree: true, + browserTabsByWorktree: true, + browserPagesByWorkspace: true, + activeBrowserTabIdByWorktree: true, + browserUrlHistory: true, + unifiedTabsByWorktree: true, + groupsByWorktree: true, + layoutByWorktree: true, + activeGroupIdByWorktree: true, + sshConnectionStates: true, + repos: true, + worktreesByRepo: true, + lastKnownRelayPtyIdByTabId: true + } + + it('contains every key of WorkspaceSessionSnapshot', () => { + const fixtureKeys = Object.keys(fixture) + expect( + fixtureKeys.every((k) => (SESSION_RELEVANT_FIELDS as readonly string[]).includes(k)) + ).toBe(true) + expect(SESSION_RELEVANT_FIELDS.length).toBe(fixtureKeys.length) + }) + + it('has no duplicate entries', () => { + expect(new Set(SESSION_RELEVANT_FIELDS).size).toBe(SESSION_RELEVANT_FIELDS.length) + }) +}) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index fe24768de90..cdda3d14aae 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -8,7 +8,7 @@ import type { import type { AppState } from '../store' import type { OpenFile } from '../store/slices/editor' -type WorkspaceSessionSnapshot = Pick< +export type WorkspaceSessionSnapshot = Pick< AppState, | 'activeRepoId' | 'activeWorktreeId' @@ -33,6 +33,43 @@ type WorkspaceSessionSnapshot = Pick< | 'lastKnownRelayPtyIdByTabId' > +// Why: the App-level Zustand subscriber that debounces session writes uses +// this list as a shallow-equality gate so it only resets the timer when a +// field that actually feeds buildWorkspaceSessionPayload changes. Keeping +// the list co-located with WorkspaceSessionSnapshot means a future field +// added to the snapshot type fails the _exhaustive check below at compile +// time, preventing the gate from silently going stale. +export const SESSION_RELEVANT_FIELDS = [ + 'activeRepoId', + 'activeWorktreeId', + 'activeTabId', + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeTabIdByWorktree', + 'openFiles', + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'browserTabsByWorktree', + 'browserPagesByWorkspace', + 'activeBrowserTabIdByWorktree', + 'browserUrlHistory', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'layoutByWorktree', + 'activeGroupIdByWorktree', + 'sshConnectionStates', + 'repos', + 'worktreesByRepo', + 'lastKnownRelayPtyIdByTabId' +] as const satisfies readonly (keyof WorkspaceSessionSnapshot)[] + +type _MissingSessionField = Exclude< + keyof WorkspaceSessionSnapshot, + (typeof SESSION_RELEVANT_FIELDS)[number] +> +const _exhaustive: [_MissingSessionField] extends [never] ? true : never = true +void _exhaustive + /** Build the editor-file portion of the workspace session for persistence. * Only edit-mode files are saved — diffs and conflict views are transient. */ export function buildEditorSessionData(