mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
State-machine rename lifecycles
This commit is contained in:
parent
cca4b03958
commit
e12cf67096
5 changed files with 316 additions and 81 deletions
|
|
@ -18,6 +18,12 @@ export interface ThreadRenameEditState {
|
|||
generating: boolean;
|
||||
}
|
||||
|
||||
type RenameLifecycleState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "editing"; threadId: string; draft: string }
|
||||
| { kind: "generating"; threadId: string; draft: string; originalDraft: string };
|
||||
type RenameGeneratingState = Extract<RenameLifecycleState, { kind: "generating" }>;
|
||||
|
||||
export interface ThreadRenameControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
vaultPath: string;
|
||||
|
|
@ -28,16 +34,14 @@ export interface ThreadRenameControllerHost {
|
|||
render: () => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string) => void;
|
||||
generateThreadTitle?: (context: ThreadNamingContext) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export class ThreadRenameController {
|
||||
private activeThreadHadTurns = false;
|
||||
private readonly autoNameAttemptedThreadIds = new Set<string>();
|
||||
private readonly autoNameInFlightThreadIds = new Set<string>();
|
||||
private renameThreadId: string | null = null;
|
||||
private renameDraft = "";
|
||||
private renameAutoNameThreadId: string | null = null;
|
||||
private renameAutoNameGeneration = 0;
|
||||
private renameState: RenameLifecycleState = { kind: "idle" };
|
||||
|
||||
constructor(private readonly host: ThreadRenameControllerHost) {}
|
||||
|
||||
|
|
@ -54,41 +58,39 @@ export class ThreadRenameController {
|
|||
}
|
||||
|
||||
editState(threadId: string): ThreadRenameEditState | null {
|
||||
if (this.renameThreadId !== threadId) return null;
|
||||
if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId) return null;
|
||||
return {
|
||||
draft: this.renameDraft,
|
||||
generating: this.renameAutoNameThreadId === threadId,
|
||||
draft: this.renameState.draft,
|
||||
generating: this.renameState.kind === "generating",
|
||||
};
|
||||
}
|
||||
|
||||
isEditing(): boolean {
|
||||
return this.renameThreadId !== null;
|
||||
return this.renameState.kind !== "idle";
|
||||
}
|
||||
|
||||
start(threadId: string): void {
|
||||
const thread = this.thread(threadId);
|
||||
if (!thread) return;
|
||||
this.renameAutoNameGeneration += 1;
|
||||
this.renameAutoNameThreadId = null;
|
||||
this.renameThreadId = threadId;
|
||||
this.renameDraft = getThreadTitle(thread);
|
||||
this.renameState = { kind: "editing", threadId, draft: getThreadTitle(thread) };
|
||||
this.host.render();
|
||||
}
|
||||
|
||||
updateDraft(threadId: string, value: string): void {
|
||||
if (this.renameThreadId !== threadId) return;
|
||||
this.renameDraft = value;
|
||||
if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId) return;
|
||||
this.renameState = { ...this.renameState, draft: value };
|
||||
this.host.render();
|
||||
}
|
||||
|
||||
cancel(threadId: string): void {
|
||||
if (this.renameThreadId !== threadId) return;
|
||||
if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId) return;
|
||||
this.clear();
|
||||
this.host.render();
|
||||
}
|
||||
|
||||
async save(threadId: string, value: string): Promise<void> {
|
||||
if (this.renameThreadId !== threadId || this.renameAutoNameThreadId === threadId) return;
|
||||
if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId || this.renameState.kind === "generating") return;
|
||||
const editingState = this.renameState;
|
||||
const title = value.trim();
|
||||
if (!title) {
|
||||
this.cancel(threadId);
|
||||
|
|
@ -96,6 +98,7 @@ export class ThreadRenameController {
|
|||
}
|
||||
|
||||
await this.host.ensureConnected();
|
||||
if (this.renameState !== editingState) return;
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
|
|
@ -115,13 +118,20 @@ export class ThreadRenameController {
|
|||
}
|
||||
|
||||
async autoNameDraft(threadId: string): Promise<void> {
|
||||
if (this.renameThreadId !== threadId || this.renameAutoNameThreadId === threadId) return;
|
||||
if (this.renameState.kind !== "editing" || this.renameState.threadId !== threadId) return;
|
||||
|
||||
const editingState = this.renameState;
|
||||
|
||||
await this.host.ensureConnected();
|
||||
const generation = this.renameAutoNameGeneration + 1;
|
||||
const draftBeforeGeneration = this.renameDraft;
|
||||
this.renameAutoNameGeneration = generation;
|
||||
this.renameAutoNameThreadId = threadId;
|
||||
if (this.renameState !== editingState) return;
|
||||
|
||||
const generatingState: RenameLifecycleState = {
|
||||
kind: "generating",
|
||||
threadId,
|
||||
draft: editingState.draft,
|
||||
originalDraft: editingState.draft,
|
||||
};
|
||||
this.renameState = generatingState;
|
||||
this.host.render();
|
||||
|
||||
try {
|
||||
|
|
@ -129,18 +139,15 @@ export class ThreadRenameController {
|
|||
if (!context) throw new Error(THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
const title = await this.generateTitle(context);
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
if (this.renameThreadId !== threadId || this.renameAutoNameGeneration !== generation) return;
|
||||
if (this.renameDraft !== draftBeforeGeneration) return;
|
||||
this.renameDraft = title;
|
||||
if (this.renameState !== generatingState) return;
|
||||
if (this.renameState.draft !== generatingState.originalDraft) return;
|
||||
this.renameState = { kind: "generating", threadId, draft: title, originalDraft: generatingState.originalDraft };
|
||||
} catch (error) {
|
||||
if (this.renameThreadId === threadId && this.renameAutoNameGeneration === generation) {
|
||||
if (this.renameState === generatingState) {
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
if (this.renameThreadId === threadId && this.renameAutoNameGeneration === generation) {
|
||||
this.renameAutoNameThreadId = null;
|
||||
this.host.render();
|
||||
}
|
||||
this.finishAutoNameDraftGeneration(threadId, generatingState);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +198,7 @@ export class ThreadRenameController {
|
|||
}
|
||||
|
||||
private async generateTitle(context: ThreadNamingContext): Promise<string | null> {
|
||||
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
|
||||
const settings = this.host.settings();
|
||||
return generateThreadTitleWithCodex(settings.codexPath, this.host.vaultPath, context, {
|
||||
threadNamingModel: settings.threadNamingModel,
|
||||
|
|
@ -199,10 +207,20 @@ export class ThreadRenameController {
|
|||
}
|
||||
|
||||
private clear(): void {
|
||||
this.renameAutoNameGeneration += 1;
|
||||
this.renameThreadId = null;
|
||||
this.renameDraft = "";
|
||||
this.renameAutoNameThreadId = null;
|
||||
this.renameState = { kind: "idle" };
|
||||
}
|
||||
|
||||
private finishAutoNameDraftGeneration(threadId: string, generatingState: RenameGeneratingState): void {
|
||||
if (this.renameState === generatingState) {
|
||||
this.renameState = { kind: "editing", threadId, draft: generatingState.draft };
|
||||
this.host.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.renameState;
|
||||
if (currentState.kind !== "generating" || currentState.threadId !== threadId) return;
|
||||
this.renameState = { kind: "editing", threadId, draft: currentState.draft };
|
||||
this.host.render();
|
||||
}
|
||||
|
||||
private threadHasName(threadId: string): boolean {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export interface ThreadsRowModel {
|
|||
archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean };
|
||||
}
|
||||
|
||||
export type ThreadsRenameState = { kind: "editing"; draft: string } | { kind: "generating"; draft: string; originalDraft: string };
|
||||
|
||||
const STATUS_PRIORITY: Record<ThreadsLiveStatus, number> = {
|
||||
"needs-input": 5,
|
||||
approval: 4,
|
||||
|
|
@ -31,8 +33,7 @@ const STATUS_PRIORITY: Record<ThreadsLiveStatus, number> = {
|
|||
export function threadRows(
|
||||
threads: readonly Thread[],
|
||||
snapshots: OpenCodexPanelSnapshot[],
|
||||
renameDrafts: ReadonlyMap<string, string>,
|
||||
autoNameThreadId: string | null = null,
|
||||
renameStates: ReadonlyMap<string, ThreadsRenameState>,
|
||||
archiveConfirmThreadId: string | null = null,
|
||||
defaultArchiveSaveMarkdown = false,
|
||||
): ThreadsRowModel[] {
|
||||
|
|
@ -41,14 +42,15 @@ export function threadRows(
|
|||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((thread) => {
|
||||
const live = liveStateForSnapshots(snapshotsByThread.get(thread.id) ?? []);
|
||||
const rename = renameStates.get(thread.id);
|
||||
return {
|
||||
thread,
|
||||
title: getThreadTitle(thread),
|
||||
live,
|
||||
rename: {
|
||||
active: renameDrafts.has(thread.id),
|
||||
draft: renameDrafts.get(thread.id) ?? thread.name ?? getThreadTitle(thread),
|
||||
generating: autoNameThreadId === thread.id,
|
||||
active: rename !== undefined,
|
||||
draft: rename?.draft ?? thread.name ?? getThreadTitle(thread),
|
||||
generating: rename?.kind === "generating",
|
||||
},
|
||||
archiveConfirm: {
|
||||
active: archiveConfirmThreadId === thread.id,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
|
|||
import { findThreadNamingContext, THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE } from "../../domain/threads/naming";
|
||||
import { generateThreadTitleWithCodex } from "../../app-server/thread-naming";
|
||||
import { renderThreadsView, unmountThreadsView } from "./renderer";
|
||||
import { threadRows } from "./state";
|
||||
import { threadRows, type ThreadsRenameState } from "./state";
|
||||
|
||||
export interface CodexThreadsHost {
|
||||
readonly settings: CodexPanelSettings;
|
||||
|
|
@ -35,9 +35,7 @@ export class CodexThreadsView extends ItemView {
|
|||
private status: string | null = null;
|
||||
private loading = false;
|
||||
private threads: readonly Thread[] = [];
|
||||
private readonly renameDrafts = new Map<string, string>();
|
||||
private renameAutoNameThreadId: string | null = null;
|
||||
private renameAutoNameGeneration = 0;
|
||||
private readonly renameStates = new Map<string, ThreadsRenameState>();
|
||||
private archiveConfirmThreadId: string | null = null;
|
||||
|
||||
constructor(
|
||||
|
|
@ -168,8 +166,7 @@ export class CodexThreadsView extends ItemView {
|
|||
rows: threadRows(
|
||||
this.threads,
|
||||
this.plugin.getOpenPanelSnapshots(),
|
||||
this.renameDrafts,
|
||||
this.renameAutoNameThreadId,
|
||||
this.renameStates,
|
||||
this.archiveConfirmThreadId,
|
||||
this.plugin.settings.archiveExportEnabled,
|
||||
),
|
||||
|
|
@ -234,26 +231,24 @@ export class CodexThreadsView extends ItemView {
|
|||
|
||||
private startRename(threadId: string, value: string): void {
|
||||
this.archiveConfirmThreadId = null;
|
||||
this.renameAutoNameGeneration += 1;
|
||||
this.renameAutoNameThreadId = null;
|
||||
this.renameDrafts.set(threadId, value);
|
||||
this.renameStates.set(threadId, { kind: "editing", draft: value });
|
||||
this.render();
|
||||
}
|
||||
|
||||
private updateRename(threadId: string, value: string): void {
|
||||
this.renameDrafts.set(threadId, value);
|
||||
const current = this.renameStates.get(threadId);
|
||||
this.renameStates.set(threadId, current?.kind === "generating" ? { ...current, draft: value } : { kind: "editing", draft: value });
|
||||
this.render();
|
||||
}
|
||||
|
||||
private cancelRename(threadId: string): void {
|
||||
if (this.renameAutoNameThreadId === threadId) this.renameAutoNameGeneration += 1;
|
||||
if (this.renameAutoNameThreadId === threadId) this.renameAutoNameThreadId = null;
|
||||
this.renameDrafts.delete(threadId);
|
||||
this.renameStates.delete(threadId);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async saveRename(threadId: string, value: string): Promise<void> {
|
||||
if (this.renameAutoNameThreadId === threadId) return;
|
||||
const editingState = this.renameStates.get(threadId);
|
||||
if (!editingState || editingState.kind === "generating") return;
|
||||
const name = value.trim();
|
||||
if (!name) {
|
||||
this.cancelRename(threadId);
|
||||
|
|
@ -261,9 +256,10 @@ export class CodexThreadsView extends ItemView {
|
|||
}
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.renameStates.get(threadId) !== editingState) return;
|
||||
if (!this.client) return;
|
||||
await this.client.setThreadName(threadId, name);
|
||||
this.renameDrafts.delete(threadId);
|
||||
this.renameStates.delete(threadId);
|
||||
this.plugin.notifyThreadRenamed(threadId, name);
|
||||
} catch (error) {
|
||||
this.status = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -272,16 +268,20 @@ export class CodexThreadsView extends ItemView {
|
|||
}
|
||||
|
||||
private async autoNameThread(threadId: string): Promise<void> {
|
||||
if (!this.renameDrafts.has(threadId) || this.renameAutoNameThreadId === threadId) return;
|
||||
const editingState = this.renameStates.get(threadId);
|
||||
if (!editingState || editingState.kind === "generating") return;
|
||||
|
||||
const draftBeforeGeneration = this.renameDrafts.get(threadId) ?? "";
|
||||
const generation = this.renameAutoNameGeneration + 1;
|
||||
this.renameAutoNameGeneration = generation;
|
||||
this.renameAutoNameThreadId = threadId;
|
||||
const generatingState: ThreadsRenameState = {
|
||||
kind: "generating",
|
||||
draft: editingState.draft,
|
||||
originalDraft: editingState.draft,
|
||||
};
|
||||
this.renameStates.set(threadId, generatingState);
|
||||
this.render();
|
||||
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.renameStates.get(threadId) !== generatingState) return;
|
||||
if (!this.client) return;
|
||||
const client = this.client;
|
||||
const context = await findThreadNamingContext({
|
||||
|
|
@ -294,18 +294,16 @@ export class CodexThreadsView extends ItemView {
|
|||
threadNamingEffort: this.plugin.settings.threadNamingEffort,
|
||||
});
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
if (this.renameAutoNameGeneration !== generation || !this.renameDrafts.has(threadId)) return;
|
||||
if (this.renameDrafts.get(threadId) !== draftBeforeGeneration) return;
|
||||
this.renameDrafts.set(threadId, title);
|
||||
const current = this.renameStates.get(threadId);
|
||||
if (current !== generatingState) return;
|
||||
if (current.draft !== generatingState.originalDraft) return;
|
||||
this.renameStates.set(threadId, { ...generatingState, draft: title });
|
||||
} catch (error) {
|
||||
if (this.renameAutoNameGeneration === generation && this.renameDrafts.has(threadId)) {
|
||||
if (this.renameStates.get(threadId) === generatingState) {
|
||||
this.status = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
if (this.renameAutoNameGeneration === generation && this.renameAutoNameThreadId === threadId) {
|
||||
this.renameAutoNameThreadId = null;
|
||||
this.render();
|
||||
}
|
||||
this.finishAutoNameThread(threadId, generatingState);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,11 +339,19 @@ export class CodexThreadsView extends ItemView {
|
|||
}
|
||||
await this.client.archiveThread(threadId);
|
||||
if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null;
|
||||
this.renameDrafts.delete(threadId);
|
||||
this.renameStates.delete(threadId);
|
||||
this.plugin.notifyThreadArchived(threadId);
|
||||
} catch (error) {
|
||||
this.status = error instanceof Error ? error.message : String(error);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private finishAutoNameThread(threadId: string, generatingState: ThreadsRenameState): void {
|
||||
const current = this.renameStates.get(threadId);
|
||||
if (current?.kind !== "generating") return;
|
||||
const draft = current === generatingState ? generatingState.draft : current.draft;
|
||||
this.renameStates.set(threadId, { kind: "editing", draft });
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,15 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { createChatStateStore } from "../../../src/features/chat/chat-state";
|
||||
import { ThreadRenameController } from "../../../src/features/chat/thread-rename";
|
||||
import type { AppServerClient } from "../../../src/app-server/client";
|
||||
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
|
||||
import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem";
|
||||
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
|
||||
describe("ThreadRenameController", () => {
|
||||
it("rerenders after updating a controlled rename draft", () => {
|
||||
const stateStore = createChatStateStore();
|
||||
stateStore.dispatch({ type: "thread/list-applied", threads: [threadFixture("thread")] });
|
||||
const render = vi.fn();
|
||||
const controller = new ThreadRenameController({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
settings: () => DEFAULT_SETTINGS,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
currentClient: () => null,
|
||||
refreshThreads: vi.fn().mockResolvedValue(undefined),
|
||||
render,
|
||||
addSystemMessage: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
});
|
||||
const { controller, render } = controllerFixture();
|
||||
|
||||
controller.start("thread");
|
||||
render.mockClear();
|
||||
|
|
@ -29,8 +19,106 @@ describe("ThreadRenameController", () => {
|
|||
expect(render).toHaveBeenCalledOnce();
|
||||
expect(controller.editState("thread")).toEqual({ draft: "New name", generating: false });
|
||||
});
|
||||
|
||||
it("applies generated rename drafts and clears the generating state", async () => {
|
||||
const generateThreadTitle = vi.fn().mockResolvedValue("Generated title");
|
||||
const { controller } = controllerFixture({ generateThreadTitle });
|
||||
|
||||
controller.start("thread");
|
||||
await controller.autoNameDraft("thread");
|
||||
|
||||
expect(generateThreadTitle).toHaveBeenCalledOnce();
|
||||
expect(controller.editState("thread")).toEqual({ draft: "Generated title", 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");
|
||||
const { controller } = controllerFixture({
|
||||
ensureConnected: vi.fn(() => connection.promise),
|
||||
generateThreadTitle,
|
||||
});
|
||||
|
||||
controller.start("thread");
|
||||
const autoName = controller.autoNameDraft("thread");
|
||||
controller.cancel("thread");
|
||||
connection.resolve(undefined);
|
||||
await autoName;
|
||||
|
||||
expect(generateThreadTitle).not.toHaveBeenCalled();
|
||||
expect(controller.editState("thread")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not save after cancellation while connection is pending", async () => {
|
||||
const connection = deferred<undefined>();
|
||||
const setThreadName = vi.fn().mockResolvedValue({});
|
||||
const client = fakeClient({ setThreadName });
|
||||
const { controller } = controllerFixture({
|
||||
ensureConnected: vi.fn(() => connection.promise),
|
||||
currentClient: () => client,
|
||||
});
|
||||
|
||||
controller.start("thread");
|
||||
const save = controller.save("thread", "Saved title");
|
||||
controller.cancel("thread");
|
||||
connection.resolve(undefined);
|
||||
await save;
|
||||
|
||||
expect(setThreadName).not.toHaveBeenCalled();
|
||||
expect(controller.editState("thread")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an edited draft when auto-name generation finishes later", async () => {
|
||||
const generatedTitle = deferred<string | null>();
|
||||
const { controller } = controllerFixture({
|
||||
generateThreadTitle: vi.fn(() => generatedTitle.promise),
|
||||
});
|
||||
|
||||
controller.start("thread");
|
||||
const autoName = controller.autoNameDraft("thread");
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.editState("thread")).toEqual({ draft: "Thread preview", generating: true });
|
||||
|
||||
controller.updateDraft("thread", "Manual draft");
|
||||
generatedTitle.resolve("Generated title");
|
||||
await autoName;
|
||||
|
||||
expect(controller.editState("thread")).toEqual({ draft: "Manual draft", generating: false });
|
||||
});
|
||||
});
|
||||
|
||||
function controllerFixture(
|
||||
overrides: Partial<ConstructorParameters<typeof ThreadRenameController>[0]> = {},
|
||||
): ConstructorParameters<typeof ThreadRenameController>[0] & { controller: ThreadRenameController; render: ReturnType<typeof vi.fn> } {
|
||||
const stateStore = createChatStateStore();
|
||||
stateStore.dispatch({ type: "thread/list-applied", threads: [threadFixture("thread")] });
|
||||
const render = vi.fn();
|
||||
const host = {
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
settings: () => DEFAULT_SETTINGS,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
currentClient: () => fakeClient(),
|
||||
refreshThreads: vi.fn().mockResolvedValue(undefined),
|
||||
render,
|
||||
addSystemMessage: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
...overrides,
|
||||
} satisfies ConstructorParameters<typeof ThreadRenameController>[0];
|
||||
return { ...host, controller: new ThreadRenameController(host), render };
|
||||
}
|
||||
|
||||
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
|
||||
return {
|
||||
setThreadName: options.setThreadName ?? vi.fn().mockResolvedValue({}),
|
||||
threadTurnsList: vi.fn().mockResolvedValue({
|
||||
data: [turnFixture([userMessage("user", "Please name this."), assistantMessage("assistant", "Done.")])],
|
||||
nextCursor: null,
|
||||
}),
|
||||
} as unknown as AppServerClient;
|
||||
}
|
||||
|
||||
function threadFixture(id: string): Thread {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -54,3 +142,39 @@ function threadFixture(id: string): Thread {
|
|||
turns: [],
|
||||
};
|
||||
}
|
||||
|
||||
function turnFixture(items: ThreadItem[]): Turn {
|
||||
return {
|
||||
id: "turn",
|
||||
items,
|
||||
itemsView: "full",
|
||||
status: "completed",
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
completedAt: 2,
|
||||
durationMs: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function userMessage(id: string, text: string): ThreadItem {
|
||||
return { type: "userMessage", id, content: [{ type: "text", text, text_elements: [] }] };
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string): ThreadItem {
|
||||
return { type: "agentMessage", id, text, phase: null, memoryCitation: null };
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,6 +310,76 @@ describe("CodexThreadsView", () => {
|
|||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Threads rename UI");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not revive threads view auto-name after cancellation", async () => {
|
||||
const title = deferred<string | null>();
|
||||
namingMock.generateThreadTitleWithCodex.mockReturnValue(title.promise);
|
||||
connectionMock.state.client = clientFixture({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
threadTurnsList: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
turnFixture([
|
||||
{ type: "userMessage", id: "u1", content: [{ type: "text", text: "rename", text_elements: [] }] },
|
||||
{ type: "agentMessage", id: "a1", text: "done", phase: "final_answer", memoryCitation: null },
|
||||
]),
|
||||
],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
const view = await threadsView();
|
||||
|
||||
await view.refresh();
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
view.containerEl
|
||||
.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")
|
||||
?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
title.resolve("Late title");
|
||||
await flushPromises();
|
||||
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a manually edited threads view draft when auto-name finishes later", async () => {
|
||||
const title = deferred<string | null>();
|
||||
namingMock.generateThreadTitleWithCodex.mockReturnValue(title.promise);
|
||||
connectionMock.state.client = clientFixture({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
threadTurnsList: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
turnFixture([
|
||||
{ type: "userMessage", id: "u1", content: [{ type: "text", text: "rename", text_elements: [] }] },
|
||||
{ type: "agentMessage", id: "a1", text: "done", phase: "final_answer", memoryCitation: null },
|
||||
]),
|
||||
],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
const view = await threadsView();
|
||||
|
||||
await view.refresh();
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
|
||||
expect(input).not.toBeNull();
|
||||
if (!input) return;
|
||||
|
||||
changeInputValue(input, "Manual draft");
|
||||
title.resolve("Late title");
|
||||
await vi.waitFor(() => {
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Manual draft");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clientFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
|
|
@ -395,3 +465,18 @@ function turnFixture(items: Turn["items"], overrides: Partial<Turn> = {}): Turn
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue