mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(threads): make auto-name exclusive and cancellable
This commit is contained in:
parent
f3d987ee03
commit
5cd106973c
20 changed files with 241 additions and 52 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -309,7 +309,6 @@ function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState,
|
|||
return {
|
||||
kind: "generating",
|
||||
draft: state.draft,
|
||||
originalDraft: state.originalDraft,
|
||||
generationToken: state.generationToken,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -473,6 +473,7 @@ function toolbarActionsFixture(): ChatPanelShellParts["toolbar"]["actions"] {
|
|||
updateDraft: vi.fn(),
|
||||
save: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
cancelAutoName: vi.fn(),
|
||||
autoName: vi.fn(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -772,6 +772,7 @@ function toolbarActionsFixture(overrides: ToolbarActionOverrides = {}): ToolbarA
|
|||
updateDraft: () => undefined,
|
||||
save: () => undefined,
|
||||
cancel: () => undefined,
|
||||
cancelAutoName: () => undefined,
|
||||
autoName: () => undefined,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ function shellParts(
|
|||
updateDraft: vi.fn(),
|
||||
save: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
cancelAutoName: vi.fn(),
|
||||
autoName: vi.fn(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue