From ecdd90bd56ccc3512090a3fbe7ed90393d3af9c0 Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Tue, 28 Jul 2026 12:02:16 +0900 Subject: [PATCH 1/5] fix(sidebar): name split-pane agent rows from their own pane title Sidebar and dashboard agent rows render per pane but resolved their label from tab-level state. `tab.title` intentionally tracks only the focused pane, so every row in a split tab showed the focused pane's name and both rows relabeled whenever the user clicked the other split. Pass the row's own pane title into the conversation-name resolver and let it replace the live-title source only. Tab-owned names (customTitle, quickCommandLabel, generatedTitle) keep their precedence, since those are names the user gave the tab. Single-pane tabs are unchanged: their `entry.terminalTitle` equals `tab.title`. Fixes #11069 Co-Authored-By: Claude Opus 5 (1M context) --- .../dashboard/dashboard-card-labels.ts | 9 +++++++- .../use-agent-row-conversation-name.test.ts | 22 +++++++++++++++++++ .../use-agent-row-conversation-name.ts | 7 +++++- .../agent-row-conversation-name.test.ts | 19 ++++++++++++++++ src/shared/agent-row-conversation-name.ts | 8 +++++-- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts index df4f5f99d34..74b5c0ccdf7 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -39,5 +39,12 @@ export function rowConversationName( ) { return undefined } - return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined + return ( + getAgentRowConversationName( + row.tab, + row.agentType, + generatedTitlesEnabled, + row.entry.terminalTitle + ) ?? 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..9ce562645a3 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 @@ -141,6 +141,28 @@ describe('useAgentRowConversationName', () => { expect(useAgentRowConversationName(makeAgent())).toBe('Renamed later') }) + it('names split-pane rows from their own pane title, not the focused tab title', () => { + storeState.current = { + settings: {}, + // Why: tab.title only tracks the focused pane, so both rows would otherwise + // relabel to whichever split was clicked last. + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Redis cache' }] + } + } + const paneA = makeAgent({ + tab: { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Redis cache' }, + entry: { prompt: 'p', terminalTitle: '✳ Linear work log' } + } as Partial) + const paneB = makeAgent({ + paneKey: 'tab-1:leaf-2', + tab: { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Redis cache' }, + entry: { prompt: 'p', terminalTitle: '✳ Redis cache' } + } as Partial) + expect(useAgentRowConversationName(paneA)).toBe('Linear work log') + expect(useAgentRowConversationName(paneB)).toBe('Redis cache') + }) + it('honors the generated-titles setting for generated names', () => { const agent = makeAgent({ tab: { customTitle: null, title: '', generatedTitle: '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..14faf5e46b4 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 @@ -44,5 +44,10 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | 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, + agent.entry.terminalTitle + ) } diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index d838ff53dcf..9d943b0aa87 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -37,6 +37,25 @@ describe('getAgentRowConversationName', () => { expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug') }) + it('prefers the row pane title over the focused-pane tab title', () => { + const tab = makeTab({ title: '✳ Redis cache strategy' }) + expect(getAgentRowConversationName(tab, 'claude', false, '✳ Linear work log')).toBe( + 'Linear work log' + ) + // Empty/status-only pane titles fall back to the tab so single-pane rows are unchanged. + expect(getAgentRowConversationName(tab, 'claude', false, ' ')).toBe('Redis cache strategy') + expect(getAgentRowConversationName(tab, 'claude', false, undefined)).toBe( + 'Redis cache strategy' + ) + }) + + it('keeps tab-owned names ahead of the pane title', () => { + const tab = makeTab({ customTitle: 'Patient sync spike', title: '✳ Redis cache strategy' }) + expect(getAgentRowConversationName(tab, 'claude', false, '✳ Linear work log')).toBe( + 'Patient sync spike' + ) + }) + 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..10bef728eb1 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, + paneTitle?: string | null ): string | null { const customTitle = tab.customTitle?.trim() if (customTitle) { @@ -123,7 +124,10 @@ export function getAgentRowConversationName( if (quickCommandLabel) { return quickCommandLabel } - const liveTitle = tab.title?.trim() ?? '' + // Why: split panes share one tab, and `tab.title` deliberately tracks only the + // focused pane (pty-connection.ts) — without the row's own pane title every + // split row would show, and re-show on each click, the last-focused pane's name. + const liveTitle = paneTitle?.trim() || (tab.title?.trim() ?? '') if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) { return liveTitle } From 6ebe94ecdf602725c003a7720d5623aa801225a6 Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Tue, 28 Jul 2026 12:19:19 +0900 Subject: [PATCH 2/5] test(sidebar): pin the pre-first-turn case for agent row names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before an agent sets a real title, the tab and pane both carry the identity echo, which the resolver rejects — rows fall back to their own prompt and already look correct. Pin that, so the shared-name symptom stays understood as something that starts at the first real title, not at row creation. Co-Authored-By: Claude Opus 5 (1M context) --- .../use-agent-row-conversation-name.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 9ce562645a3..40471a97f7f 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 @@ -163,6 +163,23 @@ describe('useAgentRowConversationName', () => { expect(useAgentRowConversationName(paneB)).toBe('Redis cache') }) + it('yields no name until an agent sets a real title, so fresh rows keep their own prompt', () => { + // Why: before the first turn both panes carry the identity echo, which is + // rejected — rows fall back to their per-pane prompt and look correct. The + // shared-name symptom only appears once a real title reaches the tab. + storeState.current = { + settings: {}, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Claude Code' }] + } + } + const freshPane = makeAgent({ + tab: { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Claude Code' }, + entry: { prompt: 'p', terminalTitle: '✳ Claude Code' } + } as Partial) + expect(useAgentRowConversationName(freshPane)).toBeNull() + }) + it('honors the generated-titles setting for generated names', () => { const agent = makeAgent({ tab: { customTitle: null, title: '', generatedTitle: 'Fix intake flow' } From 428dd4ceb7a30b877922e5420efcdbef01f3bd0f Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Tue, 28 Jul 2026 12:35:01 +0900 Subject: [PATCH 3/5] fix(sidebar): stop labeling split-pane agent rows with the focused pane's title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar and dashboard agent rows render per pane but took their name from `tab.title`, which by design carries only the focused pane's live title — pty-connection keeps it that way so two agents in splits do not make the tab title flicker. In a split tab that title names one pane and mislabels its siblings, and it flips as focus moves, so every row in the tab showed the same name and all of them changed when the user clicked another split. Drop the live title for rows whose tab has more than one pane and let each row keep its own per-pane label. Tab-owned names (customTitle, quickCommandLabel, generatedTitle) still apply to every row in the tab — those are names the user gave the tab, not one pane. Single-pane tabs, which are the case #9989 was written for, are untouched. The split test is the layout root being a split node, so the row subscribes to splits rather than to every title frame. Fixes #11069 Co-Authored-By: Claude Opus 5 (1M context) --- .../dashboard/build-dashboard-snapshot.ts | 8 +- .../dashboard/dashboard-card-labels.ts | 11 +-- .../use-agent-row-conversation-name.test.ts | 76 ++++++++++--------- .../use-agent-row-conversation-name.ts | 8 +- .../agent-row-conversation-name.test.ts | 48 ++++++++---- src/shared/agent-row-conversation-name.ts | 12 +-- 6 files changed, 100 insertions(+), 63 deletions(-) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 53e959f489c..88ba01cebd0 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, + state.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 74b5c0ccdf7..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. @@ -40,11 +41,7 @@ export function rowConversationName( return undefined } return ( - getAgentRowConversationName( - row.tab, - row.agentType, - generatedTitlesEnabled, - row.entry.terminalTitle - ) ?? undefined + 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 40471a97f7f..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,59 +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('names split-pane rows from their own pane title, not the focused tab title', () => { + 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: {}, - // Why: tab.title only tracks the focused pane, so both rows would otherwise - // relabel to whichever split was clicked last. - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Redis cache' }] + 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: { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Redis cache' }, - entry: { prompt: 'p', terminalTitle: '✳ Linear work log' } - } as Partial) + const paneA = makeAgent({ tab: splitTab } as Partial) const paneB = makeAgent({ paneKey: 'tab-1:leaf-2', - tab: { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Redis cache' }, - entry: { prompt: 'p', terminalTitle: '✳ Redis cache' } + tab: splitTab } as Partial) - expect(useAgentRowConversationName(paneA)).toBe('Linear work log') - expect(useAgentRowConversationName(paneB)).toBe('Redis cache') + expect(useAgentRowConversationName(paneA)).toBeNull() + expect(useAgentRowConversationName(paneB)).toBeNull() }) - it('yields no name until an agent sets a real title, so fresh rows keep their own prompt', () => { - // Why: before the first turn both panes carry the identity echo, which is - // rejected — rows fall back to their per-pane prompt and look correct. The - // shared-name symptom only appears once a real title reaches the tab. + 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': [{ id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Claude Code' }] - } + tabsByWorktree: { 'wt-1': [tab] }, + terminalLayoutsByTabId: { 'tab-1': { root: { type: 'leaf', leafId: 'leaf-1' } } } } - const freshPane = makeAgent({ - tab: { id: 'tab-1', worktreeId: 'wt-1', customTitle: null, title: '✳ Claude Code' }, - entry: { prompt: 'p', terminalTitle: '✳ Claude Code' } - } as Partial) - expect(useAgentRowConversationName(freshPane)).toBeNull() + 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 14faf5e46b4..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,6 +39,12 @@ 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 @@ -48,6 +54,6 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | liveTab ?? agent.tab, agent.agentType, generatedTitlesEnabled, - agent.entry.terminalTitle + tabHasSplitPanes ) } diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index 9d943b0aa87..06dae06fe1c 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -37,23 +37,41 @@ describe('getAgentRowConversationName', () => { expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug') }) - it('prefers the row pane title over the focused-pane tab title', () => { - const tab = makeTab({ title: '✳ Redis cache strategy' }) - expect(getAgentRowConversationName(tab, 'claude', false, '✳ Linear work log')).toBe( - 'Linear work log' - ) - // Empty/status-only pane titles fall back to the tab so single-pane rows are unchanged. - expect(getAgentRowConversationName(tab, 'claude', false, ' ')).toBe('Redis cache strategy') - expect(getAgentRowConversationName(tab, 'claude', false, undefined)).toBe( - 'Redis cache strategy' - ) + 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 ahead of the pane title', () => { - const tab = makeTab({ customTitle: 'Patient sync spike', title: '✳ Redis cache strategy' }) - expect(getAgentRowConversationName(tab, 'claude', false, '✳ Linear work log')).toBe( - 'Patient sync spike' - ) + 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', () => { diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index 10bef728eb1..65a5958709d 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -114,7 +114,7 @@ export function getAgentRowConversationName( tab: ConversationNameTab, agentType: AgentType | null | undefined, generatedTitlesEnabled: boolean, - paneTitle?: string | null + tabHasSplitPanes = false ): string | null { const customTitle = tab.customTitle?.trim() if (customTitle) { @@ -124,10 +124,12 @@ export function getAgentRowConversationName( if (quickCommandLabel) { return quickCommandLabel } - // Why: split panes share one tab, and `tab.title` deliberately tracks only the - // focused pane (pty-connection.ts) — without the row's own pane title every - // split row would show, and re-show on each click, the last-focused pane's name. - const liveTitle = paneTitle?.trim() || (tab.title?.trim() ?? '') + // Why: `tab.title` carries only the focused pane's live title (pty-connection + // keeps it that way so split agents don't make the tab flicker). In a split + // tab it names one pane and mislabels its siblings, and it flips as focus + // moves — so drop it there and let each row keep its own per-pane label. The + // tab-owned names above stay: the user gave those to the whole tab. + const liveTitle = tabHasSplitPanes ? '' : (tab.title?.trim() ?? '') if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) { return liveTitle } From 5df2d2a0fa45aeb761bb25fdadb647973c689cb3 Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Tue, 28 Jul 2026 12:36:38 +0900 Subject: [PATCH 4/5] refactor(dashboard): read the split test from the worktree-scoped layouts Use the selector result already in scope instead of re-reading the whole store map, matching how the pty lookup a few lines up resolves its layout. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/dashboard/build-dashboard-snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 88ba01cebd0..488631b9df1 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -307,7 +307,7 @@ export function buildDashboardSnapshot( rowConversationName( row, generatedTitlesEnabled, - state.terminalLayoutsByTabId?.[tabId]?.root?.type === 'split' + terminalLayoutsByTabId[tabId]?.root?.type === 'split' ) ), ...(terminalInput ? { terminalInput } : {}) From 6dddb4900abc46bad56d55c467082c6f8ac7b3b7 Mon Sep 17 00:00:00 2001 From: Jonghyeok Park Date: Wed, 29 Jul 2026 11:06:35 +0900 Subject: [PATCH 5/5] docs(sidebar): trim the split-title comment to its non-obvious part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five-line version narrated the precedence the code right above it already shows. Keep the pointer to where the focused-pane invariant is actually enforced (pty-connection.ts) — that is the part a reader cannot derive from this file. Co-Authored-By: Claude Opus 5 (1M context) --- src/shared/agent-row-conversation-name.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index 65a5958709d..7adf906c01c 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -124,11 +124,8 @@ export function getAgentRowConversationName( if (quickCommandLabel) { return quickCommandLabel } - // Why: `tab.title` carries only the focused pane's live title (pty-connection - // keeps it that way so split agents don't make the tab flicker). In a split - // tab it names one pane and mislabels its siblings, and it flips as focus - // moves — so drop it there and let each row keep its own per-pane label. The - // tab-owned names above stay: the user gave those to the whole tab. + // 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