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