From 0d7aa836979587b9d7c5450e67549dfe05e9f02b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 02:43:08 -0800 Subject: [PATCH 1/4] test(transcript): a reply keeps its 2026-10-30 room (red) Gallery entry agent-pane.transcript-spacing-reference and a Chromium geometry test asserting the gaps feat-cmux-next 8f5dfe06ee58 (2026-10-30) drew for it. Red on base: reply -> status row 17.75px (43.75 then), reply -> prompt 21.75px (49.75 then). Fleet job 1337494cdd3059467b0e8a2e, light and dark. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-92fa78a91e29868c5eaa --- .../TranscriptSpacingReference.gallery.ts | 72 +++++++ .../test/transcript-spacing-reference.test.ts | 201 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 webviews/src/agent-session/acpmux/conversation/TranscriptSpacingReference.gallery.ts create mode 100644 webviews/test/transcript-spacing-reference.test.ts diff --git a/webviews/src/agent-session/acpmux/conversation/TranscriptSpacingReference.gallery.ts b/webviews/src/agent-session/acpmux/conversation/TranscriptSpacingReference.gallery.ts new file mode 100644 index 000000000000..682474d02c38 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/TranscriptSpacingReference.gallery.ts @@ -0,0 +1,72 @@ +// l10n-allow-file: gallery fixtures (sample prompts, replies, lane names and commands), not shipped UI. +// The transcript's vertical spacing as Leo's capture of a NEXT build from about 2026-10-30 drew it +// (Leo, 2026-11-01: "i like this spacing kinda i prefer it to what we have now tho it's just minor +// differences"). One long running turn in the capture's shape, with stand-in text: a two-line reply +// and a harness notice under it; a steer, a message to another agent and three paragraphs; a second +// steer, a run of commands, a one-line reply, a second run, an answered question and its answer. +// transcript-spacing-reference.test.ts measures each gap, ink to ink, against the capture's +// (design/chat-transcript.md "Transcript spacing"). +import answered from "../../../../../Packages/Shared/CmuxAgentQuestion/Sources/CmuxAgentQuestion/Fixtures/answered-collapsed.json"; +import { activity, assistant, chat, row, tool, user } from "../../../gallery/fixtures/acpmux"; +import { agentPaneEntry } from "../../../gallery/format"; +import type { AgentQuestion } from "../question/model"; +import { QUESTION_RECORD } from "../question/record"; + +const command = (line: string) => tool(line, "execute", "completed", { command: line }); +const send = (target: string, text: string) => + tool(`acpmux send --no-wait ${target} '${text}'`, "execute", "completed", { + command: `acpmux send --no-wait ${target} '${text}'`, + description: `Tell ${target}`, + output: "queued", + }); + +const REPLY = + "The docs half of the release page passed review. It merges once its queued checks finish, and the changelog PR for the same release goes in right after it, once its own checks are green too."; +const BACK = [ + "I'm here. The editor restarted, which can stop background lanes partway through. Their saved progress is kept, so I can pick them up where they left off when they come back.", + "I've restarted the lane fixing the stale release cache first, since other lanes queue behind that problem. It checks what it already pushed before going on, so nothing gets done twice.", + "The other lanes may have stopped in the restart too: search speed, the importer, the nightly notes, the theme pass and the settings work. I didn't restart them all at once, because that would load the machine up again. My plan is to bring them back one at a time, starting with whichever is closest to landing.", +].join("\n\n"); + +const steer = (text: string, minutes: number) => user(text, minutes, { steer: "landed" }); + +const rows = [ + user("Where is the release page?", 32), + assistant(REPLY, 31.5), + user("", 30.5, { incoming: { kind: "notice", text: "Fixed the stale release cache ยท took 32s" }, untyped: true }), + steer("are you there?", 20), + activity([send("cache-fix", "Pick up the stale release cache where you left off.")], 19.9), + assistant(BACK, 19.8), + steer("we should regroup since it's been over an hour since the first report came in this morning", 10), + activity( + [ + command("git status"), + command("git log --oneline -5"), + command("gh pr list --limit 20"), + command("rg -n release docs"), + command("bun run check"), + ], + 9.9, + ), + assistant("I'm reading the release dashboard to put together a short digest.", 9.8), + activity( + [command("gh run list --limit 5"), command("git fetch"), command("bun test"), send("cache-fix", "Status?")], + 9.7, + ), + row(QUESTION_RECORD, 9.6, { question: answered as unknown as AgentQuestion }), + steer("Yes, ship it", 9), +]; + +export default agentPaneEntry({ + id: "agent-pane.transcript-spacing-reference", + title: "Transcript spacing (the 2026-10-30 rhythm)", + area: "Agent pane", + height: 1400, + covers: ["agent-session/acpmux/conversation/conversation.css"], + variants: { + shipped: { + note: "One running turn in the shape of Leo's capture: a reply and a notice; a steer, a sent message and three paragraphs; a steer, a run, a one-line reply, a run, an answered question and its answer.", + snapshot: chat(rows, { title: "Release check", isWorking: true }), + }, + }, +}); diff --git a/webviews/test/transcript-spacing-reference.test.ts b/webviews/test/transcript-spacing-reference.test.ts new file mode 100644 index 000000000000..02c29eeba34d --- /dev/null +++ b/webviews/test/transcript-spacing-reference.test.ts @@ -0,0 +1,201 @@ +// A reply that something follows keeps the room its action row took on 2026-10-30, though it draws +// no row there now (model.ts `keepsReplyRoom`, conversation.css `--cv-reply-room`). +// +// Leo, 2026-11-01, on a capture he took of a NEXT build from about two days earlier: "i like this +// spacing kinda i prefer it to what we have now tho it's just minor differences". +// +// In gallery entry agent-pane.transcript-spacing-reference (one running turn in the capture's shape: a +// reply and a harness notice; a steer, a sent message and three paragraphs; a steer, a run of +// commands, a one-line reply, a run, an answered question and its answer), measured in Chromium in +// light and dark from one block's last drawn text line to the next block's first, each gap equals what +// feat-cmux-next 8f5dfe06ee58 (2026-10-30) drew for the same fixture, to 0.5px. Two of them moved +// with the per-reply action row's removal on 2026-10-31 and come back here: a reply to the status row +// under it (17.75px before, 43.75px after) and a reply to the next prompt (21.75 before, 49.75 after). +// The rest never moved and are asserted so they stay. No per-reply action row comes back. +// +// CAPTURE keeps the capture's own gaps, read from its pixels, for reference only: they are not the +// 2026-10-30 build's, and a follow-up decides whether they become the target +// (design/chat-transcript.md "Rows between replies"). +// +// CMUX_TRANSCRIPT_SPACING_LOG=1 logs every gap without the assertions. +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser, type Page } from "playwright"; +import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("transcript-spacing-reference.test.ts", async () => { + setDefaultTimeout(240_000); + const ENTRY = "agent-pane.transcript-spacing-reference"; + const THEMES = [ + { name: "Apple System Colors Light", file: "light" }, + { name: "Catppuccin Mocha", file: "dark" }, + ]; + const LOG_ONLY = process.env.CMUX_TRANSCRIPT_SPACING_LOG === "1"; + + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + describe.skip("transcript spacing reference", () => test("browser", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + let vite: Awaited> | 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: "shipped" } }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + type Measured = Record; + + /** + * Every gap the capture shows, in CSS px, ink to ink: from one block's last drawn text line to the + * next block's first (a prompt's words, not its bubble; the question card's words, not its border), + * so it compares with the capture, where only the text can be measured. null where a block is missing. + */ + const measure = (page: Page): Promise => + page.evaluate(() => { + const shown = (element: Element) => { + const box = element.getBoundingClientRect(); + if (box.width === 0 || box.height === 0) return false; + for (let node: Element | null = element; node; node = node.parentElement) { + const style = getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false; + } + return true; + }; + type Box = { top: number; bottom: number }; + /** The drawn text of `scope`: the union of its shown text runs' boxes. */ + const ink = (scope: Element | null | undefined): Box | null => { + if (!scope) return null; + let box: Box | null = null; + const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (!node.textContent?.trim() || !node.parentElement || !shown(node.parentElement)) continue; + const range = document.createRange(); + range.selectNodeContents(node); + for (const rect of range.getClientRects()) { + if (rect.width === 0 || rect.height === 0) continue; + box = box + ? { top: Math.min(box.top, rect.top), bottom: Math.max(box.bottom, rect.bottom) } + : { top: rect.top, bottom: rect.bottom }; + } + } + return box; + }; + /** The shown element whose own text holds `text`. */ + const holding = (text: string): HTMLElement | null => { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) + if (node.textContent?.includes(text) && node.parentElement && shown(node.parentElement)) + return node.parentElement; + return null; + }; + const rowOf = (text: string) => holding(text)?.closest(".acpmux-row") ?? null; + const rows = [...document.querySelectorAll(".acpmux-row")] + .filter(shown) + .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); + const rowAfter = (row: HTMLElement | null) => (row ? (rows[rows.indexOf(row) + 1] ?? null) : null); + const paragraph = (text: string) => ink(holding(text)?.closest(".cv-md p, .cv-md li") ?? holding(text)); + const prompt = (text: string) => ink(holding(text)?.closest(".cv-user__bubble") ?? holding(text)); + + const reply = paragraph("docs half of the release page"); + const notice = ink(rowOf("Fixed the stale release cache")); + const there = prompt("are you there?"); + const sent = ink(rowAfter(rowOf("are you there?"))); + const first = paragraph("I'm here. The editor restarted"); + const second = paragraph("I've restarted the lane"); + const third = paragraph("The other lanes may have stopped"); + const regroup = prompt("we should regroup"); + const runFive = ink(rowAfter(rowOf("we should regroup"))); + const card = ink(holding("Which auth method")?.closest(".acpmux-row")); + const ship = prompt("Yes, ship it"); + + const gap = (above: Box | null, below: Box | null) => + above && below ? Math.round((below.top - above.bottom) * 100) / 100 : null; + return { + "reply -> notice": gap(reply, notice), + "notice -> prompt": gap(notice, there), + "prompt -> sent message": gap(there, sent), + "sent message -> paragraph": gap(sent, first), + "paragraph -> paragraph": gap(first, second), + "paragraph -> paragraph (2)": gap(second, third), + "paragraph -> prompt": gap(third, regroup), + "prompt -> run summary": gap(regroup, runFive), + "card -> prompt": gap(card, ship), + }; + }); + + const open = async (theme: string) => { + const page = await browser.newPage({ viewport: { width: 1200, height: 1700 }, deviceScaleFactor: 2 }); + const port = (vite!.httpServer!.address() as { port: number }).port; + await page.goto( + `http://127.0.0.1:${port}${galleryFrame(ENTRY, { variant: "shipped", width: 1100, theme, 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 page; + }; + + /** What 8f5dfe06ee58 (2026-10-30) drew, CSS px (fleet job dbcc3aa4fd702e93d4132ce0, light and dark alike). */ + const OCT_30: Record = { + "reply -> notice": 43.75, + "notice -> prompt": 23, + "prompt -> sent message": 53.25, + "sent message -> paragraph": 10.5, + "paragraph -> paragraph": 19.75, + "paragraph -> paragraph (2)": 19.75, + "paragraph -> prompt": 49.75, + "prompt -> run summary": 51.75, + "card -> prompt": 28, + }; + const TOLERANCE = 0.5; + + /** + * The capture's gaps, text ink to text ink, CSS px (image px times 22.75 / 30: its body line pitch is + * 30 image px). Reference only, not asserted: its build is not known yet. + */ + const CAPTURE: Record = { + "reply -> notice": 29.6, + "notice -> prompt": 84.9, + "prompt -> sent message": 62.2, + "sent message -> paragraph": 27.3, + "paragraph -> paragraph": 19.7, + "paragraph -> paragraph (2)": 20.5, + "paragraph -> prompt": 81.9, + "prompt -> run summary": 65.2, + "card -> prompt": 72.8, + }; + + for (const theme of THEMES) + test(`a reply keeps its 2026-10-30 room (${theme.name})`, async () => { + const page = await open(theme.name); + try { + const measured = await measure(page); + console.log(`TRANSCRIPT_SPACING ${theme.file} ${JSON.stringify(measured)} capture ${JSON.stringify(CAPTURE)}`); + if (LOG_ONLY) return; + const off = Object.entries(OCT_30) + .filter(([name, want]) => measured[name] === null || Math.abs(measured[name]! - want) > TOLERANCE) + .map(([name, want]) => `${name}: ${measured[name]}px, not ${want}px`); + expect(off).toEqual([]); + } finally { + await page.close(); + } + }); +}); From 1616b7cff8029912b8cd47062557874adb2fb0e9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 02:43:36 -0800 Subject: [PATCH 2/4] Transcript: a reply keeps the room its action row took on 2026-10-30 Leo, 2026-11-01: "i like this spacing kinda i prefer it to what we have now tho it's just minor differences". The per-reply action row went on 10-31 (one footer per turn) and took its 28px with it. model.ts keepsReplyRoom (the old hasReplyActions condition) gives that reply 36px under its text again (--cv-reply-room) without drawing a row; estimates count it. A status row under such a reply now has 26px more room above than below; transcript-row-alignment checks that, and every left edge as before. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-92fa78a91e29868c5eaa --- webviews/src/agent-session/acpmux/App.tsx | 17 +++++----- .../TranscriptRowAlignment.gallery.ts | 7 ++-- .../acpmux/conversation/conversation.css | 12 +++++++ webviews/src/agent-session/acpmux/model.ts | 28 +++++++++++---- .../agent-session/acpmux/rowEstimates.test.ts | 9 ++--- .../test/transcript-row-alignment.test.ts | 34 +++++++++++++------ 6 files changed, 76 insertions(+), 31 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index c824d46d208d..cfe94e4c1b03 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -50,6 +50,7 @@ import { prevDrawnRow, isBeforeFooter, hasReplyActions, + keepsReplyRoom, statusEdge, type StatusEdge, isLastFooter, @@ -1902,7 +1903,7 @@ export const readOnlyRegistry: NativeRegistry = { }; /// A row's height as the page drew it at a width. It places the row until its next version is drawn. -/// `actions`: whether a reply drew its own action row then (model.ts `hasReplyActions`); a drawn +/// `actions`: whether a reply kept its action row's room then (model.ts `keepsReplyRoom`); a drawn /// height taken without it, or with it, is stale once the row after the reply changes that. type DrawnHeight = { sessionId?: string; @@ -2092,7 +2093,7 @@ const RowFrame = React.memo(function RowFrame({ joinsNext: boolean; /// A reply right over its turn's footer: the footer hugs it (`--cv-meta-gap`), as a meta row. beforeFooter: boolean; - /// Whether the row (a reply) draws its own action row (model.ts `hasReplyActions`): its height + /// Whether the row (a reply) keeps its action row's room (model.ts `keepsReplyRoom`): its height /// depends on it, so the frame redraws and measures again when it changes. replyActions: boolean; /// Next to a status row (model.ts `statusEdge`): the room between the row and the next one is the @@ -2209,7 +2210,7 @@ const RowFrame = React.memo(function RowFrame({ id={rowDomId(row.id)} data-row-id={row.id} data-row-card={row.ended ? "true" : undefined} - className={`acpmux-row acpmux-${kind}${row.below ? " acpmux-row--below" : ""}${joinsPrevious ? " acpmux-row--after-step" : ""}${joinsNext ? " acpmux-row--before-step" : ""}${beforeFooter ? " acpmux-row--before-footer" : ""}${status ? ` acpmux-row--${status}` : ""}${lastFooter ? " acpmux-row--last-footer" : ""}${continuationFooter ? " acpmux-row--continuation-footer" : ""}${lastTurn ? " acpmux-row--last-turn" : ""}${messageEntering ? " acpmux-row--message-enter" : entering ? " acpmux-row--enter" : ""}${olderFade ? " acpmux-row--older" : ""}`} + className={`acpmux-row acpmux-${kind}${row.below ? " acpmux-row--below" : ""}${joinsPrevious ? " acpmux-row--after-step" : ""}${joinsNext ? " acpmux-row--before-step" : ""}${beforeFooter ? " acpmux-row--before-footer" : ""}${replyActions ? " acpmux-row--reply-room" : ""}${status ? ` acpmux-row--${status}` : ""}${lastFooter ? " acpmux-row--last-footer" : ""}${continuationFooter ? " acpmux-row--continuation-footer" : ""}${lastTurn ? " acpmux-row--last-turn" : ""}${messageEntering ? " acpmux-row--message-enter" : entering ? " acpmux-row--enter" : ""}${olderFade ? " acpmux-row--older" : ""}`} data-transcript-active={active ? "true" : undefined} data-transcript-selected={selected ? "true" : undefined} aria-current={active ? "true" : undefined} @@ -3161,7 +3162,7 @@ export function VirtualTranscript({ row.id, row.version, drawnHeightOf(target), - hasReplyActions(row, nextDrawnRow(shown, index)), + keepsReplyRoom(row, nextDrawnRow(shown, index)), statusEdge(row, nextDrawnRow(shown, index), prevDrawnRow(shown, index)), ); } @@ -3536,8 +3537,8 @@ export function VirtualTranscript({ if ( reply >= 0 && rows[reply]!.kind === "assistant" && - hasReplyActions(rows[reply]!, nextDrawnRow(rows, reply)) !== - hasReplyActions(last.rows[reply]!, nextDrawnRow(last.rows, reply)) + keepsReplyRoom(rows[reply]!, nextDrawnRow(rows, reply)) !== + keepsReplyRoom(last.rows[reply]!, nextDrawnRow(last.rows, reply)) ) kept = reply; if (changed.size > 0) { @@ -3575,7 +3576,7 @@ export function VirtualTranscript({ // holds until it is drawn again (`hasReplyActions` reads only the next drawn row). const next = nextDrawnRow(rows, index); if ( - (row.kind === "assistant" && Boolean(known.actions) !== hasReplyActions(row, next)) || + (row.kind === "assistant" && Boolean(known.actions) !== keepsReplyRoom(row, next)) || known.status !== statusEdge(row, next, prevDrawnRow(rows, index)) ) return undefined; @@ -5259,7 +5260,7 @@ export function VirtualTranscript({ joinsPrevious={isStepRow(row) && isStepRow(rows[absoluteIndex - 1])} joinsNext={isStepRow(row) && isStepRow(rows[absoluteIndex + 1])} beforeFooter={isBeforeFooter(row, next)} - replyActions={actions} + replyActions={keepsReplyRoom(row, next)} status={statusEdge(row, next, prevDrawnRow(rows, absoluteIndex))} lastFooter={isLastFooter(row, next)} continuationFooter={isContinuationFooter(row, next)} diff --git a/webviews/src/agent-session/acpmux/conversation/TranscriptRowAlignment.gallery.ts b/webviews/src/agent-session/acpmux/conversation/TranscriptRowAlignment.gallery.ts index 9b2ee18e02f7..ca14854c5328 100644 --- a/webviews/src/agent-session/acpmux/conversation/TranscriptRowAlignment.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/TranscriptRowAlignment.gallery.ts @@ -5,8 +5,9 @@ // does: a reply, a tool call, a reply, a subagent that finished, a reply, a background command that // ended, a reply, another agent's message, a reply. The turns above it end with a "Worked for" line (a folded turn) // and a footer that says its time and tokens beside the reply's action row. Every row's first ink -// sits on the prose's left edge, an icon's text one icon column in, and each status row sits in -// the middle of the gap between the replies around it (transcript-row-alignment.test.ts). +// sits on the prose's left edge, an icon's text one icon column in, and each status row has one room +// above and one below; the reply over it keeps its 2026-10-30 room, so above is the larger +// (transcript-row-alignment.test.ts). import { agentPaneEntry } from "../../../gallery/format"; import type { Play } from "../../../gallery/play"; import { minutesAgo } from "../../../gallery/clock"; @@ -99,7 +100,7 @@ export default agentPaneEntry({ ], variants: { mix: { - note: "A reply, a tool call, a reply, a subagent that finished, a reply, a background command that ended, a reply, another agent's message, a reply; above them a folded turn's Worked for line and a footer with its time, tokens and actions. Every row's first ink is on the prose's left edge, and each status row sits in the middle of the gap between the replies around it.", + note: "A reply, a tool call, a reply, a subagent that finished, a reply, a background command that ended, a reply, another agent's message, a reply; above them a folded turn's Worked for line and a footer with its time, tokens and actions. Every row's first ink is on the prose's left edge; the reply over each status row keeps its 2026-10-30 room (model.ts keepsReplyRoom), so the line has 26px more room above than below.", snapshot, }, roomy: { diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 081e64400454..a584e33be1ee 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -63,6 +63,8 @@ --cv-live-gap: 10px; /* over the running turn's live line, inside its row: 16px under a reply */ --cv-turn-gap: var(--ds-space-4, 16px); /* a speaker change or the end of the transcript */ --cv-continuation-gap: var(--cv-message-gap); /* the same assistant's next turn */ + /* Under a reply that something follows (model.ts keepsReplyRoom): the 2026-10-30 action row's room. */ + --cv-reply-room: calc(var(--cv-meta-gap) + var(--cv-meta-row) + var(--cv-message-gap)); /* Tool rows follow Codex's compact transcript rhythm: a 22px line with a small, quiet gap after the run before the next reply. Gallery-only attributes compare this against the prior roomier rhythm without adding a product setting. */ @@ -4760,6 +4762,16 @@ article.acpmux-row.acpmux-row--before-reply-plain { :is(.acpmux-row--before-reply, .acpmux-row--before-reply-plain) .cv-disclosure.cv-received { margin-bottom: 0; } +/* A reply that something follows keeps the room its own action row took on 2026-10-30, though it draws + no row there now (one footer per turn): its meta gap and line, then the message gap (model.ts + `keepsReplyRoom`, which estimates it as that row's chrome). Leo, 2026-11-01, on a capture of that + build: "i like this spacing kinda i prefer it to what we have now tho it's just minor differences". + It follows the status rules so it wins over a reply's status edge; a row that draws nothing still + takes no room (the rule after this one). */ +article.acpmux-row.acpmux-row--reply-room { + min-height: 0; + padding-bottom: var(--cv-reply-room); +} /* A row whose renderer draws nothing (a render update that joined an earlier card, a note whose payload never came) takes no room: the 34px floor and the gap under it were a blank band each, between "Worked for" and the reply. The element selector outranks every row-kind padding. Every diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index c40dcd404e62..75f0960d8937 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -1064,7 +1064,7 @@ const chromeHeight = (row: AcpmuxRow, actions: boolean) => userMetaHeight(row) + (row.status ? USER_STATUS_HEIGHT : 0) + (row.author ? AUTHOR_HEIGHT : 0) - : // A reply's own action row (MessageActions.tsx ReplyActions): the meta gap, then its line. + : // The room a reply keeps under its text (`keepsReplyRoom`): the meta gap, then a meta row's line. actions ? META_GAP + META_ROW : 0; @@ -1179,6 +1179,21 @@ const messageBelow = (beforeFooter: boolean) => (beforeFooter ? META_GAP : MESSA /// opposite. so let's invert our behaviour"). This replaces the per-reply row of 2026-10-29. The /// callers' height plumbing stays until ReplyActions goes in a follow-up. export const hasReplyActions = (_row: AcpmuxRow, _next: AcpmuxRow | undefined) => false; +/// Whether a reply keeps the room its own action row took on 2026-10-30 (its meta gap and 24px line, +/// then the message gap: 36px under its text) though it draws no row there now. Leo, 2026-11-01, on a +/// capture of that build: "i like this spacing kinda i prefer it to what we have now tho it's just +/// minor differences". Every finished reply with text that something follows (a prompt, a steer, +/// another agent's message, a tool run), as `hasReplyActions` read it then; never the reply over its +/// turn's footer (the footer is its row), nor a copy inside an opened "Worked for" (`settled`), nor +/// one still streaming. App.tsx marks the rows `acpmux-row--reply-room` (conversation.css +/// `--cv-reply-room`); the estimate counts it as the action row's chrome. +export const keepsReplyRoom = (row: AcpmuxRow, next: AcpmuxRow | undefined) => + row.kind === "assistant" && + next !== undefined && + !isBeforeFooter(row, next) && + row.streaming !== true && + row.settled !== true && + (row.text ?? "").trim().length > 0; export const isBeforeFooter = (row: AcpmuxRow, next: AcpmuxRow | undefined) => row.kind === "assistant" && (next?.kind === "turnSummary" || ((next?.kind === WORKING || next?.kind === THINKING) && next.below === true)); @@ -1230,7 +1245,8 @@ export const statusEdge = ( prev: AcpmuxRow | undefined, ): StatusEdge | undefined => row.kind === "assistant" - ? isStatusRow(next) && !isBeforeFooter(row, next) + ? // A reply that keeps its room (`keepsReplyRoom`) ends at that room, whatever follows it. + isStatusRow(next) && !isBeforeFooter(row, next) && !keepsReplyRoom(row, next) ? hasReplyActions(row, next) ? "before-status" : "before-status-plain" @@ -1301,12 +1317,12 @@ export const isContinuationFooter = (row: AcpmuxRow, next: AcpmuxRow | undefined next.kind === WORKING || next.kind === THINKING || next.kind === ACTIVITY_LINE); -/// What the row after a row changes about its height (`beforeFooter`, and a reply's own action row, -/// `hasReplyActions`), as one key. +/// What the row after a row changes about its height (`beforeFooter`, and the room a reply keeps, +/// `keepsReplyRoom`), as one key. const rowEdge = (row: AcpmuxRow, next: AcpmuxRow | undefined, prev: AcpmuxRow | undefined) => { const status = statusEdge(row, next, prev); const edge = status ? `${footerEdge(row, next) ?? ""}${STATUS_EDGE}${status}` : footerEdge(row, next); - return hasReplyActions(row, next) ? `${edge ?? ""}${ACTIONS_EDGE}` : edge; + return keepsReplyRoom(row, next) ? `${edge ?? ""}${ACTIONS_EDGE}` : edge; }; const STATUS_EDGE = "+"; const ACTIONS_EDGE = "+actions"; @@ -1618,7 +1634,7 @@ function measuredRowHeight( lastTurn = false, sessionId?: string, continuation = false, - /// Whether a reply draws its own action row (`hasReplyActions`). + /// Whether a reply keeps its action row's room (`keepsReplyRoom`). actions = false, ): number { const finish = (height: number) => diff --git a/webviews/src/agent-session/acpmux/rowEstimates.test.ts b/webviews/src/agent-session/acpmux/rowEstimates.test.ts index 7689b9ed335f..fbe00d963009 100644 --- a/webviews/src/agent-session/acpmux/rowEstimates.test.ts +++ b/webviews/src/agent-session/acpmux/rowEstimates.test.ts @@ -365,7 +365,7 @@ test("a step of only quiet thoughts takes no room, estimated or drawn", () => { expect(placed.tops[1]).toBe(0); }); -test("a status row between two replies is estimated in the middle of the gap", () => { +test("a status row between two replies is estimated under the reply's kept room", () => { // Leo, 2026-10-30: "see how claude code has those centered tool calls / id like more alignment too". // The reply over the run ends at its action row (its 4px meta gap and 24px line, no message gap); // the run (2px over its 22px row) ends 30px over the next reply, the action row's room and the @@ -386,10 +386,11 @@ test("a status row between two replies is estimated in the middle of the gap", ( // The same reply over a prompt: its own action row, then the message gap. expect(reply).toBe(overPrompt! - 8); } else { - // No reply draws its own action row (a turn's copy lives in its footer): the plain 12px each - // side, the reply's 8px message gap plus 2 over the run's 2px top margin. + // No reply draws its own action row (a turn's copy lives in its footer), but it keeps that row's + // room (model.ts keepsReplyRoom; Leo, 2026-11-01: "i like this spacing kinda i prefer it to what + // we have now"): the same 36px over the run as over a prompt. The run keeps the plain 12px under it. expect(step).toBe(2 + 22 + 12); - expect(reply).toBe(overPrompt! + 2); + expect(reply).toBe(overPrompt!); } expect(alone).toBeGreaterThan(0); }); diff --git a/webviews/test/transcript-row-alignment.test.ts b/webviews/test/transcript-row-alignment.test.ts index 049d5fe4291f..b40150ca4958 100644 --- a/webviews/test/transcript-row-alignment.test.ts +++ b/webviews/test/transcript-row-alignment.test.ts @@ -13,8 +13,9 @@ // and the first glyph of every action row (a reply's, a footer's) start on col-left; // - every row with a leading icon (a tool row, a subagent's end, a background command's end, // another agent's message) has its 16px icon box on col-left and its words at one shared x; -// - each status row between two replies has as much room above it as below it, and every such -// gap is the same size (design/chat-transcript.md "Rows between replies"). +// - each status row between two replies has the same room above it as every other, and the same +// below; the room above is the reply's kept room (model.ts `keepsReplyRoom`), 26px more than below +// (design/chat-transcript.md "Rows between replies"). // // CMUX_ROW_ALIGNMENT_ARTIFACT_DIR=DIR (or fast-checks' FAST_CHECKS_OUT) also writes each theme's // 2x stills and measurements to DIR. @@ -34,6 +35,11 @@ await requireBrowserLane("transcript-row-alignment.test.ts", async () => { ]; /** How far apart two edges may be, in CSS px. */ const TOLERANCE = 0.5; + /** + * How much more room a status row has above than below when a reply is over it: the reply's kept + * room (`--cv-reply-room`, 36px) against the plain share it replaces over a status row (10px). + */ + const REPLY_ROOM_EXTRA = 26; try { await (await chromium.launch({ headless: true })).close(); @@ -184,19 +190,27 @@ await requireBrowserLane("transcript-row-alignment.test.ts", async () => { `the ${item.kind} row "${item.label}"'s words start at ${px(item.text)}, the first iconed row's at ${px(text)}`, ); } + // A status row under a reply is not centred: the reply keeps the room its action row took on + // 2026-10-30 (model.ts `keepsReplyRoom`, `--cv-reply-room`; Leo, 2026-11-01: "i like this spacing + // kinda i prefer it to what we have now"), so the room above is that much more than the room below. + // Each side is still one size for every status row. for (const gap of gaps) - if (Math.abs(gap.above - gap.below) > TOLERANCE) - found.push(`the ${gap.kind} row "${gap.label}" has ${px(gap.above)} above it and ${px(gap.below)} below it`); - const sizes = gaps.flatMap((gap) => [gap.above, gap.below]); - if (sizes.length > 0 && Math.max(...sizes) - Math.min(...sizes) > TOLERANCE) - found.push( - `the gaps around status rows differ: ${gaps.map((gap) => `${gap.kind} ${px(gap.above)}/${px(gap.below)}`).join(", ")}`, - ); + if (Math.abs(gap.above - gap.below - REPLY_ROOM_EXTRA) > TOLERANCE) + found.push( + `the ${gap.kind} row "${gap.label}" has ${px(gap.above)} above it and ${px(gap.below)} below it, not ${REPLY_ROOM_EXTRA}px more above`, + ); + for (const side of ["above", "below"] as const) { + const sizes = gaps.map((gap) => gap[side]); + if (sizes.length > 0 && Math.max(...sizes) - Math.min(...sizes) > TOLERANCE) + found.push( + `the gaps ${side} status rows differ: ${gaps.map((gap) => `${gap.kind} ${px(gap[side])}`).join(", ")}`, + ); + } return found; }; for (const theme of THEMES) - test(`status, tool and message rows share the prose's left edge and centred gaps (${theme.name})`, async () => { + test(`status, tool and message rows share the prose's left edge and one gap above and below (${theme.name})`, async () => { const artifacts = process.env.CMUX_ROW_ALIGNMENT_ARTIFACT_DIR ?? process.env.FAST_CHECKS_OUT; const page = await browser.newPage({ viewport: { width: 900, height: 1200 }, deviceScaleFactor: 2 }); try { From 3e6630d124ae1b895c5d6342a1c4d99d7fb860c8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 04:59:50 -0800 Subject: [PATCH 3/4] test(transcript): hold only the gaps under a reply to 2026-10-30; gap-select's injected gap wins over the reply room Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-92fa78a91e29868c5eaa --- webviews/test/transcript-gap-select-browser.test.ts | 3 ++- webviews/test/transcript-spacing-reference.test.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/webviews/test/transcript-gap-select-browser.test.ts b/webviews/test/transcript-gap-select-browser.test.ts index f746555059fd..794b20f44ea2 100644 --- a/webviews/test/transcript-gap-select-browser.test.ts +++ b/webviews/test/transcript-gap-select-browser.test.ts @@ -147,8 +147,9 @@ await requireBrowserLane("transcript-gap-select-browser.test.ts", async () => { })), dir: "rtl", // RTL inherits the document; a local LTR line must keep its own endpoints. + // Specific enough to win over a reply's kept room (conversation.css `.acpmux-row--reply-room`). css: - '.acpmux-row[data-row-id="upper"] { padding-bottom: 64px; }' + + 'article.acpmux-row.acpmux-row[data-row-id="upper"] { padding-bottom: 64px; }' + (lineDirection === "ltr" ? ".cv-md p { direction: ltr; }" : ""), }); try { diff --git a/webviews/test/transcript-spacing-reference.test.ts b/webviews/test/transcript-spacing-reference.test.ts index 02c29eeba34d..298456fb6926 100644 --- a/webviews/test/transcript-spacing-reference.test.ts +++ b/webviews/test/transcript-spacing-reference.test.ts @@ -8,10 +8,11 @@ // reply and a harness notice; a steer, a sent message and three paragraphs; a steer, a run of // commands, a one-line reply, a run, an answered question and its answer), measured in Chromium in // light and dark from one block's last drawn text line to the next block's first, each gap equals what -// feat-cmux-next 8f5dfe06ee58 (2026-10-30) drew for the same fixture, to 0.5px. Two of them moved +// feat-cmux-next 8f5dfe06ee58 (2026-10-30) drew for the same fixture. Two of them moved // with the per-reply action row's removal on 2026-10-31 and come back here: a reply to the status row // under it (17.75px before, 43.75px after) and a reply to the next prompt (21.75 before, 49.75 after). -// The rest never moved and are asserted so they stay. No per-reply action row comes back. +// Those two are asserted to 0.5px; the rest are logged beside their 2026-10-30 values, since later +// settled designs own them (the one-line row pitch, #29965). No per-reply action row comes back. // // CAPTURE keeps the capture's own gaps, read from its pixels, for reference only: they are not the // 2026-10-30 build's, and a follow-up decides whether they become the target @@ -165,6 +166,11 @@ await requireBrowserLane("transcript-spacing-reference.test.ts", async () => { "prompt -> run summary": 51.75, "card -> prompt": 28, }; + /** + * The gaps this test holds to OCT_30: the two under a reply, which the reply's kept room sets. The + * others are logged beside them; later settled designs own them (the one-line row pitch, #29965). + */ + const MOVED = ["reply -> notice", "paragraph -> prompt"] as const; const TOLERANCE = 0.5; /** @@ -190,7 +196,7 @@ await requireBrowserLane("transcript-spacing-reference.test.ts", async () => { const measured = await measure(page); console.log(`TRANSCRIPT_SPACING ${theme.file} ${JSON.stringify(measured)} capture ${JSON.stringify(CAPTURE)}`); if (LOG_ONLY) return; - const off = Object.entries(OCT_30) + const off = MOVED.map((name) => [name, OCT_30[name]!] as const) .filter(([name, want]) => measured[name] === null || Math.abs(measured[name]! - want) > TOLERANCE) .map(([name, want]) => `${name}: ${measured[name]}px, not ${want}px`); expect(off).toEqual([]); From 7edf06e5fb5cca5b4fa9af5ad05a6b55b07a5a5d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 06:57:53 -0800 Subject: [PATCH 4/4] Transcript: a folded reply to the person keeps its room, so a turn's end moves nothing agent-pane-stream-jitter caught the reply above a tool call dropping 26px when the turn ended: turns.ts marks it settled inside the open Worked-for, and keepsReplyRoom dropped settled rows. A settled foldedReply (which kept its action row) now keeps the room; a folded short note does not. Also the router-review follow-up on #30865. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-92fa78a91e29868c5eaa --- webviews/src/agent-session/acpmux/model.ts | 7 ++++--- .../src/agent-session/acpmux/rowEstimates.test.ts | 13 ++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index ca891ea8dcc5..dfb81dee8218 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -1254,15 +1254,16 @@ export const hasReplyActions = (_row: AcpmuxRow, _next: AcpmuxRow | undefined) = /// capture of that build: "i like this spacing kinda i prefer it to what we have now tho it's just /// minor differences". Every finished reply with text that something follows (a prompt, a steer, /// another agent's message, a tool run), as `hasReplyActions` read it then; never the reply over its -/// turn's footer (the footer is its row), nor a copy inside an opened "Worked for" (`settled`), nor -/// one still streaming. App.tsx marks the rows `acpmux-row--reply-room` (conversation.css +/// turn's footer (the footer is its row), nor one still streaming. Inside an opened "Worked for" +/// (`settled`) only a reply to the person (`foldedReply`, which kept its action row) keeps it; a short +/// note there stays quiet. So a turn's end, which marks its earlier replies settled, moves nothing. App.tsx marks the rows `acpmux-row--reply-room` (conversation.css /// `--cv-reply-room`); the estimate counts it as the action row's chrome. export const keepsReplyRoom = (row: AcpmuxRow, next: AcpmuxRow | undefined) => row.kind === "assistant" && next !== undefined && !isBeforeFooter(row, next) && row.streaming !== true && - row.settled !== true && + (row.settled !== true || row.foldedReply === true) && (row.text ?? "").trim().length > 0; export const isBeforeFooter = (row: AcpmuxRow, next: AcpmuxRow | undefined) => row.kind === "assistant" && diff --git a/webviews/src/agent-session/acpmux/rowEstimates.test.ts b/webviews/src/agent-session/acpmux/rowEstimates.test.ts index cfef8ce07e87..9fb431cafd23 100644 --- a/webviews/src/agent-session/acpmux/rowEstimates.test.ts +++ b/webviews/src/agent-session/acpmux/rowEstimates.test.ts @@ -12,7 +12,7 @@ // Before: a reply over a footer was estimated as any reply (8px below it, at least 34px), and a // task's result kept the old 16px below it. import { expect, test } from "bun:test"; -import { hasReplyActions, layoutConversation, placeRows, statusEdge, type AcpmuxRow } from "./model"; +import { hasReplyActions, keepsReplyRoom, layoutConversation, placeRows, statusEdge, type AcpmuxRow } from "./model"; import { turnView } from "./conversation/turns"; import { COLLAPSED_PROMPT_LINES, PASTE_CHIP_HEIGHT, PROMPT_MORE_HEIGHT } from "./conversation/promptCollapse"; import { currentChatMetrics } from "./chatDisplay"; @@ -486,3 +486,14 @@ test("a folded run with messages to other agents is estimated as the lines it dr expect(heightsOf(run([sendMessage]))[0]).toBe(lines(1)); expect(heightsOf([{ id: "one", version: 1, at: 0, kind: "activity", items: [sendMessage] }])[0]).toBe(lines(1)); }); + +test("a turn's end keeps a reply's room: a folded reply to the person keeps it, a folded note does not", () => { + const reply = { id: "r", kind: "assistant", text: "The build passed.", version: 1 } as AcpmuxRow; + const next = { id: "n", kind: "user", text: "next", version: 1 } as AcpmuxRow; + expect(keepsReplyRoom(reply, next)).toBe(true); + // turns.ts marks a finished turn's earlier replies settled inside its open "Worked for"; a reply + // to the person there is `foldedReply`, so the end moves nothing (agent-pane-stream-jitter). + expect(keepsReplyRoom({ ...reply, settled: true, foldedReply: true }, next)).toBe(true); + expect(keepsReplyRoom({ ...reply, settled: true }, next)).toBe(false); + expect(keepsReplyRoom({ ...reply, streaming: true }, next)).toBe(false); +});