Skip to content

Commit 700363f

Browse files
authored
feat(chat): fade messages into the background at the list edges (#254)
Resolves #253. Messages were clipped mid-line at the top of the list and at the chat bar. The list now runs the full height of the screen so messages scroll underneath the bar, and background-coloured gradients at both edges let them dissolve instead of cutting off. - ChatBar is an absolute overlay rather than a spacer taking layout space; Messages takes its height as chatBarInset to keep the last message resting above it and to size the bottom fade. - ChatBar reports its baseline height instead of the live one, and re-measures when the safe-area inset changes, so switching Android navigation modes or rotating does not leave a stale baseline. - On Android the bottom ramp reaches full opacity at the top of the system navigation bar, driven entirely by insets.bottom, so gesture and three-button modes both follow without a special case. - Fades leave a right-edge gutter so they do not paint over the scroll indicator, and are drawn below the scroll-to-bottom button so it keeps receiving taps. The layout and the Android navigation-bar handling were confirmed on a physical device in both navigation modes. The later changes — scroll indicator gutter, style memoisation, comment cleanup — are lint, type and unit tested only, not yet re-checked on device.
1 parent 6351004 commit 700363f

12 files changed

Lines changed: 422 additions & 102 deletions

File tree

__tests__/useKeyboardLift.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { renderHook } from '@testing-library/react-native';
2+
import { useKeyboardLift } from '../components/chat-screen/useKeyboardLift';
3+
4+
let mockInsetsBottom = 0;
5+
const mockHeight = { value: 0 };
6+
const mockProgress = { value: 0 };
7+
8+
jest.mock('../context/ThemeContext', () => ({
9+
useTheme: () => ({
10+
theme: {
11+
insets: { top: 0, bottom: mockInsetsBottom, left: 0, right: 0 },
12+
},
13+
}),
14+
}));
15+
16+
jest.mock('react-native-keyboard-controller', () => ({
17+
useReanimatedKeyboardAnimation: () => ({
18+
height: mockHeight,
19+
progress: mockProgress,
20+
}),
21+
}));
22+
23+
describe('useKeyboardLift', () => {
24+
beforeEach(() => {
25+
mockHeight.value = 0;
26+
mockProgress.value = 0;
27+
mockInsetsBottom = 0;
28+
});
29+
30+
it('returns 0 when the keyboard is closed', () => {
31+
const { result } = renderHook(() => useKeyboardLift());
32+
33+
expect(result.current.value).toBe(0);
34+
});
35+
36+
it('gives back the bottom inset the open keyboard swallows', () => {
37+
mockInsetsBottom = 34;
38+
mockHeight.value = -346;
39+
mockProgress.value = 1;
40+
41+
const { result } = renderHook(() => useKeyboardLift());
42+
43+
expect(result.current.value).toBe(-312);
44+
});
45+
46+
it('scales the inset compensation with keyboard progress', () => {
47+
mockInsetsBottom = 34;
48+
mockHeight.value = -173;
49+
mockProgress.value = 0.5;
50+
51+
const { result } = renderHook(() => useKeyboardLift());
52+
53+
expect(result.current.value).toBe(-156);
54+
});
55+
56+
it('equals the raw keyboard height on a device without a bottom inset', () => {
57+
mockHeight.value = -300;
58+
mockProgress.value = 1;
59+
60+
const { result } = renderHook(() => useKeyboardLift());
61+
62+
expect(result.current.value).toBe(-300);
63+
});
64+
65+
it('recomputes after the keyboard values change', () => {
66+
mockInsetsBottom = 34;
67+
const { result, rerender } = renderHook(() => useKeyboardLift());
68+
69+
expect(result.current.value).toBe(0);
70+
71+
mockHeight.value = -346;
72+
mockProgress.value = 1;
73+
rerender({});
74+
75+
expect(result.current.value).toBe(-312);
76+
});
77+
});

app/(drawer)/_layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ const DrawerLayout = () => {
5555
title: 'Benchmark',
5656
}}
5757
/>
58-
<Drawer.Screen name="chat/[id]" />
58+
<Drawer.Screen
59+
name="chat/[id]"
60+
options={{
61+
headerTransparent: true,
62+
headerStyle: { backgroundColor: 'transparent' },
63+
}}
64+
/>
5965
</Drawer>
6066
);
6167
};

app/(drawer)/chat/[id].tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,13 @@ function ChatScreenInner() {
5858
const isEmpty = !isLoading && activeChatMessages.length === 0;
5959
const shouldExitOnBack = isPhantom && isEmpty;
6060
const openModelSheetRef = useRef<(() => void) | null>(null);
61+
const openModelSheet = useCallback(() => openModelSheetRef.current?.(), []);
6162

62-
const { MenuElements } = useChatHeader({
63+
const { MenuElements, titleBottom } = useChatHeader({
6364
chatId: chatId,
6465
chatModel: model,
6566
isEmpty,
66-
onSelectModelFromTitle: isPhantom
67-
? () => openModelSheetRef.current?.()
68-
: undefined,
67+
onSelectModelFromTitle: isPhantom ? openModelSheet : undefined,
6968
});
7069

7170
useFocusEffect(
@@ -141,6 +140,7 @@ function ChatScreenInner() {
141140
selectModel={handleSetModel}
142141
openModelSheetRef={openModelSheetRef}
143142
revealFromTop={shouldPlayBranchEntryAnimation}
143+
headerTitleBottom={titleBottom}
144144
/>
145145
{MenuElements}
146146
</>

components/chat-screen/ChatBar.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ const ChatBar = ({
105105

106106
const defaultBarHeight = useRef(0);
107107
const prevBarHeight = useRef(0);
108+
109+
// Inset the baseline was captured with. Checked in the layout handler, not
110+
// an effect: onLayout fires first, so an effect-driven reset would lose the
111+
// pass carrying the new height.
112+
const baselineInset = useRef<number | null>(null);
108113
const textInputRef = useRef<RNTextInput>(null);
109114
// iOS-only: bump the TextInput key to force a remount when a prompt
110115
// suggestion is set programmatically. iOS doesn't re-fire onLayout
@@ -138,12 +143,19 @@ const ChatBar = ({
138143
const handleBarLayoutForPadding = useCallback(
139144
(e: { nativeEvent: { layout: { height: number } } }) => {
140145
const height = e.nativeEvent.layout.height;
146+
const inset = theme.insets.bottom;
141147
// Only capture the default height once we're in the "with messages"
142148
// layout — otherwise the empty-state extras (WhatsNewCard, prompt
143149
// suggestions) would bake into the baseline and squeeze the scroll
144-
// view once they disappear.
145-
if (defaultBarHeight.current === 0 && hasMessages) {
150+
// view once they disappear. Re-capture on inset changes (Android
151+
// navigation mode, rotation), or the stale baseline reads the difference
152+
// as "the bar grew".
153+
if (
154+
hasMessages &&
155+
(defaultBarHeight.current === 0 || baselineInset.current !== inset)
156+
) {
146157
defaultBarHeight.current = height;
158+
baselineInset.current = inset;
147159
}
148160
const baseline = defaultBarHeight.current || height;
149161
const delta = height - baseline;
@@ -153,14 +165,22 @@ const ChatBar = ({
153165
easing: BAR_GROW_EASING,
154166
})
155167
);
168+
// Baseline, not live height — consumers must not follow the bar as it
169+
// grows with typed lines; that is what extraContentPadding is for.
156170
onHeightChange?.(hasMessages ? baseline : 0);
157171
const grew = height > prevBarHeight.current;
158172
prevBarHeight.current = height;
159173
if (delta > 0 && grew) {
160174
onBarGrow?.();
161175
}
162176
},
163-
[extraContentPadding, onBarGrow, onHeightChange, hasMessages]
177+
[
178+
extraContentPadding,
179+
onBarGrow,
180+
onHeightChange,
181+
hasMessages,
182+
theme.insets.bottom,
183+
]
164184
);
165185

166186
const {

components/chat-screen/ChatScreen.tsx

Lines changed: 45 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import React, {
88
import { Keyboard, StyleSheet, useWindowDimensions, View } from 'react-native';
99
import { LinearGradient } from 'expo-linear-gradient';
1010
import { BottomSheetModal } from '@gorhom/bottom-sheet';
11-
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller';
11+
import { useHeaderHeight } from '@react-navigation/elements';
12+
import { useKeyboardLift } from './useKeyboardLift';
1213
import { router } from 'expo-router';
1314
import Animated, {
1415
useAnimatedStyle,
@@ -62,6 +63,7 @@ interface Props {
6263
selectModel?: (model: Model) => Promise<void>;
6364
openModelSheetRef?: React.MutableRefObject<(() => void) | null>;
6465
revealFromTop?: boolean;
66+
headerTitleBottom?: number;
6567
}
6668

6769
const prepareContext = async (
@@ -89,6 +91,7 @@ export default function ChatScreen({
8991
selectModel,
9092
openModelSheetRef,
9193
revealFromTop = false,
94+
headerTitleBottom,
9295
}: Props) {
9396
const inputRef = useRef<{
9497
clear: () => void;
@@ -119,17 +122,11 @@ export default function ChatScreen({
119122

120123
const { theme } = useTheme();
121124
const styles = useMemo(() => createStyles(theme), [theme]);
125+
const headerHeight = useHeaderHeight();
122126

123-
const { height: keyboardHeight, progress: keyboardProgress } =
124-
useReanimatedKeyboardAnimation();
125-
const insetsBottom = theme.insets.bottom;
127+
const keyboardLift = useKeyboardLift();
126128
const chatBarStickyStyle = useAnimatedStyle(() => ({
127-
transform: [
128-
{
129-
translateY:
130-
keyboardHeight.value + keyboardProgress.value * insetsBottom,
131-
},
132-
],
129+
transform: [{ translateY: keyboardLift.value }],
133130
}));
134131

135132
const { settings: chatSettings, setSetting } = useChatSettings(chatId);
@@ -140,7 +137,7 @@ export default function ChatScreen({
140137
// Shared values for KeyboardChatScrollView
141138
const extraContentPadding = useSharedValue(0);
142139
const blankSpace = useSharedValue(0);
143-
const [chatBarSpacerHeight, setChatBarSpacerHeight] = useState(0);
140+
const [chatBarHeight, setChatBarHeight] = useState(0);
144141
const [rootFrame, setRootFrame] = useState({ x: 0, y: 0 });
145142
const [userActionMenu, setUserActionMenu] =
146143
useState<UserMessageActionMenuState>({ isOpen: false });
@@ -149,6 +146,11 @@ export default function ChatScreen({
149146
chatId,
150147
messageHistoryLength: messageHistory.length,
151148
});
149+
const handleBarGrow = useCallback(() => {
150+
setTimeout(() => {
151+
messagesRef.current?.scrollToEndIfAtBottom();
152+
}, 100);
153+
}, []);
152154

153155
// Freeze the scroll view's layout whenever any overlay (model picker,
154156
// attachment sheet) is presented so keyboard dismiss → sheet open doesn't
@@ -165,12 +167,14 @@ export default function ChatScreen({
165167

166168
const handleRootLayout = useCallback(() => {
167169
rootRef.current?.measureInWindow((x, y) => {
168-
setRootFrame({ x, y });
170+
setRootFrame((current) =>
171+
current.x === x && current.y === y ? current : { x, y }
172+
);
169173
});
170174
}, []);
171175

172176
const handleChatBarHeightChange = useCallback((height: number) => {
173-
setChatBarSpacerHeight((current) =>
177+
setChatBarHeight((current) =>
174178
Math.abs(current - height) > LAYOUT_HEIGHT_CHANGE_THRESHOLD
175179
? height
176180
: current
@@ -395,6 +399,11 @@ export default function ChatScreen({
395399
};
396400
}, [rootFrame.x, rootFrame.y, userActionMenu, windowWidth]);
397401

402+
const fadeBottom =
403+
headerTitleBottom !== undefined
404+
? headerTitleBottom - rootFrame.y
405+
: undefined;
406+
398407
return (
399408
<View
400409
ref={rootRef}
@@ -425,38 +434,30 @@ export default function ChatScreen({
425434
onForkMessage={handleForkMessage}
426435
onBranchMarkerPress={handleBranchMarkerPress}
427436
onUserActionMenuChange={setUserActionMenu}
437+
chatBarInset={chatBarHeight}
438+
topInset={headerHeight}
439+
fadeBottom={fadeBottom}
428440
/>
429441
</View>
430442

431-
<View
432-
style={[
433-
styles.chatBarSpacer,
434-
chatBarSpacerHeight > 0 && { height: chatBarSpacerHeight },
435-
]}
436-
>
437-
<Animated.View style={[styles.chatBarSticky, chatBarStickyStyle]}>
438-
<ChatBar
439-
chatId={chatId}
440-
onSend={handleSendMessage}
441-
onSelectModel={handlePresentModelSheet}
442-
onSelectPrompt={handleSelectPrompt}
443-
ref={inputRef}
444-
model={model}
445-
isVisionModel={model?.vision === true}
446-
extraContentPadding={extraContentPadding}
447-
thinkingEnabled={chatSettings?.thinkingEnabled || false}
448-
onThinkingToggle={handleThinkingToggle}
449-
hasMessages={hasMessages}
450-
onAttachmentSheetStateChange={setAttachmentSheetOpen}
451-
onHeightChange={handleChatBarHeightChange}
452-
onBarGrow={() => {
453-
setTimeout(() => {
454-
messagesRef.current?.scrollToEndIfAtBottom();
455-
}, 100);
456-
}}
457-
/>
458-
</Animated.View>
459-
</View>
443+
<Animated.View style={[styles.chatBarSticky, chatBarStickyStyle]}>
444+
<ChatBar
445+
chatId={chatId}
446+
onSend={handleSendMessage}
447+
onSelectModel={handlePresentModelSheet}
448+
onSelectPrompt={handleSelectPrompt}
449+
ref={inputRef}
450+
model={model}
451+
isVisionModel={model?.vision === true}
452+
extraContentPadding={extraContentPadding}
453+
thinkingEnabled={chatSettings?.thinkingEnabled || false}
454+
onThinkingToggle={handleThinkingToggle}
455+
hasMessages={hasMessages}
456+
onAttachmentSheetStateChange={setAttachmentSheetOpen}
457+
onHeightChange={handleChatBarHeightChange}
458+
onBarGrow={handleBarGrow}
459+
/>
460+
</Animated.View>
460461

461462
{userActionMenuPosition && (
462463
<View
@@ -488,11 +489,6 @@ const createStyles = (theme: Theme) =>
488489
elevation: 1,
489490
overflow: 'visible',
490491
},
491-
chatBarSpacer: {
492-
overflow: 'visible',
493-
zIndex: 2,
494-
elevation: 2,
495-
},
496492
userActionMenuOverlay: {
497493
position: 'absolute',
498494
zIndex: 1000,
@@ -503,5 +499,7 @@ const createStyles = (theme: Theme) =>
503499
bottom: 0,
504500
left: 0,
505501
right: 0,
502+
zIndex: 2,
503+
elevation: 2,
506504
},
507505
});

0 commit comments

Comments
 (0)