From 99c2ed006b66fbebebcbf104319a8dc2f32ac800 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:27:00 -0700 Subject: [PATCH] fix(terminal): stabilize dominant park inputs --- .../TerminalPaneOverlayLayer.tsx | 4 +- ...-cold-park-pre-gate-loop.react185.test.tsx | 100 ++++++++++++++++-- .../use-worktree-park-terminal-assignments.ts | 31 ++++++ .../use-worktree-park-terminal-tabs.ts | 31 ++---- 4 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/use-worktree-park-terminal-assignments.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 5dbc1482af9..a99268aba81 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -10,6 +10,7 @@ import { shouldMountBackgroundWorktreeTab } from '../terminal/background-termina import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' import { TerminalOverlaySlot } from './TerminalOverlaySlot' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' +import { useWorktreeParkTerminalAssignments } from './use-worktree-park-terminal-assignments' type TerminalOverlayAssignment = { unifiedTabId: string @@ -99,11 +100,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ } return entries }, [groupActiveTabById, unifiedTabs]) + const parkingAssignments = useWorktreeParkTerminalAssignments(assignments, coldParkTerminalPanes) const parkedTerminalTabIds = useTerminalTabColdParking({ worktreeId, terminalTabs, - assignments, + assignments: parkingAssignments, isWorktreeActive, coldParkTerminalPanes, isForceParked, 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 index 2faa792844b..0902e9ad7e6 100644 --- 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 @@ -1,5 +1,5 @@ /** @vitest-environment happy-dom */ -import { act } from 'react' +import { act, StrictMode } 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' @@ -73,6 +73,13 @@ vi.mock('./terminal-parked-tab-watchers', async () => { ...tab, title: `${tab.id}-${harness.syncCalls}` })) + }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [args.worktreeId]: state.unifiedTabsByWorktree[args.worktreeId].map((tab) => ({ + ...tab, + label: `${tab.entityId}-${harness.syncCalls}` + })) } })) } @@ -94,7 +101,9 @@ const TAB_IDS = ['tab-a', 'tab-b'] as const const GROUP_ID = 'group-a' type ParkingStoreState = { + groupsByWorktree: Record tabsByWorktree: Record + unifiedTabsByWorktree: Record } const parkingStore = useAppStore as unknown as { @@ -133,12 +142,14 @@ function renderProductionLayer(root: Root, coldParkTerminalPanes = true): unknow try { act(() => { root.render( - + + + ) }) } catch (error) { @@ -194,7 +205,7 @@ describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { const thrown = renderProductionLayer(root) expect(thrown).toBeNull() - expect(harness.coverageCalls).toBe(1) + expect(harness.coverageCalls).toBe(2) expect(harness.syncCalls).toBeLessThan(10) expect(harness.renderedParkedSets).not.toHaveLength(0) expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( @@ -215,13 +226,84 @@ describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { }) }) - expect(harness.coverageCalls).toBe(2) + expect(harness.coverageCalls).toBe(3) 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) + const beforePendingSpawnSyncCalls = harness.syncCalls + const pendingSpawnState = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...pendingSpawnState.tabsByWorktree, + [harness.worktreeId]: pendingSpawnState.tabsByWorktree[harness.worktreeId].map((tab) => + tab.id === TAB_IDS[1] ? { ...tab, pendingActivationSpawn: 1 } : tab + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforePendingSpawnSyncCalls) + + const beforeTabOrderSyncCalls = harness.syncCalls + const tabOrderState = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...tabOrderState.tabsByWorktree, + [harness.worktreeId]: tabOrderState.tabsByWorktree[harness.worktreeId].toReversed() + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeTabOrderSyncCalls) + + const beforeActiveTabChangeSyncCalls = harness.syncCalls + const activeTabState = parkingStore.getState() + act(() => { + parkingStore.setState({ + groupsByWorktree: { + ...activeTabState.groupsByWorktree, + [harness.worktreeId]: activeTabState.groupsByWorktree[harness.worktreeId].map( + (group) => ({ + ...group, + activeTabId: `unified-${TAB_IDS[1]}` + }) + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeActiveTabChangeSyncCalls) + + const beforeGroupChangeSyncCalls = harness.syncCalls + const groupState = parkingStore.getState() + const nextGroupId = 'group-b' + act(() => { + parkingStore.setState({ + groupsByWorktree: { + ...groupState.groupsByWorktree, + [harness.worktreeId]: [ + ...groupState.groupsByWorktree[harness.worktreeId], + { + id: nextGroupId, + worktreeId: harness.worktreeId, + activeTabId: `unified-${TAB_IDS[1]}`, + tabOrder: [`unified-${TAB_IDS[1]}`], + recentTabIds: [`unified-${TAB_IDS[1]}`] + } + ] + }, + unifiedTabsByWorktree: { + ...groupState.unifiedTabsByWorktree, + [harness.worktreeId]: groupState.unifiedTabsByWorktree[harness.worktreeId].map((tab) => + tab.entityId === TAB_IDS[1] ? { ...tab, groupId: nextGroupId } : tab + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeGroupChangeSyncCalls) + expect(renderProductionLayer(root, false)).toBeNull() expect(harness.slotRenders).toBeGreaterThan(0) diff --git a/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-assignments.ts b/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-assignments.ts new file mode 100644 index 00000000000..becc47e2510 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-assignments.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' + +type WorktreeParkTerminalAssignment = { + unifiedTabId: string + groupId: string + isActiveInGroup: boolean +} + +function getTerminalParkingAssignmentsKey( + assignments: ReadonlyMap +): string { + return JSON.stringify( + Array.from(assignments, ([tabId, assignment]) => [ + tabId, + assignment.unifiedTabId, + assignment.groupId, + assignment.isActiveInGroup + ]) + ) +} + +export function useWorktreeParkTerminalAssignments( + assignments: ReadonlyMap, + coldParkTerminalPanes: boolean +): ReadonlyMap { + const parkingAssignmentsKey = coldParkTerminalPanes + ? getTerminalParkingAssignmentsKey(assignments) + : assignments + // eslint-disable-next-line react-hooks/exhaustive-deps -- decorative unified labels must not retrigger dominated pre-gate work. + return useMemo(() => assignments, [parkingAssignmentsKey]) +} 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 index 604a4c9d64e..4ac6a0569a6 100644 --- 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 @@ -1,32 +1,17 @@ -import { useRef } from 'react' +import { useMemo } 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 - ) - ) +function getTerminalParkingInputsKey(terminalTabs: readonly TerminalTab[]): string { + return JSON.stringify(terminalTabs.map((tab) => [tab.id, tab.ptyId, tab.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 + const parkingInputsKey = coldParkTerminalPanes + ? getTerminalParkingInputsKey(terminalTabs) + : terminalTabs + // eslint-disable-next-line react-hooks/exhaustive-deps -- decorative tab publications must not retrigger dominated pre-gate work. + return useMemo(() => terminalTabs, [parkingInputsKey]) }