Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 73 additions & 44 deletions webviews/bench/height-index.bench.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,89 @@
/// <reference types="bun" />
// 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<string, unknown> = { 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));
67 changes: 35 additions & 32 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ import {
layoutConversation,
onlyQuietThoughts,
type ConversationLayout,
type PlacedLayout,
type RowHeights,
paneHeader,
placeRows,
withOrigin,
layoutGeometry,
placedFromEstimate,
placedWithOrigin,
placeRowsIndexed,
transcriptRowWidth,
mergeRowRanges,
scrollLeadRanges,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3315,7 +3318,7 @@ export function VirtualTranscript({
| {
rows: readonly AcpmuxRow[];
drawn: Map<string, DrawnHeight>;
layout: ConversationLayout;
layout: PlacedLayout;
width: number;
sessionId?: string;
/// A new registry estimates every row again, so nothing is kept.
Expand Down Expand Up @@ -3571,7 +3574,7 @@ export function VirtualTranscript({
measure(false);
return () => observer.disconnect();
}, [noteQueue]);
const previousLayout = useRef<ReturnType<typeof layoutConversation> | null>(null);
const previousLayout = useRef<PlacedLayout | null>(null);
const previousRows = useRef<readonly AcpmuxRow[]>(rows);
const previousViewport = useRef({ height, width, dockSpace });
/// The room kept under the last row (`room` below) as of the last placement pass.
Expand Down Expand Up @@ -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 &&
Expand All @@ -3769,19 +3774,24 @@ 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);
const to = Math.min(rows.length, last.last + DRAWN_SEARCH_ROWS);
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;
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
)
Expand All @@ -4579,7 +4591,7 @@ export function VirtualTranscript({
placed: {
count: placed.length,
idAt: (index: number) => placed[index]?.id,
tops: layout.tops,
geometry: layout.geometry,
},
sessionId,
};
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
11 changes: 6 additions & 5 deletions webviews/src/agent-session/acpmux/chatMinimap/ChatMinimap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -47,7 +47,7 @@ const MIN_TRACK = 64;

type Props = {
rows: readonly AcpmuxRow[];
layout: ConversationLayout;
layout: TranscriptLayout;
scroller: React.RefObject<HTMLElement | null>;
/// The transcript's view height (above the room under the last row).
viewportHeight: number;
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading