Skip to content
17 changes: 9 additions & 8 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
prevDrawnRow,
isBeforeFooter,
hasReplyActions,
keepsReplyRoom,
statusEdge,
type StatusEdge,
isLastFooter,
Expand Down Expand Up @@ -1893,7 +1894,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;
Expand Down Expand Up @@ -2083,7 +2084,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
Expand Down Expand Up @@ -2200,7 +2201,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}${isLineRow(row) ? " acpmux-row--line" : ""}${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}${isLineRow(row) ? " acpmux-row--line" : ""}${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}
Expand Down Expand Up @@ -3172,7 +3173,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)),
);
}
Expand Down Expand Up @@ -3576,8 +3577,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) {
Expand Down Expand Up @@ -3615,7 +3616,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)) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The comment above this check still names hasReplyActions, but the check now calls keepsReplyRoom. Update the comment to name keepsReplyRoom so it matches the code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/App.tsx, line 3579:

<comment>The comment above this check still names `hasReplyActions`, but the check now calls `keepsReplyRoom`. Update the comment to name `keepsReplyRoom` so it matches the code.</comment>

<file context>
@@ -3575,7 +3576,7 @@ export function VirtualTranscript({
               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))
               )
</file context>

known.status !== statusEdge(row, next, prevDrawnRow(rows, index))
)
return undefined;
Expand Down Expand Up @@ -5322,7 +5323,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)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }),
},
},
});
12 changes: 12 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/conversation.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,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. */
Expand Down Expand Up @@ -5076,6 +5078,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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
}
/* 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
Expand Down
29 changes: 23 additions & 6 deletions webviews/src/agent-session/acpmux/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1135,7 +1135,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;
Expand Down Expand Up @@ -1254,6 +1254,22 @@ 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 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.foldedReply === 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));
Expand Down Expand Up @@ -1305,7 +1321,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"
Expand Down Expand Up @@ -1385,12 +1402,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";
Expand Down Expand Up @@ -1714,7 +1731,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) =>
Expand Down
22 changes: 17 additions & 5 deletions webviews/src/agent-session/acpmux/rowEstimates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -383,7 +383,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
Expand All @@ -404,10 +404,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);
});
Expand Down Expand Up @@ -485,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);
});
Loading
Loading