Repository navigation
perf(chat): place transcript rows through a gap-aware index - #32837
Conversation
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 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-627034dbf10a04318c5c
…used builder Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-627034dbf10a04318c5c
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-627034dbf10a04318c5c
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
Gallery before/after for
States
Pick other states with a Shipped in:
|
|
Owned by an agent session · session |
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 addstranscriptGap(left.lastMark, right.firstMark) * scaleonly 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 wayplaceRowsskips 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), andlastMarksfor the tail metrics. The plainfromHeights/update/appendAPI and its tests stay.model.ts:placeRowsIndexed(estimate, heightAt, previous?, origin)returns the same tops, heights, total,tailGapandrestBandasplaceRowsfor the same input. A reused placement keeps its index and reads only thechangedrows and the rows fromfromon. Another scale or origin, or no index, places every row again, asplaceRowsdoes.placedWithOriginmoves the origin in O(1);withOrigincopied every top.PlacedLayoutholdsgeometryand notops/heightsarrays. Estimates (layoutConversation) still use arrays;layoutGeometryreads either kind in O(1).App.tsx: themeasuredmemo places throughplaceRowsIndexed. It passes the rows kept by object asfrom, and the indices of rows whose drawn height changed aschanged. Everytops/heightsreader in App moved tolayout.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'stop/gapBelow.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) andmapMarks(onescan).Equivalence
placeRowsIndexed.test.tscomparesplaceRowsIndexedwith base'splaceRowson every top, height, gap, total, tail gap, rest band, origin and visible range:placedWithOriginmatcheswithOrigin; an unplaced estimate reads like its arrays;placedHeightIndex.test.tsadds 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'splaceRowsas "before" (it is still inmodel.ts, unchanged) againstplaceRowsIndexedas "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.
placeRows)placeRowsIndexed)ROWS=9000 PASSES=300 bun bench/height-index.bench.ts, runROWS=90000 PASSES=100, same runplaceRowsIndexed.test.ts, runAn 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
placeRowskeeps 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
WeakMapkeyed 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
placeRowswith 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
3aca90cand 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 everycheck:piecesstep (41561212681); at the merged head's tree,bun run checkexited 0 and 79 of 79 tests passed across 10 files (41562213494).🤖 Generated with Claude Code
Agent session: agent session