From 04601d449600b633dff40a547213a87664ff54a1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 00:15:51 +0000 Subject: [PATCH 1/3] perf(chat): place transcript rows through a gap-aware index The prefix index now folds base's gap rules into its leaves: each segment keeps its first and last two drawn marks, so a drawn row's gap comes from the nearest drawn row above it across leaves, and the tail gap and rest band read from the root. placeRowsIndexed gives placeRows' output from it, reusing the last placement's index and reading only the changed rows and the new tail. App.tsx and the transcript's geometry readers (anchors, folds, follow, both minimaps) read tops through it instead of tops/heights arrays. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-627034dbf10a04318c5c --- webviews/bench/height-index.bench.ts | 117 ++-- webviews/src/agent-session/acpmux/App.tsx | 67 ++- .../acpmux/chatMinimap/ChatMinimap.tsx | 11 +- .../agent-session/acpmux/chatMinimap/paint.ts | 47 +- .../acpmux/conversation/followGlide.ts | 7 +- .../acpmux/conversation/scrollAnchor.ts | 28 +- .../acpmux/conversationGeometry.test.ts | 27 +- .../acpmux/conversationGeometry.ts | 77 ++- webviews/src/agent-session/acpmux/model.ts | 170 +++++- .../acpmux/placeRowsIndexed.test.ts | 250 ++++++++ .../acpmux/placedHeightIndex.test.ts | 101 +++- .../agent-session/acpmux/placedHeightIndex.ts | 547 ++++++++++++------ .../acpmux/threadMinimap/ScrollMap.tsx | 10 +- .../acpmux/threadMinimap/ThreadMinimap.tsx | 17 +- .../acpmux/threadMinimap/mapGeometry.ts | 21 +- .../acpmux/threadMinimap/model.ts | 22 +- 16 files changed, 1178 insertions(+), 341 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts 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 96b773aa6a68..43901c288ee0 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, @@ -2618,7 +2621,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 @@ -3311,7 +3314,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. @@ -3567,7 +3570,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. @@ -3744,6 +3747,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 && @@ -3765,6 +3770,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); @@ -3772,12 +3780,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; @@ -3787,10 +3797,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 @@ -3810,20 +3822,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, @@ -4082,7 +4094,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); @@ -4172,8 +4184,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. @@ -4434,7 +4446,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); @@ -4561,7 +4573,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, ) @@ -4575,7 +4587,7 @@ export function VirtualTranscript({ placed: { count: placed.length, idAt: (index: number) => placed[index]?.id, - tops: layout.tops, + geometry: layout.geometry, }, sessionId, }; @@ -4682,8 +4694,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) { @@ -4719,8 +4731,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; @@ -4845,7 +4857,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; @@ -5584,17 +5596,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..b95457c6ad9a 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 was = rowPlaces(before.layout); + const count = Math.min(rows.length, now.tops.length); + const kept = Math.min(count, before.rows.length, was.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] !== was.tops[index] || now.heights[index] !== was.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..655be4bd02ee 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,19 @@ 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..cf67b92daf99 100644 --- a/webviews/src/agent-session/acpmux/conversationGeometry.ts +++ b/webviews/src/agent-session/acpmux/conversationGeometry.ts @@ -1,21 +1,31 @@ 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 +33,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 +52,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 +116,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 ede16ea178a1..e7d542f657b1 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"; @@ -2290,38 +2298,154 @@ 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 }; } @@ -2344,7 +2468,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..591432774f8e --- /dev/null +++ b/webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts @@ -0,0 +1,250 @@ +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..e9f434f82c4e 100644 --- a/webviews/src/agent-session/acpmux/placedHeightIndex.ts +++ b/webviews/src/agent-session/acpmux/placedHeightIndex.ts @@ -1,261 +1,484 @@ /** - * 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; - } - return { values: copied, sum }; -} - -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; + 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; } - 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 { + rows: heights.length, + sum, + drawn, + firstMark, + firstGap, + lastMark, + beforeLastMark, + lastHeight: heights.length > 0 ? heights[heights.length - 1]! : 0, + }; } -function updateLeaf( - node: Node | undefined, - low: number, - high: number, - leafIndex: number, - offset: number, - value: number, -): Node | undefined { - if (low >= high) return node; +function makeTree(leaves: readonly Leaf[], low: number, high: number, between: GapBetween | undefined): Node | undefined { + if (low >= high || low >= leaves.length) return undefined; 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 leaf = leaves[low]!; + return { leaf, summary: leaf.summary }; } 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) }; + const left = makeTree(leaves, low, middle, between); + const right = makeTree(leaves, middle, high, between); + return { left, right, summary: join(summaryOf(left), summaryOf(right), between) }; } 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); + 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; + } + } + return node?.leaf; } -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; +/// What the rows before leaf `leafIndex` sum to. +function prefixBeforeLeaf( + root: Node | undefined, + capacity: number, + leafIndex: number, + 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) }; }; - root = replace(root, 0, capacity); - return new PlacedHeightIndex(index + 1, capacity, root); + 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) }; + }; + 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. From 0743cdcbe4b97c259e201d01fffbf6dcf43dfbfa Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 00:23:20 +0000 Subject: [PATCH 2/3] fix(chat): read both minimap placements without shadowing; drop an unused builder Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-627034dbf10a04318c5c --- .../src/agent-session/acpmux/chatMinimap/paint.ts | 6 +++--- .../src/agent-session/acpmux/placedHeightIndex.ts | 12 ------------ 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/webviews/src/agent-session/acpmux/chatMinimap/paint.ts b/webviews/src/agent-session/acpmux/chatMinimap/paint.ts index b95457c6ad9a..55c1793aa9b8 100644 --- a/webviews/src/agent-session/acpmux/chatMinimap/paint.ts +++ b/webviews/src/agent-session/acpmux/chatMinimap/paint.ts @@ -93,11 +93,11 @@ export function firstChangedRow( before: { rows: readonly AcpmuxRow[]; layout: GeometrySource }, ): number { const now = rowPlaces(layout); - const was = rowPlaces(before.layout); + const prior = rowPlaces(before.layout); const count = Math.min(rows.length, now.tops.length); - const kept = Math.min(count, before.rows.length, was.tops.length); + const kept = Math.min(count, before.rows.length, prior.tops.length); for (let index = 0; index < kept; index += 1) { - if (now.tops[index] !== was.tops[index] || now.heights[index] !== was.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; diff --git a/webviews/src/agent-session/acpmux/placedHeightIndex.ts b/webviews/src/agent-session/acpmux/placedHeightIndex.ts index e9f434f82c4e..6610e46edd27 100644 --- a/webviews/src/agent-session/acpmux/placedHeightIndex.ts +++ b/webviews/src/agent-session/acpmux/placedHeightIndex.ts @@ -133,18 +133,6 @@ function summarize( }; } -function makeTree(leaves: readonly Leaf[], low: number, high: number, between: GapBetween | undefined): Node | undefined { - if (low >= high || low >= leaves.length) return undefined; - if (high - low === 1) { - const leaf = leaves[low]!; - return { leaf, summary: leaf.summary }; - } - const middle = low + ((high - low) >>> 1); - const left = makeTree(leaves, low, middle, between); - const right = makeTree(leaves, middle, high, between); - return { left, right, summary: join(summaryOf(left), summaryOf(right), between) }; -} - function readLeaf(node: Node | undefined, low: number, high: number, leafIndex: number): Leaf | undefined { while (node && high - low > 1) { const middle = low + ((high - low) >>> 1); From 1500a7a037a3fa182749ddfb759808eb051fa787 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 01:48:54 +0000 Subject: [PATCH 3/3] style(chat): format the placement index files Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-627034dbf10a04318c5c --- .../acpmux/conversationGeometry.test.ts | 3 +- .../acpmux/conversationGeometry.ts | 6 ++- webviews/src/agent-session/acpmux/model.ts | 3 +- .../acpmux/placeRowsIndexed.test.ts | 46 ++++++++++++++++--- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversationGeometry.test.ts b/webviews/src/agent-session/acpmux/conversationGeometry.test.ts index 655be4bd02ee..e1682dca729a 100644 --- a/webviews/src/agent-session/acpmux/conversationGeometry.test.ts +++ b/webviews/src/agent-session/acpmux/conversationGeometry.test.ts @@ -34,7 +34,8 @@ describe("indexed conversation geometry", () => { 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)); + 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]); diff --git a/webviews/src/agent-session/acpmux/conversationGeometry.ts b/webviews/src/agent-session/acpmux/conversationGeometry.ts index cf67b92daf99..93c98fa58742 100644 --- a/webviews/src/agent-session/acpmux/conversationGeometry.ts +++ b/webviews/src/agent-session/acpmux/conversationGeometry.ts @@ -18,7 +18,11 @@ export type LayoutGeometry = { /// 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; + 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. */ diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index e7d542f657b1..a613a01477e7 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -2403,7 +2403,8 @@ export function placeRowsIndexed( 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]); + 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( diff --git a/webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts b/webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts index 591432774f8e..b309b4e6a8da 100644 --- a/webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts +++ b/webviews/src/agent-session/acpmux/placeRowsIndexed.test.ts @@ -25,7 +25,17 @@ import { // 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]; +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; @@ -104,7 +114,10 @@ describe("placeRowsIndexed matches placeRows", () => { 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)); + expectSame( + placeRowsIndexed(estimate, heightAt, undefined, origin), + placeRows(estimate, heightAt, undefined, origin), + ); } } }); @@ -113,12 +126,25 @@ describe("placeRowsIndexed matches placeRows", () => { // 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], + 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)); + 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. @@ -134,7 +160,10 @@ describe("placeRowsIndexed matches placeRows", () => { { heights: [], marks: [], gaps: [] }, ]) { const estimate = estimateOf(rows); - expectSame(placeRowsIndexed(estimate, () => undefined), placeRows(estimate, () => undefined)); + expectSame( + placeRowsIndexed(estimate, () => undefined), + placeRows(estimate, () => undefined), + ); } }); @@ -230,7 +259,10 @@ describe("placeRowsIndexed matches placeRows", () => { 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))); + expectSame( + after, + placeRows(estimate, (index) => (index === 10 || index === count - 20 ? 99 : undefined)), + ); } }); @@ -242,7 +274,7 @@ describe("placeRowsIndexed matches placeRows", () => { 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 }); + 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);