diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts index 35c351f3457..9ca42671c30 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts @@ -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') }) }) 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..ae7440efa82 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 @@ -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 { return { @@ -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'] + ]) + }) +}) 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..ca5e7565120 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 @@ -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: { diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index e475ae49a4d..fc4bff075a0 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -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') @@ -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') @@ -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') diff --git a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts index caa14841166..2c931c80942 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts @@ -14,13 +14,14 @@ 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 as TerminalTab, + runtimePaneTitlesByTabId: { 'tab-1': { 0: '⠋ implementing the feature' } }, ptyIdsByTabId: { 'tab-1': ['pty-0'] } }) @@ -28,21 +29,40 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent }) // 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[]) { + const status = resolveTerminalTabActivityStatus({ + tab: tab as TerminalTab, + ptyIdsByTabId: { 'tab-1': ['pty-0'] } + }) + + expect(status).toBe('active') + } + }) }) diff --git a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts index 78b71ef25da..15afaecd17d 100644 --- a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts +++ b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts @@ -30,20 +30,10 @@ function rowCount(tab: Partial, paneTitles?: Record // Why: #9040 — Claude's thinking title is a braille spinner plus task text with no // provider token, so the dot's attribution gate rejected it and the worktree resolved -// to 'active', which renders the same emerald dot as 'done'. The sidebar row builder -// already falls back to the tab's launch identity for spinner titles (#9647); the dot -// must agree. +// to 'active', which renders the same emerald dot as 'done'. The sidebar row builder falls +// back to the pane's launch identity for spinner titles (#9647); the dot must agree. Both +// halves now read the same pane-scoped channel — see the STA-2926 cases below. describe('#9040 worktree dot attributes spinner titles to the launched agent', () => { - it('spins for a Claude spinner title when the tab was launched as claude', () => { - const status = getWorktreeStatus( - [{ id: 'tab-1', title: '⠋ implementing the feature', launchAgent: 'claude' }], - [], - livePtyMap('tab-1') - ) - - expect(status).toBe('working') - }) - it('spins for a spinner-only pane title when the tab was launched as claude', () => { const status = getWorktreeStatus( [{ id: 'tab-1', title: 'bash', launchAgent: 'claude' }], @@ -55,11 +45,12 @@ describe('#9040 worktree dot attributes spinner titles to the launched agent', ( expect(status).toBe('working') }) - // Why: pins the #9647 gate — spinner attribution needs a launch identity, so a - // spinner in a tab no agent was launched in cannot spin the dot. - it('stays active for a spinner title with no launch identity', () => { + // Why (STA-2926): a spinner surviving only on `tab.title` is the leftover of a pane that + // already closed — no pane is publishing it. It must not spin the dot, because the row + // builder will not mint a row from it and the dot may never spin over zero rows. + it('does not spin for a Claude spinner tab title no pane is publishing', () => { const status = getWorktreeStatus( - [{ id: 'tab-1', title: '⠐ Review branch for regressions' }], + [{ id: 'tab-1', title: '⠋ implementing the feature', launchAgent: 'claude' }], [], livePtyMap('tab-1') ) @@ -67,11 +58,23 @@ describe('#9040 worktree dot attributes spinner titles to the launched agent', ( expect(status).toBe('active') }) - it('does not manufacture activity from a non-spinner title with a launch identity', () => { + // Why: pins the #9647 gate — spinner attribution needs a launch identity, so a + // spinner in a tab no agent was launched in cannot spin the dot. Published on a pane so the + // assertion exercises that gate rather than stopping at the no-pane-titles guard above. + it('stays active for a spinner pane title with no launch identity', () => { + const status = getWorktreeStatus([{ id: 'tab-1', title: 'bash' }], [], livePtyMap('tab-1'), { + 'tab-1': { 0: '⠐ Review branch for regressions' } + }) + + expect(status).toBe('active') + }) + + it('does not manufacture activity from a non-spinner pane title with a launch identity', () => { const status = getWorktreeStatus( [{ id: 'tab-1', title: 'bash', launchAgent: 'claude' }], [], - livePtyMap('tab-1') + livePtyMap('tab-1'), + { 'tab-1': { 0: 'bash' } } ) expect(status).toBe('active') @@ -86,19 +89,18 @@ describe('#9040 worktree dot attributes spinner titles to the launched agent', ( // deliberate choice rather than drifting silently. describe('#9040 spinner attribution trade-off is bounded', () => { it('over-reports a non-agent spinner in a tab an agent was launched in', () => { - const tab = { - id: 'tab-1', - title: '⠋ Progress: resolved 42', - launchAgent: 'claude' - } satisfies Partial + const tab = { id: 'tab-1', title: 'bash', launchAgent: 'claude' } satisfies Partial + // The spinner is published by a live pane; a tab-title-only spinner no longer reaches the + // heuristic at all (STA-2926), so the trade-off is pinned where it is still real. + const paneTitles = { 'tab-1': { 0: '⠋ Progress: resolved 42' } } - expect(getWorktreeStatus([tab], [], livePtyMap('tab-1'))).toBe('working') - // Bound 1: the same title cannot spin a tab with no launch identity. - expect(getWorktreeStatus([{ id: 'tab-1', title: tab.title }], [], livePtyMap('tab-1'))).toBe( - 'active' - ) + expect(getWorktreeStatus([tab], [], livePtyMap('tab-1'), paneTitles)).toBe('working') + // Bound 1: the same pane title cannot spin a tab with no launch identity. + expect( + getWorktreeStatus([{ id: 'tab-1', title: 'bash' }], [], livePtyMap('tab-1'), paneTitles) + ).toBe('active') // Bound 2: a dead PTY drops out regardless of launch identity. - expect(getWorktreeStatus([tab], [], {})).toBe('inactive') + expect(getWorktreeStatus([tab], [], {}, paneTitles)).toBe('inactive') }) }) @@ -109,28 +111,54 @@ describe('#9040 spinner attribution matches named-provider dot/row agreement', ( it('produces a sidebar row alongside the dot, like a named provider does', () => { const spinnerTab = { id: 'tab-1', - title: '⠋ implementing the feature', + title: 'bash', launchAgent: 'claude' } satisfies Partial - const namedTab = { id: 'tab-1', title: 'claude [working]' } - - expect(getWorktreeStatus([spinnerTab], [], livePtyMap('tab-1'))).toBe('working') - expect(rowCount(spinnerTab)).toBe(1) - // Control: the pre-existing named-provider path resolves to the same pair. - expect(getWorktreeStatus([namedTab], [], livePtyMap('tab-1'))).toBe('working') - expect(rowCount(namedTab)).toBe(1) - }) - - it('agrees for a spinner pane title too', () => { - const tab = { id: 'tab-1', title: 'bash', launchAgent: 'claude' } satisfies Partial - const paneTitles = { 'tab-1': { 0: '⠙ refactoring the parser' } } + const spinnerPaneTitles = { 0: '⠋ implementing the feature' } + const namedTab = { id: 'tab-1', title: 'bash' } + const namedPaneTitles = { 0: 'claude [working]' } const layouts = { 'tab-1': singleLeafLayout() } expect( - getWorktreeStatus([tab], [], livePtyMap('tab-1'), paneTitles, { - terminalLayoutsByTabId: layouts - }) + getWorktreeStatus( + [spinnerTab], + [], + livePtyMap('tab-1'), + { 'tab-1': spinnerPaneTitles }, + { + terminalLayoutsByTabId: layouts + } + ) + ).toBe('working') + expect(rowCount(spinnerTab, spinnerPaneTitles)).toBe(1) + // Control: the pre-existing named-provider path resolves to the same pair. + expect( + getWorktreeStatus( + [namedTab], + [], + livePtyMap('tab-1'), + { 'tab-1': namedPaneTitles }, + { + terminalLayoutsByTabId: layouts + } + ) ).toBe('working') - expect(rowCount(tab, paneTitles['tab-1'])).toBe(1) + expect(rowCount(namedTab, namedPaneTitles)).toBe(1) + }) + + // Why (STA-2926): the agreement is load-bearing in the negative direction too. Once no pane + // publishes a title, the closed pane's leftover tab title must move neither half. Previously + // it moved both — minting a recycled row on the surviving leaf — and a half-applied fix that + // removed only the row would have stranded a dot spinning "working" over zero agents. + it('moves neither the dot nor a row when no pane publishes a title', () => { + const tabs = [ + { id: 'tab-1', title: '⠋ implementing the feature', launchAgent: 'claude' }, + { id: 'tab-1', title: 'claude [working]' } + ] satisfies Partial[] + + for (const tab of tabs) { + expect(getWorktreeStatus([tab], [], livePtyMap('tab-1'))).toBe('active') + expect(rowCount(tab)).toBe(0) + } }) }) diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index bdea2105785..91285e67c60 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -34,7 +34,10 @@ describe('getWorktreeStatus', () => { { id: 'tab-2', title: 'claude [permission]' } ], [{ id: 'browser-1' }], - livePtyMap('tab-1', 'tab-2') + livePtyMap('tab-1', 'tab-2'), + // Why: the status must come from panes that are actually publishing — a tab title alone no + // longer feeds the dot (STA-2926). + { 'tab-1': { 0: 'claude [working]' }, 'tab-2': { 0: 'claude [permission]' } } ) expect(status).toBe('permission') @@ -129,15 +132,29 @@ describe('getWorktreeStatus', () => { expect(status).toBe('active') }) - it('still spins on an agent-attributable braille-spinner title', () => { + it('still spins on an agent-attributable braille-spinner pane title', () => { const status = getWorktreeStatus( [{ id: 'tab-1', title: '⠹ codex fix flaky test' }], [], - livePtyMap('tab-1') + livePtyMap('tab-1'), + { 'tab-1': { 0: '⠹ codex fix flaky test' } } ) expect(status).toBe('working') }) + + // Why (STA-2926): once every pane's title slot is cleared, `tab.title` is a provenance-free + // leftover of a pane that is gone. It used to keep the dot spinning over zero sidebar rows; + // the row builder refuses to mint a row from it, so the dot must refuse to spin on it too. + it('does not spin on an agent title no pane is publishing', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: 'claude [working]' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('active') + }) }) describe('resolveWorktreeStatus', () => { @@ -259,6 +276,7 @@ describe('resolveWorktreeStatus', () => { tabs: [{ id: 'tab-1', title: 'claude [working]' }], browserTabs: [], ptyIdsByTabId: livePtyMap('tab-1'), + runtimePaneTitlesByTabId: { 'tab-1': { 0: 'claude [working]' } }, hasPermission: false, hasLiveWorking: false, hasLiveDone: true, @@ -352,6 +370,7 @@ describe('resolveWorktreeStatus', () => { tabs: [{ id: 'tab-1', title: 'claude [permission]' }], browserTabs: [], ptyIdsByTabId: livePtyMap('tab-1'), + runtimePaneTitlesByTabId: { 'tab-1': { 0: 'claude [permission]' } }, hasPermission: false, hasLiveWorking: false, hasLiveDone: true, diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 91470f75409..082465df020 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -63,39 +63,38 @@ function tabHasStatus( ): boolean { const agentStatusPaneIds = options.agentStatusPaneIdsByTabId?.[tab.id] const paneTitles = runtimePaneTitlesByTabId[tab.id] - if (paneTitles && Object.keys(paneTitles).length > 0) { - const tabLayoutRoot = - options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root - const paneTitleEntries = Object.entries(paneTitles) - for (const [runtimePaneId, title] of paneTitleEntries) { - const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId) - // Why: runtime titles can precede layout hydration (SSH/replay); with one title and one agent row, prefer that row over a stale spinner. - const hasSingleUnmappedAgentStatusPane = - leafId === null && agentStatusPaneIds?.size === 1 && paneTitleEntries.length === 1 - if ( - agentStatusPaneIds?.has(runtimePaneId) || - (leafId !== null && agentStatusPaneIds?.has(leafId)) || - hasSingleUnmappedAgentStatusPane - ) { - continue - } - if ( - classifyTitleActivity(title) === status && - titleStatusIsAgentAttributable(title, tab.launchAgent) - ) { - return true - } - } + // Why (STA-2926, #11372): runtime pane titles are the only pane-scoped title channel, and + // clearRuntimePaneTitle drops a pane's slot the moment its PTY exits. A tab with no slots left + // has nothing publishing a title, so `tab.title` — a provenance-free mirror of the last focused + // pane — is a stale leftover rather than evidence of live work. The row builder refuses to mint + // a row from that same title, and the dot must agree with the row (#9040) instead of spinning + // "working" over zero agents. + if (!paneTitles || Object.keys(paneTitles).length === 0) { return false } - // Why: a tab title can't identify its pane; once an agent row owns one, prefer the row over a completed pane's stale "working" title. - if (agentStatusPaneIds && agentStatusPaneIds.size > 0) { - return false + const tabLayoutRoot = + options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root + const paneTitleEntries = Object.entries(paneTitles) + for (const [runtimePaneId, title] of paneTitleEntries) { + const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId) + // Why: runtime titles can precede layout hydration (SSH/replay); with one title and one agent row, prefer that row over a stale spinner. + const hasSingleUnmappedAgentStatusPane = + leafId === null && agentStatusPaneIds?.size === 1 && paneTitleEntries.length === 1 + if ( + agentStatusPaneIds?.has(runtimePaneId) || + (leafId !== null && agentStatusPaneIds?.has(leafId)) || + hasSingleUnmappedAgentStatusPane + ) { + continue + } + if ( + classifyTitleActivity(title) === status && + titleStatusIsAgentAttributable(title, tab.launchAgent) + ) { + return true + } } - return ( - classifyTitleActivity(tab.title) === status && - titleStatusIsAgentAttributable(tab.title, tab.launchAgent) - ) + return false } // Why: require agent attribution so a bare never-cleared spinner title can't spin the dot "0 agents" forever with no matching sidebar row. @@ -104,8 +103,8 @@ function titleStatusIsAgentAttributable(title: string, launchAgent?: TuiAgent | return true } // Why: a spinner proves activity but not identity (Claude's thinking title has no provider - // token, #9040); the tab's launch identity supplies it, mirroring the row builder's spinner - // fallback (#9647) so the dot and the sidebar row agree. + // token, #9040); the tab's launch identity supplies it, mirroring the row builder's own + // pane-title owner fallback (#9647) so the dot and the sidebar row agree. return containsAgentSpinnerGlyph(title) && Boolean(launchAgent) }