Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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')
})
})
29 changes: 29 additions & 0 deletions src/renderer/src/components/dashboard/agent-row-pane-live-title.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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', () => {
Expand Down
41 changes: 12 additions & 29 deletions src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
dashboardCardDisplayState,
type DashboardCard,
type DashboardCardDotState,
type DashboardCardSubagent,
type DashboardSnapshot,
type DashboardWorkspace
} from '../../../../shared/dashboard-snapshot'
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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 } : {})
})
}
Expand Down
16 changes: 14 additions & 2 deletions src/renderer/src/components/dashboard/dashboard-card-labels.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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
)
}
35 changes: 35 additions & 0 deletions src/renderer/src/components/dashboard/dashboard-subagent-cards.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading