Skip to content

Commit 84ca2a9

Browse files
yyq1025claude
andcommitted
chat: stick-to-bottom via maintainScrollAtEnd on the transcript list
Enable `maintainScrollAtEnd` so the streaming reply keeps the viewport pinned to the bottom as it grows. Bare = all triggers (dataChange/ itemLayout/layout), so it also follows late row-measure growth and inset/layout changes back to the end. Verified on device it coexists with anchoredEndSpace without fighting: maintainScrollAtEnd takes priority near the end (smooth, no jitter), while anchoredEndSpace still reserves the reply's initial display area below the anchored prompt. Moved it out of the "deliberately omitted" list and refreshed the surrounding scrolling-model comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 37c30ef commit 84ca2a9

1 file changed

Lines changed: 20 additions & 14 deletions

File tree

packages/app/src/components/transcript/chat-panel.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,10 @@ export function ChatPanel({
287287
// resting scroll manager (LegendList chat guide).
288288
freeze={freeze}
289289
// Reserve blank space below the just-sent user message for its turn so
290-
// the streaming response fills it without moving the anchor (fixes the
291-
// scroll overshoot). Undefined when idle → normal layout.
290+
// the agent's reply has a full initial display area to render into (the
291+
// prompt pinned near the top), instead of starting cramped above the
292+
// composer. maintainScrollAtEnd then follows the reply's growth to the
293+
// bottom. Undefined when idle → normal layout.
292294
anchoredEndSpace={
293295
anchorIndex >= 0
294296
? { anchorIndex, anchorOffset: headerHeight }
@@ -373,24 +375,20 @@ export function ChatPanel({
373375
// Without this the last message sits ~insets.bottom too low above
374376
// composer when the keyboard is shown.
375377
keyboardOffset={insets.bottom - 8}
376-
// ChatGPT-style scrolling, NOT Telegram-style (the upstream
377-
// AiChatExample idiom):
378+
// Scrolling model:
378379
// - initialScrollAtEnd: boot at the latest message on session
379380
// re-open. Runs a per-frame rAF ticker that retargets to true
380381
// end as items measure and the inset settles (LegendList
381382
// retargets the initial scroll after inset changes).
382-
// - During a turn, anchoredEndSpace absorbs streaming growth: the
383-
// reply fills the reserved space below the anchored prompt and
384-
// the viewport NEVER auto-follows. Once the reply outgrows the
385-
// viewport it streams below the fold; the scroll-to-end FAB
386-
// (driven by `isNearEnd`) is the user's way down.
383+
// - maintainScrollAtEnd (above): during a turn the viewport follows
384+
// the streaming reply to the bottom (stick-to-bottom). anchoredEnd-
385+
// Space reserves the reply's initial display area below the just-sent
386+
// prompt (prompt pinned near the top) so it renders into a full
387+
// viewport instead of cramped above the composer; maintainScrollAtEnd
388+
// then follows that growth down. The scroll-to-end FAB (`isNearEnd`)
389+
// is the way back down after the user scrolls up.
387390
//
388391
// DELIBERATELY OMITTED:
389-
// - `maintainScrollAtEnd`: auto-follow-the-stream. It defeats the
390-
// anchored pattern the moment the reply outgrows the reserved
391-
// space (the anchor scrolls away mid-read). The upstream AI chat
392-
// example never used it — it belongs to the Telegram-style
393-
// ChatExample only.
394392
// - `alignItemsAtEnd`: a Telegram/iMessage idiom (sparse messages
395393
// stick to the bottom of the viewport via `flexGrow:1 +
396394
// justifyContent:flex-end`). For our ChatGPT layout messages
@@ -401,6 +399,14 @@ export function ChatPanel({
401399
// rendered behind the composer backdrop because the alignment
402400
// didn't account for `contentInsetEndAdjustment`.
403401
initialScrollAtEnd
402+
// maintainScrollAtEnd: keep the viewport pinned to the bottom as the
403+
// streaming reply grows (stick-to-bottom). Bare = true = all triggers
404+
// (dataChange/itemLayout/layout) → also follows late row-measure growth
405+
// and inset/layout changes back to the end. Internally gated to
406+
// near-the-end, so it takes priority there and coexists with
407+
// anchoredEndSpace without a fight (verified smooth on device
408+
// 2026-06-13: maintainScrollAtEnd wins near the end, no jitter).
409+
maintainScrollAtEnd
404410
// List → UI-thread state mirror (reanimated integration). `isNearEnd`
405411
// gates the scroll-to-end FAB; no JS re-render involved.
406412
sharedValues={{ isNearEnd }}

0 commit comments

Comments
 (0)