diff --git a/src/domain/threads/archive-markdown.ts b/src/domain/threads/archive-markdown.ts index 8ecfd005..42a90618 100644 --- a/src/domain/threads/archive-markdown.ts +++ b/src/domain/threads/archive-markdown.ts @@ -1,5 +1,6 @@ -import { getThreadTitle, type Thread } from "./model"; +import type { Thread } from "./model"; import { referencedThreadMetadataFromPrompt } from "./reference"; +import { threadUserTitle } from "./title"; import type { ThreadTranscriptEntry } from "./transcript"; interface ParsedMarkdownLink { @@ -50,7 +51,7 @@ export function archivedThreadMarkdown( } export function archivedThreadTitle(thread: ArchiveThreadInput): string { - return getThreadTitle(thread) || "Untitled thread"; + return threadUserTitle(thread) || "Untitled thread"; } function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string): string { diff --git a/src/domain/threads/reference.ts b/src/domain/threads/reference.ts index 2593e92a..71c9c461 100644 --- a/src/domain/threads/reference.ts +++ b/src/domain/threads/reference.ts @@ -1,4 +1,5 @@ -import { getThreadTitle, type Thread } from "./model"; +import type { Thread } from "./model"; +import { threadUserTitle } from "./title"; import type { ThreadConversationSummary } from "./transcript"; export const REFERENCED_THREAD_TURN_LIMIT = 20; @@ -47,7 +48,7 @@ function referencedThreadPrompt(thread: Thread, turns: readonly ThreadConversati function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata { return { threadId: thread.id, - title: getThreadTitle(thread), + title: threadUserTitle(thread), includedTurns: count, turnLimit: REFERENCED_THREAD_TURN_LIMIT, }; diff --git a/src/domain/threads/title.ts b/src/domain/threads/title.ts new file mode 100644 index 00000000..92821337 --- /dev/null +++ b/src/domain/threads/title.ts @@ -0,0 +1,43 @@ +import { shortThreadId } from "../../utils"; +import { getThreadTitle, type Thread } from "./model"; + +const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function threadUserTitle(thread: Thread): string { + return usefulThreadTitle(thread) ?? getThreadTitle(thread); +} + +export function threadRenameDraftTitle(thread: Thread): string { + return usefulThreadTitle(thread) ?? ""; +} + +export function threadArchiveDisplayTitle(thread: Thread): string { + const title = usefulThreadTitle(thread); + return title ? truncateThreadTitle(title, MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH) : "Untitled archived thread"; +} + +export function threadWindowTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string { + if (!activeThreadId) return "Codex"; + + const thread = threads.find((item) => item.id === activeThreadId); + const title = thread ? threadUserTitle(thread) : normalizeTitle(fallbackTitle) || shortThreadId(activeThreadId); + return title ? `Codex: ${title}` : "Codex"; +} + +function usefulThreadTitle(thread: Thread): string | null { + for (const value of [thread.name, thread.preview]) { + const title = normalizeTitle(value); + if (title && title !== thread.id && !UUID_PATTERN.test(title)) return title; + } + return null; +} + +function normalizeTitle(value: string | null | undefined): string { + return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; +} + +function truncateThreadTitle(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3).trimEnd()}...`; +} diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index 03e75ecd..f7b8b910 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -4,7 +4,7 @@ import { prepareFuzzySearch, sortSearchResults, type SearchResult } from "obsidi import { findModelMetadataByIdOrName, sortedModelMetadata } from "../../../../domain/catalog/metadata"; import { supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata"; import { SLASH_COMMANDS, slashCommandSubcommands, type SlashCommandName } from "./slash-commands"; -import { getThreadTitle } from "../../../../domain/threads/model"; +import { threadUserTitle } from "../../../../domain/threads/title"; import { shortThreadId } from "../../../../utils"; export interface ComposerSuggestion { @@ -277,7 +277,7 @@ function activeThreadCommandSuggestions(beforeCursor: string, threads: readonly if (threads.some((thread) => thread.id.toLowerCase() === query)) return null; return threads .map((thread, index) => { - const title = getThreadTitle(thread); + const title = threadUserTitle(thread); const id = thread.id.toLowerCase(); const normalizedTitle = title.toLowerCase(); const score = query.length === 0 ? 2 : id.startsWith(query) ? 0 : normalizedTitle.includes(query) ? 1 : id.includes(query) ? 2 : -1; diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/conversation/slash-command-execution.ts index 5d43dd37..9f684731 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -3,7 +3,7 @@ import type { ThreadGoal } from "../../../../domain/threads/goal"; import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { normalizeReasoningEffort } from "../../../../domain/catalog/metadata"; import type { Thread } from "../../../../domain/threads/model"; -import { getThreadTitle } from "../../../../domain/threads/model"; +import { threadUserTitle } from "../../../../domain/threads/title"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; import type { GoalActions } from "../threads/goal-actions"; @@ -463,9 +463,9 @@ function resolveThreadArgument(args: string, threads: readonly Thread[]): Thread if (idMatches.length > 1) return { ok: false, message: `Multiple matching threads: ${idMatches.map((thread) => thread.id).join(", ")}` }; const titleQuery = query.toLowerCase(); - const titleMatches = threads.filter((thread) => getThreadTitle(thread).toLowerCase().includes(titleQuery)); + const titleMatches = threads.filter((thread) => threadUserTitle(thread).toLowerCase().includes(titleQuery)); if (titleMatches.length === 1 && titleMatches[0]) return { ok: true, thread: titleMatches[0] }; - if (titleMatches.length > 1) return { ok: false, message: `Multiple matching threads: ${titleMatches.map(getThreadTitle).join(", ")}` }; + if (titleMatches.length > 1) return { ok: false, message: `Multiple matching threads: ${titleMatches.map(threadUserTitle).join(", ")}` }; return { ok: false, message: `No matching thread: ${query}` }; } diff --git a/src/features/chat/application/threads/rename-editor-actions.ts b/src/features/chat/application/threads/rename-editor-actions.ts index d2ddf212..106bb8a0 100644 --- a/src/features/chat/application/threads/rename-editor-actions.ts +++ b/src/features/chat/application/threads/rename-editor-actions.ts @@ -1,5 +1,5 @@ -import { getThreadTitle } from "../../../../domain/threads/model"; import type { Thread } from "../../../../domain/threads/model"; +import { threadRenameDraftTitle } from "../../../../domain/threads/title"; import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model"; import type { ThreadOperations } from "../../../threads/thread-operations"; import type { ThreadTitleService } from "../../../threads/thread-title-service"; @@ -57,7 +57,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH start(threadId: string): void { const thread = threadById(host, threadId); if (!thread) return; - dispatch(host, { type: "ui/rename-started", threadId, draft: getThreadTitle(thread) }); + dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) }); }, updateDraft(threadId: string, value: string): void { diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 66a2d934..7f7597cc 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -1,10 +1,8 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import { appServerQueryContextRawEquals, type AppServerQueryContext } from "../../../app-server/query/keys"; -import type { Thread } from "../../../domain/threads/model"; -import { getThreadTitle } from "../../../domain/threads/model"; +import { threadUserTitle, threadWindowTitle } from "../../../domain/threads/title"; import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; -import { shortThreadId } from "../../../utils"; import { createChatViewDeferredTasks } from "./lifecycle"; import { ChatResumeWorkTracker, type ChatViewDeferredTasks } from "../application/lifecycle"; import { openPanelTurnLifecycle, parseRestoredThreadState, type ChatPanelSnapshot } from "../panel/snapshot"; @@ -16,14 +14,6 @@ import type { ChatSurfaceHandle } from "./surface-handle"; import type { ChatPanelEnvironment } from "./runtime"; import { createChatPanelSessionGraph, type ChatPanelSessionGraph } from "./session-graph"; -function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string { - if (!activeThreadId) return "Codex"; - - const thread = threads.find((item) => item.id === activeThreadId); - const title = thread ? getThreadTitle(thread).replace(/\s+/g, " ").trim() : (fallbackTitle ?? shortThreadId(activeThreadId)); - return title ? `Codex: ${title}` : "Codex"; -} - export class ChatPanelSession implements ChatSurfaceHandle { private readonly stateStore: ChatStateStore = createChatStateStore(); private readonly graph: ChatPanelSessionGraph; @@ -43,7 +33,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { } displayTitle(): string { - return codexPanelDisplayTitle(this.state.activeThread.id, this.state.threadList.listedThreads, this.restoredThreadTitle()); + return threadWindowTitle(this.state.activeThread.id, this.state.threadList.listedThreads, this.restoredThreadTitle()); } persistedState(): Record { @@ -105,6 +95,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { turnLifecycle: openPanelTurnLifecycle(this.state.turn.lifecycle), pendingApprovals: this.state.requests.approvals.length, pendingUserInputs: this.state.requests.pendingUserInputs.length, + pendingMcpElicitations: this.state.requests.pendingMcpElicitations.length, hasComposerDraft: this.state.composer.draft.trim().length > 0, connected: this.graph.connection.manager.isConnected(), }; @@ -234,7 +225,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { const threadId = this.state.activeThread.id; if (!threadId) return null; const thread = this.state.threadList.listedThreads.find((item) => item.id === threadId); - return thread ? getThreadTitle(thread) : null; + return thread ? threadUserTitle(thread) : null; } private restoredThreadTitle(): string | null { diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index 599be016..e21dff16 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -2,7 +2,7 @@ import type { ComponentChild as UiNode } from "preact"; import { h } from "preact"; import type { Thread } from "../../../../domain/threads/model"; -import { getThreadTitle } from "../../../../domain/threads/model"; +import { threadUserTitle } from "../../../../domain/threads/title"; import { rateLimitSummary } from "../../presentation/runtime/status"; import { connectionDiagnosticSectionsModel } from "../../application/connection/diagnostics-display"; import { toolInventoryDiagnosticSections } from "../../application/connection/tool-inventory-display"; @@ -135,7 +135,7 @@ function toolbarThreadRows(input: { return input.threads.map((thread) => { const threadId = thread.id; return { - title: getThreadTitle(thread), + title: threadUserTitle(thread), threadId, selected: threadId === input.activeThreadId, disabled: input.turnBusy && threadId !== input.activeThreadId, diff --git a/src/features/thread-picker/modal.ts b/src/features/thread-picker/modal.ts index 65dc60c7..88754f5b 100644 --- a/src/features/thread-picker/modal.ts +++ b/src/features/thread-picker/modal.ts @@ -1,7 +1,7 @@ import { Notice, Platform, SuggestModal, type App } from "obsidian"; -import { getThreadTitle } from "../../domain/threads/model"; import type { Thread } from "../../domain/threads/model"; +import { threadUserTitle } from "../../domain/threads/title"; import { shortThreadId } from "../../utils"; import type { ThreadCatalogActiveReader } from "../../workspace/thread-catalog"; @@ -39,7 +39,7 @@ function threadPickerSuggestions(threads: readonly Thread[], queryText: string): return [...threads] .sort((a, b) => b.updatedAt - a.updatedAt) .map((thread, index) => { - const title = getThreadTitle(thread); + const title = threadUserTitle(thread); const id = thread.id.toLowerCase(); const normalizedTitle = title.toLowerCase(); const shortId = shortThreadId(thread.id).toLowerCase(); diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index e4d39253..08965096 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -1,6 +1,6 @@ -import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator"; import type { Thread } from "../../domain/threads/model"; -import { explicitThreadName, getThreadTitle } from "../../domain/threads/model"; +import { threadRenameDraftTitle, threadUserTitle } from "../../domain/threads/title"; +import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator"; type ThreadsLiveStatus = "needs-input" | "approval" | "running" | "draft" | "offline" | "open"; @@ -69,12 +69,12 @@ export function threadRows( const rename = renameStates.get(thread.id); return { thread, - title: getThreadTitle(thread), + title: threadUserTitle(thread), live, selected, rename: { active: rename !== undefined, - draft: rename?.draft ?? explicitThreadName(thread) ?? getThreadTitle(thread), + draft: rename?.draft ?? threadRenameDraftTitle(thread), generating: rename?.kind === "generating", }, archiveConfirm: { @@ -213,7 +213,7 @@ function snapshotsForThreads(snapshots: OpenCodexPanelSnapshot[]): Map 0) return "needs-input"; + if (snapshot.pendingUserInputs > 0 || snapshot.pendingMcpElicitations > 0) return "needs-input"; if (snapshot.pendingApprovals > 0) return "approval"; if (snapshot.turnLifecycle.kind !== "idle") return "running"; if (snapshot.hasComposerDraft) return "draft"; diff --git a/src/settings/archived-section.tsx b/src/settings/archived-section.tsx index 20a189cb..1d060b3d 100644 --- a/src/settings/archived-section.tsx +++ b/src/settings/archived-section.tsx @@ -1,8 +1,8 @@ import type { ComponentChild as UiNode } from "preact"; import type { Thread } from "../domain/threads/model"; +import { threadArchiveDisplayTitle } from "../domain/threads/title"; import { shortThreadId } from "../utils"; -import { archivedThreadDisplayTitle } from "./archived-thread-title"; import type { ArchivedThreadSectionState } from "./section-state"; import { SettingRow, @@ -89,7 +89,7 @@ function ArchivedThreadList({ state }: { state: ArchivedThreadSectionState }): U } function ArchivedThreadRow({ thread, state }: { thread: Thread; state: ArchivedThreadSectionState }): UiNode { - const title = archivedThreadDisplayTitle(thread); + const title = threadArchiveDisplayTitle(thread); const deleteConfirming = state.deleteConfirmThreadId === thread.id; return ( { it("resolves display titles from explicit names, previews, then ids", () => { @@ -16,6 +17,35 @@ describe("thread helpers", () => { expect(getThreadTitle(thread({ id: "thread-id", name: null, preview: "" }))).toBe("thread-id"); }); + it("keeps user-facing titles identifiable while keeping rename drafts human-authored", () => { + const idOnly = thread({ id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", name: null, preview: "" }); + + expect(threadUserTitle(idOnly)).toBe("019e0182-cb70-7a72-ab48-8bc9d0b0d781"); + expect(threadRenameDraftTitle(idOnly)).toBe(""); + expect(threadArchiveDisplayTitle(idOnly)).toBe("Untitled archived thread"); + }); + + it("uses useful preview text instead of UUID-like names for draft and archive titles", () => { + const uuidNamed = thread({ + id: "thread-id", + name: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", + preview: " Useful preview ", + }); + + expect(threadUserTitle(uuidNamed)).toBe("Useful preview"); + expect(threadRenameDraftTitle(uuidNamed)).toBe("Useful preview"); + expect(threadArchiveDisplayTitle(uuidNamed)).toBe("Useful preview"); + }); + + it("builds window titles from loaded threads, restored titles, then short ids", () => { + const uuid = "019e0182-cb70-7a72-ab48-8bc9d0b0d781"; + + expect(threadWindowTitle(null, [])).toBe("Codex"); + expect(threadWindowTitle("thread", [thread({ id: "thread", name: " Named thread " })])).toBe("Codex: Named thread"); + expect(threadWindowTitle("thread", [], " Restored title ")).toBe("Codex: Restored title"); + expect(threadWindowTitle(uuid, [], null)).toBe("Codex: 019e0182"); + }); + it("inherits only explicit thread names for forked threads", () => { expect(inheritedForkThreadName("named", [thread({ id: "named", name: "親スレッド", preview: "Preview" })])).toBe("親スレッド"); expect(inheritedForkThreadName("preview-only", [thread({ id: "preview-only", preview: "Preview" })])).toBeNull(); diff --git a/tests/features/chat/threads/rename-editor-actions.test.ts b/tests/features/chat/threads/rename-editor-actions.test.ts index 3a3100a7..876ea216 100644 --- a/tests/features/chat/threads/rename-editor-actions.test.ts +++ b/tests/features/chat/threads/rename-editor-actions.test.ts @@ -21,6 +21,15 @@ describe("ThreadRenameEditorActions", () => { expect(actions.editState("thread")).toEqual({ draft: "New name", generating: false }); }); + it("starts rename drafts from useful titles instead of id fallbacks", () => { + const { actions, stateStore } = actionsFixture(); + stateStore.dispatch({ type: "thread-list/applied", threads: [{ ...threadFixture("thread"), preview: "" }] }); + + actions.start("thread"); + + expect(actions.editState("thread")).toEqual({ draft: "", generating: false }); + }); + it("clears inline rename state through chat UI state", () => { const { actions } = actionsFixture(); diff --git a/tests/features/threads-view/state.test.ts b/tests/features/threads-view/state.test.ts index 031bd333..a9f32a82 100644 --- a/tests/features/threads-view/state.test.ts +++ b/tests/features/threads-view/state.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Thread } from "../../../src/domain/threads/model"; import { threadRows, transitionThreadsRenameState, type ThreadsRenameState } from "../../../src/features/threads-view/state"; +import type { OpenCodexPanelSnapshot } from "../../../src/workspace/panel-coordinator"; describe("threads view rename state", () => { it("keeps a late auto-name result from reviving a cancelled rename", () => { @@ -74,6 +75,13 @@ describe("threads view rename state", () => { it("initializes rename drafts from normalized explicit thread names", () => { expect(threadRows([thread({ name: " Saved name ", preview: "Preview" })], [], new Map())[0]?.rename.draft).toBe("Saved name"); expect(threadRows([thread({ name: " ", preview: "Preview title" })], [], new Map())[0]?.rename.draft).toBe("Preview title"); + expect(threadRows([thread({ name: null, preview: "" })], [], new Map())[0]?.rename.draft).toBe(""); + }); + + it("treats pending MCP elicitations as user input live state", () => { + const rows = threadRows([thread()], [openPanelSnapshot({ pendingMcpElicitations: 1 })], new Map()); + + expect(rows[0]?.live).toMatchObject({ status: "needs-input", label: "Needs input" }); }); }); @@ -104,3 +112,18 @@ function thread(overrides: Partial = {}): Thread { ...overrides, }; } + +function openPanelSnapshot(overrides: Partial = {}): OpenCodexPanelSnapshot { + return { + viewId: "view", + threadId: "thread", + turnLifecycle: { kind: "idle" }, + pendingApprovals: 0, + pendingUserInputs: 0, + pendingMcpElicitations: 0, + hasComposerDraft: false, + connected: true, + lastFocused: false, + ...overrides, + }; +} diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 9060e5e3..fcee17e3 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -11,7 +11,7 @@ import { SettingsDynamicDataController, type SettingsDynamicDataSnapshot } from import { CodexPanelSettingTab } from "../../src/settings/tab"; import type { CodexPanelSettingTabHost } from "../../src/settings/host"; import type { Thread } from "../../src/domain/threads/model"; -import { archivedThreadDisplayTitle } from "../../src/settings/archived-thread-title"; +import { threadArchiveDisplayTitle } from "../../src/domain/threads/title"; import { notices } from "../mocks/obsidian"; import { deferred } from "../support/async"; import { installObsidianDomShims } from "../support/dom"; @@ -33,14 +33,19 @@ describe("settings tab", () => { }); it("uses a placeholder for threads without a useful title", () => { - expect(archivedThreadDisplayTitle(panelThread({ name: null, preview: "" }))).toBe("Untitled archived thread"); - expect(archivedThreadDisplayTitle(panelThread({ name: "019e0182-cb70-7a72-ab48-8bc9d0b0d781" }))).toBe("Untitled archived thread"); + expect(threadArchiveDisplayTitle(panelThread({ name: null, preview: "" }))).toBe("Untitled archived thread"); + expect(threadArchiveDisplayTitle(panelThread({ name: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", preview: "" }))).toBe( + "Untitled archived thread", + ); + expect(threadArchiveDisplayTitle(panelThread({ name: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", preview: "Preview title" }))).toBe( + "Preview title", + ); }); it("normalizes and truncates archived thread titles", () => { - expect(archivedThreadDisplayTitle(panelThread({ preview: "A title\nwith extra\tspace" }))).toBe("A title with extra space"); + expect(threadArchiveDisplayTitle(panelThread({ preview: "A title\nwith extra\tspace" }))).toBe("A title with extra space"); - const title = archivedThreadDisplayTitle(panelThread({ preview: "x".repeat(120) })); + const title = threadArchiveDisplayTitle(panelThread({ preview: "x".repeat(120) })); expect(title).toHaveLength(96); expect(title.endsWith("...")).toBe(true); });