mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract threads view rename state logic
This commit is contained in:
parent
021c2f018f
commit
505b88cb66
4 changed files with 95 additions and 96 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
tests/features/threads-view/state.test.ts
Normal file
42
tests/features/threads-view/state.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue