1+ import { Button , Host , Image } from "@expo/ui/swift-ui" ;
2+ import { buttonBorderShape , buttonStyle } from "@expo/ui/swift-ui/modifiers" ;
13import {
24 KeyboardAwareLegendList ,
35 useKeyboardChatComposerInset ,
@@ -15,8 +17,14 @@ import { useQueryClient } from "@tanstack/react-query";
1517import * as Crypto from "expo-crypto" ;
1618import { useHeaderHeight } from "expo-router/react-navigation" ;
1719import { useCallback , useEffect , useRef , useState } from "react" ;
18- import { useColorScheme , View } from "react-native" ;
20+ import { useColorScheme , View , type ViewProps } from "react-native" ;
1921import { KeyboardStickyView } from "react-native-keyboard-controller" ;
22+ import Animated , {
23+ useAnimatedProps ,
24+ useAnimatedStyle ,
25+ useSharedValue ,
26+ withTiming ,
27+ } from "react-native-reanimated" ;
2028import { useSafeAreaInsets } from "react-native-safe-area-context" ;
2129import { GitStatusBar } from "@/components/transcript/git-status-bar" ;
2230import { InputBar } from "@/components/transcript/input-bar" ;
@@ -128,8 +136,8 @@ export function ChatPanel({
128136
129137 // Idiomatic scroll-on-send (LegendList chat pattern). `scrollMessageToEnd`
130138 // brings the just-sent message into view no matter where the user had
131- // scrolled to; `freeze` (passed to the list) suspends maintainScrollAtEnd /
132- // maintainVisibleContentPosition during the animation so they don 't fight it.
139+ // scrolled to; `freeze` (passed to the list) suspends
140+ // maintainVisibleContentPosition during the animation so it doesn 't fight it.
133141 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd ( { listRef } ) ;
134142
135143 // isThinking: the turn is running (daemon-pushed activity, #17) but the last
@@ -177,12 +185,21 @@ export function ChatPanel({
177185
178186 // anchoredEndSpace anchor for the just-sent message: the streaming reply fills
179187 // reserved space below it instead of shoving the scroll target (overshoot fix).
180- // Set in rawSend at send time (the activity flag lags a round-trip); cleared
181- // when the turn goes idle.
188+ // Set in rawSend at send time (the activity flag lags a round-trip) and kept
189+ // ACROSS turns (the upstream AiChatExample idiom): clearing on idle collapses
190+ // the reserved blank space below a short reply, which jumps the viewport.
191+ // The next send simply re-anchors.
182192 const [ anchorIndex , setAnchorIndex ] = useState ( - 1 ) ;
183- useEffect ( ( ) => {
184- if ( ! isRunning ) setAnchorIndex ( - 1 ) ;
185- } , [ isRunning ] ) ;
193+
194+ // Written by the list on the UI thread (reanimated integration) — drives the
195+ // scroll-to-end FAB without a single JS-side re-render.
196+ const isNearEnd = useSharedValue ( true ) ;
197+ const scrollFabStyle = useAnimatedStyle ( ( ) => ( {
198+ opacity : withTiming ( isNearEnd . value ? 0 : 1 , { duration : 160 } ) ,
199+ } ) ) ;
200+ const scrollFabProps = useAnimatedProps < ViewProps > ( ( ) => ( {
201+ pointerEvents : isNearEnd . value ? ( "none" as const ) : ( "box-none" as const ) ,
202+ } ) ) ;
186203
187204 // The "real send" handed to InputBar. InputBar already intercepts slash
188205 // commands; only plain text + whitelisted passthroughs (/init, /review,
@@ -203,13 +220,15 @@ export function ChatPanel({
203220 } ) . isPersisted . promise . catch ( ( err ) => {
204221 console . error ( "sendPrompt failed" , err ) ;
205222 } ) ;
206- // Anchor the new message, then scroll to it next frame (after the optimistic
207- // insert reflows). closeKeyboard:false — dismissing mid-scroll fights the
208- // scroll target.
223+ // Anchor the new message and scroll synchronously — scrollToEnd is
224+ // "committed" since @legendapp/list 3.0.4: the list queues the scroll
225+ // until the data commit lands and the anchored tail has measured, and
226+ // the hook holds `freeze` until BOTH the scroll and the keyboard
227+ // dismissal finish. No rAF needed; closeKeyboard:true is the supported
228+ // pattern (the old "dismiss fights the scroll target" race is fixed
229+ // upstream).
209230 setAnchorIndex ( blocks . length ) ;
210- requestAnimationFrame ( ( ) => {
211- void scrollMessageToEnd ( { animated : true , closeKeyboard : false } ) ;
212- } ) ;
231+ void scrollMessageToEnd ( { animated : true , closeKeyboard : true } ) ;
213232 } ,
214233 [ cliSessionId , cwd , blocks . length , scrollMessageToEnd ] ,
215234 ) ;
@@ -224,9 +243,9 @@ export function ChatPanel({
224243 < >
225244 < KeyboardAwareLegendList < RenderBlock >
226245 ref = { listRef }
227- // Suspends maintainScrollAtEnd / maintainVisibleContentPosition while
228- // `scrollMessageToEnd` animates on send, so the chat-pattern scroll
229- // isn't fought by the resting scroll managers (LegendList chat guide).
246+ // Suspends maintainVisibleContentPosition while `scrollMessageToEnd`
247+ // animates on send, so the chat-pattern scroll isn't fought by the
248+ // resting scroll manager (LegendList chat guide).
230249 freeze = { freeze }
231250 // Reserve blank space below the just-sent user message for its turn so
232251 // the streaming response fills it without moving the anchor (fixes the
@@ -297,33 +316,41 @@ export function ChatPanel({
297316 // Without this the last message sits ~insets.bottom too low above
298317 // composer when the keyboard is shown.
299318 keyboardOffset = { insets . bottom - 8 }
300- // ChatGPT-style scrolling, NOT Telegram-style:
319+ // ChatGPT-style scrolling, NOT Telegram-style (the upstream
320+ // AiChatExample idiom):
301321 // - initialScrollAtEnd: boot at the latest message on session
302322 // re-open. Runs a per-frame rAF ticker that retargets to true
303323 // end as items measure and the inset settles (LegendList
304324 // retargets the initial scroll after inset changes).
305- // - maintainScrollAtEnd: when user is already pinned at bottom,
306- // new messages keep them pinned; scrolling up disengages.
325+ // - During a turn, anchoredEndSpace absorbs streaming growth: the
326+ // reply fills the reserved space below the anchored prompt and
327+ // the viewport NEVER auto-follows. Once the reply outgrows the
328+ // viewport it streams below the fold; the scroll-to-end FAB
329+ // (driven by `isNearEnd`) is the user's way down.
307330 //
308- // DELIBERATELY OMITTED: `alignItemsAtEnd`. That's a
309- // Telegram/iMessage idiom (sparse messages stick to the bottom of
310- // the viewport via `flexGrow:1 + justifyContent:flex-end` on the
311- // contentContainer). For our
312- // ChatGPT layout messages flow top-down from the header, and we
313- // got two bugs from enabling it: (1) contentContainer was forced
314- // to fill the viewport even with one short message, making the
315- // list scrollable into the composer inset zone; (2) the
316- // bottom-aligned single message rendered behind the composer
317- // backdrop because the alignment didn't account for
318- // `contentInsetEndAdjustment`.
331+ // DELIBERATELY OMITTED:
332+ // - `maintainScrollAtEnd`: auto-follow-the-stream. It defeats the
333+ // anchored pattern the moment the reply outgrows the reserved
334+ // space (the anchor scrolls away mid-read). The upstream AI chat
335+ // example never used it — it belongs to the Telegram-style
336+ // ChatExample only.
337+ // - `alignItemsAtEnd`: a Telegram/iMessage idiom (sparse messages
338+ // stick to the bottom of the viewport via `flexGrow:1 +
339+ // justifyContent:flex-end`). For our ChatGPT layout messages
340+ // flow top-down from the header, and enabling it caused two
341+ // bugs: (1) contentContainer forced to fill the viewport even
342+ // with one short message, making the list scrollable into the
343+ // composer inset zone; (2) the bottom-aligned single message
344+ // rendered behind the composer backdrop because the alignment
345+ // didn't account for `contentInsetEndAdjustment`.
319346 initialScrollAtEnd
320- maintainScrollAtEnd = { { animated : true } }
321- // v3 migration: stabilize the visible position on size/layout
322- // changes (keyboard toggle, streaming item growth) but NOT on data
323- // adds — `data:false` hands pin-to-bottom-on-new-message to
324- // `maintainScrollAtEnd` above. This is the v3 guide's recommended chat
325- // config (and the v3 default); set explicitly to document intent. v2's
326- // bare boolean mapped to different defaults .
347+ // List → UI-thread state mirror (reanimated integration). `isNearEnd`
348+ // gates the scroll-to-end FAB; no JS re-render involved.
349+ sharedValues = { { isNearEnd } }
350+ // Stabilize the visible position on size/layout changes (keyboard
351+ // toggle, streaming item growth) but NOT on data adds — with
352+ // maintainScrollAtEnd gone, new data moves nothing by design: the
353+ // anchored end space owns in-turn growth and the FAB owns catch-up .
327354 maintainVisibleContentPosition = { { size : true , data : false } }
328355 // iOS pull-down-to-dismiss for the keyboard.
329356 keyboardDismissMode = "interactive"
@@ -346,11 +373,46 @@ export function ChatPanel({
346373 InputBar; future error banners / attachment rows should go
347374 inside this same wrapper so the list auto-adjusts.
348375 `collapsable={false}` is required for `measure` / `onLayout`
349- to fire reliably on Android. */ }
376+ to fire reliably on Android.
377+
378+ `pointerEvents="box-none"`: the KSV now also contains the
379+ transparent FAB strip above the composer — the wrapper itself
380+ must not eat list touches in that strip. */ }
350381 < KeyboardStickyView
351382 offset = { { opened : insets . bottom - 8 } }
352383 style = { { position : "absolute" , left : 0 , right : 0 , bottom : 0 } }
384+ pointerEvents = "box-none"
353385 >
386+ { /* Scroll-to-end FAB — appears when the user is away from the end
387+ (streaming continues below the fold by design; see the
388+ maintainScrollAtEnd omission note above). Lives INSIDE the KSV,
389+ laid out above the composer: it rides keyboard transitions for
390+ free, and staying within the KSV's bounds keeps it tappable
391+ (RN doesn't hit-test children outside parent bounds). NOT part
392+ of `composerRef`, so it doesn't inflate the measured list
393+ inset. Visibility + hit-testing both ride `isNearEnd` on the
394+ UI thread. Liquid Glass circle via swift-ui Button (universal
395+ Button injects its own innermost buttonStyle and eats `glass`
396+ — see onboarding.tsx). */ }
397+ < Animated . View
398+ animatedProps = { scrollFabProps }
399+ style = { [ { alignItems : "center" , paddingBottom : 12 } , scrollFabStyle ] }
400+ >
401+ < Host matchContents >
402+ < Button
403+ onPress = { ( ) => {
404+ void listRef . current ?. scrollToEnd ( { animated : true } ) ;
405+ } }
406+ modifiers = { [ buttonStyle ( "glass" ) , buttonBorderShape ( "circle" ) ] }
407+ >
408+ < Image
409+ systemName = "arrow.down"
410+ size = { 17 }
411+ color = { isDark ? "#fafafa" : "#0a0a0a" }
412+ />
413+ </ Button >
414+ </ Host >
415+ </ Animated . View >
354416 < View
355417 ref = { composerRef }
356418 collapsable = { false }
0 commit comments