diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 49bc620914a..ab3e12f9b5d 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -119,7 +119,7 @@ import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, countEvictionExemptTabRoutes, formatEvictionExemptRouteCounts, - getTerminalWorktreeParkingInputsKey, + createTerminalWorktreeTopologyProjection, hasPendingRetentionSpawnWork, selectForceParkEvictableTabIds, selectRetentionForceParkedTerminalWorktrees, @@ -192,11 +192,13 @@ import { } from './terminal-pane/use-manual-terminal-worktree-parking' import { EDITOR_TAB_CONTENT_TYPES, getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' const EditorPanel = lazy(() => import('./editor/EditorPanel')) // Why: gate handler runs after a dialog advances so a stray carry-over click can't act on the next dialog; ~200ms absorbs a physical double-click while staying responsive. const CLOSE_DIALOG_DEBOUNCE_MS = 200 +const EMPTY_TERMINAL_TABS: TerminalTab[] = [] type TerminalStoreSnapshot = ReturnType function showClientCreationActionError(error: unknown): void { @@ -293,8 +295,19 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { ? 'terminal' : 'app' } +function LiveTerminalTabBar( + props: Omit, 'tabs'> +): React.JSX.Element { + const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS) + return +} function Terminal(): React.JSX.Element | null { + const terminalTopologyProjectionRef = useRef | null>(null) + terminalTopologyProjectionRef.current ??= createTerminalWorktreeTopologyProjection() const mountedWorktreeIdsRef = useRef(new Set()) // Why an array: browser-guest eviction needs activation order (LRU), not just membership. const browserGuestWorktreeRecencyRef = useRef([]) @@ -325,10 +338,11 @@ function Terminal(): React.JSX.Element | null { getResolvedExecutionHostIdForWorktree(s, renderedActiveWorktreeId) ) const activeView = useAppStore((s) => s.activeView) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const terminalWorktreeParkingInputsKey = useMemo( - () => getTerminalWorktreeParkingInputsKey(tabsByWorktree), - [tabsByWorktree] + // Why: terminal titles are leaf chrome. The root host only subscribes to + // mount/parking semantics; a real transition publishes fresh tab objects, + // while LiveTerminalTabBar reads title-only updates from the active bucket. + const tabsByWorktree = useAppStore((s) => + terminalTopologyProjectionRef.current!.project(s.tabsByWorktree) ) const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) @@ -1184,7 +1198,7 @@ function Terminal(): React.JSX.Element | null { pendingStartupByTabId, pairedRuntimeParkingEnvironmentIds, renderedActiveWorktreeId, - terminalWorktreeParkingInputsKey, + tabsByWorktree, terminalParkingEnabled, terminalParkingRevision, terminalProviderSnapshotCapabilityRevision, @@ -2466,10 +2480,9 @@ function Terminal(): React.JSX.Element | null { !effectiveActiveLayout && titlebarTabsTarget && createPortal( - ): Worktree { return { id: 'wt-1', repoId: 'repo-1', @@ -50,7 +50,8 @@ function makeWorktree(): Worktree { isUnread: false, isPinned: false, sortOrder: 0, - lastActivityAt: 1 + lastActivityAt: 1, + ...overrides } } @@ -227,6 +228,103 @@ describe('collectRetainedAgentsOnDisappear', () => { }) describe('useRetainedAgentsSync', () => { + it('does not build a fleet snapshot for a title-only publication across 300 worktrees', async () => { + const repo = makeRepo() + const worktrees = Array.from({ length: 300 }, (_, index) => + makeWorktree({ id: `wt-${index}`, path: `/repo/wt-${index}` }) + ) + const tabsByWorktree = Object.fromEntries( + worktrees.map((worktree, index) => [ + worktree.id, + [makeTab({ id: `tab-${index}`, worktreeId: worktree.id, title: `Title ${index}` })] + ]) + ) + const retainAgents = vi.fn() + const pruneRetainedAgents = vi.fn() + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: worktrees }, + tabsByWorktree, + retainAgents, + pruneRetainedAgents + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + retainAgents.mockClear() + pruneRetainedAgents.mockClear() + + const changedWorktreeId = 'wt-173' + act(() => { + useAppStore.setState({ + tabsByWorktree: { + ...tabsByWorktree, + [changedWorktreeId]: [ + { + ...tabsByWorktree[changedWorktreeId][0], + title: 'Updated display title' + } + ] + } + }) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(retainAgents).not.toHaveBeenCalled() + expect(pruneRetainedAgents).not.toHaveBeenCalled() + hook.unmount() + }) + + it('uses the freshest tab title when a real agent transition retains a completed row', async () => { + const repo = makeRepo() + const worktree = makeWorktree() + const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const row = makeAgentRow({ paneKey, state: 'done' }) + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + tabsByWorktree: { + [worktree.id]: [{ ...row.tab, title: '⠋ Codex is thinking' }] + }, + agentStatusByPaneKey: { [paneKey]: row.entry }, + agentStatusEpoch: initialAppState.agentStatusEpoch + 1 + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + + act(() => { + useAppStore.setState((state) => ({ + tabsByWorktree: { + [worktree.id]: [ + { + ...state.tabsByWorktree[worktree.id][0], + title: '⠙ Codex is thinking' + } + ] + } + })) + }) + act(() => { + useAppStore.setState((state) => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: state.agentStatusEpoch + 1 + })) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(useAppStore.getState().retainedAgentsByPaneKey[paneKey]?.tab.title).toBe( + '⠙ Codex is thinking' + ) + hook.unmount() + }) + it('does not re-retain when status removal and tab closure commit before the next retention effect', async () => { const repo = makeRepo() const worktree = makeWorktree() diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 883da974142..2e39205a84c 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -16,6 +16,10 @@ import { } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { + createWorktreeTabBucketProjection, + type WorktreeTabBucketProjection +} from '@/lib/worktree-tab-bucket-projection' // Why: when an agent finishes or its terminal closes, the store cleans up the // explicit status entry and the agent vanishes from the live status set. // Retaining the last-known "done" snapshot in the store lets the inline @@ -35,6 +39,16 @@ type RetainedAgentsSyncInputs = { type RetainedAgentsSyncSnapshotInputs = RetainedAgentsSyncInputs & { now: number } +export function createRetainedAgentsTabTopologyProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => + previousTab.id === nextTab.id && previousTab.worktreeId === nextTab.worktreeId, + onInspectBucket + }) +} function paneKeyTabId(paneKey: string): string | null { return parsePaneKey(paneKey)?.tabId ?? null @@ -122,17 +136,22 @@ export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshot } export function useRetainedAgentsSync(): void { + const tabTopologyProjectionRef = useRef | null>(null) + tabTopologyProjectionRef.current ??= createRetainedAgentsTabTopologyProjection() const retainAgents = useAppStore((s) => s.retainAgents) const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents) const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys) - const [repos, worktreesByRepo, folderWorkspaces, tabsByWorktree, agentStatusEpoch] = useAppStore( + const [repos, worktreesByRepo, folderWorkspaces, tabTopology, agentStatusEpoch] = useAppStore( useShallow( (s) => [ s.repos, s.worktreesByRepo, s.folderWorkspaces, - s.tabsByWorktree, + tabTopologyProjectionRef.current!.project(s.tabsByWorktree), s.agentStatusEpoch ] as const ) @@ -150,11 +169,11 @@ export function useRetainedAgentsSync(): void { now: Date.now() }) - // Why: read retention state via getState() after the cheap ref/epoch gate - // fires. Building the full retention snapshot scans all agents, so do it - // only when live identity/state/freshness/final-done data or worktree - // membership changes. Subscribing to retainedAgentsByPaneKey would create - // a feedback loop because this effect calls retainAgents. + // Why: read retention state via getState() after the cheap topology/epoch + // gate fires. Building the full retention snapshot scans all agents, so + // display-title-only publications must not enter this effect. The fresh + // store read still supplies current tab objects whenever a real topology + // or agent transition does run. const { retainedAgentsByPaneKey: retainedNow, retentionSuppressedPaneKeys } = state const { toRetain, consumedSuppressedPaneKeys } = collectRetainedAgentsOnDisappear({ previousAgents: prevAgentsRef.current, @@ -181,7 +200,7 @@ export function useRetainedAgentsSync(): void { repos, worktreesByRepo, folderWorkspaces, - tabsByWorktree, + tabTopology, agentStatusEpoch, retainAgents, pruneRetainedAgents, @@ -273,9 +292,8 @@ export function collectRetainedAgentsOnDisappear(args: { // Why: prefer the real destination tab — reusing the source tab's title and // launchAgent under a different id would mislabel the retained row. const ownerTab = - ownerTabId === prev.row.tab.id - ? prev.row.tab - : (args.tabIndex?.get(ownerTabId)?.tab ?? { ...prev.row.tab, id: ownerTabId }) + args.tabIndex?.get(ownerTabId)?.tab ?? + (ownerTabId === prev.row.tab.id ? prev.row.tab : { ...prev.row.tab, id: ownerTabId }) // Why: PTY exit can remove the live row before closeTab plants a suppressor; // the closed-tab marker prevents re-retention. if (args.recentlyClosedAgentStatusTabIds[ownerTabId]) { diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts index c18c3cbceed..50dbfc34644 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { + createVisibleWorktreeTerminalActivityProjection, getVisibleWorktreeBrowserActivityTabs, getVisibleWorktreeTerminalActivityTabs } from './visible-worktree-activity-inputs' @@ -49,6 +50,28 @@ describe('visible worktree activity inputs', () => { expect(second).toBe(first) expect(second['wt-1']).toBe(first['wt-1']) }) + it('inspects only the changed bucket in a 300-worktree title publication', () => { + const inspected: string[] = [] + const projector = createVisibleWorktreeTerminalActivityProjection((worktreeId) => + inspected.push(worktreeId) + ) + const tabs = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, [terminalTab(`tab-${index}`, `Title ${index}`)]] + }) + ) + const first = projector.project(tabs) + inspected.length = 0 + const changedWorktreeId = 'wt-173' + const second = projector.project({ + ...tabs, + [changedWorktreeId]: [{ ...tabs[changedWorktreeId][0], title: 'Changed display title' }] + }) + + expect(second).toBe(first) + expect(inspected).toEqual([changedWorktreeId]) + }) it('updates terminal activity projection when tab ids change', () => { const first = getVisibleWorktreeTerminalActivityTabs({ diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts index 683b984cca8..521632ce4b7 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -1,94 +1,35 @@ import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' export type TerminalActivityTab = Pick export type BrowserActivityTab = Pick -function haveSameProjection( - previous: readonly U[] | undefined, - next: readonly T[], - isSame: (previousTab: U, nextTab: T) => boolean -): boolean { - if (!previous || previous.length !== next.length) { - return false - } - for (let index = 0; index < next.length; index++) { - const previousTab = previous[index] - if (!previousTab || !isSame(previousTab, next[index])) { - return false - } - } - return true +export function createVisibleWorktreeTerminalActivityProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => ({ id: tab.id }), + isSameProjectedTab: (previousTab, nextTab) => previousTab.id === nextTab.id, + onInspectBucket + }) } -function projectTabs( - tabsByWorktree: Record, - previousProjection: Record | null, - projectTab: (tab: T) => U, - isSame: (previousTab: U, nextTab: T) => boolean -): { projection: Record; unchanged: boolean } { - const nextProjection: Record = {} - let unchanged = - previousProjection !== null && - Object.keys(previousProjection).length === Object.keys(tabsByWorktree).length - - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - const previousTabs = previousProjection?.[worktreeId] - if (haveSameProjection(previousTabs, tabs, isSame)) { - nextProjection[worktreeId] = previousTabs as U[] - continue - } - unchanged = false - nextProjection[worktreeId] = tabs.map(projectTab) - } - - return { projection: nextProjection, unchanged } -} - -function projectIdTabs( - tabsByWorktree: Record, - previousProjection: Record | null -): { projection: Record; unchanged: boolean } { - return projectTabs( - tabsByWorktree, - previousProjection, - (tab) => ({ id: tab.id }) as U, - (previousTab, nextTab) => previousTab.id === nextTab.id - ) -} - -let cachedTerminalSource: Record | null = null -let cachedTerminalProjection: Record | null = null +const terminalProjection = createVisibleWorktreeTerminalActivityProjection() export function getVisibleWorktreeTerminalActivityTabs( tabsByWorktree: Record ): Record { - if (cachedTerminalSource === tabsByWorktree && cachedTerminalProjection) { - return cachedTerminalProjection - } - const { projection, unchanged } = projectIdTabs(tabsByWorktree, cachedTerminalProjection) - cachedTerminalSource = tabsByWorktree - if (unchanged && cachedTerminalProjection) { - return cachedTerminalProjection - } - cachedTerminalProjection = projection - return projection + return terminalProjection.project(tabsByWorktree) } -let cachedBrowserSource: Record | null = null -let cachedBrowserProjection: Record | null = null +const browserProjection = createWorktreeTabBucketProjection({ + projectTab: (tab) => ({ id: tab.id }), + isSameProjectedTab: (previousTab, nextTab) => previousTab.id === nextTab.id +}) export function getVisibleWorktreeBrowserActivityTabs( browserTabsByWorktree: Record ): Record { - if (cachedBrowserSource === browserTabsByWorktree && cachedBrowserProjection) { - return cachedBrowserProjection - } - const { projection, unchanged } = projectIdTabs(browserTabsByWorktree, cachedBrowserProjection) - cachedBrowserSource = browserTabsByWorktree - if (unchanged && cachedBrowserProjection) { - return cachedBrowserProjection - } - cachedBrowserProjection = projection - return projection + return browserProjection.project(browserTabsByWorktree) } diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index d45be73c63e..d3ed7666d4c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking' import { clearTerminalProviderSnapshotCapabilities, @@ -6,7 +7,7 @@ import { } from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, - getTerminalWorktreeParkingInputsKey, + createTerminalWorktreeTopologyProjection, hasPendingRetentionSpawnWork, isEvictionExemptTerminalPty, selectForceParkEvictableTabIds, @@ -14,44 +15,77 @@ import { type TerminalWorktreeRetentionCandidate } from './terminal-hidden-worktree-retention' -describe('getTerminalWorktreeParkingInputsKey', () => { - const tabs = { - 'repo::/worktree': [ - { - id: 'tab-1', - ptyId: 'repo::/worktree@@pty-1', - pendingActivationSpawn: undefined, - title: 'Original title' - } - ] +describe('createTerminalWorktreeTopologyProjection', () => { + function tab(worktreeId: string, id = `tab-${worktreeId}`): TerminalTab { + return { + id, + ptyId: `${worktreeId}@@pty-1`, + worktreeId, + title: 'Original title', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } } - it('ignores display-only tab changes', () => { - const retitledTabs = { - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], title: 'Updated title' }] - } - expect(getTerminalWorktreeParkingInputsKey(retitledTabs)).toBe( - getTerminalWorktreeParkingInputsKey(tabs) + it('keeps the parking dependency stable for a title-only change across 300 worktrees', () => { + const inspected: string[] = [] + const projector = createTerminalWorktreeTopologyProjection((worktreeId) => + inspected.push(worktreeId) ) + const tabs = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, [tab(worktreeId)]] + }) + ) + const first = projector.project(tabs) + inspected.length = 0 + const changedWorktreeId = 'wt-173' + const second = projector.project({ + ...tabs, + [changedWorktreeId]: [{ ...tabs[changedWorktreeId][0], title: 'Updated display title' }] + }) + + expect(second).toBe(first) + expect(inspected).toEqual([changedWorktreeId]) }) - it('changes for inputs consumed by the parking policy', () => { - const original = getTerminalWorktreeParkingInputsKey(tabs) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], ptyId: null }] - }) - ).not.toBe(original) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], pendingActivationSpawn: true }] - }) - ).not.toBe(original) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], id: 'tab-2' }] - }) - ).not.toBe(original) + it.each([ + ['PTY assignment', (original: TerminalTab) => ({ ...original, ptyId: null })], + [ + 'pending spawn', + (original: TerminalTab) => ({ ...original, pendingActivationSpawn: true as const }) + ], + ['tab replacement', (original: TerminalTab) => ({ ...original, id: 'tab-2' })], + ['generation remount', (original: TerminalTab) => ({ ...original, generation: 2 })], + ['startup cwd', (original: TerminalTab) => ({ ...original, startupCwd: '/tmp' })] + ])('changes for %s', (_label, mutate) => { + const projector = createTerminalWorktreeTopologyProjection() + const originalTab = tab('wt-1', 'tab-1') + const first = projector.project({ 'wt-1': [originalTab] }) + const second = projector.project({ 'wt-1': [mutate(originalTab)] }) + + expect(second).not.toBe(first) + }) + + it('changes for add, remove, and move transitions', () => { + const projector = createTerminalWorktreeTopologyProjection() + const first = projector.project({ 'wt-1': [tab('wt-1', 'tab-1')], 'wt-2': [] }) + const added = projector.project({ + 'wt-1': [tab('wt-1', 'tab-1'), tab('wt-1', 'tab-2')], + 'wt-2': [] + }) + const removed = projector.project({ 'wt-1': [tab('wt-1', 'tab-2')], 'wt-2': [] }) + const moved = projector.project({ + 'wt-1': [], + 'wt-2': [tab('wt-2', 'tab-2')] + }) + + expect(added).not.toBe(first) + expect(removed).not.toBe(added) + expect(moved).not.toBe(removed) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 2024002bfa4..51d43e45376 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -9,6 +9,7 @@ import { type TerminalColdParkPolicyOverrides } from './terminal-hidden-view-parking' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' // Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap // at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues), @@ -30,17 +31,20 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 4 export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 15 * 60_000 -type TerminalWorktreeParkingTab = Pick - -export function getTerminalWorktreeParkingInputsKey( - tabsByWorktree: Readonly> -): string { - return JSON.stringify( - Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn]) - ]) - ) +export function createTerminalWorktreeTopologyProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => + previousTab.id === nextTab.id && + previousTab.ptyId === nextTab.ptyId && + previousTab.worktreeId === nextTab.worktreeId && + previousTab.pendingActivationSpawn === nextTab.pendingActivationSpawn && + previousTab.generation === nextTab.generation && + previousTab.startupCwd === nextTab.startupCwd, + onInspectBucket + }) } export function hasPendingRetentionSpawnWork( diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 3c31296095a..535a151e658 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -245,6 +245,56 @@ describe('createSessionWriteSubscriber', () => { expect(persist).not.toHaveBeenCalled() cleanup() }) + it('schedules no session timer for one display-title bucket in a 300-worktree fleet', () => { + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const tabsByWorktree = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => { + const worktreeId = `wt-${index}` + return [ + worktreeId, + [ + { + id: `tab-${index}`, + ptyId: `${worktreeId}@@pty-1`, + worktreeId, + title: '⠋ Codex is thinking', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + ] + }) + ) + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + tabsByWorktree + }) + vi.advanceTimersByTime(200) + persist.mockClear() + expect(vi.getTimerCount()).toBe(0) + + const changedWorktreeId = 'wt-173' + useAppStore.setState({ + tabsByWorktree: { + ...tabsByWorktree, + [changedWorktreeId]: [ + { + ...tabsByWorktree[changedWorktreeId][0], + title: '⠙ Codex is thinking' + } + ] + } + }) + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(200) + expect(persist).not.toHaveBeenCalled() + cleanup() + }) it('persists ordinary terminal title-only changes', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 05e49127e81..6e2eda4ed1f 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -3,6 +3,7 @@ import { isDecorativeAgentTitleFrameChange } from '../../../shared/agent-decorat import type { WorkspaceSessionPatch } from '../../../shared/workspace-session-state-types' import { SESSION_RELEVANT_FIELDS, shouldPersistWorkspaceSession } from './workspace-session' import { buildWorkspaceSessionPatch } from './workspace-session-patch' +import { createWorktreeTabBucketProjection } from './worktree-tab-bucket-projection' type SessionRelevantField = (typeof SESSION_RELEVANT_FIELDS)[number] type TabsByWorktree = AppState['tabsByWorktree'] @@ -33,30 +34,12 @@ function terminalTabChangedForSession(prev: TerminalTab, next: TerminalTab): boo } return prev.title !== next.title && !isDecorativeAgentTitleFrameChange(prev.title, next.title) } - -function tabsByWorktreeChangedForSession(prev: TabsByWorktree, next: TabsByWorktree): boolean { - if (prev === next) { - return false - } - const worktreeIds = new Set([...Object.keys(prev), ...Object.keys(next)]) - for (const worktreeId of worktreeIds) { - const prevTabs = prev[worktreeId] ?? [] - const nextTabs = next[worktreeId] ?? [] - if (prevTabs === nextTabs) { - continue - } - if (prevTabs.length !== nextTabs.length) { - return true - } - for (let i = 0; i < prevTabs.length; i += 1) { - const prevTab = prevTabs[i] - const nextTab = nextTabs[i] - if (!prevTab || !nextTab || terminalTabChangedForSession(prevTab, nextTab)) { - return true - } - } - } - return false +function createTerminalSessionTabsProjection() { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => + !terminalTabChangedForSession(previousTab, nextTab) + }) } function unifiedTabChangedForSession(prev: UnifiedTab, next: UnifiedTab): boolean { @@ -83,57 +66,11 @@ function unifiedTabChangedForSession(prev: UnifiedTab, next: UnifiedTab): boolea } return !isDecorativeAgentTitleFrameChange(prev.label, next.label) } - -function unifiedTabsByWorktreeChangedForSession( - prev: UnifiedTabsByWorktree, - next: UnifiedTabsByWorktree -): boolean { - if (prev === next) { - return false - } - const worktreeIds = new Set([...Object.keys(prev), ...Object.keys(next)]) - for (const worktreeId of worktreeIds) { - const prevTabs = prev[worktreeId] ?? [] - const nextTabs = next[worktreeId] ?? [] - if (prevTabs === nextTabs) { - continue - } - if (prevTabs.length !== nextTabs.length) { - return true - } - for (let i = 0; i < prevTabs.length; i += 1) { - const prevTab = prevTabs[i] - const nextTab = nextTabs[i] - if (!prevTab || !nextTab || unifiedTabChangedForSession(prevTab, nextTab)) { - return true - } - } - } - return false -} - -function sessionRelevantFieldChanged( - key: SessionRelevantField, - prevValue: unknown, - nextValue: unknown -): boolean { - if (prevValue === nextValue) { - return false - } - if (key === 'tabsByWorktree') { - // Why: focused agent CLIs can emit spinner/title OSC frames many times per - // second. Those labels are live UI chrome, not durable terminal topology. - return tabsByWorktreeChangedForSession(prevValue as TabsByWorktree, nextValue as TabsByWorktree) - } - if (key === 'unifiedTabsByWorktree') { - // Why: terminal live titles are mirrored into unified tab labels, so the - // same decorative frames must not wake unified-tab session persistence. - return unifiedTabsByWorktreeChangedForSession( - prevValue as UnifiedTabsByWorktree, - nextValue as UnifiedTabsByWorktree - ) - } - return true +function createUnifiedSessionTabsProjection() { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => !unifiedTabChangedForSession(previousTab, nextTab) + }) } export type WorkspaceSessionWrite = { @@ -166,33 +103,35 @@ export function createSessionWriteSubscriber({ // 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. + // crossed 70-110ms with many tabs, tripping setTimeout violations. Terminal + // and unified maps use durable per-worktree projections, so display frames + // reuse the prior identity while a real session change keeps fresh tabs for + // the eventual getState() patch build. `null` makes the first fire proceed. let prev: Record | null = null const pendingChangedFields = new Set() + const terminalTabsProjection = createTerminalSessionTabsProjection() + const unifiedTabsProjection = createUnifiedSessionTabsProjection() const unsub = store.subscribe((state) => { if (!shouldPersistWorkspaceSession(state)) { return } - const changedFields: SessionRelevantField[] = [] - if (prev === null) { - changedFields.push(...SESSION_RELEVANT_FIELDS) - } else { - for (const key of SESSION_RELEVANT_FIELDS) { - if (sessionRelevantFieldChanged(key, prev[key], state[key])) { - changedFields.push(key) - } - } - } - if (changedFields.length === 0) { - return - } const next: Record = {} for (const key of SESSION_RELEVANT_FIELDS) { - next[key] = state[key] + const value = state[key] + next[key] = + key === 'tabsByWorktree' + ? terminalTabsProjection.project(value as TabsByWorktree) + : key === 'unifiedTabsByWorktree' + ? unifiedTabsProjection.project(value as UnifiedTabsByWorktree) + : value + } + const changedFields = + prev === null + ? [...SESSION_RELEVANT_FIELDS] + : SESSION_RELEVANT_FIELDS.filter((key) => prev?.[key] !== next[key]) + if (changedFields.length === 0) { + return } prev = next for (const field of changedFields) { diff --git a/src/renderer/src/lib/worktree-tab-bucket-projection.ts b/src/renderer/src/lib/worktree-tab-bucket-projection.ts new file mode 100644 index 00000000000..67df99df053 --- /dev/null +++ b/src/renderer/src/lib/worktree-tab-bucket-projection.ts @@ -0,0 +1,66 @@ +export type WorktreeTabBucketProjection = { + project: (source: Readonly>) => Record +} + +export function createWorktreeTabBucketProjection(args: { + projectTab: (tab: T) => P + isSameProjectedTab: (previous: P, next: T) => boolean + onInspectBucket?: (worktreeId: string) => void +}): WorktreeTabBucketProjection { + let previousSource: Readonly> | null = null + let previousProjection: Record | null = null + const projectionBySourceBucket = new WeakMap() + + return { + project(source) { + if (source === previousSource && previousProjection) { + return previousProjection + } + + const sourceKeys = Object.keys(source) + let changed = + previousProjection === null || sourceKeys.length !== Object.keys(previousProjection).length + const nextProjection: Record = {} + + for (const worktreeId of sourceKeys) { + const sourceTabs = source[worktreeId] ?? [] + const previousTabs = previousProjection?.[worktreeId] + let projectedTabs: P[] + + if (previousSource?.[worktreeId] === sourceTabs && previousTabs) { + projectedTabs = previousTabs + } else { + args.onInspectBucket?.(worktreeId) + const cached = projectionBySourceBucket.get(sourceTabs) + if (cached) { + projectedTabs = cached + } else if ( + previousTabs && + previousTabs.length === sourceTabs.length && + sourceTabs.every((tab, index) => { + const previousTab = previousTabs[index] + return previousTab !== undefined && args.isSameProjectedTab(previousTab, tab) + }) + ) { + projectedTabs = previousTabs + } else { + projectedTabs = sourceTabs.map(args.projectTab) + } + projectionBySourceBucket.set(sourceTabs, projectedTabs) + } + + nextProjection[worktreeId] = projectedTabs + if (projectedTabs !== previousTabs) { + changed = true + } + } + + previousSource = source + if (!changed && previousProjection) { + return previousProjection + } + previousProjection = nextProjection + return nextProjection + } + } +}