Skip to content

Split streaming markdown into settled prefix + live tail; gate mermaid; row containment (C1, C3, C4) - #1899

Merged
SawyerHood merged 5 commits into
bb/mobile-perf/rerender-amplifiersfrom
bb/mobile-perf/markdown-streaming-and-containment
Aug 19, 2026
Merged

Split streaming markdown into settled prefix + live tail; gate mermaid; row containment (C1, C3, C4)#1899
SawyerHood merged 5 commits into
bb/mobile-perf/rerender-amplifiersfrom
bb/mobile-perf/markdown-streaming-and-containment

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

  • The in-progress assistant message re-ran the whole react-markdown pipeline on every text delta (MarkdownPreview memoizes on content ===), 2-5 full parses per second of a growing document, 10-30 ms each on a phone (C3).
  • Mermaid blocks called mermaid.render on mount and on every streaming delta, with no viewport gate and no reuse of a previous SVG (C4).
  • Every loaded top-level timeline row stayed in layout/paint; each style/layout pass on a phone (keyboard, orientation, streaming growth) walked all mounted rows (C1 step 1).

What changed

  • streaming-markdown-split.ts: splitStreamingMarkdown(text) returns a settled prefix and a live tail. The boundary is the last blank line that is outside an open fenced block / $$ math block / list continuation and is followed by a complete line, so the boundary only moves forward as text streams. ConversationMessageContent renders the streaming assistant row as two memoized MarkdownPreview documents (settled + tail) and falls back to one document when no boundary exists or when the row is complete. Seam classes restore the last:mb-0 / first:mt-0 margins at the split so completion does not shift layout. ThreadTimelineRows computes the streaming row (findStreamingAssistantMessageId: trailing leaf row while the runtime runs, descending pending turns and pending delegations) and provides it through a context; delegation output uses the split while pending.
  • markdown-mermaid-render-cache.ts + MarkdownMermaidDiagram: one shared IntersectionObserver gates the first render (256px root margin); source changes after the first render are debounced 300 ms trailing and keep the previous SVG visible; rendered SVGs are stored in a 32-entry LRU keyed by (source, theme, palette epoch); a cached diagram paints synchronously on mount.
  • timeline-row-containment.ts + TimelineRowsList: top-level row wrappers get max-md:[content-visibility:auto] max-md:[contain-intrinsic-block-size:auto_1.25rem]; conversation rows add an inline length-based contain-intrinsic-block-size estimate (bucketed). Nested lists keep plain wrappers. Compact-only because paint containment clips the assistant table breakout on wide layouts.

How you verified

  • New tests: streaming-markdown-split.test.ts (fences incl. marker matching, $$ blocks and inline $$x$$, loose lists / indented continuation, headings, monotonic boundary while streaming); ConversationMessageContent.streaming.test.tsx (mocks react-markdown to count parses: only the tail re-parses per delta, boundary moves forward, completion collapses to one document, open fence stays in the tail); ThreadTimelineRows.streaming.test.ts (streaming row selection incl. pending turn children, pending delegation childRows, completed turn -> null); markdown-mermaid-diagram.render.test.tsx (no render before viewport entry, one shared observer, 300 ms debounce keeps previous SVG, remount served from cache without calling mermaid, LRU eviction); ThreadTimelineRows.containment.test.tsx (top-level wrappers get the class + conversation rows carry the estimate, nested wrappers do not).
  • pnpm exec turbo run typecheck --filter=@bb/app: pass. pnpm exec turbo run lint --filter=@bb/app: 0 errors, 147 warnings (same count as baseline; touched files carry the same 12 pre-existing warnings as before). pnpm exec turbo run test --filter=@bb/app -- src/components/thread/timeline src/components/ui/markdown-* src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx src/views/thread-detail: 48 files, 411 tests pass.

Update (2026-08-19)

content-visibility: auto is now armed only where CSS scroll anchoring exists (CSS.supports("overflow-anchor", "none"): Chromium, Firefox). WebKit has no scroll anchoring, so a replayed size that differs from the real row size moved the visible content and read as a flash-and-scroll while a thread settled on iOS. iOS keeps the inert intrinsic-size estimate; Chromium/Firefox keep the layout/paint savings. Test added for the WebKit path.

