diff --git a/src/features/chat/ui/toolbar.ts b/src/features/chat/ui/toolbar.ts index 3ffa83be..ec7fe8f1 100644 --- a/src/features/chat/ui/toolbar.ts +++ b/src/features/chat/ui/toolbar.ts @@ -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"); diff --git a/src/features/threads-view/renderer.ts b/src/features/threads-view/renderer.ts index 1d498154..4863463a 100644 --- a/src/features/threads-view/renderer.ts +++ b/src/features/threads-view/renderer.ts @@ -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 { diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index c0326a99..5f517114 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -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 = { @@ -30,6 +30,7 @@ export function threadRows( threads: Thread[], snapshots: OpenCodexPanelSnapshot[], renameDrafts: ReadonlyMap, + 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, }, }; }); diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index 5f9e5677..bc63c237 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -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(); + 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 { + 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 { + 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 { try { await this.ensureConnected(); diff --git a/src/shared/ui/inline-rename.ts b/src/shared/ui/inline-rename.ts new file mode 100644 index 00000000..6b899c4b --- /dev/null +++ b/src/shared/ui/inline-rename.ts @@ -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); +} diff --git a/styles.css b/styles.css index fe3a214b..824f0626 100644 --- a/styles.css +++ b/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 { diff --git a/tests/features/chat/ui/view-renderers.test.ts b/tests/features/chat/ui/view-renderers.test.ts index da78f26f..83fb6dd4 100644 --- a/tests/features/chat/ui/view-renderers.test.ts +++ b/tests/features/chat/ui/view-renderers.test.ts @@ -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('[aria-label="Rename thread"]')?.click(); expect(startRenameThread).toHaveBeenCalledWith("thread"); - const input = parent.querySelector(".codex-panel__thread-rename-input"); + const input = parent.querySelector(".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(".codex-panel__thread-rename-input")?.disabled).toBe(false); + expect(parent.querySelector(".codex-panel__thread-rename-input")?.getAttribute("contenteditable")).toBe("plaintext-only"); expect(parent.querySelector('[aria-label="Save thread name"]')?.disabled).toBe(true); expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); expect(parent.querySelector('[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(".codex-panel-threads__rename-input")); - input.value = "New name"; + const input = expectPresent(parent.querySelector(".codex-panel-threads__rename-input")); + input.textContent = "New name"; input.dispatchEvent(new Event("input")); parent.querySelector('[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(".codex-panel-threads__rename-form")); + expect(form.querySelectorAll(".codex-panel-threads__row-button")).toHaveLength(3); + expect(parent.querySelector('[aria-label="Save thread name"]')?.disabled).toBe(false); + parent.querySelector('[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(".codex-panel-threads__rename-input")?.getAttribute("contenteditable")).toBe("plaintext-only"); + expect(parent.querySelector('[aria-label="Save thread name"]')?.disabled).toBe(true); + expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); + expect(parent.querySelector('[aria-label="Cancel rename"]')?.disabled).toBe(false); + }); }); describe("pending request renderer decisions", () => { diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 0469ddeb..22930197 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -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(); + 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('[aria-label="Rename thread"]')?.click(); - const input = view.containerEl.querySelector(".codex-panel-threads__rename-input"); + const input = view.containerEl.querySelector(".codex-panel-threads__rename-input"); expect(input).not.toBeNull(); if (!input) return; - input.value = "Renamed thread"; + input.textContent = "Renamed thread"; view.containerEl.querySelector('[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('[aria-label="Rename thread"]')?.click(); + view.containerEl.querySelector('[aria-label="Auto-name thread"]')?.click(); + + await vi.waitFor(() => { + expect(threadTurnsList).toHaveBeenCalledWith("thread", null, 20, "asc"); + expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce(); + expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.textContent).toBe("Threads rename UI"); + }); + }); }); function clientFixture(overrides: Record = {}): Record { @@ -232,6 +275,7 @@ function clientFixture(overrides: Record = {}): Record = {}): Record = {}): Turn { + return { + id: "turn", + items, + itemsView: "full", + status: "completed", + error: null, + startedAt: 1, + completedAt: 2, + durationMs: 1, + ...overrides, + }; +}