Add auto-name to threads view rename

This commit is contained in:
murashit 2026-05-25 18:59:50 +09:00
parent 7cebbbcb80
commit ee5b797f5b
8 changed files with 330 additions and 57 deletions

View file

@ -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");

View file

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

View file

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

View file

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

View 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);
}

View file

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

View file

@ -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", () => {

View file

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