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
9 changes: 7 additions & 2 deletions src/renderer/src/components/sidebar/WorktreeCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,21 @@ describe('getWorktreeStatus', () => {
// populated live-pty map so this assertion exercises the live-tab branch.
// Titles are real classifiable shapes: getWorktreeStatus reads the shared
// classifier through pane-agent-evidence, which this file does not mock.
// Why (STA-2926): the dot reads runtime pane titles, so publish each title on a pane rather
// than relying on the tab title, which no longer feeds the heuristic on its own.
const livePtyIds = { 'tab-1': ['pty-1'] }
expect(
getWorktreeStatus(
[makeTerminalTab('Claude - action required')],
[{ id: 'browser-1' }],
livePtyIds
livePtyIds,
{ 'tab-1': { 0: 'Claude - action required' } }
)
).toBe('permission')
expect(
getWorktreeStatus([makeTerminalTab('mimo working')], [{ id: 'browser-1' }], livePtyIds)
getWorktreeStatus([makeTerminalTab('mimo working')], [{ id: 'browser-1' }], livePtyIds, {
'tab-1': { 0: 'mimo working' }
})
).toBe('working')
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { buildWorktreeAgentRows } from './worktree-agent-rows'

const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777'
const LEAF_ID_2 = '88888888-8888-4888-8888-888888888888'
const LEAF_ID_3 = '99999999-9999-4999-8999-999999999999'

function makeTab(id: string, overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
Expand Down Expand Up @@ -382,3 +383,80 @@ describe('buildTitleDerivedAgentRows', () => {
expect(rows).toHaveLength(0)
})
})

// STA-2926 / #11372. Closing a split pane clears that pane's runtime title slot, but nothing
// clears `tab.title` — `resolveTabTitleAfterPaneClose` asks for a reset and
// `getFallbackTabTitle` hands the current (stale) title straight back whenever the tab has no
// customTitle/quickCommandLabel/defaultTitle. The tab title is a tab-scoped mirror of the
// focused pane, so it carries no pane provenance and must never mint a pane row.
describe('buildTitleDerivedAgentRows — closed split pane', () => {
it('does not recycle a closed pane row onto the sibling or a replacement session', () => {
// The agent pane closed; `survivingLeafId` is the leaf the sidebar would attribute the
// orphaned tab title to — the promoted sibling, or a pane opened after the close.
for (const survivingLeafId of [LEAF_ID_1, LEAF_ID_3]) {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: '✳ Claude Code' })],
entries: [],
retained: [],
// Emptied by clearRuntimePaneTitle on the closed pane's PTY exit.
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: { 'tab-1': ['pty-survivor'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(survivingLeafId) },
now: 2000
})

expect(rows).toHaveLength(0)
}
})

// Pre-fix these minted codex/working, codex/idle and gemini/idle respectively — the last one
// an agent the tab never launched, taken purely from the dead pane's leftover title.
it('does not synthesize a row from a stale spinner or identity tab title', () => {
for (const title of ['⠋ Codex', '✳ refactor the resolver', '⏸ Gemini CLI']) {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title, launchAgent: 'codex' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: { 'tab-1': ['pty-survivor'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})

expect(rows).toHaveLength(0)
}
})

it('keeps the surviving pane on its own identity rather than the closed pane title', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: '✳ Claude Code' })],
entries: [],
retained: [],
// The survivor publishes; the closed pane's slot is gone.
runtimePaneTitlesByTabId: { 'tab-1': { 2: '⠋ Codex' } },
ptyIdsByTabId: { 'tab-1': ['pty-survivor'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_2) },
now: 2000
})

expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([
[makePaneKey('tab-1', LEAF_ID_2), 'codex', 'working']
])
})

