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
7 changes: 5 additions & 2 deletions webviews/src/agent-session/acpmux/continuationSpacing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BACKGROUND_TURN } from "./conversation/backgroundTurns";
import { layoutConversation, placeRows, type AcpmuxRow, type RowHeights } from "./model";
import {
TRANSCRIPT_BLOCK_GAP,
TRANSCRIPT_COMPOSER_GAP,
TRANSCRIPT_FOOTER_CONTROL,
TRANSCRIPT_FOOTER_GAP,
TRANSCRIPT_RUN_GAP,
Expand Down Expand Up @@ -192,7 +193,7 @@ test("estimated grid rows follow the Interface Size", () => {
}
});

test("the live line sits flush under a run of one-line rows: 22 apart top to top (#29965)", () => {
test("the live line keeps the scaled run gap after activity while activity rows stay flush", () => {
const run = row("run", "activity", {
items: [
{
Expand All @@ -215,7 +216,9 @@ test("the live line sits flush under a run of one-line rows: 22 apart top to top
const layout = layoutConversation([run, next, live], 720);
expect(layout.heights[0]).toBe(22);
expect(layout.tops[1]! - layout.tops[0]!).toBe(22);
expect(layout.tops[2]! - layout.tops[1]!).toBe(22);
expect(layout.tops[2]! - layout.tops[1]! - layout.heights[1]!).toBe(TRANSCRIPT_RUN_GAP);
expect(layout.heights[2]).toBe(22);
expect(layout.tailGap).toBe(TRANSCRIPT_COMPOSER_GAP);
// The same live line right under its prompt still waits a block gap under it (grid.md 5.5).
const prompt = row("p", "user", { text: "Go" });
const waiting = layoutConversation([prompt, live], 720);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// chatgpt/live/transcript-thinking-and-tool-calls-consolidation and
// claude/live/transcript-agent-text-and-collapsed-tool-row.
import { agentPaneEntry } from "../../../gallery/format";
import { activity, assistant, chat, summary, tool, user } from "../../../gallery/fixtures/acpmux";
import { activity, assistant, chat, row, summary, tool, user } from "../../../gallery/fixtures/acpmux";
import type { AcpmuxSnapshot } from "../model";

function transcript(family: "codex" | "claude"): AcpmuxSnapshot {
Expand Down Expand Up @@ -34,13 +34,50 @@ function transcript(family: "codex" | "claude"): AcpmuxSnapshot {
return { ...snapshot, summary: { ...snapshot.summary!, family } };
}

// Leo's 2026-11-04 capture: two peer messages followed by the running Thinking line.
// Keep a real running turn so the production turn builder supplies the live footer slot.
function liveSeam(kind: "peers" | "tools" | "status"): AcpmuxSnapshot {
const peer = (id: string, harness: string, text: string) =>
user(text, 1, {
id,
continues: true,
incoming: { kind: "message", message: { name: id, harness, text, raw: text } },
});
const rows =
kind === "peers"
? [
peer("layout-owner", "claude", "The spacing update is ready."),
peer("motion-owner", "codex", "The pane opens smoothly."),
]
: kind === "tools"
? [activity([tool("Read layout rules", "read", "completed"), tool("Check spacing", "execute", "completed")], 1)]
: [row("compaction", 1, { compaction: { state: "completed", preTokens: 24_000, postTokens: 6_000 } })];
const snapshot = chat(
[user("Check the transcript spacing.", 3), assistant("I will compare the activity and live state.", 2), ...rows],
{ isWorking: true, harness: "claude", model: "claude-opus-5-5" },
);
return { ...snapshot, summary: { ...snapshot.summary!, family: "claude" } };
}

export default agentPaneEntry({
id: "agent-pane.transcript-cohesion",
title: "Transcript rhythm across harnesses",
area: "Agent pane",
height: 460,
covers: ["agent-session/acpmux/conversation/TurnRows.tsx#WorkedFor", "agent-session/acpmux/model.ts"],
variants: {
"live-peers": {
note: "Two peer-message rows remain grouped; Thinking takes the scaled run gap below them.",
snapshot: liveSeam("peers"),
},
"live-tools": {
note: "Claude keeps its tool-run disclosure and a separate live Thinking line.",
snapshot: liveSeam("tools"),
},
"live-status": {
note: "A status notice keeps the same live-state separation as a tool run.",
snapshot: liveSeam("status"),
},
"codex-closed": {
note: "Default Codex: Worked for is one 22px activity line, 16px from the reply.",
snapshot: transcript("codex"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { childRows } from "../subagents/childRows";
import type { PlayContext } from "../../../gallery/play";
import type { AcpmuxActivity, AcpmuxRow } from "../model";
import { workedTurnRows } from "../workedTurn";
import { TRANSCRIPT_FOOTER_GAP, TRANSCRIPT_RUN_GAP } from "../transcriptGrid";
import { checkRunningLineLoops, hasCompleteRunningLineShimmer } from "./runningLineMotion";
import { GALLERY_NOW, minutesAgo } from "../../../gallery/clock";
import {
Expand Down Expand Up @@ -817,15 +816,23 @@ const footerActionsPlacement = (placement: "left" | "right") => async (ctx: Play
};

/// The live line at the bottom of a running turn never overlaps the row over it. Under a run of
/// one-line rows it is one more line of the run, flush, 22 apart top to top (#31497,
/// design/chat-transcript.md "One-line row pitch", transcriptGrid.ts `transcriptGap`), never a gap
/// apart; under prose it keeps the footer slot's gap (`TRANSCRIPT_FOOTER_GAP`; Leo, 2026-10-21: "THE
/// one-line rows it keeps the scaled run gap (Leo, 2026-11-04: "the gap between tool calls and
/// thinking needs to be bigger"); under prose it keeps the footer slot's gap (Leo, 2026-10-21: "THE
/// RUNNING A COMMAND IS WAY TOO CLOSE"). Its words shimmer, and its "⌃B to run in background" waits
/// for the pointer.
const liveLineClear = async (ctx: PlayContext) => {
const doc = ctx.document;
await ctx.waitFor(() => doc.querySelector(".cv-worked.is-below .cv-thinking"));
const words = doc.querySelector<HTMLElement>(".cv-worked.is-below .cv-thinking")!.getBoundingClientRect();
const live = doc.querySelector<HTMLElement>(".cv-worked.is-below")!.getBoundingClientRect();
// Resolve through a box so both Interface Size and document zoom enter the same geometry.
const probe = doc.createElement("span");
probe.style.cssText = "position:absolute;visibility:hidden;width:0;height:var(--grid-gap-run)";
doc.body.append(probe);
const runGap = probe.getBoundingClientRect().height;
probe.style.height = "var(--grid-gap-footer)";
const footerGap = probe.getBoundingClientRect().height;
probe.remove();
// A stacked message row's first line under it (messageLineLook.ts) is the row's last line.
const over = [...doc.querySelectorAll(".cv-tool, .cv-msgline__preview, .cv-md")]
.map((node) => ({
Expand All @@ -835,13 +842,11 @@ const liveLineClear = async (ctx: PlayContext) => {
.filter(({ bottom }) => bottom <= words.top + 1);
if (!over.length) throw new Error("no tool row or reply draws over the live line");
const nearest = over.reduce((a, b) => (b.bottom > a.bottom ? b : a));
const gap = words.top - nearest.bottom;
if (nearest.step && !(gap >= -0.5 && gap < TRANSCRIPT_RUN_GAP))
throw new Error(`the live line sits ${gap.toFixed(1)}px under the run over it, not flush as one more line of it`);
if (!nearest.step && !(gap >= TRANSCRIPT_FOOTER_GAP))
throw new Error(
`the live line sits ${gap.toFixed(1)}px under the reply over it, not ${TRANSCRIPT_FOOTER_GAP}px or more`,
);
const gap = live.top - nearest.bottom;
if (nearest.step && Math.abs(gap - runGap) > 0.5)
throw new Error(`the live line sits ${gap.toFixed(1)}px under the run over it, not the scaled ${runGap}px run gap`);
if (!nearest.step && gap < footerGap - 0.5)
throw new Error(`the live line sits ${gap.toFixed(1)}px under the reply over it, not ${footerGap}px or more`);
const hint = doc.querySelector<HTMLElement>(".cv-worked.is-below .cv-command__bg");
if (hint && getComputedStyle(hint).opacity !== "0")
throw new Error("the ⌃B hint shows before the pointer is on the line");
Expand Down Expand Up @@ -2501,7 +2506,7 @@ export default agentPaneEntry({
},
},
"codex-turn-raw-commands": {
note: "Leo's turn-toolcalls-now.png: commands that all start with export PATH=… and send no description. Each reads as its meaningful command (acpmux ls, gh pr view 22363); the two runs and the short note between them are one folded line; the call in flight is the one live line at the bottom (Running acpmux send 13s), shimmering, no dots, flush under the run over it as one more 22px line of it (#31497); ⌃B to run in background shows on hover. Play: hover the line.",
note: "Leo's turn-toolcalls-now.png: commands that all start with export PATH=… and send no description. Each reads as its meaningful command (acpmux ls, gh pr view 22363); the two runs and the short note between them are one folded line; the call in flight is the one live line at the bottom (Running acpmux send 13s), shimmering, no dots, with the scaled run gap under the activity; ⌃B to run in background shows on hover. Play: hover the line.",
snapshot: chat(
[
user("What did the lanes decide? Relay it and answer.", 2),
Expand Down
15 changes: 11 additions & 4 deletions webviews/src/agent-session/acpmux/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
footerAfter,
gridLength,
MARK_FOOTER,
MARK_INDICATOR,
MARK_STATUS,
MARK_STEP,
rowMark,
Expand Down Expand Up @@ -1298,7 +1299,7 @@ const isStepRow = (row: AcpmuxRow) =>
export const isLiveLine = (row: AcpmuxRow) =>
row.below === true && (row.kind === WORKING || row.kind === THINKING || row.kind === ACTIVITY_LINE);
/// The running turn's line as its last row (`below`) right under a step row or a status row ("Subagent
/// finished", "Scheduled check", a compaction): one more line of the run, 22 under it and 22 tall
/// finished", "Scheduled check", a compaction): 22 tall, with the run gap above it
/// (`.acpmux-row--below.acpmux-row--after-step`), not the footer's 32 slot (#29965); the waiting
/// messages' head draws over it (pendingMessages.ts `withWaitingRows`). Under a status row it took the footer's
/// slot a block gap away, 55 and 70 from its neighbours (Leo, 2026-11-03: "this is pretty bad
Expand Down Expand Up @@ -2433,9 +2434,15 @@ export function layoutConversation(
let prevDrawn = prevDrawnRow(rows, start);
for (let index = start; index < rows.length; index += 1) {
const row = rows[index]!;
// A step row (a tool run or a one-line row) joins the step row before it flush (transcriptGap).
marks[index] =
isStepRow(row) || isLiveStep(row, prevDrawn) ? MARK_STEP : isStatusRow(row) ? MARK_STATUS : rowMark(row);
// A compact live line keeps its 22px height and composer gap, but is a live indicator for
// spacing: classifying it as another step would swallow the run gap before Thinking.
marks[index] = isStepRow(row)
? MARK_STEP
: isLiveStep(row, prevDrawn)
? MARK_INDICATOR
: isStatusRow(row)
? MARK_STATUS
: rowMark(row);
messages.visit(row, index, previous?.rows[index]);
if (nextDrawn <= index) {
nextDrawn = index + 1;
Expand Down
5 changes: 3 additions & 2 deletions webviews/src/agent-session/acpmux/transcriptGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,15 @@ export function transcriptGap(previous: number, next: number): number {
// Waiting for the first output: the indicator right under its prompt takes the block gap.
if ((next === MARK_INDICATOR || next === MARK_FOOTER_SLOT) && previous === MARK_PROMPT) return TRANSCRIPT_BLOCK_GAP;
if (previous === MARK_PROMPT || next === MARK_PROMPT) return TRANSCRIPT_TURN_GAP;
// The live line under a run is one more line of it (#29965): flush too.
if (previous === MARK_STEP && (next === MARK_STEP || next === MARK_INDICATOR || next === MARK_FOOTER_SLOT)) return 0;
// Any two one-line rows in a row, a status line among them ("Scheduled check", "Compacting the
// conversation…", a background task's end), sit flush on the 22 pitch: one row height and no gap
// (design/transcript-spacing.md rule 3; Leo, 2026-11-02, on a Claude chat where they stood a
// block apart: "these are all so stupid looking").
const line = (mark: number) => mark === MARK_STEP || mark === MARK_STATUS;
if (line(previous) && line(next)) return 0;
// The live state is separate from completed activity. Leo, 2026-11-04: "the gap between tool
// calls and thinking needs to be bigger". Use the same scaled run gap as activity to prose.
if (line(previous) && (next === MARK_INDICATOR || next === MARK_FOOTER_SLOT)) return TRANSCRIPT_RUN_GAP;
// A run, or any status line, and the reply's prose around it sit 16 apart, either way.
if ((line(previous) && next === MARK_MESSAGE) || (previous === MARK_MESSAGE && line(next))) return TRANSCRIPT_RUN_GAP;
return TRANSCRIPT_BLOCK_GAP;
Expand Down
22 changes: 12 additions & 10 deletions webviews/src/agent-session/acpmux/transcriptSpacing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,10 @@ export const TRANSCRIPT_SPACING: readonly SpacingRow[] = [
{
id: "step-to-live",
between: 'the waiting group and the live line under it ("Sending message to …"), centre to centre',
value: 22,
value: 38,
tolerance: 0.5,
source:
'the live line is a one-line row, and the running turn\'s last (Leo, 2026-11-03: "thinking shimmer not being at the bottom looks bad")',
'22 line + scaled run gap 16; Leo, 2026-11-04: "the gap between tool calls and thinking needs to be bigger"',
},
];

Expand Down Expand Up @@ -212,8 +212,8 @@ export const TRANSCRIPT_SPACING_KINDS: readonly SpacingRow[] = [
* status rows; Leo, 2026-11-03 04:44Z, on two captures of a running Claude chat where they stood
* 55 to 70 apart, then cramped: "this is pretty bad spacing and formatting" / "i thought we made this
* stuff smaller"). Each is a one-line row: a folded run, "Subagent finished", the live "Thinking"
* line, the waiting group's "1 message from 1 session", another agent's received line and "Sent to"
* sit flush on the 22 pitch (rule 3), 16 from the prose around them (rule 4). Measured over gallery
* line, the waiting group's "1 message from 1 session", another agent's received line and "Sent to".
* Activity rows sit flush on the 22 pitch; the live line takes the run gap below activity. Measured over gallery
* variants `working-bottom`, `reply-received`, `notes-live` and `status-prompt` by test/transcript-spacing-kinds.test.ts. */
export const TRANSCRIPT_STATUS_ROWS: readonly (SpacingRow & {
variant: "working-bottom" | "reply-received" | "notes-live" | "status-prompt";
Expand All @@ -238,9 +238,10 @@ export const TRANSCRIPT_STATUS_ROWS: readonly (SpacingRow & {
id: "waiting-to-live",
variant: "working-bottom",
between: '"1 message from 1 session" and the live "Thinking" line under it, the turn\'s last, centre to centre',
value: 22,
value: 38,
tolerance: 0.5,
source: "the live line is a one-line row (rule 3, step-to-live)",
source:
'22 line + scaled run gap 16; Leo, 2026-11-04: "the gap between tool calls and thinking needs to be bigger"',
},
{
id: "reply-footer-to-received",
Expand All @@ -262,19 +263,20 @@ export const TRANSCRIPT_STATUS_ROWS: readonly (SpacingRow & {
id: "sent-to-live",
variant: "reply-received",
between: '"Sent to" and the live "Thinking" line under it, centre to centre',
value: 22,
value: 38,
tolerance: 0.5,
source: "the live line is a one-line row (rule 3, step-to-live)",
source:
'22 line + scaled run gap 16; Leo, 2026-11-04: "the gap between tool calls and thinking needs to be bigger"',
},
{
id: "note-to-live",
variant: "notes-live",
between:
'the last "Background task finished" note, after "Received 6 messages", and the live "Thinking" line under it, centre to centre',
value: 22,
value: 38,
tolerance: 0.5,
source:
'the live line is a one-line row (rule 3, step-to-live); Leo, 2026-11-03: "this feels like too awkward of a gap :("',
'22 line + scaled run gap 16; Leo, 2026-11-04: "the gap between tool calls and thinking needs to be bigger"',
},
{
id: "status-to-prompt",
Expand Down
6 changes: 4 additions & 2 deletions webviews/test/browser/transcript-rhythm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ await requireBrowserLane("transcript-rhythm.test.ts", async () => {
chevron,
copy,
gaps: [message.top - header.bottom, group.top - message.bottom],
// The live line is one more line of the tool run over it: flush (#29965).
// The live state keeps the scaled run gap below completed activity.
liveUnderRun: live.top - group.bottom,
inner: paragraphs[1]!.top - paragraphs[0]!.bottom,
composerGap: composer.top - live.bottom,
Expand Down Expand Up @@ -150,7 +150,9 @@ await requireBrowserLane("transcript-rhythm.test.ts", async () => {
// A run and the prose by it: the run gap (design/transcript-spacing.md rule 4).
for (const gap of state.gaps)
expect(Math.abs(gap - gridLength("gap-run") * scale), "run gap").toBeLessThanOrEqual(0.5);
expect(Math.abs(state.liveUnderRun), "live line flush under its run").toBeLessThanOrEqual(0.5);
expect(Math.abs(state.liveUnderRun - gridLength("gap-run") * scale), "live line run gap").toBeLessThanOrEqual(
0.5,
);
expect(Math.abs(state.inner - 12 * scale), "paragraph gap").toBeLessThanOrEqual(0.5);
// Over a floating stack the newest line rests the rest gap over it (model.ts REST_GAP, Leo
// 2026-11-01: "more space between composer and latest messages"); docked in flow, the
Expand Down
Loading
Loading