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.
This commit is contained in:
Brennan Benson
2026-08-18 00:47:14 -07:00
committed by GitHub
parent 26bdfc0fe4
commit 7ba336099c
10 changed files with 371 additions and 36 deletions
@@ -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')
})
})
@@ -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<number, string> | undefined,
leafId: string | null | undefined
): string | null | undefined {
if (layout?.root?.type !== 'split') {
return undefined
}
if (!leafId) {
return null
}
return resolveRuntimePaneTitleForLeaf(layout, paneTitles, leafId)
}
@@ -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', () => {
@@ -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<string, DashboardCardSubagent[]>()
? 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 } : {})
})
}
@@ -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<number, string> | 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
)
}
@@ -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<string, DashboardCardSubagent[]> {
const byParentPaneKey = new Map<string, DashboardCardSubagent[]>()
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
}
@@ -7,14 +7,21 @@ const storeState = vi.hoisted(() => ({
current: { settings: {}, tabsByWorktree: {} } as {
settings: Record<string, unknown>
tabsByWorktree: Record<string, unknown[]>
terminalLayoutsByTabId?: Record<string, unknown>
runtimePaneTitlesByTabId?: Record<string, unknown>
}
}))
// 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> = {}): 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<DashboardAgentRow>)
}
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' }
@@ -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
)
}
@@ -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)
+9 -2
View File
@@ -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
}