authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 16:06:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 16:06:31-07:00
logc742edd46c5b6792ec8647f934a4703f9103aa82
treeb59d7523d07a3687c778417069b62bc54a5d96bc
parent63f334baf3432c4404ddfc9e71d33bf16575f0ed
signature Signed by PGP key B5690EEEBB952194

fix(web): show scroll-to-end as soon as the last message slips under the composer (#9280)

Co-authored-by: Claude Code <noreply@anthropic.com>

4 files changed, 22 insertions(+), 29 deletions(-)

apps/web/src/components/ChatView.tsx+2-3
...@@ -4408,8 +4408,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4408,8 +4408,7 @@ function ChatViewContent(props: ChatViewProps) {
4408 // up, and a gesture landing in that window while still pinned would4408 // up, and a gesture landing in that window while still pinned would
4409 // otherwise break follow with no scroll event left to re-arm it.4409 // otherwise break follow with no scroll event left to re-arm it.
4410 const viewportIsAwayFromEnd = () =>4410 const viewportIsAwayFromEnd = () =>
4411 resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) ===4411 resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false;
4412 false;
4413 // Only an upward wheel is a navigation intent; wheeling down while4412 // Only an upward wheel is a navigation intent; wheeling down while
4414 // following either does nothing (at the end) or moves toward it.4413 // following either does nothing (at the end) or moves toward it.
4415 const handleWheel = (event: WheelEvent) => {4414 const handleWheel = (event: WheelEvent) => {
...@@ -4488,7 +4487,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4488,7 +4487,7 @@ function ChatViewContent(props: ChatViewProps) {
4488 }4487 }
4489 removeListeners?.();4488 removeListeners?.();
4490 };4489 };
4491 }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]);4490 }, [activeThread?.id, timelineRealContentOverflowsViewport]);
44924491
4493 const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {4492 const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
4494 // Anchored-end space can be remeasured when the turn completes. Once the4493 // Anchored-end space can be remeasured when the turn completes. Once the
apps/web/src/components/chat/MessagesTimeline.logic.ts+8-10
...@@ -145,23 +145,21 @@ export interface TimelineEndState {...@@ -145,23 +145,21 @@ export interface TimelineEndState {
145 */145 */
146export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;146export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
147147
148export function resolveTimelineIsAtEnd(148export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
149 state: TimelineEndState | undefined,
150 endInset = 0,
151): boolean | undefined {
152 if (!state) {149 if (!state) {
153 return undefined;150 return undefined;
154 }151 }
155 if (state.isAtEnd) {
156 return true;
157 }
158 const { contentLength, scroll, scrollLength } = state;152 const { contentLength, scroll, scrollLength } = state;
159 if (contentLength === undefined || scroll === undefined || scrollLength === undefined) {153 if (contentLength === undefined || scroll === undefined || scrollLength === undefined) {
160 return state.isAtEnd;154 return state.isAtEnd;
161 }155 }
162 // contentLength includes the end inset (composer overlay), so subtract it to156 // contentLength includes the composer inset spacer, but the composer hides
163 // measure the distance to the real content bottom.157 // the same amount of viewport, so the inset cancels: plain
164 return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;158 // contentLength - scroll - scrollLength is the gap between the last real row
159 // and the visible edge above the composer. LegendList's own isAtEnd subtracts
160 // the inset and is true anywhere in the bottom composer-height band, so it is
161 // only a fallback here, never a short-circuit.
162 return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
165}163}
166164
167export function shouldPreserveAssistantLineBreaks(text: string): boolean {165export function shouldPreserveAssistantLineBreaks(text: string): boolean {
apps/web/src/components/chat/MessagesTimeline.test.tsx+10-7
...@@ -378,14 +378,17 @@ describe("MessagesTimeline", () => {...@@ -378,14 +378,17 @@ describe("MessagesTimeline", () => {
378 scrollLength: 800,378 scrollLength: 800,
379 }),379 }),
380 ).toBe(false);380 ).toBe(false);
381 // The composer inset is part of contentLength and must not count as381 // LegendList's isAtEnd is true anywhere within the composer-height band
382 // distance-to-end.382 // (it subtracts the inset); the last row is still hidden under the
383 // composer there, so the flag must not short-circuit the geometry.
383 expect(384 expect(
384 resolveTimelineIsAtEnd(385 resolveTimelineIsAtEnd({
385 { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 },386 isAtEnd: true,
386 100,387 contentLength: 2000,
387 ),388 scroll: 1100,
388 ).toBe(true);389 scrollLength: 800,
390 }),
391 ).toBe(false);
389 // Geometry missing (older state shape): fall back to the strict flag.392 // Geometry missing (older state shape): fall back to the strict flag.
390 expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false);393 expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false);
391394
apps/web/src/components/chat/MessagesTimeline.tsx+2-9
...@@ -555,7 +555,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -555,7 +555,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
555555
556 const handleScroll = useCallback(() => {556 const handleScroll = useCallback(() => {
557 const state = listRef.current?.getState?.();557 const state = listRef.current?.getState?.();
558 const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment);558 const isAtEnd = resolveTimelineIsAtEnd(state);
559 if (isAtEnd !== undefined && !citationPositioning) {559 if (isAtEnd !== undefined && !citationPositioning) {
560 onIsAtEndChange(isAtEnd);560 onIsAtEndChange(isAtEnd);
561 }561 }
...@@ -581,14 +581,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -581,14 +581,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
581581
582 strip.dataset.inView = inView ? "true" : "false";582 strip.dataset.inView = inView ? "true" : "false";
583 }583 }
584 }, [584 }, [citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange]);
585 citationPositioning,
586 contentInsetEndAdjustment,
587 listRef,
588 minimapItems,
589 minimapStripMap,
590 onIsAtEndChange,
591 ]);
592585
593 useEffect(() => {586 useEffect(() => {
594 const frame = requestAnimationFrame(handleScroll);587 const frame = requestAnimationFrame(handleScroll);