From 02ce66525f2b87b012070a6b02739d92758dbbb5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:10:55 -0700 Subject: [PATCH 1/3] fix(sidebar): stop a closed split pane's title from recycling its agent row (STA-2926) --- .../worktree-title-derived-agent-rows.test.ts | 78 +++++++++++++++++++ .../worktree-title-derived-agent-rows.ts | 75 ++++++++---------- 2 files changed, 109 insertions(+), 44 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..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: { From 2d35d627913e368e478745e0b1a0135170c3d9e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:48:08 -0700 Subject: [PATCH 2/3] fix(sidebar): stop a stale tab title from spinning the worktree dot (STA-2926) The row half of this PR stopped `tab.title` from synthesizing an agent row once every pane's runtime title slot is cleared. The dot half still read that same title, so a closed agent pane left the worktree dot spinning "working" over zero sidebar rows -- the exact "0 agents" symptom #9040 was filed to fix. worktree-status.ts:106-109 states its spinner fallback exists to mirror the row builder's fallback "so the dot and the sidebar row agree", so removing one side and keeping the other breaks the agreement that comment describes. Apply the same rule to the dot: with no pane publishing a title, there is nothing to attribute. #9040's real feature is unaffected -- spinner attribution runs through the pane-title branch, which is untouched. --- .../components/sidebar/WorktreeCard.test.ts | 9 +- ...rktree-status-spinner-launch-agent.test.ts | 122 +++++++++++------- src/renderer/src/lib/worktree-status.test.ts | 25 +++- src/renderer/src/lib/worktree-status.ts | 63 +++++---- 4 files changed, 135 insertions(+), 84 deletions(-) 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/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) } From 37b46351682a695d8a54856511dcb4fec75e47a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:31:35 -0700 Subject: [PATCH 3/3] test(tab-bar): move tab-dot title cases onto the pane-title channel (STA-2926) The tab-bar dot resolves through resolveWorktreeStatus, so the dot fix reaches it too: a `tab.title` no pane is publishing no longer feeds the heuristic. Five cases fed their titles through `tab.title` alone and went red or vacuous; each keeps its original assertion and now publishes the title on a pane, which is where a live title actually lives in production. Adds the negative contract directly -- an agent-shaped tab title with every pane slot cleared resolves to 'active', for both a spinner and a named provider. Restoring the old `tab.title` fallback turns that case red. --- .../terminal-tab-activity-status.test.ts | 7 ++++ .../terminal-tab-spinner-launch-agent.test.ts | 32 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) 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') + } + }) })