Commit aa6ca85
fix(agents-mobile): eliminate chat timeline flashing and fix auto-scroll (#4601)
## 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**:
- A **native composer + queue drawer**
(`agents-mobile/src/screens/SessionScreen.tsx`).
- A **chat timeline rendered inside an Expo DOM (`'use dom'`) WebView
embed** (`agents-server-ui/src/embed/SessionChatLogDomEmbed.tsx` →
`EmbedApp.tsx` → `ChatView.tsx`'s `ChatLogView` → `EntityTimeline.tsx`),
hosted by `agents-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:
- The WebView `source` is derived from the component's file path only,
never from props, so a prop change never reloads the page; there's no
`key` change either, so no remount.
- Prop updates are sent as a message: the native wrapper
`injectJavaScript`s a `$$props` payload and the page-side runtime
handles it with a plain `setProps(...)` state update on a root created
once. So a prop update is mechanically just a `setState` inside the
WebView.
- **But the payload is JSON — re-serialized and re-deserialized — so
every data prop arrives as a brand-new reference on every update.** The
page renders `<App {...freshlyDeserializedProps} {...actions} />`,
busting every downstream `useMemo` / `React.memo` / `useCallback` that
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.
- The native wrapper only emits `$$props` when 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 `$$props` update
(a changed/fresh prop reference) or re-rendered the embed*:
1. **Drawer flash on send** — the optimistic message was tracked via
`entity.status === 'running'` and pruned from inline tracking while
still `pending`, so it briefly fell into the queue drawer.
2. **Composer-resize flash** — the composer height re-renders the host
route on every multiline/attachment change, re-rendering the
(non-memoized) embed.
3. **Inset prop flash** — the bottom inset was passed as a live prop
that changed on every resize.
4. **Queued-send flash** — `scrollToBottomSignal` and
`inlineQueuedMessages` were live props that changed on send.
5. **Auto-scroll bug** — the pin-to-bottom `ResizeObserver` watched 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.**
- **Memoize** both DOM embeds and keep every native-passed prop
reference-stable (memoized `style`/`dom`, value-memoized
`serverHeaders`, stable callbacks, frozen initial inset).
- **Deliver dynamic values imperatively** via a `useDOMImperativeHandle`
ref (`SessionChatLogDomRef`):
- `setBottomInset(px)` → direct CSS-var write
(`--mobile-chat-bottom-inset`), no React.
- `scrollToBottom()` → direct `scrollTop`, no React.
- `setInlineQueuedMessages(messages)` → updates the embed's **internal**
`useState`. 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.
- The imperative handle registers a beat after the WebView boots, so the
native side pushes via a small `usePushToEmbed` hook that retries on
animation frames until the handle exists.
- Fix the drawer flash by keying off `generationActive` (matching
desktop) and keeping a message in inline tracking until it actually
leaves the pending inbox.
- Fix auto-scroll by observing the **border-box** in the pin-to-bottom
`ResizeObserver`, plus a synchronous `scrollTop` correction before the
rAF re-pin (ResizeObserver fires before paint, avoiding one misaligned
frame).
## Key decisions & trade-offs
- **Internal state for inline queued messages re-renders the embed
root.** That's fine *only because* `onNavigatePathname` is
`useCallback`'d — the embed's router is `useMemo`'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.
- **`SessionChatLogDomRef` lives in a plain `.ts` module**, not the
`'use dom'` embed file: named exports from `'use dom'` files aren't
resolvable by the consuming app (single-default-export only).
- **`expo/dom` ambient shim**
(`agents-server-ui/src/types/expo-dom.d.ts`) + an **inline
`DOMImperativeFactory` index signature** in `sessionChatLogDomRef.ts`:
agents-server-ui doesn't depend on expo (the embed is only bundled by
the mobile app), so `expo/dom` isn't resolvable there. This is
deliberate — a previous attempt to `import`/`extends` from `expo/dom`
failed mobile tsc with "Cannot find module 'expo/dom'". **Do not
"simplify" this back to importing from `expo/dom`.**
### Deliberately NOT done (so we don't go in circles)
- **Do not pass the inset/queued messages as live props** "since the
embed is memoized." `React.memo` re-renders when a prop *value* changes
— live props reintroduce the flash. The frozen-initial +
imperative-update split is intentional.
- **Do not freeze `serverHeaders` with `useMemo(..., [])`.** It must
update if auth headers refresh (token rotation); hence the
`JSON.stringify` memo key (stable identity, updates on real change). The
stringify is on a tiny object — negligible.
- **Did not unify the three imperative methods into one generic
`updateState` channel** 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 shared `usePushToEmbed`
hook instead.
- **Did not push the `ResizeObserver` behavior behind a prop / create a
`ChatLogViewMobile` wrapper.** `ChatLogView` is already the
mobile-embed-only view, and border-box + sync-scroll are
harmless/general for desktop.
- **Did not extract the inline-pending projection shared by
`ChatLogView` and `SessionScreen`** — 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;
`usePushToEmbed` hook + imperative inset/queued-message delivery;
imperative `scrollToBottom` on send; `async openSession`.
- `agents-mobile/src/screens/SessionScreen.tsx` — `generationActive`
gating; 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; stable `onNavigatePathname`.
- `agents-server-ui/src/embed/sessionChatLogDomRef.ts` *(new)* — shared
imperative-handle contract + rationale.
- `agents-server-ui/src/types/expo-dom.d.ts` *(new)* — ambient
`expo/dom` shim for agents-server-ui's tsc.
- `agents-server-ui/src/embed/EmbedApp.tsx` — inset var seeded on
document root; removed dead `scrollToBottomSignal` plumbing.
- `agents-server-ui/src/components/views/ChatView.tsx` — `ChatLogView`
uses `generationActive`/`entityStopped`; dropped `scrollToBottomSignal`
from `cacheKey` and 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-mobile` and `agents-server-ui` typecheck and lint clean.
## Rebase notes
Rebased onto `main` after the **"fork subtree"** mobile feature merged
(it touched the same files). Integration points to be aware of:
- `session.tsx` now passes `onRequestForkEntity` to the embed. It is
wrapped in a **`useCallback` (`handleForkEntity`)** rather than the
inline closure main used — an inline function would be an unstable prop
and defeat the embed's `memo` (reintroducing the flash). Keep it stable.
- `EmbedApp.tsx` merged cleanly: main's `ToastProvider` +
`forkEntityImpl` wiring coexists with this PR's removed
`scrollToBottomSignal`/inline-inset-style and dropped `CSSProperties`
import.
- `SessionScreen.tsx` auto-merged: main's fork state/menu wiring is
independent of this PR's `generationActive` gating + inline-prune guard.
## Status / follow-ups
- Draft. No automated test covers the flash behavior (it's a
WebView-layer visual artifact); verification is manual.
- The simplification pass (reuse/efficiency/altitude/comment review) is
already applied — the diff is intended to be the final, consolidated
form, not a stack of patches.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>1 parent 214e644 commit aa6ca85
9 files changed
Lines changed: 229 additions & 60 deletions
File tree
- .changeset
- packages
- agents-mobile
- app
- src/screens
- agents-server-ui/src
- components
- views
- embed
- types
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
2 | 11 | | |
3 | 12 | | |
4 | 13 | | |
| |||
23 | 32 | | |
24 | 33 | | |
25 | 34 | | |
| 35 | + | |
26 | 36 | | |
27 | 37 | | |
28 | 38 | | |
| |||
33 | 43 | | |
34 | 44 | | |
35 | 45 | | |
36 | | - | |
37 | | - | |
38 | 46 | | |
39 | 47 | | |
40 | 48 | | |
| |||
45 | 53 | | |
46 | 54 | | |
47 | 55 | | |
| 56 | + | |
48 | 57 | | |
49 | 58 | | |
50 | 59 | | |
51 | | - | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
52 | 65 | | |
53 | | - | |
| 66 | + | |
| 67 | + | |
54 | 68 | | |
| 69 | + | |
55 | 70 | | |
56 | 71 | | |
57 | 72 | | |
| |||
83 | 98 | | |
84 | 99 | | |
85 | 100 | | |
86 | | - | |
87 | 101 | | |
88 | 102 | | |
89 | 103 | | |
| |||
93 | 107 | | |
94 | 108 | | |
95 | 109 | | |
96 | | - | |
97 | | - | |
98 | | - | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
99 | 117 | | |
100 | 118 | | |
101 | 119 | | |
| |||
117 | 135 | | |
118 | 136 | | |
119 | 137 | | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
120 | 160 | | |
121 | 161 | | |
122 | 162 | | |
123 | 163 | | |
124 | 164 | | |
125 | | - | |
126 | | - | |
127 | | - | |
128 | | - | |
129 | | - | |
130 | | - | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
131 | 174 | | |
132 | 175 | | |
133 | 176 | | |
| |||
149 | 192 | | |
150 | 193 | | |
151 | 194 | | |
152 | | - | |
| 195 | + | |
| 196 | + | |
153 | 197 | | |
154 | 198 | | |
155 | 199 | | |
156 | 200 | | |
157 | | - | |
158 | | - | |
159 | | - | |
| 201 | + | |
160 | 202 | | |
161 | | - | |
162 | | - | |
163 | | - | |
164 | | - | |
165 | | - | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
166 | 206 | | |
167 | 207 | | |
168 | 208 | | |
169 | | - | |
| 209 | + | |
170 | 210 | | |
171 | 211 | | |
172 | 212 | | |
173 | 213 | | |
174 | 214 | | |
175 | | - | |
176 | | - | |
| 215 | + | |
| 216 | + | |
177 | 217 | | |
178 | 218 | | |
179 | 219 | | |
| |||
186 | 226 | | |
187 | 227 | | |
188 | 228 | | |
189 | | - | |
| 229 | + | |
190 | 230 | | |
191 | 231 | | |
192 | 232 | | |
| |||
228 | 268 | | |
229 | 269 | | |
230 | 270 | | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
231 | 298 | | |
232 | 299 | | |
233 | 300 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
241 | 241 | | |
242 | 242 | | |
243 | 243 | | |
244 | | - | |
| 244 | + | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
251 | | - | |
| 251 | + | |
252 | 252 | | |
253 | 253 | | |
254 | 254 | | |
| |||
262 | 262 | | |
263 | 263 | | |
264 | 264 | | |
265 | | - | |
| 265 | + | |
266 | 266 | | |
267 | 267 | | |
268 | 268 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
300 | 303 | | |
301 | 304 | | |
302 | 305 | | |
| |||
306 | 309 | | |
307 | 310 | | |
308 | 311 | | |
309 | | - | |
| 312 | + | |
310 | 313 | | |
311 | 314 | | |
312 | 315 | | |
| |||
534 | 537 | | |
535 | 538 | | |
536 | 539 | | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
537 | 544 | | |
538 | 545 | | |
539 | 546 | | |
| |||
673 | 680 | | |
674 | 681 | | |
675 | 682 | | |
| 683 | + | |
676 | 684 | | |
677 | 685 | | |
678 | 686 | | |
| |||
692 | 700 | | |
693 | 701 | | |
694 | 702 | | |
| 703 | + | |
695 | 704 | | |
696 | 705 | | |
697 | 706 | | |
| |||
853 | 862 | | |
854 | 863 | | |
855 | 864 | | |
| 865 | + | |
856 | 866 | | |
857 | 867 | | |
858 | 868 | | |
| |||
Lines changed: 7 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1956 | 1956 | | |
1957 | 1957 | | |
1958 | 1958 | | |
| 1959 | + | |
| 1960 | + | |
| 1961 | + | |
| 1962 | + | |
1959 | 1963 | | |
1960 | 1964 | | |
1961 | 1965 | | |
| |||
1964 | 1968 | | |
1965 | 1969 | | |
1966 | 1970 | | |
1967 | | - | |
| 1971 | + | |
| 1972 | + | |
| 1973 | + | |
1968 | 1974 | | |
1969 | 1975 | | |
1970 | 1976 | | |
| |||
Lines changed: 13 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
77 | | - | |
78 | 77 | | |
79 | 78 | | |
80 | | - | |
81 | 79 | | |
82 | 80 | | |
83 | 81 | | |
84 | | - | |
85 | | - | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
86 | 91 | | |
87 | 92 | | |
88 | 93 | | |
| |||
97 | 102 | | |
98 | 103 | | |
99 | 104 | | |
100 | | - | |
| 105 | + | |
101 | 106 | | |
102 | 107 | | |
103 | 108 | | |
104 | 109 | | |
105 | 110 | | |
106 | 111 | | |
107 | | - | |
| 112 | + | |
| 113 | + | |
108 | 114 | | |
109 | 115 | | |
110 | 116 | | |
| |||
140 | 146 | | |
141 | 147 | | |
142 | 148 | | |
143 | | - | |
| 149 | + | |
144 | 150 | | |
145 | 151 | | |
146 | 152 | | |
147 | | - | |
148 | 153 | | |
149 | 154 | | |
150 | 155 | | |
| |||
0 commit comments