perf(chat): place transcript rows through a gap-aware index

Merged
TE
3d ago
+1208-345

Carries #26833's purpose onto base's current placement: "Drawn transcript heights now update the reported row indices in an immutable prefix index. VirtualTranscript, visible-range queries, anchors, folds, and both minimaps read that geometry directly, without rebuilding or materializing full tops/heights arrays after a measured-height change or range movement." #26833 can't be merged as it stands: base's placement now sets each row's gap from its neighbours, and a plain prefix sum of heights can't express that (#26833 comment 6591297220). This PR redesigns the index so the gaps are part of it.

Part of manaflow-ai/cmuxterm-hq#4740. Replaces #26833.

What changes

  • placedHeightIndex.ts: the immutable index (leaves of 64 rows, path copying) is now gap-aware. Each segment stores its row count, its sum including the gaps between its own drawn rows, how many rows it draws (capped at 2), its first and last two drawn marks, the first drawn row's own gap, and its last row's height. Joining two segments adds transcriptGap(left.lastMark, right.firstMark) * scale only when both sides draw. So a row that starts or stops drawing changes the next drawn row's gap without touching that row's leaf, and zero-height rows are skipped the same way placeRows skips them. New: fromRows, splice (a new tail, or a truncate, in O(log rows + 64 + new rows)), update(index, height, mark?, gap?), gapAt, markAt, scan (an ordered walk), and lastMarks for the tail metrics. The plain fromHeights/update/append API and its tests stay.
  • model.ts: placeRowsIndexed(estimate, heightAt, previous?, origin) returns the same tops, heights, total, tailGap and restBand as placeRows for the same input. A reused placement keeps its index and reads only the changed rows and the rows from from on. Another scale or origin, or no index, places every row again, as placeRows does. placedWithOrigin moves the origin in O(1); withOrigin copied every top. PlacedLayout holds geometry and no tops/heights arrays. Estimates (layoutConversation) still use arrays; layoutGeometry reads either kind in O(1).
  • App.tsx: the measured memo places through placeRowsIndexed. It passes the rows kept by object as from, and the indices of rows whose drawn height changed as changed. Every tops/heights reader in App moved to layout.geometry: the resting and reported anchors, keyboard row focus, mounted spans, opening at an anchor, both fold inputs, Find and count reveals, and each row's top/gapBelow.
  • Downstream readers moved to geometry: scrollAnchor (upper bound), followGlide (heightAt), the chat minimap (rowPlaces: one ordered walk per placement, cached per geometry), the thread rail (RowTops, reading a few turns at a time) and mapMarks (one scan).

Equivalence

placeRowsIndexed.test.ts compares placeRowsIndexed with base's placeRows on every top, height, gap, total, tail gap, rest band, origin and visible range:

  • random rows over 40 seeds: a third of them zero-height, every mark kind, own gaps (no-marks mode), origin 0 and non-zero;
  • the gap rules by hand: a prompt, then hidden rows, then a message takes the turn gap; a trailing empty footer gives the block tail and the rest band; a row starting to draw moves its neighbour's gap;
  • the tail with no drawn row, one drawn row, and a footer after it;
  • 12 seeds of 30 incremental passes, each chained from the last as App chains them: a token (last row grows or a row arrives), an insert mid-list, three rows mid-list redrawn (one may start or stop drawing), and a removal mid-list;
  • an origin change places every row again; placedWithOrigin matches withOrigin; an unplaced estimate reads like its arrays;
  • read counts: a resize reads exactly 2 rows at both 9,000 and 90,000 rows, and a streamed token reads 1 row and leaves the old snapshot unchanged.

placedHeightIndex.test.ts adds tests of tops, gaps and upper bounds against the sequential walk across leaf boundaries (1 to 700 rows), of rows starting and stopping drawing, and of splice in every shape.

Sums are joined in tree order, so a top can differ from the left-to-right pass by float rounding only. The tests allow 1e-6 px. Their heights are dyadic, so they actually match exactly.

One case where App's reuse hint differs from base: when a drawn height changes above rows kept by object, base reads every row from the changed one on, and this reads only the changed rows. Both give the same result unless a kept row's own inputs changed without its drawn height changing. Base doesn't pick that row up either when nothing above it changed, so this adds no new case.

