fix(agents-mobile): eliminate chat timeline flashing and fix auto-scroll#4601
Conversation
✅ Deploy Preview for electric-next ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
4e060c3 to
e528ac9
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4601 +/- ##
==========================================
+ Coverage 57.43% 58.98% +1.55%
==========================================
Files 342 385 +43
Lines 39928 42661 +2733
Branches 11597 12215 +618
==========================================
+ Hits 22931 25162 +2231
- Misses 16960 17424 +464
- Partials 37 75 +38
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Electric Agents Mobile BuildLocal mobile checks ran for commit The EAS Android preview build was skipped because the |
Claude Code ReviewSummaryThis PR eliminates WebView "flash" in the agents-mobile chat timeline by moving every frequently-changing value off the Expo DOM prop bridge and onto an imperative handle ( Iteration 4 noteSince iteration 3 the only change is commit The composer
Both What's Working Well
Issues FoundCritical (Must Fix)None. Important (Should Fix)None. Suggestions (Nice to Have)
Issue ConformanceNo linked issue (per Previous Review StatusIterations 1-3 found no Critical or Important issues; all optional suggestions were acknowledged by the author. Iteration 4's single commit is a correct, well-documented composer-clear fix that introduces no new issues. Review iteration: 4 | 2026-06-17 |
The chat timeline is an Expo DOM (WebView) embed that re-posts — and visibly flashes — on any prop change or re-render. Sending a message, resizing the composer (multiline/attachments) and queuing messages all mutated props on the embed and flashed it; the optimistic message also briefly surfaced in the queue drawer. - Memoize the embed and keep all native-passed props reference-stable. - Deliver dynamic values (bottom inset, inline queued messages, scroll) imperatively via a useDOMImperativeHandle ref instead of as props, so they update the inner React tree without re-posting across the bridge. - Fix the drawer flash by keying off generationActive and not dropping a still-pending optimistic message from inline tracking. - Pin-to-bottom now observes the content border-box so a padding-only inset growth (multiline) reliably scrolls the timeline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e528ac9 to
769298c
Compare
|
Thanks for the thorough review. Responding to the three optional suggestions — no code changes this round, with reasoning below (happy to act on #1 if we want to close the edge): 1. Asymmetric 2. 3. |
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2026-06-17.at.13.33.40.mov |
The chat-log embed's imperative handle registers a beat after the WebView boots: `ref.current` becomes a truthy proxy before `useDOMImperativeHandle` marshals its methods in. The `usePushToEmbed` guard only checked `ref.current`, so a push during that window threw `setBottomInset is not a function`. Check the specific method (`typeof fn === 'function'`) and retry on animation frames until it registers; guard `scrollToBottom` with `?.()` for the same reason. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The composer TextInput is controlled via children (for slash-command
highlighting), not the `value` prop. RN gates text reconciliation on an event
count and rejects JS updates that are stale relative to native keystrokes, so a
`setValue('')` on send while typing fast was dropped — the sent text lingered
in the box.
Clear the field via a ref (`inputRef.current?.clear()`), an imperative command
that bypasses the event-count gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Intent
Eliminate the visible "flashing" of the agents-mobile chat timeline during normal use — sending a message, growing the composer (multiline / attachments), and queuing messages — while preserving the dynamic bottom inset, instant optimistic sending, and reliable pin-to-bottom. Also fix inconsistent auto-scroll when the composer grows.
Architecture context (read this first)
The mobile chat screen is two separate UIs:
agents-mobile/src/screens/SessionScreen.tsx).'use dom') WebView embed (agents-server-ui/src/embed/SessionChatLogDomEmbed.tsx→EmbedApp.tsx→ChatView.tsx'sChatLogView→EntityTimeline.tsx), hosted byagents-mobile/app/session.tsx.These run in separate JS contexts. The native side and the embed communicate via props (serialized across the Expo DOM bridge) and an imperative handle.
Root cause (the key finding)
A value delivered over the Expo DOM prop bridge forces a full, memoization-busting re-render of the embed's React tree every time it changes — and with a tree as heavy as the timeline, that re-render is the visible "flash."
Confirmed against the Expo SDK 54 source (
expo/src/dom/webview-wrapper.tsx+dom-entry.tsx) — it is not a native WebView reload or remount:sourceis derived from the component's file path only, never from props, so a prop change never reloads the page; there's nokeychange either, so no remount.injectJavaScripts a$$propspayload and the page-side runtime handles it with a plainsetProps(...)state update on a root created once. So a prop update is mechanically just asetStateinside the WebView.<App {...freshlyDeserializedProps} {...actions} />, busting every downstreamuseMemo/React.memo/useCallbackthat depends on a prop. Nothing memo-skips, so the whole heavy subtree re-does its work (virtualized-list measurement, markdown rendering, CSS masks) → a dropped frame = the flash.$$propswhen its marshalled props change identity. So a parent re-render that passes new inline references (style={[...]},dom={...}, inline callbacks) ships a fresh-reference update and flashes too — which is why the earlier "froze the CSS / native no-op" tests still flashed until the embed stopped re-rendering. It was never a true no-op; the references were changing.You cannot make a bridge-crossed prop update cheap (the fresh references are unavoidable). The only lever is to not send frequently-changing values as props at all — keep props referentially stable so the native side emits nothing, and push dynamic updates through the imperative handle (which never crosses the bridge).
So every flash traced back to something that emitted a
$$propsupdate (a changed/fresh prop reference) or re-rendered the embed:entity.status === 'running'and pruned from inline tracking while stillpending, so it briefly fell into the queue drawer.scrollToBottomSignalandinlineQueuedMessageswere live props that changed on send.ResizeObserverwatched the content-box, but the inset is applied as bottom padding; a padding-only change leaves the content-box untouched, so multiline growth didn't trigger a re-pin (it only scrolled when content also changed — hence intermittent).Approach
Nothing that changes frequently crosses the bridge as a prop.
style/dom, value-memoizedserverHeaders, stable callbacks, frozen initial inset).useDOMImperativeHandleref (SessionChatLogDomRef):setBottomInset(px)→ direct CSS-var write (--mobile-chat-bottom-inset), no React.scrollToBottom()→ directscrollTop, no React.setInlineQueuedMessages(messages)→ updates the embed's internaluseState. The render still cascades, but every surrounding reference stays stable, so memoized subtrees (timeline, markdown, router) are skipped — cheap, no flash — unlike a bridge prop update where all references are fresh.usePushToEmbedhook that retries on animation frames until the handle exists.generationActive(matching desktop) and keeping a message in inline tracking until it actually leaves the pending inbox.ResizeObserver, plus a synchronousscrollTopcorrection before the rAF re-pin (ResizeObserver fires before paint, avoiding one misaligned frame).Key decisions & trade-offs
onNavigatePathnameisuseCallback'd — the embed's router isuseMemo'd on it, and an unstable identity would remount (and flash) the timeline. Props are unchanged on an internal re-render, so the callback keeps its identity.SessionChatLogDomReflives in a plain.tsmodule, not the'use dom'embed file: named exports from'use dom'files aren't resolvable by the consuming app (single-default-export only).expo/domambient shim (agents-server-ui/src/types/expo-dom.d.ts) + an inlineDOMImperativeFactoryindex signature insessionChatLogDomRef.ts: agents-server-ui doesn't depend on expo (the embed is only bundled by the mobile app), soexpo/domisn't resolvable there. This is deliberate — a previous attempt toimport/extendsfromexpo/domfailed mobile tsc with "Cannot find module 'expo/dom'". Do not "simplify" this back to importing fromexpo/dom.Deliberately NOT done (so we don't go in circles)
React.memore-renders when a prop value changes — live props reintroduce the flash. The frozen-initial + imperative-update split is intentional.serverHeaderswithuseMemo(..., []). It must update if auth headers refresh (token rotation); hence theJSON.stringifymemo key (stable identity, updates on real change). The stringify is on a tiny object — negligible.updateStatechannel or replace the rAF retry with a "ready" event — Expo exposes no handle-ready signal, and three distinct semantic methods read clearer. Duplication was removed via the sharedusePushToEmbedhook instead.ResizeObserverbehavior behind a prop / create aChatLogViewMobilewrapper.ChatLogViewis already the mobile-embed-only view, and border-box + sync-scroll are harmless/general for desktop.ChatLogViewandSessionScreen— it's pre-existing, cross-package, and array-vs-map; extracting would over-abstract.Files changed
agents-mobile/app/session.tsx— memoize embeds; stabilize props;usePushToEmbedhook + imperative inset/queued-message delivery; imperativescrollToBottomon send;async openSession.agents-mobile/src/screens/SessionScreen.tsx—generationActivegating; inline-tracking prune guard (keep while still pending).agents-server-ui/src/embed/SessionChatLogDomEmbed.tsx— imperative handle (setBottomInset/scrollToBottom/setInlineQueuedMessages); internal state for queued messages; stableonNavigatePathname.agents-server-ui/src/embed/sessionChatLogDomRef.ts(new) — shared imperative-handle contract + rationale.agents-server-ui/src/types/expo-dom.d.ts(new) — ambientexpo/domshim for agents-server-ui's tsc.agents-server-ui/src/embed/EmbedApp.tsx— inset var seeded on document root; removed deadscrollToBottomSignalplumbing.agents-server-ui/src/components/views/ChatView.tsx—ChatLogViewusesgenerationActive/entityStopped; droppedscrollToBottomSignalfromcacheKeyand props.agents-server-ui/src/components/EntityTimeline.tsx— pin-to-bottom observes border-box + synchronous scroll correction.Testing / how to verify
Manually verified on device by the author: no flash on send, multiline growth, attachments, queue add, or first message to an idle agent; auto-scroll follows composer growth; inset and instant optimistic send still work.
Important: webview-side files (
SessionChatLogDomEmbed,sessionChatLogDomRef,EmbedApp,ChatView,EntityTimeline) require a DOM-bundle rebuild to take effect; native files (session.tsx,SessionScreen.tsx) hot-reload.Both
agents-mobileandagents-server-uitypecheck and lint clean.Rebase notes
Rebased onto
mainafter the "fork subtree" mobile feature merged (it touched the same files). Integration points to be aware of:session.tsxnow passesonRequestForkEntityto the embed. It is wrapped in auseCallback(handleForkEntity) rather than the inline closure main used — an inline function would be an unstable prop and defeat the embed'smemo(reintroducing the flash). Keep it stable.EmbedApp.tsxmerged cleanly: main'sToastProvider+forkEntityImplwiring coexists with this PR's removedscrollToBottomSignal/inline-inset-style and droppedCSSPropertiesimport.SessionScreen.tsxauto-merged: main's fork state/menu wiring is independent of this PR'sgenerationActivegating + inline-prune guard.Status / follow-ups
🤖 Generated with Claude Code