Skip to content

Speed up thread rendering while content streams - #3591

Merged
SawyerHood merged 5 commits into
mainfrom
bb/benchmark-thread-rendering-streaming-content-thr_d7746tynyd
Sep 12, 2026
Merged

SawyerHood merged 5 commits into
mainfrom
bb/benchmark-thread-rendering-streaming-content-thr_d7746tynyd

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

While an agent answer streams into a long thread, every newline-gated text update triggers a timeline refetch, React commits, a Markdown re-render and a layout, and on a large database that path was expensive at every layer. Profiling production builds found four root causes. First, the @container/page scroll port sat in an implicit auto grid row, so every layout evaluated the page container twice and the 100cqw table breakouts restyled every timeline row: about 11.5 s of a 21 s stream at 1× CPU. Second, Markdown re-parsed the whole message and rebuilt highlighted code DOM on every update. Third, the timeline controller committed extra intermediate renders per update. Fourth, the server rebuilt the latest timeline page from scratch on each refetch (event queries, grouping context, JSON decode), and daemon event ingest did avoidable work.

What changed

Client

  • bottom-anchored-scroll-body.tsx: explicit grid-rows-[minmax(auto,1fr)] on the timeline scroll grid. Geometry is unchanged.
  • Markdown: a stable dangerouslySetInnerHTML object for highlighted code (DOM nodes added per stream went from 15k to 2k on the default scenario), an LRU for highlights, a thread-mention precheck, and stable empty candidate arrays.
  • useThreadTimelineController: no re-render on fetch start or end once rows exist, one consistent commit per data update, and a stable side-chat send-to-main callback (useSendSideChatMessageToMain).
  • ThreadTableOfContents: per-row label cache.
  • markdown-incremental-pieces.ts (the block memoization technique from Streamdown): assistant messages parse each settled piece once. Reused pieces are re-cloned so components re-render on the same schedule as before, cold mounts parse the whole message once, and messages with footnotes or definitions fall back to single-document parsing. Message directive mounts now come from context. Markdown fence and list scanning helpers are shared with streaming-markdown-split.ts.

Server and database

  • packages/db/src/data/events.ts: INDEXED BY hint for the parented-boundary EXISTS (4 to 5.6× faster on tool-heavy threads).
  • Ingest: skip search-segment parsing for delta rows, look up completed turns directly, and send small daemon event replies uncompressed. The JSON payload is unchanged and no wire fields changed, so HOST_DAEMON_PROTOCOL_VERSION stays the same.
  • Incremental latest-page reads: a per-thread event rewrite generation (bumped by every event mutator and sweep, checked together with PRAGMA data_version), a grouping-context cache, an exact stored-event decode cache, and a latest-page selection memo that is reused only at the latest sequence of an active thread.
  • docs/debugging-and-qa.md: documents the new timeline-streaming-memo.test.ts corpus gate.

About 2k of the added lines are product code, 5.7k are tests, and 1.7k are Markdown fixture documents used by the incremental Markdown tests.

Not in this PR (each would change visible behavior and needs a decision): snapping timeline growth while streaming, desktop content-visibility for offscreen rows, skipping refetches on ticks with no visible text change, paced refetches or server-side frame coalescing, excluding delta rows from the event budget, and one Markdown container across the settled/tail seam.

How you verified

Benchmark: measured with a streaming render benchmark that ran production builds on a seeded database (1,200 threads, ~400k events) plus 150- and 600-turn threads, driving headless Chrome for Testing 149 at 1440×900. Runs interleaved base and candidate builds; medians of 5 iterations at 1× and 3 at 4×, measured on the previous base 1a7c008643. The final message text hash and timeline geometry hash matched the base in every iteration of every scenario. The harness is not part of this PR; it is in this PR's first commit (f16f089991) if anyone wants to rerun it.

Scenario Main thread LoAF blocking Frame p95 Line latency p50 / p95 Server CPU Timeline GET p50
default (16.5 KB into 150 turns) 20.8 → 10.9 s (−48%) 214 → 0 ms 50 → 16.8 ms 376 → 276 / 491 → 319 ms 6.1 → 3.6 s 25 → 9.9 ms
long (66 KB) 44.8 → 29.5 s (−34%) 3,882 → 5 ms 100 → 16.8 ms 477 → 276 / 658 → 329 ms 11.3 → 7.8 s 27.6 → 10.7 ms
pathological Markdown 26.3 → 15.0 s (−43%) 239 → 21 ms 50 → 16.8 ms 417 → 280 / 513 → 341 ms 7.6 → 4.8 s 26.5 → 10.1 ms
600-turn thread 20.4 → 12.0 s (−42%) 317 → 0 ms 33.4 → 16.8 ms 460 → 284 / 610 → 335 ms 11.2 → 4.4 s 75 → 10.4 ms
default, 4× CPU — 9.6 → 2.6 s 266 → 117 ms 805 → 481 / 1,199 → 634 ms −19% —
long, 4× CPU — 28.4 → 7.1 s 467 → 133 ms 1,131 → 545 / 1,820 → 685 ms −17% —

Style recalculation dropped 79–86% at 1×. Retained JS heap after GC is 0.5–4.8 MB higher (bounded piece cache and highlight LRU).

Behavior

  • Every change was written test-first with fail-before proofs, plus old-vs-new differential tests. Changes whose differential tests showed observable differences were dropped, for example a stable loadOlderTimelineRows identity, a timeline controller provider, and an API key verification cache.
  • A later simplification pass removed tests and simplified a few helpers. Independent reviewers found no behavior change in the product code. For every removed or merged test, a mutation reintroduced its bug; where no remaining test failed, the test was restored and re-proven.
  • Provider corpus: row snapshots written from main 267938526d and compared against this branch with row-snapshots.test.ts: 340 threads, 106,510 rows, 0 diffs. The new timeline-streaming-memo.test.ts appends streaming rows tick by tick to every corpus thread and requires each warm latest-page build to equal a build on a fresh connection with empty caches: 340 of 340 threads pass.
  • Markdown corpus check: every unique assistant message in the corpus (14,200) rendered through the incremental and single-document paths at every streaming step: 199,978 comparisons, 0 DOM differences.
  • Dev app on this branch: streamed a 16.5 KB document into a 150-turn thread on a seeded database. It rendered completely, stayed pinned to the bottom, and logged no console errors.

Commands (on 267938526d with this branch)

  • pnpm exec turbo run typecheck lint --filter=@bb/app --filter=@bb/server --filter=@bb/db: pass. oxfmt --check on changed files: pass.
  • pnpm exec turbo run test --concurrency=1 --filter=@bb/app --filter=@bb/server --filter=@bb/db: @bb/app 526 files (4,556 tests), @bb/server 2,637 tests, @bb/db 514 tests. All pass.
  • pnpm install --frozen-lockfile: pass; the lockfile is unchanged from main.

AGENT GENERATED

SawyerHood and others added 5 commits September 12, 2026 16:31
Add a streaming render benchmark that runs production builds against a
large golden database, drives headless Chrome over CDP, and records
main-thread, frame, latency, network and server CPU metrics, plus a
deterministic bench-stream provider plugin that grows long thread
histories and streams Markdown at a fixed rate.

Speed up the streaming path end to end without visible behavior changes:

- Give the timeline scroll grid an explicit minmax(auto,1fr) row so the
  page container query no longer restyles every row on each layout.
- Keep highlighted code markup stable, cache highlights, precheck thread
  mentions, and reuse empty candidate arrays.
- Commit timeline updates once per data change and skip fetch-state
  re-renders once rows exist.
- Cache table of contents labels per row.
- Parse settled Markdown pieces once while a message streams.
- Hint the parented timeline boundary query onto the turn index, skip
  search segment parsing for delta rows, and look up completed turns
  directly on ingest.
- Serve latest timeline pages incrementally with a per-thread event
  rewrite generation, a grouping context cache, a stored event decode
  cache, and a latest-page selection memo.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…cript

Co-Authored-By: Claude Code <noreply@anthropic.com>
- Benchmark runner: drop unused CDP session tracking, Chrome relaunch
  logic, collector diagnostics, experiment flags, and dead exports;
  share one process-group teardown; derive the Chrome version from the
  iteration browser.
- Bench stream provider: consolidate bridge routing and tests.
- App and server: share Markdown fence helpers, drop wrappers and
  single-use exports, and consolidate the cache, memo, and incremental
  Markdown tests around shared fixtures.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Re-add assertions and cases whose bugs no remaining test caught, each
proven against a mutation of the code under test.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Delete the benchmark runner and the bench stream provider plugin. Move
the Markdown fixture documents the incremental Markdown tests use into
the app test fixtures.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@SawyerHood SawyerHood changed the title Benchmark and speed up thread rendering while content streams Speed up thread rendering while content streams Sep 12, 2026
@SawyerHood
SawyerHood merged commit 84a960c into main Sep 12, 2026
16 checks passed
@SawyerHood
SawyerHood deleted the bb/benchmark-thread-rendering-streaming-content-thr_d7746tynyd branch September 12, 2026 21:00
SawyerHood added a commit that referenced this pull request Sep 15, 2026
## Human comments

## What was wrong

Since #3325, `mergeLoadedTimelineWithLatest` replaces every loaded row
whenever the latest page's `historySnapshot` changes, and the snapshot
includes `maxSeq`, so each appended event unloads all older pages. When
the previous conversation group is larger than the 1,500-event
latest-page budget, it exists only on an older page. While a thread
streams, each event removes it, the "Loading older messages" sentinel
scrolls back into view, auto-load fetches it again, and the next event
removes it again. The controller also discarded an older page still in
flight whenever the snapshot moved. Separately, a page's older cursor
used the group's first displayed row. Rows recorded after a request but
displayed above its message, such as "Provider environment resolved",
formed their own group with a higher sequence, so the first older
request re-served the current group and merging it moved that row below
the reply.

