Clarify thread title semantics

This commit is contained in:
murashit 2026-06-21 17:32:32 +09:00
parent 364be69754
commit 5eb8bc9933
15 changed files with 83 additions and 58 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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