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
2 changes: 2 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/AgentTag.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
font-size: inherit;
line-height: calc(1em + var(--ds-space-1));
letter-spacing: inherit;
/* A name, not a count: never the tabular figures a status line or tool row sets for its timer. */
font-variant-numeric: normal;
text-overflow: ellipsis;
white-space: nowrap;
cursor: default;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
text-overflow: ellipsis;
white-space: nowrap;
color: var(--cv-text-dim);
/* Session names, not a count: set as the row's words, never in tabular figures. */
font-variant-numeric: normal;
}
.cv-message-group__head > .cv-tool__chevron {
opacity: 0;
Expand Down
3 changes: 2 additions & 1 deletion webviews/src/agent-session/acpmux/conversation/Thinking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ export function Thinking({
</div>
);
return (
<div className={`cv-worked${below ? " is-below" : ""}${quiet ? "" : " is-quiet"}`} data-find-skip>
// `cv-worked--live`: its words start on the icon rows' text column (conversation.css).
<div className={`cv-worked cv-worked--live${below ? " is-below" : ""}${quiet ? "" : " is-quiet"}`} data-find-skip>
{waiting ? (
<output className="cv-thinking cv-waiting" data-waiting={waiting}>
{t(WAITING_LABEL[waiting])}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2081,6 +2081,14 @@ pre.cv-math-source {
keeps the footer's 4px meta gap) and 20px under a tool row, as the Codex and Claude desktop apps
space it, never flush under the text (Leo, 2026-10-21: "THE RUNNING A COMMAND IS WAY TOO CLOSE").
The line reserves the normal turn gap below it, so the end-of-turn swap does not move anything. */
/* The live line keeps the shared icon slot empty: its words start on the text column every iconed
status row's words start on, never 24px left of them on the icon column (Leo, 2026-11-03, on
"Writing a command" under "Subagent finished" and over "1 message from 1 session": "weird
indenting /cut off text?"; design/chat-transcript.md "One icon column"). A folded turn's
"Worked for" line keeps col-left. */
.cv-worked.cv-worked--live {
padding-inline-start: var(--cv-tool-text-inset);
}
.cv-worked.is-below {
/* The footer's slot (transcriptGrid.ts MARK_FOOTER_SLOT): its 32 line, 8 under a reply. */
height: var(--cv-footer-row);
Expand Down
4 changes: 3 additions & 1 deletion webviews/test/subagent-done-chip-width.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ await requireBrowserLane("subagent-done-chip-width.test.ts", async () => {
});

test("a failure with a long error: the error is cut first, the chip keeps its whole name", async () => {
const [line] = await lines("failed-long-error", 420);
// At 900px the name fits beside the line's other parts and only the error overflows; at 420px
// the name alone does not fit, so both are cut.
const [line] = await lines("failed-long-error", 900);
expect(line?.suffix?.cut).toBe(true);
expect(line!.chip.cut).toBe(false);
});
Expand Down
121 changes: 121 additions & 0 deletions webviews/test/transcript-status-text-column.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Every status row's words start on one column, and a session's name reads as the row's words.
//
// Leo, 2026-11-03, on a running turn's bottom rows ("Subagent finished: [Push stranded lane ref] ›",
// "Writing a command 5m 16s · ↓ 7.5K out", "🤖 1 message from 1 session"): "weird indenting /cut off
// text?". The live line had no icon and started its words on the icon column, 24px left of every
// other row's words. design/chat-transcript.md "One icon column": the live activity line reserves
// the shared empty icon slot.
//
// Measured in Chromium on gallery entry agent-pane.transcript-spacing-table (Leo's captures,
// variants working-bottom and reply-received):
// - the first glyph of every status row with an icon (a tool run, a finished subagent, a received
// line, the waiting messages' head) and of the live line ("Thinking") share one x;
// - a session's name (the waiting head's senders, a session chip) takes no tabular figures, so it
// is set as the row's words are.
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame";
import { requireBrowserLane } from "./support/requireBrowserLane";

await requireBrowserLane("transcript-status-text-column.test.ts", async () => {
setDefaultTimeout(240_000);
const ENTRY = "agent-pane.transcript-spacing-table";
const VARIANTS = ["working-bottom", "reply-received"];
const TOLERANCE = 0.5;

try {
await (await chromium.launch({ headless: true })).close();
} catch {
describe.skip("transcript status text column", () => test("browser", () => {}));
return;
}
const { createServer } = await import("vite");
const webviews = path.resolve(import.meta.dir, "..");
let vite: Awaited<ReturnType<typeof createServer>> | undefined;
let browser: Browser;

beforeAll(async () => {
vite = await createServer({
configFile: path.join(webviews, "vite.config.gallery-dev.ts"),
server: { port: 0, host: "127.0.0.1", strictPort: false },
logLevel: "error",
});
await vite.listen();
browser = await chromium.launch({ headless: true });
await warmGalleryFrame(vite, ENTRY, { browser, params: { variant: VARIANTS[0] } });
});
afterAll(async () => {
await browser?.close();
await vite?.close();
});

type Measured = {
rows: { kind: string; text: string; x: number }[];
names: { text: string; numeric: string }[];
};

const measure = async (variant: string): Promise<Measured> => {
const page = await browser.newPage({ viewport: { width: 800, height: 1100 }, deviceScaleFactor: 2 });
try {
const port = (vite!.httpServer!.address() as { port: number }).port;
await page.goto(`http://127.0.0.1:${port}${galleryFrame(ENTRY, { variant, width: 760, reducedMotion: "true" })}`);
await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1", undefined, {
timeout: 120_000,
});
await page.evaluate(() => document.fonts.ready);
await page.mouse.move(0, 0);
return await page.evaluate(() => {
/** The left edge of the first laid-out words in `scope`. */
const firstWords = (scope: Element) => {
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.textContent?.trim()) continue;
const range = document.createRange();
range.selectNodeContents(node);
const rect = range.getClientRects()[0];
if (rect && rect.width > 0) return { x: rect.left, text: node.textContent.trim().slice(0, 40) };
}
return null;
};
const rows: Measured["rows"] = [];
for (const row of document.querySelectorAll(".acpmux-row")) {
if (!row.getBoundingClientRect().height) continue;
// The rows that draw a leading icon, and the live line, which draws none.
const live = row.matches(".acpmux-thinking");
if (!live && !row.querySelector(":scope .cv-tool__icon, :scope .cv-received__mark")) continue;
if (row.matches(".acpmux-user:not(.acpmux-row--line), .acpmux-turnSummary")) continue;
const words = firstWords(row);
if (words) rows.push({ kind: live ? "live" : (row.className.split(" ")[1] ?? ""), ...words });
}
const names = [...document.querySelectorAll(".acpmux-row :is(.cv-message-group__senders, .cv-agent-tag)")].map(
(name) => ({
text: (name.textContent ?? "").trim().slice(0, 40),
numeric: getComputedStyle(name.querySelector(".cv-agent-tag__label") ?? name).fontVariantNumeric,
}),
);
return { rows, names };
});
} finally {
await page.close();
}
};

for (const variant of VARIANTS) {
test(`${variant}: the live line's words start on the icon rows' text column`, async () => {
const { rows } = await measure(variant);
const live = rows.filter((row) => row.kind === "live");
const iconed = rows.filter((row) => row.kind !== "live");
expect(live.length).toBeGreaterThan(0);
expect(iconed.length).toBeGreaterThan(0);
const column = iconed[0]!.x;
const off = rows.filter((row) => Math.abs(row.x - column) > TOLERANCE);
expect(off).toEqual([]);
});
test(`${variant}: a session's name takes no tabular figures`, async () => {
const { names } = await measure(variant);
expect(names.length).toBeGreaterThan(0);
expect(names.filter((name) => name.numeric.includes("tabular-nums"))).toEqual([]);
});
}
});
Loading