From 5eb8bc9933db3a306cebf0895ddd62e82d720db7 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 21 Jun 2026 17:32:32 +0900 Subject: [PATCH] Clarify thread title semantics --- src/domain/threads/archive-markdown.ts | 4 +- src/domain/threads/model.ts | 7 ---- src/domain/threads/reference.ts | 4 +- src/domain/threads/title.ts | 41 +++++++++++-------- .../chat/application/composer/suggestions.ts | 4 +- .../conversation/slash-command-execution.ts | 13 ++++-- src/features/chat/host/session.ts | 4 +- .../chat/panel/surface/toolbar-projection.tsx | 4 +- src/features/thread-picker/modal.ts | 5 ++- src/features/threads-view/renderer.tsx | 2 +- src/features/threads-view/state.ts | 4 +- tests/domain/threads/archive-markdown.test.ts | 8 ++++ tests/domain/threads/threads.test.ts | 29 ++++++++----- .../turns/slash-command-execution.test.ts | 6 +-- tests/settings/settings-tab.test.ts | 6 +-- 15 files changed, 83 insertions(+), 58 deletions(-) diff --git a/src/domain/threads/archive-markdown.ts b/src/domain/threads/archive-markdown.ts index 42a90618..ddaeaa58 100644 --- a/src/domain/threads/archive-markdown.ts +++ b/src/domain/threads/archive-markdown.ts @@ -1,6 +1,6 @@ import type { Thread } from "./model"; import { referencedThreadMetadataFromPrompt } from "./reference"; -import { threadUserTitle } from "./title"; +import { threadArchiveTitle } from "./title"; import type { ThreadTranscriptEntry } from "./transcript"; interface ParsedMarkdownLink { @@ -51,7 +51,7 @@ export function archivedThreadMarkdown( } export function archivedThreadTitle(thread: ArchiveThreadInput): string { - return threadUserTitle(thread) || "Untitled thread"; + return threadArchiveTitle(thread); } function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string): string { diff --git a/src/domain/threads/model.ts b/src/domain/threads/model.ts index 47771ea1..743abdca 100644 --- a/src/domain/threads/model.ts +++ b/src/domain/threads/model.ts @@ -7,13 +7,6 @@ export interface Thread { readonly updatedAt: number; } -export function getThreadTitle(thread: Thread): string { - return ( - [thread.name, thread.preview, thread.id].map((value) => (typeof value === "string" ? normalizeTitle(value) : "")).find(Boolean) ?? - thread.id - ); -} - export function explicitThreadName(thread: Thread): string | null { return normalizeExplicitThreadName(thread.name); } diff --git a/src/domain/threads/reference.ts b/src/domain/threads/reference.ts index 71c9c461..eef34d09 100644 --- a/src/domain/threads/reference.ts +++ b/src/domain/threads/reference.ts @@ -1,5 +1,5 @@ import type { Thread } from "./model"; -import { threadUserTitle } from "./title"; +import { threadDisplayTitle } from "./title"; import type { ThreadConversationSummary } from "./transcript"; export const REFERENCED_THREAD_TURN_LIMIT = 20; @@ -48,7 +48,7 @@ function referencedThreadPrompt(thread: Thread, turns: readonly ThreadConversati function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata { return { threadId: thread.id, - title: threadUserTitle(thread), + title: threadDisplayTitle(thread), includedTurns: count, turnLimit: REFERENCED_THREAD_TURN_LIMIT, }; diff --git a/src/domain/threads/title.ts b/src/domain/threads/title.ts index 92821337..1069bec7 100644 --- a/src/domain/threads/title.ts +++ b/src/domain/threads/title.ts @@ -1,39 +1,48 @@ import { shortThreadId } from "../../utils"; -import { getThreadTitle, type Thread } from "./model"; +import type { Thread } from "./model"; const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96; +const UNTITLED_THREAD_TITLE = "Untitled thread"; 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 threadMeaningfulTitle(thread: Thread): string | null { + for (const value of [thread.name, thread.preview]) { + const title = normalizeThreadTitleText(value); + if (title && title !== thread.id && !UUID_PATTERN.test(title)) return title; + } + return null; +} + +export function threadDisplayTitle(thread: Thread): string { + return threadMeaningfulTitle(thread) ?? UNTITLED_THREAD_TITLE; } export function threadRenameDraftTitle(thread: Thread): string { - return usefulThreadTitle(thread) ?? ""; + return threadMeaningfulTitle(thread) ?? ""; +} + +export function threadArchiveTitle(thread: Thread): string { + return threadMeaningfulTitle(thread) ?? UNTITLED_THREAD_TITLE; } export function threadArchiveDisplayTitle(thread: Thread): string { - const title = usefulThreadTitle(thread); - return title ? truncateThreadTitle(title, MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH) : "Untitled archived thread"; + return truncateThreadTitle(threadArchiveTitle(thread), MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH); } 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); + const restoredTitle = normalizeThreadTitleText(fallbackTitle); + const title = thread + ? (threadMeaningfulTitle(thread) ?? shortThreadId(thread.id)) + : restoredTitle.length > 0 + ? restoredTitle + : 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 { +function normalizeThreadTitleText(value: string | null | undefined): string { return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; } diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index f7b8b910..70405de7 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 { threadUserTitle } from "../../../../domain/threads/title"; +import { threadDisplayTitle } 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 = threadUserTitle(thread); + const title = threadDisplayTitle(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 9f684731..a69479b1 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -3,8 +3,9 @@ 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 { threadUserTitle } from "../../../../domain/threads/title"; +import { threadDisplayTitle } from "../../../../domain/threads/title"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; +import { shortThreadId } from "../../../../utils"; import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; import type { GoalActions } from "../threads/goal-actions"; import type { ThreadManagementActions } from "../threads/thread-management-actions"; @@ -463,9 +464,15 @@ 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) => threadUserTitle(thread).toLowerCase().includes(titleQuery)); + const titleMatches = threads.filter((thread) => threadDisplayTitle(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(threadUserTitle).join(", ")}` }; + if (titleMatches.length > 1) { + return { ok: false, message: `Multiple matching threads: ${titleMatches.map(threadResolutionLabel).join(", ")}` }; + } return { ok: false, message: `No matching thread: ${query}` }; } + +function threadResolutionLabel(thread: Thread): string { + return `${threadDisplayTitle(thread)} (${shortThreadId(thread.id)})`; +} diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 7f7597cc..9280be81 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -1,7 +1,7 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import { appServerQueryContextRawEquals, type AppServerQueryContext } from "../../../app-server/query/keys"; -import { threadUserTitle, threadWindowTitle } from "../../../domain/threads/title"; +import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title"; import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; import { createChatViewDeferredTasks } from "./lifecycle"; import { ChatResumeWorkTracker, type ChatViewDeferredTasks } from "../application/lifecycle"; @@ -225,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 ? threadUserTitle(thread) : null; + return thread ? threadMeaningfulTitle(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 e21dff16..2f9aec0c 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 { threadUserTitle } from "../../../../domain/threads/title"; +import { threadDisplayTitle } 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: threadUserTitle(thread), + title: threadDisplayTitle(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 88754f5b..bf3f5742 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 type { Thread } from "../../domain/threads/model"; -import { threadUserTitle } from "../../domain/threads/title"; +import { threadDisplayTitle } 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 = threadUserTitle(thread); + const title = threadDisplayTitle(thread); const id = thread.id.toLowerCase(); const normalizedTitle = title.toLowerCase(); const shortId = shortThreadId(thread.id).toLowerCase(); @@ -106,6 +106,7 @@ class ThreadPickerModal extends SuggestModal { override renderSuggestion(value: ThreadSuggestion, el: HTMLElement): void { const contentEl = el.createDiv({ cls: "suggestion-content" }); contentEl.createDiv({ cls: "suggestion-title", text: value.title }); + el.createDiv({ cls: "suggestion-note", text: shortThreadId(value.thread.id) }); } override onChooseSuggestion(item: ThreadSuggestion, evt: MouseEvent | KeyboardEvent): void { diff --git a/src/features/threads-view/renderer.tsx b/src/features/threads-view/renderer.tsx index cd55d44e..1cc37978 100644 --- a/src/features/threads-view/renderer.tsx +++ b/src/features/threads-view/renderer.tsx @@ -123,7 +123,7 @@ function ThreadRow({ row, actions }: { row: ThreadsRowModel; actions: ThreadsVie className="codex-panel-threads__row-button" onClick={(event) => { event.stopPropagation(); - actions.startRename(row.thread.id, row.thread.name ?? row.title); + actions.startRename(row.thread.id, row.rename.draft); }} /> ) : null} diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index f87d5e49..cd3021c9 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -1,5 +1,5 @@ import type { Thread } from "../../domain/threads/model"; -import { threadRenameDraftTitle, threadUserTitle } from "../../domain/threads/title"; +import { threadDisplayTitle, threadRenameDraftTitle } from "../../domain/threads/title"; import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator"; import { initialThreadRenameLifecycleState, @@ -65,7 +65,7 @@ export function threadRows( const rename = renameStates.get(thread.id); return { thread, - title: threadUserTitle(thread), + title: threadDisplayTitle(thread), live, selected, rename: { diff --git a/tests/domain/threads/archive-markdown.test.ts b/tests/domain/threads/archive-markdown.test.ts index 82d59b8f..efbd8598 100644 --- a/tests/domain/threads/archive-markdown.test.ts +++ b/tests/domain/threads/archive-markdown.test.ts @@ -50,6 +50,14 @@ describe("thread archive export", () => { expect(output).not.toContain("npm test"); }); + it("uses the shared archive title placeholder instead of leaking thread ids", () => { + const output = exportedMarkdown(thread({ name: null, preview: "" }), new Date(2026, 4, 18)); + + expect(output).toContain('title: "Untitled thread"'); + expect(output).toContain("# Untitled thread"); + expect(output).not.toContain("# 019e0182-cb70-7a72-ab48-8bc9d0b0d781"); + }); + it("falls back when turn timestamps are missing and uses start time for incomplete agent output", () => { const output = exportedMarkdown( thread({ diff --git a/tests/domain/threads/threads.test.ts b/tests/domain/threads/threads.test.ts index fccf5589..cb48973b 100644 --- a/tests/domain/threads/threads.test.ts +++ b/tests/domain/threads/threads.test.ts @@ -2,27 +2,34 @@ import { describe, expect, it } from "vitest"; import { explicitThreadName, - getThreadTitle, inheritedForkThreadName, normalizeExplicitThreadName, upsertThread, type Thread, } from "../../../src/domain/threads/model"; -import { threadArchiveDisplayTitle, threadRenameDraftTitle, threadUserTitle, threadWindowTitle } from "../../../src/domain/threads/title"; +import { + threadArchiveDisplayTitle, + threadArchiveTitle, + threadDisplayTitle, + threadMeaningfulTitle, + threadRenameDraftTitle, + threadWindowTitle, +} from "../../../src/domain/threads/title"; describe("thread helpers", () => { - it("resolves display titles from explicit names, previews, then ids", () => { - expect(getThreadTitle(thread({ name: " Named thread ", preview: "Preview" }))).toBe("Named thread"); - expect(getThreadTitle(thread({ name: " ", preview: " Preview only " }))).toBe("Preview only"); - expect(getThreadTitle(thread({ id: "thread-id", name: null, preview: "" }))).toBe("thread-id"); + it("resolves meaningful titles from explicit names, then previews, without id fallbacks", () => { + expect(threadMeaningfulTitle(thread({ name: " Named thread ", preview: "Preview" }))).toBe("Named thread"); + expect(threadMeaningfulTitle(thread({ name: " ", preview: " Preview only " }))).toBe("Preview only"); + expect(threadMeaningfulTitle(thread({ id: "thread-id", name: null, preview: "" }))).toBeNull(); }); - it("keeps user-facing titles identifiable while keeping rename drafts human-authored", () => { + it("keeps user-facing placeholders separate from rename drafts and archive titles", () => { const idOnly = thread({ id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", name: null, preview: "" }); - expect(threadUserTitle(idOnly)).toBe("019e0182-cb70-7a72-ab48-8bc9d0b0d781"); + expect(threadDisplayTitle(idOnly)).toBe("Untitled thread"); expect(threadRenameDraftTitle(idOnly)).toBe(""); - expect(threadArchiveDisplayTitle(idOnly)).toBe("Untitled archived thread"); + expect(threadArchiveTitle(idOnly)).toBe("Untitled thread"); + expect(threadArchiveDisplayTitle(idOnly)).toBe("Untitled thread"); }); it("uses useful preview text instead of UUID-like names for draft and archive titles", () => { @@ -32,8 +39,9 @@ describe("thread helpers", () => { preview: " Useful preview ", }); - expect(threadUserTitle(uuidNamed)).toBe("Useful preview"); + expect(threadDisplayTitle(uuidNamed)).toBe("Useful preview"); expect(threadRenameDraftTitle(uuidNamed)).toBe("Useful preview"); + expect(threadArchiveTitle(uuidNamed)).toBe("Useful preview"); expect(threadArchiveDisplayTitle(uuidNamed)).toBe("Useful preview"); }); @@ -43,6 +51,7 @@ describe("thread helpers", () => { 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, [thread({ id: uuid, name: null, preview: "" })])).toBe("Codex: 019e0182"); expect(threadWindowTitle(uuid, [], null)).toBe("Codex: 019e0182"); }); diff --git a/tests/features/chat/conversation/turns/slash-command-execution.test.ts b/tests/features/chat/conversation/turns/slash-command-execution.test.ts index 7f859ad4..98821d97 100644 --- a/tests/features/chat/conversation/turns/slash-command-execution.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-execution.test.ts @@ -134,7 +134,7 @@ describe("slash commands", () => { await executeSlashCommand("resume", "Draft", ctx); expect(ctx.resumeThread).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes"); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft (thread-a), Draft notes (thread-b)"); }); it("returns referenced input for /refer", async () => { @@ -428,7 +428,7 @@ describe("slash commands", () => { await executeSlashCommand("archive", "Draft", ctx); expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes"); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft (thread-a), Draft notes (thread-b)"); }); it("renames a selected thread by id argument", async () => { @@ -477,7 +477,7 @@ describe("slash commands", () => { await executeSlashCommand("rename", "Draft New name", ctx); expect(ctx.threadActions.renameThread).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes"); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft (thread-a), Draft notes (thread-b)"); }); it("documents archive", () => { diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index fcee17e3..0038bf05 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -33,10 +33,8 @@ describe("settings tab", () => { }); it("uses a placeholder for threads without a useful title", () => { - 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: null, preview: "" }))).toBe("Untitled thread"); + expect(threadArchiveDisplayTitle(panelThread({ name: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", preview: "" }))).toBe("Untitled thread"); expect(threadArchiveDisplayTitle(panelThread({ name: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", preview: "Preview title" }))).toBe( "Preview title", );