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).
| .. | .. |
|---|
| 9 | 9 | Animated, |
|---|
| 10 | 10 | Dimensions, |
|---|
| 11 | 11 | Keyboard, |
|---|
| 12 | | - KeyboardAvoidingView, |
|---|
| 13 | 12 | LayoutAnimation, |
|---|
| 14 | | - Platform, |
|---|
| 15 | 13 | Pressable, |
|---|
| 16 | 14 | ScrollView, |
|---|
| 17 | 15 | StyleSheet, |
|---|
| .. | .. |
|---|
| 276 | 274 | const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current; |
|---|
| 277 | 275 | const fadeAnim = useRef(new Animated.Value(0)).current; |
|---|
| 278 | 276 | 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 | + }, []); |
|---|
| 279 | 289 | |
|---|
| 280 | 290 | // Local ordering: merge server sessions while preserving user's drag order |
|---|
| 281 | 291 | const [orderedSessions, setOrderedSessions] = useState<WsSession[]>([]); |
|---|
| .. | .. |
|---|
| 473 | 483 | elevation: 20, |
|---|
| 474 | 484 | }} |
|---|
| 475 | 485 | > |
|---|
| 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 }}> |
|---|
| 481 | 487 | {/* Header */} |
|---|
| 482 | 488 | <View |
|---|
| 483 | 489 | style={{ |
|---|
| .. | .. |
|---|
| 564 | 570 | |
|---|
| 565 | 571 | {showProjectPicker && ( |
|---|
| 566 | 572 | <ScrollView |
|---|
| 573 | + ref={pickerScrollRef} |
|---|
| 567 | 574 | style={{ |
|---|
| 568 | 575 | marginHorizontal: 12, |
|---|
| 569 | 576 | borderRadius: 12, |
|---|
| .. | .. |
|---|
| 630 | 637 | autoCapitalize="none" |
|---|
| 631 | 638 | autoCorrect={false} |
|---|
| 632 | 639 | returnKeyType="go" |
|---|
| 640 | + onFocus={() => { |
|---|
| 641 | + setTimeout(() => pickerScrollRef.current?.scrollToEnd({ animated: true }), 100); |
|---|
| 642 | + }} |
|---|
| 633 | 643 | onSubmitEditing={() => { |
|---|
| 634 | 644 | if (customPath.trim()) launchSession({ path: customPath.trim() }); |
|---|
| 635 | 645 | }} |
|---|
| .. | .. |
|---|
| 679 | 689 | </Text> |
|---|
| 680 | 690 | </View> |
|---|
| 681 | 691 | </GestureHandlerRootView> |
|---|
| 682 | | - </KeyboardAvoidingView> |
|---|
| 683 | 692 | </Animated.View> |
|---|
| 684 | 693 | </View> |
|---|
| 685 | 694 | </View> |
|---|
| .. | .. |
|---|
| 18 | 18 | // Track the last message's content so transcript reflections trigger a scroll |
|---|
| 19 | 19 | const lastContent = messages.length > 0 ? messages[messages.length - 1].content : ""; |
|---|
| 20 | 20 | |
|---|
| 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 | + |
|---|
| 21 | 25 | useEffect(() => { |
|---|
| 22 | 26 | if (messages.length > 0) { |
|---|
| 23 | 27 | const delta = Math.abs(messages.length - prevLengthRef.current); |
|---|
| 24 | 28 | 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); |
|---|
| 32 | 32 | } else { |
|---|
| 33 | 33 | // Single new message — smooth scroll |
|---|
| 34 | 34 | setTimeout(() => { |
|---|
| .. | .. |
|---|
| 38 | 38 | } |
|---|
| 39 | 39 | prevLengthRef.current = messages.length; |
|---|
| 40 | 40 | }, [messages.length, isTyping, lastContent]); |
|---|
| 41 | + |
|---|
| 42 | + const handleContentSizeChange = useCallback(() => { |
|---|
| 43 | + if (bulkScrollRef.current) { |
|---|
| 44 | + listRef.current?.scrollToEnd({ animated: false }); |
|---|
| 45 | + } |
|---|
| 46 | + }, []); |
|---|
| 41 | 47 | |
|---|
| 42 | 48 | // Play from a voice message and auto-chain all consecutive assistant voice messages after it |
|---|
| 43 | 49 | const handlePlayVoice = useCallback(async (messageId: string) => { |
|---|
| .. | .. |
|---|
| 77 | 83 | onPlayVoice={handlePlayVoice} |
|---|
| 78 | 84 | /> |
|---|
| 79 | 85 | )} |
|---|
| 86 | + onContentSizeChange={handleContentSizeChange} |
|---|
| 80 | 87 | contentContainerStyle={{ paddingVertical: 12 }} |
|---|
| 81 | 88 | showsVerticalScrollIndicator={false} |
|---|
| 82 | 89 | ListFooterComponent={ |
|---|