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
5 changes: 2 additions & 3 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import {
} from "./model";
import { lacksImageBytes, ownQueue, queuedAttachments, queuedMessageRows } from "./conversation/queuedMessages";
import { isVisiblyEmptyMessage } from "./conversation/visibleMessage";
import { pendingMessagesRow, searchablePendingRows } from "./conversation/pendingMessages";
import { pendingMessagesRow, searchablePendingRows, withWaitingRows } from "./conversation/pendingMessages";
import { LaneNoticeBatch } from "./conversation/LaneNoticeBatch";
import { PendingMessagesCard } from "./conversation/PendingMessagesCard";
import {
Expand Down Expand Up @@ -7484,8 +7484,7 @@ function AcpmuxPane({ initialChat }: { initialChat?: InitialChat }) {
const group = pendingMessagesRow(steers, agents, lastPending.current);
lastPending.current = group;
const merged = mergeInboxNotices(turnRowsShown);
const shown = people.length ? [...merged, ...people] : merged;
const rows = group ? [...shown, group] : shown;
const rows = withWaitingRows(merged, group ? [...people, group] : people);
return childRows ? rows : btwNotes.rows(rows, snapshot.sessionId, btwSnapshot);
}, [chatPresentation, childRows, turnRowsShown, queuedRows, btwNotes, btwSnapshot, snapshot.sessionId]);
// Failed prompts in a row with one cause share one card (sendFailure.ts); a trust failure's card
Expand Down
9 changes: 2 additions & 7 deletions webviews/src/agent-session/acpmux/conversation/Thinking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,8 @@ export function Thinking({
</div>
);
return (
// `cv-worked--live`: its words start on the icon rows' text column (conversation.css), its icon
// slot empty (`data-lead-slot`: gallery/invariants.ts reads its lead edge from the box, not the words).
<div
className={`cv-worked cv-worked--live${below ? " is-below" : ""}${quiet ? "" : " is-quiet"}`}
data-find-skip
data-lead-slot
>
// `cv-worked--live`: its words start on the icon column, with no icon slot (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
13 changes: 5 additions & 8 deletions webviews/src/agent-session/acpmux/conversation/conversation.css
Original file line number Diff line number Diff line change
Expand Up @@ -2095,14 +2095,11 @@ 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);
}
/* The live line has no icon slot: its words start on the icon column, the transcript's left edge,
as the shimmer stands in the Claude and ChatGPT apps, never indented under the row above it as
if it were that row's second line (Leo, 2026-11-03, on "Thinking · 1m 23s total" under "Sent a
message, finished 3 background tasks": "the indent looks bad"). It took the text column after
"weird indenting /cut off text?" earlier that day (#33157). */
.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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
PENDING_MESSAGES_ROW_ID,
pendingMessagesRow,
searchablePendingRows,
withWaitingRows,
} from "./pendingMessages";
import { THINKING } from "./turns";
import { batchLaneNotices, flattenInboxRows, projectInboxRows } from "./laneNoticeBatches";

let at = 0;
Expand Down Expand Up @@ -540,3 +542,21 @@ test("a delivered many-thread notice has one flat fold, independent of the waiti
// The reverse order must not reuse the folded array for Find or the waiting strip either.
expect(flattenInboxRows(nextSource)).toHaveLength(14);
});

// The running turn's live line stays the transcript's last row, under the waiting messages' group
// (Leo, 2026-11-03: "thinking shimmer not being at the bottom looks bad").
test("the waiting messages draw over the running turn's live line", () => {
const reply: AcpmuxRow = { id: "a", version: 1, at: at++, kind: "assistant", text: "hi" };
const line: AcpmuxRow = { id: "thinking-live", version: 1, at: at++, kind: THINKING, below: true };
const group = pendingMessagesRow([], [queued("p-live", "tests", "done")])!;
expect(withWaitingRows([reply, line], [group]).map((row) => row.id)).toEqual([
"a",
PENDING_MESSAGES_ROW_ID,
"thinking-live",
]);
// An ended turn has no live line: the group stays at the end.
expect(withWaitingRows([reply], [group]).map((row) => row.id)).toEqual(["a", PENDING_MESSAGES_ROW_ID]);
// Nothing waits: the same rows back.
const rows = [reply, line];
expect(withWaitingRows(rows, [])).toBe(rows);
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// Cost: nothing while nothing waits. The group is made once per change of the waiting rows; the
// same rows give the same row back, so the transcript keeps its identity.
import { batchLaneNotices, flattenInboxRows, noticeOf } from "./laneNoticeBatches";
import { PENDING_MESSAGES, QUEUED_MESSAGE, type AcpmuxRow } from "../model";
import { isLiveLine, PENDING_MESSAGES, QUEUED_MESSAGE, type AcpmuxRow } from "../model";

/// The group's row id: one while any message waits.
export const PENDING_MESSAGES_ROW_ID = "pending-messages";
Expand Down Expand Up @@ -85,6 +85,16 @@ export function bySender(rows: readonly AcpmuxRow[]): { key: string; rows: Acpmu
return [...groups].map(([key, list]) => ({ key, rows: list }));
}

/// `rows`, then what waits at the transcript's end (`waiting`: the group of waiting messages, a
/// guest's held prompt), with a running turn's live line kept the last row: the shimmer stays at
/// the bottom, under what waits, as a held prompt already sits over it in its turn (Leo, 2026-11-03:
/// "thinking shimmer not being at the bottom looks bad").
export function withWaitingRows(rows: AcpmuxRow[], waiting: readonly AcpmuxRow[]): AcpmuxRow[] {
if (waiting.length === 0) return rows;
const last = rows.at(-1);
return last && isLiveLine(last) ? [...rows.slice(0, -1), ...waiting, last] : [...rows, ...waiting];
}

let version = 0;

/// The one group row of the waiting messages (`steers`, then `queued`), or undefined when none wait.
Expand Down
12 changes: 6 additions & 6 deletions webviews/src/agent-session/acpmux/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1261,17 +1261,17 @@ const TOOL_RUN_TOP = 0;
/// MARK_STEP).
const isStepRow = (row: AcpmuxRow) =>
row.kind === "activity" || (row.kind === ACTIVITY_LINE && !row.below) || isLineRow(row);
/// The running turn's live line, drawn as its last row (`below`, conversation/turns.ts).
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
/// (`.acpmux-row--below.acpmux-row--after-step`), not the footer's 32 slot, so a step row after it
/// (the waiting messages' head) is 22 under it too (#29965). Under a status row it took the footer's
/// (`.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
/// spacing and formatting"; design/transcript-spacing.md rule 3).
export const isLiveStep = (row: AcpmuxRow, prev: AcpmuxRow | undefined) =>
row.below === true &&
(row.kind === WORKING || row.kind === THINKING || row.kind === ACTIVITY_LINE) &&
prev !== undefined &&
(isStepRow(prev) || isStatusRow(prev));
isLiveLine(row) && prev !== undefined && (isStepRow(prev) || isStatusRow(prev));
const LIVE_STEP_EDGE = "+step";
/// One-line activity rows are 22 apart top to top (design/chat-transcript.md "One-line row pitch",
/// #29965), `--cv-tool-row-height`.
Expand Down
25 changes: 13 additions & 12 deletions webviews/src/agent-session/acpmux/transcriptSpacing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,19 @@ export const TRANSCRIPT_SPACING: readonly SpacingRow[] = [
source: "consecutive one-line rows sit flush, whether in one run or two rows",
},
{
id: "step-to-live",
between: 'a one-line row and the live line under it ("Sending message to …"), centre to centre',
id: "step-to-waiting",
between: 'a one-line row and the waiting group under it ("2 messages from 2 sessions"), centre to centre',
value: 22,
tolerance: 0.5,
source: "the live line is a one-line row",
source: "the waiting group's head is a one-line row",
},
{
id: "live-to-step",
between: 'the live line and the waiting group under it ("2 messages from 2 sessions"), centre to centre',
id: "step-to-live",
between: 'the waiting group and the live line under it ("Sending message to …"), centre to centre',
value: 22,
tolerance: 0.5,
source: "the waiting group's head is a one-line row",
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")',
},
];

Expand Down Expand Up @@ -216,20 +217,20 @@ export const TRANSCRIPT_STATUS_ROWS: readonly (SpacingRow & {
source: "one-line rows sit flush (rule 3)",
},
{
id: "subagent-to-live",
id: "subagent-to-waiting",
variant: "working-bottom",
between: '"Subagent finished" and the live "Thinking" line under it, centre to centre',
between: '"Subagent finished" and "1 message from 1 session" under it, centre to centre',
value: 22,
tolerance: 0.5,
source: "the live line is a one-line row (rule 3, step-to-live)",
source: "the waiting group's head is a one-line row (rule 3, step-to-waiting)",
},
{
id: "live-to-waiting",
id: "waiting-to-live",
variant: "working-bottom",
between: 'the live "Thinking" line and "1 message from 1 session" under it, centre to centre',
between: '"1 message from 1 session" and the live "Thinking" line under it, the turn\'s last, centre to centre',
value: 22,
tolerance: 0.5,
source: "the waiting group's head is a one-line row (rule 3, live-to-step)",
source: "the live line is a one-line row (rule 3, step-to-live)",
},
{
id: "reply-footer-to-received",
Expand Down
9 changes: 0 additions & 9 deletions webviews/src/gallery/invariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,6 @@ function leadingIcon(row: Element): Element | null {
return null;
}

/** The element of `row` that holds an empty icon slot before its words (`data-lead-slot`), if any. */
function slot(row: Element): Element | null {
return row.matches("[data-lead-slot]") ? row : row.querySelector("[data-lead-slot]");
}

function pxTokens(element: Element): number[] {
const style = getComputedStyle(element);
const tokens: number[] = [];
Expand Down Expand Up @@ -189,10 +184,6 @@ function transcriptViolations(doc: Document): Violation[] {
detail: `${key}: its icon centres ${fmt(delta)} px off its first line`,
});
}
} else if (slot(row)) {
// A row that keeps the icon slot empty by design (the running turn's live line, Thinking.tsx
// `data-lead-slot`): its words start on the text column, its lead edge is its box's start.
leads.push({ key, edge: slot(row)!.getBoundingClientRect().left });
} else if (text) leads.push({ key, edge: text.left });
}
violations.push(...offColumn(leads, "icon-column", "the lead edge"));
Expand Down
8 changes: 6 additions & 2 deletions webviews/test/transcript-spacing-kinds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,12 @@ await requireBrowserLane("transcript-spacing-kinds.test.ts", async () => {
const sent = byText(/^Sent to/m) ?? document.querySelector('[data-row-id="received-sent"]') ?? undefined;
const gaps: Record<string, number | null> = {
"work-to-subagent": diff(head(byText(/Sent a message/)), head(byClass("acpmux-subagentDone"))),
"subagent-to-live": diff(head(byClass("acpmux-subagentDone")), head(live)),
"live-to-waiting": diff(head(live), head(byClass("acpmux-pendingMessages"))),
"subagent-to-waiting": diff(head(byClass("acpmux-subagentDone")), head(byClass("acpmux-pendingMessages"))),
// The closed group draws its first line under its head (stacked): the live line is a row under that.
"waiting-to-live": after(
byClass("acpmux-pendingMessages"),
diff(head(byClass("acpmux-pendingMessages")), head(live)),
),
"reply-footer-to-received":
footer && footer.height > 0 ? diff((footer.top + footer.bottom) / 2, head(received)) : null,
"received-to-sent": after(received, diff(head(received), head(sent))),
Expand Down
14 changes: 12 additions & 2 deletions webviews/test/transcript-spacing-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,18 @@ await requireBrowserLane("transcript-spacing-table.test.ts", async () => {
"step-to-prose": less(diff(at(sends.at(-1)), at(short[0]))),
"prose-to-step": diff(at(short.at(-1)), at(commands[0])),
"run-to-step": diff(at(commands.at(-1)), at(inboxes[1])),
"step-to-live": diff(at(inboxes[1]), at(live)),
"live-to-step": diff(at(live), at(waiting)),
"step-to-waiting": diff(at(inboxes[1]), at(waiting)),
// Closed, the group draws its first message's line under its head (stacked): the live line
// is a row under that line (as transcript-spacing-kinds.test.ts `after`).
"step-to-live": ((gap) =>
gap == null
? null
: Math.round(
(gap -
(document.querySelector(".acpmux-pendingMessages .cv-msgline__preview")?.getBoundingClientRect()
.height ?? 0)) *
100,
) / 100)(diff(at(waiting), at(live))),
};

const bubbleElement = holding("Hand the three polish lanes")?.closest<HTMLElement>(".cv-user__bubble");
Expand Down
32 changes: 20 additions & 12 deletions webviews/test/transcript-status-text-column.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
// Every status row's words start on one column, and a session's name reads as the row's words.
// Every iconed status row's words start on one column, the live line's words start on the icon
// 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.
// Leo, 2026-11-03 22:27Z, on "Thinking · 1m 23s total" set on the text column under "Sent a message,
// finished 3 background tasks": "the indent looks bad". The live line has no icon slot: its words
// start where the rows' icons do, as the shimmer stands in the Claude and ChatGPT apps (earlier that
// day it took the text column, #33157, after "weird indenting /cut off text?").
//
// 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;
// line, the waiting messages' head) shares one x, and the live line's ("Thinking") is the x of
// those rows' icon slots;
// - 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";
Expand Down Expand Up @@ -51,7 +52,7 @@ await requireBrowserLane("transcript-status-text-column.test.ts", async () => {
});

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

Expand Down Expand Up @@ -86,7 +87,13 @@ await requireBrowserLane("transcript-status-text-column.test.ts", async () => {
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 icon = row.querySelector(":scope .cv-tool__icon, :scope .cv-received__mark")?.getBoundingClientRect();
if (words)
rows.push({
kind: live ? "live" : (row.className.split(" ")[1] ?? ""),
...words,
...(icon && icon.width > 0 ? { icon: icon.left } : {}),
});
}
const names = [...document.querySelectorAll(".acpmux-row :is(.cv-message-group__senders, .cv-agent-tag)")].map(
(name) => ({
Expand All @@ -102,15 +109,16 @@ await requireBrowserLane("transcript-status-text-column.test.ts", async () => {
};

for (const variant of VARIANTS) {
test(`${variant}: the live line's words start on the icon rows' text column`, async () => {
test(`${variant}: the iconed rows' words share one column, the live line's start on the icon 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([]);
expect(iconed.filter((row) => Math.abs(row.x - column) > TOLERANCE)).toEqual([]);
const iconColumn = iconed.find((row) => row.icon !== undefined)!.icon!;
expect(live.filter((row) => Math.abs(row.x - iconColumn) > TOLERANCE)).toEqual([]);
});
test(`${variant}: a session's name takes no tabular figures`, async () => {
const { names } = await measure(variant);
Expand Down
Loading