Skip to content

Land the mobile-performance batch (#2277-#2281) and fix a scroll re-attach edge - #2300

Merged
SawyerHood merged 11 commits into
mainfrom
integration/perf-mobile-gaps-2277-2281
Aug 22, 2026
Merged

Land the mobile-performance batch (#2277-#2281) and fix a scroll re-attach edge#2300
SawyerHood merged 11 commits into
mainfrom
integration/perf-mobile-gaps-2277-2281

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Integration of five mobile-performance PRs by @vburojevic (#2277, #2278, #2279, #2280, #2281) plus one follow-up fix found while reviewing them for regressions.

Authorship note: this lands as a squash because merge commits are disabled on this repo. The five branches are @vburojevic's work — co-author trailers on the squash preserve credit, and the source PRs are linked below.

What was wrong

Five independent mobile-performance defects, each with its own root cause:

Follow-up fix (this branch's own commit): #2280 guards the attach→detach edge with one fresh read, but not its mirror. A detached viewport that a content shrink clamps onto the new, smaller maximum (collapsing a long tool output near the end while scrolled up) is delivered its clamp scroll event before the ResizeObserver refresh, so the handler classifies it against the stale, larger cache and leaves it detached — with no later re-test. Before the cache, the live read re-attached on that very event. User-visible result: the jump-to-bottom affordance stays up while already at the bottom, and streaming content stops following.

What changed

Per source PR, unchanged from the reviewed branches:

New in this branch — apps/app/src/components/ui/bottom-anchored-scroll-body.tsx: detect the clamp case in the ResizeObserver path (cache authoritative, max offset shrank, viewport detached, now inside the bottom threshold), re-attach through a shared attachToBottom extracted from the scroll handler, and write the at-bottom anchor. No new reads in the scroll hot path. A shrink that does not reach the viewport leaves it alone.

No wire changes: nothing here touches the server/daemon protocol, so HOST_DAEMON_PROTOCOL_VERSION is unchanged. PluginComposerHost is internal to apps/app — the plugin SDK surface is untouched, and useComposerView/useComposer return identical values.

How you verified

  • Built the integration branch off origin/main; all five PRs merged with no conflicts (including ThreadDetailSecondaryContent.test.tsx, touched by both Keep the thread panel host mounted across thread navigation #2277 and Make the plugin composer host pull-based so keystrokes stop re-rendering the thread shell #2281).
  • pnpm exec turbo run typecheck --filter=@bb/app — pass.
  • pnpm exec turbo run lint --filter=@bb/app — 0 errors. The touched files carry no lint warnings on main or here (the 181 warnings are pre-existing elsewhere; the one set-state-in-effect warning in the scroll body also exists on main).
  • pnpm exec turbo run test --filter=@bb/app --force — 422 files, 3,288 passed, 3 skipped.
  • New regression test re-attaches a detached viewport that a content shrink clamps onto the bottom fails on the Stop forced layout reads in the bottom-anchored scroll hot path #2280 head and passes here; a second test (leaves a detached viewport alone when content shrinks without reaching it) guards the inverse.
  • Reviewed each PR against the server publish sites it depends on (queued-messages.ts:390, thread-lifecycle.ts:1562-1568/1669-1682), the resizable-panels callback semantics, and the draft-store/ref-sync ordering.

Known trade accepted by the owner, disclosed in #2278: while the socket reports connected, queries with neither realtime coverage nor a focus policy (Skills, plugin settings/contributions/catalog, host-path, changelog, plugin SDK useSettings) skip the focus refresh and repair on remount. A half-open socket delays realtime catch-up by up to the 5 s pong timeout.

Not verified: no iOS device/simulator run. The scroll fix relies on the same clamp-before-observer ordering #2280 specifies.

Supersedes #2277, #2278, #2279, #2280, #2281 (closed on merge; this squash carries their changes).

No issue to close: these PRs were opened directly, so the template's Fixes # line is intentionally omitted.

AGENT GENERATED

vburojevic and others added 11 commits August 22, 2026 08:23
Thread-to-thread navigation remounted the SecondaryPanelLayout
PanelGroup because the group key defaulted to resetKey (the thread id).
On desktop-inline layouts that destroyed and rebuilt the realized
secondary panel (diff views, metadata, browser deck) in one synchronous
commit per navigate, and reset the user's panel sizes every time.

Pass the documented panelGroupKey escape hatch (already used by
PluginPanelRightPanelHost) so the physical host survives navigation
while content identity still resets via resetKey.

Scope, stated plainly: this is a desktop-inline improvement. On compact
viewports the drawer already rendered outside the keyed group, and the
timeline/composer remount cost on any viewport is owned by PageShell's
own key={threadId} inside EmbeddedThreadChat — unchanged here.

Per-thread state stays correct: drafts and scroll anchors live under
that PageShell key, split layouts are keyed by thread id, and the
hasPanelExpandedRef mount-collapse guard now re-arms per thread since
the Panel instance survives navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every window focus refetched every active query older than 2s. On a
phone that meant each unlock and app switch fired a full refetch wave on
top of the realtime reconnect wave: the WebSocket manager already probes
the socket on visible, the reconnect watermark refetches exactly the
queries whose data predates the disconnect, and changes merged while
hidden flush on the next visible. The focus wave duplicated all of that
in the first interactive frames after unlock.

Make the default focus refetch injectable and gate it in main on
wsManager's connection state: while the state is "connected", realtime
owns freshness; in "connecting" or "reconnecting" the focus refetch
remains the fallback. Per-query refetchOnWindowFocus policies
(query-policies.ts) are unaffected and still win.

Known narrow trade, called out for review: a half-open socket reports
"connected" for up to the 5s pong timeout, and the handful of queries
with neither realtime coverage nor a focus policy (e.g. CLI skills
status) skip one focus wave when parked across an unlock; refetchOnMount
still repairs them on navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause (mobile telemetry: 19.6s frame hang after keydown in the
composer): ThreadDetailPromptArea's published PluginComposerHost embedded
the live draft as a value field (draft: currentPromptDraft), so every
keystroke built a new host object. usePublishPluginComposerHost's identity
check notified the pane scope, ThreadDetailSecondaryContentBody
(usePluginComposerHost) re-rendered SecondaryPanelLayout ->
ThreadTimelinePane (fresh element per render) -> PageShell ->
ThreadTimelineSurface per character, and SecondaryPanelLayout republished
its hosted-panel model to the workspace host per character.

Change: PluginComposerHost drops the draft value field; the live draft is
exposed as getCurrent() + subscribeDraft() and read through the new
usePluginComposerHostDraft hook, so only actual draft consumers re-render.

- usePromptDraftStorage and getPromptDraftAccessor gain a stable per-key
  subscribe; the thread and new-thread hosts pass it through and become
  identity-stable per thread/project.
- Hosts whose draft lives in React state (inline queued-message and
  sent-message editors, EmbeddedThreadChat's pair) get subscribeDraft from
  useComposerHostDraftNotifier, which notifies from a layout effect once a
  render committed a different draft. In EmbeddedThreadChat the notifiers
  are declared after ALL ref-sync layout effects, including the
  active-identity syncs: a thread switch changes the host identity and the
  draft in one commit, and useSyncExternalStore reads the snapshot inside
  the notification, so notifying before the identity sync would hand every
  subscriber the stale pre-switch draft with no later notification to
  correct it.
- The queued-message and sent-message hosts are keyed on their session
  scalars (editSessionId/operationId), so every host in the app now honors
  the identity-stable contract; the published value only flips between two
  stable identities (thread host <-> session host).
  EmbeddedThreadChat's ...WithDraft wrapper memos are deleted.
- Draft readers move to the hook: useComposerView/useComposer (plugin SDK
  contract unchanged - same values, same update timing) and
  FollowUpPromptBoxStackOnly.

Tests: ThreadDetailPromptArea.keystrokes.test.tsx uses the real draft store
and asserts that 21 keystrokes cause zero re-renders of a shell probe
holding the published host while a subscribed consumer tracks every
character; that submit reads the draft imperatively at event time; that
external store writes reach consumers while a pending interaction hides the
composer; and that inline queued edits publish one per-session host, stream
keystrokes without shell renders, and restore the identical thread host on
close. ThreadDetailSecondaryContent.test.tsx mounts the real
body/SecondaryPanelLayout with a store-backed publisher in the footer slot
and asserts the timeline pane render count stays flat across 20 draft
writes. EmbeddedThreadChat.test.tsx switches threads with pre-seeded drafts
and asserts subscribers observe the new thread's draft at the notification
itself (fails when the notifiers run before the identity sync). All of
these fail against the previous per-keystroke host identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mobile telemetry shows 1,500+ scroll stalls on div.thread-scrollbar.
BottomAnchoredScrollBody read scrollHeight/clientHeight on every scroll
and wheel event (syncBottomStateFromScroll, markWheelScrollIntent, and
the throttled writeScrollAnchor), forcing a synchronous layout pass per
event on an unvirtualized timeline of up to ~5,400 DOM nodes.

Mirror useStickyBottomScroll's established pattern: cache
scrollHeight - clientHeight in a ref and let per-scroll-event code read
only scrollTop plus the cache. The cache refreshes only where layout
legitimately changes: the ResizeObserver callback (it already watches
both the scroll port and the content wrapper), the bottom-restore loop
(which deliberately re-reads fresh geometry after content growth), the
programmatic scroll paths (scrollToBottom, clamped reveal, saved-row
restore, prepend compensation), and the one-shot unmount anchor flush.

Two guards keep the cache honest:

- Shrink-edge verification: on a content-shrink frame the scroll event
  outruns the ResizeObserver refresh, so a still-pinned viewport
  compares its clamped scrollTop against a stale-high max and reads as
  a user detach — unrecoverable, since the bottom-restore is suppressed
  once stick-to-bottom is off (deterministic on iOS: tap-collapsing a
  long tool output while pinned). On the attach->detach edge only, one
  fresh read re-tests the predicate before flipping state; the same
  edge verification guards markWheelScrollIntent and writeScrollAnchor.
  Growth stays cache-only (stale-low is safe). Zero reads per
  steady-state scroll event, one per detach edge.

- The cache is only authoritative after the first ResizeObserver
  delivery; before that (or under a polyfill that never fires, as in
  the shared vitest setup) hot paths fall back to live reads, the
  pre-cache behavior, instead of trusting a frozen value.

jsdom tests now deliver the ResizeObserver notification a real browser
fires when scroll geometry changes. New tests pin the contract: getter
spies prove one read on the detach edge and zero across a mid-timeline
burst and re-attach; a shrink-frame regression test proves a pinned
viewport stays pinned and never persists a detached anchor; isAtBottom
threshold transitions stay correct against the resize-refreshed cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ffer

Any thread message containing an immediate change kind (status-changed,
history-rewritten, environment-changed, tabs-changed) flushed the entire
buffered invalidation state, including every debounced events-appended
timeline invalidation for every thread. The queued-message send path
publishes [events-appended, queue-changed, status-changed] as one
message, so during streaming the 50/200ms coalescing window collapsed
exactly when it mattered, and one thread's status flip flushed every
other streaming thread's timeline invalidations with it.

Partition each message by the registry's flush priority: immediate kinds
run their dirty handlers synchronously against that message alone, and
debounced kinds stay in the scheduler. Turn completion is exempt: when a
message's eventTypes include turn/completed, every kind is recorded and
the buffer flushes at once (the old path), because the lifecycle publish
bundles the final events-appended with the status flip and splitting
them would re-enable the composer up to a debounce window before the
final assistant text renders; a completed stream needs no coalescing
protection. Hidden-document deferral is unchanged.

The immediate path routes message metadata through
mergeThreadChangeMetadata so its context (including #2169's statusChange
row snapshot) cannot drift from the buffered flush path, and passes
metadata only when the message carries a thread id: an id-less global
message runs its handlers with undefined metadata exactly like the
flush's global path, so a stray projectId cannot narrow
dirtyActiveThreadListQueries to one project.

Behavior change: only mid-stream bundles are partitioned, so a status
flip applies immediately while the timeline invalidation keeps its
coalescing window (up to 50ms); the turn-completion supersede stays
atomic. The "once per flush" search test now drives its two completions
through the hidden-document merge, the one place completions still
coalesce into a single flush.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nto the bottom

Follow-up to #2280 (cached max scroll offset). The PR guards the attach->detach
edge with one fresh read, but not its mirror: a detached viewport that a content
shrink (collapsing a long tool output near the end) clamps onto the new, smaller
maximum. The browser delivers that clamp's scroll event before the ResizeObserver
refresh, so the scroll handler classifies it against the stale, larger cache and
keeps the viewport detached; nothing re-tests it afterwards. Before the cache, the
live read re-attached on that scroll event, so streaming content kept following
the bottom.

Detect the case in the ResizeObserver path instead: the cache was authoritative,
the max offset shrank, the viewport is detached, and it now sits within the bottom
threshold. Re-attach with the same state flip the scroll handler uses (extracted
as attachToBottom) and record the at-bottom anchor. A shrink that does not reach
the viewport leaves it alone.

Regression test fails on the #2280 head and passes here; a second test covers the
non-clamping shrink.

Co-Authored-By: Vedran Burojevic <vedran.burojevic@gmail.com>
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.

2 participants