Skip to content

Commit 247ea02

Browse files
kfaracikclaude
andcommitted
fix(chat): keep a sent message pinned while the list settles
Sending the second or a later message sometimes left it where it was appended instead of pinned below the header, forcing a manual scroll. The pin was a single `scrollToEnd` fired from `onContentSizeChange`, but the list's maximum offset keeps moving for a few hundred milliseconds afterwards: `blankSpace` is applied as the scroll view's bottom contentInset and Reanimated commits it on the UI thread a frame later, the inset is recomputed once the new rows report their heights, and keyboard-controller drives its own per-frame scrollTo while the keyboard dismissed by the send animates out. Whichever lands last wins, so a lone scroll aims at a stale end and falls short. `useScrollSettler` re-asserts the position across that window (animated while the send transition is still running, instant afterwards), `recomputeBlankSpace` re-snaps whenever it moves the inset, a drag cancels the pin so the user is never fought, and the keyboard-dismiss snap no longer skips a send in flight just because the list was scrolled up when the message was written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 93b0d8a commit 247ea02

3 files changed

Lines changed: 261 additions & 32 deletions

File tree

__tests__/useScrollSettler.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { act, renderHook } from '@testing-library/react-native';
2+
import {
3+
SCROLL_SETTLE_STEPS,
4+
useScrollSettler,
5+
} from '../components/chat-screen/useScrollSettler';
6+
7+
const LAST_STEP = SCROLL_SETTLE_STEPS[SCROLL_SETTLE_STEPS.length - 1];
8+
9+
const setup = () => {
10+
const snap = jest.fn();
11+
const { result, unmount } = renderHook(() => useScrollSettler(snap));
12+
return { snap, result, unmount };
13+
};
14+
15+
beforeEach(() => {
16+
jest.useFakeTimers();
17+
});
18+
19+
afterEach(() => {
20+
jest.useRealTimers();
21+
});
22+
23+
describe('useScrollSettler', () => {
24+
it('snaps immediately, then re-snaps at every step', () => {
25+
const { snap, result } = setup();
26+
27+
act(() => result.current.start());
28+
expect(snap).toHaveBeenCalledTimes(1);
29+
expect(snap).toHaveBeenLastCalledWith(true);
30+
31+
act(() => jest.advanceTimersByTime(LAST_STEP.delay));
32+
33+
expect(snap).toHaveBeenCalledTimes(1 + SCROLL_SETTLE_STEPS.length);
34+
expect(snap.mock.calls.slice(1)).toEqual(
35+
SCROLL_SETTLE_STEPS.map((step) => [step.animated])
36+
);
37+
});
38+
39+
it('keeps the send transition animated, then corrects instantly', () => {
40+
const { snap, result } = setup();
41+
42+
act(() => result.current.start());
43+
act(() => jest.advanceTimersByTime(220));
44+
expect(snap.mock.calls.every(([animated]) => animated === true)).toBe(true);
45+
46+
snap.mockClear();
47+
act(() => jest.advanceTimersByTime(LAST_STEP.delay - 220));
48+
expect(snap).toHaveBeenCalled();
49+
expect(snap.mock.calls.every(([animated]) => animated === false)).toBe(
50+
true
51+
);
52+
});
53+
54+
it('keeps re-snapping past the keyboard-dismiss animation', () => {
55+
const { snap, result } = setup();
56+
57+
act(() => result.current.start());
58+
act(() => jest.advanceTimersByTime(300));
59+
const beforeTail = snap.mock.calls.length;
60+
61+
act(() => jest.advanceTimersByTime(LAST_STEP.delay - 300));
62+
63+
expect(snap.mock.calls.length).toBeGreaterThan(beforeTail);
64+
});
65+
66+
it('stops re-snapping once cancelled', () => {
67+
const { snap, result } = setup();
68+
69+
act(() => result.current.start());
70+
snap.mockClear();
71+
act(() => result.current.cancel());
72+
73+
act(() => jest.advanceTimersByTime(LAST_STEP.delay * 2));
74+
75+
expect(snap).not.toHaveBeenCalled();
76+
expect(result.current.isSettling()).toBe(false);
77+
});
78+
79+
it('reports settling only inside the window', () => {
80+
const { result } = setup();
81+
82+
expect(result.current.isSettling()).toBe(false);
83+
84+
act(() => result.current.start());
85+
expect(result.current.isSettling()).toBe(true);
86+
87+
act(() => jest.advanceTimersByTime(LAST_STEP.delay));
88+
expect(result.current.isSettling()).toBe(false);
89+
});
90+
91+
it('resettles only while a pin is in flight', () => {
92+
const { snap, result } = setup();
93+
94+
act(() => result.current.resettle());
95+
expect(snap).not.toHaveBeenCalled();
96+
97+
act(() => result.current.start());
98+
snap.mockClear();
99+
act(() => result.current.resettle());
100+
expect(snap).toHaveBeenLastCalledWith(true);
101+
102+
act(() => jest.advanceTimersByTime(340));
103+
snap.mockClear();
104+
act(() => result.current.resettle());
105+
expect(snap).toHaveBeenLastCalledWith(false);
106+
107+
act(() => jest.advanceTimersByTime(LAST_STEP.delay));
108+
snap.mockClear();
109+
act(() => result.current.resettle());
110+
expect(snap).not.toHaveBeenCalled();
111+
});
112+
113+
it('restarts cleanly when a second message is sent mid-window', () => {
114+
const { snap, result } = setup();
115+
116+
act(() => result.current.start());
117+
act(() => jest.advanceTimersByTime(SCROLL_SETTLE_STEPS[0].delay));
118+
snap.mockClear();
119+
120+
act(() => result.current.start());
121+
expect(snap).toHaveBeenLastCalledWith(true);
122+
123+
snap.mockClear();
124+
act(() => jest.advanceTimersByTime(LAST_STEP.delay));
125+
expect(snap).toHaveBeenCalledTimes(SCROLL_SETTLE_STEPS.length);
126+
});
127+
128+
it('drops pending timers on unmount', () => {
129+
const { snap, result, unmount } = setup();
130+
131+
act(() => result.current.start());
132+
snap.mockClear();
133+
unmount();
134+
135+
act(() => jest.advanceTimersByTime(LAST_STEP.delay * 2));
136+
137+
expect(snap).not.toHaveBeenCalled();
138+
});
139+
});

