| author | |
| committer | |
| log | c742edd46c5b6792ec8647f934a4703f9103aa82 |
| tree | b59d7523d07a3687c778417069b62bc54a5d96bc |
| parent | 63f334baf3432c4404ddfc9e71d33bf16575f0ed |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 4408 | // up, and a gesture landing in that window while still pinned would |
| 4409 | 4409 | // otherwise break follow with no scroll event left to re-arm it. |
| 4410 | 4410 | const viewportIsAwayFromEnd = () => |
| 4411 | resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === | |
| 4412 | false; | |
| 4411 | resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; | |
| 4413 | 4412 | // Only an upward wheel is a navigation intent; wheeling down while |
| 4414 | 4413 | // following either does nothing (at the end) or moves toward it. |
| 4415 | 4414 | const handleWheel = (event: WheelEvent) => { |
| ... | ... | @@ -4488,7 +4487,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4488 | 4487 | } |
| 4489 | 4488 | removeListeners?.(); |
| 4490 | 4489 | }; |
| 4491 | }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); | |
| 4490 | }, [activeThread?.id, timelineRealContentOverflowsViewport]); | |
| 4492 | 4491 | |
| 4493 | 4492 | const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { |
| 4494 | 4493 | // 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 | 145 | */ |
| 146 | 146 | export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; |
| 147 | 147 | |
| 148 | export function resolveTimelineIsAtEnd( | |
| 149 | state: TimelineEndState | undefined, | |
| 150 | endInset = 0, | |
| 151 | ): boolean | undefined { | |
| 148 | export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { | |
| 152 | 149 | if (!state) { |
| 153 | 150 | return undefined; |
| 154 | 151 | } |
| 155 | if (state.isAtEnd) { | |
| 156 | return true; | |
| 157 | } | |
| 158 | 152 | const { contentLength, scroll, scrollLength } = state; |
| 159 | 153 | if (contentLength === undefined || scroll === undefined || scrollLength === undefined) { |
| 160 | 154 | return state.isAtEnd; |
| 161 | 155 | } |
| 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; | |
| 165 | 163 | } |
| 166 | 164 | |
| 167 | 165 | export function shouldPreserveAssistantLineBreaks(text: string): boolean { |
apps/web/src/components/chat/MessagesTimeline.test.tsx+10-7| ... | ... | @@ -378,14 +378,17 @@ describe("MessagesTimeline", () => { |
| 378 | 378 | scrollLength: 800, |
| 379 | 379 | }), |
| 380 | 380 | ).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. | |
| 383 | 384 | 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); | |
| 389 | 392 | // Geometry missing (older state shape): fall back to the strict flag. |
| 390 | 393 | expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); |
| 391 | 394 |
apps/web/src/components/chat/MessagesTimeline.tsx+2-9| ... | ... | @@ -555,7 +555,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 555 | 555 | |
| 556 | 556 | const handleScroll = useCallback(() => { |
| 557 | 557 | const state = listRef.current?.getState?.(); |
| 558 | const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); | |
| 558 | const isAtEnd = resolveTimelineIsAtEnd(state); | |
| 559 | 559 | if (isAtEnd !== undefined && !citationPositioning) { |
| 560 | 560 | onIsAtEndChange(isAtEnd); |
| 561 | 561 | } |
| ... | ... | @@ -581,14 +581,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 581 | 581 | |
| 582 | 582 | strip.dataset.inView = inView ? "true" : "false"; |
| 583 | 583 | } |
| 584 | }, [ | |
| 585 | citationPositioning, | |
| 586 | contentInsetEndAdjustment, | |
| 587 | listRef, | |
| 588 | minimapItems, | |
| 589 | minimapStripMap, | |
| 590 | onIsAtEndChange, | |
| 591 | ]); | |
| 584 | }, [citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); | |
| 592 | 585 | |
| 593 | 586 | useEffect(() => { |
| 594 | 587 | const frame = requestAnimationFrame(handleScroll); |