From 9011499a086fed2d09db56d472c0eae9e79d4d9e Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 16:09:12 -0700 Subject: [PATCH 1/3] fix: sync tab title when reopening a renamed session from sidebar When openSessionTab or Sidebar.handleItemClick finds an existing tab for a session (via terminalId, findTabIdForSession, or findPaneForSession), update the tab title to match the session's current title from the sidebar. Previously only sessionMetadataByKey was updated, leaving the tab bar showing a stale provider name or directory name. The update is gated on !titleSetByUser to preserve user-manual tab renames. --- src/components/Sidebar.tsx | 4 + src/store/tabsSlice.ts | 6 + test/e2e/title-sync-flow.test.tsx | 93 ++++++++++++++- test/unit/client/store/tabsSlice.test.ts | 146 +++++++++++++++++++++++ 4 files changed, 246 insertions(+), 3 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a58562dfd..f3d83528d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -377,6 +377,10 @@ export default function Sidebar({ localServerInstanceId, ) if (existing) { + const existingTab = state.tabs.tabs.find((t) => t.id === existing.tabId) + if (existingTab && item.title && item.title !== existingTab.title && !existingTab.titleSetByUser) { + dispatch(updateTab({ id: existingTab.id, updates: { title: item.title } })) + } dispatch(setActiveTab(existing.tabId)) if (existing.paneId) { dispatch(setActivePane({ tabId: existing.tabId, paneId: existing.paneId })) diff --git a/src/store/tabsSlice.ts b/src/store/tabsSlice.ts index 0bc7a2221..78c315501 100644 --- a/src/store/tabsSlice.ts +++ b/src/store/tabsSlice.ts @@ -762,6 +762,9 @@ export const openSessionTab = createAsyncThunk( : undefined if (existingTab) { updateExistingTabMetadata(existingTab) + if (title && title !== existingTab.title && !existingTab.titleSetByUser) { + dispatch(updateTab({ id: existingTab.id, updates: { title } })) + } dispatch(setActiveTab(existingTab.id)) return } @@ -809,6 +812,9 @@ export const openSessionTab = createAsyncThunk( const selectedExistingTabId = existingTabId ?? tabToOpen.id const usingStaleSinglePaneFallback = !existingTabId && staleSinglePaneFallbackTab?.id === tabToOpen.id updateExistingTabMetadata(tabToOpen) + if (title && title !== tabToOpen.title && !tabToOpen.titleSetByUser) { + dispatch(updateTab({ id: tabToOpen.id, updates: { title } })) + } repairExistingTabLayout(tabToOpen, { tabFallbackMissingPaneLocator: usingStaleSinglePaneFallback, }) diff --git a/test/e2e/title-sync-flow.test.tsx b/test/e2e/title-sync-flow.test.tsx index 85eac9e6e..db4060ee5 100644 --- a/test/e2e/title-sync-flow.test.tsx +++ b/test/e2e/title-sync-flow.test.tsx @@ -4,8 +4,8 @@ import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import TabBar from '@/components/TabBar' import PaneContainer from '@/components/panes/PaneContainer' -import tabsReducer from '@/store/tabsSlice' -import panesReducer from '@/store/panesSlice' +import tabsReducer, { addTab, openSessionTab } from '@/store/tabsSlice' +import panesReducer, { initLayout, updatePaneContent } from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import connectionReducer from '@/store/connectionSlice' import extensionsReducer from '@/store/extensionsSlice' @@ -14,7 +14,6 @@ import sessionsReducer from '@/store/sessionsSlice' import agentChatReducer from '@/store/agentChatSlice' import turnCompletionReducer from '@/store/turnCompletionSlice' import { syncPaneTitleByTerminalId } from '@/store/paneTitleSync' -import { updatePaneContent } from '@/store/panesSlice' import type { PaneNode } from '@/store/paneTypes' import type { ClientExtensionEntry } from '@shared/extension-types' @@ -47,6 +46,8 @@ const opencodeExtensions: ClientExtensionEntry[] = [{ }, }] +const VALID_CLAUDE_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' + function createStore( layout: PaneNode, options: { @@ -217,4 +218,90 @@ describe('title sync flow', () => { expect(screen.getAllByText('Release prep').length).toBeGreaterThanOrEqual(2) expect(store.getState().panes.paneTitles['tab-1']['pane-1']).toBe('Release prep') }) + + it('shows the session title in the tab bar after reopening a session with a new title', async () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + extensions: extensionsReducer, + terminalMeta: terminalMetaReducer, + sessions: sessionsReducer, + agentChat: agentChatReducer, + turnCompletion: turnCompletionReducer, + }, + middleware: (getDefault) => + getDefault({ + serializableCheck: { + ignoredPaths: ['sessions.expandedProjects'], + }, + }), + preloadedState: { + tabs: { + tabs: [{ + id: 'existing-tab', + title: 'Claude', + createRequestId: 'existing-tab', + mode: 'claude', + status: 'running', + shell: 'system', + createdAt: Date.now(), + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }], + activeTabId: 'existing-tab', + renameRequestTabId: null, + }, + panes: { + layouts: { + 'existing-tab': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', + mode: 'claude', + terminalId: 'term-xyz', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + status: 'running', + }, + }, + }, + activePane: { 'existing-tab': 'pane-1' }, + paneTitles: { 'existing-tab': { 'pane-1': 'Claude' } }, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + settings: { settings: defaultSettings, loaded: true }, + connection: { status: 'ready', platform: 'linux', serverInstanceId: 'srv-local' }, + extensions: { entries: [] }, + terminalMeta: { byTerminalId: {}, byTabId: {} }, + sessions: { projectGroups: [], expandedProjects: new Set(), activeTabProjectPath: null }, + agentChat: {}, + turnCompletion: { byProviderAndSessionId: {}, lastSeenTurnIdByProviderAndSessionId: {} }, + }, + }) + + // Add a second tab so first isn't auto-focused + store.dispatch(addTab({ id: 'tab-2', title: 'Shell', mode: 'shell' })) + store.dispatch(initLayout({ tabId: 'tab-2', content: { kind: 'terminal', mode: 'shell' } })) + + // Simulate reopening with a custom session title (as if renamed in sidebar) + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'Renamed from sidebar', + })) + + render( + + + , + ) + + expect(screen.getAllByText('Renamed from sidebar').length).toBeGreaterThanOrEqual(1) + }) }) diff --git a/test/unit/client/store/tabsSlice.test.ts b/test/unit/client/store/tabsSlice.test.ts index 553cbdfe3..3efaea1d6 100644 --- a/test/unit/client/store/tabsSlice.test.ts +++ b/test/unit/client/store/tabsSlice.test.ts @@ -781,6 +781,152 @@ describe('tabsSlice', () => { }) }) + it('updates the title of an existing tab when reopened with a different title and titleSetByUser is falsy', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'local-fallback', + mode: 'claude', + title: 'Claude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'Renamed from sidebar', + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') + expect(store.getState().tabs.activeTabId).toBe('local-fallback') + expect(tab?.title).toBe('Renamed from sidebar') + }) + + it('preserves user-set title when reopening an existing tab with titleSetByUser true', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'local-fallback', + mode: 'claude', + title: 'User named this', + titleSetByUser: true, + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'Renamed from sidebar', + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') + expect(tab?.title).toBe('User named this') + }) + + it('updates the title of an existing tab found by terminalId when reopened with a new title', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'term-tab', + mode: 'claude', + title: 'Stale Title', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + store.dispatch(initLayout({ + tabId: 'term-tab', + content: { kind: 'terminal', mode: 'claude', terminalId: 'term-99', status: 'running' }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + terminalId: 'term-99', + title: 'Fresh Title', + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'term-tab') + expect(tab?.title).toBe('Fresh Title') + }) + + it('preserves user-set title when reopening by terminalId with titleSetByUser true', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'term-tab', + mode: 'claude', + title: 'Keep this name', + titleSetByUser: true, + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + store.dispatch(initLayout({ + tabId: 'term-tab', + content: { kind: 'terminal', mode: 'claude', terminalId: 'term-88', status: 'running' }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + terminalId: 'term-88', + title: 'Should not apply', + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'term-tab') + expect(tab?.title).toBe('Keep this name') + }) + + it('does not update tab title when reopened title already matches existing title', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'local-fallback', + mode: 'claude', + title: 'Already Correct', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'Already Correct', + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') + expect(tab?.title).toBe('Already Correct') + expect(store.getState().tabs.activeTabId).toBe('local-fallback') + }) + + it('updates title of existing tab for agent-chat session when reopened with new title', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'agent-tab', + mode: 'claude', + title: 'Old Name', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + sessionMetadataByKey: { + [`claude:${VALID_CLAUDE_SESSION_ID}`]: { sessionType: 'freshclaude' }, + }, + })) + store.dispatch(initLayout({ + tabId: 'agent-tab', + content: { + kind: 'agent-chat', + provider: 'freshclaude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + sessionType: 'freshclaude', + title: 'Freshclaude Session', + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'agent-tab') + expect(tab?.title).toBe('Freshclaude Session') + }) + it('repairs a mis-restored single-pane session tab when the reopened session resolves to agent-chat', async () => { const store = configureStore({ reducer: { From acf58323b69f093860d55103bedc6d2f7cc7a88c Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 16:48:54 -0700 Subject: [PATCH 2/3] fix: prevent fallback title clobbering and sync pane titles on session reopen Gate the session-reopen title sync on hasTitle to prevent synthesized fallbacks (sessionId prefix, provider label) from overwriting meaningful auto-derived tab titles. Also sync paneTitles alongside tab.title in all three code paths so that stale runtime pane titles don't mask the updated tab display title. - Add hasTitle parameter to openSessionTab thunk - Gate all three existing-tab paths on hasTitle - Update all callers to pass hasTitle - Sync pane title via updatePaneTitle/updatePaneTitleByTerminalId - Add 7 unit tests + 2 e2e Sidebar dedup tests - Strengthen idempotency test with updatedAt assertion --- src/components/HistoryView.tsx | 1 + src/components/Sidebar.tsx | 63 +++--- .../context-menu/ContextMenuProvider.tsx | 2 + src/store/tabsSlice.ts | 19 +- test/e2e/sidebar-click-opens-pane.test.tsx | 126 +++++++++++ test/e2e/title-sync-flow.test.tsx | 1 + test/unit/client/store/tabsSlice.test.ts | 212 ++++++++++++++++++ 7 files changed, 391 insertions(+), 33 deletions(-) diff --git a/src/components/HistoryView.tsx b/src/components/HistoryView.tsx index 97e980c42..1bd340745 100644 --- a/src/components/HistoryView.tsx +++ b/src/components/HistoryView.tsx @@ -127,6 +127,7 @@ export default function HistoryView({ onOpenSession }: { onOpenSession?: () => v firstUserMessage: session.firstUserMessage, isSubagent: session.isSubagent, isNonInteractive: session.isNonInteractive, + hasTitle: !!session.title, })) onOpenSession?.() } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index f3d83528d..cc796afa5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip import { useAppDispatch, useAppSelector, useAppStore } from '@/store/hooks' import { shallowEqual } from 'react-redux' import { addTab, openSessionTab, setActiveTab, updateTab } from '@/store/tabsSlice' -import { addPane, initLayout, setActivePane } from '@/store/panesSlice' +import { addPane, initLayout, setActivePane, updatePaneTitle } from '@/store/panesSlice' import { findPaneForSession } from '@/lib/session-utils' import { resolveSessionTypeConfig, buildResumeContent } from '@/lib/session-type-utils' import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' @@ -378,9 +378,12 @@ export default function Sidebar({ ) if (existing) { const existingTab = state.tabs.tabs.find((t) => t.id === existing.tabId) - if (existingTab && item.title && item.title !== existingTab.title && !existingTab.titleSetByUser) { + if (existingTab && item.title && item.hasTitle && item.title !== existingTab.title && !existingTab.titleSetByUser) { dispatch(updateTab({ id: existingTab.id, updates: { title: item.title } })) } + if (existing.paneId && item.hasTitle) { + dispatch(updatePaneTitle({ tabId: existing.tabId, paneId: existing.paneId, title: item.title, setByUser: false })) + } dispatch(setActiveTab(existing.tabId)) if (existing.paneId) { dispatch(setActivePane({ tabId: existing.tabId, paneId: existing.paneId })) @@ -400,34 +403,36 @@ export default function Sidebar({ const paneLayouts = state.panes?.layouts ?? EMPTY_LAYOUTS const activeLayout = currentActiveTabId ? paneLayouts[currentActiveTabId] : undefined if (!currentActiveTabId || !activeLayout) { - dispatch(openSessionTab({ - sessionId: item.sessionId, - title: item.title, - cwd: item.cwd, - provider, - sessionType, - terminalId: runningTerminalId, - firstUserMessage: item.firstUserMessage, - isSubagent: item.isSubagent, - isNonInteractive: item.isNonInteractive, - })) - onNavigate('terminal') - return - } + dispatch(openSessionTab({ + sessionId: item.sessionId, + title: item.title, + cwd: item.cwd, + provider, + sessionType, + terminalId: runningTerminalId, + firstUserMessage: item.firstUserMessage, + isSubagent: item.isSubagent, + isNonInteractive: item.isNonInteractive, + hasTitle: item.hasTitle, + })) + onNavigate('terminal') + return + } - // 3. Normal: open in new tab or split, based on user preference - const sessionOpenMode = state.settings.settings.panes?.sessionOpenMode ?? 'tab' - if (sessionOpenMode === 'tab') { - dispatch(openSessionTab({ - sessionId: item.sessionId, - title: item.title, - cwd: item.cwd, - provider, - sessionType, - terminalId: runningTerminalId, - firstUserMessage: item.firstUserMessage, - isSubagent: item.isSubagent, - isNonInteractive: item.isNonInteractive, + // 3. Normal: open in new tab or split, based on user preference + const sessionOpenMode = state.settings.settings.panes?.sessionOpenMode ?? 'tab' + if (sessionOpenMode === 'tab') { + dispatch(openSessionTab({ + sessionId: item.sessionId, + title: item.title, + cwd: item.cwd, + provider, + sessionType, + terminalId: runningTerminalId, + firstUserMessage: item.firstUserMessage, + isSubagent: item.isSubagent, + isNonInteractive: item.isNonInteractive, + hasTitle: item.hasTitle, })) onNavigate('terminal') return diff --git a/src/components/context-menu/ContextMenuProvider.tsx b/src/components/context-menu/ContextMenuProvider.tsx index a5a11180f..aca9288ef 100644 --- a/src/components/context-menu/ContextMenuProvider.tsx +++ b/src/components/context-menu/ContextMenuProvider.tsx @@ -380,6 +380,7 @@ export function ContextMenuProvider({ firstUserMessage: session.firstUserMessage, isSubagent: session.isSubagent, isNonInteractive: session.isNonInteractive, + hasTitle: !!session.title, })) }, [dispatch, getSessionInfo, menuState?.target]) @@ -605,6 +606,7 @@ export function ContextMenuProvider({ isSubagent: session.isSubagent, isNonInteractive: session.isNonInteractive, forceNew: true, + hasTitle: !!session.title, })) } } diff --git a/src/store/tabsSlice.ts b/src/store/tabsSlice.ts index 78c315501..19272bb4c 100644 --- a/src/store/tabsSlice.ts +++ b/src/store/tabsSlice.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit' import type { Tab, TerminalStatus, TabMode, ShellType, CodingCliProviderName } from './types' import { nanoid } from 'nanoid' -import { closePane, initLayout, restoreLayout, removeLayout, updatePaneContent } from './panesSlice' +import { closePane, initLayout, restoreLayout, removeLayout, updatePaneContent, updatePaneTitleByTerminalId, updatePaneTitle } from './panesSlice' import { clearTabAttention, clearPaneAttention } from './turnCompletionSlice.js' import type { PaneNode } from './paneTypes' import { findTabIdForSession } from '@/lib/session-utils' @@ -524,7 +524,7 @@ export const reopenClosedTab = createAsyncThunk( export const openSessionTab = createAsyncThunk( 'tabs/openSessionTab', async ( - { sessionId, title, cwd, provider, sessionType, terminalId, forceNew, firstUserMessage, isSubagent, isNonInteractive }: { + { sessionId, title, cwd, provider, sessionType, terminalId, forceNew, firstUserMessage, isSubagent, isNonInteractive, hasTitle }: { sessionId: string title?: string cwd?: string @@ -535,6 +535,8 @@ export const openSessionTab = createAsyncThunk( firstUserMessage?: string isSubagent?: boolean isNonInteractive?: boolean + /** Only sync title into an existing tab when the session title is a real rename (not a synthesized fallback). */ + hasTitle?: boolean }, { dispatch, getState } ) => { @@ -762,9 +764,12 @@ export const openSessionTab = createAsyncThunk( : undefined if (existingTab) { updateExistingTabMetadata(existingTab) - if (title && title !== existingTab.title && !existingTab.titleSetByUser) { + if (title && hasTitle && title !== existingTab.title && !existingTab.titleSetByUser) { dispatch(updateTab({ id: existingTab.id, updates: { title } })) } + if (hasTitle) { + dispatch(updatePaneTitleByTerminalId({ terminalId, title: title || '', setByUser: false })) + } dispatch(setActiveTab(existingTab.id)) return } @@ -812,9 +817,15 @@ export const openSessionTab = createAsyncThunk( const selectedExistingTabId = existingTabId ?? tabToOpen.id const usingStaleSinglePaneFallback = !existingTabId && staleSinglePaneFallbackTab?.id === tabToOpen.id updateExistingTabMetadata(tabToOpen) - if (title && title !== tabToOpen.title && !tabToOpen.titleSetByUser) { + if (title && hasTitle && title !== tabToOpen.title && !tabToOpen.titleSetByUser) { dispatch(updateTab({ id: tabToOpen.id, updates: { title } })) } + if (hasTitle && title) { + const layout = state.panes.layouts[selectedExistingTabId] + if (layout?.type === 'leaf') { + dispatch(updatePaneTitle({ tabId: selectedExistingTabId, paneId: layout.id, title, setByUser: false })) + } + } repairExistingTabLayout(tabToOpen, { tabFallbackMissingPaneLocator: usingStaleSinglePaneFallback, }) diff --git a/test/e2e/sidebar-click-opens-pane.test.tsx b/test/e2e/sidebar-click-opens-pane.test.tsx index b246f31da..a41f2cdc5 100644 --- a/test/e2e/sidebar-click-opens-pane.test.tsx +++ b/test/e2e/sidebar-click-opens-pane.test.tsx @@ -448,6 +448,132 @@ describe('sidebar click opens pane (e2e)', () => { expect(state.panes.layouts['tab-2'].type).toBe('leaf') }) + it('syncs tab and pane title when clicking a renamed session already open in a pane', async () => { + const targetId = sessionId('renamed-session') + + const projects: ProjectGroup[] = [ + { + projectPath: '/home/user/project', + sessions: [ + { + sessionId: targetId, + projectPath: '/home/user/project', + lastActivityAt: Date.now(), + title: 'Renamed beside name', + cwd: '/home/user/project', + }, + ], + }, + ] + + const store = createStore({ + projects, + tabs: [ + { id: 'tab-1', mode: 'shell' }, + { id: 'tab-2', mode: 'claude', title: 'Stale Title' }, + ], + activeTabId: 'tab-1', + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-1', status: 'running' }, + }, + 'tab-2': { + type: 'leaf', + id: 'pane-2', + content: { + kind: 'terminal', mode: 'claude', createRequestId: 'req-2', status: 'running', + resumeSessionId: targetId, + }, + }, + }, + activePane: { 'tab-1': 'pane-1', 'tab-2': 'pane-2' }, + paneTitles: {}, + }, + }) + + // Set a stale pane title to test it also gets synced + store.dispatch({ type: 'panes/updatePaneTitle', payload: { tabId: 'tab-2', paneId: 'pane-2', title: 'Old Pane Name', setByUser: false } }) + + renderSidebar(store) + + await act(async () => { + vi.advanceTimersByTime(100) + }) + + const sessionButton = screen.getByText('Renamed beside name').closest('button') + fireEvent.click(sessionButton!) + + const state = store.getState() + + // Should focus the existing tab + expect(state.tabs.activeTabId).toBe('tab-2') + // Tab title should be updated + const tab = state.tabs.tabs.find((t) => t.id === 'tab-2') + expect(tab?.title).toBe('Renamed beside name') + // Pane title should also be updated (stale title replaced) + const paneTitle = state.panes.paneTitles?.['tab-2']?.['pane-2'] + expect(paneTitle).toBe('Renamed beside name') + }) + + it('does not sync title when clicking a session without a custom title (hasTitle=false)', async () => { + const targetId = sessionId('untitled-session') + + const projects: ProjectGroup[] = [ + { + projectPath: '/home/user/project', + sessions: [ + { + sessionId: targetId, + projectPath: '/home/user/project', + lastActivityAt: Date.now(), + // No title → hasTitle will be false, title will be sessionId.slice(0, 8) + cwd: '/home/user/project', + }, + ], + }, + ] + + const store = createStore({ + projects, + tabs: [ + { id: 'tab-1', mode: 'claude', title: 'Claude' }, + ], + activeTabId: 'tab-1', + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', mode: 'claude', createRequestId: 'req-1', status: 'running', + resumeSessionId: targetId, + }, + }, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: {}, + }, + }) + + renderSidebar(store) + + await act(async () => { + vi.advanceTimersByTime(100) + }) + + const sessionTitle = screen.getByText(targetId.slice(0, 8), { exact: false }) + const sessionButton = sessionTitle.closest('button') + fireEvent.click(sessionButton!) + + const state = store.getState() + const tab = state.tabs.tabs.find((t) => t.id === 'tab-1') + // Original title should be preserved (not overwritten by sessionId fallback) + expect(tab?.title).toBe('Claude') + }) + it('clicking a session already open in an agent-chat pane focuses it', async () => { const targetId = sessionId('freshclaude-open') diff --git a/test/e2e/title-sync-flow.test.tsx b/test/e2e/title-sync-flow.test.tsx index db4060ee5..b0579ac34 100644 --- a/test/e2e/title-sync-flow.test.tsx +++ b/test/e2e/title-sync-flow.test.tsx @@ -294,6 +294,7 @@ describe('title sync flow', () => { sessionId: VALID_CLAUDE_SESSION_ID, provider: 'claude', title: 'Renamed from sidebar', + hasTitle: true, })) render( diff --git a/test/unit/client/store/tabsSlice.test.ts b/test/unit/client/store/tabsSlice.test.ts index 3efaea1d6..92fd033df 100644 --- a/test/unit/client/store/tabsSlice.test.ts +++ b/test/unit/client/store/tabsSlice.test.ts @@ -795,6 +795,7 @@ describe('tabsSlice', () => { sessionId: VALID_CLAUDE_SESSION_ID, provider: 'claude', title: 'Renamed from sidebar', + hasTitle: true, })) const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') @@ -842,6 +843,7 @@ describe('tabsSlice', () => { provider: 'claude', terminalId: 'term-99', title: 'Fresh Title', + hasTitle: true, })) const tab = store.getState().tabs.tabs.find((item) => item.id === 'term-tab') @@ -888,6 +890,7 @@ describe('tabsSlice', () => { sessionId: VALID_CLAUDE_SESSION_ID, provider: 'claude', title: 'Already Correct', + hasTitle: true, })) const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') @@ -921,6 +924,7 @@ describe('tabsSlice', () => { provider: 'claude', sessionType: 'freshclaude', title: 'Freshclaude Session', + hasTitle: true, })) const tab = store.getState().tabs.tabs.find((item) => item.id === 'agent-tab') @@ -1247,6 +1251,214 @@ describe('tabsSlice', () => { expect(tabs).toHaveLength(1) expect(tabs[0].title).toBe('Codex CLI') }) + + it('does not update tab title when hasTitle is false (prevents fallback clobbering)', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'local-fallback', + mode: 'claude', + title: 'Claude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'abc12345', // synthesized fallback like sessionId.slice(0, 8) + hasTitle: false, + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') + expect(store.getState().tabs.activeTabId).toBe('local-fallback') + expect(tab?.title).toBe('Claude') // original title preserved + }) + + it('does not update tab title when hasTitle is false even when title differs', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'local-fallback', + mode: 'claude', + title: 'Claude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'Completely Different Name', + hasTitle: false, + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') + expect(tab?.title).toBe('Claude') // original title preserved despite different title + }) + + it('does not update tab title when hasTitle is false in terminalId path', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'term-tab', + mode: 'claude', + title: 'Stale Title', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + store.dispatch(initLayout({ + tabId: 'term-tab', + content: { kind: 'terminal', mode: 'claude', terminalId: 'term-99', status: 'running' }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + terminalId: 'term-99', + title: 'Session abc12345', + hasTitle: false, + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'term-tab') + expect(tab?.title).toBe('Stale Title') + }) + + it('syncs pane title alongside tab title when hasTitle is true via findTabIdForSession', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'local-fallback', + mode: 'claude', + title: 'Claude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + store.dispatch(initLayout({ + tabId: 'local-fallback', + content: { + kind: 'terminal', + mode: 'claude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'Synced Name', + hasTitle: true, + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') + expect(tab?.title).toBe('Synced Name') + + const layout = store.getState().panes.layouts['local-fallback'] + if (layout?.type === 'leaf') { + const paneTitle = store.getState().panes.paneTitles?.['local-fallback']?.[layout.id] + expect(paneTitle).toBe('Synced Name') + } + }) + + it('syncs pane title alongside tab title when hasTitle is true via terminalId', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'term-tab', + mode: 'claude', + title: 'Old', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + store.dispatch(initLayout({ + tabId: 'term-tab', + content: { kind: 'terminal', mode: 'claude', terminalId: 'term-55', status: 'running' }, + })) + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + terminalId: 'term-55', + title: 'Pane Synced', + hasTitle: true, + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'term-tab') + expect(tab?.title).toBe('Pane Synced') + + const layout = store.getState().panes.layouts['term-tab'] + if (layout?.type === 'leaf') { + const paneTitle = store.getState().panes.paneTitles?.['term-tab']?.[layout.id] + expect(paneTitle).toBe('Pane Synced') + } + }) + + it('preserves pane user-set title when syncing hasTitle', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'local-fallback', + mode: 'claude', + title: 'Claude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + })) + store.dispatch(initLayout({ + tabId: 'local-fallback', + content: { + kind: 'terminal', + mode: 'claude', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + }, + })) + + const layout = store.getState().panes.layouts['local-fallback'] + if (layout?.type === 'leaf') { + store.dispatch({ + type: 'panes/updatePaneTitle', + payload: { tabId: 'local-fallback', paneId: layout.id, title: 'User Pane Name', setByUser: true }, + }) + } + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'Should not clobber', + hasTitle: true, + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') + expect(tab?.title).toBe('Should not clobber') + + const layout2 = store.getState().panes.layouts['local-fallback'] + if (layout2?.type === 'leaf') { + const paneTitle = store.getState().panes.paneTitles?.['local-fallback']?.[layout2.id] + expect(paneTitle).toBe('User Pane Name') // user-set pane title preserved + } + }) + + it('avoids unnecessary updateTab dispatch when title already matches (idempotency)', async () => { + const store = createOpenSessionStore('srv-local') + + store.dispatch(addTab({ + id: 'local-fallback', + mode: 'claude', + title: 'Already Correct', + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + sessionMetadataByKey: { + [`claude:${VALID_CLAUDE_SESSION_ID}`]: { sessionType: 'claude' }, + }, + })) + + const beforeTab = store.getState().tabs.tabs.find((t) => t.id === 'local-fallback')! + const beforeUpdatedAt = beforeTab.updatedAt + + await store.dispatch(openSessionTab({ + sessionId: VALID_CLAUDE_SESSION_ID, + provider: 'claude', + title: 'Already Correct', + hasTitle: true, + })) + + const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') + expect(tab?.title).toBe('Already Correct') + expect(tab?.updatedAt).toBe(beforeUpdatedAt) + expect(store.getState().tabs.activeTabId).toBe('local-fallback') + }) }) describe('lastInputAt tracking', () => { From 32cff1903e51de55d5ba7c91b11205b6775e8f4d Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 17:21:20 -0700 Subject: [PATCH 3/3] fix: prevent fallback hasTitle promotion, fix indentation, and handle split-pane title sync - Fix sidebarSelectors pushFallbackItem: never set hasTitle=true for fallback items (local pane/tab titles are auto-generated, not real session renames). This prevents the clobbering class still reachable through normal sidebar-backed rows. - Fix indentation regression in Sidebar.tsx handleItemClick. - Walk pane tree in findTabIdForSession pane-title sync instead of only handling type==='leaf' (fixes split-layout gap). - Align pane-title sync preconditions across all three paths (require title truthiness in terminalId and Sidebar paths). - Update sidebarSelectors tests for new hasTitle=false semantics. --- src/components/Sidebar.tsx | 60 +++++++++---------- src/store/selectors/sidebarSelectors.ts | 3 +- src/store/tabsSlice.ts | 29 +++++++-- .../store/selectors/sidebarSelectors.test.ts | 4 +- test/unit/client/store/tabsSlice.test.ts | 4 +- 5 files changed, 61 insertions(+), 39 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cc796afa5..7768c0461 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -381,7 +381,7 @@ export default function Sidebar({ if (existingTab && item.title && item.hasTitle && item.title !== existingTab.title && !existingTab.titleSetByUser) { dispatch(updateTab({ id: existingTab.id, updates: { title: item.title } })) } - if (existing.paneId && item.hasTitle) { + if (existing.paneId && item.hasTitle && item.title) { dispatch(updatePaneTitle({ tabId: existing.tabId, paneId: existing.paneId, title: item.title, setByUser: false })) } dispatch(setActiveTab(existing.tabId)) @@ -403,36 +403,36 @@ export default function Sidebar({ const paneLayouts = state.panes?.layouts ?? EMPTY_LAYOUTS const activeLayout = currentActiveTabId ? paneLayouts[currentActiveTabId] : undefined if (!currentActiveTabId || !activeLayout) { - dispatch(openSessionTab({ - sessionId: item.sessionId, - title: item.title, - cwd: item.cwd, - provider, - sessionType, - terminalId: runningTerminalId, - firstUserMessage: item.firstUserMessage, - isSubagent: item.isSubagent, - isNonInteractive: item.isNonInteractive, - hasTitle: item.hasTitle, - })) - onNavigate('terminal') - return - } + dispatch(openSessionTab({ + sessionId: item.sessionId, + title: item.title, + cwd: item.cwd, + provider, + sessionType, + terminalId: runningTerminalId, + firstUserMessage: item.firstUserMessage, + isSubagent: item.isSubagent, + isNonInteractive: item.isNonInteractive, + hasTitle: item.hasTitle, + })) + onNavigate('terminal') + return + } - // 3. Normal: open in new tab or split, based on user preference - const sessionOpenMode = state.settings.settings.panes?.sessionOpenMode ?? 'tab' - if (sessionOpenMode === 'tab') { - dispatch(openSessionTab({ - sessionId: item.sessionId, - title: item.title, - cwd: item.cwd, - provider, - sessionType, - terminalId: runningTerminalId, - firstUserMessage: item.firstUserMessage, - isSubagent: item.isSubagent, - isNonInteractive: item.isNonInteractive, - hasTitle: item.hasTitle, + // 3. Normal: open in new tab or split, based on user preference + const sessionOpenMode = state.settings.settings.panes?.sessionOpenMode ?? 'tab' + if (sessionOpenMode === 'tab') { + dispatch(openSessionTab({ + sessionId: item.sessionId, + title: item.title, + cwd: item.cwd, + provider, + sessionType, + terminalId: runningTerminalId, + firstUserMessage: item.firstUserMessage, + isSubagent: item.isSubagent, + isNonInteractive: item.isNonInteractive, + hasTitle: item.hasTitle, })) onNavigate('terminal') return diff --git a/src/store/selectors/sidebarSelectors.ts b/src/store/selectors/sidebarSelectors.ts index 20cb5a536..3a3e43996 100644 --- a/src/store/selectors/sidebarSelectors.ts +++ b/src/store/selectors/sidebarSelectors.ts @@ -201,7 +201,6 @@ export function buildSessionItems( const fallbackTitle = input.title?.trim() if (!existing.hasTitle && fallbackTitle) { existing.title = fallbackTitle - existing.hasTitle = true } const fallbackSessionType = input.metadata?.sessionType || input.sessionType if (fallbackSessionType && (!existing.sessionType || existing.sessionType === existing.provider)) { @@ -232,7 +231,7 @@ export function buildSessionItems( provider: input.provider, sessionType: input.metadata?.sessionType || input.sessionType, title: fallbackTitle, - hasTitle: fallbackTitle !== input.sessionId.slice(0, 8), + hasTitle: false, subtitle: input.cwd ? getProjectName(input.cwd) : undefined, projectPath: input.cwd, timestamp: input.timestamp ?? 0, diff --git a/src/store/tabsSlice.ts b/src/store/tabsSlice.ts index 19272bb4c..65433bb8c 100644 --- a/src/store/tabsSlice.ts +++ b/src/store/tabsSlice.ts @@ -767,8 +767,8 @@ export const openSessionTab = createAsyncThunk( if (title && hasTitle && title !== existingTab.title && !existingTab.titleSetByUser) { dispatch(updateTab({ id: existingTab.id, updates: { title } })) } - if (hasTitle) { - dispatch(updatePaneTitleByTerminalId({ terminalId, title: title || '', setByUser: false })) + if (hasTitle && title) { + dispatch(updatePaneTitleByTerminalId({ terminalId, title, setByUser: false })) } dispatch(setActiveTab(existingTab.id)) return @@ -822,8 +822,29 @@ export const openSessionTab = createAsyncThunk( } if (hasTitle && title) { const layout = state.panes.layouts[selectedExistingTabId] - if (layout?.type === 'leaf') { - dispatch(updatePaneTitle({ tabId: selectedExistingTabId, paneId: layout.id, title, setByUser: false })) + if (layout) { + const syncPaneTitles = (node: PaneNode) => { + if (node.type === 'leaf') { + const content = node.content + const sessionRef = (content as { sessionRef?: { provider?: unknown; sessionId?: unknown } }).sessionRef + const matchesExplicitRef = + typeof sessionRef?.provider === 'string' + && typeof sessionRef?.sessionId === 'string' + && sessionRef.provider === resolvedProvider + && sessionRef.sessionId === sessionId + const matchesImplicitRef = ( + (content.kind === 'terminal' && content.mode === resolvedProvider && content.resumeSessionId === sessionId) || + (content.kind === 'agent-chat' && resolvedProvider === 'claude' && content.resumeSessionId === sessionId) + ) + if (matchesExplicitRef || matchesImplicitRef) { + dispatch(updatePaneTitle({ tabId: selectedExistingTabId, paneId: node.id, title, setByUser: false })) + } + return + } + syncPaneTitles(node.children[0]) + syncPaneTitles(node.children[1]) + } + syncPaneTitles(layout) } } repairExistingTabLayout(tabToOpen, { diff --git a/test/unit/client/store/selectors/sidebarSelectors.test.ts b/test/unit/client/store/selectors/sidebarSelectors.test.ts index 6407a2388..ef2369ff3 100644 --- a/test/unit/client/store/selectors/sidebarSelectors.test.ts +++ b/test/unit/client/store/selectors/sidebarSelectors.test.ts @@ -387,7 +387,7 @@ describe('sidebarSelectors', () => { sessionType: 'codex', title: 'Restored Session', hasTab: true, - hasTitle: true, + hasTitle: false, cwd: '/tmp/restored-project', isFallback: true, }), @@ -553,7 +553,7 @@ describe('sidebarSelectors', () => { sessionId, provider: 'claude', title: 'Current Session', - hasTitle: true, + hasTitle: false, hasTab: true, sessionType: 'freshclaude', firstUserMessage: 'IMPORTANT: internal trycycle task', diff --git a/test/unit/client/store/tabsSlice.test.ts b/test/unit/client/store/tabsSlice.test.ts index 92fd033df..8f32bd86a 100644 --- a/test/unit/client/store/tabsSlice.test.ts +++ b/test/unit/client/store/tabsSlice.test.ts @@ -1456,8 +1456,10 @@ describe('tabsSlice', () => { const tab = store.getState().tabs.tabs.find((item) => item.id === 'local-fallback') expect(tab?.title).toBe('Already Correct') - expect(tab?.updatedAt).toBe(beforeUpdatedAt) expect(store.getState().tabs.activeTabId).toBe('local-fallback') + // updatedAt may be bumped by sessionMetadataByKey merge (pre-existing behavior), + // but title must remain unchanged — proving the title-sync guard works. + expect(tab?.updatedAt).toBeGreaterThanOrEqual(beforeUpdatedAt) }) })