Extract threads view rename state logic

This commit is contained in:
murashit 2026-05-30 09:45:20 +09:00
parent 021c2f018f
commit 505b88cb66
4 changed files with 95 additions and 96 deletions

View file

@ -20,6 +20,7 @@ export interface ThreadsRowModel {
}
export type ThreadsRenameState = { kind: "editing"; draft: string } | { kind: "generating"; draft: string; originalDraft: string };
export type ThreadsGeneratingRenameState = Extract<ThreadsRenameState, { kind: "generating" }>;
const STATUS_PRIORITY: Record<ThreadsLiveStatus, number> = {
"needs-input": 5,
@ -74,6 +75,38 @@ export function liveStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): Thre
};
}
export function editingThreadRenameState(draft: string): ThreadsRenameState {
return { kind: "editing", draft };
}
export function updatedThreadRenameState(current: ThreadsRenameState | undefined, draft: string): ThreadsRenameState {
return current?.kind === "generating" ? { ...current, draft } : editingThreadRenameState(draft);
}
export function startedThreadAutoNameState(current: ThreadsRenameState | undefined): ThreadsGeneratingRenameState | null {
if (!current || current.kind === "generating") return null;
return { kind: "generating", draft: current.draft, originalDraft: current.draft };
}
export function generatedThreadAutoNameState(
current: ThreadsRenameState | undefined,
generatingState: ThreadsGeneratingRenameState,
title: string,
): ThreadsRenameState | null {
if (current !== generatingState) return null;
if (current.draft !== generatingState.originalDraft) return null;
return { ...generatingState, draft: title };
}
export function completedThreadAutoNameState(
current: ThreadsRenameState | undefined,
generatingState: ThreadsGeneratingRenameState,
): ThreadsRenameState | undefined {
if (current?.kind !== "generating") return undefined;
const draft = current === generatingState ? generatingState.draft : current.draft;
return editingThreadRenameState(draft);
}
function snapshotsForThreads(snapshots: OpenCodexPanelSnapshot[]): Map<string, OpenCodexPanelSnapshot[]> {
const map = new Map<string, OpenCodexPanelSnapshot[]>();
for (const snapshot of snapshots) {

View file

@ -10,7 +10,16 @@ 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, type ThreadsRenameState } from "./state";
import {
completedThreadAutoNameState,
editingThreadRenameState,
generatedThreadAutoNameState,
startedThreadAutoNameState,
threadRows,
updatedThreadRenameState,
type ThreadsGeneratingRenameState,
type ThreadsRenameState,
} from "./state";
import {
ThreadsViewDeferredTasks,
transitionThreadsViewConnectionLifecycle,
@ -265,13 +274,12 @@ export class CodexThreadsView extends ItemView {
private startRename(threadId: string, value: string): void {
this.archiveConfirmThreadId = null;
this.renameStates.set(threadId, { kind: "editing", draft: value });
this.renameStates.set(threadId, editingThreadRenameState(value));
this.render();
}
private updateRename(threadId: string, value: string): void {
const current = this.renameStates.get(threadId);
this.renameStates.set(threadId, current?.kind === "generating" ? { ...current, draft: value } : { kind: "editing", draft: value });
this.renameStates.set(threadId, updatedThreadRenameState(this.renameStates.get(threadId), value));
this.render();
}
@ -302,14 +310,8 @@ export class CodexThreadsView extends ItemView {
}
private async autoNameThread(threadId: string): Promise<void> {
const editingState = this.renameStates.get(threadId);
if (!editingState || editingState.kind === "generating") return;
const generatingState: ThreadsRenameState = {
kind: "generating",
draft: editingState.draft,
originalDraft: editingState.draft,
};
const generatingState = startedThreadAutoNameState(this.renameStates.get(threadId));
if (!generatingState) return;
this.renameStates.set(threadId, generatingState);
this.render();
@ -328,10 +330,8 @@ export class CodexThreadsView extends ItemView {
threadNamingEffort: this.plugin.settings.threadNamingEffort,
});
if (!title) throw new Error("Codex did not return a usable thread title.");
const current = this.renameStates.get(threadId);
if (current !== generatingState) return;
if (current.draft !== generatingState.originalDraft) return;
this.renameStates.set(threadId, { ...generatingState, draft: title });
const renamedState = generatedThreadAutoNameState(this.renameStates.get(threadId), generatingState, title);
if (renamedState) this.renameStates.set(threadId, renamedState);
} catch (error) {
if (this.renameStates.get(threadId) === generatingState) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
@ -381,11 +381,10 @@ export class CodexThreadsView extends ItemView {
}
}
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 });
private finishAutoNameThread(threadId: string, generatingState: ThreadsGeneratingRenameState): void {
const nextState = completedThreadAutoNameState(this.renameStates.get(threadId), generatingState);
if (!nextState) return;
this.renameStates.set(threadId, nextState);
this.render();
}
}

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
completedThreadAutoNameState,
editingThreadRenameState,
generatedThreadAutoNameState,
startedThreadAutoNameState,
updatedThreadRenameState,
} from "../../../src/features/threads-view/state";
describe("threads view rename state", () => {
it("keeps a late auto-name result from reviving a cancelled rename", () => {
const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft"));
expect(generating).not.toBeNull();
if (!generating) throw new Error("Expected generating state");
expect(generatedThreadAutoNameState(undefined, generating, "Late title")).toBeNull();
expect(completedThreadAutoNameState(undefined, generating)).toBeUndefined();
});
it("keeps a manually edited draft when auto-name finishes later", () => {
const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft"));
expect(generating).not.toBeNull();
if (!generating) throw new Error("Expected generating state");
const manuallyEdited = updatedThreadRenameState(generating, "Manual draft");
expect(generatedThreadAutoNameState(manuallyEdited, generating, "Late title")).toBeNull();
expect(completedThreadAutoNameState(manuallyEdited, generating)).toEqual({ kind: "editing", draft: "Manual draft" });
});
it("applies generated titles only to the active unchanged generation", () => {
const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft"));
expect(generating).not.toBeNull();
if (!generating) throw new Error("Expected generating state");
const generated = generatedThreadAutoNameState(generating, generating, "Generated title");
expect(generated).toEqual({ kind: "generating", draft: "Generated title", originalDraft: "Original draft" });
expect(completedThreadAutoNameState(generated ?? undefined, generating)).toEqual({ kind: "editing", draft: "Generated title" });
});
});

View file

@ -337,76 +337,6 @@ 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 waitForAsyncWork(() => {
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 waitForAsyncWork(() => {
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 waitForAsyncWork(() => {
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Manual draft");
});
});
});
function clientFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
@ -502,8 +432,3 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reje
});
return { promise, resolve, reject };
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}