From 7ba336099c2283ab41baf027fba362de0f129e86 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:47:14 -0700 Subject: [PATCH] fix(sidebar): name each split-pane agent row from its own pane title (STA-2811) (#14707) Agent rows are per pane, but their conversation name came from `tab.title`, which carries only the FOCUSED pane's title. In a split tab every row showed one pane's name, and all of them changed when the user clicked a sibling. Rows on a multi-pane tab now resolve their own leaf's runtime pane title via the existing `resolveRuntimePaneTitleForLeaf`, and fall back to no live title rather than a sibling's. Single-pane tabs pass `undefined` and are unchanged. Extracts the subagent grouping out of build-dashboard-snapshot.ts, which was exactly at the 300-line cap. --- .../agent-row-pane-live-title.test.ts | 68 +++++++++++ .../dashboard/agent-row-pane-live-title.ts | 29 +++++ .../build-dashboard-snapshot.test.ts | 41 +++++++ .../dashboard/build-dashboard-snapshot.ts | 41 ++----- .../dashboard/dashboard-card-labels.ts | 16 ++- .../dashboard/dashboard-subagent-cards.ts | 35 ++++++ .../use-agent-row-conversation-name.test.ts | 115 +++++++++++++++++- .../use-agent-row-conversation-name.ts | 23 +++- .../agent-row-conversation-name.test.ts | 28 +++++ src/shared/agent-row-conversation-name.ts | 11 +- 10 files changed, 371 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts create mode 100644 src/renderer/src/components/dashboard/agent-row-pane-live-title.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-subagent-cards.ts diff --git a/src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts b/src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts new file mode 100644 index 00000000000..562c3300c13 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +const SPLIT: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId: LEAF_A, + expandedLeafId: null +} + +describe('resolveAgentRowPaneLiveTitle', () => { + it('returns undefined for a single-pane tab, where the tab title is the pane title', () => { + const single: TerminalLayoutSnapshot = { + root: { type: 'leaf', leafId: LEAF_A }, + activeLeafId: LEAF_A, + expandedLeafId: null + } + expect(resolveAgentRowPaneLiveTitle(single, { 1: '✳ Redis cache' }, LEAF_A)).toBeUndefined() + expect(resolveAgentRowPaneLiveTitle(undefined, { 1: '✳ Redis cache' }, LEAF_A)).toBeUndefined() + }) + + it('gives each leaf of a split its own runtime pane title', () => { + const titles = { 1: '✳ Linear work log', 2: '✳ Redis cache strategy' } + expect(resolveAgentRowPaneLiveTitle(SPLIT, titles, LEAF_A)).toBe('✳ Linear work log') + expect(resolveAgentRowPaneLiveTitle(SPLIT, titles, LEAF_B)).toBe('✳ Redis cache strategy') + }) + + it('returns null rather than a sibling title when the pane has no slot', () => { + expect(resolveAgentRowPaneLiveTitle(SPLIT, { 1: '✳ Linear work log' }, LEAF_B)).toBeNull() + expect(resolveAgentRowPaneLiveTitle(SPLIT, undefined, LEAF_A)).toBeNull() + // A leaf that is not in this layout must never inherit a pane title. + expect(resolveAgentRowPaneLiveTitle(SPLIT, { 1: 'a', 2: 'b' }, LEAF_C)).toBeNull() + // An unparseable paneKey yields no leaf id, which must suppress, not guess. + expect(resolveAgentRowPaneLiveTitle(SPLIT, { 1: 'a', 2: 'b' }, undefined)).toBeNull() + }) + + it('walks replay creation order, not tree order, for a nested split', () => { + // Left pane split again: tree order is [A, C, B], creation order is [A, B, C]. + const nested: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'horizontal', + first: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_C } + }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId: LEAF_A, + expandedLeafId: null + } + const titles = { 1: 'first', 2: 'second', 3: 'third' } + expect(resolveAgentRowPaneLiveTitle(nested, titles, LEAF_A)).toBe('first') + expect(resolveAgentRowPaneLiveTitle(nested, titles, LEAF_B)).toBe('second') + expect(resolveAgentRowPaneLiveTitle(nested, titles, LEAF_C)).toBe('third') + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-row-pane-live-title.ts b/src/renderer/src/components/dashboard/agent-row-pane-live-title.ts new file mode 100644 index 00000000000..ddcd386907d --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-row-pane-live-title.ts @@ -0,0 +1,29 @@ +import { resolveRuntimePaneTitleForLeaf } from '@/lib/runtime-pane-title-leaf-id' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' + +/** + * The live terminal title of the pane an agent row belongs to, for tabs holding + * more than one pane. + * + * `undefined` means the tab holds a single pane, so its tab title already IS + * that pane's title and the caller should keep using it. `null` means the tab + * is split but this pane's own title could not be attributed, so no live title + * belongs to the row — showing the tab title there would show a sibling's. + * + * Cost: a single-pane tab pays one property read and returns, so the common tab + * shape is unchanged. A split tab walks only its own pane-title slots against + * its own layout tree; no global map is scanned. + */ +export function resolveAgentRowPaneLiveTitle( + layout: TerminalLayoutSnapshot | undefined, + paneTitles: Record | undefined, + leafId: string | null | undefined +): string | null | undefined { + if (layout?.root?.type !== 'split') { + return undefined + } + if (!leafId) { + return null + } + return resolveRuntimePaneTitleForLeaf(layout, paneTitles, leafId) +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index 884a8ebcaf9..ad032c1684d 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -48,6 +48,7 @@ const LEAF_ID = '11111111-1111-4111-8111-111111111111' const CHILD_LEAF_ID = '33333333-3333-4333-8333-333333333333' const GRANDCHILD_LEAF_ID = '44444444-4444-4444-8444-444444444444' const GONE_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const SPLIT_SIBLING_LEAF_ID = '55555555-5555-4555-8555-555555555555' const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) const CHILD_PANE_KEY = makePaneKey(TAB_ID, CHILD_LEAF_ID) const GRANDCHILD_PANE_KEY = makePaneKey(TAB_ID, GRANDCHILD_LEAF_ID) @@ -336,6 +337,46 @@ describe('buildDashboardSnapshot', () => { expect(unnamed.cards[0].conversationName).toBeUndefined() }) + // STA-2811: both panes of a split tab carried the focused pane's title. + it('names each pane of a split tab from its own title', () => { + const siblingPaneKey = makePaneKey(TAB_ID, SPLIT_SIBLING_LEAF_ID) + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({}), + [siblingPaneKey]: entry({ paneKey: siblingPaneKey }) + }, + // The tab title is whichever pane has focus, so it must not name both. + tabsByWorktree: { w1: [{ ...tab(), title: '\u2733 Linear work log' }] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SPLIT_SIBLING_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty1', [SPLIT_SIBLING_LEAF_ID]: 'pty2' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty2'] }, + // Pane ids are replay-creation-ordered: 1 -> first leaf, 2 -> second. + runtimePaneTitlesByTabId: { + [TAB_ID]: { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' } + } + }), + NOW + ) + + const nameByPaneKey = new Map( + snapshot.cards.map((card) => [card.paneKey, card.conversationName]) + ) + expect(nameByPaneKey.get(PANE_KEY)).toBe('Linear work log') + expect(nameByPaneKey.get(siblingPaneKey)).toBe('Redis cache strategy') + }) + // Why: `orca terminal rename --title` is unbounded, and the main-process // validator drops any card whose label exceeds the shared bound. it('truncates labels to the length the snapshot validator accepts', () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 53e959f489c..d6d5e6545ff 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -4,7 +4,6 @@ import { dashboardCardDisplayState, type DashboardCard, type DashboardCardDotState, - type DashboardCardSubagent, type DashboardSnapshot, type DashboardWorkspace } from '../../../../shared/dashboard-snapshot' @@ -56,6 +55,7 @@ import { } from './dashboard-worktree-launch-options' import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options' import { dashboardBucketForDotState } from './dashboard-card-bucket' +import { groupSubagentsByParentPaneKey } from './dashboard-subagent-cards' /** The store slices the snapshot builder reads. Kept as a Pick so unit tests * can pass a partial store without constructing the whole AppState. */ @@ -141,13 +141,14 @@ export function buildDashboardSnapshot( ] : liveEntries const terminalLayoutsByTabId = selectTerminalLayoutsForWorktree(state, worktreeId) + const paneTitlesByTabId = selectRuntimePaneTitlesForWorktree(state, worktreeId) const rows = applyAgentRowLineage( buildWorktreeAgentRows({ tabs: state.tabsByWorktree[worktreeId] ?? [], entries, retained: selectRetainedAgentEntriesForWorktree(state, worktreeId), - runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId), + runtimePaneTitlesByTabId: paneTitlesByTabId, ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId), terminalLayoutsByTabId, runtimeAgentOrchestrationByPaneKey: @@ -158,33 +159,8 @@ export function buildDashboardSnapshot( }) ) const subagentsByParentPaneKey = includeCardDetails - ? new Map() + ? groupSubagentsByParentPaneKey(rows) : undefined - if (subagentsByParentPaneKey) { - for (const row of rows) { - if (row.rowSource !== 'subagent') { - continue - } - const parentPaneKey = row.entry.orchestration?.parentPaneKey - if (!parentPaneKey) { - continue - } - const subagent: DashboardCardSubagent = { - id: row.paneKey, - name: - nonEmpty(row.entry.orchestration?.displayName) ?? - nonEmpty(row.entry.prompt) ?? - row.agentType, - dotState: row.state - } - const existing = subagentsByParentPaneKey.get(parentPaneKey) - if (existing) { - existing.push(subagent) - } else { - subagentsByParentPaneKey.set(parentPaneKey, [subagent]) - } - } - } const context = includeCardDetails ? resolveDashboardCardContext(state, repo, worktree) : undefined @@ -303,7 +279,14 @@ export function buildDashboardSnapshot( // board and the sidebar bold/mute the same agents at the same time. unseen, askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined, - conversationName: boundedLabelOrUndefined(rowConversationName(row, generatedTitlesEnabled)), + conversationName: boundedLabelOrUndefined( + rowConversationName( + row, + generatedTitlesEnabled, + terminalLayoutsByTabId[row.tab.id], + paneTitlesByTabId[row.tab.id] + ) + ), ...(terminalInput ? { terminalInput } : {}) }) } diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts index df4f5f99d34..c6ea3a52bec 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -1,6 +1,8 @@ import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapshot' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' import type { DashboardAgentRow } from './useDashboardData' export function rowTask(row: DashboardAgentRow): string { @@ -28,7 +30,9 @@ export function boundedLabelOrUndefined(value: string | undefined): string | und * same agent with the same name. */ export function rowConversationName( row: DashboardAgentRow, - generatedTitlesEnabled: boolean + generatedTitlesEnabled: boolean, + layout: TerminalLayoutSnapshot | undefined, + paneTitles: Record | undefined ): string | undefined { const parentPaneKey = row.entry.orchestration?.parentPaneKey // Why: a child row rendered on its parent's tab does not own that tab's name. @@ -39,5 +43,13 @@ export function rowConversationName( ) { return undefined } - return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined + const paneLiveTitle = resolveAgentRowPaneLiveTitle( + layout, + paneTitles, + parsePaneKey(row.paneKey)?.leafId + ) + return ( + getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled, paneLiveTitle) ?? + undefined + ) } diff --git a/src/renderer/src/components/dashboard/dashboard-subagent-cards.ts b/src/renderer/src/components/dashboard/dashboard-subagent-cards.ts new file mode 100644 index 00000000000..70318997860 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-subagent-cards.ts @@ -0,0 +1,35 @@ +import type { DashboardCardSubagent } from '../../../../shared/dashboard-snapshot' +import { nonEmpty } from './dashboard-card-labels' +import type { DashboardAgentRow } from './useDashboardData' + +/** Subagent child rows, grouped under the parent pane whose session spawned + * them — they have no pane of their own, so the board nests them on the card. */ +export function groupSubagentsByParentPaneKey( + rows: readonly DashboardAgentRow[] +): Map { + const byParentPaneKey = new Map() + for (const row of rows) { + if (row.rowSource !== 'subagent') { + continue + } + const parentPaneKey = row.entry.orchestration?.parentPaneKey + if (!parentPaneKey) { + continue + } + const subagent: DashboardCardSubagent = { + id: row.paneKey, + name: + nonEmpty(row.entry.orchestration?.displayName) ?? + nonEmpty(row.entry.prompt) ?? + row.agentType, + dotState: row.state + } + const existing = byParentPaneKey.get(parentPaneKey) + if (existing) { + existing.push(subagent) + } else { + byParentPaneKey.set(parentPaneKey, [subagent]) + } + } + return byParentPaneKey +} diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts index a56d0c24cfd..f6aca88795b 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts @@ -7,14 +7,21 @@ const storeState = vi.hoisted(() => ({ current: { settings: {}, tabsByWorktree: {} } as { settings: Record tabsByWorktree: Record + terminalLayoutsByTabId?: Record + runtimePaneTitlesByTabId?: Record } })) // Why: the mocked selector makes the hook a pure function, so tests can call it -// directly without mounting a component. +// directly without mounting a component. The pane maps default to empty so each +// test declares only the ones it exercises. vi.mock('@/store', () => ({ useAppStore: (selector: (state: AppState) => unknown) => - selector(storeState.current as unknown as AppState) + selector({ + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + ...storeState.current + } as unknown as AppState) })) function makeAgent(overrides: Partial = {}): DashboardAgentRow { @@ -141,6 +148,110 @@ describe('useAgentRowConversationName', () => { expect(useAgentRowConversationName(makeAgent())).toBe('Renamed later') }) + // STA-2811 / #11069: both panes of a split tab showed one name that flipped to + // whichever pane was clicked last, because tab.title tracks only the focused pane. + describe('split panes', () => { + const LEAF_A = '11111111-1111-4111-8111-111111111111' + const LEAF_B = '22222222-2222-4222-8222-222222222222' + const SPLIT_LAYOUT = { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId: LEAF_A, + expandedLeafId: null + } + + // Why: pty-connection syncs the FOCUSED pane's title onto the tab, so the tab + // snapshot every row carries names one pane and mislabels the other. + function splitRow(leafId: string, focusedPaneTitle: string): DashboardAgentRow { + return makeAgent({ + paneKey: `tab-1:${leafId}`, + tab: { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: focusedPaneTitle } + } as Partial) + } + + function setSplitStore(focusedPaneTitle: string, customTitle: string | null = null): void { + storeState.current = { + settings: {}, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', worktreeId: 'wt-1', customTitle, title: focusedPaneTitle }] + }, + terminalLayoutsByTabId: { 'tab-1': SPLIT_LAYOUT }, + // Pane ids are replay-creation-ordered: 1 -> LEAF_A, 2 -> LEAF_B. + runtimePaneTitlesByTabId: { + 'tab-1': { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' } + } + } + } + + it('gives each pane in one tab its own name', () => { + setSplitStore('\u2733 Linear work log') + expect(useAgentRowConversationName(splitRow(LEAF_A, '\u2733 Linear work log'))).toBe( + 'Linear work log' + ) + expect(useAgentRowConversationName(splitRow(LEAF_B, '\u2733 Linear work log'))).toBe( + 'Redis cache strategy' + ) + }) + + it('does not rename the sibling row when the other pane is clicked', () => { + // Clicking pane B re-syncs the tab title to B's; both rows must be unmoved. + setSplitStore('\u2733 Redis cache strategy') + expect(useAgentRowConversationName(splitRow(LEAF_A, '\u2733 Redis cache strategy'))).toBe( + 'Linear work log' + ) + expect(useAgentRowConversationName(splitRow(LEAF_B, '\u2733 Redis cache strategy'))).toBe( + 'Redis cache strategy' + ) + }) + + it('lends no name to a split pane with no live title of its own', () => { + // Why: the tab title here is the SIBLING's, so the row keeps its own label. + storeState.current = { + settings: {}, + tabsByWorktree: { + 'wt-1': [ + { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '\u2733 Linear work log' } + ] + }, + terminalLayoutsByTabId: { 'tab-1': SPLIT_LAYOUT }, + runtimePaneTitlesByTabId: { 'tab-1': { 1: '\u2733 Linear work log' } } + } + expect(useAgentRowConversationName(splitRow(LEAF_B, '\u2733 Linear work log'))).toBeNull() + }) + + it('still lends a tab-owned name to every pane', () => { + setSplitStore('\u2733 Linear work log', 'Patient sync spike') + expect(useAgentRowConversationName(splitRow(LEAF_A, '\u2733 Linear work log'))).toBe( + 'Patient sync spike' + ) + expect(useAgentRowConversationName(splitRow(LEAF_B, '\u2733 Linear work log'))).toBe( + 'Patient sync spike' + ) + }) + + it('leaves a single-leaf tab on its tab title', () => { + storeState.current = { + settings: {}, + tabsByWorktree: { + 'wt-1': [ + { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '\u2733 Redis cache' } + ] + }, + terminalLayoutsByTabId: { + 'tab-1': { root: { type: 'leaf', leafId: LEAF_A }, activeLeafId: LEAF_A } + }, + runtimePaneTitlesByTabId: {} + } + expect(useAgentRowConversationName(splitRow(LEAF_A, '\u2733 Redis cache'))).toBe( + 'Redis cache' + ) + }) + }) + it('honors the generated-titles setting for generated names', () => { const agent = makeAgent({ tab: { customTitle: null, title: '', generatedTitle: 'Fix intake flow' } diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts index 820fea5da0b..692d0cc20f1 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts @@ -1,5 +1,6 @@ import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { DashboardAgentRow } from './useDashboardData' @@ -39,10 +40,30 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | ? undefined : getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id) ) + // Why: parsed per render rather than inside the selector, which runs on every + // store update and must stay allocation-free. + const ownLeafId = cannotOwnTabName ? null : parsePaneKey(agent.paneKey)?.leafId + // Why: in a split tab the tab title belongs to whichever pane has focus, so + // this row reads its OWN pane's title. Returns a primitive, so a row + // re-renders only when its own pane's title changes. + const paneLiveTitle = useAppStore((s) => + cannotOwnTabName + ? undefined + : resolveAgentRowPaneLiveTitle( + s.terminalLayoutsByTabId?.[agent.tab.id], + s.runtimePaneTitlesByTabId?.[agent.tab.id], + ownLeafId + ) + ) // Why: synthetic and same-tab child rows do not own the parent tab's name. if (cannotOwnTabName) { return null } // Why: retained row snapshots need a fallback after their live tab disappears. - return getAgentRowConversationName(liveTab ?? agent.tab, agent.agentType, generatedTitlesEnabled) + return getAgentRowConversationName( + liveTab ?? agent.tab, + agent.agentType, + generatedTitlesEnabled, + paneLiveTitle + ) } diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index d838ff53dcf..cf668627d71 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -37,6 +37,34 @@ describe('getAgentRowConversationName', () => { expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug') }) + it('names a split pane from its own live title, not the tab title', () => { + // Why: the tab title is the FOCUSED pane's, so the sibling must not read it. + const tab = makeTab({ title: '\u2733 Linear work log' }) + expect(getAgentRowConversationName(tab, 'claude', false, '\u2733 Redis cache strategy')).toBe( + 'Redis cache strategy' + ) + // OpenCode's semantic title is a live title too, so it follows the pane. + expect( + getAgentRowConversationName(tab, 'opencode', false, 'OC | build the release pipeline') + ).toBe('OC | build the release pipeline') + // No resolvable pane title: no live title at all, never the sibling's. + expect(getAgentRowConversationName(tab, 'claude', false, null)).toBeNull() + // A single-pane tab passes undefined and is untouched. + expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Linear work log') + }) + + it('keeps tab-owned names above the pane title', () => { + // Why: the user gave these to the whole tab, and none of them flip on focus. + const custom = makeTab({ customTitle: 'Patient sync spike' }) + expect(getAgentRowConversationName(custom, 'claude', false, 'Redis cache strategy')).toBe( + 'Patient sync spike' + ) + const quick = makeTab({ quickCommandLabel: 'Run tests' }) + expect(getAgentRowConversationName(quick, 'claude', false, null)).toBe('Run tests') + const generated = makeTab({ generatedTitle: 'Fix intake flow' }) + expect(getAgentRowConversationName(generated, 'claude', true, null)).toBe('Fix intake flow') + }) + it('strips leading status decoration from agent-set titles', () => { expect( getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false) diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index 02ea55b240b..f7e2a7dc2ca 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -113,7 +113,13 @@ function conversationNameFromLiveTitle( export function getAgentRowConversationName( tab: ConversationNameTab, agentType: AgentType | null | undefined, - generatedTitlesEnabled: boolean + generatedTitlesEnabled: boolean, + // Why: `tab.title` carries only the FOCUSED pane's title, so in a split tab it + // names one pane and mislabels its siblings. Callers on a multi-pane tab pass + // this row's own pane title, or `null` when none resolves; `undefined` (a + // single-pane tab) keeps the tab title. Tab-owned names above are unaffected: + // the user gave those to the whole tab and they do not flip on focus. + paneLiveTitle?: string | null ): string | null { const customTitle = tab.customTitle?.trim() if (customTitle) { @@ -123,7 +129,8 @@ export function getAgentRowConversationName( if (quickCommandLabel) { return quickCommandLabel } - const liveTitle = tab.title?.trim() ?? '' + const liveTitle = + paneLiveTitle === undefined ? (tab.title?.trim() ?? '') : (paneLiveTitle?.trim() ?? '') if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) { return liveTitle }