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) {
44084408 // up, and a gesture landing in that window while still pinned would
44094409 // otherwise break follow with no scroll event left to re-arm it.
44104410 const viewportIsAwayFromEnd = () =>
4411 resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) ===
4412 false;
4411 resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false;
44134412 // Only an upward wheel is a navigation intent; wheeling down while
44144413 // following either does nothing (at the end) or moves toward it.
44154414 const handleWheel = (event: WheelEvent) => {
......@@ -4488,7 +4487,7 @@ function ChatViewContent(props: ChatViewProps) {
44884487 }
44894488 removeListeners?.();
44904489 };
4491 }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]);
4490 }, [activeThread?.id, timelineRealContentOverflowsViewport]);
44924491
44934492 const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
44944493 // 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 {
145145 */
146146export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
147147
148export function resolveTimelineIsAtEnd(
149 state: TimelineEndState | undefined,
150 endInset = 0,
151): boolean | undefined {
148export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
152149 if (!state) {
153150 return undefined;
154151 }
155 if (state.isAtEnd) {
156 return true;
157 }
158152 const { contentLength, scroll, scrollLength } = state;
159153 if (contentLength === undefined || scroll === undefined || scrollLength === undefined) {
160154 return state.isAtEnd;
161155 }
162 // contentLength includes the end inset (composer overlay), so subtract it to
163 // measure the distance to the real content bottom.
164 return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
156 // contentLength includes the composer inset spacer, but the composer hides
157 // the same amount of viewport, so the inset cancels: plain
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;
165163}
166164
167165export function shouldPreserveAssistantLineBreaks(text: string): boolean {
apps/web/src/components/chat/MessagesTimeline.test.tsx+10-7
......@@ -378,14 +378,17 @@ describe("MessagesTimeline", () => {
378378 scrollLength: 800,
379379 }),
380380 ).toBe(false);
381 // The composer inset is part of contentLength and must not count as
382 // distance-to-end.
381 // LegendList's isAtEnd is true anywhere within the composer-height band
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.
383384 expect(
384 resolveTimelineIsAtEnd(
385 { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 },
386 100,
387 ),
388 ).toBe(true);
385 resolveTimelineIsAtEnd({
386 isAtEnd: true,
387 contentLength: 2000,
388 scroll: 1100,
389 scrollLength: 800,
390 }),
391 ).toBe(false);
389392 // Geometry missing (older state shape): fall back to the strict flag.
390393 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({
555555
556556 const handleScroll = useCallback(() => {
557557 const state = listRef.current?.getState?.();
558 const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment);
558 const isAtEnd = resolveTimelineIsAtEnd(state);
559559 if (isAtEnd !== undefined && !citationPositioning) {
560560 onIsAtEndChange(isAtEnd);
561561 }
......@@ -581,14 +581,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
581581
582582 strip.dataset.inView = inView ? "true" : "false";
583583 }
584 }, [
585 citationPositioning,
586 contentInsetEndAdjustment,
587 listRef,
588 minimapItems,
589 minimapStripMap,
590 onIsAtEndChange,
591 ]);
584 }, [citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange]);
592585
593586 useEffect(() => {
594587 const frame = requestAnimationFrame(handleScroll);