diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx
index dfb84f5d..a240cf70 100644
--- a/src/components/panes/PaneContainer.tsx
+++ b/src/components/panes/PaneContainer.tsx
@@ -20,7 +20,7 @@ import { cn } from '@/lib/utils'
import { getWsClient } from '@/lib/ws-client'
import { api } from '@/lib/api'
import { resolvePaneActivity } from '@/lib/pane-activity'
-import { derivePaneTitle } from '@/lib/derivePaneTitle'
+import { getPaneDisplayTitle } from '@/lib/pane-title'
import { getTabDirectoryPreference } from '@/lib/tab-directory-preference'
import {
formatPaneRuntimeLabel,
@@ -208,12 +208,12 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp
// Only handle the request if this PaneContainer renders the target pane as a leaf
if (node.type !== 'leaf' || node.id !== renameRequestPaneId) return
- const currentTitle = paneTitles[node.id] ?? derivePaneTitle(node.content, extensionEntries)
+ const currentTitle = getPaneDisplayTitle(node.content, paneTitles[node.id], extensionEntries)
setRenamingPaneId(node.id)
setRenameValue(currentTitle)
setRenameError(null)
dispatch(clearPaneRenameRequest())
- }, [renameRequestTabId, renameRequestPaneId, tabId, node, paneTitles, dispatch])
+ }, [renameRequestTabId, renameRequestPaneId, tabId, node, paneTitles, extensionEntries, dispatch])
const startRename = useCallback((paneId: string, currentTitle: string) => {
setRenamingPaneId(paneId)
@@ -367,7 +367,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp
// Render a leaf pane
if (node.type === 'leaf') {
const explicitTitle = paneTitles[node.id]
- const paneTitle = explicitTitle ?? derivePaneTitle(node.content, extensionEntries)
+ const paneTitle = getPaneDisplayTitle(node.content, explicitTitle, extensionEntries)
const paneStatus = node.content.kind === 'terminal'
? node.content.status
: node.content.kind === 'agent-chat'
diff --git a/src/lib/pane-title.ts b/src/lib/pane-title.ts
new file mode 100644
index 00000000..282de5e7
--- /dev/null
+++ b/src/lib/pane-title.ts
@@ -0,0 +1,31 @@
+import type { PaneContent } from '@/store/paneTypes'
+import type { ClientExtensionEntry } from '@shared/extension-types'
+import { derivePaneTitle } from './derivePaneTitle'
+
+/**
+ * Stored pane titles can be auto-derived before the extension registry loads,
+ * so tolerate both the extension-aware title and the legacy fallback title.
+ */
+export function matchesDerivedPaneTitle(
+ storedTitle: string | undefined,
+ content: PaneContent,
+ extensions?: ClientExtensionEntry[],
+): boolean {
+ if (!storedTitle) return false
+ if (storedTitle === derivePaneTitle(content, extensions)) return true
+ // Only treat the legacy extension-blind label as equivalent when we also
+ // have extension metadata that could have changed the canonical label.
+ return !!extensions && storedTitle === derivePaneTitle(content)
+}
+
+export function getPaneDisplayTitle(
+ content: PaneContent,
+ storedTitle: string | undefined,
+ extensions?: ClientExtensionEntry[],
+): string {
+ const derivedTitle = derivePaneTitle(content, extensions)
+ if (!storedTitle || matchesDerivedPaneTitle(storedTitle, content, extensions)) {
+ return derivedTitle
+ }
+ return storedTitle
+}
diff --git a/src/lib/tab-title.ts b/src/lib/tab-title.ts
index cadb5276..ca7eafa1 100644
--- a/src/lib/tab-title.ts
+++ b/src/lib/tab-title.ts
@@ -1,5 +1,6 @@
import { deriveTabName } from './deriveTabName'
import { derivePaneTitle } from './derivePaneTitle'
+import { matchesDerivedPaneTitle } from './pane-title'
import type { Tab } from '@/store/types'
import type { PaneNode } from '@/store/paneTypes'
import type { ClientExtensionEntry } from '@shared/extension-types'
@@ -12,8 +13,7 @@ function getSinglePaneOverrideTitle(
if (!layout || layout.type !== 'leaf') return null
const storedTitle = paneTitles?.[layout.id]
if (!storedTitle) return null
- const derivedPaneTitle = derivePaneTitle(layout.content, extensions)
- return storedTitle !== derivedPaneTitle ? storedTitle : null
+ return matchesDerivedPaneTitle(storedTitle, layout.content, extensions) ? null : storedTitle
}
export function getTabDisplayTitle(
diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts
index ffe81b64..f4a157be 100644
--- a/src/store/panesSlice.ts
+++ b/src/store/panesSlice.ts
@@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { nanoid } from 'nanoid'
import type { PanesState, PaneContent, PaneContentInput, PaneNode, PaneRefreshRequest } from './paneTypes'
import { derivePaneTitle } from '@/lib/derivePaneTitle'
+import { matchesDerivedPaneTitle } from '@/lib/pane-title'
import { isValidClaudeSessionId } from '@/lib/claude-session-id'
import { buildPaneRefreshTarget, paneRefreshTargetMatchesContent } from '@/lib/pane-utils'
import { loadPersistedPanes, loadPersistedTabs } from './persistMiddleware.js'
@@ -1037,10 +1038,12 @@ export const panesSlice = createSlice({
const root = state.layouts[tabId]
if (!root) return
let normalizedContentForTitle: PaneContent | null = null
+ let previousContentForTitle: PaneContent | null = null
function updateContent(node: PaneNode): PaneNode {
if (node.type === 'leaf') {
if (node.id === paneId) {
+ previousContentForTitle = node.content
const nextContent = normalizePaneContent(content, node.content)
normalizedContentForTitle = nextContent
return { ...node, content: nextContent }
@@ -1060,7 +1063,12 @@ export const panesSlice = createSlice({
if (!state.paneTitles[tabId]) {
state.paneTitles[tabId] = {}
}
- state.paneTitles[tabId][paneId] = derivePaneTitle(normalizedContentForTitle)
+ const existingTitle = state.paneTitles[tabId][paneId]
+ // Pane titles are stored extension-unaware in this slice; canonical labels
+ // such as "OpenCode" are normalized later in the display layer.
+ if (!existingTitle || (previousContentForTitle && matchesDerivedPaneTitle(existingTitle, previousContentForTitle))) {
+ state.paneTitles[tabId][paneId] = derivePaneTitle(normalizedContentForTitle)
+ }
}
reconcileRefreshRequestsForTab(state, tabId)
@@ -1075,10 +1083,12 @@ export const panesSlice = createSlice({
const { tabId, paneId, updates } = action.payload
const root = state.layouts[tabId]
if (!root) return
+ let previousContentForTitle: PaneContent | null = null
function mergeContent(node: PaneNode): PaneNode {
if (node.type === 'leaf') {
if (node.id === paneId) {
+ previousContentForTitle = node.content
return {
...node,
content: normalizePaneContent({ ...node.content, ...updates } as PaneContentInput | PaneContent, node.content),
@@ -1100,7 +1110,12 @@ export const panesSlice = createSlice({
if (!state.paneTitles[tabId]) {
state.paneTitles[tabId] = {}
}
- state.paneTitles[tabId][paneId] = derivePaneTitle(leaf.content)
+ const existingTitle = state.paneTitles[tabId][paneId]
+ // Pane titles are stored extension-unaware in this slice; canonical labels
+ // such as "OpenCode" are normalized later in the display layer.
+ if (!existingTitle || (previousContentForTitle && matchesDerivedPaneTitle(existingTitle, previousContentForTitle))) {
+ state.paneTitles[tabId][paneId] = derivePaneTitle(leaf.content)
+ }
}
reconcileRefreshRequestsForTab(state, tabId)
diff --git a/test/e2e/title-sync-flow.test.tsx b/test/e2e/title-sync-flow.test.tsx
index efd68fa8..85eac9e6 100644
--- a/test/e2e/title-sync-flow.test.tsx
+++ b/test/e2e/title-sync-flow.test.tsx
@@ -14,7 +14,9 @@ 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'
vi.mock('@/lib/ws-client', () => ({
getWsClient: () => ({
@@ -30,7 +32,28 @@ vi.mock('@/components/TerminalView', () => ({
default: ({ paneId }: { paneId: string }) =>
Terminal
,
}))
-function createStore(layout: PaneNode) {
+const opencodeExtensions: ClientExtensionEntry[] = [{
+ name: 'opencode',
+ version: '1.0.0',
+ label: 'OpenCode',
+ description: '',
+ category: 'cli',
+ picker: { shortcut: 'O' },
+ cli: {
+ supportsModel: true,
+ supportsPermissionMode: true,
+ supportsResume: true,
+ resumeCommandTemplate: ['opencode', '--session', '{{sessionId}}'],
+ },
+}]
+
+function createStore(
+ layout: PaneNode,
+ options: {
+ paneTitle?: string
+ extensions?: ClientExtensionEntry[]
+ } = {},
+) {
return configureStore({
reducer: {
tabs: tabsReducer,
@@ -60,7 +83,7 @@ function createStore(layout: PaneNode) {
panes: {
layouts: { 'tab-1': layout },
activePane: { 'tab-1': 'pane-1' },
- paneTitles: { 'tab-1': { 'pane-1': 'Shell' } },
+ paneTitles: { 'tab-1': { 'pane-1': options.paneTitle ?? 'Shell' } },
paneTitleSetByUser: {},
renameRequestTabId: null,
renameRequestPaneId: null,
@@ -77,7 +100,7 @@ function createStore(layout: PaneNode) {
availableClis: {},
},
extensions: {
- entries: [],
+ entries: options.extensions ?? [],
},
terminalMeta: {
byTerminalId: {},
@@ -141,4 +164,57 @@ describe('title sync flow', () => {
expect(screen.getAllByText('Release prep').length).toBeGreaterThanOrEqual(2)
expect(store.getState().tabs.tabs[0].title).toBe('Tab 1')
})
+
+ it('does not let legacy OpenCode defaults override runtime titles during pane updates', async () => {
+ const layout: PaneNode = {
+ type: 'leaf',
+ id: 'pane-1',
+ content: {
+ kind: 'terminal',
+ terminalId: 'term-1',
+ createRequestId: 'req-1',
+ status: 'running',
+ mode: 'opencode',
+ },
+ }
+ const store = createStore(layout, {
+ paneTitle: 'Opencode',
+ extensions: opencodeExtensions,
+ })
+
+ render(
+
+ <>
+
+
+ >
+ ,
+ )
+
+ expect(screen.getAllByText('OpenCode').length).toBeGreaterThanOrEqual(2)
+ expect(screen.queryByText('Opencode')).not.toBeInTheDocument()
+
+ await act(async () => {
+ await store.dispatch(syncPaneTitleByTerminalId({ terminalId: 'term-1', title: 'Release prep' }))
+ })
+
+ expect(screen.getAllByText('Release prep').length).toBeGreaterThanOrEqual(2)
+
+ await act(async () => {
+ store.dispatch(updatePaneContent({
+ tabId: 'tab-1',
+ paneId: 'pane-1',
+ content: {
+ kind: 'terminal',
+ terminalId: 'term-1',
+ createRequestId: 'req-1',
+ status: 'running',
+ mode: 'opencode',
+ },
+ }))
+ })
+
+ expect(screen.getAllByText('Release prep').length).toBeGreaterThanOrEqual(2)
+ expect(store.getState().panes.paneTitles['tab-1']['pane-1']).toBe('Release prep')
+ })
})
diff --git a/test/unit/client/lib/pane-title.test.ts b/test/unit/client/lib/pane-title.test.ts
new file mode 100644
index 00000000..4f6982f1
--- /dev/null
+++ b/test/unit/client/lib/pane-title.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import type { PaneContent } from '@/store/paneTypes'
+import type { ClientExtensionEntry } from '@shared/extension-types'
+import { getPaneDisplayTitle, matchesDerivedPaneTitle } from '@/lib/pane-title'
+
+const opencodeExtensions: ClientExtensionEntry[] = [{
+ name: 'opencode',
+ version: '1.0.0',
+ label: 'OpenCode',
+ description: '',
+ category: 'cli',
+ picker: { shortcut: 'O' },
+ cli: {
+ supportsModel: true,
+ supportsPermissionMode: true,
+ supportsResume: true,
+ resumeCommandTemplate: ['opencode', '--session', '{{sessionId}}'],
+ },
+}]
+
+const opencodeContent: PaneContent = {
+ kind: 'terminal',
+ mode: 'opencode',
+ status: 'running',
+ createRequestId: 'req-1',
+}
+
+describe('pane-title helpers', () => {
+ it('matches extension-aware derived titles', () => {
+ expect(matchesDerivedPaneTitle('OpenCode', opencodeContent, opencodeExtensions)).toBe(true)
+ })
+
+ it('matches legacy extension-blind derived titles when extensions are available', () => {
+ expect(matchesDerivedPaneTitle('Opencode', opencodeContent, opencodeExtensions)).toBe(true)
+ })
+
+ it('does not treat runtime titles as derived defaults', () => {
+ expect(matchesDerivedPaneTitle('Release prep', opencodeContent, opencodeExtensions)).toBe(false)
+ })
+
+ it('prefers the extension-aware label when the stored title is only a legacy default', () => {
+ expect(getPaneDisplayTitle(opencodeContent, 'Opencode', opencodeExtensions)).toBe('OpenCode')
+ })
+
+ it('preserves explicit runtime titles', () => {
+ expect(getPaneDisplayTitle(opencodeContent, 'Release prep', opencodeExtensions)).toBe('Release prep')
+ })
+})
diff --git a/test/unit/client/store/panesSlice.test.ts b/test/unit/client/store/panesSlice.test.ts
index ff18e096..9d2f089b 100644
--- a/test/unit/client/store/panesSlice.test.ts
+++ b/test/unit/client/store/panesSlice.test.ts
@@ -2947,8 +2947,9 @@ describe('panesSlice', () => {
expect(result.paneTitles['tab-1']['pane-1']).toBe('User Title')
})
- it('updatePaneContent DOES overwrite title when paneTitleSetByUser is false/missing', () => {
+ it('updatePaneContent rewrites titles that still match the pane default', () => {
const state = makeState(false)
+ state.paneTitles['tab-1']['pane-1'] = 'Shell'
const result = panesReducer(state, updatePaneContent({
tabId: 'tab-1',
paneId: 'pane-1',
@@ -2959,6 +2960,36 @@ describe('panesSlice', () => {
expect(result.paneTitles['tab-1']['pane-1']).toBe('Claude')
})
+ it('updatePaneContent preserves runtime titles when they no longer match the pane default', () => {
+ const state = makeState(false)
+ state.paneTitles['tab-1']['pane-1'] = 'Release prep'
+ const result = panesReducer(state, updatePaneContent({
+ tabId: 'tab-1',
+ paneId: 'pane-1',
+ content: {
+ kind: 'terminal',
+ createRequestId: 'req-1',
+ terminalId: 'term-1',
+ status: 'running',
+ mode: 'shell',
+ },
+ }))
+
+ expect(result.paneTitles['tab-1']['pane-1']).toBe('Release prep')
+ })
+
+ it('mergePaneContent preserves runtime titles when they no longer match the pane default', () => {
+ const state = makeState(false)
+ state.paneTitles['tab-1']['pane-1'] = 'Release prep'
+ const result = panesReducer(state, mergePaneContent({
+ tabId: 'tab-1',
+ paneId: 'pane-1',
+ updates: { terminalId: 'term-1' },
+ }))
+
+ expect(result.paneTitles['tab-1']['pane-1']).toBe('Release prep')
+ })
+
it('updatePaneTitle sets paneTitleSetByUser to true', () => {
const state = makeState(false)
const result = panesReducer(state, updatePaneTitle({
diff --git a/test/unit/client/store/tab-pane-title-sync.test.ts b/test/unit/client/store/tab-pane-title-sync.test.ts
index 78fc83fc..1fc050c3 100644
--- a/test/unit/client/store/tab-pane-title-sync.test.ts
+++ b/test/unit/client/store/tab-pane-title-sync.test.ts
@@ -7,6 +7,7 @@ import panesReducer, {
updatePaneTitle,
} from '../../../../src/store/panesSlice'
import type { PaneNode } from '../../../../src/store/paneTypes'
+import type { ClientExtensionEntry } from '../../../../shared/extension-types'
import { syncPaneTitleByTerminalId } from '../../../../src/store/paneTitleSync'
import { applyPaneRename, applyTabRename } from '../../../../src/store/titleSync'
import { getTabDisplayTitle } from '../../../../src/lib/tab-title'
@@ -137,6 +138,21 @@ describe('tab-pane title sync for single-pane tabs', () => {
})
describe('runtime title sync uses pane state as the source of truth', () => {
+ const opencodeExtensions: ClientExtensionEntry[] = [{
+ name: 'opencode',
+ version: '1.0.0',
+ label: 'OpenCode',
+ description: '',
+ category: 'cli',
+ picker: { shortcut: 'O' },
+ cli: {
+ supportsModel: true,
+ supportsPermissionMode: true,
+ supportsResume: true,
+ resumeCommandTemplate: ['opencode', '--session', '{{sessionId}}'],
+ },
+ }]
+
it('updates the only pane title and lets the tab display follow it', async () => {
const store = createStore()
@@ -306,5 +322,32 @@ describe('tab-pane title sync for single-pane tabs', () => {
store.getState().panes.paneTitles[tabId],
)).toBe('Some Tab')
})
+
+ it('ignores legacy auto-derived pane titles when extensions provide the canonical label', () => {
+ const store = createStore()
+
+ store.dispatch(addTab({ title: 'Tab 1', mode: 'opencode' }))
+ const tabId = store.getState().tabs.tabs[0].id
+
+ store.dispatch(initLayout({
+ tabId,
+ content: { kind: 'terminal', mode: 'opencode', terminalId: 'term-opencode' },
+ }))
+
+ const paneId = (store.getState().panes.layouts[tabId] as Extract).id
+ store.dispatch(updatePaneTitle({
+ tabId,
+ paneId,
+ title: 'Opencode',
+ setByUser: false,
+ }))
+
+ expect(getTabDisplayTitle(
+ store.getState().tabs.tabs[0],
+ store.getState().panes.layouts[tabId],
+ store.getState().panes.paneTitles[tabId],
+ opencodeExtensions,
+ )).toBe('OpenCode')
+ })
})
})