diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 53e959f489c..488631b9df1 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -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 } : {}) }) } diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts index df4f5f99d34..573614c17d3 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -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. @@ -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 + ) } 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..7bae2d81265 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 @@ -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 tabsByWorktree: Record + terminalLayoutsByTabId: Record } })) @@ -30,7 +31,7 @@ function makeAgent(overrides: Partial = {}): DashboardAgentRo } beforeEach(() => { - storeState.current = { settings: {}, tabsByWorktree: {} } + storeState.current = { settings: {}, tabsByWorktree: {}, terminalLayoutsByTabId: {} } }) describe('useAgentRowConversationName', () => { @@ -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') }) @@ -52,7 +57,7 @@ describe('useAgentRowConversationName', () => { } } ) - storeState.current = { settings: {}, tabsByWorktree } + storeState.current = { settings: {}, tabsByWorktree, terminalLayoutsByTabId: {} } expect(useAgentRowConversationName(makeAgent({ rowSource: 'subagent' }))).toBeNull() }) @@ -65,7 +70,7 @@ describe('useAgentRowConversationName', () => { } } ) - storeState.current = { settings: {}, tabsByWorktree } + storeState.current = { settings: {}, tabsByWorktree, terminalLayoutsByTabId: {} } expect( useAgentRowConversationName( makeAgent({ @@ -112,7 +117,8 @@ describe('useAgentRowConversationName', () => { ) storeState.current = { settings: {}, - tabsByWorktree: { 'wt-1': tabs } + tabsByWorktree: { 'wt-1': tabs }, + terminalLayoutsByTabId: {} } expect(useAgentRowConversationName(makeAgent())).toBe('First name') @@ -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) + const paneB = makeAgent({ + paneKey: 'tab-1:leaf-2', + tab: splitTab + } as Partial) + 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))).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) - 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') }) 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..470a3c1d4be 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 @@ -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 + ) } diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index d838ff53dcf..06dae06fe1c 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -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) diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index 02ea55b240b..7adf906c01c 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -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) { @@ -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 }