diff --git a/src/domain/threads/rename-lifecycle.ts b/src/domain/threads/rename-lifecycle.ts index 6fa325f0..03bb6bdb 100644 --- a/src/domain/threads/rename-lifecycle.ts +++ b/src/domain/threads/rename-lifecycle.ts @@ -1,7 +1,11 @@ +import type { ThreadTitleContext } from "./title-generation-model"; + +type ThreadRenameAutoNameState = { kind: "checking" } | { kind: "unavailable" } | { kind: "ready"; context: ThreadTitleContext }; + export type ThreadRenameLifecycleState = | { kind: "idle" } - | { kind: "editing"; draft: string } - | { kind: "generating"; draft: string; generationToken: number }; + | { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState } + | { kind: "generating"; draft: string; autoName: Extract; generationToken: number }; export type ThreadRenameActiveState = Exclude; type ThreadRenameGeneratingState = Extract; @@ -9,6 +13,7 @@ type ThreadRenameGeneratingState = Extract): ThreadRenameActiveState { switch (state.kind) { case "editing": - return { kind: "editing", draft: state.draft }; + return { kind: "editing", draft: state.draft, autoName: state.autoName }; case "generating": return { kind: "generating", draft: state.draft, + autoName: state.autoName, generationToken: state.generationToken, }; } diff --git a/src/features/chat/application/threads/rename-editor-actions.ts b/src/features/chat/application/threads/rename-editor-actions.ts index 33366edf..a84ae841 100644 --- a/src/features/chat/application/threads/rename-editor-actions.ts +++ b/src/features/chat/application/threads/rename-editor-actions.ts @@ -16,7 +16,8 @@ export interface ThreadRenameEditorActionsHost { ensureConnected: () => Promise; addSystemMessage: (text: string) => void; renameThread(threadId: string, value: string): Promise; - generateThreadTitle(threadId: string, signal?: AbortSignal): Promise; + resolveThreadTitleContext(threadId: string): Promise; + generateThreadTitle(context: ThreadTitleContext, signal?: AbortSignal): Promise; } export interface ThreadRenameEditorActions { @@ -34,11 +35,13 @@ export interface ThreadRenameEditorActions { export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions { let nextRenameGenerationToken = 1; let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null; + let activeContextPreparation: { threadId: string } | null = null; const action = { invalidate(): void { activeGeneration?.controller.abort(); activeGeneration = null; + activeContextPreparation = null; dispatch(host, { type: "ui/rename-cleared" }); }, @@ -60,6 +63,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH if (!thread) return; abortActiveGeneration(); dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) }); + void prepareAutoName(threadId); }, updateDraft(threadId: string, value: string): void { @@ -68,6 +72,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH cancel(threadId: string): void { abortGeneration(threadId); + if (activeContextPreparation?.threadId === threadId) activeContextPreparation = null; dispatch(host, { type: "ui/rename-cancelled", threadId }); }, @@ -85,18 +90,18 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH try { await host.ensureConnected(); - if (renameState(host) !== editingState) return; + if (!renameEditStillCurrent(host, threadId, editingState.draft)) return; const result = await host.renameThread(threadId, value); if (!result) { - if (renameState(host) === editingState) action.cancel(threadId); + if (renameEditStillCurrent(host, threadId, editingState.draft)) action.cancel(threadId); return; } - if (renameState(host) === editingState) { + if (renameEditStillCurrent(host, threadId, editingState.draft)) { dispatch(host, { type: "ui/rename-cleared" }); } } catch (error) { - if (renameState(host) !== editingState) return; + if (!renameEditStillCurrent(host, threadId, editingState.draft)) return; host.addSystemMessage(error instanceof Error ? error.message : String(error)); } }, @@ -105,24 +110,21 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH const current = renameState(host); if (current.kind !== "editing" || current.threadId !== threadId) return; - const editingState = current; - - await host.ensureConnected(); - if (renameState(host) !== editingState) return; - dispatch(host, { type: "ui/rename-generation-started", threadId, generationToken: nextRenameGenerationToken, }); const generationToken = nextRenameGenerationToken; - if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return; + const generating = renameState(host); + if (!renameGenerationStillActive(generating, threadId, generationToken)) return; nextRenameGenerationToken += 1; const controller = new AbortController(); activeGeneration = { threadId, generationToken, controller }; try { - const title = await host.generateThreadTitle(threadId, controller.signal); + const title = await host.generateThreadTitle(generating.autoName.context, controller.signal); + if (!title) throw new Error("Codex did not return a usable thread title."); dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title }); } catch (error) { if (renameGenerationStillActive(renameState(host), threadId, generationToken)) { @@ -151,6 +153,24 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH function clearGeneration(generationToken: number): void { if (activeGeneration?.generationToken === generationToken) activeGeneration = null; } + + async function prepareAutoName(threadId: string): Promise { + const preparation = { threadId }; + activeContextPreparation = preparation; + let context: ThreadTitleContext | null = null; + try { + await host.ensureConnected(); + if (activeContextPreparation !== preparation) return; + context = await host.resolveThreadTitleContext(threadId); + } catch { + // Auto-name availability is reflected by the disabled action. + } + if (activeContextPreparation !== preparation) return; + activeContextPreparation = null; + const current = renameState(host); + if (current.kind !== "editing" || current.threadId !== threadId) return; + dispatch(host, { type: "ui/rename-auto-name-context-resolved", threadId, context }); + } } export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null { @@ -161,6 +181,11 @@ function renameState(host: ThreadRenameEditorActionsHost): ChatRenameUiState { return host.stateStore.getState().ui.rename; } +function renameEditStillCurrent(host: ThreadRenameEditorActionsHost, threadId: string, draft: string): boolean { + const current = renameState(host); + return current.kind === "editing" && current.threadId === threadId && current.draft === draft; +} + function dispatch(host: ThreadRenameEditorActionsHost, action: ChatAction): void { host.stateStore.dispatch(action); } diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 288a64c6..a06e80bc 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -227,7 +227,8 @@ export function createThreadLifecycleBundle( ensureConnected, addSystemMessage: status.addSystemMessage, renameThread: (threadId, value) => foundation.threadOperations.renameThread(threadId, value), - generateThreadTitle: (threadId, signal) => foundation.titleService.generateTitle(threadId, signal), + resolveThreadTitleContext: (threadId) => foundation.titleService.resolveContext(threadId), + generateThreadTitle: (context, signal) => foundation.titleService.generate(context, signal), }); const { identity, restoration, resume } = lifecycle; diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index 69955108..77bb5e03 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -211,7 +211,9 @@ function toolbarThreadRows(input: { archiveDisabled: input.turnBusy, canArchive: true, archiveConfirm: core.archiveConfirm, - rename: core.rename.active ? { draft: core.rename.draft, generating: core.rename.generating } : null, + rename: core.rename.active + ? { draft: core.rename.draft, generating: core.rename.generating, autoNameDisabled: core.rename.autoNameDisabled } + : null, }; }); } diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index c32343a2..590455d4 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -20,6 +20,7 @@ export interface ToolbarThreadRow { rename: { draft: string; generating: boolean; + autoNameDisabled: boolean; } | null; } @@ -529,6 +530,7 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action icon={generating ? "x" : "sparkles"} label={generating ? "Cancel auto-name" : "Auto-name thread"} className="codex-panel__thread-action" + disabled={!generating && (thread.rename?.autoNameDisabled ?? true)} onPointerDown={(event) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index cc2c650d..6e2e19de 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -55,6 +55,10 @@ interface ThreadsViewOperationLease { readonly lifetime: AbortSignal; } +interface ThreadTitleContextPreparation { + readonly threadId: string; +} + export class ThreadsViewSession { private readonly lifetime = new OwnerLifetime(); private readonly operations: ThreadOperations; @@ -66,6 +70,7 @@ export class ThreadsViewSession { private threads: readonly Thread[] = []; private threadsLoaded = false; private readonly renameStates = new Map(); + private readonly renameContextPreparations = new Map(); private readonly renameGenerationControllers = new Map(); private nextRenameGenerationToken = 1; private unsubscribeThreads: (() => void) | null = null; @@ -115,6 +120,7 @@ export class ThreadsViewSession { this.lifetime.dispose(); for (const operation of this.renameGenerationControllers.values()) operation.controller.abort(); this.renameGenerationControllers.clear(); + this.renameContextPreparations.clear(); this.titleService.invalidate(); this.observedFetching = false; this.observedFetchingNextPage = false; @@ -265,6 +271,7 @@ export class ThreadsViewSession { this.archiveConfirmThreadId = null; this.transitionRenameState(threadId, { type: "started", draft: value }); this.render(); + void this.prepareAutoName(threadId); } private updateRename(threadId: string, value: string): void { @@ -274,6 +281,7 @@ export class ThreadsViewSession { private cancelRename(threadId: string): void { this.abortAutoName(threadId); + this.renameContextPreparations.delete(threadId); this.transitionRenameState(threadId, { type: "cancelled" }); this.render(); } @@ -290,22 +298,21 @@ export class ThreadsViewSession { const editingState = this.renameStates.get(threadId); if (!editingState || editingState.kind === "generating") return; try { - if (this.renameStates.get(threadId) !== editingState) return; const result = await this.operations.renameThread(threadId, value, { shouldStart: () => this.operationContextIsCurrent(lease), shouldPublish: () => this.operationContextIsCurrent(lease), }); - if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return; + if (!this.operationViewIsCurrent(lease) || !this.renameEditStillCurrent(threadId, editingState.draft)) return; if (!result) { this.cancelRename(threadId); return; } this.renameStates.delete(threadId); + this.renameContextPreparations.delete(threadId); this.render(); } catch (error) { - if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return; - this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; - this.render(); + if (!this.operationViewIsCurrent(lease) || !this.renameEditStillCurrent(threadId, editingState.draft)) return; + this.noticeError(error); } } @@ -324,8 +331,9 @@ export class ThreadsViewSession { this.render(); try { - if (this.renameStates.get(threadId) !== generatingState) return; - const title = await this.titleService.generateTitle(threadId, controller.signal); + const context = generatingState.autoName.context; + const title = await this.titleService.generate(context, controller.signal); + if (!title) throw new Error("Codex did not return a usable thread title."); if (!this.operationViewIsCurrent(lease)) return; this.transitionRenameState(threadId, { type: "generation-succeeded", generationToken, draft: title }); } catch (error) { @@ -393,6 +401,23 @@ export class ThreadsViewSession { if (nextState !== previousState) this.render(); } + private async prepareAutoName(threadId: string): Promise { + const preparation = { threadId }; + this.renameContextPreparations.set(threadId, preparation); + let context = null; + try { + context = await this.titleService.resolveContext(threadId); + } catch { + // Auto-name availability is reflected by the disabled action. + } + if (!this.lifetime.isActive() || this.renameContextPreparations.get(threadId) !== preparation) return; + this.renameContextPreparations.delete(threadId); + const state = this.renameStates.get(threadId); + if (state?.kind !== "editing") return; + this.transitionRenameState(threadId, { type: "auto-name-context-resolved", context }); + this.render(); + } + private abortAutoName(threadId: string): void { this.renameGenerationControllers.get(threadId)?.controller.abort(); this.renameGenerationControllers.delete(threadId); @@ -408,6 +433,11 @@ export class ThreadsViewSession { return nextState; } + private renameEditStillCurrent(threadId: string, draft: string): boolean { + const current = this.renameStates.get(threadId); + return current?.kind === "editing" && current.draft === draft; + } + private viewWindow(): Window { return this.environment.viewWindow() ?? window; } diff --git a/src/features/threads-view/shell.dom.tsx b/src/features/threads-view/shell.dom.tsx index c0338a0a..2f9d795e 100644 --- a/src/features/threads-view/shell.dom.tsx +++ b/src/features/threads-view/shell.dom.tsx @@ -253,6 +253,7 @@ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions: icon={row.rename.generating ? "x" : "sparkles"} label={row.rename.generating ? "Cancel auto-name" : "Auto-name thread"} className="codex-panel-threads__row-button" + disabled={!row.rename.generating && row.rename.autoNameDisabled} onPointerDown={(event) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/features/threads/list/row-projection.ts b/src/features/threads/list/row-projection.ts index 03f1298a..2fc76e1e 100644 --- a/src/features/threads/list/row-projection.ts +++ b/src/features/threads/list/row-projection.ts @@ -6,6 +6,7 @@ interface ThreadRowCoreRenameProjection { readonly active: boolean; readonly draft: string; readonly generating: boolean; + readonly autoNameDisabled: boolean; } interface ThreadRowCoreArchiveConfirmProjection { @@ -37,6 +38,7 @@ export function threadRowCoreProjection(input: { active: rename !== undefined, draft: rename?.draft ?? threadRenameDraftTitle(input.thread), generating: rename?.kind === "generating", + autoNameDisabled: rename?.autoName.kind !== "ready", }, archiveConfirm: { active: input.archiveConfirmActive ?? false, diff --git a/src/features/threads/workflows/thread-title-service.ts b/src/features/threads/workflows/thread-title-service.ts index d838bbd7..4d836626 100644 --- a/src/features/threads/workflows/thread-title-service.ts +++ b/src/features/threads/workflows/thread-title-service.ts @@ -1,8 +1,4 @@ -import { - THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, - type ThreadTitleContext, - threadTitleContextFromTurnTranscriptSummary, -} from "../../../domain/threads/title-generation-model"; +import { type ThreadTitleContext, threadTitleContextFromTurnTranscriptSummary } from "../../../domain/threads/title-generation-model"; import type { TurnTranscriptSummary } from "../../../domain/threads/transcript"; import type { ThreadTitleTransport } from "./ports"; @@ -15,10 +11,9 @@ export interface ThreadTitleServiceHost { export interface ThreadTitleService { invalidate(): void; - generateTitle(threadId: string, signal?: AbortSignal): Promise; resolveContext(threadId: string): Promise; completedTurnContext(turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null): ThreadTitleContext | null; - generate(context: ThreadTitleContext): Promise; + generate(context: ThreadTitleContext, signal?: AbortSignal): Promise; } export function createThreadTitleService(host: ThreadTitleServiceHost): ThreadTitleService { @@ -29,17 +24,16 @@ export function createThreadTitleService(host: ThreadTitleServiceHost): ThreadTi controller.abort(); controller = new AbortController(); }, - generateTitle: async (threadId, signal) => { + generate: async (context, signal) => { const operation = linkedAbortSignal(controller.signal, signal); try { - return await generateTitle(host, threadId, operation.signal); + return await generateTitleFromContext(host, context, operation.signal); } finally { operation.dispose(); } }, resolveContext: (threadId) => resolveThreadTitleContext(host, threadId), completedTurnContext: (turnId, completedTurnTranscriptSummary) => completedTurnContext(host, turnId, completedTurnTranscriptSummary), - generate: (context) => generateTitleFromContext(host, context, controller.signal), }; } @@ -68,16 +62,6 @@ function linkedAbortSignal( }; } -async function generateTitle(host: ThreadTitleServiceHost, threadId: string, signal: AbortSignal): Promise { - const context = await resolveThreadTitleContext(host, threadId); - throwIfTitleGenerationCancelled(signal); - if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE); - - const title = await generateTitleFromContext(host, context, signal); - if (!title) throw new Error("Codex did not return a usable thread title."); - return title; -} - async function resolveThreadTitleContext(host: ThreadTitleServiceHost, threadId: string): Promise { const visibleContext = host.visibleContext?.(threadId); if (visibleContext) return visibleContext; diff --git a/tests/domain/threads/rename-lifecycle.test.ts b/tests/domain/threads/rename-lifecycle.test.ts index 3ced896f..a148e3e7 100644 --- a/tests/domain/threads/rename-lifecycle.test.ts +++ b/tests/domain/threads/rename-lifecycle.test.ts @@ -32,7 +32,7 @@ describe("thread rename lifecycle", () => { type: "generation-finished", generationToken: generating.generationToken, }), - ).toEqual({ kind: "editing", draft: "Generated title" }); + ).toEqual({ kind: "editing", draft: "Generated title", autoName: generating.autoName }); }); it("does not create an editor from a stray draft update", () => { @@ -46,7 +46,11 @@ type ThreadRenameGeneratingState = Extract { threadId: "thread", draft: "Original", }); + const context = { userRequest: "Request", assistantResponse: "Response" }; + state = reduceUiSlice(state, { + type: "ui/rename-auto-name-context-resolved", + threadId: "thread", + context, + }); state = reduceUiSlice(state, { type: "ui/rename-generation-started", threadId: "thread", @@ -81,7 +87,7 @@ describe("chat UI state", () => { generationToken: 1, }); - expect(state.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Generated title" }); + expect(state.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Generated title", autoName: { kind: "ready", context } }); }); it("clears goal expansion only when the displayed goal identity changes", () => { diff --git a/tests/features/chat/application/threads/rename-editor-actions.test.ts b/tests/features/chat/application/threads/rename-editor-actions.test.ts index 5f9cfe10..f0530126 100644 --- a/tests/features/chat/application/threads/rename-editor-actions.test.ts +++ b/tests/features/chat/application/threads/rename-editor-actions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protocol/turn"; import { normalizeExplicitThreadName, type Thread } from "../../../../../src/domain/threads/model"; +import type { ThreadTitleContext } from "../../../../../src/domain/threads/title-generation-model"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { createThreadRenameEditorActions, @@ -39,14 +40,41 @@ describe("ThreadRenameEditorActions", () => { expect(actions.editState("thread")).toBeNull(); }); - it("applies generated rename drafts and clears the generating state", async () => { + it("keeps auto-name disabled when no title context is available", async () => { + const resolvedContext = deferred(); const generateThreadTitle = vi.fn().mockResolvedValue("Generated title"); - const { actions } = actionsFixture({ generateThreadTitle }); + const addSystemMessage = vi.fn(); + const { actions, stateStore } = actionsFixture({ + resolveThreadTitleContext: vi.fn(() => resolvedContext.promise), + generateThreadTitle, + addSystemMessage, + }); actions.start("thread"); + expect(stateStore.getState().ui.rename).toMatchObject({ autoName: { kind: "checking" } }); + await actions.autoNameDraft("thread"); + expect(generateThreadTitle).not.toHaveBeenCalled(); + + resolvedContext.resolve(null); + await flushPromises(); + + expect(stateStore.getState().ui.rename).toMatchObject({ autoName: { kind: "unavailable" } }); + expect(addSystemMessage).not.toHaveBeenCalled(); + }); + + it("applies generated rename drafts and clears the generating state", async () => { + const context = { userRequest: "Please name this.", assistantResponse: "Done." }; + const resolveThreadTitleContext = vi.fn().mockResolvedValue(context); + const generateThreadTitle = vi.fn().mockResolvedValue("Generated title"); + const { actions } = actionsFixture({ resolveThreadTitleContext, generateThreadTitle }); + + actions.start("thread"); + await flushPromises(); await actions.autoNameDraft("thread"); expect(generateThreadTitle).toHaveBeenCalledOnce(); + expect(generateThreadTitle).toHaveBeenCalledWith(context, expect.any(AbortSignal)); + expect(resolveThreadTitleContext).toHaveBeenCalledOnce(); expect(actions.editState("thread")).toEqual({ draft: "Generated title", generating: false }); }); @@ -61,6 +89,7 @@ describe("ThreadRenameEditorActions", () => { }); actions.start("thread"); + await flushPromises(); const autoName = actions.autoNameDraft("thread"); await Promise.resolve(); expect(generationSignal?.aborted).toBe(false); @@ -89,6 +118,7 @@ describe("ThreadRenameEditorActions", () => { }); actions.start("thread"); + await flushPromises(); const autoName = actions.autoNameDraft("thread"); await Promise.resolve(); actions.start("other"); @@ -109,6 +139,7 @@ describe("ThreadRenameEditorActions", () => { }); actions.start("thread"); + await flushPromises(); const autoName = actions.autoNameDraft("thread"); actions.cancel("thread"); connection.resolve(undefined); @@ -168,6 +199,36 @@ describe("ThreadRenameEditorActions", () => { expect(addSystemMessage).toHaveBeenCalledWith("Rename failed."); }); + it("keeps preparing auto-name while a rename save is in flight", async () => { + const context = deferred(); + const saved = deferred(); + const readyContext = { userRequest: "Name this thread.", assistantResponse: "Done." }; + const resolveThreadTitleContext = vi.fn().mockReturnValue(context.promise); + const generateThreadTitle = vi.fn().mockResolvedValue("Generated title"); + const { actions } = actionsFixture({ + currentClient: () => fakeClient({ renameThreadRequest: vi.fn(() => saved.promise) }), + resolveThreadTitleContext, + generateThreadTitle, + }); + + actions.start("thread"); + await flushPromises(); + expect(resolveThreadTitleContext).toHaveBeenCalledOnce(); + + const saving = actions.save("thread", "Unsaved draft"); + await flushPromises(); + actions.updateDraft("thread", "Changed while saving"); + context.resolve(readyContext); + saved.reject(new Error("Rename failed.")); + await saving; + await flushPromises(); + expect(resolveThreadTitleContext).toHaveBeenCalledOnce(); + + await actions.autoNameDraft("thread"); + expect(generateThreadTitle).toHaveBeenCalledWith(readyContext, expect.any(AbortSignal)); + expect(actions.editState("thread")).toEqual({ draft: "Generated title", generating: false }); + }); + it("does not report a delayed save failure after the edit is invalidated", async () => { const saved = deferred(); const addSystemMessage = vi.fn(); @@ -220,6 +281,7 @@ describe("ThreadRenameEditorActions", () => { }); actions.start("thread"); + await flushPromises(); const autoName = actions.autoNameDraft("thread"); await flushPromises(); @@ -240,10 +302,12 @@ describe("ThreadRenameEditorActions", () => { const { actions } = actionsFixture({ generateThreadTitle }); actions.start("thread"); + await flushPromises(); const firstAutoName = actions.autoNameDraft("thread"); await flushPromises(); actions.cancel("thread"); actions.start("thread"); + await flushPromises(); const secondAutoName = actions.autoNameDraft("thread"); await flushPromises(); @@ -265,11 +329,13 @@ describe("ThreadRenameEditorActions", () => { const { actions } = actionsFixture({ generateThreadTitle, addSystemMessage }); actions.start("thread"); + await flushPromises(); const staleAutoName = actions.autoNameDraft("thread"); await flushPromises(); actions.invalidate(); actions.start("thread"); + await flushPromises(); await actions.autoNameDraft("thread"); oldTitle.resolve("Stale title"); await staleAutoName; @@ -283,6 +349,7 @@ function actionsFixture( overrides: Partial> & { currentClient?: () => AppServerClient; generateThreadTitle?: ThreadRenameEditorActionsHost["generateThreadTitle"]; + resolveThreadTitleContext?: ThreadRenameEditorActionsHost["resolveThreadTitleContext"]; } = {}, ): ThreadRenameEditorActionsHost & { actions: ThreadRenameEditorActions; @@ -307,6 +374,8 @@ function actionsFixture( notifyThreadRenamed(threadId, name); return true; }, + resolveThreadTitleContext: + overrides.resolveThreadTitleContext ?? vi.fn().mockResolvedValue({ userRequest: "Please name this.", assistantResponse: "Done." }), generateThreadTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"), } satisfies ThreadRenameEditorActionsHost; return { ...host, notifyThreadRenamed, actions: createThreadRenameEditorActions(host) }; diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 0e642626..75e80c15 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -290,7 +290,9 @@ describe("chat panel surface projections", () => { state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn" } } }); state = chatStateWith(state, { ui: { toolbarPanel: "history" } }); state = chatStateWith(state, { ui: { archiveConfirmThreadId: "thread-2" } }); - state = chatStateWith(state, { ui: { rename: { kind: "editing", threadId: "thread-1", draft: "Active" } } }); + state = chatStateWith(state, { + ui: { rename: { kind: "editing", threadId: "thread-1", draft: "Active", autoName: { kind: "unavailable" } } }, + }); state = chatStateWith(state, { connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" }) }, }); diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index 158947a4..5ba3270e 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -264,7 +264,7 @@ describe("Toolbar decisions", () => { disabled: false, archiveDisabled: false, canArchive: true, - rename: { draft: "Draft title", generating: false }, + rename: { draft: "Draft title", generating: false, autoNameDisabled: false }, }, ], }), @@ -299,7 +299,7 @@ describe("Toolbar decisions", () => { disabled: false, archiveDisabled: false, canArchive: true, - rename: { draft: "New title", generating: false }, + rename: { draft: "New title", generating: false, autoNameDisabled: false }, }, ], }), @@ -338,7 +338,7 @@ describe("Toolbar decisions", () => { disabled: false, archiveDisabled: false, canArchive: true, - rename: { draft: "Draft title", generating: true }, + rename: { draft: "Draft title", generating: true, autoNameDisabled: false }, }, ], }), @@ -366,7 +366,7 @@ describe("Toolbar decisions", () => { disabled: false, archiveDisabled: false, canArchive: true, - rename: { draft: "Draft title", generating: false }, + rename: { draft: "Draft title", generating: false, autoNameDisabled: false }, }, ], }), @@ -376,6 +376,36 @@ describe("Toolbar decisions", () => { parent.remove(); }); + it("disables auto-name while title context is unavailable", () => { + const parent = document.createElement("div"); + const autoNameThread = vi.fn(); + + mountToolbar( + parent, + toolbarModel({ + historyOpen: true, + openPanel: "history", + threads: [ + { + title: "Editing", + threadId: "editing", + selected: false, + disabled: false, + archiveDisabled: false, + canArchive: true, + rename: { draft: "Draft title", generating: false, autoNameDisabled: true }, + }, + ], + }), + toolbarActions({ autoNameThread }), + ); + + const autoName = expectPresent(parent.querySelector('[aria-label="Auto-name thread"]')); + expect(autoName.disabled).toBe(true); + autoName.click(); + expect(autoNameThread).not.toHaveBeenCalled(); + }); + it("renders toolbar archive confirmation with the default action on the right", () => { const parent = document.createElement("div"); const startArchiveThread = vi.fn(); diff --git a/tests/features/threads-view/shell.test.tsx b/tests/features/threads-view/shell.test.tsx index d942bd52..7e3bd1e7 100644 --- a/tests/features/threads-view/shell.test.tsx +++ b/tests/features/threads-view/shell.test.tsx @@ -44,7 +44,7 @@ function rowFixture(overrides: Partial = {}): ThreadsRowModel { title, live: null, selected: false, - rename: { active: false, draft: title, generating: false }, + rename: { active: false, draft: title, generating: false, autoNameDisabled: true }, archiveConfirm: { active: false, defaultSaveMarkdown: false }, ...overrides, }; @@ -171,7 +171,7 @@ describe("threads view renderer decisions", () => { const actions = threadsViewActions(); const row = rowFixture({ title: "Old name", - rename: { active: true, draft: "Old name", generating: false }, + rename: { active: true, draft: "Old name", generating: false, autoNameDisabled: false }, }); renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); @@ -184,7 +184,11 @@ describe("threads view renderer decisions", () => { renderThreadsViewShell( parent, - { status: "1 thread", loading: false, rows: [{ ...row, rename: { active: true, draft: "New name", generating: false } }] }, + { + status: "1 thread", + loading: false, + rows: [{ ...row, rename: { active: true, draft: "New name", generating: false, autoNameDisabled: false } }], + }, actions, ); const renamedInput = expectPresent(parent.querySelector(".codex-panel-threads__rename-input")); @@ -208,7 +212,7 @@ describe("threads view renderer decisions", () => { const actions = threadsViewActions(); const row = rowFixture({ title: "Old name", - rename: { active: true, draft: "Old name", generating: false }, + rename: { active: true, draft: "Old name", generating: false, autoNameDisabled: false }, }); renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); @@ -224,7 +228,7 @@ describe("threads view renderer decisions", () => { const actions = threadsViewActions(); const row = rowFixture({ title: "Old name", - rename: { active: true, draft: "Old name", generating: true }, + rename: { active: true, draft: "Old name", generating: true, autoNameDisabled: false }, }); renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); diff --git a/tests/features/threads-view/state.test.ts b/tests/features/threads-view/state.test.ts index 38cd1b3d..2a9ce316 100644 --- a/tests/features/threads-view/state.test.ts +++ b/tests/features/threads-view/state.test.ts @@ -6,7 +6,11 @@ import { type ThreadsViewPanelActivity, threadRows, transitionThreadsRenameState describe("threads view rename state", () => { it("keeps auto-name results scoped to the active unchanged generation", () => { const editing = transitionThreadsRenameState(undefined, { type: "started", draft: "Original draft" }); - const generating = transitionThreadsRenameState(editing, { type: "generation-started", generationToken: 1 }); + const ready = transitionThreadsRenameState(editing, { + type: "auto-name-context-resolved", + context: { userRequest: "Request", assistantResponse: "Response" }, + }); + const generating = transitionThreadsRenameState(ready, { type: "generation-started", generationToken: 1 }); if (generating?.kind !== "generating") throw new Error("Expected generating state"); const generated = transitionThreadsRenameState(generating, { @@ -18,11 +22,13 @@ describe("threads view rename state", () => { expect(generated).toEqual({ kind: "generating", draft: "Generated title", + autoName: { kind: "ready", context: { userRequest: "Request", assistantResponse: "Response" } }, generationToken: 1, }); expect(transitionThreadsRenameState(generated, { type: "generation-finished", generationToken: generating.generationToken })).toEqual({ kind: "editing", draft: "Generated title", + autoName: { kind: "ready", context: { userRequest: "Request", assistantResponse: "Response" } }, }); expect(transitionThreadsRenameState(generated, { type: "cancelled" })).toBeUndefined(); expect(transitionThreadsRenameState(undefined, { type: "draft-updated", draft: "Stray" })).toBeUndefined(); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index d40c960d..170abc81 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -472,6 +472,9 @@ describe("CodexThreadsView", () => { await view.refresh(); view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + await waitForAsyncWork(() => { + expect(view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(false); + }); view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.click(); await waitForAsyncWork(() => { @@ -482,11 +485,75 @@ describe("CodexThreadsView", () => { sortDirection: "asc", itemsView: "full", }); + expect(threadTurnsList).toHaveBeenCalledOnce(); expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.value).toBe("Threads rename UI"); }); }); + it("disables auto-name without publishing an error when completed history is unavailable", async () => { + const threadTurnsList = vi.fn().mockResolvedValue({ data: [], nextCursor: null }); + connectionMock.state.client = clientFixture({ + "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), + "thread/turns/list": threadTurnsList, + }); + const view = await threadsView(); + + await view.refresh(); + view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + await waitForAsyncWork(() => { + expect(threadTurnsList).toHaveBeenCalledOnce(); + }); + + expect(view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); + expect(view.containerEl.querySelector(".codex-panel-threads__status")).toBeNull(); + expect(view.containerEl.textContent).not.toContain("completed history"); + expect(namingMock.generateThreadTitleWithCodex).not.toHaveBeenCalled(); + }); + + it("keeps loading auto-name history while a rename save is in flight", async () => { + const history = deferred(); + const saved = deferred(); + const completedHistory = { + data: [ + turnFixture([ + { type: "userMessage", id: "u1", clientId: null, content: [{ type: "text", text: "Name this", text_elements: [] }] }, + { type: "agentMessage", id: "a1", text: "Done.", phase: "final_answer", memoryCitation: null }, + ]), + ], + nextCursor: null, + }; + const threadTurnsList = vi.fn().mockReturnValue(history.promise); + namingMock.generateThreadTitleWithCodex.mockResolvedValue("Generated title"); + connectionMock.state.client = clientFixture({ + "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), + "thread/name/set": vi.fn(() => saved.promise), + "thread/turns/list": threadTurnsList, + }); + const view = await threadsView(); + + await view.refresh(); + view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + await waitForAsyncWork(() => expect(threadTurnsList).toHaveBeenCalledOnce()); + const input = view.containerEl.querySelector(".codex-panel-threads__rename-input"); + if (!input) throw new Error("Missing rename input"); + input.dispatchEvent(new FocusEvent("blur")); + changeInputValue(input, "Changed while saving"); + history.resolve(completedHistory); + saved.reject(new Error("Rename failed.")); + + await waitForAsyncWork(() => { + expect(threadTurnsList).toHaveBeenCalledOnce(); + expect(view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(false); + }); + const autoName = view.containerEl.querySelector('[aria-label="Auto-name thread"]'); + autoName?.click(); + await waitForAsyncWork(() => { + expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); + expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.value).toBe("Generated title"); + }); + }); + it("replaces the auto-name action with cancellation while generating", async () => { const threadTurnsList = vi.fn().mockResolvedValue({ data: [ @@ -512,6 +579,9 @@ describe("CodexThreadsView", () => { await view.refresh(); view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + await waitForAsyncWork(() => { + expect(view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(false); + }); view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.click(); await waitForAsyncWork(() => { @@ -545,6 +615,9 @@ describe("CodexThreadsView", () => { await view.refresh(); view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + await waitForAsyncWork(() => { + expect(view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(false); + }); view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.click(); await waitForAsyncWork(() => { expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); @@ -585,6 +658,9 @@ describe("CodexThreadsView", () => { await view.refresh(); view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + await waitForAsyncWork(() => { + expect(view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(false); + }); view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.click(); await waitForAsyncWork(() => { expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); diff --git a/tests/features/threads/list/row-projection.test.ts b/tests/features/threads/list/row-projection.test.ts index a7c894af..31c0c1eb 100644 --- a/tests/features/threads/list/row-projection.test.ts +++ b/tests/features/threads/list/row-projection.test.ts @@ -8,7 +8,12 @@ describe("thread row core projection", () => { const row = threadRowCoreProjection({ thread: thread({ name: " Saved name ", preview: "Preview" }), selected: true, - renameState: { kind: "generating", draft: "Draft", generationToken: 1 }, + renameState: { + kind: "generating", + draft: "Draft", + autoName: { kind: "ready", context: { userRequest: "Request", assistantResponse: "Response" } }, + generationToken: 1, + }, archiveConfirmActive: true, defaultArchiveSaveMarkdown: true, }); @@ -17,7 +22,7 @@ describe("thread row core projection", () => { threadId: "thread", title: "Saved name", selected: true, - rename: { active: true, draft: "Draft", generating: true }, + rename: { active: true, draft: "Draft", generating: true, autoNameDisabled: false }, archiveConfirm: { active: true, defaultSaveMarkdown: true }, }); }); @@ -27,6 +32,7 @@ describe("thread row core projection", () => { active: false, draft: "Preview title", generating: false, + autoNameDisabled: true, }); expect(threadRowCoreProjection({ thread: thread({ name: null, preview: "" }), selected: false }).rename.draft).toBe(""); }); diff --git a/tests/features/threads/workflows/thread-title-service.test.ts b/tests/features/threads/workflows/thread-title-service.test.ts index a168fd51..690b91c4 100644 --- a/tests/features/threads/workflows/thread-title-service.test.ts +++ b/tests/features/threads/workflows/thread-title-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, type ThreadTitleContext } from "../../../../src/domain/threads/title-generation-model"; +import type { ThreadTitleContext } from "../../../../src/domain/threads/title-generation-model"; import { createThreadTitleService, type ThreadTitleService, @@ -8,7 +8,7 @@ import { } from "../../../../src/features/threads/workflows/thread-title-service"; describe("ThreadTitleService", () => { - it("generates a title from visible context without saving it", async () => { + it("resolves visible context once and generates from the prepared context", async () => { const generateThreadTitle = vi.fn().mockResolvedValue("Generated title"); const withClient = vi.fn().mockRejectedValue(new Error("should not read persisted context")); const service = titleService({ @@ -20,16 +20,19 @@ describe("ThreadTitleService", () => { }, }); - await expect(service.generateTitle("thread")).resolves.toBe("Generated title"); + const context = await service.resolveContext("thread"); + expect(context).toEqual(titleContext("visible request", "visible response")); + if (!context) throw new Error("Expected visible title context."); + await expect(service.generate(context)).resolves.toBe("Generated title"); expect(withClient).not.toHaveBeenCalled(); expect(generateThreadTitle).toHaveBeenCalledWith(titleContext("visible request", "visible response"), expect.any(AbortSignal)); }); - it("throws the existing unavailable-context error when no context can be resolved", async () => { + it("reports unavailable context without turning it into a generation error", async () => { const service = titleService(); - await expect(service.generateTitle("thread")).rejects.toThrow(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE); + await expect(service.resolveContext("thread")).resolves.toBeNull(); }); it("prefers visible completed-turn context over completed summaries", () => { @@ -71,13 +74,14 @@ describe("ThreadTitleService", () => { }, }); - const staleTitle = service.generateTitle("thread"); + const context = titleContext("request", "response"); + const staleTitle = service.generate(context); await Promise.resolve(); const staleSignal = generateTitle.mock.calls[0]?.[1]; service.invalidate(); expect(staleSignal?.aborted).toBe(true); - await expect(service.generateTitle("thread")).resolves.toBe("Fresh title"); + await expect(service.generate(context)).resolves.toBe("Fresh title"); resolveOldTitle("Stale title"); await expect(staleTitle).rejects.toThrow("Thread title generation cancelled."); });