@@ -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 */
117123export 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