diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html
index 45cecbaab085..5701d40ed48a 100644
--- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html
+++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html
@@ -1994,51 +1994,51 @@
display: none;
}
}
-.acpmux-shell{height:100%;display:flex;flex-direction:column;color:var(--agent-text);font:13px "Helvetica Neue", Arial, sans-serif;background:var(--agent-page-bg);overflow:hidden}.acpmux-header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--agent-border)}.acpmux-title{font-size:14px}.acpmux-status{margin-left:8px;color:var(--agent-muted);font-size:11px}.acpmux-session,.acpmux-model,.acpmux-mode,.acpmux-effort{border:1px solid var(--agent-border);border-radius:999px;background:var(--agent-control);color:var(--agent-text);padding:5px 9px}.acpmux-scroll{flex:1;overflow:auto;contain:strict}.acpmux-spacer{position:relative;min-height:100%}.acpmux-thread{position:absolute;inset:0 0 auto;padding:16px max(18px,calc((100% - 760px)/2));}.acpmux-row{position:absolute;left:max(18px,calc((100% - 760px)/2));right:max(18px,calc((100% - 760px)/2));min-height:34px;contain:layout paint}.acpmux-user{display:flex;justify-content:flex-end}.acpmux-bubble,.acpmux-user-bubble{max-width:78%;padding:9px 12px;box-sizing:border-box;border-radius:16px;background:color-mix(in srgb,var(--agent-accent) 20%,transparent);white-space:pre-wrap}.acpmux-markdown{line-height:20px}.acpmux-markdown>*{margin:8px 0 0}.acpmux-markdown>:first-child{margin-top:0}.acpmux-markdown code{font:11.5px var(--font-mono)}.acpmux-markdown blockquote{padding-left:12px;border-left:2px solid var(--agent-border);color:var(--agent-soft)}.acpmux-markdown pre,.acpmux-activity-item pre{padding:9px;border-radius:8px;overflow:auto;background:var(--agent-input-bg);font:12px var(--font-mono)}.acpmux-summary,.acpmux-muted{color:var(--agent-muted);font-size:11px;padding:8px 0}.acpmux-activity-toggle{border:0;background:none;color:var(--agent-muted);padding:5px 0;cursor:pointer}.acpmux-activity-items{border-left:1px solid var(--agent-border);padding-left:12px;color:var(--agent-soft)}.acpmux-activity-item{padding:3px 0}.acpmux-glyph{display:inline-block;width:18px;color:var(--agent-accent)}.acpmux-permission{padding:0 max(18px,calc((100% - 760px)/2))}.acpmux-permission-card{padding:11px;border:1px solid var(--agent-accent);border-radius:12px;background:var(--agent-surface-elevated)}.acpmux-permission-buttons{display:flex;gap:7px;margin-top:9px}.acpmux-permission-buttons button,.acpmux-composer button{border:0;border-radius:999px;padding:7px 12px;background:var(--agent-accent);color:#fff}.acpmux-queue{display:flex;gap:6px;overflow:auto;padding:5px max(18px,calc((100% - 760px)/2));color:var(--agent-muted)}.acpmux-composer{display:flex;align-items:end;gap:8px;padding:10px max(18px,calc((100% - 760px)/2));border-top:1px solid var(--agent-border);background:var(--agent-surface)}.acpmux-chips{display:flex;gap:5px}.acpmux-composer textarea{flex:1;resize:none;border:1px solid var(--agent-border-strong);border-radius:15px;padding:10px;background:var(--agent-input-bg);color:var(--agent-text);font:inherit}.acpmux-cancel{background:var(--agent-control)!important;color:var(--agent-text)!important}
+.acpmux-shell{height:100%;display:flex;flex-direction:column;color:var(--agent-text);font:13px "Helvetica Neue", Arial, sans-serif;background:var(--agent-page-bg);overflow:hidden}.acpmux-header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--agent-border)}.acpmux-title{font-size:14px}.acpmux-status{margin-left:8px;color:var(--agent-muted);font-size:11px}.acpmux-session,.acpmux-model,.acpmux-mode,.acpmux-effort{border:1px solid var(--agent-border);border-radius:999px;background:var(--agent-control);color:var(--agent-text);padding:5px 9px}.acpmux-scroll{flex:1;overflow:auto;contain:strict}.acpmux-spacer{position:relative;min-height:100%}.acpmux-thread{position:absolute;inset:0 0 auto;padding:16px max(18px,calc((100% - 760px)/2));}.acpmux-row{position:absolute;left:max(18px,calc((100% - 760px)/2));right:max(18px,calc((100% - 760px)/2));min-height:34px;box-sizing:border-box;padding-bottom:8px;contain:layout paint}.acpmux-row.acpmux-user,.acpmux-row.acpmux-assistant{padding-bottom:16px}.acpmux-user{display:flex;justify-content:flex-end}.acpmux-bubble,.acpmux-user-bubble{max-width:78%;padding:9px 12px;box-sizing:border-box;border-radius:16px;background:color-mix(in srgb,var(--agent-accent) 20%,transparent);white-space:pre-wrap}.acpmux-markdown{line-height:20px}.acpmux-markdown>*{margin:8px 0 0}.acpmux-markdown>:first-child{margin-top:0}.acpmux-markdown code{font:11.5px var(--font-mono)}.acpmux-markdown blockquote{padding-left:12px;border-left:2px solid var(--agent-border);color:var(--agent-soft)}.acpmux-markdown pre,.acpmux-activity-item pre{padding:9px;border-radius:8px;overflow:auto;background:var(--agent-input-bg);font:12px var(--font-mono)}.acpmux-summary,.acpmux-muted{color:var(--agent-muted);font-size:11px;padding:8px 0}.acpmux-activity-toggle{border:0;background:none;color:var(--agent-muted);padding:5px 0;cursor:pointer}.acpmux-activity-items{border-left:1px solid var(--agent-border);padding-left:12px;color:var(--agent-soft)}.acpmux-activity-item{padding:3px 0}.acpmux-glyph{display:inline-block;width:18px;color:var(--agent-accent)}.acpmux-permission{padding:0 max(18px,calc((100% - 760px)/2))}.acpmux-permission-card{padding:11px;border:1px solid var(--agent-accent);border-radius:12px;background:var(--agent-surface-elevated)}.acpmux-permission-buttons{display:flex;gap:7px;margin-top:9px}.acpmux-permission-buttons button,.acpmux-composer button{border:0;border-radius:999px;padding:7px 12px;background:var(--agent-accent);color:#fff}.acpmux-queue{display:flex;gap:6px;overflow:auto;padding:5px max(18px,calc((100% - 760px)/2));color:var(--agent-muted)}.acpmux-composer{display:flex;align-items:end;gap:8px;padding:10px max(18px,calc((100% - 760px)/2));border-top:1px solid var(--agent-border);background:var(--agent-surface)}.acpmux-chips{display:flex;gap:5px}.acpmux-composer textarea{flex:1;resize:none;border:1px solid var(--agent-border-strong);border-radius:15px;padding:10px;background:var(--agent-input-bg);color:var(--agent-text);font:inherit}.acpmux-cancel{background:var(--agent-control)!important;color:var(--agent-text)!important}
diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx
index fa4fea2774cc..f7d2a9f25167 100644
--- a/webviews/src/agent-session/acpmux/App.tsx
+++ b/webviews/src/agent-session/acpmux/App.tsx
@@ -1,8 +1,8 @@
-import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import type { Token } from "marked";
import { applyAgentTheme } from "../shared/theme";
-import { diffRows, layoutConversation, markdownBlocks, safeHref, transcriptRowWidth, visibleLayoutRange, type AcpmuxPermission, type AcpmuxRow, type AcpmuxSnapshot } from "./model";
+import { diffRows, layoutConversation, markdownBlocks, placeRows, safeHref, transcriptRowWidth, visibleLayoutRange, type AcpmuxPermission, type AcpmuxRow, type AcpmuxSnapshot } from "./model";
import { AcpmuxDirectClient, type AcpmuxHostConfig } from "./direct";
import { startMockHost } from "./mock";
import { createAcpmuxDebug, type AcpmuxDebug } from "./debug";
@@ -80,18 +80,26 @@ const EditedFilesRow = memo(function EditedFilesRow({ row }: RowProps) { const f
const defaultRegistry: NativeRegistry = { user: MessageRow, assistant: MessageRow, activity: ToolActivityRow, editedFiles: EditedFilesRow, turnSummary: SummaryRow, notice: NoticeRow, plan: NoticeRow, typing: NoticeRow, permission: PermissionRow };
-function MeasuredCustomRow({ children, onHeight }: { children: React.ReactNode; onHeight: (height: number) => void }) {
+/// A row's height as the page drew it, valid while the row's content version and width hold.
+type DrawnHeight = { version: number; width: number; height: number };
+type ReportDrawn = (id: string, version: number, height: number) => void;
+
+/// One transcript row. It reports its drawn height before the frame paints whenever it mounts or
+/// its content, width or expansion changes; the transcript's ResizeObserver reports later changes
+/// (a font that loads, a custom renderer that grows).
+function RowFrame({ row, kind, index, setSize, top, rowWidth, expanded, observer, report, children }: { row: AcpmuxRow; kind: string; index: number; setSize: number; top: number; rowWidth: number; expanded: boolean; observer: ResizeObserver | undefined; report: ReportDrawn; children: React.ReactNode }) {
const ref = useRef(null);
useLayoutEffect(() => {
const node = ref.current;
- if (!node) return;
- const report = () => onHeight(node.getBoundingClientRect().height);
- const observer = new ResizeObserver(report);
+ if (!node || !observer) return;
observer.observe(node);
- report();
- return () => observer.disconnect();
- }, [onHeight]);
- return }>{children}
;
+ return () => observer.unobserve(node);
+ }, [observer]);
+ useLayoutEffect(() => {
+ const node = ref.current;
+ if (node) report(row.id, row.version, node.getBoundingClientRect().height);
+ }, [row.id, row.version, rowWidth, expanded, report]);
+ return {children};
}
/// Who spoke, for assistive technology: each article is one message in the transcript feed.
@@ -99,6 +107,9 @@ const speaker = (kind: string) => kind === "user" ? "You" : kind === "assistant"
const rowKind = (row: AcpmuxRow) => row.kind === "activity" && row.items?.some((item) => item.tool?.kind === "edit" || item.tool?.kind === "fileChange") ? "editedFiles" : row.kind;
const currentRegistry = (): NativeRegistry => ({ ...defaultRegistry, ...(window.cmuxAcpmuxRegistry as unknown as NativeRegistry | undefined) });
+/// Where a scroller sits, read while its content still matches `totalHeight`.
+const scrollPosition = (node: HTMLElement, totalHeight: number) => ({ top: node.scrollTop, atLatest: node.scrollTop >= totalHeight - node.clientHeight - 1 });
+
/// Scroll steps of rows mounted ahead in the scroll direction, capped in viewports.
/// A scroll commits from its event, a frame after the offset moved, so without the
/// lead a fling shows a blank edge on every frame.
@@ -112,20 +123,80 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry =
const [height, setHeight] = useState(600);
const ref = useRef(null);
const [width, setWidth] = useState(760);
- const [measuredHeights, setMeasuredHeights] = useState(new Map());
+ // Rows place by their drawn height once drawn, and by the estimate until then.
+ const [drawn, setDrawn] = useState(new Map());
+ const pendingDrawn = useRef(new Map());
+ const rowWidthRef = useRef(transcriptRowWidth(width));
+ rowWidthRef.current = transcriptRowWidth(width);
+ const rowsRef = useRef(rows);
+ rowsRef.current = rows;
+ const reportDrawn = useCallback((id, version, drawnHeight) => {
+ // Zero is a row not laid out (hidden, or no layout at all), not a height.
+ if (drawnHeight > 0) pendingDrawn.current.set(id, { version, width: rowWidthRef.current, height: drawnHeight });
+ }, []);
+ // All of a commit's reports land in one update, before the frame paints.
+ const flushDrawn = useCallback(() => {
+ if (!pendingDrawn.current.size) return;
+ const updates = pendingDrawn.current;
+ pendingDrawn.current = new Map();
+ setDrawn((current) => {
+ let next: Map | undefined;
+ for (const [id, entry] of updates) {
+ const old = current.get(id);
+ if (old && old.version === entry.version && old.width === entry.width && Math.abs(old.height - entry.height) < 0.5) continue;
+ next ??= new Map(current);
+ next.set(id, entry);
+ }
+ return next ?? current;
+ });
+ }, []);
+ const observer = useMemo(() => typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver((entries?: ResizeObserverEntry[]) => {
+ for (const entry of entries ?? []) {
+ const target = entry.target as HTMLElement;
+ const row = rowsRef.current[Number(target.getAttribute("aria-posinset")) - 1];
+ if (row && row.id === target.dataset.rowId) reportDrawn(row.id, row.version, target.getBoundingClientRect().height);
+ }
+ // A late size change (a font loading) must not paint a frame of overlap first.
+ flushSync(flushDrawn);
+ }), [reportDrawn, flushDrawn]);
+ useEffect(() => () => observer?.disconnect(), [observer]);
+ // Forget rows that left the transcript (a session switch, older history unloaded).
+ useEffect(() => {
+ const cache = measurementCache.current;
+ if (cache.size <= rows.length && drawn.size <= rows.length) return;
+ const ids = new Set(rows.map((row) => row.id));
+ for (const id of cache.keys()) if (!ids.has(id)) cache.delete(id);
+ setDrawn((current) => {
+ if ([...current.keys()].every((id) => ids.has(id))) return current;
+ return new Map([...current].filter(([id]) => ids.has(id)));
+ });
+ }, [rows, drawn]);
+ useLayoutEffect(flushDrawn);
const didOpenAtLatest = useRef(false);
const measurementCache = useRef(new Map());
useEffect(() => { const node = ref.current; if (!node) return; const observer = new ResizeObserver(() => { setHeight(node.clientHeight); setWidth(node.clientWidth); }); observer.observe(node); setWidth(node.clientWidth); return () => observer.disconnect(); }, []);
const previousLayout = useRef | null>(null);
- // Scroll frames re-render with the same rows; only rows, width, measured custom
- // heights or the registry can move a row.
+ const scrolledTo = useRef({ top: 0, atLatest: false });
+ // Scroll frames re-render with the same rows; only rows, width or the registry
+ // change an estimate.
+ const estimated = useMemo(() => {
+ const layoutStart = acpmuxPerf.enabled ? performance.now() : 0;
+ const layout = layoutConversation(rows, transcriptRowWidth(width), measurementCache.current, (row, rowWidth) => registry[rowKind(row)]?.measure?.(row, rowWidth));
+ return { layout, ms: acpmuxPerf.enabled ? performance.now() - layoutStart : 0 };
+ }, [rows, width, registry]);
+ // A row that draws moves only the rows below it: place them again, measuring none.
const measured = useMemo(() => {
const layoutStart = acpmuxPerf.enabled ? performance.now() : 0;
- const layout = layoutConversation(rows, transcriptRowWidth(width), measurementCache.current, (row, rowWidth) => registry[rowKind(row)]?.measure?.(row, rowWidth) ?? measuredHeights.get(row.id));
+ const rowWidth = transcriptRowWidth(width);
+ const layout = drawn.size === 0 ? estimated.layout : placeRows(estimated.layout, (index) => {
+ const known = drawn.get(rows[index].id);
+ return known && known.version === rows[index].version && known.width === rowWidth ? known.height : undefined;
+ });
return { layout, ms: acpmuxPerf.enabled ? performance.now() - layoutStart : 0 };
- }, [rows, width, measuredHeights, registry]);
+ }, [estimated, drawn, rows, width]);
const layout = measured.layout;
const reportedLayout = useRef(null);
+ const reportedEstimate = useRef(null);
const lead = Math.min(Math.abs(scroll.delta) * SCROLL_LEAD_STEPS, height * MAX_SCROLL_LEAD_VIEWPORTS);
const range = visibleLayoutRange(layout, scroll.delta < 0 ? scroll.top - lead : scroll.top, height + lead);
useLayoutEffect(() => {
@@ -135,16 +206,33 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry =
// A memo hit spent no time in geometry this render.
const freshLayout = reportedLayout.current !== measured;
reportedLayout.current = measured;
- const layoutMs = freshLayout ? measured.ms : 0;
+ const freshEstimate = reportedEstimate.current !== estimated;
+ reportedEstimate.current = estimated;
+ const layoutMs = (freshLayout ? measured.ms : 0) + (freshEstimate ? estimated.ms : 0);
if (acpmuxPerf.enabled && freshLayout) acpmuxPerf.addLayout(layoutMs);
if (acpmuxPerf.enabled && renderStart > 0) { const now = performance.now(); acpmuxPerf.commit(now - renderStart, layoutMs, acpmuxPerf.mountedTop, acpmuxPerf.mountedBottom, now); }
});
useLayoutEffect(() => {
const old = previousLayout.current;
const node = ref.current;
- if (old && node && old.tops.length === layout.tops.length && range.first > 0) {
- const delta = layout.tops[range.first] - old.tops[range.first];
- if (Math.abs(delta) > 0.5) node.scrollTop += delta;
+ if (old && node && old.tops.length === layout.tops.length) {
+ // Content that shrank under the viewport has already clamped the live offset to
+ // the new end; the offset recorded before this commit is where the reader was.
+ const live = node.scrollTop;
+ const clamped = live < scrolledTo.current.top - 0.5 && live >= layout.totalHeight - node.clientHeight - 1;
+ const top = clamped ? scrolledTo.current.top : live;
+ const atLatest = clamped ? scrolledTo.current.atLatest : top >= old.totalHeight - node.clientHeight - 1;
+ // At the first row nothing above can move it.
+ if (top > 0 && didOpenAtLatest.current && atLatest) {
+ // At the latest row: stay there as rows settle to their drawn heights.
+ const latest = Math.max(0, layout.totalHeight - node.clientHeight);
+ if (Math.abs(latest - node.scrollTop) > 0.5) node.scrollTop = latest;
+ } else if (top > 0) {
+ // Keep the row at the top of the viewport where it is as rows above it change height.
+ const anchor = visibleLayoutRange(old, top, 0, 0).first;
+ const delta = layout.tops[anchor] - old.tops[anchor];
+ if (clamped || Math.abs(delta) > 0.5) node.scrollTop = top + delta;
+ }
}
// Runs on height too: rows that fit and then overflow on a height-only shrink keep the same memoized layout.
if (!didOpenAtLatest.current && node && layout.totalHeight > node.clientHeight) {
@@ -154,13 +242,14 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry =
didOpenAtLatest.current = true;
}
previousLayout.current = layout;
+ if (node) scrolledTo.current = scrollPosition(node, layout.totalHeight);
}, [layout, range.first, height]);
// Commit before this frame paints; deferring to the next animation frame left the edge blank.
// Each settled scroll's frame pacing goes to the host, which picks the pane's rendering rate.
const pacing = useMemo(() => new ScrollPacing((intervals) => { callNative("pane.framePacing", { intervals }).catch(() => {}); }), []);
useEffect(() => () => pacing.stop(), [pacing]);
- const onScroll = (event: React.UIEvent) => { pacing.scrolled(); const next = event.currentTarget.scrollTop; flushSync(() => setScroll((current) => ({ top: next, delta: next - current.top }))); };
- return {rows.slice(range.first, range.last).map((row, index) => { const absoluteIndex = range.first + index; const kind = rowKind(row); const Component = registry[kind] ?? NoticeRow; const rendered =
; return
{Component.measure || defaultRegistry[kind] ? rendered : setMeasuredHeights((current) => { if (current.get(row.id) === value) return current; const next = new Map(current); next.set(row.id, value); return next; })}>{rendered}}; })}
;
+ const onScroll = (event: React.UIEvent) => { pacing.scrolled(); const next = event.currentTarget.scrollTop; scrolledTo.current = scrollPosition(event.currentTarget, layout.totalHeight); flushSync(() => setScroll((current) => ({ top: next, delta: next - current.top }))); };
+ return {rows.slice(range.first, range.last).map((row, index) => { const absoluteIndex = range.first + index; const kind = rowKind(row); const Component = registry[kind] ?? NoticeRow; const isExpanded = expanded.has(row.id); return ; })}
;
}
function PermissionCard({ permission }: { permission: AcpmuxPermission }) { return {permission.title || "Permission required"}{permission.options.map((option) => )}
; }
diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts
index 8d8d99d13e91..c2de47ab7645 100644
--- a/webviews/src/agent-session/acpmux/model.ts
+++ b/webviews/src/agent-session/acpmux/model.ts
@@ -108,10 +108,18 @@ export function visibleRowRange(rowCount: number, scrollTop: number, viewportHei
return { first, last };
}
+/// First-layout estimates for rows not yet drawn; a drawn row places by its drawn height. Each
+/// includes the row's bottom padding (`.acpmux-row` in styles.css: 16px for messages, 8px else).
function fallbackRowHeight(row: AcpmuxRow, width: number): number {
const textLines = Math.max(1, Math.ceil((row.text?.length ?? 0) / Math.max(24, Math.floor(width / 8))));
- if (row.kind === "activity") return Math.max(46, 24 + (row.items?.length ?? 0) * 20);
- if (row.kind === "turnSummary" || row.kind === "notice" || row.kind === "typing") return 32;
+ if (row.kind === "activity") {
+ // Collapsed tool calls, or the edited-files list (a title and one line per file).
+ const edits = row.items?.filter((item) => item.tool?.kind === "edit" || item.tool?.kind === "fileChange").length ?? 0;
+ return edits ? 8 + 16 * (1 + edits) : 34;
+ }
+ // Card padding and border, title, button row.
+ if (row.kind === "permission") return 87;
+ if (row.kind === "turnSummary" || row.kind === "notice" || row.kind === "plan" || row.kind === "typing") return 37;
return 24 + chromeHeight(row) + textLines * MESSAGE_LINE_HEIGHT;
}
@@ -173,6 +181,8 @@ function measuredRowHeight(row: AcpmuxRow, width: number, cache: Map 64) entry.prepared.clear();
}
if (entry.blocks.length === 0) return fallbackRowHeight(row, width);
const contentWidth = Math.max(80, row.kind === "user" ? USER_BUBBLE_SHARE * width - USER_BUBBLE_SIDES : width);
@@ -196,6 +206,22 @@ export function layoutConversation(rows: AcpmuxRow[], width: number, cache = new
return { tops, heights, totalHeight: top };
}
+/// Places rows again over `estimate`, taking a row's height from `heightAt` when it has one.
+/// No row is measured, so this costs one pass over the rows' heights.
+export function placeRows(estimate: ConversationLayout, heightAt: (index: number) => number | undefined): ConversationLayout {
+ const tops = new Float64Array(estimate.heights.length);
+ const heights = new Float64Array(estimate.heights.length);
+ let top = 0;
+ for (let index = 0; index < heights.length; index += 1) {
+ tops[index] = top;
+ const known = heightAt(index);
+ const height = known !== undefined && known > 0 ? known : estimate.heights[index];
+ heights[index] = height;
+ top += height;
+ }
+ return { tops, heights, totalHeight: top };
+}
+
function upperBound(values: Float64Array, target: number): number {
let low = 0;
let high = values.length;
diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css
index 76e7a2214cbf..a59e350a2b8e 100644
--- a/webviews/src/agent-session/acpmux/styles.css
+++ b/webviews/src/agent-session/acpmux/styles.css
@@ -1 +1 @@
-.acpmux-shell{height:100%;display:flex;flex-direction:column;color:var(--agent-text);font:13px "Helvetica Neue", Arial, sans-serif;background:var(--agent-page-bg);overflow:hidden}.acpmux-header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--agent-border)}.acpmux-title{font-size:14px}.acpmux-status{margin-left:8px;color:var(--agent-muted);font-size:11px}.acpmux-session,.acpmux-model,.acpmux-mode,.acpmux-effort{border:1px solid var(--agent-border);border-radius:999px;background:var(--agent-control);color:var(--agent-text);padding:5px 9px}.acpmux-scroll{flex:1;overflow:auto;contain:strict}.acpmux-spacer{position:relative;min-height:100%}.acpmux-thread{position:absolute;inset:0 0 auto;padding:16px max(18px,calc((100% - 760px)/2));}.acpmux-row{position:absolute;left:max(18px,calc((100% - 760px)/2));right:max(18px,calc((100% - 760px)/2));min-height:34px;contain:layout paint}.acpmux-user{display:flex;justify-content:flex-end}.acpmux-bubble,.acpmux-user-bubble{max-width:78%;padding:9px 12px;box-sizing:border-box;border-radius:16px;background:color-mix(in srgb,var(--agent-accent) 20%,transparent);white-space:pre-wrap}.acpmux-markdown{line-height:20px}.acpmux-markdown>*{margin:8px 0 0}.acpmux-markdown>:first-child{margin-top:0}.acpmux-markdown code{font:11.5px var(--font-mono)}.acpmux-markdown blockquote{padding-left:12px;border-left:2px solid var(--agent-border);color:var(--agent-soft)}.acpmux-markdown pre,.acpmux-activity-item pre{padding:9px;border-radius:8px;overflow:auto;background:var(--agent-input-bg);font:12px var(--font-mono)}.acpmux-summary,.acpmux-muted{color:var(--agent-muted);font-size:11px;padding:8px 0}.acpmux-activity-toggle{border:0;background:none;color:var(--agent-muted);padding:5px 0;cursor:pointer}.acpmux-activity-items{border-left:1px solid var(--agent-border);padding-left:12px;color:var(--agent-soft)}.acpmux-activity-item{padding:3px 0}.acpmux-glyph{display:inline-block;width:18px;color:var(--agent-accent)}.acpmux-permission{padding:0 max(18px,calc((100% - 760px)/2))}.acpmux-permission-card{padding:11px;border:1px solid var(--agent-accent);border-radius:12px;background:var(--agent-surface-elevated)}.acpmux-permission-buttons{display:flex;gap:7px;margin-top:9px}.acpmux-permission-buttons button,.acpmux-composer button{border:0;border-radius:999px;padding:7px 12px;background:var(--agent-accent);color:#fff}.acpmux-queue{display:flex;gap:6px;overflow:auto;padding:5px max(18px,calc((100% - 760px)/2));color:var(--agent-muted)}.acpmux-composer{display:flex;align-items:end;gap:8px;padding:10px max(18px,calc((100% - 760px)/2));border-top:1px solid var(--agent-border);background:var(--agent-surface)}.acpmux-chips{display:flex;gap:5px}.acpmux-composer textarea{flex:1;resize:none;border:1px solid var(--agent-border-strong);border-radius:15px;padding:10px;background:var(--agent-input-bg);color:var(--agent-text);font:inherit}.acpmux-cancel{background:var(--agent-control)!important;color:var(--agent-text)!important}
+.acpmux-shell{height:100%;display:flex;flex-direction:column;color:var(--agent-text);font:13px "Helvetica Neue", Arial, sans-serif;background:var(--agent-page-bg);overflow:hidden}.acpmux-header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--agent-border)}.acpmux-title{font-size:14px}.acpmux-status{margin-left:8px;color:var(--agent-muted);font-size:11px}.acpmux-session,.acpmux-model,.acpmux-mode,.acpmux-effort{border:1px solid var(--agent-border);border-radius:999px;background:var(--agent-control);color:var(--agent-text);padding:5px 9px}.acpmux-scroll{flex:1;overflow:auto;contain:strict}.acpmux-spacer{position:relative;min-height:100%}.acpmux-thread{position:absolute;inset:0 0 auto;padding:16px max(18px,calc((100% - 760px)/2));}.acpmux-row{position:absolute;left:max(18px,calc((100% - 760px)/2));right:max(18px,calc((100% - 760px)/2));min-height:34px;box-sizing:border-box;padding-bottom:8px;contain:layout paint}.acpmux-row.acpmux-user,.acpmux-row.acpmux-assistant{padding-bottom:16px}.acpmux-user{display:flex;justify-content:flex-end}.acpmux-bubble,.acpmux-user-bubble{max-width:78%;padding:9px 12px;box-sizing:border-box;border-radius:16px;background:color-mix(in srgb,var(--agent-accent) 20%,transparent);white-space:pre-wrap}.acpmux-markdown{line-height:20px}.acpmux-markdown>*{margin:8px 0 0}.acpmux-markdown>:first-child{margin-top:0}.acpmux-markdown code{font:11.5px var(--font-mono)}.acpmux-markdown blockquote{padding-left:12px;border-left:2px solid var(--agent-border);color:var(--agent-soft)}.acpmux-markdown pre,.acpmux-activity-item pre{padding:9px;border-radius:8px;overflow:auto;background:var(--agent-input-bg);font:12px var(--font-mono)}.acpmux-summary,.acpmux-muted{color:var(--agent-muted);font-size:11px;padding:8px 0}.acpmux-activity-toggle{border:0;background:none;color:var(--agent-muted);padding:5px 0;cursor:pointer}.acpmux-activity-items{border-left:1px solid var(--agent-border);padding-left:12px;color:var(--agent-soft)}.acpmux-activity-item{padding:3px 0}.acpmux-glyph{display:inline-block;width:18px;color:var(--agent-accent)}.acpmux-permission{padding:0 max(18px,calc((100% - 760px)/2))}.acpmux-permission-card{padding:11px;border:1px solid var(--agent-accent);border-radius:12px;background:var(--agent-surface-elevated)}.acpmux-permission-buttons{display:flex;gap:7px;margin-top:9px}.acpmux-permission-buttons button,.acpmux-composer button{border:0;border-radius:999px;padding:7px 12px;background:var(--agent-accent);color:#fff}.acpmux-queue{display:flex;gap:6px;overflow:auto;padding:5px max(18px,calc((100% - 760px)/2));color:var(--agent-muted)}.acpmux-composer{display:flex;align-items:end;gap:8px;padding:10px max(18px,calc((100% - 760px)/2));border-top:1px solid var(--agent-border);background:var(--agent-surface)}.acpmux-chips{display:flex;gap:5px}.acpmux-composer textarea{flex:1;resize:none;border:1px solid var(--agent-border-strong);border-radius:15px;padding:10px;background:var(--agent-input-bg);color:var(--agent-text);font:inherit}.acpmux-cancel{background:var(--agent-control)!important;color:var(--agent-text)!important}
diff --git a/webviews/src/agent-session/acpmux/transcript.test.tsx b/webviews/src/agent-session/acpmux/transcript.test.tsx
index 5a25372101aa..2a3bb60e33c0 100644
--- a/webviews/src/agent-session/acpmux/transcript.test.tsx
+++ b/webviews/src/agent-session/acpmux/transcript.test.tsx
@@ -13,7 +13,7 @@ Object.assign(globals, {
document: dom.window.document,
navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement,
- ResizeObserver: class { constructor(callback: () => void) { resizeCallbacks.push(callback); } observe() {} disconnect() {} },
+ ResizeObserver: class { constructor(callback: () => void) { resizeCallbacks.push(callback); } observe() {} unobserve() {} disconnect() {} },
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0) as unknown as number,
cancelAnimationFrame: (handle: number) => clearTimeout(handle),
IS_REACT_ACT_ENVIRONMENT: true,
@@ -32,7 +32,9 @@ function fakeViewport(size: { width: number; height: number }) {
const isScroller = (node: HTMLElement) => node.classList.contains("acpmux-scroll");
Object.defineProperty(prototype, "clientHeight", { configurable: true, get(this: HTMLElement) { return isScroller(this) ? size.height : 0; } });
Object.defineProperty(prototype, "clientWidth", { configurable: true, get(this: HTMLElement) { return isScroller(this) ? size.width : 0; } });
- Object.defineProperty(prototype, "scrollTop", { configurable: true, get(this: HTMLElement) { return offsets.get(this) ?? 0; }, set(this: HTMLElement, value: number) { offsets.set(this, value); } });
+ // Like a browser, the offset clamps to the content once it lays out again.
+ const maximum = (node: HTMLElement) => Math.max(0, parseFloat(node.querySelector(".acpmux-spacer")?.style.height || "0") - size.height);
+ Object.defineProperty(prototype, "scrollTop", { configurable: true, get(this: HTMLElement) { const offset = Math.min(offsets.get(this) ?? 0, maximum(this)); offsets.set(this, offset); return offset; }, set(this: HTMLElement, value: number) { offsets.set(this, Math.max(0, Math.min(value, maximum(this)))); } });
return () => { for (const key of ["clientHeight", "clientWidth", "scrollTop"]) delete (prototype as unknown as Record)[key]; };
}
@@ -182,6 +184,144 @@ describe("acpmux transcript accessibility", () => {
});
});
+/// A seeded generator, so a failing shape reproduces.
+function seeded(seed: number) {
+ let state = seed >>> 0;
+ return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 2 ** 32; };
+}
+
+/// Rows of every kind the transcript draws, in random markdown shapes.
+function randomConversation(count: number, random: () => number): AcpmuxRow[] {
+ const pieces = ["A sentence with `code` in it.", "## Heading\nText under it.", "- one\n- [ ] two\n- three", "```\nlet x = 1\n```", "> quoted", "Line one\nline two", "**bold** and [a link](https://example.com)"];
+ return Array.from({ length: count }, (_, index) => {
+ const pick = random();
+ const text = Array.from({ length: 1 + Math.floor(random() * 4) }, () => pieces[Math.floor(random() * pieces.length)]!).join("\n\n");
+ if (pick < 0.35) return { id: `r${index}`, version: 1, at: index, kind: "user", text };
+ if (pick < 0.75) return { id: `r${index}`, version: 1, at: index, kind: "assistant", text };
+ if (pick < 0.85) return { id: `r${index}`, version: 1, at: index, kind: "activity", toolCount: 2, items: [{ kind: "tool", text: "Read a file" }] } as AcpmuxRow;
+ if (pick < 0.92) return { id: `r${index}`, version: 1, at: index, kind: "permission", permission: { permissionId: `p${index}`, title: "Allow this?", options: [{ id: "allow", name: "Allow" }] } } as AcpmuxRow;
+ return { id: `r${index}`, version: 1, at: index, kind: "turnSummary", durationMs: 2000, toolCount: 1 };
+ });
+}
+
+describe("acpmux measured rows", () => {
+ /// The layout estimates a row's height before it draws, and some shapes always draw taller
+ /// than any estimate (fonts, permission cards, expanded tool output). A row the page has drawn
+ /// must be placed by its drawn height, so no row runs under the next one.
+ test("drawn rows never overlap, whatever their shape", async () => {
+ const restore = fakeViewport({ width: 760, height: 600 });
+ const random = seeded(16476);
+ const conversation = randomConversation(300, random);
+ // jsdom does no layout: each row draws at a height the estimator can't know.
+ const drawn = new Map(conversation.map((row) => [row.id, 30 + Math.round(random() * 220)]));
+ const prototype = dom.window.HTMLElement.prototype;
+ const original = prototype.getBoundingClientRect;
+ prototype.getBoundingClientRect = function (this: HTMLElement) {
+ const index = Number(this.getAttribute("aria-posinset")) - 1;
+ const height = this.classList.contains("acpmux-row") ? drawn.get(conversation[index]?.id ?? "") ?? 0 : 0;
+ return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: height, width: 0, height, toJSON() { return {}; } } as DOMRect;
+ };
+ const root = createRoot(dom.window.document.getElementById("root")!);
+ const overlaps = () => {
+ const placed = [...dom.window.document.querySelectorAll(".acpmux-row")]
+ .map((article) => ({ index: Number(article.getAttribute("aria-posinset")) - 1, top: Number(/translateY\(([-\d.]+)px\)/.exec(article.style.transform)?.[1]) }))
+ .sort((a, b) => a.index - b.index);
+ const found: string[] = [];
+ for (let position = 1; position < placed.length; position += 1) {
+ const above = placed[position - 1]!;
+ const below = placed[position]!;
+ if (below.index !== above.index + 1) continue;
+ const bottom = above.top + drawn.get(conversation[above.index]!.id)!;
+ if (bottom > below.top + 0.5) found.push(`${conversation[above.index]!.id} ends at ${bottom}, ${conversation[below.index]!.id} starts at ${below.top}`);
+ }
+ return found;
+ };
+ try {
+ await act(async () => root.render(createElement(VirtualTranscript, { rows: conversation, onToggleActivity: () => {}, expanded: new Set() })));
+ expect(overlaps()).toEqual([]);
+ const scroller = dom.window.document.querySelector(".acpmux-scroll") as HTMLElement;
+ // Opened at the latest row, it stays there as the rows settle to their drawn heights.
+ const spacer = dom.window.document.querySelector(".acpmux-spacer") as HTMLElement;
+ expect(scroller.scrollTop).toBe(parseFloat(spacer.style.height) - 600);
+ for (const top of [0, 4000, 9000]) {
+ await act(async () => { scroller.scrollTop = top; scroller.dispatchEvent(new dom.window.Event("scroll")); });
+ expect(overlaps()).toEqual([]);
+ }
+ // A row above the viewport that grows leaves the row at the viewport's top where it is.
+ const placed = () => [...dom.window.document.querySelectorAll(".acpmux-row")].map((article) => ({ article, index: Number(article.getAttribute("aria-posinset")) - 1, top: Number(/translateY\(([-\d.]+)px\)/.exec(article.style.transform)?.[1]) }));
+ const atTop = () => placed().filter((row) => row.top <= scroller.scrollTop).sort((a, b) => b.top - a.top)[0]!;
+ const anchor = atTop();
+ const offset = scroller.scrollTop - anchor.top;
+ const above = placed().filter((row) => row.index < anchor.index).sort((a, b) => a.index - b.index)[0]!;
+ drawn.set(conversation[above.index]!.id, drawn.get(conversation[above.index]!.id)! + 100);
+ await act(async () => { for (const callback of resizeCallbacks) (callback as (entries: { target: Element }[]) => void)([{ target: above.article }]); });
+ expect(atTop().index).toBe(anchor.index);
+ expect(scroller.scrollTop - atTop().top).toBe(offset);
+ expect(overlaps()).toEqual([]);
+ } finally {
+ await act(async () => root.unmount());
+ prototype.getBoundingClientRect = original;
+ restore();
+ }
+ });
+ /// A fling mounts rows that have not drawn yet, and each one reports its height once.
+ /// Placing it must not measure every row of the conversation again.
+ test("a row's drawn height re-places the rows without measuring them again", async () => {
+ const restore = fakeViewport({ width: 760, height: 600 });
+ let measures = 0;
+ const Plain = Object.assign(() => null, { measure: () => { measures += 1; return 50; } });
+ const registry = { user: Plain, assistant: Plain } as never;
+ const prototype = dom.window.HTMLElement.prototype;
+ const original = prototype.getBoundingClientRect;
+ let drawnHeight = 0;
+ prototype.getBoundingClientRect = function (this: HTMLElement) {
+ const height = this.classList.contains("acpmux-row") && this.getAttribute("aria-posinset") === "200" ? drawnHeight : 0;
+ return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: height, width: 0, height, toJSON() { return {}; } } as DOMRect;
+ };
+ const root = createRoot(dom.window.document.getElementById("root")!);
+ try {
+ await act(async () => root.render(createElement(VirtualTranscript, { rows, onToggleActivity: () => {}, expanded: new Set(), registry })));
+ const spacer = dom.window.document.querySelector(".acpmux-spacer") as HTMLElement;
+ const estimated = parseFloat(spacer.style.height);
+ const afterOpen = measures;
+ drawnHeight = 90;
+ const latest = dom.window.document.querySelector('.acpmux-row[aria-posinset="200"]')!;
+ await act(async () => { for (const callback of resizeCallbacks) (callback as (entries: { target: Element }[]) => void)([{ target: latest }]); });
+ expect(parseFloat(spacer.style.height)).toBe(estimated + 40);
+ expect(measures).toBe(afterOpen);
+ } finally {
+ await act(async () => root.unmount());
+ prototype.getBoundingClientRect = original;
+ restore();
+ }
+ });
+
+ /// Rows that draw shorter than estimated shrink the content under a viewport at the latest row,
+ /// and the browser clamps the offset before the layout effect sees it.
+ test("opened at the latest row, it stays there as rows draw shorter than estimated", async () => {
+ const restore = fakeViewport({ width: 760, height: 600 });
+ const conversation: AcpmuxRow[] = Array.from({ length: 300 }, (_, index) => ({ id: `long-${index}`, version: 1, at: index, kind: "assistant", text: `${"word ".repeat(200)}${index}` }));
+ const prototype = dom.window.HTMLElement.prototype;
+ const original = prototype.getBoundingClientRect;
+ prototype.getBoundingClientRect = function (this: HTMLElement) {
+ const height = this.classList.contains("acpmux-row") ? 40 : 0;
+ return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: height, width: 0, height, toJSON() { return {}; } } as DOMRect;
+ };
+ const root = createRoot(dom.window.document.getElementById("root")!);
+ try {
+ await act(async () => root.render(createElement(VirtualTranscript, { rows: conversation, onToggleActivity: () => {}, expanded: new Set() })));
+ const scroller = dom.window.document.querySelector(".acpmux-scroll") as HTMLElement;
+ const spacer = dom.window.document.querySelector(".acpmux-spacer") as HTMLElement;
+ for (let frame = 0; frame < 3; frame += 1) await act(async () => { scroller.dispatchEvent(new dom.window.Event("scroll")); });
+ expect(scroller.scrollTop).toBe(parseFloat(spacer.style.height) - 600);
+ } finally {
+ await act(async () => root.unmount());
+ prototype.getBoundingClientRect = original;
+ restore();
+ }
+ });
+});
+
describe("acpmux renderer registry", () => {
test("registering the same renderer again does not re-render the pane", async () => {
const root = createRoot(dom.window.document.getElementById("root")!);