Model rename generation lifecycles as state machines

This commit is contained in:
murashit 2026-06-21 13:51:44 +09:00
parent 1e42fe6ea2
commit caf76c8ef8
6 changed files with 385 additions and 102 deletions

View file

@ -14,6 +14,23 @@ export type ChatRenameUiState =
};
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
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<ChatRenameUiKind, Record<ChatRenameUiActionType, ChatRenameUiTransition>>;
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<ChatRenameUiActionType, ChatRenameUiTransition>;
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<string>, keep: (value: string) => boolean): ReadonlySet<string> {

View file

@ -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<string, ThreadsRenameState>();
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<void> {
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 {

View file

@ -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<ThreadsRenameState, { kind: "generating" }>;
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<ThreadsRenameLifecycleEventType, ThreadsRenameLifecycleTransition>
>;
const STATUS_PRIORITY: Record<ThreadsLiveStatus, number> = {
"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<string, OpenCodexPanelSnapshot[]> {

View file

@ -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") });

View file

@ -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<ThreadsRenameState, { kind: "generating" }> {
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> = {}): Thread {
return {
id: "thread",

View file

@ -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<string | null>();
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<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
const autoName = view.containerEl.querySelector<HTMLButtonElement>('[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<HTMLInputElement>(".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: [