Skip to content

Commit aa6ca85

Browse files
msfstefclaude
andauthored
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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@electric-ax/agents-mobile': patch
3+
'@electric-ax/agents-server-ui': patch
4+
---
5+
6+
Fix mobile chat timeline flashing when sending messages, resizing the composer (multiline/attachments) and queuing messages, and fix inconsistent auto-scroll as the composer grows. The timeline WebView embed now receives dynamic updates (bottom inset, inline queued messages, scroll) imperatively instead of via props that re-render and flash it. Also fix the composer occasionally not clearing after send when typing quickly.

packages/agents-mobile/app/session.tsx

Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { useEffect, useMemo, useState, type ComponentType } from 'react'
1+
import {
2+
memo,
3+
useCallback,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
type ComponentType,
9+
type Ref,
10+
} from 'react'
211
import {
312
Keyboard,
413
Platform,
@@ -23,6 +32,7 @@ import {
2332
} from '../src/screens/SessionScreen'
2433
import type { EmbedViewId } from '../src/lib/embedView'
2534
import SessionChatLogDomEmbedModule from '@electric-ax/agents-server-ui/src/embed/SessionChatLogDomEmbed'
35+
import type { SessionChatLogDomRef } from '@electric-ax/agents-server-ui/src/embed/sessionChatLogDomRef'
2636
import SessionStateInspectorDomEmbedModule from '@electric-ax/agents-server-ui/src/embed/SessionStateInspectorDomEmbed'
2737
import { getActiveServerHeadersSnapshot } from '@electric-ax/agents-server-ui/src/lib/auth-fetch'
2838
import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage'
@@ -33,8 +43,6 @@ type SessionDomEmbedProps = {
3343
serverUrl: string
3444
entityUrl: string
3545
theme: `light` | `dark`
36-
scrollToBottomSignal?: number
37-
inlineQueuedMessages?: Array<OptimisticInboxMessage>
3846
bottomInset?: number
3947
onRequestOpenEntity: (entityUrl: string) => Promise<void>
4048
// Marshalled so the per-message fork runs over native networking.
@@ -45,13 +53,20 @@ type SessionDomEmbedProps = {
4553
style?: StyleProp<ViewStyle>
4654
matchContents?: boolean
4755
serverHeaders?: { url: string; headers: Record<string, string> } | null
56+
ref?: Ref<SessionChatLogDomRef>
4857
dom?: unknown
4958
}
5059

51-
const SessionChatLogDomEmbed =
60+
// The timeline is an Expo DOM (WebView) embed that re-posts — and visibly
61+
// flickers — on any prop change or re-render. So we memo it, keep every prop
62+
// below reference-stable, and push the values that actually change (bottom
63+
// inset, queued messages, scroll) imperatively via the ref, not as props.
64+
const SessionChatLogDomEmbed = memo(
5265
SessionChatLogDomEmbedModule as ComponentType<SessionDomEmbedProps>
53-
const SessionStateInspectorDomEmbed =
66+
)
67+
const SessionStateInspectorDomEmbed = memo(
5468
SessionStateInspectorDomEmbedModule as ComponentType<SessionDomEmbedProps>
69+
)
5570

5671
export default function SessionRoute(): React.ReactElement | null {
5772
const params = useLocalSearchParams<{
@@ -83,7 +98,6 @@ function SessionRouteInner({
8398
const [chatComposerHeight, setChatComposerHeight] = useState(
8499
CHAT_COMPOSER_BASE_HEIGHT + insets.bottom
85100
)
86-
const [chatLogScrollSignal, setChatLogScrollSignal] = useState(0)
87101
const [inlineQueuedMessages, setInlineQueuedMessages] = useState<
88102
Array<OptimisticInboxMessage>
89103
>([])
@@ -93,9 +107,13 @@ function SessionRouteInner({
93107
: (params.entityUrl ?? ``)
94108
const view = params.view === `state-explorer` ? `state-explorer` : `chat`
95109

96-
// Read once per render — the DOM embed receives this as a prop and
97-
// re-registers it on its side of the JS-context boundary.
98-
const serverHeaders = getActiveServerHeadersSnapshot()
110+
// A fresh object each call, so memo on its serialized value: stable identity
111+
// across renders, but still updates if the auth headers actually change.
112+
const serverHeadersSnapshot = getActiveServerHeadersSnapshot()
113+
const serverHeadersKey = serverHeadersSnapshot
114+
? JSON.stringify(serverHeadersSnapshot)
115+
: ``
116+
const serverHeaders = useMemo(() => serverHeadersSnapshot, [serverHeadersKey])
99117

100118
const embedTop = insets.top + HEADER_HEIGHT
101119
const composerInset =
@@ -117,17 +135,42 @@ function SessionRouteInner({
117135
}),
118136
[embedFrame.height, embedFrame.width]
119137
)
138+
const embedStyle = useMemo(() => [styles.domEmbedWeb, embedSize], [embedSize])
139+
const embedDom = useMemo(
140+
() => domOptions(styles, embedSize, tokens.bg),
141+
[embedSize, tokens.bg]
142+
)
143+
// The inset is also seeded once as a prop so the first paint is correct before
144+
// the imperative handle (which registers after boot) takes over.
145+
const chatLogRef = useRef<SessionChatLogDomRef>(null)
146+
const initialComposerInsetRef = useRef(composerInset)
147+
usePushToEmbed(chatLogRef, `setBottomInset`, composerInset)
148+
usePushToEmbed(chatLogRef, `setInlineQueuedMessages`, inlineQueuedMessages)
149+
const handleSend = useCallback((): void => {
150+
// `?.()` guards the method too: the handle may not be registered yet.
151+
chatLogRef.current?.scrollToBottom?.()
152+
}, [])
153+
// Reference-stable so it doesn't break the embed's memo (forkEntity itself is
154+
// stable from the provider); marshals the fork over native networking.
155+
const handleForkEntity = useCallback(
156+
(targetUrl: string, opts?: { pointer?: ForkPointer }) =>
157+
forkEntity({ entityUrl: targetUrl, pointer: opts?.pointer }),
158+
[forkEntity]
159+
)
120160

121161
const goBack = (): void => {
122162
if (router.canGoBack()) router.back()
123163
else router.replace(`/`)
124164
}
125-
const openSession = (target: string): void => {
126-
router.push({
127-
pathname: `/session`,
128-
params: { entityUrl: target, view: `chat` },
129-
})
130-
}
165+
const openSession = useCallback(
166+
async (target: string): Promise<void> => {
167+
router.push({
168+
pathname: `/session`,
169+
params: { entityUrl: target, view: `chat` },
170+
})
171+
},
172+
[router]
173+
)
131174
const openShare = (): void => {
132175
router.push({ pathname: `/session-share`, params: { entityUrl } })
133176
}
@@ -149,31 +192,28 @@ function SessionRouteInner({
149192
>
150193
{view === `chat` ? (
151194
<SessionChatLogDomEmbed
152-
style={[styles.domEmbedWeb, embedSize]}
195+
ref={chatLogRef}
196+
style={embedStyle}
153197
matchContents={false}
154198
serverUrl={serverUrl}
155199
entityUrl={entityUrl}
156200
theme={scheme}
157-
scrollToBottomSignal={chatLogScrollSignal}
158-
inlineQueuedMessages={inlineQueuedMessages}
159-
bottomInset={composerInset}
201+
bottomInset={initialComposerInsetRef.current}
160202
serverHeaders={serverHeaders}
161-
onRequestOpenEntity={async (target) => openSession(target)}
162-
onRequestForkEntity={(targetUrl, opts) =>
163-
forkEntity({ entityUrl: targetUrl, pointer: opts?.pointer })
164-
}
165-
dom={domOptions(styles, embedSize, tokens.bg)}
203+
onRequestOpenEntity={openSession}
204+
onRequestForkEntity={handleForkEntity}
205+
dom={embedDom}
166206
/>
167207
) : (
168208
<SessionStateInspectorDomEmbed
169-
style={[styles.domEmbedWeb, embedSize]}
209+
style={embedStyle}
170210
matchContents={false}
171211
serverUrl={serverUrl}
172212
entityUrl={entityUrl}
173213
theme={scheme}
174214
serverHeaders={serverHeaders}
175-
onRequestOpenEntity={async (target) => openSession(target)}
176-
dom={domOptions(styles, embedSize, tokens.bg)}
215+
onRequestOpenEntity={openSession}
216+
dom={embedDom}
177217
/>
178218
)}
179219
</View>
@@ -186,7 +226,7 @@ function SessionRouteInner({
186226
onOpenEntity={openSession}
187227
onOpenStateSource={openStateSource}
188228
onComposerHeightChange={setChatComposerHeight}
189-
onSendMessage={() => setChatLogScrollSignal(Date.now())}
229+
onSendMessage={handleSend}
190230
onInlineQueuedMessagesChange={setInlineQueuedMessages}
191231
onShare={openShare}
192232
/>
@@ -228,6 +268,33 @@ function domOptions(
228268
}
229269
}
230270

271+
// Push a value into the chat-log embed's imperative handle, retrying on
272+
// animation frames until the method exists. `ref.current` becomes a truthy
273+
// proxy a beat BEFORE `useDOMImperativeHandle` marshals its methods in, so we
274+
// must check the method itself — a truthy `ref.current` is not enough.
275+
function usePushToEmbed(
276+
ref: { current: SessionChatLogDomRef | null },
277+
method: `setBottomInset` | `setInlineQueuedMessages`,
278+
value: unknown
279+
): void {
280+
useEffect(() => {
281+
let frame = 0
282+
let attempts = 0
283+
const apply = (): void => {
284+
const fn = ref.current?.[method]
285+
if (typeof fn === `function`) {
286+
fn(value)
287+
return
288+
}
289+
if (attempts++ < 120) frame = requestAnimationFrame(apply)
290+
}
291+
apply()
292+
return () => {
293+
if (frame) cancelAnimationFrame(frame)
294+
}
295+
}, [ref, method, value])
296+
}
297+
231298
function useKeyboardBottomInset(windowHeight: number): number {
232299
const [keyboardInset, setKeyboardInset] = useState(0)
233300

packages/agents-mobile/src/screens/SessionScreen.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,14 +241,14 @@ export function SessionScreen({
241241
[pendingInbox]
242242
)
243243
const projectedPendingMessage = useMemo(() => {
244-
if (entity?.status === `running`) return null
244+
if (generationActive) return null
245245
for (const [key, message] of inlineQueuedMessages) {
246246
if (processedInboxKeys.has(key)) continue
247247
return pendingInboxByKey.get(key) ?? message
248248
}
249249
return null
250250
}, [
251-
entity?.status,
251+
generationActive,
252252
inlineQueuedMessages,
253253
pendingInboxByKey,
254254
processedInboxKeys,
@@ -262,7 +262,7 @@ export function SessionScreen({
262262
[pendingInbox, projectedPendingMessage]
263263
)
264264
const inlineQueuedSubmits =
265-
entity?.status !== `running` &&
265+
!generationActive &&
266266
pendingInbox.length === 0 &&
267267
inlineQueuedMessages.size === 0
268268

@@ -297,6 +297,9 @@ export function SessionScreen({
297297
let next: Map<string, OptimisticInboxMessage> | null = null
298298
for (const key of current.keys()) {
299299
if (!processedInboxKeys.has(key)) continue
300+
// Still pending though in the timeline — keep tracking inline until it
301+
// leaves the pending inbox, else it flashes back into the queue drawer.
302+
if (pendingInboxByKey.has(key)) continue
300303
next ??= new Map(current)
301304
next.delete(key)
302305
inlineQueuedKeysRef.current.delete(key)
@@ -306,7 +309,7 @@ export function SessionScreen({
306309
}
307310
return next ?? current
308311
})
309-
}, [inlineQueuedMessages.size, processedInboxKeys])
312+
}, [inlineQueuedMessages.size, processedInboxKeys, pendingInboxByKey])
310313

311314
useEffect(
312315
() => () => {
@@ -534,6 +537,10 @@ function NativeMessageComposer({
534537
const styles = useMemo(() => createComposerStyles(tokens), [tokens])
535538
const { keyboardVisible, keyboardTranslateY } = useKeyboardAttachment()
536539
const [value, setValue] = useState(``)
540+
// Cleared imperatively on send: the input is controlled via children (for
541+
// highlighting), not `value`, so a `setValue('')` while typing fast can be
542+
// rejected by RN as stale (older event count) and leave the sent text behind.
543+
const inputRef = useRef<TextInput>(null)
537544
const [sending, setSending] = useState(false)
538545
const [error, setError] = useState<string | null>(null)
539546
const [editingMessage, setEditingMessage] = useState<{
@@ -673,6 +680,7 @@ function NativeMessageComposer({
673680
}
674681

675682
setValue(``)
683+
inputRef.current?.clear()
676684
setPendingSelection(null)
677685
slash.reset()
678686
setEditingMessage(null)
@@ -692,6 +700,7 @@ function NativeMessageComposer({
692700
}
693701

694702
setValue(``)
703+
inputRef.current?.clear()
695704
setPendingSelection(null)
696705
slash.reset()
697706
setEditingMessage(null)
@@ -853,6 +862,7 @@ function NativeMessageComposer({
853862
/>
854863
)}
855864
<TextInput
865+
ref={inputRef}
856866
onChangeText={setValue}
857867
onSelectionChange={(event) => {
858868
slash.onSelectionChange(event)

packages/agents-server-ui/src/components/EntityTimeline.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1956,6 +1956,10 @@ export function EntityTimeline({
19561956
let frame: ReturnType<typeof requestAnimationFrame> | null = null
19571957
const pinToBottom = () => {
19581958
if (!isNearBottom.current) return
1959+
// Scroll synchronously (ResizeObserver fires before paint) so a growing
1960+
// inset doesn't paint one misaligned frame; the rAF re-pin then settles
1961+
// the virtualizer for new rows.
1962+
viewport.scrollTop = viewport.scrollHeight
19591963
if (frame !== null) cancelAnimationFrame(frame)
19601964
frame = requestAnimationFrame(() => {
19611965
frame = null
@@ -1964,7 +1968,9 @@ export function EntityTimeline({
19641968
}
19651969

19661970
const observer = new ResizeObserver(pinToBottom)
1967-
observer.observe(contentElement)
1971+
// border-box, not content-box: the inset is bottom padding, which a
1972+
// content-box observation can't see — so composer-only growth would be missed.
1973+
observer.observe(contentElement, { box: `border-box` })
19681974
return () => {
19691975
observer.disconnect()
19701976
if (frame !== null) cancelAnimationFrame(frame)

packages/agents-server-ui/src/components/views/ChatView.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,20 @@ export function ChatLogView({
7474
entityStopped,
7575
isSpawning,
7676
tileId,
77-
scrollToBottomSignal,
7877
inlineQueuedMessages = [],
7978
}: ViewProps & {
80-
scrollToBottomSignal?: number
8179
inlineQueuedMessages?: Array<OptimisticInboxMessage>
8280
}): React.ReactElement {
8381
const connectUrl = isSpawning ? null : entityUrl
84-
const { timelineRows, pendingInbox, entities, db, loading, error } =
85-
useEntityTimeline(baseUrl || null, connectUrl)
82+
const {
83+
timelineRows,
84+
pendingInbox,
85+
entities,
86+
generationActive,
87+
db,
88+
loading,
89+
error,
90+
} = useEntityTimeline(baseUrl || null, connectUrl)
8691
const canFork = useEntityPermission(entity, `fork`)
8792
const navigate = useNavigate()
8893
const processedInboxKeys = useMemo(
@@ -97,14 +102,15 @@ export function ChatLogView({
97102
[pendingInbox]
98103
)
99104
const projectedPendingMessage = useMemo(() => {
100-
if (entity.status === `running`) return null
105+
if (entityStopped || generationActive) return null
101106
for (const message of inlineQueuedMessages) {
102107
if (processedInboxKeys.has(message.key)) continue
103108
return pendingInboxByKey.get(message.key) ?? message
104109
}
105110
return null
106111
}, [
107-
entity.status,
112+
entityStopped,
113+
generationActive,
108114
inlineQueuedMessages,
109115
pendingInboxByKey,
110116
processedInboxKeys,
@@ -140,11 +146,10 @@ export function ChatLogView({
140146
error={error}
141147
entityStopped={entityStopped}
142148
baseUrl={baseUrl}
143-
cacheKey={`${baseUrl}${connectUrl ?? ``}:${scrollToBottomSignal ?? 0}`}
149+
cacheKey={`${baseUrl}${connectUrl ?? ``}`}
144150
tileId={tileId}
145151
entityUrl={connectUrl}
146152
entities={entities}
147-
scrollToBottomSignal={scrollToBottomSignal}
148153
forkFromHereByRunKey={forkFromHereByRunKey}
149154
/>
150155
)

0 commit comments

Comments
 (0)