Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Comment on lines +73 to +92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Claim the leaf when a live slot resolves.

Sorting only changes processing order. If a live title produces no row, Lines 107-111 do not mark its pane key as seen. A stale parked title for the same leaf can then create an incorrect agent row.

Track resolved live leaf IDs before title classification. Skip parked slots for those leaf IDs. Add a regression case with a plain live title and a stale parked working title.

Proposed fix
+      const liveLeafIds = new Set<string>()
       for (const [paneId, title] of paneTitleEntries) {
+        const numericPaneId = Number(paneId)
         const leafId = resolveLeafIdForTitleFallback({
           layout,
           leafIds,
           liveSlotIds,
           liveSlotsAreDense,
-          paneId: Number(paneId),
+          paneId: numericPaneId,
           title
         })
         if (!leafId) {
           continue
         }
+        const isLiveSlot = numericPaneId >= FIRST_PANE_ID
+        if (!isLiveSlot && liveLeafIds.has(leafId)) {
+          continue
+        }
+        if (isLiveSlot) {
+          liveLeafIds.add(leafId)
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
? 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,
? 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)
const liveLeafIds = new Set<string>()
for (const [paneId, title] of paneTitleEntries) {
const numericPaneId = Number(paneId)
const leafId = resolveLeafIdForTitleFallback({
layout,
leafIds,
liveSlotIds,
liveSlotsAreDense,
paneId: numericPaneId,
title
})
if (!leafId) {
continue
}
const isLiveSlot = numericPaneId >= FIRST_PANE_ID
if (!isLiveSlot && liveLeafIds.has(leafId)) {
continue
}
if (isLiveSlot) {
liveLeafIds.add(leafId)
}

paneId: Number(paneId),
title
})
Expand Down Expand Up @@ -266,26 +280,67 @@ 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)
if (matchingTitleLeafIds.length === 1) {
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[] {
Expand Down