perf(renderer): project terminal topology consumers

This commit is contained in:
Neil
2026-08-23 15:44:15 -07:00
parent ab3b1d07cd
commit 2f2f78b3d6
10 changed files with 422 additions and 236 deletions
+22 -9
View File
@@ -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<typeof useAppStore.getState>
function showClientCreationActionError(error: unknown): void {
@@ -293,8 +295,19 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
? 'terminal'
: 'app'
}
function LiveTerminalTabBar(
props: Omit<React.ComponentProps<typeof TabBar>, 'tabs'>
): React.JSX.Element {
const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS)
return <TabBar {...props} tabs={tabs} />
}
function Terminal(): React.JSX.Element | null {
const terminalTopologyProjectionRef = useRef<WorktreeTabBucketProjection<
TerminalTab,
TerminalTab
> | null>(null)
terminalTopologyProjectionRef.current ??= createTerminalWorktreeTopologyProjection()
const mountedWorktreeIdsRef = useRef(new Set<string>())
// Why an array: browser-guest eviction needs activation order (LRU), not just membership.
const browserGuestWorktreeRecencyRef = useRef<string[]>([])
@@ -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(
<TabBar
tabs={tabs}
activeTabId={activeTabId}
<LiveTerminalTabBar
worktreeId={renderedActiveWorktreeId}
activeTabId={activeTabId}
onActivate={handleActivateTab}
onClose={handleCloseTab}
onCloseOthers={handleCloseOthers}
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-pane-authority'
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
@@ -32,7 +32,7 @@ function makeRepo(): Repo {
}
}
function makeWorktree(): Worktree {
function makeWorktree(overrides?: Partial<Worktree>): 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()
@@ -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<TerminalTab, TerminalTab>({
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<WorktreeTabBucketProjection<
TerminalTab,
TerminalTab
> | 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]) {
@@ -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({
@@ -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<TerminalTab, 'id'>
export type BrowserActivityTab = Pick<BrowserWorkspace, 'id'>
function haveSameProjection<T, U>(
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<TerminalTab, TerminalActivityTab>({
projectTab: (tab) => ({ id: tab.id }),
isSameProjectedTab: (previousTab, nextTab) => previousTab.id === nextTab.id,
onInspectBucket
})
}
function projectTabs<T, U>(
tabsByWorktree: Record<string, readonly T[]>,
previousProjection: Record<string, U[]> | null,
projectTab: (tab: T) => U,
isSame: (previousTab: U, nextTab: T) => boolean
): { projection: Record<string, U[]>; unchanged: boolean } {
const nextProjection: Record<string, U[]> = {}
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<T extends { id: string }, U extends { id: string }>(
tabsByWorktree: Record<string, readonly T[]>,
previousProjection: Record<string, U[]> | null
): { projection: Record<string, U[]>; unchanged: boolean } {
return projectTabs(
tabsByWorktree,
previousProjection,
(tab) => ({ id: tab.id }) as U,
(previousTab, nextTab) => previousTab.id === nextTab.id
)
}
let cachedTerminalSource: Record<string, TerminalTab[]> | null = null
let cachedTerminalProjection: Record<string, TerminalActivityTab[]> | null = null
const terminalProjection = createVisibleWorktreeTerminalActivityProjection()
export function getVisibleWorktreeTerminalActivityTabs(
tabsByWorktree: Record<string, TerminalTab[]>
): Record<string, TerminalActivityTab[]> {
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<string, BrowserWorkspace[]> | null = null
let cachedBrowserProjection: Record<string, BrowserActivityTab[]> | null = null
const browserProjection = createWorktreeTabBucketProjection<BrowserWorkspace, BrowserActivityTab>({
projectTab: (tab) => ({ id: tab.id }),
isSameProjectedTab: (previousTab, nextTab) => previousTab.id === nextTab.id
})
export function getVisibleWorktreeBrowserActivityTabs(
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
): Record<string, BrowserActivityTab[]> {
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)
}
@@ -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)
})
})
@@ -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<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
export function getTerminalWorktreeParkingInputsKey(
tabsByWorktree: Readonly<Record<string, readonly TerminalWorktreeParkingTab[]>>
): 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<TerminalTab, TerminalTab>({
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(
@@ -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>()
@@ -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<TerminalTab, TerminalTab>({
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<UnifiedTab, UnifiedTab>({
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<string, unknown> | null = null
const pendingChangedFields = new Set<SessionRelevantField>()
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<string, unknown> = {}
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) {
@@ -0,0 +1,66 @@
export type WorktreeTabBucketProjection<T, P> = {
project: (source: Readonly<Record<string, readonly T[]>>) => Record<string, P[]>
}
export function createWorktreeTabBucketProjection<T, P>(args: {
projectTab: (tab: T) => P
isSameProjectedTab: (previous: P, next: T) => boolean
onInspectBucket?: (worktreeId: string) => void
}): WorktreeTabBucketProjection<T, P> {
let previousSource: Readonly<Record<string, readonly T[]>> | null = null
let previousProjection: Record<string, P[]> | null = null
const projectionBySourceBucket = new WeakMap<readonly T[], P[]>()
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<string, P[]> = {}
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
}
}
}