it('still rows a live pane that publishes its own runtime title', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: '⠋ Codex', launchAgent: 'codex' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Codex' } },
ptyIdsByTabId: { 'tab-1': ['pty-codex'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})

expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([
[makePaneKey('tab-1', LEAF_ID_1), 'codex', 'working']
])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -71,58 +71,45 @@ export function buildTitleDerivedAgentRows(args: {
? Object.entries(paneTitles).sort(([a], [b]) => Number(a) - Number(b))
: []

if (paneTitleEntries.length > 0) {
for (const [paneId, title] of paneTitleEntries) {
const leafId = resolveLeafIdForTitleFallback({
layout,
paneTitleEntries,
paneId: Number(paneId),
title
})
if (!leafId) {
continue
}
const row = buildTitleDerivedAgentRow({
tab,
leafId,
title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
})
if (!row || args.seenPaneKeys.has(row.paneKey)) {
continue
}
rows.push(row)
args.seenPaneKeys.add(row.paneKey)
// Why (STA-2926, #11372): runtimePaneTitles is the only pane-scoped title channel — it is
// written per OSC frame and cleared the moment a pane's PTY exits or its pane closes. A tab
// with none of them has no pane publishing a title, so there is nothing here to row. The
// former `tab.title` fallback recycled a dead pane's row onto whichever leaf survived: the
// tab title is a tab-scoped mirror of the *focused* pane and carries no pane provenance, so
// once its pane closed it could not be attributed to any leaf — it was evidence of nothing.
// Symmetrical with #14650's `isShellProcess` guard, which refuses to resurrect a row from a
// title that is positive evidence the agent exited.
for (const [paneId, title] of paneTitleEntries) {
const leafId = resolveLeafIdForTitleFallback({
layout,
paneTitleEntries,
paneId: Number(paneId),
title
})
if (!leafId) {
continue
}
continue
}

const leafId = layout?.activeLeafId ?? collectLeafIds(layout?.root ?? null)[0]
if (!leafId) {
continue
}
const row = buildTitleDerivedAgentRow({
tab,
leafId,
title: tab.title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
})
if (!row || args.seenPaneKeys.has(row.paneKey)) {
continue
const row = buildTitleDerivedAgentRow({
tab,
leafId,
title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
})
if (!row || args.seenPaneKeys.has(row.paneKey)) {
continue
}
rows.push(row)
args.seenPaneKeys.add(row.paneKey)
}
rows.push(row)
args.seenPaneKeys.add(row.paneKey)
}

return rows
}

/**
* Constructs a dashboard agent row from a terminal tab's title fallback,
* Constructs a dashboard agent row from one pane's live runtime title,
* normalising Pi-compatible agent names to their owner.
*/
function buildTitleDerivedAgentRow(args: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ describe('resolveTerminalTabActivityStatus', () => {
resolveTerminalTabActivityStatus({
tab: { id: TAB_ID, title: 'Codex working' },
agentStatusByPaneKey: { [stale.paneKey]: stale },
// Why (STA-2926): the title has to be live on a pane — a tab title alone no longer
// reaches the heuristic, so publishing it is what makes this a "live" title.
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } },
ptyIdsByTabId: LIVE_PTY
})
).toBe('working')
Expand All @@ -117,6 +120,7 @@ describe('resolveTerminalTabActivityStatus', () => {
resolveTerminalTabActivityStatus({
tab: { id: TAB_ID, title: 'Codex working' },
agentStatusByPaneKey: { [restored.paneKey]: restored },
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } },
ptyIdsByTabId: LIVE_PTY
})
).toBe('active')
Expand Down Expand Up @@ -207,6 +211,9 @@ describe('resolveTerminalTabActivityStatus', () => {
expect(
resolveTerminalTabActivityStatus({
tab: { id: TAB_ID, title: 'zsh' },
// Published so the shell title actually reaches the classifier rather than stopping at
// the no-pane-titles guard (STA-2926).
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'zsh' } },
ptyIdsByTabId: LIVE_PTY
})
).toBe('active')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,55 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent
resetTerminalTabActivityFlagsCacheForTest()
})

it('reports working for a Claude spinner title on a live tab', () => {
it('reports working for a Claude spinner pane title on a live tab', () => {
const status = resolveTerminalTabActivityStatus({
tab: {
id: 'tab-1',
title: '⠋ implementing the feature',
title: 'bash',
launchAgent: 'claude'
} satisfies Partial<TerminalTab> as TerminalTab,
runtimePaneTitlesByTabId: { 'tab-1': { 0: '⠋ implementing the feature' } },
ptyIdsByTabId: { 'tab-1': ['pty-0'] }
})

expect(status).toBe('working')
})

// Control: the named-provider path this must stay at parity with.
it('reports working for a named-provider title', () => {
it('reports working for a named-provider pane title', () => {
const status = resolveTerminalTabActivityStatus({
tab: { id: 'tab-1', title: 'claude [working]' } as TerminalTab,
tab: { id: 'tab-1', title: 'bash' } as TerminalTab,
runtimePaneTitlesByTabId: { 'tab-1': { 0: 'claude [working]' } },
ptyIdsByTabId: { 'tab-1': ['pty-0'] }
})

expect(status).toBe('working')
})

it('stays out of working for a spinner title with no launch identity', () => {
it('stays out of working for a spinner pane title with no launch identity', () => {
const status = resolveTerminalTabActivityStatus({
tab: { id: 'tab-1', title: '⠐ Review branch for regressions' } as TerminalTab,
tab: { id: 'tab-1', title: 'bash' } as TerminalTab,
runtimePaneTitlesByTabId: { 'tab-1': { 0: '⠐ Review branch for regressions' } },
ptyIdsByTabId: { 'tab-1': ['pty-0'] }
})

expect(status).not.toBe('working')
})

// Why (STA-2926): the tab-bar dot resolves through the same heuristic as the sidebar, so it
// inherits the same rule — a title left on the tab after its pane stopped publishing is a
// stale leftover with no pane to attribute it to, and must not light the dot.
it('does not report working for an agent title no pane is publishing', () => {
for (const tab of [
{ id: 'tab-1', title: '⠋ implementing the feature', launchAgent: 'claude' },
{ id: 'tab-1', title: 'claude [working]' }
] satisfies Partial<TerminalTab>[]) {
const status = resolveTerminalTabActivityStatus({
tab: tab as TerminalTab,
ptyIdsByTabId: { 'tab-1': ['pty-0'] }
})

expect(status).toBe('active')
}
})
})
Loading