diff --git a/src/features/chat/application/state/ui-state.ts b/src/features/chat/application/state/ui-state.ts index 1fc3629d..16256ff2 100644 --- a/src/features/chat/application/state/ui-state.ts +++ b/src/features/chat/application/state/ui-state.ts @@ -14,6 +14,23 @@ export type ChatRenameUiState = }; export type ChatRenameGeneratingUiState = Extract; +type ChatRenameUiAction = Extract< + UiAction, + { + type: + | "ui/rename-started" + | "ui/rename-draft-updated" + | "ui/rename-cancelled" + | "ui/rename-generation-started" + | "ui/rename-generation-succeeded" + | "ui/rename-generation-finished" + | "ui/rename-cleared"; + } +>; +type ChatRenameUiActionType = ChatRenameUiAction["type"]; +type ChatRenameUiKind = ChatRenameUiState["kind"]; +type ChatRenameUiTransition = (state: ChatRenameUiState, action: ChatRenameUiAction) => ChatRenameUiState; +type ChatRenameUiTransitionTable = Record>; type ChatGoalEditorUiState = | { readonly kind: "closed" } @@ -110,21 +127,13 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState case "ui/archive-confirm-set": return patchObject(state, { archiveConfirmThreadId: action.threadId }); case "ui/rename-started": - return patchObject(state, { rename: { kind: "editing", threadId: action.threadId, draft: action.draft } }); case "ui/rename-draft-updated": - return patchObject(state, { rename: renameUiStateUpdated(state.rename, action.threadId, action.draft) }); case "ui/rename-cancelled": - return patchObject(state, { rename: renameUiStateCancelled(state.rename, action.threadId) }); case "ui/rename-generation-started": - return patchObject(state, { - rename: renameUiGenerationStarted(state.rename, action.threadId, action.originalDraft, action.generationToken), - }); case "ui/rename-generation-succeeded": - return patchObject(state, { rename: renameUiGenerationSucceeded(state.rename, action.generatingState, action.draft) }); case "ui/rename-generation-finished": - return patchObject(state, { rename: renameUiGenerationFinished(state.rename, action.threadId, action.generatingState) }); case "ui/rename-cleared": - return patchObject(state, { rename: initialRenameUiState() }); + return patchObject(state, { rename: transitionChatRenameUiState(state.rename, action) }); case "ui/goal-editor-started": return patchObject(state, { goalEditor: { @@ -254,52 +263,111 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string) return { ...state, objectiveDraft: objective }; } -function renameUiStateUpdated(state: ChatRenameUiState, threadId: string, draft: string): ChatRenameUiState { - if (state.kind === "idle" || state.threadId !== threadId) return state; - return { ...state, draft }; +function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState { + return chatRenameUiTransitions[state.kind][action.type](state, action); } -function renameUiStateCancelled(state: ChatRenameUiState, threadId: string): ChatRenameUiState { +const keepRenameUiState: ChatRenameUiTransition = (state) => state; + +const startRenameUiTransition: ChatRenameUiTransition = (_state, action) => ({ + kind: "editing", + threadId: requireRenameThreadId(action), + draft: requireRenameDraft(action), +}); + +const updateRenameUiDraftTransition: ChatRenameUiTransition = (state, action) => { + if (state.kind === "idle" || state.threadId !== requireRenameThreadId(action)) return state; + return { ...state, draft: requireRenameDraft(action) }; +}; + +const cancelRenameUiTransition: ChatRenameUiTransition = (state, action) => { + const threadId = requireRenameThreadId(action); if (state.kind === "idle" || state.threadId !== threadId) return state; return initialRenameUiState(); -} +}; -function renameUiGenerationStarted( - state: ChatRenameUiState, - threadId: string, - originalDraft: string, - generationToken: number, -): ChatRenameUiState { +const startRenameGenerationTransition: ChatRenameUiTransition = (state, action) => { + const threadId = requireRenameThreadId(action); if (state.kind !== "editing" || state.threadId !== threadId) return state; return { kind: "generating", threadId, draft: state.draft, - originalDraft, - generationToken, + originalDraft: requireRenameOriginalDraft(action), + generationToken: requireRenameGenerationToken(action), }; -} +}; -function renameUiGenerationSucceeded( - state: ChatRenameUiState, - generatingState: ChatRenameGeneratingUiState, - draft: string, -): ChatRenameUiState { +const succeedRenameGenerationTransition: ChatRenameUiTransition = (state, action) => { + const generatingState = requireRenameGeneratingState(action); if (!renameGenerationStillActive(state, generatingState) || state.draft !== generatingState.originalDraft) return state; - return { ...state, draft }; -} + return { ...state, draft: requireRenameDraft(action) }; +}; -function renameUiGenerationFinished( - state: ChatRenameUiState, - threadId: string, - generatingState: ChatRenameGeneratingUiState, -): ChatRenameUiState { +const finishRenameGenerationTransition: ChatRenameUiTransition = (state, action) => { + const threadId = requireRenameThreadId(action); + const generatingState = requireRenameGeneratingState(action); if (!renameGenerationStillActive(state, generatingState) || state.threadId !== threadId) return state; return { kind: "editing", threadId: state.threadId, draft: state.draft, }; +}; + +const clearRenameUiTransition: ChatRenameUiTransition = (state) => (state.kind === "idle" ? state : initialRenameUiState()); + +const renameUiStateActiveTransitions = { + "ui/rename-started": startRenameUiTransition, + "ui/rename-draft-updated": updateRenameUiDraftTransition, + "ui/rename-cancelled": cancelRenameUiTransition, + "ui/rename-generation-started": startRenameGenerationTransition, + "ui/rename-generation-succeeded": succeedRenameGenerationTransition, + "ui/rename-generation-finished": finishRenameGenerationTransition, + "ui/rename-cleared": clearRenameUiTransition, +} satisfies Record; + +const chatRenameUiTransitions: ChatRenameUiTransitionTable = { + idle: { + ...renameUiStateActiveTransitions, + "ui/rename-draft-updated": keepRenameUiState, + "ui/rename-cancelled": keepRenameUiState, + "ui/rename-generation-started": keepRenameUiState, + "ui/rename-generation-succeeded": keepRenameUiState, + "ui/rename-generation-finished": keepRenameUiState, + "ui/rename-cleared": clearRenameUiTransition, + }, + editing: renameUiStateActiveTransitions, + generating: { + ...renameUiStateActiveTransitions, + "ui/rename-generation-started": keepRenameUiState, + }, +}; + +function requireRenameThreadId(action: ChatRenameUiAction): string { + if ("threadId" in action) return action.threadId; + if ("generatingState" in action) return action.generatingState.threadId; + throw new Error(`Rename UI action ${action.type} does not include a thread id.`); +} + +function requireRenameDraft(action: ChatRenameUiAction): string { + if ("draft" in action) return action.draft; + throw new Error(`Rename UI action ${action.type} does not include a draft.`); +} + +function requireRenameOriginalDraft(action: ChatRenameUiAction): string { + if ("originalDraft" in action) return action.originalDraft; + throw new Error(`Rename UI action ${action.type} does not include an original draft.`); +} + +function requireRenameGenerationToken(action: ChatRenameUiAction): number { + if ("generationToken" in action) return action.generationToken; + throw new Error(`Rename UI action ${action.type} does not include a generation token.`); +} + +function requireRenameGeneratingState(action: ChatRenameUiAction): ChatRenameGeneratingUiState { + if ("generatingState" in action) return action.generatingState; + throw new Error(`Rename UI action ${action.type} does not include generating state.`); } function filterStringSet(values: ReadonlySet, keep: (value: string) => boolean): ReadonlySet { diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 7917cc0f..95d79ac8 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -12,13 +12,10 @@ import { createThreadOperations, type ThreadOperations } from "../threads/thread import { createThreadTitleService, type ThreadTitleService } from "../threads/thread-title-service"; import { renderThreadsView, unmountThreadsView } from "./renderer"; import { - completedThreadAutoNameState, - editingThreadRenameState, - generatedThreadAutoNameState, - startedThreadAutoNameState, threadRows, - updatedThreadRenameState, + transitionThreadsRenameState, type ThreadsGeneratingRenameState, + type ThreadsRenameLifecycleEvent, type ThreadsRenameState, } from "./state"; import { @@ -72,6 +69,7 @@ export class CodexThreadsSession { private status: ThreadsViewStatus = { kind: "idle" }; private threads: readonly Thread[] = []; private readonly renameStates = new Map(); + private nextRenameGenerationToken = 1; private unsubscribeThreads: (() => void) | null = null; private archiveConfirmThreadId: string | null = null; @@ -239,17 +237,17 @@ export class CodexThreadsSession { private startRename(threadId: string, value: string): void { this.archiveConfirmThreadId = null; - this.renameStates.set(threadId, editingThreadRenameState(value)); + this.transitionRenameState(threadId, { type: "started", draft: value }); this.render(); } private updateRename(threadId: string, value: string): void { - this.renameStates.set(threadId, updatedThreadRenameState(this.renameStates.get(threadId), value)); + this.transitionRenameState(threadId, { type: "draft-updated", draft: value }); this.render(); } private cancelRename(threadId: string): void { - this.renameStates.delete(threadId); + this.transitionRenameState(threadId, { type: "cancelled" }); this.render(); } @@ -271,16 +269,19 @@ export class CodexThreadsSession { } private async autoNameThread(threadId: string): Promise { - const generatingState = startedThreadAutoNameState(this.renameStates.get(threadId)); - if (!generatingState) return; - this.renameStates.set(threadId, generatingState); + const previousState = this.renameStates.get(threadId); + const generatingState = this.transitionRenameState(threadId, { + type: "auto-name-started", + generationToken: this.nextRenameGenerationToken, + }); + if (generatingState === previousState || generatingState?.kind !== "generating") return; + this.nextRenameGenerationToken += 1; this.render(); try { if (this.renameStates.get(threadId) !== generatingState) return; const title = await this.titleService.generateTitle(threadId); - const renamedState = generatedThreadAutoNameState(this.renameStates.get(threadId), generatingState, title); - if (renamedState) this.renameStates.set(threadId, renamedState); + this.transitionRenameState(threadId, { type: "auto-name-generated", generatingState, title }); } catch (error) { if (this.renameStates.get(threadId) === generatingState) { this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; @@ -323,10 +324,19 @@ export class CodexThreadsSession { } private finishAutoNameThread(threadId: string, generatingState: ThreadsGeneratingRenameState): void { - const nextState = completedThreadAutoNameState(this.renameStates.get(threadId), generatingState); - if (!nextState) return; - this.renameStates.set(threadId, nextState); - this.render(); + const previousState = this.renameStates.get(threadId); + const nextState = this.transitionRenameState(threadId, { type: "auto-name-finished", generatingState }); + if (nextState !== previousState) this.render(); + } + + private transitionRenameState(threadId: string, event: ThreadsRenameLifecycleEvent): ThreadsRenameState | undefined { + const nextState = transitionThreadsRenameState(this.renameStates.get(threadId), event); + if (nextState) { + this.renameStates.set(threadId, nextState); + } else { + this.renameStates.delete(threadId); + } + return nextState; } private viewWindow(): Window { diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index ba87d48b..e4d39253 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -20,8 +20,28 @@ export interface ThreadsRowModel { archiveConfirm: { active: boolean; defaultSaveMarkdown: boolean }; } -export type ThreadsRenameState = { kind: "editing"; draft: string } | { kind: "generating"; draft: string; originalDraft: string }; +export type ThreadsRenameState = + | { kind: "editing"; draft: string } + | { kind: "generating"; draft: string; originalDraft: string; generationToken: number }; export type ThreadsGeneratingRenameState = Extract; +type ThreadsRenameLifecycleKind = ThreadsRenameState["kind"] | "idle"; +export type ThreadsRenameLifecycleState = ThreadsRenameState | undefined; +export type ThreadsRenameLifecycleEvent = + | { type: "started"; draft: string } + | { type: "draft-updated"; draft: string } + | { type: "cancelled" } + | { type: "auto-name-started"; generationToken: number } + | { type: "auto-name-generated"; generatingState: ThreadsGeneratingRenameState; title: string } + | { type: "auto-name-finished"; generatingState: ThreadsGeneratingRenameState }; +type ThreadsRenameLifecycleEventType = ThreadsRenameLifecycleEvent["type"]; +type ThreadsRenameLifecycleTransition = ( + state: ThreadsRenameLifecycleState, + event: ThreadsRenameLifecycleEvent, +) => ThreadsRenameLifecycleState; +type ThreadsRenameLifecycleTransitionTable = Record< + ThreadsRenameLifecycleKind, + Record +>; const STATUS_PRIORITY: Record = { "needs-input": 5, @@ -83,36 +103,102 @@ function selectedStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): boolean return snapshots.some((snapshot) => snapshot.threadId !== null && snapshot.lastFocused); } -export function editingThreadRenameState(draft: string): ThreadsRenameState { +export function transitionThreadsRenameState( + state: ThreadsRenameLifecycleState, + event: ThreadsRenameLifecycleEvent, +): ThreadsRenameLifecycleState { + return threadsRenameLifecycleTransitions[threadsRenameLifecycleKind(state)][event.type](state, event); +} + +const keepThreadsRenameState: ThreadsRenameLifecycleTransition = (state) => state; + +const startThreadsRenameTransition: ThreadsRenameLifecycleTransition = (_state, event) => + editingThreadRenameState(requireThreadsRenameDraft(event)); + +const updateThreadsRenameDraftTransition: ThreadsRenameLifecycleTransition = (state, event) => { + const draft = requireThreadsRenameDraft(event); + return state?.kind === "generating" ? { ...state, draft } : editingThreadRenameState(draft); +}; + +const cancelThreadsRenameTransition: ThreadsRenameLifecycleTransition = () => undefined; + +const startThreadsAutoNameTransition: ThreadsRenameLifecycleTransition = (state, event) => { + if (!state || state.kind === "generating") return state; + return { kind: "generating", draft: state.draft, originalDraft: state.draft, generationToken: requireThreadsGenerationToken(event) }; +}; + +const generatedThreadsAutoNameTransition: ThreadsRenameLifecycleTransition = (state, event) => { + const generatingState = requireThreadsGeneratingState(event); + if (!threadsRenameGenerationStillActive(state, generatingState)) return state; + if (state.draft !== state.originalDraft) return state; + return { ...state, draft: requireGeneratedThreadsTitle(event) }; +}; + +const finishThreadsAutoNameTransition: ThreadsRenameLifecycleTransition = (state, event) => { + if (!threadsRenameGenerationStillActive(state, requireThreadsGeneratingState(event))) return state; + return editingThreadRenameState(state.draft); +}; + +const threadsRenameLifecycleTransitions: ThreadsRenameLifecycleTransitionTable = { + idle: { + started: startThreadsRenameTransition, + "draft-updated": updateThreadsRenameDraftTransition, + cancelled: keepThreadsRenameState, + "auto-name-started": keepThreadsRenameState, + "auto-name-generated": keepThreadsRenameState, + "auto-name-finished": keepThreadsRenameState, + }, + editing: { + started: startThreadsRenameTransition, + "draft-updated": updateThreadsRenameDraftTransition, + cancelled: cancelThreadsRenameTransition, + "auto-name-started": startThreadsAutoNameTransition, + "auto-name-generated": keepThreadsRenameState, + "auto-name-finished": keepThreadsRenameState, + }, + generating: { + started: startThreadsRenameTransition, + "draft-updated": updateThreadsRenameDraftTransition, + cancelled: cancelThreadsRenameTransition, + "auto-name-started": keepThreadsRenameState, + "auto-name-generated": generatedThreadsAutoNameTransition, + "auto-name-finished": finishThreadsAutoNameTransition, + }, +}; + +function threadsRenameLifecycleKind(state: ThreadsRenameLifecycleState): ThreadsRenameLifecycleKind { + return state?.kind ?? "idle"; +} + +function editingThreadRenameState(draft: string): ThreadsRenameState { return { kind: "editing", draft }; } -export function updatedThreadRenameState(current: ThreadsRenameState | undefined, draft: string): ThreadsRenameState { - return current?.kind === "generating" ? { ...current, draft } : editingThreadRenameState(draft); -} - -export function startedThreadAutoNameState(current: ThreadsRenameState | undefined): ThreadsGeneratingRenameState | null { - if (!current || current.kind === "generating") return null; - return { kind: "generating", draft: current.draft, originalDraft: current.draft }; -} - -export function generatedThreadAutoNameState( - current: ThreadsRenameState | undefined, +function threadsRenameGenerationStillActive( + state: ThreadsRenameLifecycleState, generatingState: ThreadsGeneratingRenameState, - title: string, -): ThreadsRenameState | null { - if (current !== generatingState) return null; - if (current.draft !== generatingState.originalDraft) return null; - return { ...generatingState, draft: title }; +): state is ThreadsGeneratingRenameState { + return state?.kind === "generating" && state.generationToken === generatingState.generationToken; } -export function completedThreadAutoNameState( - current: ThreadsRenameState | undefined, - generatingState: ThreadsGeneratingRenameState, -): ThreadsRenameState | undefined { - if (current?.kind !== "generating") return undefined; - const draft = current === generatingState ? generatingState.draft : current.draft; - return editingThreadRenameState(draft); +function requireThreadsRenameDraft(event: ThreadsRenameLifecycleEvent): string { + if ("draft" in event) return event.draft; + throw new Error(`Threads rename lifecycle event ${event.type} does not include a draft.`); +} + +function requireThreadsGeneratingState(event: ThreadsRenameLifecycleEvent): ThreadsGeneratingRenameState { + if ("generatingState" in event) return event.generatingState; + throw new Error(`Threads rename lifecycle event ${event.type} does not include generating state.`); +} + +function requireThreadsGenerationToken(event: ThreadsRenameLifecycleEvent): number { + if ("generationToken" in event) return event.generationToken; + throw new Error(`Threads rename lifecycle event ${event.type} does not include a generation token.`); +} + +function requireGeneratedThreadsTitle(event: ThreadsRenameLifecycleEvent): string { + if ("title" in event) return event.title; + throw new Error(`Threads rename lifecycle event ${event.type} does not include a title.`); } function snapshotsForThreads(snapshots: OpenCodexPanelSnapshot[]): Map { diff --git a/tests/features/chat/state-reducer.test.ts b/tests/features/chat/state-reducer.test.ts index fef46962..55bb7774 100644 --- a/tests/features/chat/state-reducer.test.ts +++ b/tests/features/chat/state-reducer.test.ts @@ -614,6 +614,41 @@ describe("chatReducer", () => { expect(state.ui.goalEditor.kind).toBe("closed"); }); + it("keeps rename generation callbacks scoped to the active generation", () => { + let state = chatStateFixture(); + state = chatReducer(state, { type: "ui/rename-started", threadId: "thread", draft: "Original" }); + state = chatReducer(state, { + type: "ui/rename-generation-started", + threadId: "thread", + originalDraft: "Original", + generationToken: 1, + }); + const generatingState = state.ui.rename; + if (generatingState.kind !== "generating") throw new Error("Expected generating rename state."); + + const staleSucceeded = chatReducer(state, { + type: "ui/rename-generation-succeeded", + generatingState: { ...generatingState, generationToken: 2 }, + draft: "Late title", + }); + expect(staleSucceeded).toBe(state); + + const manuallyEdited = chatReducer(state, { type: "ui/rename-draft-updated", threadId: "thread", draft: "Manual draft" }); + const generatedAfterManualEdit = chatReducer(manuallyEdited, { + type: "ui/rename-generation-succeeded", + generatingState, + draft: "Generated title", + }); + expect(generatedAfterManualEdit).toBe(manuallyEdited); + + const finished = chatReducer(manuallyEdited, { + type: "ui/rename-generation-finished", + threadId: "thread", + generatingState, + }); + expect(finished.ui.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Manual draft" }); + }); + it("clears expanded goal objective state when the displayed goal identity changes", () => { let state = chatStateFixture(); state = chatReducer(state, { type: "active-thread/goal-set", goal: goal("thread") }); diff --git a/tests/features/threads-view/state.test.ts b/tests/features/threads-view/state.test.ts index 5653b303..031bd333 100644 --- a/tests/features/threads-view/state.test.ts +++ b/tests/features/threads-view/state.test.ts @@ -1,45 +1,74 @@ import { describe, expect, it } from "vitest"; import type { Thread } from "../../../src/domain/threads/model"; -import { - completedThreadAutoNameState, - editingThreadRenameState, - generatedThreadAutoNameState, - startedThreadAutoNameState, - threadRows, - updatedThreadRenameState, -} from "../../../src/features/threads-view/state"; +import { threadRows, transitionThreadsRenameState, type ThreadsRenameState } from "../../../src/features/threads-view/state"; describe("threads view rename state", () => { it("keeps a late auto-name result from reviving a cancelled rename", () => { - const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft")); - expect(generating).not.toBeNull(); - if (!generating) throw new Error("Expected generating state"); + const generating = generatingRenameState("Original draft", 1); - expect(generatedThreadAutoNameState(undefined, generating, "Late title")).toBeNull(); - expect(completedThreadAutoNameState(undefined, generating)).toBeUndefined(); + expect( + transitionThreadsRenameState(undefined, { type: "auto-name-generated", generatingState: generating, title: "Late title" }), + ).toBeUndefined(); + expect(transitionThreadsRenameState(undefined, { type: "auto-name-finished", generatingState: generating })).toBeUndefined(); }); it("keeps a manually edited draft when auto-name finishes later", () => { - const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft")); - expect(generating).not.toBeNull(); - if (!generating) throw new Error("Expected generating state"); + const generating = generatingRenameState("Original draft", 1); - const manuallyEdited = updatedThreadRenameState(generating, "Manual draft"); + const manuallyEdited = transitionThreadsRenameState(generating, { type: "draft-updated", draft: "Manual draft" }); - expect(generatedThreadAutoNameState(manuallyEdited, generating, "Late title")).toBeNull(); - expect(completedThreadAutoNameState(manuallyEdited, generating)).toEqual({ kind: "editing", draft: "Manual draft" }); + expect( + transitionThreadsRenameState(manuallyEdited, { type: "auto-name-generated", generatingState: generating, title: "Late title" }), + ).toBe(manuallyEdited); + expect(transitionThreadsRenameState(manuallyEdited, { type: "auto-name-finished", generatingState: generating })).toEqual({ + kind: "editing", + draft: "Manual draft", + }); }); it("applies generated titles only to the active unchanged generation", () => { - const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft")); - expect(generating).not.toBeNull(); - if (!generating) throw new Error("Expected generating state"); + const generating = generatingRenameState("Original draft", 1); - const generated = generatedThreadAutoNameState(generating, generating, "Generated title"); + const generated = transitionThreadsRenameState(generating, { + type: "auto-name-generated", + generatingState: generating, + title: "Generated title", + }); - expect(generated).toEqual({ kind: "generating", draft: "Generated title", originalDraft: "Original draft" }); - expect(completedThreadAutoNameState(generated ?? undefined, generating)).toEqual({ kind: "editing", draft: "Generated title" }); + expect(generated).toEqual({ + kind: "generating", + draft: "Generated title", + originalDraft: "Original draft", + generationToken: 1, + }); + expect(transitionThreadsRenameState(generated, { type: "auto-name-finished", generatingState: generating })).toEqual({ + kind: "editing", + draft: "Generated title", + }); + }); + + it("keeps ignored rename lifecycle transitions as no-ops", () => { + const editing = editingRenameState("Original draft"); + const generating = generatingRenameState("Original draft", 1); + + expect(transitionThreadsRenameState(undefined, { type: "auto-name-started", generationToken: 1 })).toBeUndefined(); + expect(transitionThreadsRenameState(editing, { type: "auto-name-generated", generatingState: generating, title: "Late" })).toBe( + editing, + ); + expect(transitionThreadsRenameState(generating, { type: "auto-name-started", generationToken: 2 })).toBe(generating); + }); + + it("keeps stale auto-name completion from finishing a newer generation", () => { + const oldGenerating = generatingRenameState("Original draft", 1); + const currentGenerating = generatingRenameState("Original draft", 2); + + expect( + transitionThreadsRenameState(currentGenerating, { type: "auto-name-generated", generatingState: oldGenerating, title: "Old" }), + ).toBe(currentGenerating); + expect(transitionThreadsRenameState(currentGenerating, { type: "auto-name-finished", generatingState: oldGenerating })).toBe( + currentGenerating, + ); }); it("initializes rename drafts from normalized explicit thread names", () => { @@ -48,6 +77,22 @@ describe("threads view rename state", () => { }); }); +function editingRenameState(draft: string): ThreadsRenameState { + return expectRenameState(transitionThreadsRenameState(undefined, { type: "started", draft })); +} + +function generatingRenameState(draft: string, generationToken: number): Extract { + const editing = editingRenameState(draft); + const generating = transitionThreadsRenameState(editing, { type: "auto-name-started", generationToken }); + if (generating?.kind !== "generating") throw new Error("Expected generating state"); + return generating; +} + +function expectRenameState(state: ThreadsRenameState | undefined): ThreadsRenameState { + if (state) return state; + throw new Error("Expected rename state"); +} + function thread(overrides: Partial = {}): Thread { return { id: "thread", diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 8d9ec211..dd70eb73 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -349,6 +349,45 @@ describe("CodexThreadsView", () => { }); }); + it("ignores duplicate auto-name starts while a thread rename is already generating", async () => { + const threadTurnsList = vi.fn().mockResolvedValue({ + data: [ + turnFixture([ + { + type: "userMessage", + id: "u1", + clientId: null, + content: [{ type: "text", text: "rename stale handling", text_elements: [] }], + }, + { type: "agentMessage", id: "a1", text: "Handled.", phase: "final_answer", memoryCitation: null }, + ]), + ], + nextCursor: null, + }); + const generatedTitle = deferred(); + namingMock.generateThreadTitleWithCodex.mockReturnValue(generatedTitle.promise); + connectionMock.state.client = clientFixture({ + listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), + threadTurnsList, + }); + const view = await threadsView(); + + 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(); + + await waitForAsyncWork(() => { + expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); + }); + + generatedTitle.resolve("Generated title"); + await waitForAsyncWork(() => { + expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.value).toBe("Generated title"); + }); + }); + it("keeps a manually edited rename draft when threads view auto-name finishes later", async () => { const threadTurnsList = vi.fn().mockResolvedValue({ data: [