From 56df57e39ec3b352c3aecc10302f1877ffaaebfe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:46:11 -0700 Subject: [PATCH] fix(terminal): stop worktree park title feedback loop --- ...-cold-park-pre-gate-loop.react185.test.tsx | 231 ++++++++++++++++++ .../use-terminal-tab-cold-parking.ts | 4 +- .../use-worktree-park-terminal-tabs.ts | 32 +++ 3 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/use-worktree-park-terminal-tabs.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx new file mode 100644 index 00000000000..2faa792844b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx @@ -0,0 +1,231 @@ +/** @vitest-environment happy-dom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const harness = vi.hoisted(() => ({ + worktreeId: 'repo::/cold-park-pre-gate', + coverageCalls: 0, + syncCalls: 0, + slotRenders: 0, + renderedParkedSets: [] as string[][] +})) + +vi.mock('../../store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + activeGroupIdByWorktree: {} as Record, + groupsByWorktree: {} as Record, + pendingStartupByTabId: {} as Record, + runtimeStatusByEnvironmentId: new Map(), + settings: {} as Record, + sleepingAgentSessionsByPaneKey: {} as Record, + tabsByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record, + unifiedTabsByWorktree: {} as Record, + consumeSuppressedPtyExit: () => false, + focusGroup: () => {}, + reconcileWorktreeTabModel: () => ({ renderableTabCount: 2 }), + setActiveWorktree: () => {} + })) + return { useAppStore } +}) + +vi.mock('../native-chat/use-native-chat-toggle-shortcut', () => ({ + useNativeChatToggleShortcut: () => {} +})) + +vi.mock('./TerminalOverlaySlot', () => ({ + TerminalOverlaySlot: () => { + harness.slotRenders += 1 + return null + } +})) + +vi.mock('./terminal-parked-tab-watchers', async () => { + const { useAppStore } = await import('../../store') + return { + canWatcherCoverParkedTerminalTab: () => { + harness.coverageCalls += 1 + return harness.coverageCalls % 2 === 1 + }, + disposeParkedTerminalWatchersForWorktree: () => {}, + syncParkedTerminalTabWatchers: (args: { + worktreeId: string + parkedTabIds: ReadonlySet + }) => { + harness.syncCalls += 1 + harness.renderedParkedSets.push([...args.parkedTabIds].sort()) + if (args.parkedTabIds.size === 0) { + return + } + ;( + useAppStore as unknown as { + setState: (update: (state: ParkingStoreState) => Partial) => void + } + ).setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [args.worktreeId]: state.tabsByWorktree[args.worktreeId].map((tab) => ({ + ...tab, + title: `${tab.id}-${harness.syncCalls}` + })) + } + })) + } + } +}) + +vi.mock('./terminal-parking-e2e-overrides', () => ({ + getTerminalParkingPolicyOverrides: () => ({ coldParkDelayMs: 0, hotRetainMs: 0 }) +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +import { useAppStore } from '../../store' +import TerminalPaneOverlayLayer from './TerminalPaneOverlayLayer' + +const TAB_IDS = ['tab-a', 'tab-b'] as const +const GROUP_ID = 'group-a' + +type ParkingStoreState = { + tabsByWorktree: Record +} + +const parkingStore = useAppStore as unknown as { + getState: () => ParkingStoreState + setState: (partial: Record) => void +} + +function terminalTab(id: string): TerminalTab { + return { + id, + worktreeId: harness.worktreeId, + ptyId: `${harness.worktreeId}@@session-${id}`, + title: id, + generation: 0 + } as TerminalTab +} + +function unifiedTerminalTab(id: string): Tab { + return { + id: `unified-${id}`, + entityId: id, + worktreeId: harness.worktreeId, + groupId: GROUP_ID, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function renderProductionLayer(root: Root, coldParkTerminalPanes = true): unknown { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let thrown: unknown = null + try { + act(() => { + root.render( + + ) + }) + } catch (error) { + thrown = error + } + consoleError.mockRestore() + return thrown +} + +describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { + let container: HTMLDivElement + let root: Root | undefined + + beforeEach(() => { + harness.coverageCalls = 0 + harness.syncCalls = 0 + harness.slotRenders = 0 + harness.renderedParkedSets.length = 0 + const tabs = TAB_IDS.map(terminalTab) + const unifiedTabs = TAB_IDS.map(unifiedTerminalTab) + parkingStore.setState({ + activeGroupIdByWorktree: { [harness.worktreeId]: GROUP_ID }, + groupsByWorktree: { + [harness.worktreeId]: [ + { + id: GROUP_ID, + worktreeId: harness.worktreeId, + activeTabId: unifiedTabs[0].id, + tabOrder: unifiedTabs.map((tab) => tab.id), + recentTabIds: [unifiedTabs[0].id] + } + ] + }, + tabsByWorktree: { [harness.worktreeId]: tabs }, + unifiedTabsByWorktree: { [harness.worktreeId]: unifiedTabs } + }) + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + try { + act(() => root?.unmount()) + } catch { + // A failed initial commit leaves no mounted tree to clean up. + } + root = undefined + container.remove() + }) + + it('settles title publications without suppressing PTY rechecks', () => { + root = createRoot(container) + const thrown = renderProductionLayer(root) + + expect(thrown).toBeNull() + expect(harness.coverageCalls).toBe(1) + expect(harness.syncCalls).toBeLessThan(10) + expect(harness.renderedParkedSets).not.toHaveLength(0) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + expect(harness.slotRenders).toBe(0) + + const settledSyncCalls = harness.syncCalls + const state = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...state.tabsByWorktree, + [harness.worktreeId]: state.tabsByWorktree[harness.worktreeId].map((tab) => + tab.id === TAB_IDS[1] ? { ...tab, ptyId: `${tab.ptyId}-replacement` } : tab + ) + } + }) + }) + + expect(harness.coverageCalls).toBe(2) + expect(harness.syncCalls).toBeGreaterThan(settledSyncCalls) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + expect(harness.slotRenders).toBe(0) + + expect(renderProductionLayer(root, false)).toBeNull() + expect(harness.slotRenders).toBeGreaterThan(0) + + expect(renderProductionLayer(root)).toBeNull() + expect(harness.renderedParkedSets.at(-1)).toEqual([...TAB_IDS]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 6a7a0e1a498..75177ee15ec 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -36,6 +36,7 @@ import { disposeParkedTerminalWatchersForWorktree, syncParkedTerminalTabWatchers } from './terminal-parked-tab-watchers' +import { useWorktreeParkTerminalTabs } from './use-worktree-park-terminal-tabs' type TerminalOverlayTabAssignment = { groupId: string @@ -77,7 +78,7 @@ export function useTerminalTabColdParking(args: { }): ReadonlySet { const { worktreeId, - terminalTabs, + terminalTabs: publishedTerminalTabs, assignments, isWorktreeActive, coldParkTerminalPanes, @@ -86,6 +87,7 @@ export function useTerminalTabColdParking(args: { activityTerminalPortals, activationDeferredMountTabIds } = args + const terminalTabs = useWorktreeParkTerminalTabs(publishedTerminalTabs, coldParkTerminalPanes) const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) const terminalParkingEnabled = useAppStore( (state) => state.settings?.terminalHiddenViewParking !== false diff --git a/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-tabs.ts b/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-tabs.ts new file mode 100644 index 00000000000..604a4c9d64e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-tabs.ts @@ -0,0 +1,32 @@ +import { useRef } from 'react' +import type { TerminalTab } from '../../../../shared/types' + +function haveSameTerminalParkingInputs( + left: readonly TerminalTab[], + right: readonly TerminalTab[] +): boolean { + return ( + left.length === right.length && + left.every( + (tab, index) => + tab.id === right[index].id && + tab.ptyId === right[index].ptyId && + tab.pendingActivationSpawn === right[index].pendingActivationSpawn + ) + ) +} + +export function useWorktreeParkTerminalTabs( + terminalTabs: readonly TerminalTab[], + coldParkTerminalPanes: boolean +): readonly TerminalTab[] { + const stableTabsRef = useRef(terminalTabs) + // Why: a dominant worktree park hides pre-gate churn from final-verdict damping. + if ( + !coldParkTerminalPanes || + !haveSameTerminalParkingInputs(stableTabsRef.current, terminalTabs) + ) { + stableTabsRef.current = terminalTabs + } + return stableTabsRef.current +}