fix(tabs): a session's own name replaces a quick-command tab name (#38677)

* fix(tabs): a session's own name replaces a quick-command tab name

* fix(tabs): only a session's own name replaces a quick-command tab name

Session titles now say whether they are the session's own name (a rename
or a provider-generated name) or a stand-in such as the first prompt. A
quick-command tab keeps its label until the session has a name; a name
typed on the tab still wins. Titles from older hosts and saved tabs with
no kind keep the label; old saved titles are asked for once.

* fix(tabs): classify History-cached titles for session-id-only title requests

* fix(tabs): keep a cached session title when its transcript cannot be reread

* fix(tabs): keep a session title when its classification reread fails

* fix(tabs): keep the transcript for an unclassified cached session title

* test(tabs): keep the cached-title fixture's agent literal
This commit is contained in:
Brennan Benson
2026-11-05 16:30:03 -08:00
committed by GitHub
parent b468781c20
commit c7eae748d8
29 changed files with 343 additions and 93 deletions
@@ -61,6 +61,7 @@ describe('codex-specific resume behavior', () => {
process.platform
)
expect(seeded?.title).toBe('codex seed question')
expect(seeded?.titleKind).toBe('fallback')
// Index titles arrive independently of transcript writes.
await writeFile(
@@ -75,6 +76,7 @@ describe('codex-specific resume behavior', () => {
)
expect(stats.reused).toBe(1)
expect(renamed?.title).toBe('Indexed thread title')
expect(renamed?.titleKind).toBe('named')
expect(renamed).toEqual(
await parseAgentSessionFile(await candidateFor('codex', path, codexHome), process.platform)
)
@@ -1,7 +1,6 @@
import { requestSessionSearchRoots } from './session-scanner-service-root-request'
import { aiVaultScanWork, trackAiVaultScanProgress } from '../ai-vault-scan-progress'
import type { AiVaultScanWork } from '../../../shared/ai-vault/scanning/ai-vault-scan-progress'
import type { AiVaultSessionTitle } from '../../../shared/ai-vault/contracts/ai-vault-session-title'
import type { AiVaultListResult } from '../../../shared/ai-vault/contracts/ai-vault-types'
import { readAiVaultFirstUserPrompt } from '../session-first-user-prompt-read'
import {
@@ -21,7 +20,10 @@ import {
type AiVaultServiceRequest,
type AiVaultServiceResultValue
} from './session-scanner-service-protocol'
import { readAiVaultSessionTitlesFromFiles } from '../session-titles/session-title-file-reader'
import {
readAiVaultSessionTitlesFromFiles,
type CachedAiVaultSessionTitle
} from '../session-titles/session-title-file-reader'
import { resolveHostReadableAiVaultTitleRequests } from '../session-titles/session-title-request-paths'
import { listLocalAiVaultSubagentSessions } from '../session-subagent-reader'
import {
@@ -36,7 +38,7 @@ if (!process.send) {
const controllers = new Map<number, AbortController>()
const cancelled = new Set<number>()
const pending = new Set<number>()
const titleIndex = new Map<string, AiVaultSessionTitle>()
const titleIndex = new Map<string, CachedAiVaultSessionTitle>()
const invalidatedPaths = new Set<string>()
const sessionSearch = new SessionScannerServiceSearch(requestSessionSearchRoots)
let initialized = false
@@ -117,7 +119,10 @@ async function executeRequest(request: AiVaultServiceRequest): Promise<AiVaultSe
cacheServiceTitle(titleIndex, {
agent: session.agent,
sessionId: session.sessionId,
title: session.title.trim()
title: session.title.trim(),
...(session.titleKind
? { kind: session.titleKind }
: { transcriptPath: session.filePath })
})
}
}
@@ -2,8 +2,8 @@ import type {
AiVaultListResult,
AiVaultSubagentListResult
} from '../../../shared/ai-vault/contracts/ai-vault-types'
import type { CachedAiVaultSessionTitle } from '../session-titles/session-title-file-reader'
import type {
AiVaultSessionTitle,
AiVaultSessionTitleRequest,
AiVaultSessionTitlesResult
} from '../../../shared/ai-vault/contracts/ai-vault-session-title'
@@ -184,8 +184,8 @@ export function isAiVaultServiceChildMessage(value: unknown): value is AiVaultSe
}
export function cacheServiceTitle(
titleIndex: Map<string, AiVaultSessionTitle>,
title: AiVaultSessionTitle,
titleIndex: Map<string, CachedAiVaultSessionTitle>,
title: CachedAiVaultSessionTitle,
maxEntries = 4_096
): void {
const key = `${title.agent}\0${title.sessionId}`
@@ -13,7 +13,7 @@ afterEach(async () => {
})
describe('scanAiVaultSessions Claude title selection', () => {
it('prefers the latest generated ai-title over the first user prompt, but a custom-title wins over both', async () => {
it('prefers the latest generated ai-title over the first user prompt, but a custom-title wins over both, and marks which is a name', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-ai-title-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
@@ -62,12 +62,28 @@ describe('scanAiVaultSessions Claude title selection', () => {
}
])
await writeJsonlFile(join(projectDir, 'prompt.jsonl'), [
{
type: 'user',
sessionId: 'prompt',
timestamp: '2026-05-01T12:00:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Unnamed session prompt' }
}
])
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.title).sort()).toEqual([
'Updated karma discussion title',
'User set title'
// Renames and generated names are the session's own; a prompt only stands in until one exists.
expect(
result.sessions
.map((session) => [session.title, session.titleKind])
.sort(([a], [b]) => String(a).localeCompare(String(b)))
).toEqual([
['Unnamed session prompt', 'fallback'],
['Updated karma discussion title', 'named'],
['User set title', 'named']
])
})
})
@@ -176,7 +176,9 @@ export async function finalizeClaudeSessionParseState(
snapshot.accumulator.filePath
)
}
return finalizeSession(snapshot.accumulator, platform, options)
// A rename (custom-title) or Claude's generated name is the session's own; prompts and metadata are stand-ins.
const titleKind = snapshot.accumulator.title || snapshot.generatedTitle ? 'named' : 'fallback'
return finalizeSession(snapshot.accumulator, platform, { ...options, titleKind })
}
export function createClaudeSessionResumeState(
@@ -8,7 +8,9 @@ export async function refreshCodexTitleFromIndex(
readIndexedTitle: (sessionId: string) => Promise<string | null>
): Promise<AiVaultSession> {
const title = await readIndexedTitle(session.sessionId)
return title && title !== session.title ? { ...session, title } : session
return title && (title !== session.title || session.titleKind !== 'named')
? { ...session, title, titleKind: 'named' }
: session
}
export function refreshCachedCodexTitle(
@@ -222,6 +222,8 @@ describe('parseCodexSessionFile', () => {
)
expect(session?.lastUserPrompt).toBe('Review the PR and fix real regressions')
// The first prompt only stands in for a name; an index thread name would replace it as 'named'.
expect(session?.titleKind).toBe('fallback')
})
it('does not double-count usage when token count formats switch', async () => {
@@ -1,6 +1,9 @@
import { openTranscriptReadStream } from '../../../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../../../shared/ai-vault/contracts/ai-vault-types'
import type {
AiVaultSession,
AiVaultSessionTitleKind
} from '../../../../shared/ai-vault/contracts/ai-vault-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import {
@@ -218,13 +221,17 @@ async function finalizeCodexParseState(
// Finalize a snapshot: the live state keeps accumulating appended lines.
const snapshot = cloneCodexParseState(state)
// Recheck the signature-cached index: a title can arrive after the transcript stops growing.
let titleKind: AiVaultSessionTitleKind =
snapshot.titleSource === 'meta' && snapshot.accumulator.title ? 'named' : 'fallback'
if (snapshot.sawSessionMeta) {
const indexedTitle = await args.titleReader?.(snapshot.accumulator.sessionId)
if (indexedTitle) {
snapshot.accumulator.title = indexedTitle
titleKind = 'named'
}
}
return finalizeSession(snapshot.accumulator, platform, {
titleKind,
codexHome: args.codexHome,
executionHostId: args.executionHostId,
executionHostPlatform: args.executionHostPlatform
@@ -55,6 +55,7 @@ const aiVaultSessionSchema = z.object({
agent: z.string().min(1),
sessionId: z.string(),
title: z.string(),
titleKind: z.enum(['named', 'fallback']).optional().catch(undefined),
cwd: z.string().nullable(),
// Malformed aliases must not discard the entire session.
cwdAliases: z
@@ -2,7 +2,8 @@ import { basename, extname } from 'node:path'
import {
aiVaultAgentLabel,
type AiVaultAgent,
type AiVaultSession
type AiVaultSession,
type AiVaultSessionTitleKind
} from '../../shared/ai-vault/contracts/ai-vault-types'
import { buildAiVaultResumeCommand } from '../../shared/ai-vault/resume/ai-vault-resume-command'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
@@ -97,6 +98,7 @@ export function finalizeSession(
codexHome?: string | null
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
titleKind?: AiVaultSessionTitleKind
} = {}
): AiVaultSession | null {
const sessionId = accumulator.sessionId.trim()
@@ -119,6 +121,7 @@ export function finalizeSession(
agent: accumulator.agent,
sessionId,
title,
...(options.titleKind ? { titleKind: options.titleKind } : {}),
cwd: accumulator.cwd,
branch: accumulator.branch,
model: accumulator.model,
@@ -81,6 +81,8 @@ describe('AI Vault session titles with a stalled WSL transcript lstat', () => {
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
const { titles } = await pending
expect(titles).toEqual([{ agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world' }])
expect(titles).toEqual([
{ agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world', kind: 'fallback' }
])
})
})
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CachedAiVaultSessionTitle } from './session-title-file-reader'
import * as transcriptFs from '../../native-chat/wsl-transcript-fs-access'
const parseAgentSessionFileCached = vi.hoisted(() => vi.fn())
@@ -51,7 +52,8 @@ describe('readAiVaultSessionTitlesFromFiles', () => {
parseAgentSessionFileCached.mockResolvedValue({
agent: 'codex',
sessionId: 'session-1',
title: ' Exact title '
title: ' Exact title ',
titleKind: 'fallback'
})
const controller = new AbortController()
@@ -61,7 +63,7 @@ describe('readAiVaultSessionTitlesFromFiles', () => {
{ signal: controller.signal }
)
).resolves.toEqual({
titles: [{ agent: 'codex', sessionId: 'session-1', title: 'Exact title' }]
titles: [{ agent: 'codex', sessionId: 'session-1', title: 'Exact title', kind: 'fallback' }]
})
expect(parseAgentSessionFileCached.mock.calls[0]?.[4]).toBe(controller.signal)
expect(parseAgentSessionFileCached).toHaveBeenCalledTimes(1)
@@ -71,6 +73,88 @@ describe('readAiVaultSessionTitlesFromFiles', () => {
})
})
it('rereads a session cached before titles carried a kind once, so a renamed session counts as named', async () => {
const path = await transcriptPath()
const cached = { agent: 'claude', sessionId: 'session-1', title: 'Renamed' }
parseAgentSessionFileCached
.mockResolvedValueOnce(cached)
.mockResolvedValueOnce({ ...cached, titleKind: 'named' })
await expect(
readAiVaultSessionTitlesFromFiles([
{ agent: 'claude', sessionId: 'session-1', transcriptPath: path }
])
).resolves.toEqual({
titles: [{ agent: 'claude', sessionId: 'session-1', title: 'Renamed', kind: 'named' }]
})
expect(parseAgentSessionFileCached).toHaveBeenCalledTimes(2)
expect(parseAgentSessionFileCached.mock.calls[1]?.[3]).toBe('whole')
// A failed reread keeps the cached title, unclassified.
parseAgentSessionFileCached.mockResolvedValueOnce(cached).mockResolvedValueOnce(null)
await expect(
readAiVaultSessionTitlesFromFiles([
{ agent: 'claude', sessionId: 'session-1', transcriptPath: path }
])
).resolves.toEqual({
titles: [{ agent: 'claude', sessionId: 'session-1', title: 'Renamed' }]
})
})
it('classifies a History-cached title from an old parse row when the request has only a session id', async () => {
const path = await transcriptPath()
parseAgentSessionFileCached.mockResolvedValue({
agent: 'claude',
sessionId: 'session-1',
title: 'Login cleanup',
titleKind: 'named'
})
const cached = new Map<string, CachedAiVaultSessionTitle>([
[
'session-1',
{ agent: 'claude', sessionId: 'session-1', title: 'Login cleanup', transcriptPath: path }
]
])
const cache = {
get: (request: { sessionId: string }) => cached.get(request.sessionId) ?? null,
set: (title: CachedAiVaultSessionTitle) => cached.set(title.sessionId, title)
}
const read = () =>
readAiVaultSessionTitlesFromFiles([{ agent: 'claude', sessionId: 'session-1' }], { cache })
const named = { agent: 'claude', sessionId: 'session-1', title: 'Login cleanup', kind: 'named' }
await expect(read()).resolves.toEqual({ titles: [named] })
// The classified title replaces the cache entry, so the transcript is read only once.
await expect(read()).resolves.toEqual({ titles: [named] })
expect(parseAgentSessionFileCached).toHaveBeenCalledTimes(1)
// When the transcript cannot be read, the cached title still comes back, unclassified.
cached.set('session-1', {
agent: 'claude',
sessionId: 'session-1',
title: 'Login cleanup',
transcriptPath: `${path}.missing`
})
await expect(read()).resolves.toEqual({
titles: [{ agent: 'claude', sessionId: 'session-1', title: 'Login cleanup' }]
})
// A reread that fails after the first parse keeps the transcript in the cache, so a later request classifies it.
const unclassified = {
agent: 'claude' as const,
sessionId: 'session-1',
title: 'Login cleanup'
}
cached.set('session-1', { ...unclassified, transcriptPath: path })
parseAgentSessionFileCached
.mockResolvedValueOnce(unclassified)
.mockRejectedValueOnce(new Error('transient'))
await expect(read()).resolves.toEqual({ titles: [unclassified] })
expect(cached.get('session-1')?.transcriptPath).toBe(path)
parseAgentSessionFileCached.mockResolvedValueOnce({ ...unclassified, titleKind: 'named' })
await expect(read()).resolves.toEqual({ titles: [{ ...unclassified, kind: 'named' }] })
})
it('rejects a transcript whose parsed identity does not match the request', async () => {
const path = await transcriptPath()
parseAgentSessionFileCached.mockResolvedValue({
@@ -106,7 +190,8 @@ describe('readAiVaultSessionTitlesFromFiles', () => {
Array.from({ length: 9 }, () => ({
agent: 'codex',
sessionId: 'named',
title: 'Indexed name'
title: 'Indexed name',
kind: 'named'
}))
)
expect(
@@ -120,7 +205,8 @@ describe('readAiVaultSessionTitlesFromFiles', () => {
parseAgentSessionFileCached.mockResolvedValue({
agent: 'codex',
sessionId: 'session-0',
title: 'Title'
title: 'Title',
titleKind: 'fallback'
})
await readAiVaultSessionTitlesFromFiles(
@@ -15,9 +15,12 @@ import {
const TITLE_PARSE_CONCURRENCY = 4
/** A title the scanner already saw, with its transcript when the title's kind is not known yet. */
export type CachedAiVaultSessionTitle = AiVaultSessionTitle & { transcriptPath?: string }
export type AiVaultSessionTitleCache = {
get(request: AiVaultSessionTitleRequest): AiVaultSessionTitle | null
set(title: AiVaultSessionTitle): void
get(request: AiVaultSessionTitleRequest): CachedAiVaultSessionTitle | null
set(title: CachedAiVaultSessionTitle): void
}
async function readOneTitle(
@@ -30,7 +33,17 @@ async function readOneTitle(
}
const transcriptPath = request.transcriptPath?.trim()
if (!transcriptPath) {
return cache?.get(request) ?? null
const cached = cache?.get(request)
if (!cached) {
return null
}
const { transcriptPath: cachedPath, ...title } = cached
// Why: History caches titles from parse rows saved before titles carried a kind; reading that
// transcript here classifies it once (the result replaces the cached entry, so this cannot repeat).
// A failed reread (a stalled WSL distro) still returns the cached title, unclassified.
return !title.kind && cachedPath
? ((await readOneTitle({ ...request, transcriptPath: cachedPath }, signal, cache)) ?? title)
: title
}
try {
const home = request.agent === 'codex' ? codexHomeFromSessionFilePath(transcriptPath) : null
@@ -40,7 +53,12 @@ async function readOneTitle(
return null
}
if (title) {
const resolved = { agent: request.agent, sessionId: request.sessionId, title }
const resolved: AiVaultSessionTitle = {
agent: request.agent,
sessionId: request.sessionId,
title,
kind: 'named'
}
cache?.set(resolved)
return resolved
}
@@ -50,22 +68,41 @@ async function readOneTitle(
return null
}
// Raw lstat matches scanner keys only for providers without content dependencies: Claude and Codex.
const session = await parseAgentSessionFileCached(
{
agent: request.agent,
file: {
path: transcriptPath,
mtimeMs: stats.mtimeMs,
modifiedAt: stats.mtime.toISOString(),
sizeBytes: stats.size
},
codexHome: null
const candidate = {
agent: request.agent,
file: {
path: transcriptPath,
mtimeMs: stats.mtimeMs,
modifiedAt: stats.mtime.toISOString(),
sizeBytes: stats.size
},
codexHome: null
}
let session = await parseAgentSessionFileCached(
candidate,
process.platform,
undefined,
undefined,
signal
)
// Why: entries cached before titles carried their kind would rank every name as a stand-in; reread
// such a transcript once so the cache learns the kind, instead of invalidating every cached session.
if (
session?.agent === request.agent &&
session.sessionId === request.sessionId &&
!session.titleKind &&
!signal?.aborted
) {
// A failed reread keeps the first result, unclassified.
session =
(await parseAgentSessionFileCached(
candidate,
process.platform,
undefined,
'whole',
signal
).catch(() => null)) ?? session
}
if (
signal?.aborted ||
session?.agent !== request.agent ||
@@ -74,12 +111,14 @@ async function readOneTitle(
) {
return null
}
const title = {
const title: AiVaultSessionTitle = {
agent: request.agent,
sessionId: request.sessionId,
title: session.title.trim()
title: session.title.trim(),
...(session.titleKind ? { kind: session.titleKind } : {})
}
cache?.set(title)
// An unclassified title keeps its transcript so a later session-id-only request can still classify it.
cache?.set(title.kind ? title : { ...title, transcriptPath })
return title
} catch {
return null
@@ -34,6 +34,8 @@ function parseTitle(value: unknown): AiVaultSessionTitle {
return {
agent: record.agent,
sessionId: record.sessionId,
title: record.title.trim()
title: record.title.trim(),
// Unknown kinds from a newer host stay unclassified rather than failing the whole result.
...(record.kind === 'named' || record.kind === 'fallback' ? { kind: record.kind } : {})
}
}
@@ -65,7 +65,12 @@ export function terminalNameInputs(tab: MirroredTerminalTab): MirroredTerminalTa
generatedTitle: tab.generatedTitle,
defaultTitle: tab.defaultTitle,
aiVaultTitle: provider
? { agent: provider.agent, sessionId: provider.sessionId, title: provider.title }
? {
agent: provider.agent,
sessionId: provider.sessionId,
title: provider.title,
...(provider.kind ? { kind: provider.kind } : {})
}
: provider
}
}
@@ -10,7 +10,7 @@ const tab: RuntimeMobileSessionTerminalTab = {
leafId: 'leaf-1',
title: 'Codex 公司 High',
quickCommandLabel: 'Codex 公司 High',
aiVaultTitle: { agent: 'codex', sessionId: 'session-1', title: 'Login cleanup' },
aiVaultTitle: { agent: 'codex', sessionId: 'session-1', title: 'Login cleanup', kind: 'named' },
resolvesSessionTitle: true,
isActive: true
}
@@ -20,8 +20,20 @@ describe('paired desktop tab names', () => {
const projected = projectRuntimeMobileSessionTitle({ worktree: 'wt-1' }, tab, {
isSessionTitlePublisher: () => true
})
expect(
resolveTerminalTabTitle({ title: tab.title, customTitle: null, ...projected }, true)
).toBe('Codex 公司 High')
const resolve = (overrides: Partial<Parameters<typeof resolveTerminalTabTitle>[0]>) =>
resolveTerminalTabTitle(
{ title: tab.title, customTitle: null, ...projected, ...overrides },
true
)
// The session's own name replaces the quick-command name; a name typed on the tab beats both.
expect(resolve({})).toBe('Login cleanup')
expect(resolve({ aiVaultTitle: undefined })).toBe('Codex 公司 High')
expect(resolve({ customTitle: 'My tab' })).toBe('My tab')
// A first-prompt stand-in, or a title from a host that does not say its kind, keeps the quick-command name.
const prompt = { agent: 'codex' as const, sessionId: 'session-1', title: 'fix the login page' }
expect(resolve({ aiVaultTitle: { ...prompt, kind: 'fallback' } })).toBe('Codex 公司 High')
expect(resolve({ aiVaultTitle: prompt })).toBe('Codex 公司 High')
// Without a quick-command name, a stand-in still names the tab as before.
expect(resolve({ aiVaultTitle: prompt, quickCommandLabel: null })).toBe('fix the login page')
})
})
@@ -1,6 +1,8 @@
import type { AgentProviderSessionMetadata } from '../../../shared/agent-session/lifecycle/agent-session-resume'
import { isAiVaultTitleAgent } from '../../../shared/ai-vault/contracts/ai-vault-session-title'
import type { TerminalTab } from '../../../shared/workspace-layout/terminal-tab-types'
import {
isAiVaultTitleAgent,
sameAiVaultSessionTitle
} from '../../../shared/ai-vault/contracts/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'
@@ -101,17 +103,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)
@@ -131,7 +122,7 @@ function terminalTabsEqual(current: AppState, previous: AppState): boolean {
left.id !== right.id ||
left.worktreeId !== right.worktreeId ||
left.sessionTitleFromHost !== right.sessionTitleFromHost ||
!titleEqual(left.aiVaultTitle, right.aiVaultTitle)
!sameAiVaultSessionTitle(left.aiVaultTitle, right.aiVaultTitle)
) {
return false
}
@@ -30,6 +30,38 @@ describe('AI Vault tab title sync', () => {
stop()
})
it('asks once for a sleeping title saved before titles carried a kind', async () => {
const store = makeState({
executionHostId: 'ssh:dev-box',
worktreeId: 'worktree-1',
path: '/workspace/albacore',
sleeping: true,
aiVaultTitle: { agent: 'codex', sessionId: 'codex-session', title: 'Login cleanup' }
})
const resolveSessionTitles = vi.fn(async () => ({
titles: [
{
agent: 'codex' as const,
sessionId: 'codex-session',
title: 'Login cleanup',
kind: 'named' as const
}
]
}))
const stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles })
await vi.waitFor(() =>
expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle?.kind).toBe('named')
)
const tab = store.getState().tabsByWorktree['worktree-1'][0] as TerminalTab
expect(resolveTerminalTabTitle({ ...tab, quickCommandLabel: 'Codex High' }, false)).toBe(
'Login cleanup'
)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(resolveSessionTitles).toHaveBeenCalledTimes(1)
stop()
})
it('drops a client result that returns after the paired host started naming the tab', async () => {
const store = makeState({
executionHostId: 'runtime:server-1',
+23 -12
View File
@@ -1,4 +1,5 @@
import type {
AiVaultSessionTitle,
AiVaultSessionTitlesArgs,
AiVaultSessionTitlesResult
} from '../../../shared/ai-vault/contracts/ai-vault-session-title'
@@ -58,15 +59,23 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
const lastSettledAttemptAt = new Map<string, number>()
const dirty = new Set<string>()
const writeTitle = (request: AiVaultTitleRequest, title: string | null): void => {
const writeTitle = (
request: AiVaultTitleRequest,
title: Pick<AiVaultSessionTitle, 'title' | 'kind'> | null
): void => {
writing = true
try {
dependencies
.getState()
.setAiVaultTabTitle(
request.tabId,
title ? { agent: request.agent, sessionId: request.providerSession.id, title } : null
)
dependencies.getState().setAiVaultTabTitle(
request.tabId,
title
? {
agent: request.agent,
sessionId: request.providerSession.id,
title: title.title,
...(title.kind ? { kind: title.kind } : {})
}
: null
)
} finally {
writing = false
}
@@ -87,13 +96,13 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
if (stopped) {
return
}
const titleByIdentity = new Map<string, string>()
const titleByIdentity = new Map<string, Pick<AiVaultSessionTitle, 'title' | 'kind'>>()
for (const title of result.titles) {
if (title.title.trim()) {
titleByIdentity.set(
`${first.executionHostId}\0${title.agent}\0${title.sessionId}`,
title.title.trim()
)
titleByIdentity.set(`${first.executionHostId}\0${title.agent}\0${title.sessionId}`, {
title: title.title.trim(),
kind: title.kind
})
}
}
const currentByTabId = new Map(
@@ -156,6 +165,8 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
!identityMatches ||
!stored?.title.trim() ||
(request.refresh && last === undefined) ||
// Titles saved before they carried a kind are asked for once, so a sleeping session's name can outrank its quick-command label.
(!stored?.kind && last === undefined) ||
(request.refresh &&
(dirty.has(aiVaultTitleRequestKey(request)) ||
now - last! >=
@@ -2,6 +2,7 @@ import type { Tab, TabGroup } from '../../../../shared/tab-types'
import type { OpenFile } from '../../store/slices/editor'
import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state'
import { sameStringArray } from './state-equality-core'
import { sameAiVaultSessionTitle } from '../../../../shared/ai-vault/contracts/ai-vault-session-title'
export function openFileEqual(a: OpenFile, b: OpenFile): boolean {
return (
@@ -85,9 +86,7 @@ export function tabEqual(a: Tab, b: Tab): boolean {
a.label === b.label &&
// Include generated label in equality or unified titles diverge from terminal tabs.
a.generatedLabel === b.generatedLabel &&
a.aiVaultTitle?.agent === b.aiVaultTitle?.agent &&
a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId &&
a.aiVaultTitle?.title === b.aiVaultTitle?.title &&
sameAiVaultSessionTitle(a.aiVaultTitle, b.aiVaultTitle) &&
a.customLabel === b.customLabel &&
a.color === b.color &&
a.sortOrder === b.sortOrder &&
@@ -10,6 +10,7 @@ import {
sameRuntimeBrowserPlacement
} from '../../../../shared/runtime-browser-placement'
import { sameStringArray } from './state-equality-core'
import { sameAiVaultSessionTitle } from '../../../../shared/ai-vault/contracts/ai-vault-session-title'
export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean {
return (
@@ -21,9 +22,7 @@ export 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 &&
sameAiVaultSessionTitle(a.aiVaultTitle, b.aiVaultTitle) &&
a.sessionTitleFromHost === b.sessionTitleFromHost &&
a.customTitle === b.customTitle &&
a.color === b.color &&
@@ -11,6 +11,7 @@ import {
} from '../slices/terminal-tab-owner-index'
import { getTabIdFromPaneKey } from './terminal-pty-identities'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { sameAiVaultSessionTitle } from '../../../../shared/ai-vault/contracts/ai-vault-session-title'
export function createTerminalTabPresentationActions(
set: TerminalStoreSet,
@@ -56,11 +57,7 @@ export function createTerminalTabPresentationActions(
}
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
if (!current || sameTitle) {
if (!current || sameAiVaultSessionTitle(current.aiVaultTitle, aiVaultTitle)) {
return s
}
const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab))
@@ -122,7 +122,7 @@ export function getAgentRowConversationName(
aiVaultTitle &&
aiVaultTitle.agent === agentType &&
aiVaultTitle.sessionId === providerSessionId
? aiVaultTitle.title
? aiVaultTitle
: null,
generatedTitle: tab.generatedTitle
},
@@ -1,4 +1,4 @@
import type { AiVaultAgent } from './ai-vault-types'
import type { AiVaultAgent, AiVaultSessionTitleKind } from './ai-vault-types'
import type { ExecutionHostId } from '../../execution-host'
export const AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT = 64
@@ -7,6 +7,8 @@ export type AiVaultSessionTitle = {
agent: Extract<AiVaultAgent, 'claude' | 'codex'>
sessionId: string
title: string
/** Absent from older hosts and saved tabs: unclassified, ranked like a fallback. */
kind?: AiVaultSessionTitleKind
}
export type AiVaultSessionTitleRequest = {
@@ -29,3 +31,16 @@ export function isAiVaultTitleAgent(
): agent is AiVaultSessionTitle['agent'] {
return agent === 'claude' || agent === 'codex'
}
/** Kind is compared too, so a title whose text is unchanged can still become a known name. */
export function sameAiVaultSessionTitle(
a: AiVaultSessionTitle | null | undefined,
b: AiVaultSessionTitle | null | undefined
): boolean {
return (
a?.agent === b?.agent &&
a?.sessionId === b?.sessionId &&
a?.title === b?.title &&
a?.kind === b?.kind
)
}
@@ -107,6 +107,9 @@ export type AiVaultSessionSubagentInfo = {
status: AiVaultSubagentRunStatus | null
}
/** Where a title came from: the session's own name (a rename or provider-generated), or a stand-in such as its first prompt. */
export type AiVaultSessionTitleKind = 'named' | 'fallback'
export type AiVaultSession = {
/** Recorded account home; null proves System default, absent means unknown. */
accountHome?: AgentSessionAccountHome | null
@@ -117,6 +120,8 @@ export type AiVaultSession = {
agent: AiVaultAgent
sessionId: string
title: string
/** Claude and Codex only: whether `title` is the session's own name or a stand-in; absent means unclassified. */
titleKind?: AiVaultSessionTitleKind
cwd: string | null
/** Host-proven spellings of `cwd` for matching scopes and resume targets; launch paths remain separate. */
cwdAliases?: string[]
@@ -135,7 +135,8 @@ const MovePaneToNewTabCommand = z.object({
const AiVaultTitle = z.object({
agent: z.enum(['claude', 'codex']),
sessionId: z.string(),
title: z.string()
title: z.string(),
kind: z.enum(['named', 'fallback']).optional().catch(undefined)
})
/** A tab's rename: a title as the window stores it today; null clears it. */
+12 -6
View File
@@ -1,3 +1,4 @@
import type { AiVaultSessionTitle } from './ai-vault/contracts/ai-vault-session-title'
import type { Tab } from './tab-types'
import type { TerminalTab } from './workspace-layout/terminal-tab-types'
import { isMeaningfulOpenCodeTerminalTitle } from './agent-status/title/providers/opencode-terminal-title'
@@ -8,8 +9,8 @@ export type TabNameSources = {
quickCommandLabel?: string | null
/** The program's own terminal title right now. */
liveTitle: string
/** The agent session's own name, already checked to belong to this tab's session. */
sessionTitle?: string | null
/** The agent session's title, already checked to belong to this tab's session. */
sessionTitle?: Pick<AiVaultSessionTitle, 'title' | 'kind'> | null
generatedTitle?: string | null
}
@@ -18,11 +19,16 @@ export function resolveTabNameSource(
sources: TabNameSources,
generatedTitlesEnabled: boolean
): string {
const sessionTitle = sources.sessionTitle?.title.trim() ?? ''
// Why: a quick-command label only names the tab until the session has a name of its own (a rename or
// a provider-generated name); a first-prompt stand-in does not replace it. A typed tab name wins over both.
const sessionNamed = sources.sessionTitle?.kind === 'named'
return (
sources.customTitle?.trim() ||
sources.quickCommandLabel?.trim() ||
(isMeaningfulOpenCodeTerminalTitle(sources.liveTitle) ? sources.liveTitle : '') ||
sources.sessionTitle?.trim() ||
(sessionNamed ? sessionTitle : '') ||
sources.quickCommandLabel?.trim() ||
sessionTitle ||
(generatedTitlesEnabled ? sources.generatedTitle?.trim() : '') ||
''
)
@@ -42,7 +48,7 @@ export function resolveTerminalTabTitle(
customTitle: tab.customTitle,
quickCommandLabel: tab.quickCommandLabel,
liveTitle,
sessionTitle: tab.aiVaultTitle?.title,
sessionTitle: tab.aiVaultTitle,
generatedTitle: tab.generatedTitle
},
generatedTitlesEnabled
@@ -66,7 +72,7 @@ export function resolveUnifiedTabLabel(
customTitle: tab?.customLabel,
quickCommandLabel: tab?.quickCommandLabel,
liveTitle: liveLabel,
sessionTitle: tab?.aiVaultTitle?.title,
sessionTitle: tab?.aiVaultTitle,
generatedTitle: tab?.generatedLabel
},
generatedTitlesEnabled
@@ -177,7 +177,8 @@ describe('parseWorkspaceSession', () => {
aiVaultTitle: {
agent: 'codex',
sessionId: 'session-1',
title: 'Provider thread name'
title: 'Provider thread name',
kind: 'named'
},
customTitle: null,
color: null,
@@ -201,7 +202,8 @@ describe('parseWorkspaceSession', () => {
aiVaultTitle: {
agent: 'codex',
sessionId: 'session-1',
title: 'Provider thread name'
title: 'Provider thread name',
kind: 'unknown-future-kind'
},
customLabel: null,
color: null,
@@ -216,8 +218,12 @@ describe('parseWorkspaceSession', () => {
if (result.ok) {
expect(result.value.tabsByWorktree.wt[0].generatedTitle).toBe('Refactor auth')
expect(result.value.tabsByWorktree.wt[0].aiVaultTitle?.title).toBe('Provider thread name')
// The kind survives a restore, so a session's name still outranks a quick-command label.
expect(result.value.tabsByWorktree.wt[0].aiVaultTitle?.kind).toBe('named')
expect(result.value.unifiedTabs?.wt[0].generatedLabel).toBe('Refactor auth')
expect(result.value.unifiedTabs?.wt[0].aiVaultTitle?.title).toBe('Provider thread name')
// An unknown kind from a newer build keeps the title, unclassified.
expect(result.value.unifiedTabs?.wt[0].aiVaultTitle?.kind).toBeUndefined()
expect(result.value.unifiedTabs?.wt[0].executionHostId).toBe('runtime:host-b')
}
})
@@ -47,7 +47,8 @@ export const terminalTabSchema = z.object({
.object({
agent: z.enum(['claude', 'codex']),
sessionId: z.string(),
title: z.string()
title: z.string(),
kind: z.enum(['named', 'fallback']).optional().catch(undefined)
})
.nullable()
.optional()
@@ -92,7 +93,8 @@ const tabSchema = z.object({
.object({
agent: z.enum(['claude', 'codex']),
sessionId: z.string(),
title: z.string()
title: z.string(),
kind: z.enum(['named', 'fallback']).optional().catch(undefined)
})
.nullable()
.optional()