mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user