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 a58562dfd..7768c0461 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' @@ -377,6 +377,13 @@ export default function Sidebar({ localServerInstanceId, ) if (existing) { + const existingTab = state.tabs.tabs.find((t) => t.id === existing.tabId) + 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 && item.title) { + 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 })) @@ -406,6 +413,7 @@ export default function Sidebar({ firstUserMessage: item.firstUserMessage, isSubagent: item.isSubagent, isNonInteractive: item.isNonInteractive, + hasTitle: item.hasTitle, })) onNavigate('terminal') return @@ -424,6 +432,7 @@ export default function Sidebar({ 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/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 0bc7a2221..65433bb8c 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,6 +764,12 @@ export const openSessionTab = createAsyncThunk( : undefined if (existingTab) { updateExistingTabMetadata(existingTab) + if (title && hasTitle && title !== existingTab.title && !existingTab.titleSetByUser) { + dispatch(updateTab({ id: existingTab.id, updates: { title } })) + } + if (hasTitle && title) { + dispatch(updatePaneTitleByTerminalId({ terminalId, title, setByUser: false })) + } dispatch(setActiveTab(existingTab.id)) return } @@ -809,6 +817,36 @@ export const openSessionTab = createAsyncThunk( const selectedExistingTabId = existingTabId ?? tabToOpen.id const usingStaleSinglePaneFallback = !existingTabId && staleSinglePaneFallbackTab?.id === tabToOpen.id updateExistingTabMetadata(tabToOpen) + 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) { + 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, { 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 85eac9e6e..b0579ac34 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,91 @@ 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', + hasTitle: true, + })) + + render( + + + , + ) + + expect(screen.getAllByText('Renamed from sidebar').length).toBeGreaterThanOrEqual(1) + }) }) 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 553cbdfe3..8f32bd86a 100644 --- a/test/unit/client/store/tabsSlice.test.ts +++ b/test/unit/client/store/tabsSlice.test.ts @@ -781,6 +781,156 @@ 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', + hasTitle: true, + })) + + 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', + hasTitle: true, + })) + + 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', + hasTitle: true, + })) + + 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', + hasTitle: true, + })) + + 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: { @@ -1101,6 +1251,216 @@ 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(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) + }) }) describe('lastInputAt tracking', () => {