Skip to content

Commit b4e495f

Browse files
yyq1025claude
andcommitted
chat: drop maintainScrollAtEnd (prompt-pinned-top, no stick-to-bottom)
The streaming reply no longer auto-follows to the bottom. On send, anchoredEndSpace pins the just-sent prompt near the top and the reply renders downward into the reserved area below it; the scroll-to-end FAB jumps to the latest. Better for long structured agent replies (read from the top), and it removes the maintainScrollAtEnd / MVCP / KCSV contention over the scroll offset. Reconciles the previously-stale MVCP comment that already described this design as if maintainScrollAtEnd were gone. Also refresh the new-session composer note (the @expo/ui Menu Host caveat is moot now that the pickers use the Nitro menu). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c75b3f3 commit b4e495f

2 files changed

Lines changed: 42 additions & 32 deletions

File tree

packages/app/src/app/(main)/(drawer)/(stack)/index.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,14 @@ export default function NewSessionScreen() {
199199
home indicator (KSV is absolutely-positioned at bottom: 0,
200200
which is below the safe area); `offset.opened: -8` leaves an
201201
8pt visual gap above the keyboard.
202-
NOTE: tried collapsing to a single KeyboardAvoidingView so
203-
heading + composer move together — turned out to interact
204-
badly with iOS IME candidate-bar frame updates and SwiftUI
205-
Menu's Host async measurement, leaving the InputBar's `+`
206-
button misaligned on first keyboard show until the user
207-
switched IMEs. Reverting to the KSV pattern matches the
208-
detail page (chat-panel.tsx) and avoids the regression. */}
202+
NOTE (likely stale — kept as history): an earlier attempt to
203+
collapse this into a single KeyboardAvoidingView misaligned the
204+
InputBar's `+` button on first keyboard show. The cause was
205+
@expo/ui's SwiftUI Menu Host async measurement + an iOS IME
206+
candidate-bar bug — BOTH now moot: the `+`/model pickers moved to
207+
the Nitro menu (@yyq1025/react-native-nitro-menu, pure UIKit, no
208+
Host), and the @expo/ui Host keyboard bug was fixed upstream. So
209+
this caveat probably no longer applies. */}
209210
<View>
210211
<GitStatusBar
211212
cwd={cwd}

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

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,19 @@ type ChatPanelProps = {
106106
* and leaves the last message ~8pt above the keyboard top. Without it
107107
* the last message sits a touch low above the composer.
108108
*
109+
* Per-session lifecycle: because this whole surface mounts fresh per
110+
* session (the detail screen swaps it on the loading→ready transition),
111+
* `initialScrollAtEnd`, the composer-inset measurement, the KCSV keyboard
112+
* scroll-state, and the daemon live-fanout registration all start clean on
113+
* every switch — no cross-mount state to reset by hand. (A persistent
114+
* ChatPanel was tried and removed: it saved the mount cost but left
115+
* LegendList/KCSV's per-mount keyboard + scroll state stale across
116+
* switches, which couldn't be re-seeded from outside the wrapper.)
117+
*
109118
* Trade-off: brief no-composer flash during the loading transition
110119
* (typically <300ms for an in-flight subscribe). Mirror of how
111120
* Claude Desktop / ChatGPT handle session-switch loading — input is
112-
* hidden until the session is ready to receive it. The alternative —
113-
* keeping composer mounted across loading — required ad-hoc fixes
114-
* for the inset hook state and initialScrollAtEnd that weren't
115-
* reliable.
121+
* hidden until the session is ready to receive it.
116122
*/
117123
export function ChatPanel({
118124
cliSessionId,
@@ -289,8 +295,9 @@ export function ChatPanel({
289295
// Reserve blank space below the just-sent user message for its turn so
290296
// the agent's reply has a full initial display area to render into (the
291297
// 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.
298+
// composer. The reply fills this reserved space without moving the
299+
// scroll (no stick-to-bottom); the FAB jumps to the latest on demand.
300+
// Undefined when idle → normal layout.
294301
anchoredEndSpace={
295302
anchorIndex >= 0
296303
? { anchorIndex, anchorOffset: headerHeight }
@@ -379,14 +386,15 @@ export function ChatPanel({
379386
// - initialScrollAtEnd: boot at the latest message on session
380387
// re-open. Runs a per-frame rAF ticker that retargets to true
381388
// end as items measure and the inset settles (LegendList
382-
// retargets the initial scroll after inset changes).
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.
389+
// retargets the initial scroll after inset changes). Re-fires
390+
// per session because the whole surface mounts fresh each switch.
391+
// - on send: anchoredEndSpace reserves the reply's initial display
392+
// area below the just-sent prompt (prompt pinned near the top) so
393+
// it renders into a full viewport instead of cramped above the
394+
// composer. The scroll does NOT auto-follow the streaming reply
395+
// (deliberately no maintainScrollAtEnd — see below) — you read the
396+
// reply from the prompt downward; the scroll-to-end FAB
397+
// (`isNearEnd`) is the way to jump to the latest.
390398
//
391399
// DELIBERATELY OMITTED:
392400
// - `alignItemsAtEnd`: a Telegram/iMessage idiom (sparse messages
@@ -399,14 +407,14 @@ export function ChatPanel({
399407
// rendered behind the composer backdrop because the alignment
400408
// didn't account for `contentInsetEndAdjustment`.
401409
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
410+
// maintainScrollAtEnd DELIBERATELY OMITTED — no stick-to-bottom. We
411+
// tried it (84ca2a9) for follow-the-stream, but for a coding assistant
412+
// the long structured replies read better from the TOP: anchoredEndSpace
413+
// pins the just-sent prompt near the top and the reply renders downward
414+
// into the reserved area below it; the FAB jumps to the latest on
415+
// demand. (It also collided with MVCP + KCSV's keyboard lift over the
416+
// same scroll offset.) The viewport only moves on send (anchoredEndSpace)
417+
// and via the FAB — never auto-chasing the stream tail.
410418
// List → UI-thread state mirror (reanimated integration). `isNearEnd`
411419
// gates the scroll-to-end FAB; no JS re-render involved.
412420
sharedValues={{ isNearEnd }}
@@ -415,9 +423,10 @@ export function ChatPanel({
415423
fabReady.set(true);
416424
}}
417425
// Stabilize the visible position on size/layout changes (keyboard
418-
// toggle, streaming item growth) but NOT on data adds — with
419-
// maintainScrollAtEnd gone, new data moves nothing by design: the
420-
// anchored end space owns in-turn growth and the FAB owns catch-up.
426+
// toggle, streaming item growth) but NOT on data adds — new data moves
427+
// nothing by design: the anchored end space owns in-turn growth (the
428+
// reply renders into the reserved area below the pinned prompt) and the
429+
// FAB owns catch-up to the latest.
421430
maintainVisibleContentPosition={{ size: true, data: false }}
422431
// iOS pull-down-to-dismiss for the keyboard.
423432
keyboardDismissMode="interactive"

0 commit comments

Comments
 (0)