Split streaming markdown into settled prefix + live tail; gate mermaid; row containment (C1, C3, C4) - #1899
Merged
SawyerHood merged 5 commits intoAug 19, 2026
Conversation
SawyerHood
force-pushed
the
bb/mobile-perf/markdown-streaming-and-containment
branch
from
August 19, 2026 07:42
be71b48 to
1430c11
Compare
SawyerHood
force-pushed
the
bb/mobile-perf/markdown-streaming-and-containment
branch
from
August 19, 2026 07:50
1430c11 to
91c6af0
Compare
SawyerHood
marked this pull request as ready for review
August 19, 2026 08:04
SawyerHood
force-pushed
the
bb/mobile-perf/markdown-streaming-and-containment
branch
from
August 19, 2026 15:59
a1b8737 to
3e63669
Compare
…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
force-pushed
the
bb/mobile-perf/markdown-streaming-and-containment
branch
from
August 19, 2026 16:13
3e63669 to
b6f53de
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
MarkdownPreviewmemoizes oncontent ===), 2-5 full parses per second of a growing document, 10-30 ms each on a phone (C3).mermaid.renderon mount and on every streaming delta, with no viewport gate and no reuse of a previous SVG (C4).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.ConversationMessageContentrenders the streaming assistant row as two memoizedMarkdownPreviewdocuments (settled + tail) and falls back to one document when no boundary exists or when the row is complete. Seam classes restore thelast:mb-0/first:mt-0margins at the split so completion does not shift layout.ThreadTimelineRowscomputes 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 getmax-md:[content-visibility:auto] max-md:[contain-intrinsic-block-size:auto_1.25rem]; conversation rows add an inline length-basedcontain-intrinsic-block-sizeestimate (bucketed). Nested lists keep plain wrappers. Compact-only because paint containment clips the assistant table breakout on wide layouts.How you verified
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: autois 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).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).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).streaming: booleanon the assistant variant of ConversationMessageContent (app-internal component contract; stories/tests updated).