mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
perf(renderer): project terminal topology consumers
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+68
-34
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user