Centralize thread title projections

This commit is contained in:
murashit 2026-06-21 15:55:02 +09:00
parent dd9a0b42dc
commit 80edc64443
17 changed files with 146 additions and 62 deletions

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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<string, unknown> {
@ -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 {

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

View file

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

View file

@ -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<string, O
}
function snapshotStatus(snapshot: OpenCodexPanelSnapshot): ThreadsLiveStatus {
if (snapshot.pendingUserInputs > 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";

View file

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

View file

@ -1,19 +0,0 @@
import { getThreadTitle, type Thread } from "../domain/threads/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 archivedThreadDisplayTitle(thread: Thread): string {
const title = normalizedThreadTitle(thread);
if (!title || title === thread.id || UUID_PATTERN.test(title)) return "Untitled archived thread";
return truncateTitle(title, MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH);
}
function normalizedThreadTitle(thread: Thread): string {
return getThreadTitle(thread).replace(/\s+/g, " ").trim();
}
function truncateTitle(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3).trimEnd()}...`;
}

View file

@ -6,8 +6,8 @@ import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../ap
import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../domain/catalog/metadata";
import type { Thread } from "../domain/threads/model";
import { threadArchiveDisplayTitle } from "../domain/threads/title";
import { errorMessage } from "../utils";
import { archivedThreadDisplayTitle } from "./archived-thread-title";
import { loadHookData } from "./app-server-data";
import type { SettingsDynamicDataHost } from "./host";
import {
@ -19,7 +19,7 @@ import {
} from "./lifecycle";
function archivedThreadTitleForStatus(thread: Thread | undefined, threadId: string): string {
return thread ? archivedThreadDisplayTitle(thread) : threadId;
return thread ? threadArchiveDisplayTitle(thread) : threadId;
}
interface SettingsDynamicDataControllerCallbacks {
@ -355,7 +355,7 @@ export class SettingsDynamicDataController {
this.host.threadCatalog.recordThreadRestored(restoredThread);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Restored "${archivedThreadDisplayTitle(restoredThread)}".`,
status: `Restored "${threadArchiveDisplayTitle(restoredThread)}".`,
operationToken,
});
},

View file

@ -8,6 +8,7 @@ import {
upsertThread,
type Thread,
} from "../../../src/domain/threads/model";
import { threadArchiveDisplayTitle, threadRenameDraftTitle, threadUserTitle, threadWindowTitle } from "../../../src/domain/threads/title";
describe("thread helpers", () => {
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();

View file

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

View file

@ -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> = {}): Thread {
...overrides,
};
}
function openPanelSnapshot(overrides: Partial<OpenCodexPanelSnapshot> = {}): OpenCodexPanelSnapshot {
return {
viewId: "view",
threadId: "thread",
turnLifecycle: { kind: "idle" },
pendingApprovals: 0,
pendingUserInputs: 0,
pendingMcpElicitations: 0,
hasComposerDraft: false,
connected: true,
lastFocused: false,
...overrides,
};
}

View file

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