From 95cdff9019a9facf366bb27c8c4388381186d466 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:08:31 -0700 Subject: [PATCH] fix(sidebar): attribute split-pane runtime titles to their own leaf (STA-3264) --- .../worktree-title-derived-agent-rows.test.ts | 111 ++++++++++++++++++ .../worktree-title-derived-agent-rows.ts | 75 ++++++++++-- 2 files changed, 176 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 6fcd0a6719c..c746399b78a 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -382,3 +382,114 @@ describe('buildTitleDerivedAgentRows', () => { expect(rows).toHaveLength(0) }) }) + +// Why: `runtimePaneTitlesByTabId` mixes two disjoint id spaces — live PaneManager +// ids (>= 1) and the `-(leafIndex + 1)` slots a parked tab mints — so attributing a +// title by its position in the numerically sorted slot list puts one split pane's +// lifecycle on its sibling's row (STA-3264). +describe('split-pane runtime title attribution', () => { + const LEAF_ID_3 = '99999999-9999-4999-8999-999999999999' + + function makeNestedSplitLayout(): TerminalLayoutSnapshot { + // Split once (leaf 1 | leaf 2), then split the FIRST pane again (leaf 3). + // Layout traversal order is [1, 3, 2]; pane-creation order is [1, 2, 3]. + return { + root: { + type: 'split', + direction: 'vertical', + first: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID_1 }, + second: { type: 'leaf', leafId: LEAF_ID_3 } + }, + second: { type: 'leaf', leafId: LEAF_ID_2 } + }, + activeLeafId: LEAF_ID_1, + expandedLeafId: null + } + } + + function rowsFor( + paneTitles: Record, + layout: TerminalLayoutSnapshot, + ptyIds: string[] + ) { + return buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { title: '⠋ Codex' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': paneTitles }, + ptyIdsByTabId: { 'tab-1': ptyIds }, + terminalLayoutsByTabId: { 'tab-1': layout }, + now: 2000 + }) + } + + it('keeps a finished split pane out of Running while its sibling keeps working', () => { + // A parked split tab reports its panes through synthetic slots numbered off the + // in-order leaf list: -1 is the first leaf, -2 the second. + const rows = rowsFor({ '-1': 'Codex', '-2': '⠋ Codex' }, makeSplitLayout(), ['pty-a', 'pty-b']) + + expect(rows.map((row) => [row.paneKey, row.state, row.entry.lastAssistantMessage])).toEqual([ + [makePaneKey('tab-1', LEAF_ID_1), 'idle', 'Idle'], + [makePaneKey('tab-1', LEAF_ID_2), 'working', 'Running'] + ]) + }) + + it('lets a revealed tab’s live slots outrank the parked slots it left behind', () => { + // Revealing a parked tab mounts live slots without clearing the parked ones, so + // both id spaces describe the same two leaves at once. The live pair is current: + // leaf 1 has finished, leaf 2 is still working. + const rows = rowsFor( + { '-1': '⠋ Codex', '-2': '⠋ Codex', 1: 'Codex', 2: '⠋ Codex' }, + makeSplitLayout(), + ['pty-a', 'pty-b'] + ) + + expect(rows.map((row) => [row.paneKey, row.state])).toEqual([ + [makePaneKey('tab-1', LEAF_ID_1), 'idle'], + [makePaneKey('tab-1', LEAF_ID_2), 'working'] + ]) + }) + + it('does not let sibling panes inherit each other’s agent or state', () => { + const rows = rowsFor( + { 1: 'Antigravity', 2: '⠋ Codex', 3: '⠋ Gemini CLI' }, + makeNestedSplitLayout(), + ['pty-a', 'pty-b', 'pty-c'] + ) + + expect( + rows + .map((row) => [row.paneKey, row.agentType, row.state]) + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + ).toEqual([ + [makePaneKey('tab-1', LEAF_ID_1), 'antigravity', 'idle'], + [makePaneKey('tab-1', LEAF_ID_2), 'codex', 'working'], + [makePaneKey('tab-1', LEAF_ID_3), 'gemini', 'working'] + ]) + }) + + it('does not recycle a closed split pane’s row onto a surviving sibling', () => { + // Panes 1|2|3 were open; closing pane 1 promotes the surviving pair and clears + // only that pane's slot, leaving the survivors' live ids sparse (2, 3). + const survivingLayout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID_2 }, + second: { type: 'leaf', leafId: LEAF_ID_3 } + }, + activeLeafId: LEAF_ID_2, + expandedLeafId: null + } + const rows = rowsFor({ 2: '⠋ Codex', 3: 'Gemini CLI' }, survivingLayout, ['pty-b', 'pty-c']) + + expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([ + [makePaneKey('tab-1', LEAF_ID_2), 'codex', 'working'], + [makePaneKey('tab-1', LEAF_ID_3), 'gemini', 'idle'] + ]) + expect(rows.some((row) => row.paneKey === makePaneKey('tab-1', LEAF_ID_1))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 70289b4b3fb..b9329bac892 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -9,6 +9,8 @@ import type { AgentStatusState, AgentType } from '../../../../shared/agent-status-types' +import { FIRST_PANE_ID } from '../../../../shared/pane-key' +import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, @@ -68,14 +70,26 @@ export function buildTitleDerivedAgentRows(args: { const paneTitles = runtimePaneTitlesByTabId[tab.id] const paneTitleEntries = paneTitles && Object.keys(paneTitles).length > 0 - ? Object.entries(paneTitles).sort(([a], [b]) => Number(a) - Number(b)) + ? Object.entries(paneTitles).sort(compareRuntimePaneTitleSlots) : [] if (paneTitleEntries.length > 0) { + // Why: hoisted per tab — the leaf lists are layout-derived, not pane-derived. + const leafIds = collectLeafIds(layout?.root ?? null) + const liveSlotIds = paneTitleEntries + .map(([paneId]) => Number(paneId)) + .filter((paneId) => paneId >= FIRST_PANE_ID) + // Why: pane ids only encode creation order while they are the dense sequence a + // fresh mount or replay allocates; an in-session pane close leaves them sparse. + const liveSlotsAreDense = + liveSlotIds.length === leafIds.length && + liveSlotIds.every((paneId, index) => paneId === FIRST_PANE_ID + index) for (const [paneId, title] of paneTitleEntries) { const leafId = resolveLeafIdForTitleFallback({ layout, - paneTitleEntries, + leafIds, + liveSlotIds, + liveSlotsAreDense, paneId: Number(paneId), title }) @@ -266,12 +280,56 @@ function titleStatusToRowState( return 'idle' } +/** + * Orders runtime pane-title slots so live PaneManager ids claim their leaf before + * the synthetic slots a parked tab minted for the same leaf. Revealing a parked tab + * mounts new live slots without clearing the parked ones, so both id spaces coexist + * and the stale parked title must never win the row. + */ +function compareRuntimePaneTitleSlots([a]: [string, string], [b]: [string, string]): number { + const paneIdA = Number(a) + const paneIdB = Number(b) + const isLiveA = paneIdA >= FIRST_PANE_ID + if (isLiveA !== paneIdB >= FIRST_PANE_ID) { + return isLiveA ? -1 : 1 + } + return paneIdA - paneIdB +} + +/** + * Resolves the layout leaf that owns a runtime pane title. + * + * `runtimePaneTitlesByTabId` mixes two disjoint id spaces: live PaneManager ids + * (`>= FIRST_PANE_ID`, allocated in pane-creation order) and the `-(leafIndex + 1)` + * slots parked tabs mint in `fallbackParkedPaneCandidates`. Neither space is ordered + * like the layout's in-order leaf traversal, so attributing a title by its position + * in the slot list lands one pane's status on a sibling's row. + */ function resolveLeafIdForTitleFallback(args: { layout: TerminalLayoutSnapshot | undefined - paneTitleEntries: [string, string][] + leafIds: string[] + liveSlotIds: number[] + liveSlotsAreDense: boolean paneId: number title: string }): string | null { + if (args.leafIds.length === 1) { + return args.leafIds[0] + } + if (args.paneId < FIRST_PANE_ID) { + // Parked slots are defined off the in-order leaf list, so invert that definition. + return args.leafIds[-args.paneId - 1] ?? null + } + if (args.liveSlotsAreDense) { + const creationOrderLeafId = resolveRuntimePaneTitleLeafIdFromRoot( + args.layout?.root, + String(args.paneId) + ) + if (creationOrderLeafId) { + return creationOrderLeafId + } + } + const matchingTitleLeafIds = Object.entries(args.layout?.titlesByLeafId ?? {}) .filter(([, title]) => title === args.title) .map(([leafId]) => leafId) @@ -279,13 +337,10 @@ function resolveLeafIdForTitleFallback(args: { return matchingTitleLeafIds[0] } - const leafIds = collectLeafIds(args.layout?.root ?? null) - if (leafIds.length === 1) { - return leafIds[0] - } - - const paneIndex = args.paneTitleEntries.findIndex(([paneId]) => Number(paneId) === args.paneId) - return paneIndex !== -1 ? (leafIds[paneIndex] ?? null) : null + // Why: in-session pane closes leave the survivors' ids sparse, which creation order + // cannot resolve. Index within the LIVE slots only — never across both id spaces. + const paneIndex = args.liveSlotIds.indexOf(args.paneId) + return paneIndex !== -1 ? (args.leafIds[paneIndex] ?? null) : null } function collectLeafIds(node: TerminalPaneLayoutNode | null): string[] {