Skip to content

Large diffs are not rendered by default.

131 changes: 110 additions & 21 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -80,25 +80,36 @@ 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<HTMLElement>(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 <div ref={ref as React.RefObject<HTMLDivElement>}>{children}</div>;
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 <article ref={ref} data-row-id={row.id} className={`acpmux-row acpmux-${kind}`} aria-label={speaker(kind)} aria-posinset={index + 1} aria-setsize={setSize} style={{ transform: `translateY(${top}px)` }}>{children}</article>;
}

/// Who spoke, for assistive technology: each article is one message in the transcript feed.
const speaker = (kind: string) => kind === "user" ? "You" : kind === "assistant" ? "Agent" : undefined;
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.
Expand All @@ -112,20 +123,80 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry =
const [height, setHeight] = useState(600);
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(760);
const [measuredHeights, setMeasuredHeights] = useState(new Map<string, number>());
// Rows place by their drawn height once drawn, and by the estimate until then.
const [drawn, setDrawn] = useState(new Map<string, DrawnHeight>());
const pendingDrawn = useRef(new Map<string, DrawnHeight>());
const rowWidthRef = useRef(transcriptRowWidth(width));
rowWidthRef.current = transcriptRowWidth(width);
const rowsRef = useRef(rows);
rowsRef.current = rows;
const reportDrawn = useCallback<ReportDrawn>((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<string, DrawnHeight> | 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The drawn-height cache has no session identity: switching to a same-sized session can recreate IDs such as user-1 at version 1 and retain the prior session's heights. Invalidate drawn heights on session changes or include a session/generation key in the cache validity check.

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 161:

<comment>The drawn-height cache has no session identity: switching to a same-sized session can recreate IDs such as `user-1` at version 1 and retain the prior session's heights. Invalidate drawn heights on session changes or include a session/generation key in the cache validity check.</comment>

<file context>
@@ -111,7 +119,54 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry =
+  // 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);
</file context>

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<string, import("./model").PreparedRow>());
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<ReturnType<typeof layoutConversation> | 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<typeof measured | null>(null);
const reportedEstimate = useRef<typeof estimated | null>(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(() => {
Expand All @@ -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) {
Expand All @@ -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<HTMLDivElement>) => { pacing.scrolled(); const next = event.currentTarget.scrollTop; flushSync(() => setScroll((current) => ({ top: next, delta: next - current.top }))); };
return <div ref={ref} className="acpmux-scroll" role="feed" aria-label="Transcript" onScroll={onScroll}><div className="acpmux-spacer" style={{ height: layout.totalHeight }}><div className="acpmux-thread">{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 = <Component row={row} onToggleActivity={onToggleActivity} expanded={expanded.has(row.id)} />; return <article className={`acpmux-row acpmux-${kind}`} aria-label={speaker(kind)} aria-posinset={absoluteIndex + 1} aria-setsize={canLoadOlder ? -1 : rows.length} style={{ transform: `translateY(${layout.tops[absoluteIndex]}px)` }} key={row.id}>{Component.measure || defaultRegistry[kind] ? rendered : <MeasuredCustomRow onHeight={(value) => setMeasuredHeights((current) => { if (current.get(row.id) === value) return current; const next = new Map(current); next.set(row.id, value); return next; })}>{rendered}</MeasuredCustomRow>}</article>; })}</div></div></div>;
const onScroll = (event: React.UIEvent<HTMLDivElement>) => { pacing.scrolled(); const next = event.currentTarget.scrollTop; scrolledTo.current = scrollPosition(event.currentTarget, layout.totalHeight); flushSync(() => setScroll((current) => ({ top: next, delta: next - current.top }))); };
return <div ref={ref} className="acpmux-scroll" role="feed" aria-label="Transcript" onScroll={onScroll}><div className="acpmux-spacer" style={{ height: layout.totalHeight }}><div className="acpmux-thread">{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 <RowFrame key={row.id} row={row} kind={kind} index={absoluteIndex} setSize={canLoadOlder ? -1 : rows.length} top={layout.tops[absoluteIndex]} rowWidth={transcriptRowWidth(width)} expanded={isExpanded} observer={observer} report={reportDrawn}><Component row={row} onToggleActivity={onToggleActivity} expanded={isExpanded} /></RowFrame>; })}</div></div></div>;
}

