From 8490b06e56df81157d4b333ce02333cbe17ef321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=87=E1=85=A9=E1=84=80?= =?UTF-8?q?=E1=85=A7=E1=86=BC?= Date: Wed, 16 Sep 2026 01:01:54 +0900 Subject: [PATCH] fix(sidebar): give a split tab's generated title only to the pane that produced it The auto-generated tab title comes from one pane's agent prompt but is stored tab-wide, so every agent row in a split tab showed it. Record the pane that produced the title and let only that pane's rows use it: workspace-card and dashboard rows, the board snapshot, and the Agents list. Titles saved before the source was recorded keep today's behavior. --- .../dashboard/dashboard-card-labels.test.ts | 17 ++++ .../dashboard/dashboard-card-labels.ts | 3 +- .../use-agent-row-conversation-name.test.ts | 20 +++++ .../use-agent-row-conversation-name.ts | 3 +- .../src/lib/activity-thread-display.test.ts | 19 +++++ .../src/lib/activity-thread-display.ts | 13 ++- .../mirrored-generated-title.test.ts | 70 +++++++++++++++ .../state-equality-tabs.ts | 1 + .../web-session-tabs-sync/terminal-build.ts | 4 + .../agent-generated-tab-title-pane.test.ts | 85 +++++++++++++++++++ .../store/slices/terminal-tab-title-batch.ts | 6 +- .../agent-row-conversation-name.test.ts | 25 ++++++ src/shared/agent-row-conversation-name.ts | 17 +++- src/shared/agent-tab-title.ts | 16 ++++ src/shared/terminal-tab-types.ts | 2 + src/shared/workspace-session-schema.test.ts | 36 ++++++++ src/shared/workspace-session-schema.ts | 1 + 17 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/mirrored-generated-title.test.ts create mode 100644 src/renderer/src/store/slices/agent-generated-tab-title-pane.test.ts diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts index 7d08ee7f9d8c..d3c2811e31e6 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts @@ -56,4 +56,21 @@ describe('rowConversationName', () => { 'Redis cache strategy' ) }) + + it('publishes a generated title only for the split pane that produced it', () => { + const paneTitles = { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' } + const tab = { + ...TAB, + aiVaultTitle: null, + generatedTitle: 'Fix intake flow', + generatedTitlePaneKey: makePaneKey(TAB_ID, LEAF_A) + } + + expect( + rowConversationName({ ...row(LEAF_A, 'session-a'), tab }, true, LAYOUT, paneTitles) + ).toBe('Fix intake flow') + expect( + rowConversationName({ ...row(LEAF_B, 'session-b'), tab }, true, LAYOUT, paneTitles) + ).toBe('Redis cache strategy') + }) }) diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts index 5c2b66b1d50a..e555383b08c4 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -54,7 +54,8 @@ export function rowConversationName( row.agentType, generatedTitlesEnabled, paneLiveTitle, - row.entry.providerSession?.id + row.entry.providerSession?.id, + row.paneKey ) ?? undefined ) } diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts index 9065cc64e34d..8b8ab781663d 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts @@ -216,6 +216,26 @@ describe('useAgentRowConversationName', () => { expect(useAgentRowConversationName(sessionB)).toBe('Redis cache strategy') }) + it('gives a generated title only to the pane whose prompt produced it', () => { + setSplitStore('\u2733 Linear work log') + storeState.current.settings = { tabAutoGenerateTitle: true } + storeState.current.tabsByWorktree['wt-1'][0] = { + id: 'tab-1', + worktreeId: 'wt-1', + customTitle: null, + title: '\u2733 Linear work log', + generatedTitle: 'Fix intake flow', + generatedTitlePaneKey: `tab-1:${LEAF_A}` + } + + expect(useAgentRowConversationName(splitRow(LEAF_A, '\u2733 Linear work log'))).toBe( + 'Fix intake flow' + ) + expect(useAgentRowConversationName(splitRow(LEAF_B, '\u2733 Linear work log'))).toBe( + 'Redis cache strategy' + ) + }) + it('does not rename the sibling row when the other pane is clicked', () => { // Clicking pane B re-syncs the tab title to B's; both rows must be unmoved. setSplitStore('\u2733 Redis cache strategy') diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts index 6cdb38f23055..4fadfc843b50 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts @@ -65,6 +65,7 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | agent.agentType, generatedTitlesEnabled, paneLiveTitle, - agent.entry.providerSession?.id + agent.entry.providerSession?.id, + agent.paneKey ) } diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts index 03d5e7883316..78487a85139e 100644 --- a/src/renderer/src/lib/activity-thread-display.test.ts +++ b/src/renderer/src/lib/activity-thread-display.test.ts @@ -67,6 +67,25 @@ describe('getActivityThreadTaskTitle', () => { ).toBe('Refactor auth middleware') }) + it("names a split pane from its own prompt, not a sibling pane's generated title", () => { + const splitTab = { ...tab, generatedTitlePaneKey: 'tab-1:pane-a' } + const entry = { prompt: 'Design a Redis cache strategy', stateHistory: [] } + expect( + getActivityThreadTaskTitle({ + entry: { ...entry, paneKey: 'tab-1:pane-b' }, + tab: splitTab, + generatedTitlesEnabled: true + }) + ).toBe('Design a Redis cache strategy') + expect( + getActivityThreadTaskTitle({ + entry: { ...entry, paneKey: 'tab-1:pane-a' }, + tab: splitTab, + generatedTitlesEnabled: true + }) + ).toBe('Refactor auth middleware') + }) + it('ignores terse live prompts and uses generated title or history', () => { expect( getActivityThreadTaskTitle({ diff --git a/src/renderer/src/lib/activity-thread-display.ts b/src/renderer/src/lib/activity-thread-display.ts index c330edcb6f00..989efd8b7545 100644 --- a/src/renderer/src/lib/activity-thread-display.ts +++ b/src/renderer/src/lib/activity-thread-display.ts @@ -5,6 +5,7 @@ import type { } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { Worktree } from '../../../shared/worktree/types' +import { isGeneratedTabTitleOwnedByPane } from '../../../shared/agent-tab-title' import { getAgentRowPrimaryText, isOrcaDispatchPrompt, @@ -95,8 +96,11 @@ export function getActivityThreadWorkspaceTitle( /** Stable task identity for Activity sidebar rows — not the latest follow-up turn. */ export function getActivityThreadTaskTitle(args: { - entry: Pick - tab: Pick + entry: Pick & { paneKey?: string } + tab: Pick< + TerminalTab, + 'customTitle' | 'generatedTitle' | 'generatedTitlePaneKey' | 'title' | 'defaultTitle' + > generatedTitlesEnabled: boolean }): string { const customTitle = args.tab.customTitle?.trim() @@ -111,7 +115,10 @@ export function getActivityThreadTaskTitle(args: { // Why: respect the user's tabAutoGenerateTitle setting — a disabled generated // title must not resurface here (mirrors resolveTerminalTabTitle's gate). - const generatedTitle = args.generatedTitlesEnabled ? args.tab.generatedTitle?.trim() : '' + const generatedTitle = + args.generatedTitlesEnabled && isGeneratedTabTitleOwnedByPane(args.tab, args.entry.paneKey) + ? args.tab.generatedTitle?.trim() + : '' if (generatedTitle) { return generatedTitle } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-generated-title.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-generated-title.test.ts new file mode 100644 index 000000000000..c03037ae7c72 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-generated-title.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { buildMirroredTerminalTabs } from './terminal-build' +import { toWebTerminalSurfaceTabId } from '../web-terminal-surface-id' + +const HOST_TAB = 'host-tab-1' +const SNAPSHOT: RuntimeMobileSessionTabsResult = { + worktree: 'repo-1::worktree-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'surface-1', + parentTabId: HOST_TAB, + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'handle-1', + isActive: true + } + ] +} + +function makeExisting(id: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId: 'repo-1::worktree-1', + title: 'Terminal', + generatedTitle: 'Fix intake flow', + generatedTitlePaneKey: `${id}:leaf-1`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +describe('buildMirroredTerminalTabs generated title', () => { + function rebuild(existing: TerminalTab, lookupId: string): TerminalTab | undefined { + const [mirrored] = buildMirroredTerminalTabs( + SNAPSHOT, + 'env-1', + new Map([[lookupId, existing]]), + {}, + 0, + 1_000 + ) + return mirrored?.tab + } + + it('keeps the client-local generated title and its source pane across a host rebuild', () => { + const localTabId = toWebTerminalSurfaceTabId(HOST_TAB) + expect(rebuild(makeExisting(localTabId), localTabId)).toMatchObject({ + generatedTitle: 'Fix intake flow', + generatedTitlePaneKey: `${localTabId}:leaf-1` + }) + }) + + it('drops a source pane recorded under another tab id so the title is not hidden', () => { + const tab = rebuild(makeExisting(HOST_TAB), HOST_TAB) + expect(tab?.generatedTitle).toBe('Fix intake flow') + expect(tab && 'generatedTitlePaneKey' in tab).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts index 929c1d661ce0..5999bedee1e1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts @@ -18,6 +18,7 @@ export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { a.quickCommandLabel === b.quickCommandLabel && a.startupCwd === b.startupCwd && a.generatedTitle === b.generatedTitle && + a.generatedTitlePaneKey === b.generatedTitlePaneKey && a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && a.aiVaultTitle?.title === b.aiVaultTitle?.title && diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index aacb445bbe3f..96df4dfbbacb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -170,6 +170,10 @@ export function buildMirroredTerminalTabs( // Why: the host transport carries no generated title, so rebuilding the tab // without this dropped the client's agent-prompt label on every snapshot. ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), + // Why: the source pane key embeds the tab id, so a record found under another id would hide the title. + ...(existing?.generatedTitle && existing.id === localTabId && existing.generatedTitlePaneKey + ? { generatedTitlePaneKey: existing.generatedTitlePaneKey } + : {}), ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), // Why: the recovery ledger is client-local and the host carries none, so // rebuilding without it would restore this tab's remount allowance on diff --git a/src/renderer/src/store/slices/agent-generated-tab-title-pane.test.ts b/src/renderer/src/store/slices/agent-generated-tab-title-pane.test.ts new file mode 100644 index 000000000000..f3b32f64a9f1 --- /dev/null +++ b/src/renderer/src/store/slices/agent-generated-tab-title-pane.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' + +const WORKTREE_ID = 'repo1::/path/wt1' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const DISPATCH_PROMPT = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task-1 + +=== CLI COMMANDS === +orca orchestration send --to term_parent + +=== TASK === +Implement the detailed worker instructions that should not be the short label` + +function seedSplitTab(store: ReturnType): string { + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + } + }) + return store.getState().createTab(WORKTREE_ID).id +} + +function generatedTitleOf(store: ReturnType) { + const { generatedTitle, generatedTitlePaneKey } = store.getState().tabsByWorktree[WORKTREE_ID][0] + return { generatedTitle, generatedTitlePaneKey } +} + +describe('generated tab title source pane', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('records the pane whose prompt produced the title and keeps it when a sibling starts', () => { + vi.useFakeTimers() + const store = createTestStore() + const tabId = seedSplitTab(store) + + store.getState().setAgentStatus(makePaneKey(tabId, LEAF_A), { + state: 'working', + prompt: 'Fix the patient intake flow in the portal', + agentType: 'claude' + }) + store.getState().setAgentStatus(makePaneKey(tabId, LEAF_B), { + state: 'working', + prompt: 'Design a Redis cache strategy for sessions', + agentType: 'codex' + }) + + expect(generatedTitleOf(store)).toEqual({ + generatedTitle: 'Fix the patient intake flow in the', + generatedTitlePaneKey: makePaneKey(tabId, LEAF_A) + }) + }) + + it('moves the source to the pane whose dispatch replaces the title', () => { + vi.useFakeTimers() + const store = createTestStore() + const tabId = seedSplitTab(store) + const paneB = makePaneKey(tabId, LEAF_B) + + store.getState().setAgentStatus(makePaneKey(tabId, LEAF_A), { + state: 'working', + prompt: 'Fix the patient intake flow in the portal', + agentType: 'claude' + }) + store.getState().setAgentStatus(paneB, { + state: 'working', + prompt: DISPATCH_PROMPT, + agentType: 'codex' + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [paneB]: { taskId: 'task-1', dispatchId: 'ctx-1', displayName: 'Better worker label' } + }) + + expect(generatedTitleOf(store)).toEqual({ + generatedTitle: 'Better worker label', + generatedTitlePaneKey: paneB + }) + }) +}) diff --git a/src/renderer/src/store/slices/terminal-tab-title-batch.ts b/src/renderer/src/store/slices/terminal-tab-title-batch.ts index d0456541af33..9765efa4ec62 100644 --- a/src/renderer/src/store/slices/terminal-tab-title-batch.ts +++ b/src/renderer/src/store/slices/terminal-tab-title-batch.ts @@ -237,7 +237,11 @@ export function applyGeneratedTabTitleUpdates( if (!generatedTitle || existingGeneratedTitle === generatedTitle) { continue } - updateStageTabs(stage, tabIndexes, (tab) => ({ ...tab, generatedTitle })) + updateStageTabs(stage, tabIndexes, (tab) => ({ + ...tab, + generatedTitle, + generatedTitlePaneKey: paneKey + })) updateStageUnifiedLabel(stage, tabId, 'generatedLabel', generatedTitle) } return finishTitleStages(state, stages) diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index f77a08e2fd41..c6c9dc4fee52 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -108,6 +108,31 @@ describe('getAgentRowConversationName', () => { expect(getAgentRowConversationName(generated, 'claude', true, null)).toBe('Fix intake flow') }) + it('gives a generated title only to the pane whose prompt produced it', () => { + const tab = makeTab({ + generatedTitle: 'Fix intake flow', + generatedTitlePaneKey: 'tab-1:pane-a' + }) + expect(getAgentRowConversationName(tab, 'claude', true, null, undefined, 'tab-1:pane-a')).toBe( + 'Fix intake flow' + ) + expect( + getAgentRowConversationName( + tab, + 'claude', + true, + '✳ Redis cache strategy', + undefined, + 'tab-1:pane-b' + ) + ).toBe('Redis cache strategy') + expect( + getAgentRowConversationName(tab, 'claude', true, null, undefined, 'tab-1:pane-b') + ).toBeNull() + // A caller that names no pane keeps today's tab-wide behavior, like an omitted pane title. + expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow') + }) + it('strips leading status decoration from agent-set titles', () => { expect( getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false) diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index 2f4ed31203ba..c5e760fc36eb 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -9,6 +9,7 @@ import type { AgentType } from './agent-status-types' import { isClaudeManagementTitle } from './agent-title-core' import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' +import { isGeneratedTabTitleOwnedByPane } from './agent-tab-title' import { formatAgentTypeLabel } from './agent-type-label' import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title' @@ -16,7 +17,13 @@ import type { TerminalTab } from './terminal-tab-types' export type ConversationNameTab = Pick< TerminalTab, - 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' | 'defaultTitle' + | 'customTitle' + | 'quickCommandLabel' + | 'aiVaultTitle' + | 'generatedTitle' + | 'generatedTitlePaneKey' + | 'title' + | 'defaultTitle' > // Why: synthetic status titles ("Codex ready", "Cursor - action required") are @@ -121,7 +128,8 @@ export function getAgentRowConversationName( // single-pane tab) keeps the tab title. Tab-owned names above are unaffected: // the user gave those to the whole tab and they do not flip on focus. paneLiveTitle?: string | null, - providerSessionId?: string + providerSessionId?: string, + paneKey?: string ): string | null { const customTitle = tab.customTitle?.trim() if (customTitle) { @@ -147,7 +155,10 @@ export function getAgentRowConversationName( ) { return providerTitle } - const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : '' + const generatedTitle = + generatedTitlesEnabled && isGeneratedTabTitleOwnedByPane(tab, paneKey) + ? tab.generatedTitle?.trim() + : '' if (generatedTitle) { return generatedTitle } diff --git a/src/shared/agent-tab-title.ts b/src/shared/agent-tab-title.ts index 76a363042671..2311c2e53197 100644 --- a/src/shared/agent-tab-title.ts +++ b/src/shared/agent-tab-title.ts @@ -1,3 +1,5 @@ +import type { TerminalTab } from './terminal-tab-types' + export const GENERATED_TAB_TITLE_MAX_LENGTH = 40 export const GENERATED_TAB_TITLE_SOURCE_SCAN_LIMIT = 512 @@ -109,3 +111,17 @@ export function deriveGeneratedTabTitle(prompt: string): string | null { return truncateAtWordBoundary(capitalizeFirstLetter(candidate), GENERATED_TAB_TITLE_MAX_LENGTH) } + +/** + * Whether a pane's row may show its tab's generated title. The title comes from one pane's + * prompt, so it names only that pane; untagged older titles, or callers without a pane, keep + * today's tab-wide behavior. + */ +export function isGeneratedTabTitleOwnedByPane( + tab: Pick, + paneKey: string | undefined +): boolean { + return ( + !tab.generatedTitlePaneKey || paneKey === undefined || tab.generatedTitlePaneKey === paneKey + ) +} diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index d99472e2fde3..947bc49b18f6 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -65,6 +65,8 @@ export type TerminalTab = { defaultTitle?: string /** Stable opt-in label derived from the first known agent prompt. */ generatedTitle?: string | null + /** Pane whose agent prompt produced `generatedTitle`; absent on titles saved before it was recorded. */ + generatedTitlePaneKey?: string | null /** Stable AI Vault conversation name, bound to its provider session identity. */ aiVaultTitle?: AiVaultSessionTitle | null /** Stable label from the tab-bar Quick Command that created this terminal. */ diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 81bc37b6b653..74e3123ac3d3 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -238,6 +238,7 @@ describe('parseWorkspaceSession', () => { title: 'Claude working', defaultTitle: 'Terminal 1', generatedTitle: 'Refactor auth', + generatedTitlePaneKey: 'tab1:11111111-1111-4111-8111-111111111111', aiVaultTitle: { agent: 'codex', sessionId: 'session-1', @@ -279,6 +280,9 @@ describe('parseWorkspaceSession', () => { expect(result.ok).toBe(true) if (result.ok) { expect(result.value.tabsByWorktree.wt[0].generatedTitle).toBe('Refactor auth') + expect(result.value.tabsByWorktree.wt[0].generatedTitlePaneKey).toBe( + 'tab1:11111111-1111-4111-8111-111111111111' + ) expect(result.value.tabsByWorktree.wt[0].aiVaultTitle?.title).toBe('Provider thread name') expect(result.value.unifiedTabs?.wt[0].generatedLabel).toBe('Refactor auth') expect(result.value.unifiedTabs?.wt[0].aiVaultTitle?.title).toBe('Provider thread name') @@ -286,6 +290,38 @@ describe('parseWorkspaceSession', () => { } }) + it('drops a malformed generated title source without dropping the terminal tab', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Codex', + generatedTitle: 'Refactor auth', + generatedTitlePaneKey: { leaf: 'future-shape' }, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + terminalLayoutsByTabId: {} + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt).toHaveLength(1) + expect(result.value.tabsByWorktree.wt[0].generatedTitle).toBe('Refactor auth') + expect(result.value.tabsByWorktree.wt[0].generatedTitlePaneKey).toBeUndefined() + } + }) + it('drops malformed AI Vault titles without rejecting the workspace session', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 0a24551381e8..8b160286e2ce 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -86,6 +86,7 @@ const terminalTabSchema = z.object({ title: z.string(), defaultTitle: z.string().optional(), generatedTitle: z.string().nullable().optional(), + generatedTitlePaneKey: z.string().nullable().optional().catch(undefined), aiVaultTitle: z .object({ agent: z.enum(['claude', 'codex']),