fix(tabs): preserve cleared Vault titles across state rebuilds

This commit is contained in:
Brennan Benson
2026-08-23 13:14:42 -07:00
parent f43a9d6098
commit 31e13bd32c
22 changed files with 463 additions and 47 deletions
@@ -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')
})
})
@@ -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,
@@ -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)
})
})
@@ -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
}
@@ -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',
@@ -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({
@@ -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<WebSessionTabsSyncState>
expect(patch.tabsByWorktree?.[WT]?.[0]?.aiVaultTitle).toBeNull()
expect(patch.unifiedTabsByWorktree?.[WT]?.[0]?.aiVaultTitle).toBeNull()
})
@@ -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 &&
@@ -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'
})
})
})
@@ -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(),
@@ -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,
@@ -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.
+1 -1
View File
@@ -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,
@@ -18,7 +18,7 @@ type CanonicalTerminals = {
/** PTYs already claimed by canonical rows that survive hydration, keyed to their claimant. */
tabIdByPtyId: Map<string, string>
quickCommandLabelByTabId: Map<string, string>
aiVaultTitleByTabId: Map<string, AiVaultSessionTitle>
aiVaultTitleByTabId: Map<string, AiVaultSessionTitle | null>
}
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
@@ -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'
+5 -5
View File
@@ -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<AppState, [], [], TerminalSlice>
}
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
}
@@ -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' })
+2 -1
View File
@@ -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
}
+16
View File
@@ -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'] {
+25
View File
@@ -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,
+2 -2
View File
@@ -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
)
@@ -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,