diff --git a/webviews/bench/height-index.bench.ts b/webviews/bench/height-index.bench.ts
index 30bdf80e765f..9bffb495b17a 100644
--- a/webviews/bench/height-index.bench.ts
+++ b/webviews/bench/height-index.bench.ts
@@ -1,60 +1,89 @@
///
+// l10n-allow-file: bench output labels, not shipped UI.
/**
- * Model-only deciding bench for transcript height corrections.
+ * Model-only bench for transcript placement: base's sequential `placeRows` (before) against the
+ * gap-aware index's `placeRowsIndexed` (after), fed the same estimate, marks and drawn heights as
+ * App.tsx feeds them, for the three passes a long chat makes:
*
- * This intentionally does not claim browser or React-frame coverage. It uses
- * the same row counts and token-burst update loop for the old full-prefix
- * placement shape and the immutable block-tree update path.
+ * - token: the last row (a streaming reply) grows; every row before it is kept;
+ * - middle: a row half way down draws at a new height (a fold, an image loading);
+ * - top: a row near the top draws at a new height (a measure after scrolling to the top).
+ *
+ * Each pass places from the previous one, as App does. It checks that both give the same total and
+ * tops, and prints ms per pass (p50/p95) for each. No React, DOM or browser frame claim.
*
* bun bench/height-index.bench.ts
- * ROWS=9000 UPDATES=300 bun bench/height-index.bench.ts
+ * ROWS=9000 PASSES=300 bun bench/height-index.bench.ts
*/
-import { PlacedHeightIndex } from "../src/agent-session/acpmux/placedHeightIndex";
+import {
+ placeRows,
+ placeRowsIndexed,
+ type ConversationLayout,
+ type PlacedLayout,
+} from "../src/agent-session/acpmux/model";
+import { MARK_MESSAGE, MARK_PROMPT, MARK_STEP, MARK_FOOTER_ROW } from "../src/agent-session/acpmux/transcriptGrid";
const rows = Number(process.env.ROWS ?? 9_000);
-const updates = Number(process.env.UPDATES ?? 300);
-if (!Number.isSafeInteger(rows) || rows <= 0) throw new Error("ROWS must be a positive integer");
-if (!Number.isSafeInteger(updates) || updates <= 0) throw new Error("UPDATES must be a positive integer");
+const passes = Number(process.env.PASSES ?? 300);
+if (!Number.isSafeInteger(rows) || rows <= 1) throw new Error("ROWS must be an integer above 1");
+if (!Number.isSafeInteger(passes) || passes <= 0) throw new Error("PASSES must be a positive integer");
-const initial = new Float64Array(rows);
-initial.fill(42);
-const changed = Math.max(0, rows - 1);
+// A chat's shape: prompt, steps, a reply, its footer row, and rows that draw nothing.
+const MARK_CYCLE = [MARK_PROMPT, MARK_STEP, MARK_STEP, 0, MARK_MESSAGE, MARK_FOOTER_ROW, 0];
+const estimate: ConversationLayout = {
+ tops: new Float64Array(rows),
+ heights: Float64Array.from({ length: rows }, (_, index) => (index % 7 === 3 ? 0 : 22.75 + (index % 13) * 8)),
+ marks: Uint8Array.from({ length: rows }, (_, index) => MARK_CYCLE[index % MARK_CYCLE.length]!),
+ totalHeight: 0,
+ scale: 1,
+};
+const drawn = Float64Array.from(estimate.heights);
+const heightAt = (index: number) => drawn[index];
-function fullPrefixPlacement(values: Float64Array, nextHeight: number): number {
- const tops = new Float64Array(values.length);
- let top = 0;
- for (let index = 0; index < values.length; index += 1) {
- tops[index] = top;
- top += index === changed ? nextHeight : values[index]!;
- }
- return tops[changed]! + nextHeight;
+function percentile(values: number[], share: number): number {
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.min(sorted.length - 1, Math.floor(share * sorted.length))]!;
}
-const beforeStart = performance.now();
-let beforeTotal = 0;
-for (let update = 0; update < updates; update += 1) beforeTotal += fullPrefixPlacement(initial, 43 + (update & 1));
-const beforeMs = performance.now() - beforeStart;
+type Workload = { name: string; row: number };
+const workloads: Workload[] = [
+ { name: "token", row: rows - 1 },
+ { name: "middle", row: Math.floor(rows / 2) },
+ { name: "top", row: 5 },
+];
-let index = PlacedHeightIndex.fromHeights(initial);
-const afterStart = performance.now();
-let afterTotal = 0;
-for (let update = 0; update < updates; update += 1) {
- index = index.update(changed, 43 + (update & 1));
- afterTotal += index.totalHeight;
+const report: Record = { rows, passes, scope: "model-only; no React, DOM, WebKit or frame claim" };
+for (const { name, row } of workloads) {
+ drawn.set(estimate.heights);
+ let before = placeRows(estimate, heightAt);
+ let after: PlacedLayout = placeRowsIndexed(estimate, heightAt);
+ const beforeMs: number[] = [];
+ const afterMs: number[] = [];
+ for (let pass = 0; pass < passes; pass += 1) {
+ drawn[row] = estimate.heights[row]! + 1 + (pass % 5);
+ let start = performance.now();
+ before = placeRows(estimate, heightAt, { layout: before, kept: row });
+ beforeMs.push(performance.now() - start);
+ start = performance.now();
+ after = placeRowsIndexed(estimate, heightAt, {
+ layout: after,
+ kept: row,
+ from: rows,
+ changed: [row],
+ });
+ afterMs.push(performance.now() - start);
+ }
+ for (const at of [0, row, rows - 1])
+ if (Math.abs(after.geometry.topAt(at) - before.tops[at]!) > 1e-6) throw new Error(`${name}: tops differ at ${at}`);
+ if (Math.abs(after.totalHeight - before.totalHeight) > 1e-6) throw new Error(`${name}: totals differ`);
+ const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
+ report[name] = {
+ beforeP50Ms: Number(percentile(beforeMs, 0.5).toFixed(4)),
+ beforeP95Ms: Number(percentile(beforeMs, 0.95).toFixed(4)),
+ afterP50Ms: Number(percentile(afterMs, 0.5).toFixed(4)),
+ afterP95Ms: Number(percentile(afterMs, 0.95).toFixed(4)),
+ speedupTotal: Number((sum(beforeMs) / Math.max(sum(afterMs), Number.EPSILON)).toFixed(2)),
+ };
}
-const afterMs = performance.now() - afterStart;
-
-const report = {
- rows,
- updates,
- beforeMs: Number(beforeMs.toFixed(3)),
- afterMs: Number(afterMs.toFixed(3)),
- beforePerUpdateMs: Number((beforeMs / updates).toFixed(4)),
- afterPerUpdateMs: Number((afterMs / updates).toFixed(4)),
- speedup: Number((beforeMs / Math.max(afterMs, Number.EPSILON)).toFixed(2)),
- totalsMatch: Math.abs(beforeTotal / updates - afterTotal / updates) < 0.001,
- scope: "model-only; no React, DOM, WebKit, or full-app frame claim",
-};
-if (!report.totalsMatch) throw new Error("full and indexed placement totals differ");
console.log(JSON.stringify(report, null, 2));
diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx
index a7977f7af350..7fae9c500c1c 100644
--- a/webviews/src/agent-session/acpmux/App.tsx
+++ b/webviews/src/agent-session/acpmux/App.tsx
@@ -36,10 +36,13 @@ import {
layoutConversation,
onlyQuietThoughts,
type ConversationLayout,
+ type PlacedLayout,
type RowHeights,
paneHeader,
- placeRows,
- withOrigin,
+ layoutGeometry,
+ placedFromEstimate,
+ placedWithOrigin,
+ placeRowsIndexed,
transcriptRowWidth,
mergeRowRanges,
scrollLeadRanges,
@@ -2622,7 +2625,7 @@ function restingAnchor(
): ScrollAnchor | null {
const index = visibleLayoutRange(placed, top, 0, 0).first;
const row = shown[index];
- return atLatest || !row ? null : { rowId: row.id, offset: top - placed.tops[index] };
+ return atLatest || !row ? null : { rowId: row.id, offset: top - layoutGeometry(placed).topAt(index) };
}
/// Jump to latest's diameter, CSS px (conversation.css .acpmux-jump-latest): its fisheye filter is
@@ -3315,7 +3318,7 @@ export function VirtualTranscript({
| {
rows: readonly AcpmuxRow[];
drawn: Map;
- layout: ConversationLayout;
+ layout: PlacedLayout;
width: number;
sessionId?: string;
/// A new registry estimates every row again, so nothing is kept.
@@ -3571,7 +3574,7 @@ export function VirtualTranscript({
measure(false);
return () => observer.disconnect();
}, [noteQueue]);
- const previousLayout = useRef | null>(null);
+ const previousLayout = useRef(null);
const previousRows = useRef(rows);
const previousViewport = useRef({ height, width, dockSpace });
/// The room kept under the last row (`room` below) as of the last placement pass.
@@ -3748,6 +3751,8 @@ export function VirtualTranscript({
// Rows before the first that changed (by object, or by drawn height) keep their places. A
// drawn height comes from a mounted row, so its index is looked for around the mounted range.
let kept = 0;
+ let rowsKept = 0;
+ const changedAt: number[] = [];
if (
last &&
last.width === rowWidth &&
@@ -3769,6 +3774,9 @@ export function VirtualTranscript({
keepsReplyRoom(last.rows[reply]!, nextDrawnRow(last.rows, reply))
)
kept = reply;
+ // The rows from here on are new or follow a new row: all placed again. Before it, only the
+ // rows whose drawn height changed are (placeRowsIndexed's `from` and `changed`).
+ rowsKept = kept;
if (changed.size > 0) {
let found = 0;
const from = Math.max(0, last.first - DRAWN_SEARCH_ROWS);
@@ -3776,12 +3784,14 @@ export function VirtualTranscript({
for (let index = from; index < to && found < changed.size; index += 1)
if (changed.has(rows[index]!.id)) {
kept = Math.min(kept, index);
+ if (index < rowsKept) changedAt.push(index);
found += 1;
}
// A held row (a margin drag's selection end) is mounted far from that range: looked up itself.
for (const index of last.held)
if ((index < from || index >= to) && rows[index] && changed.has(rows[index]!.id)) {
kept = Math.min(kept, index);
+ if (index < rowsKept) changedAt.push(index);
found += 1;
}
if (found < changed.size) kept = 0;
@@ -3791,10 +3801,12 @@ export function VirtualTranscript({
// places; the room is moved below only when a page lands or a row above the pin draws.
const placedOrigin =
last && last.sessionId === sessionId && last.width === rowWidth ? (last.layout.origin ?? 0) : 0;
+ // Placed through the gap-aware index (placedHeightIndex.ts): a streamed token or a drawn
+ // height reads only the rows it changed, in O(log rows) each, and copies no row's place.
const placed =
drawn.size === 0
- ? withOrigin(estimated.layout, placedOrigin)
- : placeRows(
+ ? placedFromEstimate(estimated.layout, placedOrigin)
+ : placeRowsIndexed(
estimated.layout,
// A row keeps its last drawn height until its new version is drawn: a streaming reply's
// text appears after the version that holds it, so an estimate of the version ran ahead
@@ -3814,20 +3826,20 @@ export function VirtualTranscript({
return undefined;
return known.height;
},
- last && kept > 0 ? { layout: last.layout, kept } : undefined,
+ last && kept > 0 ? { layout: last.layout, kept, from: rowsKept, changed: changedAt } : undefined,
placedOrigin,
);
const reserved = olderReserve(olderRoom.current, {
sessionId,
rows,
- rowTop: (index) => placed.tops[index]! - placedOrigin,
+ rowTop: (index) => placed.geometry.topAt(index) - placedOrigin,
rowsHeight: placed.totalHeight - placedOrigin,
canLoadOlder,
earlier: earlier?.length,
promptCount,
});
olderRoom.current = reserved.state;
- const layout = withOrigin(placed, reserved.reserve);
+ const layout = placedWithOrigin(placed, reserved.reserve);
lastPlaced.current = {
rows,
drawn,
@@ -4086,7 +4098,7 @@ export function VirtualTranscript({
return;
}
unlift();
- const top = layout.tops[nextIndex] ?? 0;
+ const top = layout.geometry.topAt(nextIndex);
const maximum = Math.max(0, layout.totalHeight - node.clientHeight);
const nextTop = Math.max(0, Math.min(maximum, top));
const physicalTop = physicalScrollTop(node, nextTop);
@@ -4176,8 +4188,8 @@ export function VirtualTranscript({
const tabStopId = activeVisible ? effectiveActiveRowId : rows[range.first]?.id;
useLayoutEffect(() => {
const spans = mountedRanges.map(({ first, last }): MountedSpan => [
- layout.tops[first],
- layout.tops[last - 1] + layout.heights[last - 1],
+ layout.geometry.topAt(first),
+ layout.geometry.topAt(last - 1) + layout.geometry.heightAt(last - 1),
]);
acpmuxPerf.mounted = spans;
// A memo hit spent no time in geometry this render.
@@ -4438,7 +4450,7 @@ export function VirtualTranscript({
endSeek();
setSeeking(false);
} else {
- const top = at < 0 ? latest : Math.min(latest, layout.tops[at] + openAt!.offset);
+ const top = at < 0 ? latest : Math.min(latest, layout.geometry.topAt(at) + openAt!.offset);
clearScrollOrigin(node);
if (at < 0) node.scrollTop = top;
else writeAnchorScrollTop(node, top);
@@ -4565,7 +4577,7 @@ export function VirtualTranscript({
{
count: shown.length,
idAt: (index) => shown[index]?.id,
- tops: foldInputs.current.layout.tops,
+ geometry: foldInputs.current.layout.geometry,
},
foldInputs.current.sessionId,
)
@@ -4579,7 +4591,7 @@ export function VirtualTranscript({
placed: {
count: placed.length,
idAt: (index: number) => placed[index]?.id,
- tops: layout.tops,
+ geometry: layout.geometry,
},
sessionId,
};
@@ -4686,8 +4698,8 @@ export function VirtualTranscript({
revealWindow.current = 0;
const node = ref.current;
if (!node || !findTarget || !targetKey || !findRequest.current?.()) return;
- const top = layout.tops[findIndex];
- const bottom = top + layout.heights[findIndex];
+ const top = layout.geometry.topAt(findIndex);
+ const bottom = top + layout.geometry.heightAt(findIndex);
// In view where it draws (`lift` from its place while the rows follow a grown composer).
const drawn = layoutScrollTop(node) - liftRef.current;
if (top >= drawn && bottom <= drawn + node.clientHeight - dockSpaceRef.current) {
@@ -4723,8 +4735,8 @@ export function VirtualTranscript({
useLayoutEffect(() => {
const node = ref.current;
if (!node || revealIndex < 0 || !rowRequest.current?.()) return;
- const top = layout.tops[revealIndex];
- const bottom = top + layout.heights[revealIndex];
+ const top = layout.geometry.topAt(revealIndex);
+ const bottom = top + layout.geometry.heightAt(revealIndex);
// In view where it draws (`lift` from its place while the rows follow a grown composer).
const drawn = layoutScrollTop(node) - liftRef.current;
if (top >= drawn && bottom <= drawn + node.clientHeight - dockSpaceRef.current) return;
@@ -4849,7 +4861,7 @@ export function VirtualTranscript({
? (seenAnchor.current ?? null)
: scrolledTo.current.atLatest || !row
? null
- : { rowId: row.id, offset: logicalTop - placed.tops[index] };
+ : { rowId: row.id, offset: logicalTop - placed.geometry.topAt(index) };
const key = JSON.stringify(anchor);
if (key === reportedAnchor.current) return;
reportedAnchor.current = key;
@@ -5588,17 +5600,8 @@ export function VirtualTranscript({
lastTurn={row.lastTurn === true}
index={absoluteIndex}
setSize={canLoadOlder ? -1 : rows.length}
- top={layout.tops[absoluteIndex]}
- gapBelow={
- absoluteIndex + 1 < rows.length
- ? Math.max(
- 0,
- layout.tops[absoluteIndex + 1]! -
- layout.tops[absoluteIndex]! -
- layout.heights[absoluteIndex]!,
- )
- : 0
- }
+ top={layout.geometry.topAt(absoluteIndex)}
+ gapBelow={absoluteIndex + 1 < rows.length ? layout.geometry.gapAt(absoluteIndex + 1) : 0}
rowWidth={scaledRowWidth}
expanded={isExpanded}
observer={observer}
diff --git a/webviews/src/agent-session/acpmux/chatMinimap/ChatMinimap.tsx b/webviews/src/agent-session/acpmux/chatMinimap/ChatMinimap.tsx
index e79c896bff2a..c3b9766e0379 100644
--- a/webviews/src/agent-session/acpmux/chatMinimap/ChatMinimap.tsx
+++ b/webviews/src/agent-session/acpmux/chatMinimap/ChatMinimap.tsx
@@ -7,7 +7,7 @@
// a placeholder with a shimmer (chatMinimap.css), and it draws once when the reply ends. The box then
// moves only for the person's own scrolling, never for the transcript following the reply. Every listener, observer and timer goes on unmount.
import React, { memo, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
-import { transcriptColumn, type AcpmuxRow, type ConversationLayout } from "../model";
+import { layoutGeometry, transcriptColumn, type AcpmuxRow, type TranscriptLayout } from "../model";
import { useT } from "../i18n";
import { clearScrollOrigin, layoutScrollTop, physicalScrollTop } from "../conversation/scrollCoordinates";
import { useChatDisplay, useChatMinimap } from "../chatDisplay";
@@ -47,7 +47,7 @@ const MIN_TRACK = 64;
type Props = {
rows: readonly AcpmuxRow[];
- layout: ConversationLayout;
+ layout: TranscriptLayout;
scroller: React.RefObject;
/// The transcript's view height (above the room under the last row).
viewportHeight: number;
@@ -59,7 +59,7 @@ type Props = {
/// Where these rows first differ from the last render's (App.tsx rowsChange), if the transcript knows.
change?: RowsChange;
};
-type Source = { rows: readonly AcpmuxRow[]; layout: ConversationLayout };
+type Source = { rows: readonly AcpmuxRow[]; layout: TranscriptLayout };
/// The placeholder's look: the shimmer, except in the gallery, which compares the others. Only the
/// gallery sets an arm in globalThis.cmuxExperiments; a stored cmux.experiments override is ignored.
@@ -286,9 +286,10 @@ const Minimap = memo(function Minimap({
if (streaming >= 0) {
const { layout } = source.current;
const scale = minimapScale(layout.totalHeight, track);
- const from = layout.tops[streaming] ?? layout.totalHeight;
+ const geometry = layoutGeometry(layout);
+ const from = streaming < geometry.count ? geometry.topAt(streaming) : layout.totalHeight;
const top = Math.min(track - MIN_PLACEHOLDER, (from + THREAD_PADDING_TOP) * scale);
- const height = Math.min(track - top, Math.max(MIN_PLACEHOLDER, (layout.heights[streaming] ?? 0) * scale));
+ const height = Math.min(track - top, Math.max(MIN_PLACEHOLDER, geometry.heightAt(streaming) * scale));
setHold({ top, height });
} else setHold(undefined);
schedule(false);
diff --git a/webviews/src/agent-session/acpmux/chatMinimap/paint.ts b/webviews/src/agent-session/acpmux/chatMinimap/paint.ts
index d196c07949b9..55c1793aa9b8 100644
--- a/webviews/src/agent-session/acpmux/chatMinimap/paint.ts
+++ b/webviews/src/agent-session/acpmux/chatMinimap/paint.ts
@@ -5,7 +5,8 @@
// rest (dates, notices, the working line) draws nothing. Each kind is one path and one fill, and
// rows that land on the same device pixel line merge, so a draw is bounded by the canvas's height,
// not by the number of rows, after one pass over them.
-import type { AcpmuxRow, ConversationLayout } from "../model";
+import type { AcpmuxRow } from "../model";
+import type { GeometrySource, LayoutGeometry } from "../conversationGeometry";
import { THREAD_PADDING_TOP } from "../threadMinimap/model";
/// The minimap's width in CSS px, and the room it keeps from the pane's right edge.
@@ -61,19 +62,42 @@ export type MinimapContext = Pick<
"setTransform" | "clearRect" | "beginPath" | "rect" | "fill" | "fillStyle" | "globalAlpha"
>;
+/// Every row's top and height, for the full passes below: an estimate's own arrays, or one ordered
+/// walk of a placement's index, kept per geometry (a placement is immutable), so a draw and the
+/// change check before it walk it once. The map's passes are O(rows) as before; nothing per token
+/// reads this while a reply streams (the picture stops above it).
+const places = new WeakMap();
+export function rowPlaces(layout: GeometrySource): { tops: ArrayLike; heights: ArrayLike } {
+ if (!("geometry" in layout)) return layout;
+ const geometry = layout.geometry;
+ let kept = places.get(geometry);
+ if (!kept) {
+ const tops = new Float64Array(geometry.count);
+ const heights = new Float64Array(geometry.count);
+ geometry.scan(0, geometry.count, (index, top, height) => {
+ tops[index] = top;
+ heights[index] = height;
+ });
+ kept = { tops, heights };
+ places.set(geometry, kept);
+ }
+ return kept;
+}
+
/// The first row whose place, height, identity or kind on the map differs between two drawn states:
/// a draw whose rows all match the last one's is skipped. A new object for the same row (a token)
/// is no change.
export function firstChangedRow(
rows: readonly AcpmuxRow[],
- layout: ConversationLayout,
- before: { rows: readonly AcpmuxRow[]; layout: ConversationLayout },
+ layout: GeometrySource,
+ before: { rows: readonly AcpmuxRow[]; layout: GeometrySource },
): number {
- const count = Math.min(rows.length, layout.tops.length);
- const kept = Math.min(count, before.rows.length, before.layout.tops.length);
+ const now = rowPlaces(layout);
+ const prior = rowPlaces(before.layout);
+ const count = Math.min(rows.length, now.tops.length);
+ const kept = Math.min(count, before.rows.length, prior.tops.length);
for (let index = 0; index < kept; index += 1) {
- if (layout.tops[index] !== before.layout.tops[index] || layout.heights[index] !== before.layout.heights[index])
- return index;
+ if (now.tops[index] !== prior.tops[index] || now.heights[index] !== prior.heights[index]) return index;
const row = rows[index]!;
const was = before.rows[index]!;
if (row !== was && (row.id !== was.id || minimapKind(row) !== minimapKind(was))) return index;
@@ -104,7 +128,7 @@ const jitter = (seed: number, line: number) => 0.55 + (((seed + line * 17) % 41)
export function paintMinimap(
ctx: MinimapContext,
rows: readonly AcpmuxRow[],
- layout: ConversationLayout,
+ layout: GeometrySource,
{
width,
height,
@@ -122,7 +146,8 @@ export function paintMinimap(
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
if (scale <= 0) return 0;
- const count = Math.min(rows.length, layout.tops.length);
+ const { tops, heights } = rowPlaces(layout);
+ const count = Math.min(rows.length, tops.length);
const kinds = Array.from({ length: count }, (_, index) => minimapKind(rows[index]!));
const pixel = 1 / dpr;
let drawn = 0;
@@ -135,9 +160,9 @@ export function paintMinimap(
let lastLine = -1;
for (let index = 0; index < count; index += 1) {
if (kinds[index] !== kind) continue;
- const rowHeight = layout.heights[index]! * scale;
+ const rowHeight = heights[index]! * scale;
if (rowHeight <= 0) continue;
- const top = (layout.tops[index]! + THREAD_PADDING_TOP) * scale;
+ const top = (tops[index]! + THREAD_PADDING_TOP) * scale;
if (top >= height) break;
const line = Math.floor(top * dpr);
// Under a device pixel: one pixel line, unless this kind already drew on it.
diff --git a/webviews/src/agent-session/acpmux/conversation/followGlide.ts b/webviews/src/agent-session/acpmux/conversation/followGlide.ts
index 814fc86bb480..773c9703fe9c 100644
--- a/webviews/src/agent-session/acpmux/conversation/followGlide.ts
+++ b/webviews/src/agent-session/acpmux/conversation/followGlide.ts
@@ -15,7 +15,8 @@
// to latest, a chat switch and unmount, and `fit` when the room it lands in shrinks below what it
// still holds back (VirtualTranscript).
import { MOTION_EASE_OUT, MOTION_MS } from "../../../motion/scale.generated";
-import type { AcpmuxRow, ConversationLayout } from "../model";
+import type { AcpmuxRow } from "../model";
+import { geometryOf, type GeometrySource } from "../conversationGeometry";
import { cssTimeMs } from "./fold";
import { THINKING } from "./turns";
@@ -27,11 +28,11 @@ export const FOLLOW_GLIDE_OFF = '[data-follow-glide="off"]';
/// waits hidden while the reply streams over it (`quietKey`, Thinking.tsx). 0 without one (Debug
/// `liveLineBelow` or `codexToolRuns` off, a line that names a call, a queue's cards last): there the
/// view steps as before. O(1): the last row only.
-export function followRoom(rows: readonly AcpmuxRow[], layout: ConversationLayout): number {
+export function followRoom(rows: readonly AcpmuxRow[], layout: GeometrySource): number {
const last = rows.length - 1;
const row = rows[last];
if (!row?.below || row.kind !== THINKING || row.quietKey === undefined) return 0;
- return layout.heights[last] ?? 0;
+ return geometryOf(layout).heightAt(last);
}
export type FollowGlide = {
diff --git a/webviews/src/agent-session/acpmux/conversation/scrollAnchor.ts b/webviews/src/agent-session/acpmux/conversation/scrollAnchor.ts
index f9668bd0f0ec..69538cdf4935 100644
--- a/webviews/src/agent-session/acpmux/conversation/scrollAnchor.ts
+++ b/webviews/src/agent-session/acpmux/conversation/scrollAnchor.ts
@@ -1,4 +1,5 @@
-import type { AcpmuxRow, ConversationLayout } from "../model";
+import type { AcpmuxRow } from "../model";
+import { geometryOf, type GeometrySource } from "../conversationGeometry";
/** Keep the follow correction small enough to absorb layout rounding, but not a real measured gap. */
export const FOLLOW_BOTTOM_DEADBAND_PX = 4;
@@ -23,17 +24,10 @@ export function settleFollowBottom(
return true;
}
-// Binary search only geometry; importing the transcript model here would also initialize its
-// markdown/font measurement dependencies. Zero-height bookkeeping rows are skipped as in layout.
-function firstVisible(tops: Float64Array, top: number): number {
- let low = 0;
- let high = tops.length;
- while (low < high) {
- const middle = (low + high) >>> 1;
- if (tops[middle]! <= Math.max(0, top)) low = middle + 1;
- else high = middle;
- }
- return Math.max(0, low - 1);
+// Geometry only: importing the transcript model here would also initialize its markdown/font
+// measurement dependencies. Zero-height bookkeeping rows are skipped as in layout.
+function firstVisible(layout: GeometrySource, top: number): number {
+ return Math.max(0, geometryOf(layout).upperBoundTop(Math.max(0, top)) - 1);
}
// A prepend moves old rows by the count added; a stream keeps their indices. Check both in O(1),
@@ -77,14 +71,16 @@ export function preserveScrollAnchorTop({
liveTop,
anchorTop,
}: {
- oldLayout: ConversationLayout;
- newLayout: ConversationLayout;
+ oldLayout: GeometrySource;
+ newLayout: GeometrySource;
oldRows: readonly Pick[];
newRows: readonly Pick[];
liveTop: number;
anchorTop: number;
}): number | undefined {
- const first = firstVisible(oldLayout.tops, liveTop);
+ const first = firstVisible(oldLayout, liveTop);
+ const oldGeometry = geometryOf(oldLayout);
+ const newGeometry = geometryOf(newLayout);
// A row's own resize must not move its top edge. The first visible row is
// therefore the anchor; rows below it are irrelevant to this correction. A simple prepend or
// stream resolves in constant time. Build an id index only after that fast path misses, which
@@ -99,7 +95,7 @@ export function preserveScrollAnchorTop({
newIndex = movedIndex(newRows, id, oldIndex, newRows.length - oldRows.length, indexById);
}
if (newIndex < 0) continue;
- return anchorTop + newLayout.tops[newIndex]! - oldLayout.tops[oldIndex]!;
+ return anchorTop + newGeometry.topAt(newIndex) - oldGeometry.topAt(oldIndex);
}
return undefined;
}
diff --git a/webviews/src/agent-session/acpmux/conversationGeometry.test.ts b/webviews/src/agent-session/acpmux/conversationGeometry.test.ts
index a5c9662ce26d..e1682dca729a 100644
--- a/webviews/src/agent-session/acpmux/conversationGeometry.test.ts
+++ b/webviews/src/agent-session/acpmux/conversationGeometry.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { applyHeightChanges, geometryFromHeights } from "./conversationGeometry";
-import { placeRowsIndexed } from "./model";
+import { applyHeightChanges, geometryFromArrays, geometryFromHeights } from "./conversationGeometry";
describe("indexed conversation geometry", () => {
test("exposes prefix accessors without a materialized tops array", () => {
@@ -26,17 +25,20 @@ describe("indexed conversation geometry", () => {
expect(after.totalHeight).toBe(5_121);
});
- test("placeRowsIndexed accepts a bounded changed-index batch", () => {
- const estimate = geometryFromHeights([12, 12, 12, 12, 12]);
- const placed = placeRowsIndexed(estimate, [
- { index: 1, height: 20 },
- { index: 3, height: 4 },
- ]);
- expect(placed.totalHeight).toBe(60);
- expect(placed.topAt(2)).toBe(32);
- expect(placed.topAt(4)).toBe(48);
- expect(estimate.totalHeight).toBe(60);
- expect(placed).not.toBe(estimate);
+ test("array geometry answers the same queries as indexed geometry", () => {
+ const heights = [12, 0, 12, 4, 0, 12];
+ const indexed = geometryFromHeights(heights);
+ const tops = Float64Array.from({ length: heights.length }, (_, index) => indexed.topAt(index));
+ const arrays = geometryFromArrays(tops, heights, indexed.totalHeight, 5);
+ for (let index = 0; index <= heights.length; index += 1) {
+ expect(arrays.topAt(index)).toBe(indexed.topAt(index) + 5);
+ expect(arrays.gapAt(index)).toBe(0);
+ }
+ for (const target of [-1, 0, 5, 11, 17, 29, 40, 45])
+ expect(arrays.upperBoundTop(target + 5)).toBe(indexed.upperBoundTop(target));
+ const seen: number[] = [];
+ arrays.scan(1, 4, (index, top) => void seen.push(index, top));
+ expect(seen).toEqual([1, 17, 2, 17, 3, 29]);
});
test("ignores invalid changes without shifting row identity", () => {
diff --git a/webviews/src/agent-session/acpmux/conversationGeometry.ts b/webviews/src/agent-session/acpmux/conversationGeometry.ts
index 39bbe06e69bd..93c98fa58742 100644
--- a/webviews/src/agent-session/acpmux/conversationGeometry.ts
+++ b/webviews/src/agent-session/acpmux/conversationGeometry.ts
@@ -1,21 +1,35 @@
import { PlacedHeightIndex } from "./placedHeightIndex";
/**
- * Read-only transcript geometry. The accessors are backed by an immutable
- * prefix index; callers do not need to materialize tops or heights to answer
- * a range or anchor query.
+ * Read-only transcript geometry. Placed layouts are backed by an immutable
+ * gap-aware index (placedHeightIndex.ts), estimates by their arrays; callers
+ * answer range, anchor and fold queries through these accessors without
+ * materializing tops or heights.
*/
export type LayoutGeometry = {
readonly count: number;
+ /// Where the last row ends, origin included.
readonly totalHeight: number;
+ /// A row's top (origin included); `count` and past give `totalHeight`.
readonly topAt: (index: number) => number;
readonly heightAt: (index: number) => number;
+ /// The gap the row takes above itself: its top less where the row before it ends.
+ readonly gapAt: (index: number) => number;
+ /// The first row whose top is greater than `target`, `count` when none is.
readonly upperBoundTop: (target: number) => number;
+ /// Visits rows `from` to `to` (exclusive) in order; `visit` returning false stops.
+ readonly scan: (
+ from: number,
+ to: number,
+ visit: (index: number, top: number, height: number) => boolean | void,
+ ) => void;
};
/** Geometry together with its persistent source, for bounded point updates. */
export type IndexedLayoutGeometry = LayoutGeometry & {
readonly index: PlacedHeightIndex;
+ /// Room above the first row (ConversationLayout.origin), added to every top.
+ readonly origin: number;
};
export type HeightChange = {
@@ -23,14 +37,17 @@ export type HeightChange = {
readonly height: number;
};
-export function geometryFromIndex(index: PlacedHeightIndex): IndexedLayoutGeometry {
+export function geometryFromIndex(index: PlacedHeightIndex, origin = 0): IndexedLayoutGeometry {
return {
count: index.count,
- totalHeight: index.totalHeight,
- topAt: (at) => index.topAt(at),
+ totalHeight: origin + index.totalHeight,
+ topAt: (at) => origin + index.topAt(at),
heightAt: (at) => index.heightAt(at),
- upperBoundTop: (target) => index.upperBoundTop(target),
+ gapAt: (at) => index.gapAt(at),
+ upperBoundTop: (target) => index.upperBoundTop(target - origin),
+ scan: (from, to, visit) => index.scan(from, to, (at, top, height) => visit(at, origin + top, height)),
index,
+ origin,
};
}
@@ -39,6 +56,54 @@ export function geometryFromHeights(heights: ArrayLike): IndexedLayoutGe
return geometryFromIndex(PlacedHeightIndex.fromHeights(heights));
}
+function upperBound(values: ArrayLike, target: number): number {
+ let low = 0;
+ let high = values.length;
+ while (low < high) {
+ const middle = (low + high) >>> 1;
+ if (values[middle]! <= target) low = middle + 1;
+ else high = middle;
+ }
+ return low;
+}
+
+/**
+ * Geometry over a placed pair of arrays (an estimate, or a layout a test builds), shifted
+ * by `by`. O(1) to make: nothing is copied.
+ */
+export function geometryFromArrays(
+ tops: ArrayLike,
+ heights: ArrayLike,
+ totalHeight: number,
+ by = 0,
+): LayoutGeometry {
+ const count = Math.min(tops.length, heights.length);
+ return {
+ count,
+ totalHeight: totalHeight + by,
+ topAt: (at) => (at >= count ? totalHeight + by : at < 0 ? by : (tops[at] ?? 0) + by),
+ heightAt: (at) => heights[at] ?? 0,
+ gapAt: (at) =>
+ at > 0 && at < count && heights[at]! > 0 ? Math.max(0, tops[at]! - tops[at - 1]! - heights[at - 1]!) : 0,
+ upperBoundTop: (target) => upperBound(tops, target - by),
+ scan: (from, to, visit) => {
+ for (let at = Math.max(0, from); at < Math.min(count, to); at += 1)
+ if (visit(at, tops[at]! + by, heights[at]!) === false) return;
+ },
+ };
+}
+
+/// A layout either kind of placement gives: a placement's geometry, or an estimate's arrays.
+export type GeometrySource =
+ | { readonly geometry: LayoutGeometry }
+ | { readonly tops: ArrayLike; readonly heights: ArrayLike; readonly totalHeight: number };
+
+/// The geometry of either kind of layout: O(1), nothing is copied. (model.ts `layoutGeometry`;
+/// here so geometry readers need not load the transcript model.)
+export function geometryOf(layout: GeometrySource): LayoutGeometry {
+ return "geometry" in layout ? layout.geometry : geometryFromArrays(layout.tops, layout.heights, layout.totalHeight);
+}
+
/**
* Applies only the supplied measured rows. Each change copies one bounded
* leaf and its tree path, while the input geometry remains an anchor-safe
@@ -55,5 +120,5 @@ export function applyHeightChanges(
if (!Number.isFinite(change.height) || change.height < 0) continue;
index = index.update(change.index, change.height);
}
- return index === previous.index ? previous : geometryFromIndex(index);
+ return index === previous.index ? previous : geometryFromIndex(index, previous.origin);
}
diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts
index d38aff3cc2ba..e23f4c3369e8 100644
--- a/webviews/src/agent-session/acpmux/model.ts
+++ b/webviews/src/agent-session/acpmux/model.ts
@@ -2,6 +2,7 @@ import { BUBBLE } from "./transcriptSpacing";
import {
footerAfter,
gridLength,
+ MARK_FOOTER,
MARK_STATUS,
MARK_STEP,
rowMark,
@@ -64,7 +65,14 @@ import type { ChatTool } from "./summary/chatTools";
import type { TaskGoal } from "./goal";
import { MessagePlacement } from "./conversation/messagePlacement";
import { isVisiblyEmptyMessage } from "./conversation/visibleMessage";
-import { applyHeightChanges, type HeightChange, type IndexedLayoutGeometry } from "./conversationGeometry";
+import {
+ geometryFromArrays,
+ geometryFromIndex,
+ geometryOf,
+ type IndexedLayoutGeometry,
+ type LayoutGeometry,
+} from "./conversationGeometry";
+import { PlacedHeightIndex } from "./placedHeightIndex";
export type { HeightChange, IndexedLayoutGeometry, LayoutGeometry } from "./conversationGeometry";
export { applyHeightChanges, geometryFromHeights, geometryFromIndex } from "./conversationGeometry";
@@ -2296,38 +2304,155 @@ export function withOrigin(layout: ConversationLayout, origin: number): Conversa
return { ...rest, tops, totalHeight: layout.totalHeight + by, ...(origin ? { origin } : {}) };
}
+/// A transcript placed by drawn heights: its rows' geometry (an immutable gap-aware index once
+/// rows draw, the estimate's arrays before), and the estimate's marks and tail metrics. It carries
+/// no `tops` or `heights` arrays: readers ask `geometry`, so a drawn height or a streamed token
+/// never copies every row.
+export type PlacedLayout = {
+ geometry: LayoutGeometry;
+ /// The content's height: the rows' end (origin included), or more where App adds room under them.
+ totalHeight: number;
+ gaps?: Float64Array;
+ marks?: Uint8Array;
+ tailGap?: number;
+ scale?: number;
+ restBand?: number;
+ origin?: number;
+};
+
+/// Any placed transcript: an estimate (arrays) or a placement (geometry).
+export type TranscriptLayout = ConversationLayout | PlacedLayout;
+
+/// The geometry of either kind of layout: O(1), nothing is copied.
+export const layoutGeometry = (layout: TranscriptLayout): LayoutGeometry => geometryOf(layout);
+
+/// An estimate, unplaced, as a PlacedLayout starting at `origin` (before any row draws). O(1).
+export function placedFromEstimate(estimate: ConversationLayout, origin = 0): PlacedLayout {
+ const by = origin - (estimate.origin ?? 0);
+ const { tops, heights, ...rest } = estimate;
+ return {
+ ...rest,
+ geometry: geometryFromArrays(tops, heights, estimate.totalHeight, by),
+ totalHeight: estimate.totalHeight + by,
+ origin: origin || undefined,
+ };
+}
+
+/// `layout` with its first row starting at `origin` instead (withOrigin for a placement): O(1).
+export function placedWithOrigin(layout: PlacedLayout, origin: number): PlacedLayout {
+ const by = origin - (layout.origin ?? 0);
+ if (by === 0) return layout;
+ const geometry = layout.geometry;
+ const indexed = "index" in geometry ? (geometry as IndexedLayoutGeometry) : undefined;
+ const moved: LayoutGeometry = indexed
+ ? geometryFromIndex(indexed.index, indexed.origin + by)
+ : {
+ ...geometry,
+ totalHeight: geometry.totalHeight + by,
+ topAt: (at) => geometry.topAt(at) + by,
+ upperBoundTop: (target) => geometry.upperBoundTop(target - by),
+ scan: (from, to, visit) => geometry.scan(from, to, (at, top, height) => visit(at, top + by, height)),
+ };
+ return { ...layout, geometry: moved, totalHeight: layout.totalHeight + by, origin: origin || undefined };
+}
+
+/// What a placement may skip: rows from `kept` up to `from` are the last placement's except
+/// `changed` (indices in that span whose height may differ). Rows from `from` on are placed again.
+export type PlacedReuse = {
+ layout: PlacedLayout;
+ kept: number;
+ from?: number;
+ changed?: readonly number[];
+};
+
/**
- * Places measured rows through the persistent geometry path. This additive
- * API is used by the indexed consumer port; the array layout above remains for
- * legacy callers until that port is complete.
+ * `placeRows` through the gap-aware index (placedHeightIndex.ts): the same tops, heights, total,
+ * tail gap and rest band for every input, up to float rounding of the sums. A reused placement
+ * keeps its index; only `changed` rows and the rows from `from` on are read, each in O(log rows),
+ * so a streamed token or a drawn height costs O(log rows + rows placed again), not O(rows).
+ * With no reusable `previous` (another scale or origin, no index) every row is read once.
*/
export function placeRowsIndexed(
- estimate: IndexedLayoutGeometry,
- changes: readonly HeightChange[],
-): IndexedLayoutGeometry {
- return applyHeightChanges(estimate, changes);
-}
-
-function upperBound(values: Float64Array, target: number): number {
- let low = 0;
- let high = values.length;
- while (low < high) {
- const middle = (low + high) >>> 1;
- if (values[middle] <= target) low = middle + 1;
- else high = middle;
+ estimate: ConversationLayout,
+ heightAt: (index: number) => number | undefined,
+ previous?: PlacedReuse,
+ /// Where the first row starts (ConversationLayout.origin). Kept rows keep their places only at it.
+ origin = 0,
+): PlacedLayout {
+ const count = estimate.heights.length;
+ const scale = gridScale();
+ const marks = estimate.marks;
+ const resolve = (index: number) => {
+ // A drawn 0 is a row that draws nothing (App.tsx drawnHeightOf): it takes no room.
+ const known = heightAt(index);
+ return known !== undefined && known >= 0 ? known : estimate.heights[index]!;
+ };
+ const prior = previous?.layout.geometry;
+ const reusable =
+ previous &&
+ prior &&
+ "index" in prior &&
+ (previous.layout.scale ?? 1) === scale &&
+ (previous.layout.origin ?? 0) === origin &&
+ Boolean(previous.layout.marks) === Boolean(marks)
+ ? (prior as IndexedLayoutGeometry).index
+ : undefined;
+ const kept = reusable ? Math.max(0, Math.min(previous!.kept, reusable.count, count)) : 0;
+ let index: PlacedHeightIndex;
+ if (!reusable || kept === 0) {
+ const heights = new Float64Array(count);
+ for (let at = 0; at < count; at += 1) heights[at] = resolve(at);
+ index = PlacedHeightIndex.fromRows(heights, marks, marks ? undefined : estimate.gaps, {
+ between: marks ? (before, next) => transcriptGap(before, next) * scale : undefined,
+ });
+ } else {
+ index = reusable;
+ const from = Math.max(kept, Math.min(previous!.from ?? kept, reusable.count, count));
+ for (const at of previous!.changed ?? [])
+ if (at >= kept && at < from)
+ index = index.update(at, resolve(at), marks?.[at], marks ? undefined : estimate.gaps?.[at]);
+ const tail = new Float64Array(count - from);
+ for (let at = from; at < count; at += 1) tail[at - from] = resolve(at);
+ index = index.splice(
+ from,
+ tail,
+ marks?.subarray(from, count),
+ marks ? undefined : estimate.gaps?.subarray(from, count),
+ );
}
- return low;
+ const geometry = geometryFromIndex(index, origin);
+ // A turn footer that draws nothing after the last drawn row (transcriptGrid.ts footerAfter):
+ // only the trailing rows that draw nothing are read.
+ let footer = false;
+ if (marks)
+ for (let at = count - 1; at >= 0 && index.heightAt(at) === 0; at -= 1)
+ if (index.markAt(at) === MARK_FOOTER) {
+ footer = true;
+ break;
+ }
+ const { last, beforeLast } = index.lastMarks;
+ return {
+ geometry,
+ totalHeight: geometry.totalHeight,
+ gaps: estimate.gaps,
+ marks,
+ tailGap: marks ? tailGap(last, beforeLast, footer) * scale : estimate.tailGap,
+ restBand: marks ? restBand(footer) * scale : estimate.restBand,
+ scale,
+ ...(origin ? { origin } : {}),
+ };
}
export function visibleLayoutRange(
- layoutModel: ConversationLayout,
+ layoutModel: TranscriptLayout,
scrollTop: number,
viewportHeight: number,
overscan = 4,
) {
- if (layoutModel.tops.length === 0) return { first: 0, last: 0 };
- const first = Math.max(0, upperBound(layoutModel.tops, Math.max(0, scrollTop)) - 1 - overscan);
- const last = Math.min(layoutModel.tops.length, upperBound(layoutModel.tops, scrollTop + viewportHeight) + overscan);
+ const geometry = layoutGeometry(layoutModel);
+ if (geometry.count === 0) return { first: 0, last: 0 };
+ const first = Math.max(0, geometry.upperBoundTop(Math.max(0, scrollTop)) - 1 - overscan);
+ const last = Math.min(geometry.count, geometry.upperBoundTop(scrollTop + viewportHeight) + overscan);
return { first, last };
}
@@ -2350,7 +2475,7 @@ export function mergeRowRanges(ranges: RowRange[]): RowRange[] {
/// frames reach, with a margin for uneven frames: the rows between them and the capped lead are
/// never on screen, so they are not built (cx-j33s4).
export function scrollLeadRanges(
- layoutModel: ConversationLayout,
+ layoutModel: TranscriptLayout,
scrollTop: number,
delta: number,
viewportHeight: number,
diff --git a/webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts b/webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts
new file mode 100644
index 000000000000..b309b4e6a8da
--- /dev/null
+++ b/webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts
@@ -0,0 +1,282 @@
+import { describe, expect, test } from "bun:test";
+import {
+ layoutGeometry,
+ placedFromEstimate,
+ placedWithOrigin,
+ placeRows,
+ placeRowsIndexed,
+ visibleLayoutRange,
+ withOrigin,
+ type ConversationLayout,
+ type PlacedLayout,
+} from "./model";
+import {
+ MARK_FOOTER,
+ MARK_FOOTER_ROW,
+ MARK_FOOTER_SLOT,
+ MARK_INDICATOR,
+ MARK_MESSAGE,
+ MARK_PROMPT,
+ MARK_STATUS,
+ MARK_STEP,
+} from "./transcriptGrid";
+
+// Equivalence of the gap-aware index with base's sequential `placeRows`: every top, height, the
+// total, the tail gap and the rest band, for random rows with zero-height runs, every mark, own
+// gaps, an origin, and the incremental passes App makes (an insert, a resize mid-list, a removal).
+
+const MARKS = [
+ 0,
+ MARK_PROMPT,
+ MARK_MESSAGE,
+ MARK_INDICATOR,
+ MARK_FOOTER,
+ MARK_STEP,
+ MARK_FOOTER_ROW,
+ MARK_FOOTER_SLOT,
+ MARK_STATUS,
+];
+/// Float rounding of a tree's sums against a left-to-right pass: far under a device pixel.
+const EPSILON = 1e-6;
+
+function random(seed: number) {
+ let state = seed >>> 0 || 1;
+ return () => {
+ state ^= state << 13;
+ state ^= state >>> 17;
+ state ^= state << 5;
+ return (state >>> 0) / 4294967296;
+ };
+}
+
+type Rows = { heights: number[]; marks: number[]; gaps: number[] };
+
+function randomRows(next: () => number, count: number): Rows {
+ const rows: Rows = { heights: [], marks: [], gaps: [] };
+ for (let index = 0; index < count; index += 1) pushRow(rows, next);
+ return rows;
+}
+
+function pushRow(rows: Rows, next: () => number, at = rows.heights.length) {
+ const roll = next();
+ // A third draw nothing, some in long runs; the rest are fractional, as drawn text is.
+ const height = roll < 0.33 ? 0 : roll < 0.4 ? 22.75 : Math.round(next() * 4000) / 16;
+ rows.heights.splice(at, 0, height);
+ rows.marks.splice(at, 0, MARKS[Math.floor(next() * MARKS.length)]!);
+ rows.gaps.splice(at, 0, Math.floor(next() * 4) * 8);
+}
+
+function estimateOf(rows: Rows, withMarks = true): ConversationLayout {
+ const count = rows.heights.length;
+ return {
+ tops: new Float64Array(count),
+ heights: Float64Array.from(rows.heights),
+ totalHeight: 0,
+ gaps: Float64Array.from(rows.gaps),
+ ...(withMarks ? { marks: Uint8Array.from(rows.marks) } : { tailGap: 7, restBand: 3 }),
+ scale: 1,
+ };
+}
+
+function expectSame(indexed: PlacedLayout, base: ConversationLayout) {
+ const geometry = indexed.geometry;
+ expect(geometry.count).toBe(base.heights.length);
+ for (let index = 0; index < base.heights.length; index += 1) {
+ expect(geometry.heightAt(index)).toBe(base.heights[index]!);
+ expect(Math.abs(geometry.topAt(index) - base.tops[index]!)).toBeLessThan(EPSILON);
+ }
+ geometry.scan(0, geometry.count, (index, top, height) => {
+ expect(Math.abs(top - base.tops[index]!)).toBeLessThan(EPSILON);
+ expect(height).toBe(base.heights[index]!);
+ });
+ expect(Math.abs(indexed.totalHeight - base.totalHeight)).toBeLessThan(EPSILON);
+ expect(indexed.tailGap).toBe(base.tailGap);
+ expect(indexed.restBand).toBe(base.restBand);
+ expect(indexed.origin ?? 0).toBe(base.origin ?? 0);
+ // The gap a row draws above itself (App's `gapBelow` of the row before it).
+ for (let index = 1; index < base.heights.length; index += 1) {
+ const gap = Math.max(0, base.tops[index]! - base.tops[index - 1]! - base.heights[index - 1]!);
+ expect(Math.abs(geometry.gapAt(index) - gap)).toBeLessThan(EPSILON);
+ }
+ // Range queries read the same rows.
+ for (let probe = -10; probe < base.totalHeight + 40; probe += Math.max(7, base.totalHeight / 97)) {
+ expect(visibleLayoutRange(indexed, probe, 300)).toEqual(visibleLayoutRange(base, probe, 300));
+ }
+}
+
+describe("placeRowsIndexed matches placeRows", () => {
+ test("random rows: zero-height runs, every mark, own gaps and origins", () => {
+ for (let seed = 1; seed <= 40; seed += 1) {
+ const next = random(seed);
+ const rows = randomRows(next, 1 + Math.floor(next() * 300));
+ const drawn = rows.heights.map((height) => (next() < 0.5 ? undefined : next() < 0.2 ? 0 : height + 3.5));
+ const heightAt = (index: number) => drawn[index];
+ for (const withMarks of [true, false])
+ for (const origin of [0, 1234.5]) {
+ const estimate = estimateOf(rows, withMarks);
+ expectSame(
+ placeRowsIndexed(estimate, heightAt, undefined, origin),
+ placeRows(estimate, heightAt, undefined, origin),
+ );
+ }
+ }
+ });
+
+ test("the gap rules: a drawn row's gap comes from the nearest drawn row above it", () => {
+ // A prompt, hidden rows, a message: the turn gap, not a gap from the hidden rows' marks.
+ const rows: Rows = {
+ heights: [40, 0, 0, 80, 0, 32, 22, 22, 0],
+ marks: [
+ MARK_PROMPT,
+ MARK_STEP,
+ MARK_FOOTER,
+ MARK_MESSAGE,
+ MARK_STEP,
+ MARK_FOOTER_ROW,
+ MARK_STEP,
+ MARK_STEP,
+ MARK_FOOTER,
+ ],
+ gaps: [0, 0, 0, 0, 0, 0, 0, 0, 0],
+ };
+ const estimate = estimateOf(rows);
+ const indexed = placeRowsIndexed(estimate, () => undefined);
+ expectSame(
+ indexed,
+ placeRows(estimate, () => undefined),
+ );
+ // A trailing empty footer: the tail takes the block gap and the rest band.
+ expect(indexed.restBand).toBeGreaterThan(0);
+ // A row that starts drawing changes the gap of the next drawn row.
+ const grown = (index: number) => (index === 4 ? 22 : undefined);
+ expectSame(placeRowsIndexed(estimate, grown), placeRows(estimate, grown));
+ });
+
+ test("the tail: no drawn row, one drawn row, a footer after it", () => {
+ for (const rows of [
+ { heights: [0, 0], marks: [MARK_FOOTER, MARK_FOOTER], gaps: [0, 0] },
+ { heights: [30], marks: [MARK_INDICATOR], gaps: [0] },
+ { heights: [30, 10, 0], marks: [MARK_PROMPT, MARK_INDICATOR, MARK_FOOTER], gaps: [0, 0, 0] },
+ { heights: [], marks: [], gaps: [] },
+ ]) {
+ const estimate = estimateOf(rows);
+ expectSame(
+ placeRowsIndexed(estimate, () => undefined),
+ placeRows(estimate, () => undefined),
+ );
+ }
+ });
+
+ test("incremental passes: appends, an insert, resizes mid-list and a removal", () => {
+ for (let seed = 100; seed < 112; seed += 1) {
+ const next = random(seed);
+ const rows = randomRows(next, 200 + Math.floor(next() * 200));
+ let drawn = rows.heights.map((height) => height);
+ const heightAt = (index: number) => drawn[index];
+ let estimate = estimateOf(rows);
+ let base = placeRows(estimate, heightAt, undefined, 64);
+ let indexed = placeRowsIndexed(estimate, heightAt, undefined, 64);
+ expectSame(indexed, base);
+ for (let pass = 0; pass < 30; pass += 1) {
+ const kind = pass % 4;
+ let kept: number;
+ let from: number | undefined;
+ let changed: number[] | undefined;
+ if (kind === 0) {
+ // A token: the last row grows, or a row arrives.
+ if (next() < 0.5) pushRow(rows, next);
+ kept = rows.heights.length - 1;
+ drawn = [...drawn.slice(0, kept), ...rows.heights.slice(kept)];
+ } else if (kind === 1) {
+ // An insert mid-list (a page in, a row placed between two).
+ const at = Math.floor(next() * rows.heights.length);
+ pushRow(rows, next, at);
+ drawn.splice(at, 0, rows.heights[at]!);
+ kept = at;
+ } else if (kind === 2) {
+ // Rows mid-list draw at new heights (one starts or stops drawing).
+ changed = [];
+ for (let count = 0; count < 3; count += 1) {
+ const at = Math.floor(next() * rows.heights.length);
+ drawn[at] = next() < 0.3 ? 0 : Math.round(next() * 3000) / 16;
+ changed.push(at);
+ }
+ kept = Math.min(...changed);
+ from = rows.heights.length;
+ } else {
+ // A removal mid-list.
+ const at = Math.floor(next() * rows.heights.length);
+ for (const list of [rows.heights, rows.marks, rows.gaps, drawn]) list.splice(at, 1);
+ kept = at;
+ }
+ estimate = estimateOf(rows);
+ base = placeRows(estimate, heightAt, { layout: base, kept }, 64);
+ indexed = placeRowsIndexed(estimate, heightAt, { layout: indexed, kept, from, changed }, 64);
+ expectSame(indexed, base);
+ }
+ }
+ });
+
+ test("another origin places every row again, as placeRows does", () => {
+ const next = random(7);
+ const rows = randomRows(next, 150);
+ const estimate = estimateOf(rows);
+ const heightAt = () => undefined;
+ const first = placeRowsIndexed(estimate, heightAt, undefined, 10);
+ const base = placeRows(estimate, heightAt, { layout: placeRows(estimate, heightAt, undefined, 10), kept: 100 }, 50);
+ expectSame(placeRowsIndexed(estimate, heightAt, { layout: first, kept: 100 }, 50), base);
+ });
+
+ test("an origin moves every top in O(1), as withOrigin does", () => {
+ const next = random(9);
+ const rows = randomRows(next, 180);
+ const estimate = estimateOf(rows);
+ const base = placeRows(estimate, () => undefined, undefined, 20);
+ const indexed = placeRowsIndexed(estimate, () => undefined, undefined, 20);
+ expectSame(placedWithOrigin(indexed, 500), withOrigin(base, 500));
+ expectSame(placedWithOrigin(indexed, 0), withOrigin(base, 0));
+ // An unplaced estimate (no row drawn yet) reads the same as the estimate's arrays.
+ expectSame(placedWithOrigin(placedFromEstimate(base, 20), 77), withOrigin(base, 77));
+ });
+
+ test("a resize reads only the changed rows: two reads at 9,000 rows and at 90,000", () => {
+ for (const count of [9_000, 90_000]) {
+ const next = random(count);
+ const rows = randomRows(next, count);
+ const estimate = estimateOf(rows);
+ const placed = placeRowsIndexed(estimate, () => undefined);
+ let reads = 0;
+ const heightAt = (index: number) => {
+ reads += 1;
+ return index === 10 || index === count - 20 ? 99 : undefined;
+ };
+ const after = placeRowsIndexed(estimate, heightAt, {
+ layout: placed,
+ kept: 10,
+ from: count,
+ changed: [10, count - 20],
+ });
+ expect(reads).toBe(2);
+ expect(after.geometry.heightAt(10)).toBe(99);
+ expect(placed.geometry.heightAt(10)).toBe(rows.heights[10]!);
+ expectSame(
+ after,
+ placeRows(estimate, (index) => (index === 10 || index === count - 20 ? 99 : undefined)),
+ );
+ }
+ });
+
+ test("a streamed token reads one row and keeps the old snapshot", () => {
+ const next = random(3);
+ const rows = randomRows(next, 5_000);
+ const estimate = estimateOf(rows);
+ const placed = placeRowsIndexed(estimate, () => undefined);
+ const before = layoutGeometry(placed).topAt(4_999);
+ rows.heights[4_999] = 400;
+ let reads = 0;
+ const grown = placeRowsIndexed(estimateOf(rows), () => ((reads += 1), undefined), { layout: placed, kept: 4_999 });
+ expect(reads).toBe(1);
+ expect(grown.geometry.heightAt(4_999)).toBe(400);
+ expect(layoutGeometry(placed).topAt(4_999)).toBe(before);
+ });
+});
diff --git a/webviews/src/agent-session/acpmux/placedHeightIndex.test.ts b/webviews/src/agent-session/acpmux/placedHeightIndex.test.ts
index 9568ebd7f8c6..4fb1281797b6 100644
--- a/webviews/src/agent-session/acpmux/placedHeightIndex.test.ts
+++ b/webviews/src/agent-session/acpmux/placedHeightIndex.test.ts
@@ -1,5 +1,21 @@
import { describe, expect, test } from "bun:test";
-import { PlacedHeightIndex } from "./placedHeightIndex";
+import { PlacedHeightIndex, type GapBetween } from "./placedHeightIndex";
+
+/// placeRows' left-to-right walk: a drawn row's gap comes from the nearest drawn row above it.
+function sequential(heights: readonly number[], marks: readonly number[], between: GapBetween) {
+ const tops: number[] = [];
+ let top = 0;
+ let last = -1;
+ for (let index = 0; index < heights.length; index += 1) {
+ if (heights[index]! > 0) {
+ if (last >= 0) top += between(marks[last]!, marks[index]!);
+ last = index;
+ }
+ tops.push(top);
+ top += heights[index]!;
+ }
+ return { tops, total: top };
+}
function bruteTops(heights: readonly number[]): number[] {
const tops: number[] = [];
@@ -104,4 +120,87 @@ describe("PlacedHeightIndex", () => {
// assertion depend on a particular machine's absolute clock speed.
expect(largeMs).toBeLessThan(Math.max(50, smallMs * 8));
});
+
+ test("gap-aware: tops, gaps and upper bounds match the sequential walk across leaves", () => {
+ const between: GapBetween = (before, next) => (before === next ? 0 : 8 + before + 2 * next);
+ for (const count of [1, 63, 64, 65, 200, 700]) {
+ const heights = Array.from({ length: count }, (_, index) => ((index * 37) % 7 < 3 ? 0 : ((index * 13) % 40) + 1));
+ const marks = Array.from({ length: count }, (_, index) => (index * 5) % 9);
+ const index = PlacedHeightIndex.fromRows(heights, marks, undefined, { between });
+ const { tops, total } = sequential(heights, marks, between);
+ expect(index.totalHeight).toBe(total);
+ expect([...index.materializeTops()]).toEqual(tops);
+ for (let at = 0; at < count; at += 1) {
+ expect(index.topAt(at)).toBe(tops[at]!);
+ expect(index.markAt(at)).toBe(marks[at]!);
+ expect(index.gapAt(at)).toBe(at > 0 ? tops[at]! - tops[at - 1]! - heights[at - 1]! : 0);
+ }
+ for (let target = -1; target <= total + 1; target += 3) {
+ let expected = 0;
+ while (expected < count && tops[expected]! <= target) expected += 1;
+ expect(index.upperBoundTop(target)).toBe(expected);
+ }
+ }
+ });
+
+ test("gap-aware: a row that starts or stops drawing moves its neighbours' gaps", () => {
+ const between: GapBetween = (before, next) => (before === 1 ? 24 : next === 2 ? 16 : 4);
+ const heights = [10, 0, 0, 30, 0, 20];
+ const marks = [1, 2, 3, 2, 3, 2];
+ let index = PlacedHeightIndex.fromRows(heights, marks, undefined, { between });
+ const check = () => {
+ const { tops, total } = sequential(heights, marks, between);
+ expect([...index.materializeTops()]).toEqual(tops);
+ expect(index.totalHeight).toBe(total);
+ };
+ check();
+ for (const [at, height] of [
+ [1, 5],
+ [3, 0],
+ [0, 0],
+ [5, 0],
+ [0, 7],
+ ] as const) {
+ const before = index;
+ index = index.update(at, height);
+ heights[at] = height;
+ check();
+ expect(before).not.toBe(index);
+ }
+ });
+
+ test("gap-aware: splice keeps the prefix and replaces the tail, growing or shrinking", () => {
+ const between: GapBetween = (before, next) => before * 3 + next;
+ const heights = Array.from({ length: 300 }, (_, index) => (index % 4 === 0 ? 0 : index % 11));
+ const marks = Array.from({ length: 300 }, (_, index) => index % 5);
+ const full = PlacedHeightIndex.fromRows(heights, marks, undefined, { between });
+ for (const [from, length] of [
+ [0, 10],
+ [64, 0],
+ [100, 400],
+ [299, 1],
+ [300, 70],
+ [130, 3],
+ ] as const) {
+ const tail = Array.from({ length }, (_, offset) => (offset % 3 === 0 ? 0 : 5 + (offset % 7)));
+ const tailMarks = Array.from({ length }, (_, offset) => (offset * 7) % 5);
+ const spliced = full.splice(from, tail, tailMarks);
+ const nextHeights = [...heights.slice(0, from), ...tail];
+ const nextMarks = [...marks.slice(0, from), ...tailMarks];
+ const { tops, total } = sequential(nextHeights, nextMarks, between);
+ expect(spliced.count).toBe(nextHeights.length);
+ expect([...spliced.materializeTops()]).toEqual(tops);
+ expect(spliced.totalHeight).toBe(total);
+ // The source snapshot is untouched.
+ expect(full.count).toBe(300);
+ expect(full.topAt(299)).toBe(sequential(heights, marks, between).tops[299]!);
+ }
+ });
+
+ test("gap-aware: own gaps apply between drawn rows only", () => {
+ const index = PlacedHeightIndex.fromRows([0, 10, 0, 10, 10], undefined, [9, 9, 9, 3, 5]);
+ expect([...index.materializeTops()]).toEqual([0, 0, 10, 13, 28]);
+ expect(index.totalHeight).toBe(38);
+ expect(index.lastMarks).toEqual({ last: 0, beforeLast: 0 });
+ });
});
diff --git a/webviews/src/agent-session/acpmux/placedHeightIndex.ts b/webviews/src/agent-session/acpmux/placedHeightIndex.ts
index 7f2b931a5671..6610e46edd27 100644
--- a/webviews/src/agent-session/acpmux/placedHeightIndex.ts
+++ b/webviews/src/agent-session/acpmux/placedHeightIndex.ts
@@ -1,261 +1,472 @@
/**
- * An immutable prefix-sum index for transcript row heights.
+ * An immutable, gap-aware placement index for transcript rows.
*
- * Rows are grouped into small leaves so a drawn-height correction copies one
- * leaf and the logarithmic path to that leaf. The previous index is never
- * mutated, which lets the anchor code compare two layouts while a new one is
- * being prepared.
+ * Each row has a height, a gap mark (transcriptGrid.ts `rowMark`) and, for a
+ * layout placed without marks, a fixed gap of its own. A row's top is where
+ * the rows above it end plus its own gap, and its gap depends on the nearest
+ * row above it that draws (height > 0): rows that draw nothing take no gap and
+ * are skipped when the next drawn row picks one. That is model.ts `placeRows`
+ * exactly, folded into a tree:
+ *
+ * - rows sit in leaves of BLOCK_SIZE, each summarised as (rows, sum, drawn
+ * count, first and last two drawn marks, the first drawn row's own gap, the
+ * last row's height);
+ * - two summaries join with the gap between the left's last drawn mark and the
+ * right's first, so a row that starts or stops drawing changes its
+ * neighbours' gaps without touching their leaves.
+ *
+ * A height correction copies one leaf and the logarithmic path to it; the
+ * previous index is never mutated, which lets the anchor code compare two
+ * layouts while a new one is prepared. Sums are joined in tree order, not
+ * left to right, so a top can differ from the sequential pass by float
+ * rounding only (far under a device pixel).
*/
const BLOCK_SIZE = 64;
+/// The gap between two drawn rows, by their marks (transcriptGrid.ts `transcriptGap` at a scale).
+export type GapBetween = (previousMark: number, nextMark: number) => number;
+
+/// How rows gap: by their marks (`between`), else by each row's own `gaps` entry, else not at all.
+export type GapRules = { between?: GapBetween };
+
// Layout geometry is monotone and finite. Invalid measurements are treated as
// a zero-height row, preserving their position in the transcript.
function normalizeHeight(value: number): number {
return Number.isFinite(value) && value >= 0 ? value : 0;
}
-type Leaf = {
- readonly values: Float64Array;
+type Summary = {
+ readonly rows: number;
+ /// Where the segment's rows end, from its start, with the gaps between its own drawn rows.
readonly sum: number;
+ /// Drawn rows (height > 0), capped at 2: the tail metrics read the last two.
+ readonly drawn: number;
+ readonly firstMark: number;
+ /// The first drawn row's own gap (layouts placed without marks).
+ readonly firstGap: number;
+ readonly lastMark: number;
+ readonly beforeLastMark: number;
+ /// The segment's last row's height: its top is `sum - lastHeight` (0 for a row that draws nothing).
+ readonly lastHeight: number;
+};
+
+const EMPTY: Summary = {
+ rows: 0,
+ sum: 0,
+ drawn: 0,
+ firstMark: 0,
+ firstGap: 0,
+ lastMark: 0,
+ beforeLastMark: 0,
+ lastHeight: 0,
+};
+
+function join(a: Summary, b: Summary, between: GapBetween | undefined): Summary {
+ if (b.rows === 0) return a;
+ if (a.rows === 0) return b;
+ const gap = a.drawn > 0 && b.drawn > 0 ? (between ? between(a.lastMark, b.firstMark) : b.firstGap) : 0;
+ return {
+ rows: a.rows + b.rows,
+ sum: a.sum + gap + b.sum,
+ drawn: Math.min(2, a.drawn + b.drawn),
+ firstMark: a.drawn > 0 ? a.firstMark : b.firstMark,
+ firstGap: a.drawn > 0 ? a.firstGap : b.firstGap,
+ lastMark: b.drawn > 0 ? b.lastMark : a.lastMark,
+ beforeLastMark: b.drawn > 1 ? b.beforeLastMark : b.drawn === 1 ? (a.drawn > 0 ? a.lastMark : 0) : a.beforeLastMark,
+ lastHeight: b.lastHeight,
+ };
+}
+
+type Leaf = {
+ readonly heights: Float64Array;
+ readonly marks?: Uint8Array;
+ readonly gaps?: Float64Array;
+ readonly summary: Summary;
};
type Node = {
- readonly sum: number;
+ readonly summary: Summary;
readonly left?: Node;
readonly right?: Node;
readonly leaf?: Leaf;
};
-function nodeSum(node: Node | undefined): number {
- return node?.sum ?? 0;
-}
+const summaryOf = (node: Node | undefined): Summary => node?.summary ?? EMPTY;
-function makeLeaf(values: ArrayLike, start: number, count: number): Leaf {
- const copied = new Float64Array(count);
+/// A leaf's summary, placed left to right as `placeRows` does.
+function summarize(
+ heights: Float64Array,
+ marks: Uint8Array | undefined,
+ gaps: Float64Array | undefined,
+ between: GapBetween | undefined,
+): Summary {
let sum = 0;
- for (let index = 0; index < count; index += 1) {
- const value = normalizeHeight(values[start + index] ?? 0);
- copied[index] = value;
- sum += value;
+ let drawn = 0;
+ let firstMark = 0;
+ let firstGap = 0;
+ let lastMark = 0;
+ let beforeLastMark = 0;
+ for (let offset = 0; offset < heights.length; offset += 1) {
+ const height = heights[offset]!;
+ if (height > 0) {
+ const mark = marks?.[offset] ?? 0;
+ if (drawn === 0) {
+ firstMark = mark;
+ firstGap = gaps?.[offset] ?? 0;
+ } else sum += between ? between(lastMark, mark) : (gaps?.[offset] ?? 0);
+ beforeLastMark = drawn > 0 ? lastMark : 0;
+ lastMark = mark;
+ drawn = Math.min(2, drawn + 1);
+ }
+ sum += height;
}
- return { values: copied, sum };
+ return {
+ rows: heights.length,
+ sum,
+ drawn,
+ firstMark,
+ firstGap,
+ lastMark,
+ beforeLastMark,
+ lastHeight: heights.length > 0 ? heights[heights.length - 1]! : 0,
+ };
}
-function makeTree(leaves: readonly Leaf[], low: number, high: number): Node | undefined {
- if (low >= high) return undefined;
- if (high - low === 1) {
- const leaf = leaves[low];
- return leaf ? { leaf, sum: leaf.sum } : undefined;
+function readLeaf(node: Node | undefined, low: number, high: number, leafIndex: number): Leaf | undefined {
+ while (node && high - low > 1) {
+ const middle = low + ((high - low) >>> 1);
+ if (leafIndex < middle) {
+ node = node.left;
+ high = middle;
+ } else {
+ node = node.right;
+ low = middle;
+ }
}
- const middle = low + ((high - low) >>> 1);
- const left = makeTree(leaves, low, middle);
- const right = makeTree(leaves, middle, high);
- return { left, right, sum: nodeSum(left) + nodeSum(right) };
+ return node?.leaf;
}
-function updateLeaf(
- node: Node | undefined,
- low: number,
- high: number,
+/// What the rows before leaf `leafIndex` sum to.
+function prefixBeforeLeaf(
+ root: Node | undefined,
+ capacity: number,
leafIndex: number,
- offset: number,
- value: number,
-): Node | undefined {
- if (low >= high) return node;
- if (high - low === 1) {
- const previous = node?.leaf;
- if (!previous) return node;
- if (previous.values[offset] === value) return node;
- const values = Float64Array.from(previous.values);
- values[offset] = value;
- // Re-sum the copied bounded leaf instead of subtracting floating-point
- // values from the old aggregate. Repeated 0.1/0.2 corrections must settle
- // exactly when all entries become zero.
- let sum = 0;
- for (const entry of values) sum += entry;
- const leaf = { values, sum };
- return { leaf, sum: leaf.sum };
- }
- const middle = low + ((high - low) >>> 1);
- const left = leafIndex < middle ? updateLeaf(node?.left, low, middle, leafIndex, offset, value) : node?.left;
- const right = leafIndex >= middle ? updateLeaf(node?.right, middle, high, leafIndex, offset, value) : node?.right;
- return { left, right, sum: nodeSum(left) + nodeSum(right) };
-}
-
-function readLeaf(node: Node | undefined, low: number, high: number, leafIndex: number): Leaf | undefined {
- if (!node || low >= high) return undefined;
- if (high - low === 1) return node.leaf;
- const middle = low + ((high - low) >>> 1);
- return leafIndex < middle
- ? readLeaf(node.left, low, middle, leafIndex)
- : readLeaf(node.right, middle, high, leafIndex);
-}
-
-function prefixBeforeLeaf(node: Node | undefined, low: number, high: number, leafIndex: number): number {
- if (!node || low >= high || leafIndex <= low) return 0;
- if (high - low === 1) return node.sum;
- const middle = low + ((high - low) >>> 1);
- if (leafIndex <= middle) return prefixBeforeLeaf(node.left, low, middle, leafIndex);
- return nodeSum(node.left) + prefixBeforeLeaf(node.right, middle, high, leafIndex);
-}
-
-function upperBoundNode(
- node: Node | undefined,
- low: number,
- high: number,
- count: number,
- target: number,
- before: number,
-): number {
- if (!node || low >= high) return -1;
- if (high - low === 1) {
- const leaf = node.leaf;
- if (!leaf) return -1;
- const start = low * BLOCK_SIZE;
- const limit = Math.min(BLOCK_SIZE, count - start);
- let top = before;
- for (let offset = 0; offset < limit; offset += 1) {
- if (top > target) return start + offset;
- top += leaf.values[offset] ?? 0;
+ between: GapBetween | undefined,
+): Summary {
+ let prefix = EMPTY;
+ let node = root;
+ let low = 0;
+ let high = capacity;
+ while (node && high - low > 1) {
+ const middle = low + ((high - low) >>> 1);
+ if (leafIndex < middle) {
+ node = node.left;
+ high = middle;
+ } else {
+ prefix = join(prefix, summaryOf(node.left), between);
+ node = node.right;
+ low = middle;
}
- return -1;
}
- const middle = low + ((high - low) >>> 1);
- const left = node.left;
- const leftSum = nodeSum(left);
- if (before + leftSum > target) {
- const result = upperBoundNode(left, low, middle, count, target, before);
- if (result >= 0) return result;
- }
- return upperBoundNode(node.right, middle, high, count, target, before + leftSum);
+ return prefix;
}
+/// One row placed after the rows summarised by (top, drawn, lastMark): the walk `placeRows` takes.
+type Cursor = { top: number; drawn: boolean; lastMark: number };
+
/**
- * Immutable row-height prefix sums. `update` and `append` share untouched
- * tree nodes with their input; they never retain a reference to an entire
- * previous snapshot or mutate a shared leaf.
+ * Immutable gap-aware row placement. `update`, `splice` and `append` share
+ * untouched tree nodes with their input; they never retain a reference to an
+ * entire previous snapshot or mutate a shared leaf.
*/
export class PlacedHeightIndex {
readonly count: number;
+ /// Where the last row ends (gaps included).
readonly totalHeight: number;
private constructor(
count: number,
private readonly capacity: number,
private readonly root: Node | undefined,
+ private readonly rules: GapRules,
) {
this.count = count;
- this.totalHeight = nodeSum(root);
+ this.totalHeight = summaryOf(root).sum;
}
- static empty(): PlacedHeightIndex {
- return new PlacedHeightIndex(0, 1, undefined);
+ static empty(rules: GapRules = {}): PlacedHeightIndex {
+ return new PlacedHeightIndex(0, 1, undefined, rules);
}
+ /// Plain prefix sums: no row takes a gap.
static fromHeights(values: ArrayLike): PlacedHeightIndex {
- const count = values.length;
- const blockCount = Math.max(1, Math.ceil(count / BLOCK_SIZE));
- let capacity = 1;
- while (capacity < blockCount) capacity <<= 1;
- const leaves: Leaf[] = [];
- for (let start = 0; start < count; start += BLOCK_SIZE)
- leaves.push(makeLeaf(values, start, Math.min(BLOCK_SIZE, count - start)));
- return new PlacedHeightIndex(count, capacity, makeTree(leaves, 0, capacity));
+ return PlacedHeightIndex.fromRows(values);
+ }
+
+ /// Rows with their marks (gapped by `rules.between`) or their own gaps.
+ static fromRows(
+ heights: ArrayLike,
+ marks?: ArrayLike,
+ gaps?: ArrayLike,
+ rules: GapRules = {},
+ ): PlacedHeightIndex {
+ return PlacedHeightIndex.empty(rules).splice(0, heights, marks, gaps);
+ }
+
+ /// The gap rules this index places by.
+ get gapRules(): GapRules {
+ return this.rules;
+ }
+
+ /// Marks of the last two drawn rows (0 where there is none), for transcriptGrid.ts `tailGap`.
+ get lastMarks(): { last: number; beforeLast: number } {
+ const summary = summaryOf(this.root);
+ return { last: summary.drawn > 0 ? summary.lastMark : 0, beforeLast: summary.beforeLastMark };
+ }
+
+ private leafAt(index: number): Leaf | undefined {
+ return readLeaf(this.root, 0, this.capacity, Math.floor(index / BLOCK_SIZE));
}
heightAt(index: number): number {
if (!Number.isInteger(index) || index < 0 || index >= this.count) return 0;
- const leaf = readLeaf(this.root, 0, this.capacity, Math.floor(index / BLOCK_SIZE));
- return leaf?.values[index % BLOCK_SIZE] ?? 0;
+ return this.leafAt(index)?.heights[index % BLOCK_SIZE] ?? 0;
+ }
+
+ markAt(index: number): number {
+ if (!Number.isInteger(index) || index < 0 || index >= this.count) return 0;
+ return this.leafAt(index)?.marks?.[index % BLOCK_SIZE] ?? 0;
+ }
+
+ private gapOf(cursor: Cursor, leaf: Leaf, offset: number): number {
+ if (!cursor.drawn) return 0;
+ const between = this.rules.between;
+ return between ? between(cursor.lastMark, leaf.marks?.[offset] ?? 0) : (leaf.gaps?.[offset] ?? 0);
}
+ /// Steps `cursor` over row `offset` of `leaf`, returning that row's top.
+ private step(cursor: Cursor, leaf: Leaf, offset: number): number {
+ const height = leaf.heights[offset]!;
+ if (height > 0) {
+ cursor.top += this.gapOf(cursor, leaf, offset);
+ cursor.drawn = true;
+ cursor.lastMark = leaf.marks?.[offset] ?? 0;
+ }
+ const top = cursor.top;
+ cursor.top += height;
+ return top;
+ }
+
+ private cursorAtLeaf(leafIndex: number): Cursor {
+ const prefix = prefixBeforeLeaf(this.root, this.capacity, leafIndex, this.rules.between);
+ return { top: prefix.sum, drawn: prefix.drawn > 0, lastMark: prefix.lastMark };
+ }
+
+ /// The row's top: where the rows above it end, plus its own gap when it draws.
topAt(index: number): number {
if (!Number.isInteger(index) || index <= 0) return 0;
if (index >= this.count) return this.totalHeight;
const leafIndex = Math.floor(index / BLOCK_SIZE);
const leaf = readLeaf(this.root, 0, this.capacity, leafIndex);
- let sum = prefixBeforeLeaf(this.root, 0, this.capacity, leafIndex);
- for (let offset = 0; offset < index % BLOCK_SIZE; offset += 1) sum += leaf?.values[offset] ?? 0;
- return sum;
+ const cursor = this.cursorAtLeaf(leafIndex);
+ if (!leaf) return cursor.top;
+ const offset = index % BLOCK_SIZE;
+ for (let at = 0; at < offset; at += 1) this.step(cursor, leaf, at);
+ return this.step(cursor, leaf, offset);
}
- /** Returns a new index with one row's height replaced. */
- update(index: number, value: number): PlacedHeightIndex {
- if (!Number.isInteger(index) || index < 0 || index >= this.count || !Number.isFinite(value) || value < 0)
- return this;
- const previous = this.heightAt(index);
- if (previous === value) return this;
+ /// The gap the row takes above itself (0 for a row that draws nothing or the first drawn row).
+ gapAt(index: number): number {
+ if (!Number.isInteger(index) || index < 0 || index >= this.count) return 0;
const leafIndex = Math.floor(index / BLOCK_SIZE);
- const root = updateLeaf(this.root, 0, this.capacity, leafIndex, index % BLOCK_SIZE, value);
- return new PlacedHeightIndex(this.count, this.capacity, root);
+ const leaf = readLeaf(this.root, 0, this.capacity, leafIndex);
+ if (!leaf) return 0;
+ const offset = index % BLOCK_SIZE;
+ if (!(leaf.heights[offset]! > 0)) return 0;
+ const cursor = this.cursorAtLeaf(leafIndex);
+ for (let at = 0; at < offset; at += 1) this.step(cursor, leaf, at);
+ return this.gapOf(cursor, leaf, offset);
}
- /** Returns a new index with the supplied rows appended. */
- append(values: ArrayLike): PlacedHeightIndex {
- if (values.length === 0) return this;
- let next: PlacedHeightIndex | undefined;
- for (let index = 0; index < values.length; index += 1) {
- const base = next ?? this;
- const rowIndex = base.count;
- const value = normalizeHeight(values[index] ?? 0);
- next = base.appendOne(rowIndex, value);
+ /**
+ * Visits rows `from` to `to` (exclusive) in order with their tops and heights, in
+ * O(log rows + visited). Stops early when `visit` returns false.
+ */
+ scan(from: number, to: number, visit: (index: number, top: number, height: number) => boolean | void): void {
+ const start = Math.max(0, Math.floor(from));
+ const end = Math.min(this.count, Math.ceil(to));
+ if (start >= end) return;
+ let leafIndex = Math.floor(start / BLOCK_SIZE);
+ const cursor = this.cursorAtLeaf(leafIndex);
+ let index = leafIndex * BLOCK_SIZE;
+ while (index < end) {
+ const leaf = readLeaf(this.root, 0, this.capacity, leafIndex);
+ if (!leaf) return;
+ for (let offset = 0; offset < leaf.heights.length && index < end; offset += 1, index += 1) {
+ const top = this.step(cursor, leaf, offset);
+ if (index >= start && visit(index, top, leaf.heights[offset]!) === false) return;
+ }
+ leafIndex += 1;
}
- return next ?? this;
}
- private appendOne(index: number, value: number): PlacedHeightIndex {
+ /** Returns a new index with one row's height (and mark or own gap, when given) replaced. */
+ update(index: number, value: number, mark?: number, gap?: number): PlacedHeightIndex {
+ if (!Number.isInteger(index) || index < 0 || index >= this.count || !Number.isFinite(value) || value < 0)
+ return this;
+ const offset = index % BLOCK_SIZE;
const leafIndex = Math.floor(index / BLOCK_SIZE);
- let capacity = this.capacity;
- let root = this.root;
- if (leafIndex >= capacity) {
- while (leafIndex >= capacity) {
- root = { left: root, sum: nodeSum(root) };
- capacity <<= 1;
- }
+ const previous = readLeaf(this.root, 0, this.capacity, leafIndex);
+ if (!previous) return this;
+ const sameMark = mark === undefined || (previous.marks?.[offset] ?? 0) === mark;
+ const sameGap = gap === undefined || (previous.gaps?.[offset] ?? 0) === gap;
+ if (previous.heights[offset] === value && sameMark && sameGap) return this;
+ const heights = Float64Array.from(previous.heights);
+ heights[offset] = value;
+ let marks = previous.marks;
+ if (!sameMark) {
+ marks = marks ? Uint8Array.from(marks) : new Uint8Array(heights.length);
+ marks[offset] = mark!;
}
- const offset = index % BLOCK_SIZE;
- const previous = readLeaf(root, 0, capacity, leafIndex);
- const valueCount = Math.min(BLOCK_SIZE, index + 1 - leafIndex * BLOCK_SIZE);
- const values = new Float64Array(Math.max(previous?.values.length ?? 0, valueCount));
- if (previous) values.set(previous.values);
- const old = values[offset] ?? 0;
- values[offset] = value;
- const leaf = { values, sum: previous ? previous.sum + value - old : value };
- // A freshly appended row can only land in the rightmost leaf. Rebuilding
- // that path keeps every untouched block shared with the old snapshot.
- const replace = (node: Node | undefined, low: number, high: number): Node | undefined => {
- if (high - low === 1) return { leaf, sum: leaf.sum };
+ let gaps = previous.gaps;
+ if (!sameGap) {
+ gaps = gaps ? Float64Array.from(gaps) : new Float64Array(heights.length);
+ gaps[offset] = gap!;
+ }
+ // Re-sum the copied bounded leaf instead of subtracting floating-point
+ // values from the old aggregate: corrections that settle must settle exactly.
+ const leaf: Leaf = { heights, marks, gaps, summary: summarize(heights, marks, gaps, this.rules.between) };
+ const between = this.rules.between;
+ const replace = (node: Node | undefined, low: number, high: number): Node => {
+ if (high - low === 1) return { leaf, summary: leaf.summary };
const middle = low + ((high - low) >>> 1);
const left = leafIndex < middle ? replace(node?.left, low, middle) : node?.left;
const right = leafIndex >= middle ? replace(node?.right, middle, high) : node?.right;
- return { left, right, sum: nodeSum(left) + nodeSum(right) };
+ return { left, right, summary: join(summaryOf(left), summaryOf(right), between) };
+ };
+ return new PlacedHeightIndex(this.count, this.capacity, replace(this.root, 0, this.capacity), this.rules);
+ }
+
+ /**
+ * Returns a new index whose rows from `from` on are `heights` (with `marks` and `gaps`):
+ * a truncate, an append, or a new tail. Rows before `from` keep their leaves; the cost is
+ * O(log rows + BLOCK_SIZE + new rows).
+ */
+ splice(
+ from: number,
+ heights: ArrayLike,
+ marks?: ArrayLike,
+ gaps?: ArrayLike,
+ ): PlacedHeightIndex {
+ const start = Math.max(0, Math.min(this.count, Math.floor(from)));
+ const count = start + heights.length;
+ const leafCount = Math.max(1, Math.ceil(count / BLOCK_SIZE));
+ let capacity = this.capacity;
+ let root = this.root;
+ while (capacity < leafCount) {
+ root = root ? { left: root, summary: root.summary } : undefined;
+ capacity <<= 1;
+ }
+ const between = this.rules.between;
+ const fromLeaf = Math.floor(start / BLOCK_SIZE);
+ const withMarks = marks !== undefined || between !== undefined;
+ const build = (node: Node | undefined, low: number, high: number): Node | undefined => {
+ if (high <= fromLeaf) return node;
+ if (low * BLOCK_SIZE >= count) return undefined;
+ if (high - low === 1) {
+ const first = low * BLOCK_SIZE;
+ const length = Math.min(BLOCK_SIZE, count - first);
+ const old = node?.leaf;
+ const leafHeights = new Float64Array(length);
+ const leafMarks = withMarks ? new Uint8Array(length) : undefined;
+ const leafGaps = gaps || old?.gaps ? new Float64Array(length) : undefined;
+ for (let offset = 0; offset < length; offset += 1) {
+ const row = first + offset;
+ if (row < start) {
+ leafHeights[offset] = old?.heights[offset] ?? 0;
+ if (leafMarks) leafMarks[offset] = old?.marks?.[offset] ?? 0;
+ if (leafGaps) leafGaps[offset] = old?.gaps?.[offset] ?? 0;
+ } else {
+ leafHeights[offset] = normalizeHeight(heights[row - start] ?? 0);
+ if (leafMarks) leafMarks[offset] = marks?.[row - start] ?? 0;
+ if (leafGaps) leafGaps[offset] = gaps?.[row - start] ?? 0;
+ }
+ }
+ const leaf: Leaf = {
+ heights: leafHeights,
+ marks: leafMarks,
+ gaps: leafGaps,
+ summary: summarize(leafHeights, leafMarks, leafGaps, between),
+ };
+ return { leaf, summary: leaf.summary };
+ }
+ const middle = low + ((high - low) >>> 1);
+ const left = build(node?.left, low, middle);
+ const right = build(node?.right, middle, high);
+ if (!left && !right) return undefined;
+ return { left, right, summary: join(summaryOf(left), summaryOf(right), between) };
};
- root = replace(root, 0, capacity);
- return new PlacedHeightIndex(index + 1, capacity, root);
+ return new PlacedHeightIndex(count, capacity, build(root, 0, capacity), this.rules);
+ }
+
+ /** Returns a new index with the supplied rows appended. */
+ append(values: ArrayLike, marks?: ArrayLike, gaps?: ArrayLike): PlacedHeightIndex {
+ if (values.length === 0) return this;
+ return this.splice(this.count, values, marks, gaps);
}
/** Finds the first row whose top is greater than `target` (upper-bound semantics). */
upperBoundTop(target: number): number {
- const result = upperBoundNode(this.root, 0, this.capacity, this.count, target, 0);
- return result >= 0 ? result : this.count;
+ const between = this.rules.between;
+ let prefix = EMPTY;
+ let node = this.root;
+ let low = 0;
+ let high = this.capacity;
+ // The descent picks the leftmost subtree whose last row's top is past `target`; a top
+ // that rounding puts on the other side is caught by the leaf scans below.
+ while (node && high - low > 1) {
+ const middle = low + ((high - low) >>> 1);
+ const left = summaryOf(node.left);
+ const through = join(prefix, left, between);
+ if (left.rows > 0 && through.sum - left.lastHeight > target) {
+ node = node.left;
+ high = middle;
+ } else {
+ prefix = through;
+ node = node.right;
+ low = middle;
+ }
+ }
+ let leafIndex = Math.max(0, low - 1);
+ // Scan from the leaf before the descent's, in case rounding put the answer there.
+ const cursor = this.cursorAtLeaf(leafIndex);
+ let index = leafIndex * BLOCK_SIZE;
+ while (index < this.count) {
+ const leaf = readLeaf(this.root, 0, this.capacity, leafIndex);
+ if (!leaf) break;
+ for (let offset = 0; offset < leaf.heights.length; offset += 1, index += 1)
+ if (this.step(cursor, leaf, offset) > target) return index;
+ leafIndex += 1;
+ }
+ return this.count;
}
/** Explicitly materializes a compatibility array for consumers that need one. */
materializeHeights(): Float64Array {
const values = new Float64Array(this.count);
- for (let index = 0; index < this.count; index += 1) values[index] = this.heightAt(index);
+ this.scan(0, this.count, (index, _top, height) => {
+ values[index] = height;
+ });
return values;
}
/** Explicitly materializes row tops for legacy consumers. */
materializeTops(): Float64Array {
const values = new Float64Array(this.count);
- let top = 0;
- for (let index = 0; index < this.count; index += 1) {
+ this.scan(0, this.count, (index, top) => {
values[index] = top;
- top += this.heightAt(index);
- }
+ });
return values;
}
}
diff --git a/webviews/src/agent-session/acpmux/threadMinimap/ScrollMap.tsx b/webviews/src/agent-session/acpmux/threadMinimap/ScrollMap.tsx
index b7556e168c3c..5ba0a3c9463a 100644
--- a/webviews/src/agent-session/acpmux/threadMinimap/ScrollMap.tsx
+++ b/webviews/src/agent-session/acpmux/threadMinimap/ScrollMap.tsx
@@ -6,7 +6,7 @@
// mark jumps to it (a prompt as the compact rail's click does); a click on the track jumps there;
// dragging anywhere scrubs the transcript. Geometry is the transcript's layout (mapGeometry.ts).
import React, { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
-import { type AcpmuxRow, type ConversationLayout } from "../model";
+import { layoutGeometry, type AcpmuxRow, type TranscriptLayout } from "../model";
import { useT } from "../i18n";
import { layoutScrollTop, physicalScrollTop } from "../conversation/scrollCoordinates";
import { MinimapTick } from "../../../ui/MinimapTick";
@@ -66,7 +66,7 @@ export function ScrollMap({
}: {
rows: readonly AcpmuxRow[];
turns: readonly MinimapTurn[];
- layout: ConversationLayout;
+ layout: TranscriptLayout;
scroller: React.RefObject;
viewportHeight: number;
scrolls: boolean;
@@ -175,7 +175,7 @@ export function ScrollMap({
physicalScrollTop(
node,
Math.min(
- Math.max(0, THREAD_PADDING_TOP + (layout.tops[mark.row] ?? 0) - LANDING_OFFSET),
+ Math.max(0, THREAD_PADDING_TOP + rowTop(layoutGeometry(layout), mark.row) - LANDING_OFFSET),
Math.max(0, layout.totalHeight - node.clientHeight),
),
);
@@ -449,3 +449,7 @@ const Mark = memo(function Mark({
);
});
+
+/// A row's layout top, 0 past the placed rows (as an unplaced row's `tops[index] ?? 0`).
+const rowTop = (geometry: ReturnType, index: number) =>
+ index >= 0 && index < geometry.count ? geometry.topAt(index) : 0;
diff --git a/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx b/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx
index 030d878addb7..c576c61e329f 100644
--- a/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx
+++ b/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx
@@ -9,7 +9,7 @@
// the numbers are in model.ts.
import React, { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
-import { type AcpmuxRow, type ConversationLayout } from "../model";
+import { layoutGeometry, type AcpmuxRow, type TranscriptLayout } from "../model";
import { useT } from "../i18n";
import { layoutScrollTop, physicalScrollTop } from "../conversation/scrollCoordinates";
import { MinimapTick } from "../../../ui/MinimapTick";
@@ -52,6 +52,7 @@ import {
railScrollTop,
railTop as railTopAt,
ROW_PITCH,
+ rowTopsOf,
SETTLE_FRAMES,
SHIFT_MS,
STEP_CURVE,
@@ -148,7 +149,7 @@ export function ThreadMinimap({
promptCount?: number;
loadThrough?: (seq: number) => Promise;
sessionId?: string;
- layout: ConversationLayout;
+ layout: TranscriptLayout;
scroller: React.RefObject;
scrollTop: number;
viewportHeight: number;
@@ -309,14 +310,16 @@ export function ThreadMinimap({
/// did, and the transcript restores the reader's place only after that commit, with a scroll. Until
/// that scroll arrives, the band reads the offset as it will be.
const restoring = useRef<{ from: number; by: number } | undefined>(undefined);
+ /// Turn tops read from the transcript's geometry, a few turns at a time (nothing copies every row).
+ const tops = useMemo(() => rowTopsOf(layoutGeometry(layout)), [layout]);
const added = prependedBy(anchor.current, ticks);
const anchored = added === undefined ? undefined : turns[anchor.current!.index + added];
const seen = domTop.current && domTop.current.sessionId === sessionId ? domTop.current.top : undefined;
if (anchored && seen !== undefined)
- restoring.current = { from: seen, by: (layout.tops[anchored.index] ?? 0) - anchor.current!.layoutTop };
+ restoring.current = { from: seen, by: (tops(anchored.index) ?? 0) - anchor.current!.layoutTop };
const shift = restoring.current && restoring.current.from === seen ? restoring.current.by : 0;
const top = (seen ?? scrollTop) + shift;
- const onScreen = visibleTurns(turns, layout.tops, layout.totalHeight, top, viewportHeight, loadedStart);
+ const onScreen = visibleTurns(turns, tops, layout.totalHeight, top, viewportHeight, loadedStart);
const boxHeight = railBox?.height ?? viewportHeight;
const boxFoot = railBox?.foot ?? foot;
/// The window the rail's 92% cap reads. Without one (no layout), the rail's own box stands in, not
@@ -373,7 +376,7 @@ export function ThreadMinimap({
const live = useRef({
turns,
ticks,
- tops: layout.tops,
+ tops,
total: layout.totalHeight,
marks,
viewportHeight,
@@ -387,7 +390,7 @@ export function ThreadMinimap({
live.current = {
turns,
ticks,
- tops: layout.tops,
+ tops,
total: layout.totalHeight,
marks,
viewportHeight,
@@ -702,7 +705,7 @@ export function ThreadMinimap({
tick: { key: ticks[onScreen.first]!.key, id: ticks[onScreen.first]!.id },
first: { key: ticks[0]!.key, id: ticks[0]!.id },
index: onScreen.first,
- layoutTop: layout.tops[first.index] ?? 0,
+ layoutTop: tops(first.index) ?? 0,
y: railTop + onScreen.first * ROW_PITCH - scrolled,
};
});
diff --git a/webviews/src/agent-session/acpmux/threadMinimap/mapGeometry.ts b/webviews/src/agent-session/acpmux/threadMinimap/mapGeometry.ts
index 03df9fdc2395..6dbc1bae019c 100644
--- a/webviews/src/agent-session/acpmux/threadMinimap/mapGeometry.ts
+++ b/webviews/src/agent-session/acpmux/threadMinimap/mapGeometry.ts
@@ -1,7 +1,8 @@
// The full-height thread minimap's model (ScrollMap.tsx): the rail spans the transcript's viewport
// and maps the whole scroll height onto it, so every message draws where it is in the chat. Pure
// functions over the transcript's layout, so nothing here measures a row.
-import type { AcpmuxRow, ConversationLayout } from "../model";
+import type { AcpmuxRow } from "../model";
+import { geometryOf, type GeometrySource } from "../conversationGeometry";
import { THREAD_PADDING_TOP, type MinimapTurn } from "./model";
/// Space kept above and below the rail inside the transcript's viewport.
@@ -91,24 +92,30 @@ export function mapThumb(
export function mapMarks(
rows: readonly AcpmuxRow[],
turns: readonly MinimapTurn[],
- layout: ConversationLayout,
+ layout: GeometrySource & { readonly totalHeight: number },
height: number,
): MapMark[] {
const marks: MapMark[] = [];
let turn = -1;
let lastAgent = -Infinity;
- for (let index = 0; index < rows.length; index += 1) {
+ const total = layout.totalHeight;
+ // One ordered walk of the rows' places (O(rows), as the loop over the rows always was).
+ const visit = (index: number, top: number) => {
const row = rows[index]!;
if (row.kind === "user") {
while (turn + 1 < turns.length && turns[turn + 1]!.index <= index) turn += 1;
- marks.push({ kind: "user", row: index, turn, y: mapY(layout.tops[index] ?? 0, layout.totalHeight, height) });
+ marks.push({ kind: "user", row: index, turn, y: mapY(top, total, height) });
} else if (row.kind === "assistant" && row.text) {
- const y = mapY(layout.tops[index] ?? 0, layout.totalHeight, height);
- if (y - lastAgent < MERGE_PX) continue;
+ const y = mapY(top, total, height);
+ if (y - lastAgent < MERGE_PX) return;
lastAgent = y;
marks.push({ kind: "agent", row: index, turn, y });
}
- }
+ };
+ const geometry = geometryOf(layout);
+ geometry.scan(0, rows.length, visit);
+ // Rows past the layout's (a layout a render behind) sit at the top, as an unplaced row did.
+ for (let index = geometry.count; index < rows.length; index += 1) visit(index, 0);
return marks;
}
diff --git a/webviews/src/agent-session/acpmux/threadMinimap/model.ts b/webviews/src/agent-session/acpmux/threadMinimap/model.ts
index 7898ba415aa3..224c5f0f72be 100644
--- a/webviews/src/agent-session/acpmux/threadMinimap/model.ts
+++ b/webviews/src/agent-session/acpmux/threadMinimap/model.ts
@@ -620,9 +620,19 @@ function ticksOf(turns: readonly MinimapTurn[], previous: readonly MinimapTick[]
}
/// The turn the reader is in: the last prompt above a third of the way down the viewport.
+/// A row's layout top by index, undefined past the rows (an estimate's `tops[index]`, or a
+/// placement's index read for just the rows asked: the rail reads a few turns, never every row).
+export type RowTops = (index: number) => number | undefined;
+
+/// RowTops over a placed layout's geometry.
+export const rowTopsOf =
+ (geometry: { count: number; topAt: (index: number) => number }): RowTops =>
+ (index) =>
+ index >= 0 && index < geometry.count ? geometry.topAt(index) : undefined;
+
export function currentTurn(
turns: readonly MinimapTurn[],
- tops: Float64Array,
+ tops: RowTops,
scrollTop: number,
viewportHeight: number,
firstLoaded = 0,
@@ -634,7 +644,7 @@ export function currentTurn(
low = firstLoaded;
while (low < high) {
const middle = (low + high) >>> 1;
- if ((tops[turns[middle].index] ?? 0) <= anchor) low = middle + 1;
+ if ((tops(turns[middle].index) ?? 0) <= anchor) low = middle + 1;
else high = middle;
}
return Math.max(firstLoaded, low - 1);
@@ -649,7 +659,7 @@ export function currentTurn(
/// counts once its bubble is on screen: our turns have no block above the bubble to show first.
export function visibleTurns(
turns: readonly MinimapTurn[],
- tops: Float64Array,
+ tops: RowTops,
totalHeight: number,
scrollTop: number,
viewportHeight: number,
@@ -659,14 +669,14 @@ export function visibleTurns(
const bottom = scrollTop + viewportHeight - THREAD_PADDING_TOP;
const first = currentTurn(turns, tops, scrollTop + LANDING_OFFSET, 0, firstLoaded);
let last = first;
- while (last + 1 < turns.length && (tops[turns[last + 1].index] ?? totalHeight) < bottom) last += 1;
+ while (last + 1 < turns.length && (tops(turns[last + 1].index) ?? totalHeight) < bottom) last += 1;
return { first, last };
}
/// The scroll offset that lands a turn's prompt row (its images, else its bubble) LANDING_OFFSET
/// below the viewport's top.
-export function turnScrollTop(turn: MinimapTurn, tops: Float64Array): number {
- return Math.max(0, THREAD_PADDING_TOP + (tops[turn.index] ?? 0) - LANDING_OFFSET);
+export function turnScrollTop(turn: MinimapTurn, tops: RowTops): number {
+ return Math.max(0, THREAD_PADDING_TOP + (tops(turn.index) ?? 0) - LANDING_OFFSET);
}
/// The start of a reply as blocks with bold/code spans; fenced code, heading marks and link targets drop.