Skip to content

Commit 0f004ad

Browse files
yyq1025claude
andcommitted
app: AI-chat scroll idiom — drop maintainScrollAtEnd, add glass scroll-to-end FAB
Adopts the upstream AiChatExample pattern (in @legendapp/list 3.0.4): maintainScrollAtEnd removed (it defeated the anchored-end-space pattern once a reply outgrew the reserved space), the send anchor now persists across turns (clearing on idle collapsed the end space and jumped the viewport), and sharedValues={{isNearEnd}} drives a swift-ui Liquid Glass scroll-to-end FAB — UI-thread opacity/pointerEvents, zero JS re-renders. The FAB lives inside the KeyboardStickyView above the composer (rides keyboard transitions, stays within parent bounds for hit-testing, excluded from the measured composer inset). rawSend also drops its requestAnimationFrame wrapper: scrollToEnd is committed in 3.0.4 (queued until data commit + anchored tail measured, freeze held until scroll and keyboard dismissal both finish). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7407d45 commit 0f004ad

1 file changed

Lines changed: 101 additions & 39 deletions

File tree

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

Lines changed: 101 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Button, Host, Image } from "@expo/ui/swift-ui";
2+
import { buttonBorderShape, buttonStyle } from "@expo/ui/swift-ui/modifiers";
13
import {
24
KeyboardAwareLegendList,
35
useKeyboardChatComposerInset,
@@ -15,8 +17,14 @@ import { useQueryClient } from "@tanstack/react-query";
1517
import * as Crypto from "expo-crypto";
1618
import { useHeaderHeight } from "expo-router/react-navigation";
1719
import { useCallback, useEffect, useRef, useState } from "react";
18-
import { useColorScheme, View } from "react-native";
20+
import { useColorScheme, View, type ViewProps } from "react-native";
1921
import { KeyboardStickyView } from "react-native-keyboard-controller";
22+
import Animated, {
23+
useAnimatedProps,
24+
useAnimatedStyle,
25+
useSharedValue,
26+
withTiming,
27+
} from "react-native-reanimated";
2028
import { useSafeAreaInsets } from "react-native-safe-area-context";
2129
import { GitStatusBar } from "@/components/transcript/git-status-bar";
2230
import { 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

Comments
 (0)