From 901778b6da86572113b0622394e32b4d2b5f3046 Mon Sep 17 00:00:00 2001 From: idevlab Date: Wed, 16 Sep 2026 11:57:47 +0800 Subject: [PATCH 1/2] fix(desktop): align tool cards and traffic lights, dedupe session title - Share the assistant column's leading edge for tool titles, icons, and the chart legend - Pin the native macOS traffic lights to the rail's 16px leading column - Render one session header title by treating matching task/session names as one thread --- apps/desktop/src/App.tsx | 25 ++-- apps/desktop/src/electrobun/index.ts | 7 +- apps/desktop/src/session/ChartBlock.tsx | 2 +- apps/desktop/src/session/SessionTitlePair.tsx | 25 ++++ apps/desktop/src/session/TurnCard.tsx | 6 +- apps/desktop/src/session/title.ts | 40 ++++++ apps/desktop/tests/sessionTitle.test.ts | 54 ++++++++ .../tests/sessionTitlePairRendered.test.tsx | 51 ++++++++ apps/desktop/tests/turnCardRendered.test.tsx | 118 ++++++++++++++++++ .../tests/windowChromeContract.test.ts | 8 +- .../evidence/tool-card-after-leading-dark.png | Bin 0 -> 109214 bytes .../evidence/tool-card-before-centered.png | Bin 0 -> 121597 bytes .../tool-card-column-aligned-dark.png | Bin 0 -> 107645 bytes .../tool-card-column-aligned-light.png | Bin 0 -> 105286 bytes .../tool-card-column-aligned-narrow.png | Bin 0 -> 230608 bytes .../intent.md | 46 +++++++ .../2026-09-15-align-tool-card-title/plan.md | 41 ++++++ .../2026-09-15-align-tool-card-title/spec.md | 58 +++++++++ .../verification.md | 85 +++++++++++++ .../evidence/traffic-lights-column-light.png | Bin 0 -> 112911 bytes .../2026-09-16-align-traffic-lights/intent.md | 43 +++++++ .../2026-09-16-align-traffic-lights/plan.md | 40 ++++++ .../2026-09-16-align-traffic-lights/spec.md | 42 +++++++ .../verification.md | 76 +++++++++++ .../intent.md | 49 ++++++++ .../plan.md | 47 +++++++ .../spec.md | 49 ++++++++ .../verification.md | 73 +++++++++++ 28 files changed, 958 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/session/SessionTitlePair.tsx create mode 100644 apps/desktop/src/session/title.ts create mode 100644 apps/desktop/tests/sessionTitle.test.ts create mode 100644 apps/desktop/tests/sessionTitlePairRendered.test.tsx create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/evidence/tool-card-after-leading-dark.png create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/evidence/tool-card-before-centered.png create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/evidence/tool-card-column-aligned-dark.png create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/evidence/tool-card-column-aligned-light.png create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/evidence/tool-card-column-aligned-narrow.png create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/intent.md create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/plan.md create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/spec.md create mode 100644 docs/sdlc/changes/2026-09-15-align-tool-card-title/verification.md create mode 100644 docs/sdlc/changes/2026-09-16-align-traffic-lights/evidence/traffic-lights-column-light.png create mode 100644 docs/sdlc/changes/2026-09-16-align-traffic-lights/intent.md create mode 100644 docs/sdlc/changes/2026-09-16-align-traffic-lights/plan.md create mode 100644 docs/sdlc/changes/2026-09-16-align-traffic-lights/spec.md create mode 100644 docs/sdlc/changes/2026-09-16-align-traffic-lights/verification.md create mode 100644 docs/sdlc/changes/2026-09-16-dedupe-session-header-title/intent.md create mode 100644 docs/sdlc/changes/2026-09-16-dedupe-session-header-title/plan.md create mode 100644 docs/sdlc/changes/2026-09-16-dedupe-session-header-title/spec.md create mode 100644 docs/sdlc/changes/2026-09-16-dedupe-session-header-title/verification.md 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