Fixes: part of the mobile / iOS Safari performance program (verified sweep report in the bb thread; no single issue).

Stack context

Layer 20 of 22 in the bb/mobile-perf/* stack (bottom → top: quick wins first, big rocks last).

  • Prerequisite (layer below): bb/mobile-perf/rerender-amplifiers (Remove re-render amplifiers across slots, contexts, banners and the prompt area (C6-C13, D2, D4, E9, E11, E12, H8) #1898).
  • Next layer: bb/mobile-perf/mobile-interactions (Phone interactions: long-press menu, touch listeners, sidebar across routes, composer retention (E1, E6, E7, E10, D3, J3, J4, I6, B28) #1900).
  • Audit findings addressed: see title IDs. Review: approved-with-fixes; 1 review fix commit(s).
  • Reviewer notes / follow-ups: C1 still needs an iOS Simulator pass (Linux review box; caniuse confirms iOS Safari through 26.3 has no scroll anchoring). With the fix, rows are laid out once before opting in, so | C3 (design trade-off, not fixed): plugin message-directive components and stateful markdown widgets (code-block wrap toggle) that sit in the live tail remount when the boundary mov | C4: cached SVGs reuse the first renderer's element ids, so two simultaneously mounted copies of the same diagram (e.g. split view of the same thread) share ids; visually identical | C1: paint containment can clip the assistant table breakout by ~2px inside expanded turn bodies on compact (nested list is pl-3/pr-2 asymmetric). Negligible.
  • Wire/contract: None. No server/daemon wire, route, command or protocol-version change. New required prop streaming: boolean on the assistant variant of ConversationMessageContent (app-internal component contract; stories/tests updated).

AGENT GENERATED: by Claude Opus 5

@SawyerHood SawyerHood changed the title bb/mobile perf/markdown streaming and containment Split streaming markdown into settled prefix + live tail; gate mermaid; row containment (C1, C3, C4) Aug 19, 2026
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/markdown-streaming-and-containment branch from be71b48 to 1430c11 Compare August 19, 2026 07:42
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/markdown-streaming-and-containment branch from 1430c11 to 91c6af0 Compare August 19, 2026 07:50
@SawyerHood
SawyerHood marked this pull request as ready for review August 19, 2026 08:04
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/markdown-streaming-and-containment branch from a1b8737 to 3e63669 Compare August 19, 2026 15:59
SawyerHood and others added 5 commits August 19, 2026 16:10
…cache SVGs

Every mermaid code block ran `mermaid.render` on mount and again on every
streaming delta, with no viewport gate and no reuse of a previous result. A
thread with several diagrams above the fold on a phone paid all of those
renders during the first paint, and a streaming diagram re-rendered several
times per second.

MarkdownMermaidDiagram now
- waits until its container comes near the viewport (one shared
  IntersectionObserver for all diagrams, 256px root margin) before the first
  render;
- debounces `source` changes 300 ms trailing and keeps the previous diagram on
  screen while the next render is pending (first render and theme changes stay
  immediate);
- stores rendered SVGs in a small module LRU keyed by (source, theme, palette
  epoch), so a remounted or theme-restored diagram paints synchronously — a
  cached diagram skips both the gate and the loading placeholder on mount.

Co-Authored-By: Claude <noreply@anthropic.com>
…e tail

`MarkdownPreview` memoizes on `content ===`, so the in-progress assistant
message re-ran the whole react-markdown pipeline (remark-gfm/math/directives,
rehype-katex, highlighting) on every text delta — 2-5 full parses per second
of a growing document, 10-30 ms each on a phone.

The timeline now marks the one assistant row that can still receive deltas
(the trailing leaf row while the runtime runs, descending through the pending
turn or delegation that owns the frontier) through a new context, and
`ConversationMessageContent` renders that row as two memoized markdown
documents: a settled prefix that ends at the last blank line outside an open
fence / `$$` block / list, and the live tail. Only the tail re-parses per
delta; the prefix only grows, so its render is reused until the boundary
moves. Completed messages (and rows with no safe boundary) render as one
document exactly as before. Delegation output uses the same split while the
delegation is pending.

The boundary requires the line after the blank line to be complete, so a
partially streamed line can never make an earlier boundary flip, and the
split point moves forward only. Seam margins (`last:mb-0` on the trailing
paragraph, `first:mt-0` on a leading heading) are restored on the two
documents so the layout does not shift when the finished message collapses
back to one document.

Co-Authored-By: Claude <noreply@anthropic.com>
Every loaded timeline page stays mounted, and each style/layout pass on a
phone (keyboard show/hide, orientation change, streaming growth) walked all
of those rows. Top-level row wrappers now opt into `content-visibility: auto`
on compact viewports (`max-md:`), with `contain-intrinsic-block-size: auto
<estimate>` so the browser keeps the real height once a row has rendered and
uses an estimate only for rows that never rendered yet: one text line for
work/turn/system rows and a length-based estimate for conversation rows
(compact column width, bucketed so the streaming row's estimate is not
rewritten per delta).

Compact-only because paint containment clips the assistant markdown table
breakout, which extends past the row column on wide layouts; on phones the
breakout width equals the row width. Nested lists (turn / bundle bodies) keep
plain wrappers because their parent body animates its own height.

The bottom-anchor sentinel, unread divider `scrollIntoView`, search-scroll
reveal and the per-thread scroll restore all target elements outside or via
the row wrapper and keep working: `scrollIntoView` renders skipped content,
and the restore re-applies on the ResizeObserver settle as neighbouring rows
render.

Co-Authored-By: Claude <noreply@anthropic.com>
`content-visibility: auto` from the first frame left every row above the
initial viewport (the timeline mounts scrolled to the bottom) and every
prepended older page at the intrinsic-size estimate. iOS Safari has no
scroll anchoring, so each estimate -> real correction while scrolling up
shifted the visible content. Wrappers now mount with only the
`contain-intrinsic-block-size: auto <estimate>` declaration, get laid out
once at their real size (which records the last remembered size), and add
`content-visibility: auto` two animation frames later via classList so the
React `className` prop stays constant and never clobbers the imperative
search-match flash class.

Also write the Mermaid render cache key separators as backslash-u0000 escapes:
the literal NUL bytes made git treat the module as binary.

Co-Authored-By: Claude <noreply@anthropic.com>
WebKit has no scroll anchoring, so a skipped row whose replayed size
differs from its real size moves the visible content instead of being
absorbed. On iOS that reads as a flash-and-scroll while a thread settles
after load. The arming hook now checks CSS.supports('overflow-anchor', 'none')
(Chromium, Firefox) before adding content-visibility: auto; WebKit keeps the
intrinsic-size estimate, which is inert without it.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/markdown-streaming-and-containment branch from 3e63669 to b6f53de Compare August 19, 2026 16:13
@SawyerHood
SawyerHood merged commit c196a87 into main Aug 19, 2026
11 of 12 checks passed
@SawyerHood
SawyerHood deleted the bb/mobile-perf/markdown-streaming-and-containment branch August 19, 2026 16:23
SawyerHood added a commit that referenced this pull request Aug 19, 2026
…rompt area (C6-C13, D2, D4, E9, E11, E12, H8) (#1898)

## What was wrong

Several client-side amplifiers made phones re-render far more than the
data changed:

- The plugin slot store rebuilt all 14 slot arrays per plugin activation
and emitted once per bundle at boot (~18 app-wide passes; every markdown
body re-parsed once directives existed).
- `useNavigate()` under `<BrowserRouter>` rebuilds per pathname, so the
thread-actions context, the fork handler in the timeline static context,
and every sidebar row (`useThreadRowSplitDrag`,
`useCreateThreadInWorktree`, environment archive) re-rendered on every
navigation.
- `MessageActionBar` mounted the hidden desktop tooltip bar on touch
phones in addition to the mobile popover.
- Every `ConversationRow` re-rendered per new message via the
latest-actionable id contexts, and React Compiler skipped it (a
ref-callback passed as `ref` typed the edit object as a ref).
- Title-mention resource maps were rebuilt twice (AppLayout +
ProjectList) per sidebar refetch and provided twice.
- Sidebar rows subscribed to `splitLayoutAtom` on compact viewports
where splits are off.
- Draft presence re-read localStorage for every thread per keystroke and
per sidebar render.
- `useChildThreadPendingAttention` returned a fresh array per render,
invalidating the memoized prompt stack.
- `ThreadDetailPromptArea` failed React Compiler compilation
(try/finally, render-time ref writes, ref-typed closures).
- Workflow/todo/background cards mounted collapsed bodies with per-row 1
s timers.
- `experimental_useSidebarThreads` remapped every entry into a fresh DTO
per update.
- Page header and retained secondary panel re-rendered on every
SidebarContext commit for one boolean.

## What changed

- Plugin slot store: per-kind structural sharing, per-plugin flattened
slot cache, and a bounded notification batch (150 ms) around reconcile.
- `useRouteNavigate()` from `RouteNavigationProvider` (ref-held
navigate, stable identity, no location subscription) used by rows,
ThreadActionsProvider, fork handler and plugin sidebar hooks; fork
handler reads the thread from a ref.
- Mobile branch in MessageActionBar (popover or plain inline buttons);
desktop unchanged.
- `ConversationRow` = thin context reader +
`memo(ConversationRowContent)`; `InlineMessageEditorHost` isolates the
ref callback so both compile.
- `buildThreadTitleMentionResources` with value retention; one provider
(AppLayout); ProjectList reads the context.
- Split indicator subscribes to a null atom when compact/disabled.
- Presence store caches bits, re-reads only the changed key, stays
silent when presence does not flip.
- Module-level `combine` + frozen empty array for child attention.
- ThreadDetailPromptArea compiler bailouts removed
(`runWhileFollowUpShortcutSending`, `useLatestRef`, layout-effect ref
write, module-level composer-host accessors).
- Shared `AnimatedBody` (realize on first expand, retain) for the three
cards; `useSecondTick` shared ticker.
- WeakMap DTO memo for plugin sidebar threads; SDK docs note the
uncapped array and windowing expectation.
- `SidebarShowingContext` boolean for
`useIsSidebarShowing`/`useOptionalIsSidebarShowing`.

## How you verified

- `pnpm exec turbo run typecheck --filter=@bb/app
--filter=@get-bb/plugin-sdk --filter=@bb/plugin-build`: pass.
- `pnpm exec turbo run lint --filter=@bb/app`: 0 errors (pre-existing
compiler warnings only).
- `pnpm exec turbo run test --filter=@bb/app`: 370 files, 2910 passed, 3
skipped. `@get-bb/plugin-sdk` tests: 103 passed. `@bb/plugin-build`
tests: 20 passed.
- New tests fail before / pass after: plugin-slots structural sharing +
batching, ThreadActionsProvider.navigation (stable context and
useRouteNavigate reader across a MemoryRouter navigation),
useForkThreadFromMessage identity, MessageActionBar mobile branch,
ThreadTimelineRows.row-isolation, ThreadTitleMentions.resources
retention, paneContentSplitIndicator compact gating,
usePromptDraftStorage presence reads/renders,
child-thread-pending-interactions.hook identity, AnimatedBody
realize/retain + shared interval, plugin-sidebar-hooks DTO identity,
sidebar showing-bit isolation.
- babel-plugin-react-compiler@1.0.0 run over ThreadDetailPromptArea.tsx
and ThreadTimelineRows.tsx: `ThreadDetailPromptArea` (452 slots),
`ConversationRow`, `ConversationRowContent` now CompileSuccess.

Fixes: part of the mobile / iOS Safari performance program (verified
sweep report in the bb thread; no single issue).


## Stack context

Layer 19 of 22 in the `bb/mobile-perf/*` stack (bottom → top: quick wins
first, big rocks last).
- Prerequisite (layer below): `bb/mobile-perf/plugin-host-slots`
(#1897).
- Next layer: `bb/mobile-perf/markdown-streaming-and-containment`
(#1899).
- Audit findings addressed: see title IDs. Review: approved-with-fixes;
1 review fix commit(s).
- Deliberately not done here: D6: already implemented on the base commit
dde6cdd (#1857 memoizes initialEditorContent on [richTextEditing]);
verified, no change needed
- Partial: E12: only the showing-bit context was split out (page header
+ retained ThreadSecondaryPanel); the full actions/state split of
SidebarContext was left as a foll; E11: compact gating only; the
optional desktop per-content derived-atom refinement and the
SplitThreadArea reconcile short-circuit were not done
- Reviewer notes / follow-ups: C6 batching: while a slot batch is open
(<=150 ms) a component that re-renders for unrelated reasons reads the
fresh snapshot via getPluginSlotSnapshot() while un-rendered subscrib |
E12 is a partial split (only the sidebar-showing boolean got its own
context); the actions/state split from the audit remains a follow-up as
the implementer stated. | C7 test is a hook-identity test rather than
the Profiler-through-ThreadDetailView test the audit suggested; the
row-isolation test in ThreadTimelineRows.row-isolation.test.tsx cove |
apps/app/.ladle is not covered by tsc/lint; the RouteNavigationProvider
addition there was checked by inspection only.
- Wire/contract: None. No server/daemon wire, host RPC, or protocol
changes; HOST_DAEMON_PROTOCOL_VERSION untouched. Public plugin SDK: only
doc comments on the existing experimental_useSidebarThreads member
(bundled-types regenerated); no new API members. Internal app contract:
PluginFrontendReconcileDeps gained a required beginSlotBatch dep (test
doubles updated).

> AGENT GENERATED: by Claude Code (claude-mangosteen-eap)

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 19, 2026
…routes, composer retention (E1, E6, E7, E10, D3, J3, J4, I6, B28) (#1900)

## What was wrong

On phones (iOS Safari, compact viewports) several app-side patterns made
every page and every thread heavier than it needed to be:

- Sidebar rows wrapped each row in a modal Radix ContextMenu whose 700
ms long-press set aria-hidden on #root, registered a non-passive
touchmove and flipped body pointer-events with the timeline mounted
behind the drawer (E1).
- dnd-kit's TouchSensor kept a permanent non-passive window touchmove
listener from sidebar DndContexts mounted at boot, and the SidebarInset
swipe-open registered another one for every content touch. Both make the
first move of every scroll wait for the main thread (E6, E7).
- Settings/Tools routes swapped the whole AppSidebar out; returning
remounted ProjectList in the closed drawer (E10).
- A pending permission/question swapped FollowUpPromptBox out of the
tree, rebuilding TipTap per approval (D3).
- The collapsed compact composer built the full TipTap editor during
thread mount (D5 (reverted, see below)).
- Fixed-panel storage was scanned and zod-parsed on every navigation and
re-written on mount (J3).
- Heavy per-thread caches used the default 5-minute gcTime (J4).
- PR check icons and the GitHub logo fetched light+dark PNGs from
github.githubassets.com (I6).
- Root compose showed only "Loading…" until the sidebar bootstrap
settled (B28).

## What changed

- E1: `CompactLongPressMenu` (long-press/right-click detector, nothing
mounted until first open) opens the existing responsive drawer with the
same DropdownMenu items; desktop keeps the context menu.
- E6: `SidebarTouchSensor` installs dnd-kit's listener only while the
compact drawer shows (external store written by SidebarProvider, no
context subscription); tab strip wires TouchSensor only when open with
2+ tabs.
- E7: only edge-zone touches (24-72 px) get the non-passive swipe path;
deeper touches keep the recognizer on a passive listener without
preventDefault.
- E10: on compact one persistent `<Sidebar>` panel hosts the AppSidebar
body (hidden while Settings/Tools body shows); `mobileHosted` mode on
AppSidebar/SectionSidebar; 220 ms close hold preserved.
- D3: `pendingInteraction` prop on FollowUpPromptBox keeps the editor
shell mounted and hidden, interaction as last stack item, footer pickers
read-only.
- D5 (reverted, see below): static 48 px compact row on compact + coarse
pointer; editor realizes after paint (idle/timeout, transition) or at
first tap (pointerdown mounts under an overlay, click focuses via
`focusEndForTap`); Stop/voice/drafted submit work from the row; #1771
handoff untouched.
- J3: prune once per page load from idle, lastUsedAt checked before
schema parse, no-op storage writes skipped.
- J4: `HEAVY_PAYLOAD_QUERY_POLICY` (60 s gc) on turn-summary details and
file previews; diff patches use a reader lease with a 60 s post-unmount
eviction (observer-less entries no longer gc while shown).
- I6: bundled GitHub glyph + theme-token status dot; logo via the shared
icon.
- B28: composer renders immediately with a loading project picker;
projectId-keyed queries gate on the settled bootstrap.

## How you verified

- `pnpm exec turbo run typecheck --filter=@bb/app`: pass.
- `pnpm exec turbo run lint --filter=@bb/app`: 0 errors (147
pre-existing warnings, unchanged count).
- `pnpm exec turbo run test --filter=@bb/app --force`: 364 files, 2906
passed, 3 skipped (one registry boundary test fixed in the last commit
and re-run green).
- New/updated tests: compact-long-press-menu.test.tsx,
useSidebarReorderDnd.test.tsx (sensor install/remove), sidebar.test.tsx
(passive vs non-passive registration), AppLayoutSidebar.test.tsx (single
panel + single mount across app -> settings -> tools -> app),
FollowUpPromptBox.test.tsx (editor DOM identity across pending
interaction; deferred compact editor lifecycle),
ThreadDetailPromptArea.test.tsx (composer retained + stack ordering),
fixed-panel-tabs-sync.test.ts (no rewrite, prune decisions),
use-environment-diff-patches.test.tsx (retention lease),
PluginNewThreadComposer.test.tsx (root composer before settle, gated
queries).

Fixes: mobile perf sweep findings E1, E6, E7, E10, D3, D5 (reverted, see
below), J3, J4, I6, B28.

## Update (2026-08-19)

The deferred compact follow-up editor (D5: static stand-in row +
pointerdown/click focus handoff) was removed from this PR after a device
test on iPhone showed the caret rendering above the composer after the
first tap and a ~11 px footer shift when the real editor replaced the
stand-in. The compact composer keeps the previously verified
always-mounted behavior (#1263, #1381, #1771). D5 stays open as a
follow-up once it can be verified on iOS.

Fixes: part of the mobile / iOS Safari performance program (verified
sweep report in the bb thread; no single issue).


## Stack context

Layer 21 of 22 in the `bb/mobile-perf/*` stack (bottom → top: quick wins
first, big rocks last).
- Prerequisite (layer below):
`bb/mobile-perf/markdown-streaming-and-containment` (#1899).
- Next layer: `bb/mobile-perf/diff-and-file-preview` (#1901).
- Audit findings addressed: see title IDs. Review: approved-with-fixes;
5 review fix commit(s).
- Deliberately not done here: D5: reverted after an iPhone test showed a
mispositioned caret and a footer shift; needs a device-verified handoff
before it returns
- Reviewer notes / follow-ups: Device/iOS Simulator verification still
needed for: D5 tap handoff (pointerdown-cancel + click focus with real
TipTap; jsdom mocks PromptBoxInternal), E7 deep-content passive swipe |
E1: ThreadActionsContextMenu/ProjectActionsContextMenu now branch on
useIsCompactViewport, so crossing the compact breakpoint (tablet
rotation, window resize) remounts every row's | J4: diff patches now
refetch after >60 s away from a thread; turn-summary details/file
previews gc after 60 s without observers (by design per audit).
- Wire/contract: None. No server/daemon wire, protocol, CLI, plugin API,
or DB changes; HOST_DAEMON_PROTOCOL_VERSION untouched. All changes are
in apps/app.

> AGENT GENERATED: by Claude Code (claude-mangosteen-eap)

---------

Co-authored-by: Claude <noreply@anthropic.com>
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