## What changed

- `packages/client-core` `mergeLoadedTimelineWithLatest`: when a new
latest snapshot reaches the loaded tip, loaded older pages stay and only
the rows the latest page covers are replaced. It still rebuilds from the
latest page when the pages do not line up, when they share no rows and
leave a gap, or when the server reports a later change to a row the page
omitted. On a cursor tie it keeps the walk's cursor.
`prependOlderTimelineRows` keeps the loaded order when an older page
repeats loaded rows.
- `useThreadTimelineController`: an in-flight older page is applied when
its request cursor is still the loaded `olderCursor`, instead of
requiring an unchanged snapshot.
- `apps/server` pagination: new `timelinePage.olderRowsSourceSeqEnd`,
the greatest `sourceSeqEnd` among rows the snapshot projected before the
page's returned rows but omitted (`null` when none). That covers older
conversation groups, owned groups a size cut left out, and the leaves a
content cut dropped from the page's oldest group, so a command that
completes before a content cut still triggers a rebuild. A row that
keeps changing before a content cut therefore rebuilds on each refresh.
Leading rows recorded after a group's message now join that group, and
its cursor is the message row.
- `packages/server-contract`: the field is optional in
`timelinePageMetadataSchema`, matching the other snapshot fields. No
daemon wire change, so `HOST_DAEMON_PROTOCOL_VERSION` is unchanged.
- `docs/timeline-pagination.md` describes the new rule. This relaxes
#3325's "a new latest snapshot replaces loaded rows" behavior for
refreshes that only append to newer rows; a later change to a row the
page omitted still replaces them.
- One #3325 assertion changed in `timeline-event-budget.test.ts`. After
a late append that sorts into the newest group, the merge must now equal
the full canonical timeline rather than the latest page alone.

## How you verified

- New client-core merge tests: a streaming refresh keeps older pages, a
tie keeps the walk cursor, a content-cut running turn keeps its head, a
group starting right after the tip appends, no overlap with a gap
rebuilds, a change to an omitted row rebuilds, and a repeated group
keeps the loaded order. 5 of these fail against main's merge code.
- New controller test: an in-flight older page applies across refreshes
that advance the snapshot, and older rows survive later refreshes.
- New server unit tests: the cursor anchors at the message row, and the
rebuild value covers context-only older groups, owned groups a size cut
left out, and rows before a content cut.
- Endpoint tests: walked history stays equal to the canonical timeline
after an append to the newest group. A late command completion reports a
rebuild both in an older group and before a content cut in the newest
group; after the rebuild and reloading older pages, the rows equal the
canonical timeline. The content-cut case fails with the previous
pagination code.
- `pnpm exec turbo run typecheck` for `@bb/server-contract`,
`@bb/client-core`, `@bb/server` and `@bb/app`, and earlier for `@bb/cli`
and `@bb/mobile`.
- `pnpm exec turbo run test` for `@bb/client-core`,
`@bb/server-contract`, `@bb/cli`, and `@bb/server --
test/services/threads test/provider-corpus test/routes`. The provider
corpus correctness walk skipped because no corpus was available.
- App Vitest run directly for `src/components/thread/timeline`,
`src/views/thread-detail` and `src/hooks/queries`, because the PWA icon
check fails in this checkout.
- Browser, isolated source instance with
`BB_FF_TIMELINE_WINDOW_EVENT_BUDGET=20` and real Claude Code turns sent
from the composer. A MutationObserver recorded whether the previous
assistant message stayed in the DOM while a follow-up streamed. With
main's client merge it left and returned 8 times within about 4 seconds,
and "Loading older messages" showed in 19 samples. With this change it
never left over a 30-second turn.
- Replayed a real affected thread's events through the server at each of
49 consecutive sequences, feeding both clients. With main's client the
previous message dropped on 49 of 49 refreshes; with this change, 0. The
rebuild value stayed `null` throughout.
- The browser check ran before rebasing onto #3591, and the replay
before the omitted-rows change. Tests and typechecks were rerun after
both.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> AGENT GENERATED

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
danielbachhuber added a commit to danielbachhuber/bb that referenced this pull request Sep 15, 2026
- markdown-preview.test.tsx: the only marked conflict, and it was adjacency
  in the import block. This branch added `onTestFinished` to the vitest
  import; main added `highlightMarkdownCode` on the next line. Both imports
  stay.
- markdown-preview.tsx merged cleanly and needed no follow-up. Main's
  changes there came from the incremental streaming render (get-bb#3591) and the
  timeline lightbox work (get-bb#3489, get-bb#3596); none of them touch `MarkdownAnchor`
  or `MarkdownListItem`, so the fragment-link handling and the forwarded
  `<li id>` survive as written.
- The incremental renderer does not undercut this fix. It treats
  `footnoteDefinition` and `footnoteReference` as global constructs
  (markdown-incremental-pieces.ts), so a message with footnotes is not split
  across pieces, and every piece renders inside the same
  `[data-markdown-preview]` root the destination lookup scopes to.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant