mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(tabs): preserve cleared Vault titles across state rebuilds
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user