State-machine rename lifecycles

This commit is contained in:
murashit 2026-05-28 19:43:21 +09:00
parent cca4b03958
commit e12cf67096
5 changed files with 316 additions and 81 deletions

View file

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

View file

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

View file

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

View file

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

View file

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