diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4d11377e..714e6b2a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -408,6 +408,7 @@ import type { SessionCreationShell, } from "./session/sessionEvents"; import { SessionHeaderActions } from "./session/SessionHeaderActions"; +import { SessionTitlePair } from "./session/SessionTitlePair"; import { QuickChatPanel, SideChatPanel } from "./session/SideChatPanel"; import type { TransientChatSeed } from "./session/SideChatPanel"; import { StageTrack } from "./session/StageTrack"; @@ -8453,22 +8454,14 @@ export default function App() { {activeTitle} - {/* The session title trails the task title for context — unless both carry the same - name (a task created from a single-prompt thread), which would print it twice. */} - {activeBoardTask && - activeSessionTitle != null && - activeSessionTitle !== "" && - activeSessionTitle.trim() !== - activeBoardTask.title.trim() ? ( - <> - - / - - - {activeSessionTitle} - - - ) : null} + {/* The session title trails the task title for context — unless both name the + same thread (a task created from a single-prompt thread). */} + {activeBoardTask == null ? null : ( + + )} {!activeBoardTask && activeSession != null && activeSession !== "" ? ( diff --git a/apps/desktop/src/electrobun/index.ts b/apps/desktop/src/electrobun/index.ts index 7f8322c8..db54389d 100644 --- a/apps/desktop/src/electrobun/index.ts +++ b/apps/desktop/src/electrobun/index.ts @@ -502,7 +502,7 @@ if (process.platform === "darwin") { } // AppKit can reset standard-window-button frames during its own resize layout pass. Reapply the // same fixed position afterward; the 46px titlebar has no runtime geometry to measure. - mainWindow.on("resize", () => mainWindow.setWindowButtonPosition(22, 16)); + mainWindow.on("resize", () => mainWindow.setWindowButtonPosition(16, 16)); } mainWindow.webview.on("dom-ready", () => { @@ -510,8 +510,9 @@ mainWindow.webview.on("dom-ready", () => { mainWindow.webview.executeJavascript( 'document.documentElement.classList.add("macos-window-glass")' ); - // Center the 14px native controls in the shared 46px Codex-aligned title row. - mainWindow.setWindowButtonPosition(22, 16); + // Share the rail's leading content column (mx-2 + px-2 = 16px) and center the 14px native + // controls in the shared 46px title row. + mainWindow.setWindowButtonPosition(16, 16); } rendererReady = true; rpc.send.hostStatus({ ready: true }); diff --git a/apps/desktop/src/session/ChartBlock.tsx b/apps/desktop/src/session/ChartBlock.tsx index 645bd0af..e35a5b55 100644 --- a/apps/desktop/src/session/ChartBlock.tsx +++ b/apps/desktop/src/session/ChartBlock.tsx @@ -202,7 +202,7 @@ export function ChartBlock({ spec }: { spec: ChartSpec }) { {spec.series.length > 1 ? (
{spec.series.map((series, index) => ( diff --git a/apps/desktop/src/session/SessionTitlePair.tsx b/apps/desktop/src/session/SessionTitlePair.tsx new file mode 100644 index 00000000..e322246d --- /dev/null +++ b/apps/desktop/src/session/SessionTitlePair.tsx @@ -0,0 +1,25 @@ +import { sessionTitleTail } from "./title"; + +/** + * The automatic session title that trails a pane's task title for context. A task created from a + * single-prompt thread already names the thread, so this renders nothing when both names agree + * (`session/title.ts` owns the comparison) and never renders an empty session title. + */ +export function SessionTitlePair({ + taskTitle, + sessionTitle, +}: { + taskTitle: string; + sessionTitle: string | null | undefined; +}) { + const trailing = sessionTitleTail(taskTitle, sessionTitle); + if (trailing == null) return null; + return ( + <> + / + + {trailing} + + + ); +} diff --git a/apps/desktop/src/session/TurnCard.tsx b/apps/desktop/src/session/TurnCard.tsx index fa97eda8..a997ecac 100644 --- a/apps/desktop/src/session/TurnCard.tsx +++ b/apps/desktop/src/session/TurnCard.tsx @@ -265,7 +265,7 @@ function ToolCallBlock({ return (
} className={cn( - "group text-muted-foreground hover:text-foreground h-auto w-full min-w-0 justify-start gap-2", + "group text-muted-foreground hover:text-foreground h-auto w-full min-w-0 justify-start gap-2 text-start has-[>svg]:ps-0", compact ? "py-1" : "py-1.5" )} > @@ -396,7 +396,7 @@ function ToolCallGroup({ tools }: { tools: ToolEntry[] }) { focusStyle="inset" /> } - className="group text-muted-foreground hover:text-foreground h-auto w-full min-w-0 justify-start gap-2 py-1.5" + className="group text-muted-foreground hover:text-foreground h-auto w-full min-w-0 justify-start gap-2 py-1.5 text-start has-[>svg]:ps-0" > diff --git a/apps/desktop/src/session/title.ts b/apps/desktop/src/session/title.ts new file mode 100644 index 00000000..1a4f43c1 --- /dev/null +++ b/apps/desktop/src/session/title.ts @@ -0,0 +1,40 @@ +/** + * The board auto-names a task from the submitted prompt (`summarizeDoc`, whitespace-collapsed and + * sliced to 72 characters), while its session carries the core's automatic first-sentence title + * (`initial_session_title`: leading markdown dropped, first sentence only, bounded to 8 words / 40 + * characters, 24 for unspaced scripts). Both name the same thread, so the session header must treat + * them as one name instead of printing the title twice. + */ + +/** Case-folded comparison key: whitespace collapsed, leading markdown and trailing punctuation dropped. */ +export function threadTitleKey(value: string): string { + return value + .replaceAll(/\s+/gu, " ") + .trim() + .replace(/^[#>*-+`"']+\s*/u, "") + .replace(/[.!?。?!;;::,,、"'“”‘’]+$/u, "") + .toLocaleLowerCase(); +} + +/** + * Whether two display names describe one thread. A prefix counts because the prompt slice and the + * automatic title stop at independent bounds. + */ +export function sameThreadTitle(left: string, right: string): boolean { + const a = threadTitleKey(left); + const b = threadTitleKey(right); + if (a === "" || b === "") return false; + return a === b || a.startsWith(b) || b.startsWith(a); +} + +/** + * The session name to trail after the task title, or `null` when the task already names the thread. + * An empty session title never renders. + */ +export function sessionTitleTail( + taskTitle: string, + sessionTitle: string | null | undefined +): string | null { + if (sessionTitle == null || sessionTitle.trim() === "") return null; + return sameThreadTitle(taskTitle, sessionTitle) ? null : sessionTitle; +} diff --git a/apps/desktop/tests/sessionTitle.test.ts b/apps/desktop/tests/sessionTitle.test.ts new file mode 100644 index 00000000..7e20b4db --- /dev/null +++ b/apps/desktop/tests/sessionTitle.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import { sameThreadTitle, sessionTitleTail } from "../src/session/title"; + +const appSource = readFileSync( + new URL("../src/App.tsx", import.meta.url), + "utf-8" +); + +describe("thread title equivalence", () => { + test("treats the automatic first-sentence title as the same name as its prompt", () => { + // The board names the task from the raw prompt; the core's `initial_session_title` stops at the + // first sentence and drops its punctuation. A single-prompt thread must print one name. + const prompt = "帮我把这个项目里的图像都压缩成 WebP。"; + const automatic = "帮我把这个项目里的图像都压缩成 WebP"; + + expect(sameThreadTitle(automatic, prompt)).toBe(true); + expect(sessionTitleTail(prompt, automatic)).toBeNull(); + }); + + test("matches across whitespace, case, leading markdown, and bounded prefixes", () => { + expect(sameThreadTitle("# Fix the Parser", "fix the parser")).toBe(true); + expect( + sessionTitleTail( + "Add dark mode toggle across every pane", + "Add dark mode toggle" + ) + ).toBeNull(); + expect(sameThreadTitle("Ship it", "Ship it!")).toBe(true); + }); + + test("keeps a genuinely different session name", () => { + expect(sessionTitleTail("Release notes", "Fix the parser")).toBe( + "Fix the parser" + ); + expect(sessionTitleTail("Fix the parser", "Parser fix")).toBe("Parser fix"); + }); + + test("never renders an empty or missing session title", () => { + expect(sessionTitleTail("Fix the parser", null)).toBeNull(); + expect(sessionTitleTail("Fix the parser", undefined)).toBeNull(); + expect(sessionTitleTail("Fix the parser", " ")).toBeNull(); + }); +}); + +test("the session header renders its trailing title through the shared component", () => { + expect(appSource).toContain( + 'import { SessionTitlePair } from "./session/SessionTitlePair"' + ); + expect(appSource).toContain(" { + dom.document.body.replaceChildren(); + restoreDom(); +}); + +describe("SessionTitlePair rendered header pair", () => { + test("prints nothing when the task already names the thread", () => { + const rendered = mount( + + ); + + expect(rendered.container.textContent).toBe(""); + expect(rendered.container.querySelectorAll("span")).toHaveLength(0); + rendered.unmount(); + }); + + test("trails a genuinely different session name after the separator", () => { + const rendered = mount( + + ); + const spans = [...rendered.container.querySelectorAll("span")]; + + expect(spans).toHaveLength(2); + expect(spans[0].textContent).toBe("/"); + expect(spans[1].textContent).toBe("Fix the parser"); + rendered.unmount(); + }); + + test("prints nothing for an empty session title", () => { + const rendered = mount( + + ); + + expect(rendered.container.textContent).toBe(""); + rendered.unmount(); + }); +}); diff --git a/apps/desktop/tests/turnCardRendered.test.tsx b/apps/desktop/tests/turnCardRendered.test.tsx index 06da4b72..5f088425 100644 --- a/apps/desktop/tests/turnCardRendered.test.tsx +++ b/apps/desktop/tests/turnCardRendered.test.tsx @@ -457,6 +457,124 @@ describe("TurnCard rendered activity", () => { rendered.unmount(); }); + test("leads tool-card titles from the shared icon edge", async () => { + activateDom(); + disableCanvasDrawing(); + const turn = { + ...runningTurn(), + content: [ + { kind: "tool", toolId: "search-1", transcriptSeq: 11 }, + { kind: "tool", toolId: "search-2", transcriptSeq: 12 }, + ], + tools: [ + { + id: "search-1", + title: 'Search tools: "codetwo scenes"', + status: "completed", + kind: "other", + outputs: [{ type: "text", text: "catalog" }], + }, + { + id: "search-2", + title: 'Search tools: "codetwo openai"', + status: "completed", + kind: "other", + outputs: [{ type: "text", text: "computer use" }], + }, + ], + endedAt: 2, + }; + const rendered = mount( + + + + ); + const group = rendered.container.querySelector("[data-tool-call-group]"); + const trigger = group?.querySelector("button"); + const title = trigger?.querySelector("span.flex-1"); + + // A native