mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Clarify thread title semantics
This commit is contained in:
parent
364be69754
commit
5eb8bc9933
15 changed files with 83 additions and 58 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() : "";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue