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.