Skip to content
Closed
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
Expand Up @@ -303,7 +303,13 @@ 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[tabId]?.root?.type === 'split'
)
),
...(terminalInput ? { terminalInput } : {})
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export function boundedLabelOrUndefined(value: string | undefined): string | und
* same agent with the same name. */
export function rowConversationName(
row: DashboardAgentRow,
generatedTitlesEnabled: boolean
generatedTitlesEnabled: boolean,
tabHasSplitPanes: boolean
): 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 +40,8 @@ export function rowConversationName(
) {
return undefined
}
return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined
return (
getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled, tabHasSplitPanes) ??
undefined
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { useAgentRowConversationName } from './use-agent-row-conversation-name'
import type { DashboardAgentRow } from './useDashboardData'

const storeState = vi.hoisted(() => ({
current: { settings: {}, tabsByWorktree: {} } as {
current: { settings: {}, tabsByWorktree: {}, terminalLayoutsByTabId: {} } as {
settings: Record<string, unknown>
tabsByWorktree: Record<string, unknown[]>
terminalLayoutsByTabId: Record<string, unknown>
}
}))

Expand All @@ -30,7 +31,7 @@ function makeAgent(overrides: Partial<DashboardAgentRow> = {}): DashboardAgentRo
}

beforeEach(() => {
storeState.current = { settings: {}, tabsByWorktree: {} }
storeState.current = { settings: {}, tabsByWorktree: {}, terminalLayoutsByTabId: {} }
})

describe('useAgentRowConversationName', () => {
Expand All @@ -39,7 +40,11 @@ describe('useAgentRowConversationName', () => {
})

it('ignores a retired stored opt-out value', () => {
storeState.current = { settings: { agentRowsUseConversationName: false }, tabsByWorktree: {} }
storeState.current = {
settings: { agentRowsUseConversationName: false },
tabsByWorktree: {},
terminalLayoutsByTabId: {}
}
expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike')
})

Expand All @@ -52,7 +57,7 @@ describe('useAgentRowConversationName', () => {
}
}
)
storeState.current = { settings: {}, tabsByWorktree }
storeState.current = { settings: {}, tabsByWorktree, terminalLayoutsByTabId: {} }
expect(useAgentRowConversationName(makeAgent({ rowSource: 'subagent' }))).toBeNull()
})

Expand All @@ -65,7 +70,7 @@ describe('useAgentRowConversationName', () => {
}
}
)
storeState.current = { settings: {}, tabsByWorktree }
storeState.current = { settings: {}, tabsByWorktree, terminalLayoutsByTabId: {} }
expect(
useAgentRowConversationName(
makeAgent({
Expand Down Expand Up @@ -112,7 +117,8 @@ describe('useAgentRowConversationName', () => {
)
storeState.current = {
settings: {},
tabsByWorktree: { 'wt-1': tabs }
tabsByWorktree: { 'wt-1': tabs },
terminalLayoutsByTabId: {}
}

expect(useAgentRowConversationName(makeAgent())).toBe('First name')
Expand All @@ -136,20 +142,61 @@ describe('useAgentRowConversationName', () => {
// snapshot; a rename landing after that must still surface.
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', worktreeId: 'wt-1', customTitle: 'Renamed later', title: '' }]
}
},
terminalLayoutsByTabId: {}
}
expect(useAgentRowConversationName(makeAgent())).toBe('Renamed later')
})

it('gives split-pane rows no shared name, so each keeps its own per-pane label', () => {
const splitTab = { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Redis cache' }
storeState.current = {
settings: {},
tabsByWorktree: { 'wt-1': [splitTab] },
// Why: a split root means tab.title is only the focused pane's title.
terminalLayoutsByTabId: {
'tab-1': {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-1' },
second: { type: 'leaf', leafId: 'leaf-2' },
ratio: 0.5
}
}
}
}
const paneA = makeAgent({ tab: splitTab } as Partial<DashboardAgentRow>)
const paneB = makeAgent({
paneKey: 'tab-1:leaf-2',
tab: splitTab
} as Partial<DashboardAgentRow>)
expect(useAgentRowConversationName(paneA)).toBeNull()
expect(useAgentRowConversationName(paneB)).toBeNull()
})

it('keeps the live title on a single-pane tab', () => {
const tab = { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Redis cache' }
storeState.current = {
settings: {},
tabsByWorktree: { 'wt-1': [tab] },
terminalLayoutsByTabId: { 'tab-1': { root: { type: 'leaf', leafId: 'leaf-1' } } }
}
expect(useAgentRowConversationName(makeAgent({ tab } as Partial<DashboardAgentRow>))).toBe(
'Redis cache'
)
})

it('honors the generated-titles setting for generated names', () => {
const agent = makeAgent({
tab: { customTitle: null, title: '', generatedTitle: 'Fix intake flow' }
} as Partial<DashboardAgentRow>)
storeState.current = { settings: {}, tabsByWorktree: {} }
storeState.current = { settings: {}, tabsByWorktree: {}, terminalLayoutsByTabId: {} }
expect(useAgentRowConversationName(agent)).toBeNull()
storeState.current = {
settings: { tabAutoGenerateTitle: true },
tabsByWorktree: {}
tabsByWorktree: {},
terminalLayoutsByTabId: {}
}
expect(useAgentRowConversationName(agent)).toBe('Fix intake flow')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,21 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string |
? undefined
: getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id)
)
// Why: a split root means the tab holds more than one pane, so its live title
// belongs to whichever pane has focus. Selecting the boolean (not the layout)
// keeps this row subscribed to splits, not to every title frame.
const tabHasSplitPanes = useAppStore(
(s) => !cannotOwnTabName && s.terminalLayoutsByTabId[agent.tab.id]?.root?.type === 'split'
)
// 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,
tabHasSplitPanes
)
}
37 changes: 37 additions & 0 deletions src/shared/agent-row-conversation-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,43 @@ describe('getAgentRowConversationName', () => {
expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug')
})

it('drops the live title in a split tab, where it names only the focused pane', () => {
const tab = makeTab({ title: '✳ Investigate replay bug' })
expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug')
expect(getAgentRowConversationName(tab, 'claude', false, true)).toBeNull()
// OpenCode semantic titles are live titles too, so they go with it.
const openCodeTab = makeTab({ title: 'OC | build the release pipeline' })
expect(getAgentRowConversationName(openCodeTab, 'opencode', false, true)).toBeNull()
})

it('keeps tab-owned names in a split tab', () => {
// Why: the user gave these to the whole tab, so every pane in it may show them.
expect(
getAgentRowConversationName(
makeTab({ customTitle: 'Patient sync spike' }),
'claude',
false,
true
)
).toBe('Patient sync spike')
expect(
getAgentRowConversationName(
makeTab({ quickCommandLabel: 'Run tests' }),
'claude',
false,
true
)
).toBe('Run tests')
expect(
getAgentRowConversationName(
makeTab({ generatedTitle: 'Fix intake flow' }),
'claude',
true,
true
)
).toBe('Fix intake flow')
})

it('strips leading status decoration from agent-set titles', () => {
expect(
getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false)
Expand Down
7 changes: 5 additions & 2 deletions src/shared/agent-row-conversation-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ function conversationNameFromLiveTitle(
export function getAgentRowConversationName(
tab: ConversationNameTab,
agentType: AgentType | null | undefined,
generatedTitlesEnabled: boolean
generatedTitlesEnabled: boolean,
tabHasSplitPanes = false
): string | null {
const customTitle = tab.customTitle?.trim()
if (customTitle) {
Expand All @@ -123,7 +124,9 @@ export function getAgentRowConversationName(
if (quickCommandLabel) {
return quickCommandLabel
}
const liveTitle = tab.title?.trim() ?? ''
// Why: pty-connection only propagates the focused pane's title to the tab
// (deliberately, to stop split agents flickering), so in a split it mislabels siblings.
const liveTitle = tabHasSplitPanes ? '' : (tab.title?.trim() ?? '')
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
return liveTitle
}
Expand Down