components/chat-screen/Messages.tsx

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import BranchMarker from './BranchMarker';
4949
import Toast from 'react-native-toast-message';
5050
import { SUPPORTS_USER_ACTION_MENU } from '../../constants/chat-screen';
5151
import { useKeyboardLift } from './useKeyboardLift';
52+
import { useScrollSettler } from './useScrollSettler';
5253
import { visibleMessageText } from '../../utils/messageText';
5354

5455
/**
@@ -68,6 +69,8 @@ const GENERATION_ERROR_MEASUREMENT_KEY = 'generation-error';
6869

6970
const MESSAGE_PIN_OFFSET = 8;
7071

72+
const PIN_FALLBACK_MS = 300;
73+
7174
export interface MessagesHandle {
7275
onMessageSent: () => void;
7376
scrollToEnd: () => void;
@@ -227,6 +230,17 @@ const Messages = ({
227230
scrollRef.current?.scrollToEnd({ animated: false });
228231
}, []);
229232

233+
const {
234+
start: startPin,
235+
cancel: cancelPin,
236+
resettle: resettlePin,
237+
isSettling: isPinSettling,
238+
} = useScrollSettler(
239+
useCallback((animated: boolean) => {
240+
scrollRef.current?.scrollToEnd({ animated });
241+
}, [])
242+
);
243+
230244
const scheduleInitialScrollToEnd = useCallback(() => {
231245
clearInitialScrollTimers();
232246
snapToEnd();
@@ -306,9 +320,10 @@ const Messages = ({
306320
opacity.set(0);
307321
pinActive.current = false;
308322
blankSpace.set(0);
323+
cancelPin();
309324
}
310325
prevChatLengthRef.current = chatHistory.length;
311-
}, [chatHistory.length, opacity, blankSpace]);
326+
}, [chatHistory.length, opacity, blankSpace, cancelPin]);
312327

313328
useLayoutEffect(() => clearInitialScrollTimers, [clearInitialScrollTimers]);
314329

@@ -372,8 +387,9 @@ const Messages = ({
372387
};
373388

374389
if (
375-
wasAtBottomDuringKeyboard.current &&
376-
!userScrolledDuringKeyboard.current
390+
(wasAtBottomDuringKeyboard.current &&
391+
!userScrolledDuringKeyboard.current) ||
392+
isPinSettling()
377393
) {
378394
clearPendingSnap();
379395
firstFrame = requestAnimationFrame(() => {
@@ -397,7 +413,7 @@ const Messages = ({
397413
showSub.remove();
398414
hideSub.remove();
399415
};
400-
}, [closeUserActionMenu]);
416+
}, [closeUserActionMenu, isPinSettling]);
401417

402418
// Armed from onMessageSent until the chat is cleared; gates recomputeBlankSpace.
403419
// Stays armed past end-of-stream so the final layout (once the stats row and
@@ -420,7 +436,29 @@ const Messages = ({
420436
listBottomPadding +
421437
MESSAGE_PIN_OFFSET;
422438
blankSpace.set(Math.max(0, raw));
423-
}, [blankSpace, listBottomPadding, listTopPadding]);
439+
resettlePin();
440+
}, [blankSpace, listBottomPadding, listTopPadding, resettlePin]);
441+
442+
const pinFallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
443+
const clearPinFallback = useCallback(() => {
444+
if (pinFallbackTimer.current) {
445+
clearTimeout(pinFallbackTimer.current);
446+
pinFallbackTimer.current = null;
447+
}
448+
}, []);
449+
450+
const runPendingPin = useCallback(() => {
451+
if (!pendingPinRef.current) return;
452+
pendingPinRef.current = false;
453+
clearPinFallback();
454+
closeUserActionMenu();
455+
if (Platform.OS !== 'ios' && containerHeight.current > 0) {
456+
blankSpace.set(containerHeight.current - topInset + MESSAGE_PIN_OFFSET);
457+
}
458+
startPin();
459+
}, [blankSpace, clearPinFallback, closeUserActionMenu, startPin, topInset]);
460+
461+
useLayoutEffect(() => clearPinFallback, [clearPinFallback]);
424462

425463
useImperativeHandle(
426464
ref,
@@ -453,9 +491,18 @@ const Messages = ({
453491
}
454492
}
455493
pendingPinRef.current = true;
494+
clearPinFallback();
495+
pinFallbackTimer.current = setTimeout(runPendingPin, PIN_FALLBACK_MS);
456496
},
457497
}),
458-
[blankSpace, closeUserActionMenu, opacity, topInset]
498+
[
499+
blankSpace,
500+
clearPinFallback,
501+
closeUserActionMenu,
502+
opacity,
503+
runPendingPin,
504+
topInset,
505+
]
459506
);
460507

461508
const handleContainerLayout = useCallback(
@@ -596,7 +643,8 @@ const Messages = ({
596643
if (keyboardOpenRef.current) {
597644
userScrolledDuringKeyboard.current = true;
598645
}
599-
}, []);
646+
cancelPin();
647+
}, [cancelPin]);
600648

601649
const handleForkMessage = useCallback(
602650
(message: Message) => {
@@ -620,28 +668,7 @@ const Messages = ({
620668
return;
621669
}
622670

623-
// After send: now that the new chat row has rendered, seed
624-
// blankSpace and scroll to end. Doing this here (instead of
625-
// synchronously in onMessageSent) avoids a 1-frame flick where
626-
// the old content gets lifted by the new inset before the new
627-
// DOM commits.
628-
// Android: defer the pin here (not in onMessageSent) so the new
629-
// row has committed before we expand blankSpace. Animate both
630-
// blankSpace and scrollToEnd for a smooth transition.
631-
if (pendingPinRef.current) {
632-
pendingPinRef.current = false;
633-
closeUserActionMenu();
634-
if (Platform.OS !== 'ios' && containerHeight.current > 0) {
635-
blankSpace.set(
636-
containerHeight.current - topInset + MESSAGE_PIN_OFFSET
637-
);
638-
}
639-
requestAnimationFrame(() => {
640-
requestAnimationFrame(() => {
641-
scrollRef.current?.scrollToEnd({ animated: true });
642-
});
643-
});
644-
}
671+
runPendingPin();
645672

646673
// During streaming, check if content has grown past the viewport
647674
// so the scroll-to-bottom button can appear without the user
@@ -662,12 +689,10 @@ const Messages = ({
662689
}
663690
},
664691
[
665-
blankSpace,
666-
closeUserActionMenu,
667692
listBottomPadding,
668693
listTopPadding,
694+
runPendingPin,
669695
scheduleInitialScrollToEnd,
670-
topInset,
671696
]
672697
);
673698

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
export const SCROLL_SETTLE_STEPS = [
4+
{ delay: 60, animated: true },
5+
{ delay: 130, animated: true },
6+
{ delay: 220, animated: true },
7+
{ delay: 340, animated: false },
8+
{ delay: 480, animated: false },
9+
];
10+
11+
const SETTLE_WINDOW_MS =
12+
SCROLL_SETTLE_STEPS[SCROLL_SETTLE_STEPS.length - 1].delay;
13+
14+
const ANIMATED_WINDOW_MS = 300;
15+
16+
export interface ScrollSettler {
17+
start: () => void;
18+
cancel: () => void;
19+
resettle: () => void;
20+
isSettling: () => boolean;
21+
}
22+
23+
export const useScrollSettler = (
24+
snapToEnd: (animated: boolean) => void
25+
): ScrollSettler => {
26+
const snapRef = useRef(snapToEnd);
27+
useEffect(() => {
28+
snapRef.current = snapToEnd;
29+
}, [snapToEnd]);
30+
31+
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
32+
const startedAt = useRef(0);
33+
const settlingUntil = useRef(0);
34+
35+
const cancel = useCallback(() => {
36+
timers.current.forEach(clearTimeout);
37+
timers.current = [];
38+
settlingUntil.current = 0;
39+
}, []);
40+
41+
const isSettling = useCallback(() => Date.now() < settlingUntil.current, []);
42+
43+
const start = useCallback(() => {
44+
cancel();
45+
startedAt.current = Date.now();
46+
settlingUntil.current = startedAt.current + SETTLE_WINDOW_MS;
47+
snapRef.current(true);
48+
SCROLL_SETTLE_STEPS.forEach(({ delay, animated }) => {
49+
timers.current.push(
50+
setTimeout(() => {
51+
snapRef.current(animated);
52+
}, delay)
53+
);
54+
});
55+
}, [cancel]);
56+
57+
const resettle = useCallback(() => {
58+
if (!isSettling()) return;
59+
snapRef.current(Date.now() - startedAt.current < ANIMATED_WINDOW_MS);
60+
}, [isSettling]);
61+
62+
useEffect(() => cancel, [cancel]);
63+
64+
return { start, cancel, resettle, isSettling };
65+
};

0 commit comments

Comments
 (0)