mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add auto-name to threads view rename
This commit is contained in:
parent
7cebbbcb80
commit
ee5b797f5b
8 changed files with 330 additions and 57 deletions
|
|
@ -2,6 +2,7 @@ import { setIcon } from "obsidian";
|
|||
|
||||
import type { EffectiveConfigSection, RateLimitSummary } from "../../../runtime/view";
|
||||
import { createToolbarButton } from "../../../shared/ui/components";
|
||||
import { createInlineRenameEditor } from "../../../shared/ui/inline-rename";
|
||||
import { renderEffectiveConfig } from "./config";
|
||||
|
||||
export type ToolbarPanelKind = "history" | "status" | "runtime";
|
||||
|
|
@ -373,39 +374,28 @@ function renderThreadList(parent: HTMLElement, threads: ToolbarThreadRow[], acti
|
|||
|
||||
function renderThreadRenameRow(parent: HTMLElement, thread: ToolbarThreadRow, actions: ToolbarActions): void {
|
||||
const form = parent.createDiv({ cls: "codex-panel__thread-rename" });
|
||||
const input = form.createEl("input", {
|
||||
cls: "codex-panel__thread-rename-input",
|
||||
attr: {
|
||||
type: "text",
|
||||
value: thread.rename?.draft ?? thread.title,
|
||||
"aria-label": `Rename ${thread.title}`,
|
||||
const editor = createInlineRenameEditor(form, {
|
||||
className: "codex-panel__thread-rename-input",
|
||||
value: thread.rename?.draft ?? thread.title,
|
||||
ariaLabel: `Rename ${thread.title}`,
|
||||
onUpdate: (value) => {
|
||||
actions.updateRenameDraft(thread.threadId, value);
|
||||
},
|
||||
});
|
||||
input.oninput = () => {
|
||||
actions.updateRenameDraft(thread.threadId, input.value);
|
||||
};
|
||||
input.onkeydown = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (!thread.rename?.generating) actions.saveRenameThread(thread.threadId, input.value);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onSave: (value) => {
|
||||
actions.saveRenameThread(thread.threadId, value);
|
||||
},
|
||||
onCancel: () => {
|
||||
actions.cancelRenameThread(thread.threadId);
|
||||
}
|
||||
};
|
||||
input.win.setTimeout(() => {
|
||||
if (input.ownerDocument.activeElement !== input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
canSave: () => !(thread.rename?.generating ?? false),
|
||||
});
|
||||
|
||||
const save = createToolbarButton(form, "check", "Save thread name");
|
||||
save.addClass("codex-panel__thread-action");
|
||||
save.disabled = thread.rename?.generating ?? false;
|
||||
save.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
actions.saveRenameThread(thread.threadId, input.value);
|
||||
actions.saveRenameThread(thread.threadId, editor.value());
|
||||
};
|
||||
|
||||
const cancel = createToolbarButton(form, "x", "Cancel rename");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { setIcon } from "obsidian";
|
||||
|
||||
import { createInlineRenameEditor } from "../../shared/ui/inline-rename";
|
||||
import type { ThreadsRowModel } from "./state";
|
||||
|
||||
export interface ThreadsViewModel {
|
||||
|
|
@ -16,6 +17,7 @@ export interface ThreadsViewActions {
|
|||
updateRename: (threadId: string, value: string) => void;
|
||||
saveRename: (threadId: string, value: string) => void;
|
||||
cancelRename: (threadId: string) => void;
|
||||
autoNameThread: (threadId: string) => void;
|
||||
archiveThread: (threadId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -55,10 +57,12 @@ function renderThreadRow(parent: HTMLElement, row: ThreadsRowModel, actions: Thr
|
|||
attr: { role: "button", tabindex: "0", "aria-label": `Open thread: ${row.title}` },
|
||||
});
|
||||
if (row.live) item.addClass(`codex-panel-threads__row--${row.live.status}`);
|
||||
if (row.rename.active) item.addClass("codex-panel-threads__row--renaming");
|
||||
item.onclick = () => {
|
||||
actions.openThread(row.thread.id);
|
||||
};
|
||||
item.onkeydown = (event) => {
|
||||
if (row.rename.active) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
actions.openThread(row.thread.id);
|
||||
|
|
@ -89,32 +93,37 @@ function renderRenameRow(parent: HTMLElement, row: ThreadsRowModel, actions: Thr
|
|||
parent.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
const input = parent.createEl("input", {
|
||||
cls: "codex-panel-threads__rename-input",
|
||||
attr: { type: "text", value: row.rename.draft, "aria-label": "Thread name" },
|
||||
});
|
||||
input.oninput = () => {
|
||||
actions.updateRename(row.thread.id, input.value);
|
||||
};
|
||||
input.onkeydown = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
actions.saveRename(row.thread.id, input.value);
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
const form = parent.createDiv({ cls: "codex-panel-threads__rename-form" });
|
||||
const editor = createInlineRenameEditor(form, {
|
||||
className: "codex-panel-threads__rename-input",
|
||||
value: row.rename.draft,
|
||||
ariaLabel: "Thread name",
|
||||
onUpdate: (value) => {
|
||||
actions.updateRename(row.thread.id, value);
|
||||
},
|
||||
onSave: (value) => {
|
||||
actions.saveRename(row.thread.id, value);
|
||||
},
|
||||
onCancel: () => {
|
||||
actions.cancelRename(row.thread.id);
|
||||
}
|
||||
};
|
||||
},
|
||||
canSave: () => !row.rename.generating,
|
||||
});
|
||||
|
||||
const save = iconButton(parent, "check", "Save thread name", "codex-panel-threads__row-button");
|
||||
const save = iconButton(form, "check", "Save thread name", "codex-panel-threads__row-button");
|
||||
save.disabled = row.rename.generating;
|
||||
save.onclick = () => {
|
||||
actions.saveRename(row.thread.id, input.value);
|
||||
actions.saveRename(row.thread.id, editor.value());
|
||||
};
|
||||
const cancel = iconButton(parent, "x", "Cancel rename", "codex-panel-threads__row-button");
|
||||
const cancel = iconButton(form, "x", "Cancel rename", "codex-panel-threads__row-button");
|
||||
cancel.onclick = () => {
|
||||
actions.cancelRename(row.thread.id);
|
||||
};
|
||||
input.focus();
|
||||
input.select();
|
||||
const autoName = iconButton(form, row.rename.generating ? "loader" : "sparkles", "Auto-name thread", "codex-panel-threads__row-button");
|
||||
autoName.disabled = row.rename.generating;
|
||||
autoName.onclick = () => {
|
||||
actions.autoNameThread(row.thread.id);
|
||||
};
|
||||
}
|
||||
|
||||
function iconButton(parent: HTMLElement, icon: string, label: string, className: string): HTMLButtonElement {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export interface ThreadsRowModel {
|
|||
thread: Thread;
|
||||
title: string;
|
||||
live: ThreadsLiveState | null;
|
||||
rename: { active: boolean; draft: string };
|
||||
rename: { active: boolean; draft: string; generating: boolean };
|
||||
}
|
||||
|
||||
const STATUS_PRIORITY: Record<ThreadsLiveStatus, number> = {
|
||||
|
|
@ -30,6 +30,7 @@ export function threadRows(
|
|||
threads: Thread[],
|
||||
snapshots: OpenCodexPanelSnapshot[],
|
||||
renameDrafts: ReadonlyMap<string, string>,
|
||||
autoNameThreadId: string | null = null,
|
||||
): ThreadsRowModel[] {
|
||||
const snapshotsByThread = snapshotsForThreads(snapshots);
|
||||
return [...threads]
|
||||
|
|
@ -43,6 +44,7 @@ export function threadRows(
|
|||
rename: {
|
||||
active: renameDrafts.has(thread.id),
|
||||
draft: renameDrafts.get(thread.id) ?? thread.name ?? getThreadTitle(thread),
|
||||
generating: autoNameThreadId === thread.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { Thread } from "../../generated/app-server/v2/Thread";
|
|||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import { exportArchivedThreadMarkdown } from "../../domain/threads/export";
|
||||
import type { OpenCodexPanelSnapshot } from "../chat/panel-snapshot";
|
||||
import { findThreadNamingContext, generateThreadTitleWithCodex, THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE } from "../chat/thread-naming";
|
||||
import { renderThreadsView } from "./renderer";
|
||||
import { threadRows } from "./state";
|
||||
|
||||
|
|
@ -32,6 +33,8 @@ export class CodexThreadsView extends ItemView {
|
|||
private loading = false;
|
||||
private threads: Thread[] = [];
|
||||
private readonly renameDrafts = new Map<string, string>();
|
||||
private renameAutoNameThreadId: string | null = null;
|
||||
private renameAutoNameGeneration = 0;
|
||||
|
||||
constructor(
|
||||
leaf: WorkspaceLeaf,
|
||||
|
|
@ -146,7 +149,7 @@ export class CodexThreadsView extends ItemView {
|
|||
{
|
||||
status: this.status,
|
||||
loading: this.loading,
|
||||
rows: threadRows(this.threads, this.plugin.getOpenPanelSnapshots(), this.renameDrafts),
|
||||
rows: threadRows(this.threads, this.plugin.getOpenPanelSnapshots(), this.renameDrafts, this.renameAutoNameThreadId),
|
||||
},
|
||||
{
|
||||
refresh: () => void this.refresh(),
|
||||
|
|
@ -162,6 +165,7 @@ export class CodexThreadsView extends ItemView {
|
|||
cancelRename: (threadId) => {
|
||||
this.cancelRename(threadId);
|
||||
},
|
||||
autoNameThread: (threadId) => void this.autoNameThread(threadId),
|
||||
archiveThread: (threadId) => void this.archiveThread(threadId),
|
||||
},
|
||||
);
|
||||
|
|
@ -196,6 +200,8 @@ export class CodexThreadsView extends ItemView {
|
|||
}
|
||||
|
||||
private startRename(threadId: string, value: string): void {
|
||||
this.renameAutoNameGeneration += 1;
|
||||
this.renameAutoNameThreadId = null;
|
||||
this.renameDrafts.set(threadId, value);
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -205,11 +211,14 @@ export class CodexThreadsView extends ItemView {
|
|||
}
|
||||
|
||||
private cancelRename(threadId: string): void {
|
||||
if (this.renameAutoNameThreadId === threadId) this.renameAutoNameGeneration += 1;
|
||||
if (this.renameAutoNameThreadId === threadId) this.renameAutoNameThreadId = null;
|
||||
this.renameDrafts.delete(threadId);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async saveRename(threadId: string, value: string): Promise<void> {
|
||||
if (this.renameAutoNameThreadId === threadId) return;
|
||||
const name = value.trim();
|
||||
if (!name) {
|
||||
this.cancelRename(threadId);
|
||||
|
|
@ -227,6 +236,44 @@ export class CodexThreadsView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
private async autoNameThread(threadId: string): Promise<void> {
|
||||
if (!this.renameDrafts.has(threadId) || this.renameAutoNameThreadId === threadId) return;
|
||||
|
||||
const draftBeforeGeneration = this.renameDrafts.get(threadId) ?? "";
|
||||
const generation = this.renameAutoNameGeneration + 1;
|
||||
this.renameAutoNameGeneration = generation;
|
||||
this.renameAutoNameThreadId = threadId;
|
||||
this.render();
|
||||
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
if (!this.client) return;
|
||||
const client = this.client;
|
||||
const context = await findThreadNamingContext({
|
||||
threadId,
|
||||
readTurns: (id, cursor, limit, sortDirection) => client.threadTurnsList(id, cursor, limit, sortDirection),
|
||||
});
|
||||
if (!context) throw new Error(THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
const title = await generateThreadTitleWithCodex(this.plugin.settings.codexPath, this.plugin.vaultPath, context, {
|
||||
threadNamingModel: this.plugin.settings.threadNamingModel,
|
||||
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);
|
||||
} catch (error) {
|
||||
if (this.renameAutoNameGeneration === generation && this.renameDrafts.has(threadId)) {
|
||||
this.status = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
if (this.renameAutoNameGeneration === generation && this.renameAutoNameThreadId === threadId) {
|
||||
this.renameAutoNameThreadId = null;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async archiveThread(threadId: string): Promise<void> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
|
|
|||
91
src/shared/ui/inline-rename.ts
Normal file
91
src/shared/ui/inline-rename.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
export interface InlineRenameOptions {
|
||||
className: string;
|
||||
value: string;
|
||||
ariaLabel: string;
|
||||
onUpdate: (value: string) => void;
|
||||
onSave: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
canSave?: () => boolean;
|
||||
}
|
||||
|
||||
export interface InlineRenameEditor {
|
||||
element: HTMLElement;
|
||||
value: () => string;
|
||||
}
|
||||
|
||||
export function createInlineRenameEditor(parent: HTMLElement, options: InlineRenameOptions): InlineRenameEditor {
|
||||
const editor = parent.createDiv({
|
||||
cls: options.className,
|
||||
text: options.value,
|
||||
attr: {
|
||||
role: "textbox",
|
||||
"aria-label": options.ariaLabel,
|
||||
spellcheck: "false",
|
||||
contenteditable: "plaintext-only",
|
||||
},
|
||||
});
|
||||
|
||||
editor.oninput = () => {
|
||||
options.onUpdate(editorText(editor));
|
||||
};
|
||||
editor.onkeydown = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (!event.isComposing && (options.canSave?.() ?? true)) options.onSave(editorText(editor));
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
options.onCancel();
|
||||
}
|
||||
};
|
||||
editor.onpaste = (event) => {
|
||||
event.preventDefault();
|
||||
insertPlainText(editor, event.clipboardData?.getData("text/plain") ?? "");
|
||||
options.onUpdate(editorText(editor));
|
||||
};
|
||||
|
||||
editor.win.setTimeout(() => {
|
||||
if (editor.ownerDocument.activeElement !== editor) {
|
||||
editor.focus();
|
||||
selectElementText(editor);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return { element: editor, value: () => editorText(editor) };
|
||||
}
|
||||
|
||||
function editorText(editor: HTMLElement): string {
|
||||
return editor.textContent.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function insertPlainText(editor: HTMLElement, text: string): void {
|
||||
const selection = editor.ownerDocument.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
editor.textContent = `${editor.textContent}${text}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!editor.contains(range.commonAncestorContainer)) {
|
||||
editor.textContent = `${editor.textContent}${text}`;
|
||||
return;
|
||||
}
|
||||
|
||||
range.deleteContents();
|
||||
const node = editor.ownerDocument.createTextNode(text);
|
||||
range.insertNode(node);
|
||||
range.setStartAfter(node);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function selectElementText(editor: HTMLElement): void {
|
||||
const selection = editor.ownerDocument.getSelection();
|
||||
if (!selection) return;
|
||||
const range = editor.ownerDocument.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
42
styles.css
42
styles.css
|
|
@ -178,6 +178,14 @@
|
|||
background: var(--codex-panel-threads-gutter-color);
|
||||
}
|
||||
|
||||
.codex-panel-threads__row--renaming {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.codex-panel-threads__row--renaming:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent);
|
||||
}
|
||||
|
||||
.codex-panel-threads__row-title-line,
|
||||
.codex-panel-threads__row-title {
|
||||
min-width: 0;
|
||||
|
|
@ -226,8 +234,27 @@
|
|||
stroke-width: var(--icon-stroke);
|
||||
}
|
||||
|
||||
.codex-panel-threads__rename-input {
|
||||
.codex-panel-threads__rename-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--codex-panel-toolbar-button-gap);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codex-panel-threads__rename-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--nav-item-color-active, var(--text-normal));
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: var(--nav-item-weight, var(--font-normal));
|
||||
line-height: var(--line-height-tight);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.codex-panel {
|
||||
|
|
@ -1042,15 +1069,26 @@
|
|||
gap: var(--codex-panel-panel-gap);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.codex-panel__thread-rename:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent);
|
||||
}
|
||||
|
||||
.codex-panel__thread-rename-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: var(--codex-panel-size-nav-item);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--nav-item-color-active, var(--text-normal));
|
||||
font-size: var(--codex-panel-toolbar-text-size);
|
||||
line-height: var(--codex-panel-toolbar-line-height);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.codex-panel__thread {
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ function threadsViewActions() {
|
|||
updateRename: vi.fn(),
|
||||
saveRename: vi.fn(),
|
||||
cancelRename: vi.fn(),
|
||||
autoNameThread: vi.fn(),
|
||||
archiveThread: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
|
@ -1856,10 +1857,10 @@ describe("toolbar renderer decisions", () => {
|
|||
parent.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
|
||||
expect(startRenameThread).toHaveBeenCalledWith("thread");
|
||||
|
||||
const input = parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input");
|
||||
const input = parent.querySelector<HTMLElement>(".codex-panel__thread-rename-input");
|
||||
if (!input) throw new Error("Missing thread rename input");
|
||||
expect(input.value).toBe("Draft title");
|
||||
input.value = "New title";
|
||||
expect(input.textContent).toBe("Draft title");
|
||||
input.textContent = "New title";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title");
|
||||
|
||||
|
|
@ -1893,7 +1894,7 @@ describe("toolbar renderer decisions", () => {
|
|||
toolbarActions(),
|
||||
);
|
||||
|
||||
expect(parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input")?.disabled).toBe(false);
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel__thread-rename-input")?.getAttribute("contenteditable")).toBe("plaintext-only");
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')?.disabled).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel rename"]')?.disabled).toBe(false);
|
||||
|
|
@ -1958,19 +1959,56 @@ describe("threads view renderer decisions", () => {
|
|||
thread: threadFixture({ id: "thread", name: "Old name" }),
|
||||
title: "Old name",
|
||||
live: null,
|
||||
rename: { active: true, draft: "Old name" },
|
||||
rename: { active: true, draft: "Old name", generating: false },
|
||||
};
|
||||
|
||||
renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions);
|
||||
|
||||
const input = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input"));
|
||||
input.value = "New name";
|
||||
const input = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__rename-input"));
|
||||
input.textContent = "New name";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')?.click();
|
||||
|
||||
expect(actions.updateRename).toHaveBeenCalledWith("thread", "New name");
|
||||
expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name");
|
||||
});
|
||||
|
||||
it("renders threads view rename actions inline with auto-name", () => {
|
||||
const parent = document.createElement("div");
|
||||
const actions = threadsViewActions();
|
||||
const row: ThreadsRowModel = {
|
||||
thread: threadFixture({ id: "thread", name: "Old name" }),
|
||||
title: "Old name",
|
||||
live: null,
|
||||
rename: { active: true, draft: "Old name", generating: false },
|
||||
};
|
||||
|
||||
renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions);
|
||||
|
||||
const form = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__rename-form"));
|
||||
expect(form.querySelectorAll(".codex-panel-threads__row-button")).toHaveLength(3);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')?.disabled).toBe(false);
|
||||
parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
|
||||
|
||||
expect(actions.autoNameThread).toHaveBeenCalledWith("thread");
|
||||
});
|
||||
|
||||
it("renders threads view rename auto-name loading state", () => {
|
||||
const parent = document.createElement("div");
|
||||
const row: ThreadsRowModel = {
|
||||
thread: threadFixture({ id: "thread", name: "Old name" }),
|
||||
title: "Old name",
|
||||
live: null,
|
||||
rename: { active: true, draft: "Old name", generating: true },
|
||||
};
|
||||
|
||||
renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, threadsViewActions());
|
||||
|
||||
expect(parent.querySelector<HTMLElement>(".codex-panel-threads__rename-input")?.getAttribute("contenteditable")).toBe("plaintext-only");
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')?.disabled).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel rename"]')?.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending request renderer decisions", () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
|
||||
import type * as ThreadNamingModule from "../../../src/features/chat/thread-naming";
|
||||
import { installObsidianDomShims } from "../chat/ui/dom-test-helpers";
|
||||
|
||||
const connectionMock = vi.hoisted(() => {
|
||||
|
|
@ -22,6 +24,10 @@ const connectionMock = vi.hoisted(() => {
|
|||
};
|
||||
});
|
||||
|
||||
const namingMock = vi.hoisted(() => ({
|
||||
generateThreadTitleWithCodex: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/app-server/connection-manager", () => {
|
||||
class StaleConnectionError extends Error {}
|
||||
|
||||
|
|
@ -48,12 +54,21 @@ vi.mock("../../../src/app-server/connection-manager", () => {
|
|||
return { ConnectionManager, StaleConnectionError };
|
||||
});
|
||||
|
||||
vi.mock("../../../src/features/chat/thread-naming", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ThreadNamingModule>();
|
||||
return {
|
||||
...actual,
|
||||
generateThreadTitleWithCodex: namingMock.generateThreadTitleWithCodex,
|
||||
};
|
||||
});
|
||||
|
||||
installObsidianDomShims();
|
||||
|
||||
describe("CodexThreadsView", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
connectionMock.reset();
|
||||
namingMock.generateThreadTitleWithCodex.mockReset();
|
||||
});
|
||||
|
||||
it("renders thread list from app-server history", async () => {
|
||||
|
|
@ -214,10 +229,10 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
await view.refresh();
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
|
||||
const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
|
||||
const input = view.containerEl.querySelector<HTMLElement>(".codex-panel-threads__rename-input");
|
||||
expect(input).not.toBeNull();
|
||||
if (!input) return;
|
||||
input.value = "Renamed thread";
|
||||
input.textContent = "Renamed thread";
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')?.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
|
|
@ -225,6 +240,34 @@ describe("CodexThreadsView", () => {
|
|||
expect(host.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread");
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-names a thread rename draft from completed history", async () => {
|
||||
const threadTurnsList = vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
turnFixture([
|
||||
{ type: "userMessage", id: "u1", content: [{ type: "text", text: "threads viewのrenameを直したい", text_elements: [] }] },
|
||||
{ type: "agentMessage", id: "a1", text: "rename UIを調整しました。", phase: "final_answer", memoryCitation: null },
|
||||
]),
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
namingMock.generateThreadTitleWithCodex.mockResolvedValue("Threads rename UI");
|
||||
connectionMock.state.client = clientFixture({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
threadTurnsList,
|
||||
});
|
||||
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(threadTurnsList).toHaveBeenCalledWith("thread", null, 20, "asc");
|
||||
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
|
||||
expect(view.containerEl.querySelector<HTMLElement>(".codex-panel-threads__rename-input")?.textContent).toBe("Threads rename UI");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clientFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
|
|
@ -232,6 +275,7 @@ function clientFixture(overrides: Record<string, unknown> = {}): Record<string,
|
|||
listThreads: vi.fn().mockResolvedValue({ data: [] }),
|
||||
archiveThread: vi.fn().mockResolvedValue({}),
|
||||
setThreadName: vi.fn().mockResolvedValue({}),
|
||||
threadTurnsList: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
rejectServerRequest: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -294,3 +338,17 @@ function threadFixture(overrides: Record<string, unknown> = {}): Record<string,
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function turnFixture(items: Turn["items"], overrides: Partial<Turn> = {}): Turn {
|
||||
return {
|
||||
id: "turn",
|
||||
items,
|
||||
itemsView: "full",
|
||||
status: "completed",
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
completedAt: 2,
|
||||
durationMs: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue