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
26 changes: 22 additions & 4 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
nextDrawnRow,
isBeforeFooter,
isLastFooter,
isContinuationFooter,
QUEUED_MESSAGE,
WAITING_MESSAGES,
} from "./model";
Expand Down Expand Up @@ -1630,6 +1631,7 @@ const RowFrame = React.memo(function RowFrame({
joinsNext,
beforeFooter,
lastFooter,
continuationFooter,
lastTurn,
index,
setSize,
Expand Down Expand Up @@ -1662,6 +1664,8 @@ const RowFrame = React.memo(function RowFrame({
beforeFooter: boolean;
/// The chat's last footer: it ends at the message gap, so no band sits over the composer.
lastFooter: boolean;
/// The next drawn turn belongs to this same assistant: half the normal turn gap.
continuationFooter: boolean;
/// The final row of a latest turn without a footer: preserve the normal turn gap at the composer.
lastTurn: boolean;
index: number;
Expand Down Expand Up @@ -1743,15 +1747,27 @@ const RowFrame = React.memo(function RowFrame({
row.version,
acpmuxPerf.timed("row.measure", () => drawnHeightOf(node)),
);
}, [row.id, row.version, rowWidth, expanded, joinsPrevious, joinsNext, beforeFooter, lastFooter, lastTurn, report]);
}, [
row.id,
row.version,
rowWidth,
expanded,
joinsPrevious,
joinsNext,
beforeFooter,
lastFooter,
continuationFooter,
lastTurn,
report,
]);
return (
<article
ref={ref}
id={rowDomId(row.id)}
data-row-id={row.id}
data-row-card={row.ended ? "true" : undefined}
data-floating-copy={copyable ? "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" : ""}${lastFooter ? " acpmux-row--last-footer" : ""}${lastTurn ? " acpmux-row--last-turn" : ""}${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" : ""}${lastFooter ? " acpmux-row--last-footer" : ""}${continuationFooter ? " acpmux-row--continuation-footer" : ""}${lastTurn ? " acpmux-row--last-turn" : ""}${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 @@ -4129,6 +4145,7 @@ export function VirtualTranscript({
{mountedRanges.flatMap(({ first, last }) =>
rows.slice(first, last).map((row, index) => {
const absoluteIndex = first + index;
const next = nextDrawnRow(rows, absoluteIndex);
const kind = rowKind(row);
const Component = registry[kind] ?? NoticeRow;
const isExpanded = expanded.has(row.id);
Expand All @@ -4141,8 +4158,9 @@ export function VirtualTranscript({
kind={kind}
joinsPrevious={isStepRow(row) && isStepRow(rows[absoluteIndex - 1])}
joinsNext={isStepRow(row) && isStepRow(rows[absoluteIndex + 1])}
beforeFooter={isBeforeFooter(row, nextDrawnRow(rows, absoluteIndex))}
lastFooter={isLastFooter(row, nextDrawnRow(rows, absoluteIndex))}
beforeFooter={isBeforeFooter(row, next)}
lastFooter={isLastFooter(row, next)}
continuationFooter={isContinuationFooter(row, next)}
lastTurn={row.lastTurn === true}
index={absoluteIndex}
setSize={canLoadOlder ? -1 : rows.length}
Expand Down
47 changes: 47 additions & 0 deletions webviews/src/agent-session/acpmux/continuationSpacing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// l10n-allow-file: transcript geometry fixtures.
import { expect, test } from "bun:test";
import { layoutConversation, type AcpmuxRow, type RowHeights } from "./model";

const row = (id: string, kind: string, fields: Partial<AcpmuxRow> = {}): AcpmuxRow => ({
id,
kind,
version: 1,
at: 0,
...fields,
});
const footer = row("footer", "turnSummary", { text: "Done.", durationMs: 1000, status: "completed" });

test("same assistant continuation halves the footer gap; a speaker change and the end retain it", () => {
const assistant = row("next", "assistant", { text: "Next update." });
const height = (next?: AcpmuxRow) => layoutConversation(next ? [footer, next] : [footer], 720).heights[0];
expect(height(assistant)).toBe(32);
expect(height(row("user", "user", { text: "Next task." }))).toBe(40);
expect(
height(
row("peer", "user", {
incoming: { kind: "message", message: { name: "other-agent", text: "Report.", raw: "Report." } },
}),
),
).toBe(40);
expect(height()).toBe(40);
});

test("a retained footer changes its estimate when a continuation arrives or becomes a user row", () => {
const hidden = row("hidden", "user", { incoming: { kind: "hidden" } });
const known: RowHeights = new WeakMap();
const rows = [footer, hidden];
const layout = layoutConversation(rows, 720, undefined, undefined, known);
const next = [...rows, row("next", "assistant", { text: "Next update." })];
const continued = layoutConversation(next, 720, undefined, undefined, known, { rows, layout });
expect(continued.heights[0]).toBe(32);
expect(continued).toEqual(layoutConversation(next, 720));
const changed = [...rows, row("next", "user", { text: "Next task." })];
const switched = layoutConversation(changed, 720, undefined, undefined, known, { rows: next, layout: continued });
expect(switched.heights[0]).toBe(40);
expect(switched).toEqual(layoutConversation(changed, 720));
});

test("empty continuation footers retain the full hover-control floor", () => {
const empty = row("empty", "turnSummary", { status: "completed", durationMs: 0 });
expect(layoutConversation([empty, row("next", "assistant", { text: "Next update." })], 720).heights[0]).toBe(24);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// l10n-allow-file: public, deterministic conversation and spacing comparisons.
import type { CSSProperties } from "react";
import { componentEntry } from "../../../gallery/format";
import { assistant, summary, user } from "../../../gallery/fixtures/acpmux";
import { VirtualTranscript } from "../App";
import { turnView } from "./turns";

const rows = turnView(
[
assistant("The public fixture is ready.\n\nThe next update comes from this same assistant.", 8, {
id: "continuation-a",
}),
summary(8, { id: "continuation-footer-a", status: "completed" }),
assistant("The checks passed. The handoff is ready for review.", 7, { id: "continuation-b" }),
summary(7, { id: "continuation-footer-b", status: "completed" }),
user("The review is complete.", 6, {
id: "continuation-peer",
untyped: true,
incoming: {
kind: "message",
message: { name: "review-agent", text: "The review is complete.", raw: "The review is complete." },
},
}),
assistant("The other agent's report is recorded.", 5, { id: "continuation-c" }),
summary(5, { id: "continuation-footer-c", status: "completed" }),
user("Keep the speaker changes easy to see.", 4, { id: "continuation-user" }),
assistant("The speaker changes keep their full gap.", 3, { id: "continuation-d" }),
summary(3, { id: "continuation-footer-d", status: "completed" }),
],
new Set(),
);

type Props = { gap?: string; tucked?: boolean };
function ContinuationSpacing({ gap, tucked }: Props) {
return (
<div
className="acpmux-shell"
style={
{
...(gap ? { "--cv-continuation-gap": gap } : {}),
...(tucked ? { "--cv-meta-gap": "0px" } : {}),
} as CSSProperties
}
>
<VirtualTranscript
rows={rows}
sessionId="continuation-spacing"
expanded={new Set()}
onToggleActivity={() => {}}
/>
</div>
);
}

export default componentEntry<Props>({
id: "agent-pane.continuation-spacing",
title: "Same assistant, closer next turn",
area: "Agent pane",
height: 760,
pane: true,
covers: ["agent-session/acpmux/App.tsx#VirtualTranscript"],
load: async () => ContinuationSpacing,
styles: () => Promise.all([import("../styles.css"), import("./conversation.css")]),
variants: {
shipped: {
props: {},
note: "Selected: half the turn gap (the shared 8px message gap); speaker changes keep 16px.",
},
"two-thirds": {
props: { gap: "calc(var(--cv-turn-gap) * 2 / 3)" },
note: "Two thirds: more separation, but the continuation still feels like a new speaker.",
},
"half-tucked": {
props: { tucked: true },
note: "Half gap with the footer tucked against the answer: denser, but the controls crowd the text.",
},
before: {
props: { gap: "var(--cv-turn-gap)" },
note: "Before: the same assistant's next turn keeps the full 16px gap.",
},
},
});
10 changes: 7 additions & 3 deletions webviews/src/agent-session/acpmux/conversation/conversation.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@
bands). A message's meta row (a prompt's age, ticks and actions; a reply's footer) is one
compact line right under it; messages in a turn sit one message gap apart, turns one turn gap.
model.ts (fallbackRowHeight, chromeHeight) estimates rows with the same numbers. */
--cv-meta-gap: 4px; /* a message to its meta row */
--cv-meta-gap: var(--ds-space-1, 4px); /* a message to its meta row */
--cv-meta-row: 24px; /* the meta row's line */
--cv-message-gap: 8px; /* a message (or its meta row) to the next row in its turn */
--cv-message-gap: var(--ds-space-2, 8px); /* a message (or its meta row) to the next row in its turn */
--cv-live-gap: 10px; /* over the running turn's live line, inside its row: 16px under a reply */
--cv-turn-gap: 16px; /* a paragraph gap (14px) plus a small separator to the next message */
--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 */
/* 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 @@ -3912,6 +3913,9 @@ article.acpmux-row:empty {
.acpmux-row.acpmux-turnSummary.acpmux-row--last-footer {
padding-bottom: var(--cv-turn-gap);
}
.acpmux-row.acpmux-turnSummary.acpmux-row--continuation-footer {
padding-bottom: var(--cv-continuation-gap);
}
/* A legacy/history turn can end without a footer. Mark only its latest row so the composer keeps
the normal turn rhythm without a permanent runway below every turn. */
.acpmux-row.acpmux-row--last-turn {
Expand Down
43 changes: 39 additions & 4 deletions webviews/src/agent-session/acpmux/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1054,9 +1054,29 @@ export const isLastFooter = (row: AcpmuxRow, next: AcpmuxRow | undefined) =>
next.kind === WAITING_MESSAGES ||
((next.kind === "user" || next.kind === QUEUED_MESSAGE) &&
(next.received !== undefined || next.incoming?.kind === "message" || next.incoming?.kind === "notice")));
/// A completed turn followed by more of this chat's assistant. Incoming agents are user rows,
/// so a user/agent speaker change keeps the full turn gap. Check only the next drawn row.
export const isContinuationFooter = (row: AcpmuxRow, next: AcpmuxRow | undefined) =>
row.kind === "turnSummary" &&
next !== undefined &&
(next.kind === "assistant" ||
next.kind === "activity" ||
next.kind === "plan" ||
next.kind === WORKED ||
next.kind === WORKING ||
next.kind === THINKING ||
next.kind === ACTIVITY_LINE);
/// What the row after a row changes about its height (`beforeFooter`), as one key.
const rowEdge = (row: AcpmuxRow, next: AcpmuxRow | undefined) =>
isBeforeFooter(row, next) ? "footer" : isLastFooter(row, next) ? "last" : row.lastTurn ? "last-turn" : undefined;
isBeforeFooter(row, next)
? "footer"
: isContinuationFooter(row, next)
? "continuation"
: isLastFooter(row, next)
? "last"
: row.lastTurn
? "last-turn"
: undefined;

function fallbackRowHeight(row: AcpmuxRow, width: number, beforeFooter = false, lastTurn = false): number {
const finish = (height: number) =>
Expand Down Expand Up @@ -1326,13 +1346,17 @@ function measuredRowHeight(
beforeFooter = false,
lastTurn = false,
sessionId?: string,
continuation = false,
): number {
const finish = (height: number) =>
height === 0 || !lastTurn ? height : height + TURN_GAP - (row.kind === "activity" ? TOOL_RUN_BOTTOM : MESSAGE_GAP);
// A task's result is one 26px line, however long the prompt that brought it (offer/SuggestedTasks.tsx).
if (row.received) return finish(26 + MESSAGE_GAP);
// A turn's footer carries its answer's text for Copy (conversation/turns.ts); it draws one line.
if (row.kind === "turnSummary") return fallbackRowHeight(row, width, beforeFooter);
if (row.kind === "turnSummary") {
const gap = continuation ? MESSAGE_GAP : TURN_GAP;
return isEmptyFooter(row) ? Math.max(META_ROW, gap) : META_ROW + gap;
}
// The live activity line under a plan has a label, but its row still reserves the footer slot.
if (row.kind === ACTIVITY_LINE && row.below) return fallbackRowHeight(row, width, beforeFooter, lastTurn);
// Another agent's message: one 26px line in `.cv-received`'s 2px margins, then its glass card
Expand Down Expand Up @@ -1418,7 +1442,10 @@ function measuredRowHeight(
/// new object), so a long chat's layout measures only the rows that changed since the last one.
/// The row's edge is kept too: a reply's height changes when its turn's footer arrives after it, and
/// a footer's when a prompt follows it.
export type RowHeights = WeakMap<AcpmuxRow, { width: number; height: number; edge?: "footer" | "last" | "last-turn" }>;
export type RowHeights = WeakMap<
AcpmuxRow,
{ width: number; height: number; edge?: "footer" | "last" | "last-turn" | "continuation" }
>;

/** DOM-free row geometry. Only visible rows need their React elements painted. */
export function layoutConversation(
Expand Down Expand Up @@ -1471,7 +1498,15 @@ export function layoutConversation(
height =
customHeight !== undefined && Number.isFinite(customHeight) && customHeight > 0
? customHeight
: measuredRowHeight(row, width, cache, edge === "footer", edge === "last-turn", sessionId);
: measuredRowHeight(
row,
width,
cache,
edge === "footer",
edge === "last-turn",
sessionId,
edge === "continuation",
);
known?.set(row, { width, height, edge });
}
heights[index] = height;
Expand Down
Loading
Loading