Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/HistoryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export default function HistoryView({ onOpenSession }: { onOpenSession?: () => v
firstUserMessage: session.firstUserMessage,
isSubagent: session.isSubagent,
isNonInteractive: session.isNonInteractive,
hasTitle: !!session.title,
}))
onOpenSession?.()
}
Expand Down
11 changes: 10 additions & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 }))
Expand Down Expand Up @@ -406,6 +413,7 @@ export default function Sidebar({
firstUserMessage: item.firstUserMessage,
isSubagent: item.isSubagent,
isNonInteractive: item.isNonInteractive,
hasTitle: item.hasTitle,
}))
onNavigate('terminal')
return
Expand All @@ -424,6 +432,7 @@ export default function Sidebar({
firstUserMessage: item.firstUserMessage,
isSubagent: item.isSubagent,
isNonInteractive: item.isNonInteractive,
hasTitle: item.hasTitle,
}))
onNavigate('terminal')
return
Expand Down
2 changes: 2 additions & 0 deletions src/components/context-menu/ContextMenuProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ export function ContextMenuProvider({
firstUserMessage: session.firstUserMessage,
isSubagent: session.isSubagent,
isNonInteractive: session.isNonInteractive,
hasTitle: !!session.title,
}))
}, [dispatch, getSessionInfo, menuState?.target])

Expand Down Expand Up @@ -605,6 +606,7 @@ export function ContextMenuProvider({
isSubagent: session.isSubagent,
isNonInteractive: session.isNonInteractive,
forceNew: true,
hasTitle: !!session.title,
}))
}
}
Expand Down
3 changes: 1 addition & 2 deletions src/store/selectors/sidebarSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 40 additions & 2 deletions src/store/tabsSlice.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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 }
) => {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
})
Expand Down
126 changes: 126 additions & 0 deletions test/e2e/sidebar-click-opens-pane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
Loading
Loading