Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 9 additions & 16 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -8453,22 +8454,14 @@ export default function App() {
<span className="session-header-title electrobun-webkit-app-region-drag text-ui max-w-96 truncate font-medium">
{activeTitle}
</span>
{/* 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() ? (
<>
<span className="text-ui text-muted-foreground/50 shrink-0">
/
</span>
<span className="electrobun-webkit-app-region-drag text-fine text-muted-foreground max-w-64 truncate">
{activeSessionTitle}
</span>
</>
) : 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 : (
<SessionTitlePair
taskTitle={activeBoardTask.title}
sessionTitle={activeSessionTitle}
/>
)}
{!activeBoardTask &&
activeSession != null &&
activeSession !== "" ? (
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/electrobun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,16 +502,17 @@ 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", () => {
if (process.platform === "darwin") {
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 });
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/session/ChartBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export function ChartBlock({ spec }: { spec: ChartSpec }) {
</figcaption>
{spec.series.length > 1 ? (
<div
className="mb-1.5 flex flex-wrap gap-x-3 gap-y-1"
className="-ms-surface-inset mb-1.5 flex flex-wrap gap-x-3 gap-y-1"
aria-label={t("chart.series")}
>
{spec.series.map((series, index) => (
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/session/SessionTitlePair.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<span className="text-ui text-muted-foreground/50 shrink-0">/</span>
<span className="electrobun-webkit-app-region-drag text-fine text-muted-foreground max-w-64 truncate">
{trailing}
</span>
</>
);
}
6 changes: 3 additions & 3 deletions apps/desktop/src/session/TurnCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ function ToolCallBlock({
return (
<div
className={cn(
"text-body text-muted-foreground flex min-w-0 items-center gap-2 px-1",
"text-body text-muted-foreground flex min-w-0 items-center gap-2 pe-1",
compact ? "py-1" : "my-3 py-1.5"
)}
data-tool-call={tool.id}
Expand All @@ -292,7 +292,7 @@ function ToolCallBlock({
/>
}
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"
)}
>
Expand Down Expand Up @@ -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"
>
<LatestIcon className="size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate" title={latest.title}>
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/session/title.ts
Original file line number Diff line number Diff line change
@@ -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;
}
54 changes: 54 additions & 0 deletions apps/desktop/tests/sessionTitle.test.ts
Original file line number Diff line number Diff line change
@@ -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("<SessionTitlePair");
expect(appSource).toContain("taskTitle={activeBoardTask.title}");
expect(appSource).not.toContain("activeSessionTitle.trim() !==");
});
51 changes: 51 additions & 0 deletions apps/desktop/tests/sessionTitlePairRendered.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @ts-nocheck
import { afterEach, describe, expect, test } from "bun:test";

import { activateDom, dom, mount, restoreDom } from "./domTestHarness";

activateDom();
const { SessionTitlePair } = await import("../src/session/SessionTitlePair");

afterEach(() => {
dom.document.body.replaceChildren();
restoreDom();
});

describe("SessionTitlePair rendered header pair", () => {
test("prints nothing when the task already names the thread", () => {
const rendered = mount(
<SessionTitlePair
taskTitle="帮我把这个项目里的图像都压缩成 WebP。"
sessionTitle="帮我把这个项目里的图像都压缩成 WebP"
/>
);

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(
<SessionTitlePair
taskTitle="Release notes"
sessionTitle="Fix the parser"
/>
);
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(
<SessionTitlePair taskTitle="Release notes" sessionTitle=" " />
);

expect(rendered.container.textContent).toBe("");
rendered.unmount();
});
});
118 changes: 118 additions & 0 deletions apps/desktop/tests/turnCardRendered.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<I18nProvider>
<TurnCard turn={turn} />
</I18nProvider>
);
const group = rendered.container.querySelector("[data-tool-call-group]");
const trigger = group?.querySelector("button");
const title = trigger?.querySelector("span.flex-1");

// A native <button> centers text by default; the row trigger must opt back into the leading
// edge so the title sits beside the tool icon instead of floating mid-row. The trigger also
// drops its start inset so that icon lines up with the transcript text column.
expect(trigger?.classList.contains("justify-start")).toBe(true);
expect(trigger?.classList.contains("text-start")).toBe(true);
expect(trigger?.classList.contains("has-[>svg]:ps-0")).toBe(true);
expect(title?.classList.contains("min-w-0")).toBe(true);
expect(title?.classList.contains("flex-1")).toBe(true);
expect(title?.classList.contains("truncate")).toBe(true);

click(trigger!);
await flush();

const compact = group?.querySelector("[data-tool-call] button");
expect(compact?.classList.contains("text-start")).toBe(true);
expect(compact?.classList.contains("has-[>svg]:ps-0")).toBe(true);
expect(compact?.textContent).toContain("Search tools");
rendered.unmount();
});

test("keeps a standalone tool row on the shared content column", () => {
activateDom();
disableCanvasDrawing();
const rendered = mount(
<I18nProvider>
<TurnCard
turn={{
...runningTurn(),
tools: [
{
id: "run-1",
title: "Run renderer verification",
status: "in_progress",
},
],
}}
/>
</I18nProvider>
);
const row = rendered.container.querySelector('[data-tool-call="run-1"]');

// The no-output row carries its own inset; only the end side stays so the leading icon shares
// the markdown text edge.
expect(row?.classList.contains("pe-1")).toBe(true);
expect(row?.classList.contains("px-1")).toBe(false);
rendered.unmount();
});

test("keeps the chart legend on the shared content column", () => {
activateDom();
disableCanvasDrawing();
const source = `\`\`\`chart\n${JSON.stringify({
type: "bar",
title: "Build time",
xLabel: "Release",
yLabel: "Seconds",
labels: ["1.0", "1.1"],
series: [
{ name: "Desktop", values: [42, 31] },
{ name: "Windows", values: [30, 28] },
],
})}\n\`\`\``;
const rendered = mount(
<I18nProvider>
<TurnCard
turn={{
...runningTurn(),
text: source,
textDeltas: [source],
content: [{ kind: "text", text: source, transcriptSeq: 11 }],
endedAt: 2,
}}
/>
</I18nProvider>
);
const legend = rendered.container.querySelector("figure [aria-label]");

expect(legend?.children).toHaveLength(2);
expect(legend?.classList.contains("-ms-surface-inset")).toBe(true);
rendered.unmount();
});

test("keeps an active tool history open with a bounded Codex-style fade", () => {
activateDom();
disableCanvasDrawing();
Expand Down
Loading
Loading