function PermissionCard({ permission }: { permission: AcpmuxPermission }) { return <div className="acpmux-permission-card"><strong>{permission.title || "Permission required"}</strong><div className="acpmux-permission-buttons">{permission.options.map((option) => <button key={option.id} onClick={() => void callNative("chat.permission", { permissionId: permission.permissionId, optionId: option.id })}>{option.name}</button>)}</div></div>; }
Expand Down
30 changes: 28 additions & 2 deletions webviews/src/agent-session/acpmux/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -173,6 +181,8 @@ function measuredRowHeight(row: AcpmuxRow, width: number, cache: Map<string, Pre
} else if (entry.text !== row.text) {
entry.text = row.text;
entry.blocks = markdownBlocks(row.text);
// A streaming row prepares a new last block on every version; keep the cache bounded.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Once a streaming row passes 64 versions, every later version clears the entire prepared map and re-measures all blocks from scratch. Streaming rows change text on every chunk, so the entry.text !== row.text branch runs each version and the prepared.size > 64 check stays true; the cap therefore destroys exactly the reuse it is meant to preserve — the first blocks of a long assistant message keep identical text strings across versions and would otherwise be reused from the cache. For a stream extending past ~64 chunks (common for agent responses, re-laid out each version), this turns every frame's layout into a full re-preparation of the message. Evict only entries not referenced by the current entry.blocks (or least-recently-used entries) instead of clearing the whole map.

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/model.ts, line 184:

<comment>Once a streaming row passes 64 versions, every later version clears the entire `prepared` map and re-measures all blocks from scratch. Streaming rows change `text` on every chunk, so the `entry.text !== row.text` branch runs each version and the `prepared.size > 64` check stays true; the cap therefore destroys exactly the reuse it is meant to preserve — the first blocks of a long assistant message keep identical text strings across versions and would otherwise be reused from the cache. For a stream extending past ~64 chunks (common for agent responses, re-laid out each version), this turns every frame's layout into a full re-preparation of the message. Evict only entries not referenced by the current `entry.blocks` (or least-recently-used entries) instead of clearing the whole map.</comment>

<file context>
@@ -173,6 +181,8 @@ function measuredRowHeight(row: AcpmuxRow, width: number, cache: Map<string, Pre
   } else if (entry.text !== row.text) {
     entry.text = row.text;
     entry.blocks = markdownBlocks(row.text);
+    // A streaming row prepares a new last block on every version; keep the cache bounded.
+    if (entry.prepared.size > 64) entry.prepared.clear();
   }
</file context>

if (entry.prepared.size > 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);
Expand All @@ -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];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: placeRows accepts any positive value from heightAt, including Infinity. A non-finite height then propagates: every subsequent tops entry and totalHeight becomes Infinity, upperBound in visibleLayoutRange returns the array end for every boundary after that row, and the spacer's height: Infinity is invalid CSS, collapsing the virtualized list. The sibling path layoutConversation guards the same kind of input with Number.isFinite(customHeight). Add the same check so a bad known height falls back to the estimate.

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/model.ts, line 218:

<comment>`placeRows` accepts any positive value from `heightAt`, including `Infinity`. A non-finite height then propagates: every subsequent `tops` entry and `totalHeight` becomes `Infinity`, `upperBound` in `visibleLayoutRange` returns the array end for every boundary after that row, and the spacer's `height: Infinity` is invalid CSS, collapsing the virtualized list. The sibling path `layoutConversation` guards the same kind of input with `Number.isFinite(customHeight)`. Add the same check so a bad known height falls back to the estimate.</comment>

<file context>
@@ -206,6 +206,22 @@ export function layoutConversation(rows: AcpmuxRow[], width: number, cache = new
+  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;
</file context>
Suggested change
const height = known !== undefined && known > 0 ? known : estimate.heights[index];
const height = known !== undefined && Number.isFinite(known) && 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;
Expand Down
Loading
Loading