mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user