From 505b88cb66f8ad153ec264b4c5afaa01706edc9e Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 30 May 2026 09:45:20 +0900 Subject: [PATCH] Extract threads view rename state logic --- src/features/threads-view/state.ts | 33 ++++++++++ src/features/threads-view/view.ts | 41 ++++++------- tests/features/threads-view/state.test.ts | 42 +++++++++++++ tests/features/threads-view/view.test.ts | 75 ----------------------- 4 files changed, 95 insertions(+), 96 deletions(-) create mode 100644 tests/features/threads-view/state.test.ts diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index fef7daf7..c0ba797e 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -20,6 +20,7 @@ export interface ThreadsRowModel { } export type ThreadsRenameState = { kind: "editing"; draft: string } | { kind: "generating"; draft: string; originalDraft: string }; +export type ThreadsGeneratingRenameState = Extract; const STATUS_PRIORITY: Record = { "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 { const map = new Map(); for (const snapshot of snapshots) { diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index 12fff29d..8697a24d 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -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 { - 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(); } } diff --git a/tests/features/threads-view/state.test.ts b/tests/features/threads-view/state.test.ts new file mode 100644 index 00000000..e7f7d880 --- /dev/null +++ b/tests/features/threads-view/state.test.ts @@ -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" }); + }); +}); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index e592afda..f1aca087 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -337,76 +337,6 @@ describe("CodexThreadsView", () => { expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.value).toBe("Threads rename UI"); }); }); - - it("does not revive threads view auto-name after cancellation", async () => { - const title = deferred(); - 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('[aria-label="Rename thread"]')?.click(); - view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.click(); - - await waitForAsyncWork(() => { - expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); - }); - - view.containerEl - .querySelector(".codex-panel-threads__rename-input") - ?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" })); - title.resolve("Late title"); - await flushPromises(); - - expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")).toBeNull(); - }); - - it("keeps a manually edited threads view draft when auto-name finishes later", async () => { - const title = deferred(); - 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('[aria-label="Rename thread"]')?.click(); - view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.click(); - - await waitForAsyncWork(() => { - expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); - }); - - const input = view.containerEl.querySelector(".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(".codex-panel-threads__rename-input")?.value).toBe("Manual draft"); - }); - }); }); function clientFixture(overrides: Record = {}): Record { @@ -502,8 +432,3 @@ function deferred(): { promise: Promise; resolve: (value: T) => void; reje }); return { promise, resolve, reject }; } - -async function flushPromises(): Promise { - await Promise.resolve(); - await Promise.resolve(); -}