From 31e13bd32c3ef5005d8bde3f3a71685c66bfb64d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:14:42 -0700 Subject: [PATCH] fix(tabs): preserve cleared Vault titles across state rebuilds --- .../useTabGroupItemProjections.test.ts | 15 ++++ .../tab-group/useTabGroupItemProjections.ts | 11 ++- .../ai-vault-tab-title-sync-inputs.test.ts | 21 ++++++ .../src/lib/ai-vault-tab-title-sync-inputs.ts | 19 ++--- .../src/lib/ai-vault-tab-title-sync.test.ts | 14 ++++ ...-graph-terminal-surface-projection.test.ts | 43 +++++++++++ ...ssion-tabs-sync-terminal-mirroring.test.ts | 22 +++++- .../src/runtime/web-session-tabs-sync.ts | 9 +-- .../slices/agent-generated-tab-title.test.ts | 49 ++++++++++++ .../tabs-hydration-generated-title.test.ts | 75 +++++++++++++++++++ .../src/store/slices/tabs-hydration.ts | 15 ++-- .../slices/tabs-model-reconciliation.test.ts | 29 +++++++ src/renderer/src/store/slices/tabs.ts | 2 +- .../slices/terminal-session-row-hydration.ts | 14 +++- ...terminals-hydration-canonical-rows.test.ts | 46 ++++++++++++ src/renderer/src/store/slices/terminals.ts | 10 +-- .../agent-row-conversation-name.test.ts | 10 +++ src/shared/agent-row-conversation-name.ts | 3 +- src/shared/ai-vault-session-title.ts | 16 ++++ src/shared/tab-title-resolution.test.ts | 25 +++++++ src/shared/tab-title-resolution.ts | 4 +- src/shared/workspace-session-schema.test.ts | 58 ++++++++++++++ 22 files changed, 463 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts index 671a7482dbf..d08371be385 100644 --- a/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts @@ -111,4 +111,19 @@ describe('useTabGroupItemProjections', () => { expect(terminalTabs[0]?.aiVaultTitle).toBeNull() }) + + it('uses a terminal clear when the unified tab still has stale title metadata', () => { + const { terminalTabs } = useTabGroupItemProjections({ + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + worktreeState: makeState({ + terminalTab: makeTerminalTab({ aiVaultTitle: null }), + unifiedTab: makeUnifiedTab() + }) + }) + + expect(terminalTabs[0]?.aiVaultTitle).toBeNull() + expect(terminalTabs[0]?.title).toBe('✳ pull again') + expect(resolveTerminalTabTitle(terminalTabs[0]!, true)).toBe('✳ pull again') + }) }) diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts index f17662c27cf..9e420560a2e 100644 --- a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts +++ b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts @@ -56,6 +56,8 @@ export function useTabGroupItemProjections({ .filter((item) => item.contentType === 'terminal') .map((item) => { const terminalTab = terminalTabById.get(item.entityId) + const aiVaultTitle = + terminalTab?.aiVaultTitle !== undefined ? terminalTab.aiVaultTitle : item.aiVaultTitle return { id: item.entityId, unifiedTabId: item.id, @@ -65,7 +67,8 @@ export function useTabGroupItemProjections({ { ...item, quickCommandLabel: item.quickCommandLabel ?? terminalTab?.quickCommandLabel, - generatedLabel: item.generatedLabel ?? terminalTab?.generatedTitle + generatedLabel: item.generatedLabel ?? terminalTab?.generatedTitle, + aiVaultTitle }, worktreeState.generatedTabTitlesEnabled, item.label @@ -74,11 +77,7 @@ export function useTabGroupItemProjections({ quickCommandLabel: terminalTab?.quickCommandLabel ?? item.quickCommandLabel ?? null, generatedTitle: terminalTab?.generatedTitle ?? item.generatedLabel ?? null, // Why: TabBar re-resolves this projection; dropping vault lets first-bind generated win. - ...(terminalTab?.aiVaultTitle !== undefined - ? { aiVaultTitle: terminalTab.aiVaultTitle } - : item.aiVaultTitle !== undefined - ? { aiVaultTitle: item.aiVaultTitle } - : {}), + ...(aiVaultTitle !== undefined ? { aiVaultTitle } : {}), customTitle: item.customLabel ?? terminalTab?.customTitle ?? null, color: item.color ?? terminalTab?.color ?? null, sortOrder: item.sortOrder, diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts new file mode 100644 index 00000000000..bb8a7a42ef2 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { createTestStore, makeTab } from '@/store/slices/store-test-helpers' +import { aiVaultTitleSyncInputsChanged } from './ai-vault-tab-title-sync-inputs' + +describe('aiVaultTitleSyncInputsChanged', () => { + it('distinguishes an absent Vault title from an explicit clear', () => { + const previous = createTestStore().getState() + const tab = makeTab({ id: 'terminal-1', worktreeId: 'wt-1' }) + const withAbsent = { + ...previous, + tabsByWorktree: { 'wt-1': [tab] } + } + const withClear = { + ...withAbsent, + tabsByWorktree: { 'wt-1': [{ ...tab, aiVaultTitle: null }] } + } + + expect(aiVaultTitleSyncInputsChanged(withClear, withAbsent)).toBe(true) + expect(aiVaultTitleSyncInputsChanged(withClear, withClear)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts index 569d79b0dc8..9034fba2a2e 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts @@ -1,6 +1,8 @@ import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' -import { isAiVaultTitleAgent } from '../../../shared/ai-vault-session-title' -import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { + aiVaultSessionTitlesEqual, + isAiVaultTitleAgent +} from '../../../shared/ai-vault-session-title' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' import { collectAiVaultTitleRequests } from './ai-vault-tab-title-requests' @@ -92,17 +94,6 @@ function agentRecordsEqual(current: AppState, previous: AppState): boolean { ) } -function titleEqual( - left: TerminalTab['aiVaultTitle'], - right: TerminalTab['aiVaultTitle'] -): boolean { - return ( - left?.agent === right?.agent && - left?.sessionId === right?.sessionId && - left?.title === right?.title - ) -} - function terminalTabsEqual(current: AppState, previous: AppState): boolean { const currentKeys = Object.keys(current.tabsByWorktree) const previousKeys = Object.keys(previous.tabsByWorktree) @@ -121,7 +112,7 @@ function terminalTabsEqual(current: AppState, previous: AppState): boolean { if ( left.id !== right.id || left.worktreeId !== right.worktreeId || - !titleEqual(left.aiVaultTitle, right.aiVaultTitle) + !aiVaultSessionTitlesEqual(left.aiVaultTitle, right.aiVaultTitle) ) { return false } diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts index 5da4afba247..e6783d9d165 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts @@ -292,6 +292,20 @@ describe('AI Vault tab title sync', () => { stop() }) + it('leaves a fresh unnamed session absent when the Vault has no title', async () => { + const store = makeState({ + executionHostId: 'ssh:dev-box', + worktreeId: 'worktree-1', + path: '/workspace/albacore' + }) + const resolveSessionTitles = vi.fn(async () => ({ titles: [] })) + const stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles }) + + await vi.waitFor(() => expect(resolveSessionTitles).toHaveBeenCalledTimes(1)) + expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle).toBeUndefined() + stop() + }) + it('defers title reads through the configured background scheduler', async () => { const store = makeState({ executionHostId: 'ssh:dev-box', diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts index 73108a71fb8..9a632934023 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts @@ -494,6 +494,49 @@ describe('buildMobileSessionTabSnapshots', () => { }) }) + it('publishes the tri-state Vault title precedence to paired clients', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const tab = { + id: 'term-1', + worktreeId: 'wt-1', + title: 'Claude working', + generatedTitle: 'Pull again', + customTitle: null, + ptyId: 'pty-1', + color: null, + sortOrder: 0, + createdAt: 1 + } + const base = makeState({ + settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, + tabBarOrderByWorktree: { 'wt-1': ['term-1'] }, + tabsByWorktree: { 'wt-1': [tab] } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } as AppState['terminalLayoutsByTabId'] + }) + const publishedTitle = ( + aiVaultTitle?: AppState['tabsByWorktree'][string][number]['aiVaultTitle'] + ) => + buildMobileSessionTabSnapshots({ + ...base, + tabsByWorktree: { + 'wt-1': [{ ...tab, ...(aiVaultTitle !== undefined ? { aiVaultTitle } : {}) }] + } as AppState['tabsByWorktree'] + })[0]?.tabs[0]?.title + + expect(publishedTitle()).toBe('Pull again') + expect(publishedTitle(null)).toBe('Claude working') + expect(publishedTitle({ agent: 'claude', sessionId: 'session-1', title: 'Housekeeping' })).toBe( + 'Housekeeping' + ) + }) + it('publishes quick command labels to mobile snapshots before generated titles', () => { const leafId = '11111111-1111-4111-8111-111111111111' const state = makeState({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts index 08b36c768dc..98b9f9869a3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { Tab } from '../../../shared/tab-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' import { @@ -123,18 +124,34 @@ describe('applyWebSessionTabsSnapshot', () => { id: toWebTerminalSurfaceTabId('host-tab-1'), ptyId: 'remote:web-env-1@@terminal-1', worktreeId: WT, - title: 'Claude working', - defaultTitle: 'Terminal', + title: 'Claude', + defaultTitle: 'Claude', customTitle: null, color: null, sortOrder: 0, createdAt: NOW, aiVaultTitle: null } + const existingUnifiedTab: Tab = { + id: existingTab.id, + entityId: existingTab.id, + groupId: 'host-group-1', + worktreeId: WT, + executionHostId: 'runtime:web-env-1', + contentType: 'terminal', + label: 'Claude', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW, + isPreview: false, + isPinned: false + } const patch = applyWebSessionTabsSnapshot( makeState({ tabsByWorktree: { [WT]: [existingTab] }, + unifiedTabsByWorktree: { [WT]: [existingUnifiedTab] }, ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-1'] } }), makeSnapshot([ @@ -153,7 +170,6 @@ describe('applyWebSessionTabsSnapshot', () => { NOW + 1 ) as Partial - expect(patch.tabsByWorktree?.[WT]?.[0]?.aiVaultTitle).toBeNull() expect(patch.unifiedTabsByWorktree?.[WT]?.[0]?.aiVaultTitle).toBeNull() }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 8b59807de99..182d65ab753 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -29,6 +29,7 @@ import type { } from '../../../shared/browser-workspace-types' import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import { aiVaultSessionTitlesEqual } from '../../../shared/ai-vault-session-title' import type { OpenFile } from '../store/slices/editor' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { @@ -2255,9 +2256,7 @@ function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { a.quickCommandLabel === b.quickCommandLabel && a.startupCwd === b.startupCwd && a.generatedTitle === b.generatedTitle && - a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && - a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && - a.aiVaultTitle?.title === b.aiVaultTitle?.title && + aiVaultSessionTitlesEqual(a.aiVaultTitle, b.aiVaultTitle) && a.customTitle === b.customTitle && a.color === b.color && a.sortOrder === b.sortOrder && @@ -2465,9 +2464,7 @@ function tabEqual(a: Tab, b: Tab): boolean { // Why: the generated label is the visible tab title; ignoring it let the // equality bail keep a unified tab that disagreed with its terminal tab. a.generatedLabel === b.generatedLabel && - a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && - a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && - a.aiVaultTitle?.title === b.aiVaultTitle?.title && + aiVaultSessionTitlesEqual(a.aiVaultTitle, b.aiVaultTitle) && a.customLabel === b.customLabel && a.color === b.color && a.sortOrder === b.sortOrder && diff --git a/src/renderer/src/store/slices/agent-generated-tab-title.test.ts b/src/renderer/src/store/slices/agent-generated-tab-title.test.ts index 3973d799051..41548b54bd1 100644 --- a/src/renderer/src/store/slices/agent-generated-tab-title.test.ts +++ b/src/renderer/src/store/slices/agent-generated-tab-title.test.ts @@ -523,4 +523,53 @@ Implement task B worker instructions for the next dispatch`, expect(resolveTerminalTabTitle(tab, true)).not.toBe('Pull again') expect(getAgentRowConversationName(tab, 'claude', true)).not.toBe('Pull again') }) + + it('persists an explicit clear from an absent Vault title and blocks later generation', () => { + vi.useFakeTimers() + const store = createTestStore() + const tabId = seedWorktree(store, true) + + store.setState({ + tabsByWorktree: { + ...store.getState().tabsByWorktree, + [WORKTREE_ID]: store + .getState() + .tabsByWorktree[WORKTREE_ID].map((tab) => + tab.id === tabId ? { ...tab, generatedTitle: 'Pull again' } : tab + ) + } + }) + store.getState().setAiVaultTabTitle(tabId, null) + store.getState().setAgentStatus(makePaneKey(tabId, LEAF_ID), { + state: 'working', + prompt: 'Restore the stale title', + agentType: 'claude' + }) + + const tab = store.getState().tabsByWorktree[WORKTREE_ID][0] + expect(tab.aiVaultTitle).toBeNull() + expect(tab.generatedTitle).toBe('Pull again') + expect(resolveTerminalTabTitle(tab, true)).not.toBe('Pull again') + }) + + it('treats repeated clears as equal and accepts the next resolved title', () => { + const store = createTestStore() + const tabId = seedWorktree(store, true) + store.getState().setAiVaultTabTitle(tabId, null) + const clearedTabs = store.getState().tabsByWorktree + + store.getState().setAiVaultTabTitle(tabId, null) + expect(store.getState().tabsByWorktree).toBe(clearedTabs) + + store.getState().setAiVaultTabTitle(tabId, { + agent: 'claude', + sessionId: 'claude-session-2', + title: 'Housekeeping' + }) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].aiVaultTitle).toEqual({ + agent: 'claude', + sessionId: 'claude-session-2', + title: 'Housekeeping' + }) + }) }) diff --git a/src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts b/src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts index dc8d7836a2a..bb3145b8485 100644 --- a/src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts @@ -82,6 +82,81 @@ describe('buildHydratedTabState generated terminal titles', () => { expect(result.unifiedTabsByWorktree.w1[0].generatedLabel).toBe('Persisted agent title') }) + it('preserves an explicit Vault clear across unified hydration conflicts', () => { + const title = { agent: 'claude' as const, sessionId: 'session-1', title: 'Stale name' } + const baseTerminal = { + id: 't1', + ptyId: null, + worktreeId: 'w1', + title: 'Claude working', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const baseUnified = { + id: 't1', + entityId: 't1', + groupId: 'g1', + worktreeId: 'w1', + contentType: 'terminal' as const, + label: 'Claude working', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const hydrate = (terminalTitle: typeof title | null, unifiedTitle: typeof title | null) => + buildHydratedTabState( + { + ...makeBaseSession(), + tabsByWorktree: { w1: [{ ...baseTerminal, aiVaultTitle: terminalTitle }] }, + unifiedTabs: { w1: [{ ...baseUnified, aiVaultTitle: unifiedTitle }] }, + tabGroups: { + w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }] + } + }, + new Set(['w1']) + ).unifiedTabsByWorktree.w1[0].aiVaultTitle + + expect(hydrate(title, null)).toBeNull() + expect(hydrate(null, title)).toBeNull() + }) + + it('converts legacy Vault title objects and clears without inventing absence', () => { + const makeSession = ( + aiVaultTitle: { agent: 'claude'; sessionId: string; title: string } | null + ) => + ({ + ...makeBaseSession(), + tabsByWorktree: { + w1: [ + { + id: 'tt1', + ptyId: null, + worktreeId: 'w1', + title: 'bash', + aiVaultTitle, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 100 + } + ] + } + }) satisfies WorkspaceSessionState + const title = { agent: 'claude' as const, sessionId: 'session-1', title: 'Housekeeping' } + + expect( + buildHydratedTabState(makeSession(title), new Set(['w1'])).unifiedTabsByWorktree.w1[0] + .aiVaultTitle + ).toEqual(title) + expect( + buildHydratedTabState(makeSession(null), new Set(['w1'])).unifiedTabsByWorktree.w1[0] + .aiVaultTitle + ).toBeNull() + }) + it('hydrates quick command labels from persisted terminal metadata', () => { const session: WorkspaceSessionState = { ...makeBaseSession(), diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index c115c431539..2b19abf8456 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -87,9 +87,9 @@ function hydrateUnifiedFormat( .map((tab) => [tab.id, tab.quickCommandLabel!.trim()]) ) const aiVaultTitleByTerminalId = new Map( - (session.tabsByWorktree[worktreeId] ?? []) - .filter((tab) => tab.aiVaultTitle) - .map((tab) => [tab.id, tab.aiVaultTitle!]) + (session.tabsByWorktree[worktreeId] ?? []).flatMap((tab) => + tab.aiVaultTitle !== undefined ? [[tab.id, tab.aiVaultTitle] as const] : [] + ) ) const hydratedTabs = [...tabs] .map((tab) => ({ @@ -104,11 +104,15 @@ function hydrateUnifiedFormat( ? tab.quickCommandLabel.trim() : quickCommandLabelByTerminalId.get(tab.entityId) const generatedLabel = generatedTitleByTerminalId.get(tab.entityId) - const aiVaultTitle = tab.aiVaultTitle ?? aiVaultTitleByTerminalId.get(tab.entityId) + const terminalAiVaultTitle = aiVaultTitleByTerminalId.get(tab.entityId) + const aiVaultTitle = + tab.aiVaultTitle === null || terminalAiVaultTitle === null + ? null + : (tab.aiVaultTitle ?? terminalAiVaultTitle) return { ...tab, ...(quickCommandLabel ? { quickCommandLabel } : {}), - ...(aiVaultTitle ? { aiVaultTitle } : {}), + ...(aiVaultTitle !== undefined ? { aiVaultTitle } : {}), ...(!tab.generatedLabel?.trim() && generatedLabel ? { generatedLabel } : {}) } }) @@ -238,6 +242,7 @@ function hydrateLegacyFormat( label: tt.title, ...(tt.quickCommandLabel?.trim() ? { quickCommandLabel: tt.quickCommandLabel.trim() } : {}), ...(tt.generatedTitle?.trim() ? { generatedLabel: tt.generatedTitle.trim() } : {}), + ...(tt.aiVaultTitle !== undefined ? { aiVaultTitle: tt.aiVaultTitle } : {}), customLabel: tt.customTitle, color: tt.color, sortOrder: tt.sortOrder, diff --git a/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts b/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts index 0869b720ec0..043261ab4af 100644 --- a/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts +++ b/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts @@ -108,6 +108,35 @@ describe('TabsSlice', () => { expect(result.renderableTabCount).toBe(1) }) + it('keeps an explicit Vault clear while promoting a reconnecting legacy terminal', () => { + store.setState({ + tabsByWorktree: { + [WT]: [ + { + id: 'reconnecting-terminal', + ptyId: null, + worktreeId: WT, + title: 'claude', + aiVaultTitle: null, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'reconnecting-terminal': [] }, + pendingReconnectPtyIdByTabId: { 'reconnecting-terminal': 'session-live' }, + unifiedTabsByWorktree: { [WT]: [] }, + groupsByWorktree: {}, + activeGroupIdByWorktree: {} + }) + + store.getState().reconcileWorktreeTabModel(WT) + + expect(store.getState().unifiedTabsByWorktree[WT][0]?.aiVaultTitle).toBeNull() + }) + // A session mirrored from a runtime host used to append a fresh leaf for a // group the layout already held, so returning to a split workspace showed the // same tab strip in several columns. Reconciliation collapses the repeats. diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 7a1c5b1150a..8461b1be0e6 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -664,7 +664,7 @@ export function projectWorktreeTabModelReconciliation( ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), - ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), + ...(tab.aiVaultTitle !== undefined ? { aiVaultTitle: tab.aiVaultTitle } : {}), customLabel: tab.customTitle, color: tab.color, sortOrder: tab.sortOrder, diff --git a/src/renderer/src/store/slices/terminal-session-row-hydration.ts b/src/renderer/src/store/slices/terminal-session-row-hydration.ts index eda4f369725..4b7fe38a9b7 100644 --- a/src/renderer/src/store/slices/terminal-session-row-hydration.ts +++ b/src/renderer/src/store/slices/terminal-session-row-hydration.ts @@ -18,7 +18,7 @@ type CanonicalTerminals = { /** PTYs already claimed by canonical rows that survive hydration, keyed to their claimant. */ tabIdByPtyId: Map quickCommandLabelByTabId: Map - aiVaultTitleByTabId: Map + aiVaultTitleByTabId: Map } export type HydrateWorkspaceTerminalRowsOptions = { @@ -146,7 +146,9 @@ function readCanonicalTerminals( ) ), aiVaultTitleByTabId: new Map( - canonicalTabs.flatMap((tab) => (tab.aiVaultTitle ? [[tab.entityId, tab.aiVaultTitle]] : [])) + canonicalTabs.flatMap((tab) => + tab.aiVaultTitle !== undefined ? [[tab.entityId, tab.aiVaultTitle] as const] : [] + ) ) } } @@ -202,11 +204,15 @@ function restoreCanonicalMetadata( ): TerminalTab { const quickCommandLabel = row.quickCommandLabel?.trim() || canonical.quickCommandLabelByTabId.get(row.id) - const aiVaultTitle = row.aiVaultTitle ?? canonical.aiVaultTitleByTabId.get(row.id) + const canonicalAiVaultTitle = canonical.aiVaultTitleByTabId.get(row.id) + const aiVaultTitle = + row.aiVaultTitle === null || canonicalAiVaultTitle === null + ? null + : (row.aiVaultTitle ?? canonicalAiVaultTitle) return { ...clearTransientTerminalState(row, index), ...(quickCommandLabel ? { quickCommandLabel } : {}), - ...(aiVaultTitle ? { aiVaultTitle } : {}), + ...(aiVaultTitle !== undefined ? { aiVaultTitle } : {}), sortOrder: index, // Why: suppress restored mounts so only real activity updates Recent. pendingActivationSpawn: true diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts index 464159fa219..f04ed49cf9c 100644 --- a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts @@ -5,8 +5,54 @@ import type { WorkspaceSessionState } from '../../../../shared/workspace-session import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' +import { hydrateWorkspaceTerminalRows } from './terminal-session-row-hydration' describe('hydrateWorkspaceSession canonical terminal rows', () => { + it('keeps an explicit clear when canonical and legacy Vault metadata disagree', () => { + const worktreeId = 'repo1::/wt-1' + const staleTitle = { + agent: 'claude' as const, + sessionId: 'claude-session-1', + title: 'Stale name' + } + const row = makeTab({ id: 'terminal-1', worktreeId, aiVaultTitle: null }) + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [row] }, + unifiedTabs: { + [worktreeId]: [ + { + id: 'unified-1', + entityId: row.id, + groupId: 'group-1', + worktreeId, + contentType: 'terminal' as const, + label: row.title, + aiVaultTitle: staleTitle, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + + expect(hydrateWorkspaceTerminalRows(session, worktreeId, [row]).rows[0].aiVaultTitle).toBeNull() + expect( + hydrateWorkspaceTerminalRows( + { + ...session, + unifiedTabs: { + [worktreeId]: [{ ...session.unifiedTabs[worktreeId][0], aiVaultTitle: null }] + } + }, + worktreeId, + [{ ...row, aiVaultTitle: staleTitle }] + ).rows[0].aiVaultTitle + ).toBeNull() + }) + it('drops only legacy rows that duplicate canonical PTY ownership', () => { const store = createTestStore() const worktreeId = 'repo1::/wt-1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 4f65774dcec..208ef0a0323 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -47,7 +47,10 @@ import type { SessionOptionValue } from '../../../../shared/native-chat-session- import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' -import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' +import { + aiVaultSessionTitlesEqual, + type AiVaultSessionTitle +} from '../../../../shared/ai-vault-session-title' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' import { sweepRetiredTerminalTabState } from './retired-terminal-tab-state-sweep' @@ -1987,10 +1990,7 @@ export const createTerminalSlice: StateCreator } const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] const current = tabs.find((tab) => tab.id === tabId) - const sameTitle = - current?.aiVaultTitle?.agent === aiVaultTitle?.agent && - current?.aiVaultTitle?.sessionId === aiVaultTitle?.sessionId && - current?.aiVaultTitle?.title === aiVaultTitle?.title + const sameTitle = aiVaultSessionTitlesEqual(current?.aiVaultTitle, aiVaultTitle) if (!current || sameTitle) { return s } diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index 07af6ee4a84..ed59b7dc478 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -66,6 +66,16 @@ describe('getAgentRowConversationName', () => { ) }) + it('does not restore a generated name while the AI Vault title is explicitly cleared', () => { + const tab = makeTab({ + aiVaultTitle: null, + generatedTitle: 'Pull again', + title: '✳ Housekeeping' + }) + expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Housekeeping') + expect(getAgentRowConversationName(tab, 'claude', true, null)).toBeNull() + }) + it('names a split pane from its own live title, not the tab title', () => { // Why: the tab title is the FOCUSED pane's, so the sibling must not read it. const tab = makeTab({ title: '\u2733 Linear work log' }) diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index a5a153b3b74..8340369b079 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -138,7 +138,8 @@ export function getAgentRowConversationName( if (aiVaultTitle) { return aiVaultTitle } - const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : '' + const generatedTitle = + generatedTitlesEnabled && tab.aiVaultTitle !== null ? tab.generatedTitle?.trim() : '' if (generatedTitle) { return generatedTitle } diff --git a/src/shared/ai-vault-session-title.ts b/src/shared/ai-vault-session-title.ts index d72d5b6192b..1e1171962dd 100644 --- a/src/shared/ai-vault-session-title.ts +++ b/src/shared/ai-vault-session-title.ts @@ -24,6 +24,22 @@ export type AiVaultSessionTitlesResult = { titles: AiVaultSessionTitle[] } +export function aiVaultSessionTitlesEqual( + left: AiVaultSessionTitle | null | undefined, + right: AiVaultSessionTitle | null | undefined +): boolean { + if (left === right) { + return true + } + return ( + left != null && + right != null && + left.agent === right.agent && + left.sessionId === right.sessionId && + left.title === right.title + ) +} + export function isAiVaultTitleAgent( agent: string | null | undefined ): agent is AiVaultSessionTitle['agent'] { diff --git a/src/shared/tab-title-resolution.test.ts b/src/shared/tab-title-resolution.test.ts index 852cf90c3e6..728a4114402 100644 --- a/src/shared/tab-title-resolution.test.ts +++ b/src/shared/tab-title-resolution.test.ts @@ -108,6 +108,31 @@ describe('tab title resolution', () => { ).toBe('Housekeeping') }) + it('treats an explicit Vault clear as suppressing only the generated fallback', () => { + expect( + resolveTerminalTabTitle( + { + customTitle: null, + aiVaultTitle: null, + generatedTitle: 'Pull again', + title: 'Claude working' + }, + true + ) + ).toBe('Claude working') + expect( + resolveUnifiedTabLabel( + { + customLabel: null, + aiVaultTitle: null, + generatedLabel: 'Pull again', + label: 'Claude working' + }, + true + ) + ).toBe('Claude working') + }) + it('keeps manual and quick-command labels ahead of AI Vault titles', () => { const aiVaultTitle = { agent: 'claude' as const, diff --git a/src/shared/tab-title-resolution.ts b/src/shared/tab-title-resolution.ts index ffcab13925d..36a7971e7f1 100644 --- a/src/shared/tab-title-resolution.ts +++ b/src/shared/tab-title-resolution.ts @@ -16,7 +16,7 @@ export function resolveTerminalTabTitle( tab.quickCommandLabel?.trim() || (isMeaningfulOpenCodeTerminalTitle(liveTitle) ? liveTitle : '') || tab.aiVaultTitle?.title.trim() || - (generatedTitlesEnabled ? tab.generatedTitle?.trim() : '') || + (generatedTitlesEnabled && tab.aiVaultTitle !== null ? tab.generatedTitle?.trim() : '') || liveTitle || fallback ) @@ -35,7 +35,7 @@ export function resolveUnifiedTabLabel( tab?.quickCommandLabel?.trim() || (isMeaningfulOpenCodeTerminalTitle(liveLabel) ? liveLabel : '') || tab?.aiVaultTitle?.title.trim() || - (generatedTitlesEnabled ? tab?.generatedLabel?.trim() : '') || + (generatedTitlesEnabled && tab?.aiVaultTitle !== null ? tab?.generatedLabel?.trim() : '') || liveLabel || fallback ) diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index c8da6581641..7fab3549b55 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -14,6 +14,64 @@ describe('parseWorkspaceSession', () => { expect(result.ok).toBe(true) }) + it('preserves absent, cleared, and resolved Vault titles on both tab representations', () => { + const title = { agent: 'claude' as const, sessionId: 'session-1', title: 'Housekeeping' } + for (const aiVaultTitle of [undefined, null, title]) { + const optionalTitle = aiVaultTitle === undefined ? {} : { aiVaultTitle } + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'terminal-1', + tabsByWorktree: { + wt: [ + { + id: 'terminal-1', + ptyId: null, + worktreeId: 'wt', + title: 'Claude working', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...optionalTitle + } + ] + }, + terminalLayoutsByTabId: {}, + unifiedTabs: { + wt: [ + { + id: 'unified-1', + entityId: 'terminal-1', + groupId: 'group-1', + worktreeId: 'wt', + contentType: 'terminal', + label: 'Claude working', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...optionalTitle + } + ] + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + const terminal = result.value.tabsByWorktree.wt[0] + const unified = result.value.unifiedTabs?.wt[0] + if (aiVaultTitle === undefined) { + expect(terminal).not.toHaveProperty('aiVaultTitle') + expect(unified).not.toHaveProperty('aiVaultTitle') + } else { + expect(terminal.aiVaultTitle).toEqual(aiVaultTitle) + expect(unified?.aiVaultTitle).toEqual(aiVaultTitle) + } + } + } + }) + it('preserves external SSH file ownership across session parsing', () => { const result = parseWorkspaceSession({ activeRepoId: null,