Performance

Model-only, the same harness run once at this head: bench/height-index.bench.ts. It runs base's placeRows as "before" (it is still in model.ts, unchanged) against placeRowsIndexed as "after", fed the same estimate, marks and drawn heights. Each pass chains from the last one, as App does.

Bench run 41561212681 (8-vCPU Linux, Bun). Each row is ms per placement pass, p50 / p95, over 300 passes at 9,000 rows and 100 passes at 90,000 rows. Both sides gave the same tops and total on every workload.

MetricBefore (placeRows)After (placeRowsIndexed)DeltaHarness
9,000 rows, token (last row grows), p50 / p950.0091 / 0.0527 ms0.0042 / 0.0256 ms3.9x totalROWS=9000 PASSES=300 bun bench/height-index.bench.ts, run
9,000 rows, mid-list resize, p50 / p950.0554 / 0.0703 ms0.0038 / 0.0110 ms12.1x totalsame
9,000 rows, resize near the top, p50 / p950.0640 / 0.0951 ms0.0030 / 0.0083 ms21.4x totalsame
90,000 rows, token, p50 / p950.0397 / 0.4927 ms0.0064 / 0.0396 ms10.7x totalROWS=90000 PASSES=100, same run
90,000 rows, mid-list resize, p50 / p950.3287 / 0.9604 ms0.0064 / 0.0216 ms50.6x totalsame
90,000 rows, resize near the top, p50 / p950.5202 / 1.3969 ms0.0063 / 0.0204 ms72.8x totalsame
Rows read for a resize of two rows, 9,000 and 90,000 rowsevery row from the first change on2placeRowsIndexed.test.ts, run

An earlier run, 41551700215, gave the same shape: at 9,000 rows a 4.5x / 12.0x / 13.9x total speedup, at 90,000 rows 9.8x / 34.6x / 59.0x.

The absolute win is small at today's chat sizes: at 9,000 rows base's pass is already about 0.06 ms, because placeRows keeps the unchanged prefix. The gain matters for long chats (about 1 ms p95 to about 0.02 ms at 90,000 rows) and for measures near the top.

What this does not change: layoutConversation (the estimate) still allocates and copies its four O(rows) arrays whenever the rows change, so with that in place a streamed token is still O(rows) memcpy end to end. Moving the estimate onto the index is the follow-up. The chat minimap's and rail's full-row passes stay O(rows) per paint, as on base; the minimap doesn't repaint while a reply streams. No browser frame claim.

Timers and observers

None added. The minimap's place cache is a WeakMap keyed by immutable geometry, so it is freed with its placement.

Size

About 1,550 changed lines (+1,208 / -345). Kept as one PR: the index redesign changes the placement type that every reader in App.tsx and the minimaps reads, so landing the index alone would leave production on placeRows with an unused second geometry API (the state #26745 left base in). About 650 of the lines are the equivalence tests.

Changelog

Changed: Transcript rows are placed through a gap-aware index, so a streamed token or a measured row no longer copies every row's place.

No capture: no visual change; placement output is equal to base's by test.

Observed / Not run

  • Ran on the fleet at this head: the 49 tests of the placement index, its geometry, the anchor, follow, minimap paint and scroll map, 49 pass and 0 fail (41561212681). The full webview suite (41552476154) ended with 13 failing files. I re-ran those 13 at base 3aca90c and at this head (41560098877 base, 41560105019 head). The same 10 fail on both (chat-header-plain-icons, chat-header-tab-row-surface, check-system-blue, find-import-pressed, gallery-coverage, hairline-rims, theme-colors, tooltip-rules, ui-rules, agent-pane-queue-row-click). The other 3 pass on both (thread-minimap-default-wave, summary-scroll-steady, agent-pane-short-pane-composer). That full run was cut short by its deadline, so not every file ran at this head.
  • bun run check (lint, format, type check, check:pieces): green: format, lint, type check and every check:pieces step (41561212681); at the merged head's tree, bun run check exited 0 and 79 of 79 tests passed across 10 files (41562213494).
  • Not run: the browser anchor/drift oracle (#26833's 3,000-turn harness) and the WebKit scroll-frame bench.

šŸ¤– Generated with Claude Code

Agent session: agent session