refactor(threads): make auto-name exclusive and cancellable

This commit is contained in:
murashit 2026-07-21 10:26:57 +09:00
parent f3d987ee03
commit 5cd106973c
20 changed files with 241 additions and 52 deletions

View file

@ -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<ThreadRenameLifecycleState, { kind: "idle" }>;
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
@ -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;

View file

@ -309,7 +309,6 @@ function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState,
return {
kind: "generating",
draft: state.draft,
originalDraft: state.originalDraft,
generationToken: state.generationToken,
};
}

View file

@ -16,7 +16,7 @@ export interface ThreadRenameEditorActionsHost {
ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void;
renameThread(threadId: string, value: string): Promise<boolean>;
generateThreadTitle(threadId: string): Promise<string>;
generateThreadTitle(threadId: string, signal?: AbortSignal): Promise<string>;
}
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<void>;
autoNameDraft(threadId: string): Promise<void>;
}
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<void> {
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 {

View file

@ -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;

View file

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

View file

@ -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
)}
/>
<ToolbarRowActionButton
icon={generating ? "loader" : "sparkles"}
label="Auto-name thread"
icon={generating ? "x" : "sparkles"}
label={generating ? "Cancel auto-name" : "Auto-name thread"}
className="codex-panel__thread-action"
disabled={generating}
onPointerDown={(event) => {
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);
}}
/>
</>

View file

@ -66,6 +66,7 @@ export class ThreadsViewSession {
private threads: readonly Thread[] = [];
private threadsLoaded = false;
private readonly renameStates = new Map<string, ThreadsRenameState>();
private readonly renameGenerationControllers = new Map<string, { generationToken: number; controller: AbortController }>();
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<void> {
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) {

View file

@ -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<HTMLInputElement | null>(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:
</div>
<div className="codex-panel-threads__actions codex-panel-threads__rename-actions">
<ThreadsRowButton
icon={row.rename.generating ? "loader" : "sparkles"}
label="Auto-name thread"
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}
onPointerDown={(event) => {
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);
}}
/>
</div>

View file

@ -15,7 +15,7 @@ export interface ThreadTitleServiceHost {
export interface ThreadTitleService {
invalidate(): void;
generateTitle(threadId: string): Promise<string>;
generateTitle(threadId: string, signal?: AbortSignal): Promise<string>;
resolveContext(threadId: string): Promise<ThreadTitleContext | null>;
completedTurnContext(turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null): ThreadTitleContext | null;
generate(context: ThreadTitleContext): Promise<string | null>;
@ -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<string> {
const context = await resolveThreadTitleContext(host, threadId);
throwIfTitleGenerationCancelled(signal);

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -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<string>();
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<string>();
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<undefined>();
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<string>();
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<Pick<ThreadRenameEditorActionsHost, "ensureConnected" | "addSystemMessage">> & {
currentClient?: () => AppServerClient;
generateThreadTitle?: () => Promise<string>;
generateThreadTitle?: ThreadRenameEditorActionsHost["generateThreadTitle"];
} = {},
): ThreadRenameEditorActionsHost & {
actions: ThreadRenameEditorActions;

View file

@ -473,6 +473,7 @@ function toolbarActionsFixture(): ChatPanelShellParts["toolbar"]["actions"] {
updateDraft: vi.fn(),
save: vi.fn(),
cancel: vi.fn(),
cancelAutoName: vi.fn(),
autoName: vi.fn(),
},
},

View file

@ -772,6 +772,7 @@ function toolbarActionsFixture(overrides: ToolbarActionOverrides = {}): ToolbarA
updateDraft: () => undefined,
save: () => undefined,
cancel: () => undefined,
cancelAutoName: () => undefined,
autoName: () => undefined,
},
},

View file

@ -143,6 +143,7 @@ function shellParts(
updateDraft: vi.fn(),
save: vi.fn(),
cancel: vi.fn(),
cancelAutoName: vi.fn(),
autoName: vi.fn(),
},
},

View file

@ -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<HTMLInputElement>(".codex-panel__thread-rename-input")?.disabled).toBe(false);
expect(parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input")?.disabled).toBe(true);
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')).toBeNull();
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
const cancelAutoNameButton = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel auto-name"]'));
expect(cancelAutoNameButton.disabled).toBe(false);
cancelAutoNameButton.click();
expect(cancelAutoName).toHaveBeenCalledWith("editing");
expect(parent.querySelector<HTMLButtonElement>('[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<HTMLInputElement>(".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(),
},
},

View file

@ -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<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(false);
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
expect(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(true);
const cancelAutoName = expectPresent(parent.querySelector<HTMLButtonElement>('[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", () => {

View file

@ -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({

View file

@ -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<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
const autoName = view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]');
autoName?.click();
autoName?.click();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
await waitForAsyncWork(() => {
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
expect(view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')).toBeNull();
expect(view.containerEl.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
@ -592,12 +593,21 @@ describe("CodexThreadsView", () => {
const input = view.containerEl.querySelector<HTMLInputElement>(".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<HTMLButtonElement>('[aria-label="Cancel auto-name"]')?.click();
const editableInput = view.containerEl.querySelector<HTMLInputElement>(".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<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Manual title");
});
expect(view.containerEl.querySelector<HTMLInputElement>(".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 () => {

View file

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