From 5cd106973c2dd08bf93de2bdb5223c72493ccfb3 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 21 Jul 2026 10:26:57 +0900 Subject: [PATCH] refactor(threads): make auto-name exclusive and cancellable --- src/domain/threads/rename-lifecycle.ts | 7 +-- .../chat/application/state/ui-state.ts | 1 - .../threads/rename-editor-actions.ts | 35 +++++++++++- .../chat/host/bundles/thread-bundle.ts | 2 +- src/features/chat/panel/toolbar-actions.ts | 3 + src/features/chat/ui/toolbar.tsx | 14 +++-- src/features/threads-view/session.ts | 25 +++++++- src/features/threads-view/shell.dom.tsx | 14 +++-- .../threads/workflows/thread-title-service.ts | 36 +++++++++++- tests/domain/threads/rename-lifecycle.test.ts | 14 +++-- .../chat/application/state/ui-state.test.ts | 4 +- .../threads/rename-editor-actions.test.ts | 57 ++++++++++++++++++- tests/features/chat/panel/shell.test.tsx | 1 + .../chat/panel/surface/projections.test.ts | 1 + .../chat/panel/toolbar-archive-state.test.tsx | 1 + tests/features/chat/ui/toolbar.test.ts | 36 ++++++++++-- tests/features/threads-view/shell.test.tsx | 11 +++- tests/features/threads-view/state.test.ts | 1 - tests/features/threads-view/view.test.ts | 28 ++++++--- .../threads/list/row-projection.test.ts | 2 +- 20 files changed, 241 insertions(+), 52 deletions(-) diff --git a/src/domain/threads/rename-lifecycle.ts b/src/domain/threads/rename-lifecycle.ts index 1d61521a..6fa325f0 100644 --- a/src/domain/threads/rename-lifecycle.ts +++ b/src/domain/threads/rename-lifecycle.ts @@ -1,7 +1,7 @@ export type ThreadRenameLifecycleState = | { kind: "idle" } | { kind: "editing"; draft: string } - | { kind: "generating"; draft: string; originalDraft: string; generationToken: number }; + | { kind: "generating"; draft: string; generationToken: number }; export type ThreadRenameActiveState = Exclude; type ThreadRenameGeneratingState = Extract; @@ -27,7 +27,7 @@ export function transitionThreadRenameLifecycleState( case "started": return { kind: "editing", draft: event.draft }; case "draft-updated": - return state.kind === "idle" ? state : { ...state, draft: event.draft }; + return state.kind === "editing" ? { ...state, draft: event.draft } : state; case "cancelled": return state.kind === "idle" ? state : initialThreadRenameLifecycleState(); case "generation-started": @@ -35,11 +35,10 @@ export function transitionThreadRenameLifecycleState( return { kind: "generating", draft: state.draft, - originalDraft: state.draft, generationToken: event.generationToken, }; case "generation-succeeded": - if (!threadRenameGenerationStillActive(state, event.generationToken) || state.draft !== state.originalDraft) return state; + if (!threadRenameGenerationStillActive(state, event.generationToken)) return state; return { ...state, draft: event.draft }; case "generation-finished": if (!threadRenameGenerationStillActive(state, event.generationToken)) return state; diff --git a/src/features/chat/application/state/ui-state.ts b/src/features/chat/application/state/ui-state.ts index e63fd712..fd2e3d2f 100644 --- a/src/features/chat/application/state/ui-state.ts +++ b/src/features/chat/application/state/ui-state.ts @@ -309,7 +309,6 @@ function chatRenameActiveStateWithoutThreadId(state: Exclude Promise; addSystemMessage: (text: string) => void; renameThread(threadId: string, value: string): Promise; - generateThreadTitle(threadId: string): Promise; + generateThreadTitle(threadId: string, signal?: AbortSignal): Promise; } export interface ThreadRenameEditorActions { @@ -26,15 +26,19 @@ export interface ThreadRenameEditorActions { start(threadId: string): void; updateDraft(threadId: string, value: string): void; cancel(threadId: string): void; + cancelAutoName(threadId: string): void; save(threadId: string, value: string): Promise; autoNameDraft(threadId: string): Promise; } export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions { let nextRenameGenerationToken = 1; + let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null; const action = { invalidate(): void { + activeGeneration?.controller.abort(); + activeGeneration = null; dispatch(host, { type: "ui/rename-cleared" }); }, @@ -54,6 +58,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH start(threadId: string): void { const thread = host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId); if (!thread) return; + abortActiveGeneration(); dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) }); }, @@ -62,9 +67,17 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH }, cancel(threadId: string): void { + abortGeneration(threadId); dispatch(host, { type: "ui/rename-cancelled", threadId }); }, + cancelAutoName(threadId: string): void { + const current = renameState(host); + if (current.kind !== "generating" || current.threadId !== threadId) return; + abortGeneration(threadId); + finishAutoNameDraftGeneration(host, threadId, current.generationToken); + }, + async save(threadId: string, value: string): Promise { const current = renameState(host); if (current.kind === "idle" || current.threadId !== threadId || current.kind === "generating") return; @@ -105,21 +118,39 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH const generationToken = nextRenameGenerationToken; if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return; nextRenameGenerationToken += 1; + const controller = new AbortController(); + activeGeneration = { threadId, generationToken, controller }; try { - const title = await host.generateThreadTitle(threadId); + const title = await host.generateThreadTitle(threadId, controller.signal); dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title }); } catch (error) { if (renameGenerationStillActive(renameState(host), threadId, generationToken)) { host.addSystemMessage(error instanceof Error ? error.message : String(error)); } } finally { + clearGeneration(generationToken); finishAutoNameDraftGeneration(host, threadId, generationToken); } }, }; return action; + + function abortGeneration(threadId: string): void { + if (activeGeneration?.threadId !== threadId) return; + abortActiveGeneration(); + } + + function abortActiveGeneration(): void { + if (!activeGeneration) return; + activeGeneration.controller.abort(); + activeGeneration = null; + } + + function clearGeneration(generationToken: number): void { + if (activeGeneration?.generationToken === generationToken) activeGeneration = null; + } } export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null { diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index e66d6c1c..288a64c6 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -227,7 +227,7 @@ export function createThreadLifecycleBundle( ensureConnected, addSystemMessage: status.addSystemMessage, renameThread: (threadId, value) => foundation.threadOperations.renameThread(threadId, value), - generateThreadTitle: (threadId) => foundation.titleService.generateTitle(threadId), + generateThreadTitle: (threadId, signal) => foundation.titleService.generateTitle(threadId, signal), }); const { identity, restoration, resume } = lifecycle; diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index 09585cfe..9b27586d 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -195,6 +195,9 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb cancel: (threadId) => { deps.rename.cancel(threadId); }, + cancelAutoName: (threadId) => { + deps.rename.cancelAutoName(threadId); + }, autoName: (threadId) => { void deps.rename.autoNameDraft(threadId); }, diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index e9a7d01c..2b0c6905 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -87,6 +87,7 @@ interface ToolbarThreadActions { updateDraft: (threadId: string, value: string) => void; save: (threadId: string, value: string) => void; cancel: (threadId: string) => void; + cancelAutoName: (threadId: string) => void; autoName: (threadId: string) => void; }; } @@ -485,8 +486,8 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action const generating = thread.rename?.generating ?? false; const draft = thread.rename?.draft ?? thread.title; useLayoutEffect(() => { - focusToolbarRenameInput(inputRef.current); - }, [draft]); + if (!generating) focusToolbarRenameInput(inputRef.current); + }, [draft, generating]); return ( <> @@ -501,6 +502,7 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action className="codex-panel-ui__nav-inline-input codex-panel__thread-rename-input" type="text" value={draft} + disabled={generating} onInput={(event) => { actions.rename.updateDraft(thread.threadId, event.currentTarget.value); }} @@ -523,17 +525,17 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action )} /> { event.preventDefault(); event.stopPropagation(); }} onClick={(event) => { event.stopPropagation(); - actions.rename.autoName(thread.threadId); + if (generating) actions.rename.cancelAutoName(thread.threadId); + else actions.rename.autoName(thread.threadId); }} /> diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index e68ecc0b..cc2c650d 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -66,6 +66,7 @@ export class ThreadsViewSession { private threads: readonly Thread[] = []; private threadsLoaded = false; private readonly renameStates = new Map(); + private readonly renameGenerationControllers = new Map(); private nextRenameGenerationToken = 1; private unsubscribeThreads: (() => void) | null = null; private archiveConfirmThreadId: string | null = null; @@ -112,6 +113,8 @@ export class ThreadsViewSession { close(): void { this.lifetime.dispose(); + for (const operation of this.renameGenerationControllers.values()) operation.controller.abort(); + this.renameGenerationControllers.clear(); this.titleService.invalidate(); this.observedFetching = false; this.observedFetchingNextPage = false; @@ -231,6 +234,9 @@ export class ThreadsViewSession { cancelRename: (threadId) => { this.cancelRename(threadId); }, + cancelAutoName: (threadId) => { + this.cancelAutoName(threadId); + }, autoNameThread: (threadId) => void this.autoNameThread(threadId), startArchive: (threadId) => { this.startArchive(threadId); @@ -267,10 +273,18 @@ export class ThreadsViewSession { } private cancelRename(threadId: string): void { + this.abortAutoName(threadId); this.transitionRenameState(threadId, { type: "cancelled" }); this.render(); } + private cancelAutoName(threadId: string): void { + const state = this.renameStates.get(threadId); + if (state?.kind !== "generating") return; + this.abortAutoName(threadId); + this.finishAutoNameThread(threadId, state.generationToken); + } + private async saveRename(threadId: string, value: string): Promise { const lease = this.captureOperationLease(); const editingState = this.renameStates.get(threadId); @@ -305,11 +319,13 @@ export class ThreadsViewSession { }); if (generatingState === previousState || generatingState?.kind !== "generating") return; this.nextRenameGenerationToken += 1; + const controller = new AbortController(); + this.renameGenerationControllers.set(threadId, { generationToken, controller }); this.render(); try { if (this.renameStates.get(threadId) !== generatingState) return; - const title = await this.titleService.generateTitle(threadId); + const title = await this.titleService.generateTitle(threadId, controller.signal); if (!this.operationViewIsCurrent(lease)) return; this.transitionRenameState(threadId, { type: "generation-succeeded", generationToken, draft: title }); } catch (error) { @@ -318,6 +334,8 @@ export class ThreadsViewSession { this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; } } finally { + const operation = this.renameGenerationControllers.get(threadId); + if (operation?.generationToken === generationToken) this.renameGenerationControllers.delete(threadId); if (this.operationViewIsCurrent(lease)) { this.finishAutoNameThread(threadId, generationToken); } @@ -375,6 +393,11 @@ export class ThreadsViewSession { if (nextState !== previousState) this.render(); } + private abortAutoName(threadId: string): void { + this.renameGenerationControllers.get(threadId)?.controller.abort(); + this.renameGenerationControllers.delete(threadId); + } + private transitionRenameState(threadId: string, event: ThreadRenameLifecycleEvent): ThreadsRenameState | undefined { const nextState = transitionThreadsRenameState(this.renameStates.get(threadId), event); if (nextState) { diff --git a/src/features/threads-view/shell.dom.tsx b/src/features/threads-view/shell.dom.tsx index 87fee7d0..c0338a0a 100644 --- a/src/features/threads-view/shell.dom.tsx +++ b/src/features/threads-view/shell.dom.tsx @@ -25,6 +25,7 @@ export interface ThreadsViewShellActions { updateRename: (threadId: string, value: string) => void; saveRename: (threadId: string, value: string) => void; cancelRename: (threadId: string) => void; + cancelAutoName: (threadId: string) => void; autoNameThread: (threadId: string) => void; startArchive: (threadId: string) => void; archiveThread: (threadId: string, saveMarkdown: boolean) => void; @@ -214,8 +215,8 @@ function threadArchiveDisabled(row: ThreadsRowModel): boolean { function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions: ThreadsViewShellActions; className: string }): UiNode { const inputRef = useRef(null); useLayoutEffect(() => { - focusThreadsRenameInput(inputRef.current); - }, [row.rename.draft]); + if (!row.rename.generating) focusThreadsRenameInput(inputRef.current); + }, [row.rename.draft, row.rename.generating]); return ( <> @@ -226,6 +227,7 @@ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions: className="codex-panel-ui__nav-inline-input codex-panel-threads__rename-input" type="text" value={row.rename.draft} + disabled={row.rename.generating} onInput={(event) => { actions.updateRename(row.threadId, event.currentTarget.value); }} @@ -248,17 +250,17 @@ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions:
{ event.preventDefault(); event.stopPropagation(); }} onClick={(event) => { event.stopPropagation(); - actions.autoNameThread(row.threadId); + if (row.rename.generating) actions.cancelAutoName(row.threadId); + else actions.autoNameThread(row.threadId); }} />
diff --git a/src/features/threads/workflows/thread-title-service.ts b/src/features/threads/workflows/thread-title-service.ts index 7712292f..d838bbd7 100644 --- a/src/features/threads/workflows/thread-title-service.ts +++ b/src/features/threads/workflows/thread-title-service.ts @@ -15,7 +15,7 @@ export interface ThreadTitleServiceHost { export interface ThreadTitleService { invalidate(): void; - generateTitle(threadId: string): Promise; + generateTitle(threadId: string, signal?: AbortSignal): Promise; resolveContext(threadId: string): Promise; completedTurnContext(turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null): ThreadTitleContext | null; generate(context: ThreadTitleContext): Promise; @@ -29,13 +29,45 @@ export function createThreadTitleService(host: ThreadTitleServiceHost): ThreadTi controller.abort(); controller = new AbortController(); }, - generateTitle: (threadId) => generateTitle(host, threadId, controller.signal), + generateTitle: async (threadId, signal) => { + const operation = linkedAbortSignal(controller.signal, signal); + try { + return await generateTitle(host, threadId, operation.signal); + } finally { + operation.dispose(); + } + }, resolveContext: (threadId) => resolveThreadTitleContext(host, threadId), completedTurnContext: (turnId, completedTurnTranscriptSummary) => completedTurnContext(host, turnId, completedTurnTranscriptSummary), generate: (context) => generateTitleFromContext(host, context, controller.signal), }; } +function linkedAbortSignal( + ownerSignal: AbortSignal, + operationSignal: AbortSignal | undefined, +): { + signal: AbortSignal; + dispose(): void; +} { + if (!operationSignal) return { signal: ownerSignal, dispose: () => undefined }; + + const controller = new AbortController(); + const abort = (): void => { + controller.abort(); + }; + ownerSignal.addEventListener("abort", abort, { once: true }); + operationSignal.addEventListener("abort", abort, { once: true }); + if (ownerSignal.aborted || operationSignal.aborted) abort(); + return { + signal: controller.signal, + dispose: () => { + ownerSignal.removeEventListener("abort", abort); + operationSignal.removeEventListener("abort", abort); + }, + }; +} + async function generateTitle(host: ThreadTitleServiceHost, threadId: string, signal: AbortSignal): Promise { const context = await resolveThreadTitleContext(host, threadId); throwIfTitleGenerationCancelled(signal); diff --git a/tests/domain/threads/rename-lifecycle.test.ts b/tests/domain/threads/rename-lifecycle.test.ts index 263864cf..3ced896f 100644 --- a/tests/domain/threads/rename-lifecycle.test.ts +++ b/tests/domain/threads/rename-lifecycle.test.ts @@ -7,7 +7,7 @@ import { } from "../../../src/domain/threads/rename-lifecycle"; describe("thread rename lifecycle", () => { - it("keeps late generation callbacks scoped to the active unchanged generation", () => { + it("blocks draft updates and keeps callbacks scoped to the active generation", () => { const generating = generatingRenameState("Original draft", 1); const staleGenerated = transitionThreadRenameLifecycleState(generating, { @@ -17,20 +17,22 @@ describe("thread rename lifecycle", () => { }); expect(staleGenerated).toBe(generating); - const manuallyEdited = transitionThreadRenameLifecycleState(generating, { type: "draft-updated", draft: "Manual draft" }); - const generatedAfterManualEdit = transitionThreadRenameLifecycleState(manuallyEdited, { + const draftUpdate = transitionThreadRenameLifecycleState(generating, { type: "draft-updated", draft: "Manual draft" }); + expect(draftUpdate).toBe(generating); + + const generated = transitionThreadRenameLifecycleState(draftUpdate, { type: "generation-succeeded", generationToken: generating.generationToken, draft: "Generated title", }); - expect(generatedAfterManualEdit).toBe(manuallyEdited); + expect(generated).toEqual({ ...generating, draft: "Generated title" }); expect( - transitionThreadRenameLifecycleState(manuallyEdited, { + transitionThreadRenameLifecycleState(generated, { type: "generation-finished", generationToken: generating.generationToken, }), - ).toEqual({ kind: "editing", draft: "Manual draft" }); + ).toEqual({ kind: "editing", draft: "Generated title" }); }); it("does not create an editor from a stray draft update", () => { diff --git a/tests/features/chat/application/state/ui-state.test.ts b/tests/features/chat/application/state/ui-state.test.ts index 888c5820..e85af1ce 100644 --- a/tests/features/chat/application/state/ui-state.test.ts +++ b/tests/features/chat/application/state/ui-state.test.ts @@ -73,7 +73,7 @@ describe("chat UI state", () => { type: "ui/rename-generation-succeeded", threadId: "thread", generationToken: 1, - draft: "Late title", + draft: "Generated title", }); state = reduceUiSlice(state, { type: "ui/rename-generation-finished", @@ -81,7 +81,7 @@ describe("chat UI state", () => { generationToken: 1, }); - expect(state.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Manual draft" }); + expect(state.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Generated title" }); }); 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 4efc2f37..5f9cfe10 100644 --- a/tests/features/chat/application/threads/rename-editor-actions.test.ts +++ b/tests/features/chat/application/threads/rename-editor-actions.test.ts @@ -50,6 +50,56 @@ describe("ThreadRenameEditorActions", () => { expect(actions.editState("thread")).toEqual({ draft: "Generated title", generating: false }); }); + it("returns to editing and ignores a generated title after auto-name cancellation", async () => { + const generatedTitle = deferred(); + let generationSignal: AbortSignal | undefined; + const { actions } = actionsFixture({ + generateThreadTitle: vi.fn((_threadId, signal) => { + generationSignal = signal; + return generatedTitle.promise; + }), + }); + + actions.start("thread"); + const autoName = actions.autoNameDraft("thread"); + await Promise.resolve(); + expect(generationSignal?.aborted).toBe(false); + actions.cancelAutoName("thread"); + + expect(generationSignal?.aborted).toBe(true); + expect(actions.editState("thread")).toEqual({ draft: "Thread preview", generating: false }); + generatedTitle.resolve("Generated title"); + await autoName; + + expect(actions.editState("thread")).toEqual({ draft: "Thread preview", generating: false }); + }); + + it("aborts auto-name when rename moves to another thread", async () => { + const generatedTitle = deferred(); + let generationSignal: AbortSignal | undefined; + const { actions, stateStore } = actionsFixture({ + generateThreadTitle: vi.fn((_threadId, signal) => { + generationSignal = signal; + return generatedTitle.promise; + }), + }); + stateStore.dispatch({ + type: "thread-list/applied", + threads: [threadFixture("thread"), { ...threadFixture("other"), preview: "Other preview" }], + }); + + actions.start("thread"); + const autoName = actions.autoNameDraft("thread"); + await Promise.resolve(); + actions.start("other"); + + expect(generationSignal?.aborted).toBe(true); + expect(actions.editState("other")).toEqual({ draft: "Other preview", generating: false }); + generatedTitle.resolve("Stale generated title"); + await autoName; + expect(actions.editState("other")).toEqual({ draft: "Other preview", generating: false }); + }); + it("does not revive rename generation after cancellation while connection is pending", async () => { const connection = deferred(); const generateThreadTitle = vi.fn().mockResolvedValue("Generated title"); @@ -163,7 +213,7 @@ describe("ThreadRenameEditorActions", () => { expect(actions.editState("thread")).toEqual({ draft: "New draft", generating: false }); }); - it("keeps an edited draft when auto-name generation finishes later", async () => { + it("ignores draft updates while auto-name generation is active", async () => { const generatedTitle = deferred(); const { actions } = actionsFixture({ generateThreadTitle: vi.fn(() => generatedTitle.promise), @@ -176,10 +226,11 @@ describe("ThreadRenameEditorActions", () => { expect(actions.editState("thread")).toEqual({ draft: "Thread preview", generating: true }); actions.updateDraft("thread", "Manual draft"); + expect(actions.editState("thread")).toEqual({ draft: "Thread preview", generating: true }); generatedTitle.resolve("Generated title"); await autoName; - expect(actions.editState("thread")).toEqual({ draft: "Manual draft", generating: false }); + expect(actions.editState("thread")).toEqual({ draft: "Generated title", generating: false }); }); it("does not let an older auto-name request finish a newer generation", async () => { @@ -231,7 +282,7 @@ describe("ThreadRenameEditorActions", () => { function actionsFixture( overrides: Partial> & { currentClient?: () => AppServerClient; - generateThreadTitle?: () => Promise; + generateThreadTitle?: ThreadRenameEditorActionsHost["generateThreadTitle"]; } = {}, ): ThreadRenameEditorActionsHost & { actions: ThreadRenameEditorActions; diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index f84438c5..316ce238 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -473,6 +473,7 @@ function toolbarActionsFixture(): ChatPanelShellParts["toolbar"]["actions"] { updateDraft: vi.fn(), save: vi.fn(), cancel: vi.fn(), + cancelAutoName: vi.fn(), autoName: vi.fn(), }, }, diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 87a2bc28..3649f2f4 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -772,6 +772,7 @@ function toolbarActionsFixture(overrides: ToolbarActionOverrides = {}): ToolbarA updateDraft: () => undefined, save: () => undefined, cancel: () => undefined, + cancelAutoName: () => undefined, autoName: () => undefined, }, }, diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index 069b1da2..3bdc212c 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -143,6 +143,7 @@ function shellParts( updateDraft: vi.fn(), save: vi.fn(), cancel: vi.fn(), + cancelAutoName: vi.fn(), autoName: vi.fn(), }, }, diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index 1c4bb2e5..e780e406 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -318,8 +318,10 @@ describe("Toolbar decisions", () => { parent.remove(); }); - it("renders auto-name loading without disabling the rename draft field", () => { + it("disables the rename draft and exposes auto-name cancellation while loading", () => { const parent = document.createElement("div"); + document.body.append(parent); + const cancelAutoName = vi.fn(); mountToolbar( parent, @@ -337,13 +339,37 @@ describe("Toolbar decisions", () => { }, ], }), - toolbarActions(), + toolbarActions({ cancelAutoName }), ); - expect(parent.querySelector(".codex-panel__thread-rename-input")?.disabled).toBe(false); + expect(parent.querySelector(".codex-panel__thread-rename-input")?.disabled).toBe(true); expect(parent.querySelector('[aria-label="Save thread name"]')).toBeNull(); - expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); + const cancelAutoNameButton = expectPresent(parent.querySelector('[aria-label="Cancel auto-name"]')); + expect(cancelAutoNameButton.disabled).toBe(false); + cancelAutoNameButton.click(); + expect(cancelAutoName).toHaveBeenCalledWith("editing"); expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); + + mountToolbar( + parent, + toolbarModel({ + historyOpen: true, + openPanel: "history", + threads: [ + { + title: "Editing", + threadId: "editing", + selected: false, + disabled: false, + canArchive: true, + rename: { draft: "Draft title", generating: false }, + }, + ], + }), + toolbarActions(), + ); + expect(document.activeElement).toBe(parent.querySelector(".codex-panel__thread-rename-input")); + parent.remove(); }); it("renders toolbar archive confirmation with the default action on the right", () => { @@ -475,6 +501,7 @@ interface ToolbarActionOverrides { updateRenameDraft?: (threadId: string, value: string) => void; saveRenameThread?: (threadId: string, value: string) => void; cancelRenameThread?: (threadId: string) => void; + cancelAutoName?: (threadId: string) => void; autoNameThread?: (threadId: string) => void; } @@ -508,6 +535,7 @@ function toolbarActions(overrides: ToolbarActionOverrides = {}): ToolbarActions updateDraft: overrides.updateRenameDraft ?? vi.fn(), save: overrides.saveRenameThread ?? vi.fn(), cancel: overrides.cancelRenameThread ?? vi.fn(), + cancelAutoName: overrides.cancelAutoName ?? vi.fn(), autoName: overrides.autoNameThread ?? vi.fn(), }, }, diff --git a/tests/features/threads-view/shell.test.tsx b/tests/features/threads-view/shell.test.tsx index 0de975bc..d942bd52 100644 --- a/tests/features/threads-view/shell.test.tsx +++ b/tests/features/threads-view/shell.test.tsx @@ -60,6 +60,7 @@ function threadsViewActions() { updateRename: vi.fn(), saveRename: vi.fn(), cancelRename: vi.fn(), + cancelAutoName: vi.fn(), autoNameThread: vi.fn(), startArchive: vi.fn(), archiveThread: vi.fn(), @@ -220,15 +221,19 @@ describe("threads view renderer decisions", () => { it("renders threads view rename auto-name loading state", () => { const parent = document.createElement("div"); + const actions = threadsViewActions(); const row = rowFixture({ title: "Old name", rename: { active: true, draft: "Old name", generating: true }, }); - renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, threadsViewActions()); + renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); - expect(parent.querySelector(".codex-panel-threads__rename-input")?.disabled).toBe(false); - expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); + expect(parent.querySelector(".codex-panel-threads__rename-input")?.disabled).toBe(true); + const cancelAutoName = expectPresent(parent.querySelector('[aria-label="Cancel auto-name"]')); + expect(cancelAutoName.disabled).toBe(false); + cancelAutoName.click(); + expect(actions.cancelAutoName).toHaveBeenCalledWith("thread"); }); it("disables history expansion during any shared thread fetch", () => { diff --git a/tests/features/threads-view/state.test.ts b/tests/features/threads-view/state.test.ts index 4d9c3865..38cd1b3d 100644 --- a/tests/features/threads-view/state.test.ts +++ b/tests/features/threads-view/state.test.ts @@ -18,7 +18,6 @@ describe("threads view rename state", () => { expect(generated).toEqual({ kind: "generating", draft: "Generated title", - originalDraft: "Original draft", generationToken: 1, }); expect(transitionThreadsRenameState(generated, { type: "generation-finished", generationToken: generating.generationToken })).toEqual({ diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 8c265394..d40c960d 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -487,7 +487,7 @@ describe("CodexThreadsView", () => { }); }); - it("ignores duplicate auto-name starts while a thread rename is already generating", async () => { + it("replaces the auto-name action with cancellation while generating", async () => { const threadTurnsList = vi.fn().mockResolvedValue({ data: [ turnFixture([ @@ -512,12 +512,12 @@ describe("CodexThreadsView", () => { await view.refresh(); view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); - const autoName = view.containerEl.querySelector('[aria-label="Auto-name thread"]'); - autoName?.click(); - autoName?.click(); + view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.click(); await waitForAsyncWork(() => { expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); + expect(view.containerEl.querySelector('[aria-label="Auto-name thread"]')).toBeNull(); + expect(view.containerEl.querySelector('[aria-label="Cancel auto-name"]')).not.toBeNull(); }); generatedTitle.resolve("Generated title"); @@ -559,7 +559,7 @@ describe("CodexThreadsView", () => { expect(view.containerEl.textContent).not.toContain("Late title"); }); - it("keeps a manually edited rename draft when threads view auto-name finishes later", async () => { + it("cancels auto-name without applying a late generated title", async () => { const threadTurnsList = vi.fn().mockResolvedValue({ data: [ turnFixture([ @@ -581,6 +581,7 @@ describe("CodexThreadsView", () => { "thread/turns/list": threadTurnsList, }); const view = await threadsView(); + document.body.append(view.containerEl); await view.refresh(); view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); @@ -592,12 +593,21 @@ describe("CodexThreadsView", () => { const input = view.containerEl.querySelector(".codex-panel-threads__rename-input"); expect(input).not.toBeNull(); if (!input) return; - changeInputValue(input, "Manual title"); + expect(input.disabled).toBe(true); + const generationSignal = namingMock.generateThreadTitleWithCodex.mock.calls[0]?.[4]?.signal as AbortSignal | undefined; + expect(generationSignal?.aborted).toBe(false); + view.containerEl.querySelector('[aria-label="Cancel auto-name"]')?.click(); + const editableInput = view.containerEl.querySelector(".codex-panel-threads__rename-input"); + expect(generationSignal?.aborted).toBe(true); + expect(editableInput?.disabled).toBe(false); + expect(editableInput?.value).toBe("Thread preview"); + expect(document.activeElement).toBe(editableInput); generatedTitle.resolve("Generated title"); + for (let index = 0; index < 10; index += 1) await Promise.resolve(); - await waitForAsyncWork(() => { - expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.value).toBe("Manual title"); - }); + expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.value).toBe("Thread preview"); + expect(view.containerEl.textContent).not.toContain("Generated title"); + view.containerEl.remove(); }); it("ignores refresh requests while detached from an execution runtime", async () => { diff --git a/tests/features/threads/list/row-projection.test.ts b/tests/features/threads/list/row-projection.test.ts index d2a2edda..a7c894af 100644 --- a/tests/features/threads/list/row-projection.test.ts +++ b/tests/features/threads/list/row-projection.test.ts @@ -8,7 +8,7 @@ describe("thread row core projection", () => { const row = threadRowCoreProjection({ thread: thread({ name: " Saved name ", preview: "Preview" }), selected: true, - renameState: { kind: "generating", draft: "Draft", originalDraft: "Saved name", generationToken: 1 }, + renameState: { kind: "generating", draft: "Draft", generationToken: 1 }, archiveConfirmActive: true, defaultArchiveSaveMarkdown: true, });