Matthias Nott
2026-03-07 7d69229cd76447b92ee66f472f760994d00817ae
fix: reliable scroll-to-bottom, keyboard-aware project picker

- MessageList: use onContentSizeChange with bulk-scroll flag instead of
fixed timers. Flag set on bulk load (delta > 1), cleared after 3s.
Every content size change during that window scrolls to bottom.
- SessionDrawer: track keyboard height via keyboardWillShow/Hide events,
apply as paddingBottom on drawer content. Scroll picker to end on
TextInput focus. Removed KeyboardAvoidingView (doesn't work with
absolute positioning).
2 files modified
changed files
components/SessionDrawer.tsx patch | view | blame | history
components/chat/MessageList.tsx patch | view | blame | history
components/SessionDrawer.tsx
....@@ -9,9 +9,7 @@
99 Animated,
1010 Dimensions,
1111 Keyboard,
12
- KeyboardAvoidingView,
1312 LayoutAnimation,
14
- Platform,
1513 Pressable,
1614 ScrollView,
1715 StyleSheet,
....@@ -276,6 +274,18 @@
276274 const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
277275 const fadeAnim = useRef(new Animated.Value(0)).current;
278276 const [rendered, setRendered] = useState(false);
277
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
278
+ const pickerScrollRef = useRef<ScrollView>(null);
279
+
280
+ useEffect(() => {
281
+ const showSub = Keyboard.addListener("keyboardWillShow", (e) => {
282
+ setKeyboardHeight(e.endCoordinates.height);
283
+ });
284
+ const hideSub = Keyboard.addListener("keyboardWillHide", () => {
285
+ setKeyboardHeight(0);
286
+ });
287
+ return () => { showSub.remove(); hideSub.remove(); };
288
+ }, []);
279289
280290 // Local ordering: merge server sessions while preserving user's drag order
281291 const [orderedSessions, setOrderedSessions] = useState<WsSession[]>([]);
....@@ -473,11 +483,7 @@
473483 elevation: 20,
474484 }}
475485 >
476
- <KeyboardAvoidingView
477
- style={{ flex: 1 }}
478
- behavior={Platform.OS === "ios" ? "padding" : undefined}
479
- >
480
- <GestureHandlerRootView style={{ flex: 1 }}>
486
+ <GestureHandlerRootView style={{ flex: 1, paddingBottom: keyboardHeight }}>
481487 {/* Header */}
482488 <View
483489 style={{
....@@ -564,6 +570,7 @@
564570
565571 {showProjectPicker && (
566572 <ScrollView
573
+ ref={pickerScrollRef}
567574 style={{
568575 marginHorizontal: 12,
569576 borderRadius: 12,
....@@ -630,6 +637,9 @@
630637 autoCapitalize="none"
631638 autoCorrect={false}
632639 returnKeyType="go"
640
+ onFocus={() => {
641
+ setTimeout(() => pickerScrollRef.current?.scrollToEnd({ animated: true }), 100);
642
+ }}
633643 onSubmitEditing={() => {
634644 if (customPath.trim()) launchSession({ path: customPath.trim() });
635645 }}
....@@ -679,7 +689,6 @@
679689 </Text>
680690 </View>
681691 </GestureHandlerRootView>
682
- </KeyboardAvoidingView>
683692 </Animated.View>
684693 </View>
685694 </View>
components/chat/MessageList.tsx
....@@ -18,17 +18,17 @@
1818 // Track the last message's content so transcript reflections trigger a scroll
1919 const lastContent = messages.length > 0 ? messages[messages.length - 1].content : "";
2020
21
+ // Flag: when true, every content size change triggers a scroll to bottom.
22
+ // Used for bulk loads (restart, session switch) where FlatList renders lazily.
23
+ const bulkScrollRef = useRef(false);
24
+
2125 useEffect(() => {
2226 if (messages.length > 0) {
2327 const delta = Math.abs(messages.length - prevLengthRef.current);
2428 if (delta > 1) {
25
- // Bulk load (restart, session switch) — FlatList renders lazily,
26
- // so fire multiple scroll attempts to catch late renders.
27
- for (const delay of [100, 300, 700]) {
28
- setTimeout(() => {
29
- listRef.current?.scrollToEnd({ animated: false });
30
- }, delay);
31
- }
29
+ // Bulk load — let onContentSizeChange handle scrolling
30
+ bulkScrollRef.current = true;
31
+ setTimeout(() => { bulkScrollRef.current = false; }, 3000);
3232 } else {
3333 // Single new message — smooth scroll
3434 setTimeout(() => {
....@@ -38,6 +38,12 @@
3838 }
3939 prevLengthRef.current = messages.length;
4040 }, [messages.length, isTyping, lastContent]);
41
+
42
+ const handleContentSizeChange = useCallback(() => {
43
+ if (bulkScrollRef.current) {
44
+ listRef.current?.scrollToEnd({ animated: false });
45
+ }
46
+ }, []);
4147
4248 // Play from a voice message and auto-chain all consecutive assistant voice messages after it
4349 const handlePlayVoice = useCallback(async (messageId: string) => {
....@@ -77,6 +83,7 @@
7783 onPlayVoice={handlePlayVoice}
7884 />
7985 )}
86
+ onContentSizeChange={handleContentSizeChange}
8087 contentContainerStyle={{ paddingVertical: 12 }}
8188 showsVerticalScrollIndicator={false}
8289 ListFooterComponent={