| author | |
| committer | |
| log | 1c7d059f550a53dd94d5b9802640ecd11b759d1a |
| tree | 5182c84b4739a354616240fde9020aadb74a3094 |
| parent | 6fa457607886caf096e7871b67e447f76d3772f6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>5 files changed, 258 insertions(+), 35 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+69-2| ... | @@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1335 | ); | 1335 | ); |
| 1336 | const [viewportHeight, setViewportHeight] = useState(0); | 1336 | const [viewportHeight, setViewportHeight] = useState(0); |
| 1337 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); | 1337 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); |
| 1338 | // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed | ||
| 1339 | // whenever the viewport drifts back inside its geometric threshold, which | ||
| 1340 | // yanked users off history they were reading every time a stream chunk grew | ||
| 1341 | // a row. Follow breaks when the user scrolls up and away, and re-arms only | ||
| 1342 | // when the list actually returns to the end (or on send / thread switch). | ||
| 1343 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); | ||
| 1344 | const endFollowEnabledRef = useRef(true); | ||
| 1345 | // A "user scroll session" spans from drag start through the end of its | ||
| 1346 | // momentum; only motion inside a session can break follow, so MVCP | ||
| 1347 | // compensations and programmatic scrolls never strand a follower. | ||
| 1348 | const userScrollSessionRef = useRef(false); | ||
| 1349 | const setEndFollow = useCallback((enabled: boolean) => { | ||
| 1350 | if (endFollowEnabledRef.current === enabled) { | ||
| 1351 | return; | ||
| 1352 | } | ||
| 1353 | endFollowEnabledRef.current = enabled; | ||
| 1354 | setEndFollowEnabled(enabled); | ||
| 1355 | }, []); | ||
| 1338 | const [interactionState, setInteractionState] = useState<{ | 1356 | const [interactionState, setInteractionState] = useState<{ |
| 1339 | readonly copiedRowId: string | null; | 1357 | readonly copiedRowId: string | null; |
| 1340 | readonly expandedWorkGroups: Record<string, boolean>; | 1358 | readonly expandedWorkGroups: Record<string, boolean>; |
| ... | @@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1454 | const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; | 1472 | const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; |
| 1455 | nearListEnd.value = | 1473 | nearListEnd.value = |
| 1456 | contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; | 1474 | contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; |
| 1475 | |||
| 1476 | // Latch bookkeeping. LegendList recomputes its inset-aware end distance | ||
| 1477 | // before invoking this handler, so getState() is current. Returning to | ||
| 1478 | // the end re-arms follow no matter who scrolled (the user, or our own | ||
| 1479 | // scroll-to-end); moving away breaks it only during a user-initiated | ||
| 1480 | // scroll session, so MVCP compensations and programmatic repositioning | ||
| 1481 | // can never strand a follower. | ||
| 1482 | const listState = props.listRef.current?.getState(); | ||
| 1483 | if (listState) { | ||
| 1484 | if (listState.isWithinMaintainScrollAtEndThreshold) { | ||
| 1485 | setEndFollow(true); | ||
| 1486 | } else if (userScrollSessionRef.current) { | ||
| 1487 | setEndFollow(false); | ||
| 1488 | } | ||
| 1489 | } | ||
| 1457 | }, | 1490 | }, |
| 1458 | [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd], | 1491 | [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow], |
| 1459 | ); | 1492 | ); |
| 1493 | const handleScrollBeginDrag = useCallback(() => { | ||
| 1494 | userScrollSessionRef.current = true; | ||
| 1495 | }, []); | ||
| 1496 | // The session must survive past finger-lift so momentum that carries the | ||
| 1497 | // user away from the end still breaks follow; a drag released with no | ||
| 1498 | // momentum ends its session at the release itself, otherwise at momentum | ||
| 1499 | // end. Leaving a session open would let a later animated maintain-scroll | ||
| 1500 | // read as user motion and break follow spuriously. | ||
| 1501 | const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 1502 | const velocity = event.nativeEvent.velocity?.y ?? 0; | ||
| 1503 | if (Math.abs(velocity) < 0.05) { | ||
| 1504 | userScrollSessionRef.current = false; | ||
| 1505 | } | ||
| 1506 | }, []); | ||
| 1507 | const handleMomentumScrollEnd = useCallback(() => { | ||
| 1508 | userScrollSessionRef.current = false; | ||
| 1509 | }, []); | ||
| 1460 | 1510 | ||
| 1461 | // Gated variant of the 180ms feed layout slide. Instant while browsing | 1511 | // Gated variant of the 180ms feed layout slide. Instant while browsing |
| 1462 | // history: maintainVisibleContentPosition compensates the scroll offset in | 1512 | // history: maintainVisibleContentPosition compensates the scroll offset in |
| ... | @@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1496 | reportHeaderMaterialVisibility(false); | 1546 | reportHeaderMaterialVisibility(false); |
| 1497 | }, [props.threadId, reportHeaderMaterialVisibility]); | 1547 | }, [props.threadId, reportHeaderMaterialVisibility]); |
| 1498 | 1548 | ||
| 1549 | // A thread switch opens pinned to the end; a send explicitly returns to the | ||
| 1550 | // live edge (ThreadDetailScreen scrolls the new message into place). Both | ||
| 1551 | // re-arm follow regardless of where the user had scrolled before. | ||
| 1552 | useEffect(() => { | ||
| 1553 | userScrollSessionRef.current = false; | ||
| 1554 | setEndFollow(true); | ||
| 1555 | }, [props.threadId, setEndFollow]); | ||
| 1556 | useEffect(() => { | ||
| 1557 | if (props.anchorMessageId !== null) { | ||
| 1558 | userScrollSessionRef.current = false; | ||
| 1559 | setEndFollow(true); | ||
| 1560 | } | ||
| 1561 | }, [props.anchorMessageId, setEndFollow]); | ||
| 1562 | |||
| 1499 | const expandedWorkGroupIds = useMemo(() => { | 1563 | const expandedWorkGroupIds = useMemo(() => { |
| 1500 | const ids = new Set<string>(); | 1564 | const ids = new Set<string>(); |
| 1501 | for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { | 1565 | for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { |
| ... | @@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1847 | // anchor scrolls also lets it correct a scroll that landed on a | 1911 | // anchor scrolls also lets it correct a scroll that landed on a |
| 1848 | // stale end target once the anchor row finishes measuring. | 1912 | // stale end target once the anchor row finishes measuring. |
| 1849 | maintainScrollAtEnd={ | 1913 | maintainScrollAtEnd={ |
| 1850 | disclosureToggleSettling | 1914 | disclosureToggleSettling || !endFollowEnabled |
| 1851 | ? false | 1915 | ? false |
| 1852 | : { | 1916 | : { |
| 1853 | animated: true, | 1917 | animated: true, |
| ... | @@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1896 | alignItemsAtEnd | 1960 | alignItemsAtEnd |
| 1897 | initialScrollAtEnd | 1961 | initialScrollAtEnd |
| 1898 | onScroll={handleScroll} | 1962 | onScroll={handleScroll} |
| 1963 | onScrollBeginDrag={handleScrollBeginDrag} | ||
| 1964 | onScrollEndDrag={handleScrollEndDrag} | ||
| 1965 | onMomentumScrollEnd={handleMomentumScrollEnd} | ||
| 1899 | scrollEventThrottle={16} | 1966 | scrollEventThrottle={16} |
| 1900 | ListHeaderComponent={ | 1967 | ListHeaderComponent={ |
| 1901 | <> | 1968 | <> |
apps/web/src/components/ChatView.tsx+119-24| ... | @@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; | ... | @@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; |
| 244 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; | 244 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 245 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | 245 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 246 | import { MessagesTimeline } from "./chat/MessagesTimeline"; | 246 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 247 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; | ||
| 247 | import { ChatHeader } from "./chat/ChatHeader"; | 248 | import { ChatHeader } from "./chat/ChatHeader"; |
| 248 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; | 249 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 249 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 250 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| ... | @@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 3567 | new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), | 3568 | new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), |
| 3568 | ); | 3569 | ); |
| 3569 | const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end"); | 3570 | const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end"); |
| 3571 | // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd | ||
| 3572 | // re-pins on its own (independent of the refs), so the timeline needs a | ||
| 3573 | // render-visible flag to switch it off once the user scrolls away. | ||
| 3574 | const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); | ||
| 3570 | const pendingTimelineAnchorRef = useRef<MessageId | null>(null); | 3575 | const pendingTimelineAnchorRef = useRef<MessageId | null>(null); |
| 3571 | const positionedTimelineAnchorRef = useRef<MessageId | null>(null); | 3576 | const positionedTimelineAnchorRef = useRef<MessageId | null>(null); |
| 3572 | const settledTimelineAnchorRef = useRef<MessageId | null>(null); | 3577 | const settledTimelineAnchorRef = useRef<MessageId | null>(null); |
| ... | @@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3583 | anchorUserScrollGenerationRef.current += 1; | 3588 | anchorUserScrollGenerationRef.current += 1; |
| 3584 | timelineScrollModeRef.current = "free-scrolling"; | 3589 | timelineScrollModeRef.current = "free-scrolling"; |
| 3585 | liveFollowUserScrollGenerationRef.current = null; | 3590 | liveFollowUserScrollGenerationRef.current = null; |
| 3591 | setTimelineLiveFollowEnabled(false); | ||
| 3586 | pendingTimelineAnchorRef.current = null; | 3592 | pendingTimelineAnchorRef.current = null; |
| 3587 | positionedTimelineAnchorRef.current = null; | 3593 | positionedTimelineAnchorRef.current = null; |
| 3588 | settledTimelineAnchorRef.current = null; | 3594 | settledTimelineAnchorRef.current = null; |
| ... | @@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3654 | isAtEndRef.current = true; | 3660 | isAtEndRef.current = true; |
| 3655 | timelineScrollModeRef.current = "following-end"; | 3661 | timelineScrollModeRef.current = "following-end"; |
| 3656 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | 3662 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 3663 | setTimelineLiveFollowEnabled(true); | ||
| 3657 | pendingTimelineAnchorRef.current = null; | 3664 | pendingTimelineAnchorRef.current = null; |
| 3658 | activeTimelineAnchorIndexRef.current = null; | 3665 | activeTimelineAnchorIndexRef.current = null; |
| 3659 | showScrollDebouncer.current.cancel(); | 3666 | showScrollDebouncer.current.cancel(); |
| ... | @@ -3662,37 +3669,120 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3662,37 +3669,120 @@ function ChatViewContent(props: ChatViewProps) { |
| 3662 | }, []); | 3669 | }, []); |
| 3663 | useEffect(() => { | 3670 | useEffect(() => { |
| 3664 | let removeListeners: (() => void) | null = null; | 3671 | let removeListeners: (() => void) | null = null; |
| 3665 | const frame = requestAnimationFrame(() => { | 3672 | let frame: number | null = null; |
| 3666 | const scrollNode = legendListRef.current?.getScrollableNode(); | 3673 | const attach = (remainingAttempts: number) => { |
| 3667 | if (!scrollNode) { | 3674 | frame = requestAnimationFrame(() => { |
| 3668 | return; | 3675 | frame = null; |
| 3669 | } | 3676 | const scrollNode = legendListRef.current?.getScrollableNode(); |
| 3670 | const handleManualNavigation = () => { | 3677 | if (!scrollNode) { |
| 3671 | cancelTimelineLiveFollowForUserNavigationRef.current(); | 3678 | // The list may not have mounted on the first frame after a thread |
| 3672 | }; | 3679 | // switch — without a retry the opt-out listeners never attach and |
| 3673 | scrollNode.addEventListener("wheel", handleManualNavigation, { | 3680 | // live-follow becomes impossible to escape for the whole thread. |
| 3674 | passive: true, | 3681 | if (remainingAttempts > 0) { |
| 3675 | }); | 3682 | attach(remainingAttempts - 1); |
| 3676 | scrollNode.addEventListener("touchmove", handleManualNavigation, { | 3683 | } |
| 3677 | passive: true, | 3684 | return; |
| 3678 | }); | 3685 | } |
| 3679 | scrollNode.addEventListener("pointerdown", handleManualNavigation, { | 3686 | const handleManualNavigation = () => { |
| 3680 | passive: true, | 3687 | cancelTimelineLiveFollowForUserNavigationRef.current(); |
| 3688 | }; | ||
| 3689 | // The gestures below must only break follow when they can actually | ||
| 3690 | // move the viewport away from the live edge. Follow now gates | ||
| 3691 | // LegendList's maintainScrollAtEnd, so a spurious break while pinned | ||
| 3692 | // at the end produces no scroll event, never re-arms, and streaming | ||
| 3693 | // silently stops following. Underflowing content can't scroll at all, | ||
| 3694 | // so nothing there should break follow. | ||
| 3695 | const contentScrollsUp = () => timelineRealContentOverflowsViewport(); | ||
| 3696 | // The follow re-arm band, not the strict flag: streaming growth makes | ||
| 3697 | // isAtEnd flicker false for a frame before the follow scroll catches | ||
| 3698 | // up, and a gesture landing in that window while still pinned would | ||
| 3699 | // otherwise break follow with no scroll event left to re-arm it. | ||
| 3700 | const viewportIsAwayFromEnd = () => | ||
| 3701 | resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === | ||
| 3702 | false; | ||
| 3703 | // Only an upward wheel is a navigation intent; wheeling down while | ||
| 3704 | // following either does nothing (at the end) or moves toward it. | ||
| 3705 | const handleWheel = (event: WheelEvent) => { | ||
| 3706 | if (event.deltaY < 0 && contentScrollsUp()) { | ||
| 3707 | handleManualNavigation(); | ||
| 3708 | } | ||
| 3709 | }; | ||
| 3710 | // Touch direction isn't observable here (touchmove fires on any | ||
| 3711 | // finger motion, scrolling or not), so break only once the drag has | ||
| 3712 | // actually carried the viewport out of the end band — an upward flick | ||
| 3713 | // gets there within its first few events and later touchmoves break. | ||
| 3714 | const handleTouchMove = () => { | ||
| 3715 | if (viewportIsAwayFromEnd()) { | ||
| 3716 | handleManualNavigation(); | ||
| 3717 | } | ||
| 3718 | }; | ||
| 3719 | // Scrollbar drags produce no wheel/touch events; they are the only | ||
| 3720 | // pointerdowns whose target is the scroll node itself rather than a | ||
| 3721 | // message row. Content clicks break follow only away from the end | ||
| 3722 | // (reading or selecting up there must hold position); clicking near | ||
| 3723 | // the live edge keeps following. | ||
| 3724 | const handlePointerDown = (event: PointerEvent) => { | ||
| 3725 | if (event.target === scrollNode) { | ||
| 3726 | if (contentScrollsUp()) { | ||
| 3727 | handleManualNavigation(); | ||
| 3728 | } | ||
| 3729 | return; | ||
| 3730 | } | ||
| 3731 | if (viewportIsAwayFromEnd()) { | ||
| 3732 | handleManualNavigation(); | ||
| 3733 | } | ||
| 3734 | }; | ||
| 3735 | // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and | ||
| 3736 | // pointer events entirely; without this the timeline yanks back to | ||
| 3737 | // the end on the next stream chunk. | ||
| 3738 | const handleKeyDown = (event: KeyboardEvent) => { | ||
| 3739 | switch (event.key) { | ||
| 3740 | case "PageUp": | ||
| 3741 | case "Home": | ||
| 3742 | case "ArrowUp": | ||
| 3743 | if (contentScrollsUp()) { | ||
| 3744 | handleManualNavigation(); | ||
| 3745 | } | ||
| 3746 | break; | ||
| 3747 | default: | ||
| 3748 | break; | ||
| 3749 | } | ||
| 3750 | }; | ||
| 3751 | scrollNode.addEventListener("wheel", handleWheel, { | ||
| 3752 | passive: true, | ||
| 3753 | }); | ||
| 3754 | scrollNode.addEventListener("touchmove", handleTouchMove, { | ||
| 3755 | passive: true, | ||
| 3756 | }); | ||
| 3757 | scrollNode.addEventListener("pointerdown", handlePointerDown, { | ||
| 3758 | passive: true, | ||
| 3759 | }); | ||
| 3760 | scrollNode.addEventListener("keydown", handleKeyDown); | ||
| 3761 | removeListeners = () => { | ||
| 3762 | scrollNode.removeEventListener("wheel", handleWheel); | ||
| 3763 | scrollNode.removeEventListener("touchmove", handleTouchMove); | ||
| 3764 | scrollNode.removeEventListener("pointerdown", handlePointerDown); | ||
| 3765 | scrollNode.removeEventListener("keydown", handleKeyDown); | ||
| 3766 | }; | ||
| 3681 | }); | 3767 | }); |
| 3682 | removeListeners = () => { | 3768 | }; |
| 3683 | scrollNode.removeEventListener("wheel", handleManualNavigation); | 3769 | attach(12); |
| 3684 | scrollNode.removeEventListener("touchmove", handleManualNavigation); | ||
| 3685 | scrollNode.removeEventListener("pointerdown", handleManualNavigation); | ||
| 3686 | }; | ||
| 3687 | }); | ||
| 3688 | 3770 | ||
| 3689 | return () => { | 3771 | return () => { |
| 3690 | cancelAnimationFrame(frame); | 3772 | if (frame !== null) { |
| 3773 | cancelAnimationFrame(frame); | ||
| 3774 | } | ||
| 3691 | removeListeners?.(); | 3775 | removeListeners?.(); |
| 3692 | }; | 3776 | }; |
| 3693 | }, [activeThread?.id]); | 3777 | }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); |
| 3694 | 3778 | ||
| 3695 | const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { | 3779 | const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { |
| 3780 | // Anchored-end space can be remeasured when the turn completes. Once the | ||
| 3781 | // user has scrolled away (or returned to ordinary end-following), that | ||
| 3782 | // remeasurement must not restart the send-time anchor positioning. | ||
| 3783 | if (timelineScrollModeRef.current !== "anchoring-new-turn") { | ||
| 3784 | return; | ||
| 3785 | } | ||
| 3696 | if (pendingTimelineAnchorRef.current === messageId) { | 3786 | if (pendingTimelineAnchorRef.current === messageId) { |
| 3697 | pendingTimelineAnchorRef.current = null; | 3787 | pendingTimelineAnchorRef.current = null; |
| 3698 | } | 3788 | } |
| ... | @@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3798 | if (isAtEnd) { | 3888 | if (isAtEnd) { |
| 3799 | timelineScrollModeRef.current = "following-end"; | 3889 | timelineScrollModeRef.current = "following-end"; |
| 3800 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | 3890 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 3891 | setTimelineLiveFollowEnabled(true); | ||
| 3801 | showScrollDebouncer.current.cancel(); | 3892 | showScrollDebouncer.current.cancel(); |
| 3802 | setShowScrollToBottom(false); | 3893 | setShowScrollToBottom(false); |
| 3803 | } else { | 3894 | } else { |
| ... | @@ -3878,6 +3969,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3878,6 +3969,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3878 | isAtEndRef.current = true; | 3969 | isAtEndRef.current = true; |
| 3879 | timelineScrollModeRef.current = "following-end"; | 3970 | timelineScrollModeRef.current = "following-end"; |
| 3880 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | 3971 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 3972 | setTimelineLiveFollowEnabled(true); | ||
| 3881 | pendingTimelineAnchorRef.current = null; | 3973 | pendingTimelineAnchorRef.current = null; |
| 3882 | positionedTimelineAnchorRef.current = null; | 3974 | positionedTimelineAnchorRef.current = null; |
| 3883 | settledTimelineAnchorRef.current = null; | 3975 | settledTimelineAnchorRef.current = null; |
| ... | @@ -4945,6 +5037,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4945,6 +5037,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4945 | isAtEndRef.current = true; | 5037 | isAtEndRef.current = true; |
| 4946 | timelineScrollModeRef.current = "anchoring-new-turn"; | 5038 | timelineScrollModeRef.current = "anchoring-new-turn"; |
| 4947 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | 5039 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 5040 | setTimelineLiveFollowEnabled(true); | ||
| 4948 | pendingTimelineAnchorRef.current = messageIdForSend; | 5041 | pendingTimelineAnchorRef.current = messageIdForSend; |
| 4949 | activeTimelineAnchorIndexRef.current = null; | 5042 | activeTimelineAnchorIndexRef.current = null; |
| 4950 | showScrollDebouncer.current.cancel(); | 5043 | showScrollDebouncer.current.cancel(); |
| ... | @@ -5389,6 +5482,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5389,6 +5482,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5389 | isAtEndRef.current = true; | 5482 | isAtEndRef.current = true; |
| 5390 | timelineScrollModeRef.current = "anchoring-new-turn"; | 5483 | timelineScrollModeRef.current = "anchoring-new-turn"; |
| 5391 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | 5484 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 5485 | setTimelineLiveFollowEnabled(true); | ||
| 5392 | pendingTimelineAnchorRef.current = messageIdForSend; | 5486 | pendingTimelineAnchorRef.current = messageIdForSend; |
| 5393 | activeTimelineAnchorIndexRef.current = null; | 5487 | activeTimelineAnchorIndexRef.current = null; |
| 5394 | showScrollDebouncer.current.cancel(); | 5488 | showScrollDebouncer.current.cancel(); |
| ... | @@ -6055,6 +6149,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6055,6 +6149,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6055 | onAnchorReady={onTimelineAnchorReady} | 6149 | onAnchorReady={onTimelineAnchorReady} |
| 6056 | onAnchorSizeChanged={onTimelineAnchorSizeChanged} | 6150 | onAnchorSizeChanged={onTimelineAnchorSizeChanged} |
| 6057 | contentInsetEndAdjustment={composerOverlayHeight} | 6151 | contentInsetEndAdjustment={composerOverlayHeight} |
| 6152 | liveFollowEnabled={timelineLiveFollowEnabled} | ||
| 6058 | onIsAtEndChange={onIsAtEndChange} | 6153 | onIsAtEndChange={onIsAtEndChange} |
| 6059 | onManualNavigation={cancelTimelineLiveFollowForUserNavigation} | 6154 | onManualNavigation={cancelTimelineLiveFollowForUserNavigation} |
| 6060 | hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} | 6155 | hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} |
apps/web/src/components/chat/MessagesTimeline.logic.ts+29-3| ... | @@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; | ... | @@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; |
| 18 | 18 | ||
| 19 | export interface TimelineEndState { | 19 | export interface TimelineEndState { |
| 20 | readonly isAtEnd?: boolean; | 20 | readonly isAtEnd?: boolean; |
| 21 | readonly isNearEnd?: boolean; | 21 | readonly contentLength?: number; |
| 22 | readonly scroll?: number; | ||
| 23 | readonly scrollLength?: number; | ||
| 22 | } | 24 | } |
| 23 | 25 | ||
| 24 | export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { | 26 | /** |
| 25 | return state?.isNearEnd ?? state?.isAtEnd; | 27 | * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's |
| 28 | * isNearEnd fires within half a viewport, which re-armed live-follow while the | ||
| 29 | * user was reading history and yanked them back down on the next stream chunk. | ||
| 30 | * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming | ||
| 31 | * reliable while streaming content is still growing under the viewport. | ||
| 32 | */ | ||
| 33 | export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; | ||
| 34 | |||
| 35 | export function resolveTimelineIsAtEnd( | ||
| 36 | state: TimelineEndState | undefined, | ||
| 37 | endInset = 0, | ||
| 38 | ): boolean | undefined { | ||
| 39 | if (!state) { | ||
| 40 | return undefined; | ||
| 41 | } | ||
| 42 | if (state.isAtEnd) { | ||
| 43 | return true; | ||
| 44 | } | ||
| 45 | const { contentLength, scroll, scrollLength } = state; | ||
| 46 | if (contentLength === undefined || scroll === undefined || scrollLength === undefined) { | ||
| 47 | return state.isAtEnd; | ||
| 48 | } | ||
| 49 | // contentLength includes the end inset (composer overlay), so subtract it to | ||
| 50 | // measure the distance to the real content bottom. | ||
| 51 | return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; | ||
| 26 | } | 52 | } |
| 27 | 53 | ||
| 28 | export function resolveTimelineMinimapHeightStyle(itemCount: number): string { | 54 | export function resolveTimelineMinimapHeightStyle(itemCount: number): string { |
apps/web/src/components/chat/MessagesTimeline.test.tsx+30-3| ... | @@ -194,6 +194,7 @@ function buildProps() { | ... | @@ -194,6 +194,7 @@ function buildProps() { |
| 194 | onAnchorReady: () => {}, | 194 | onAnchorReady: () => {}, |
| 195 | onAnchorSizeChanged: () => {}, | 195 | onAnchorSizeChanged: () => {}, |
| 196 | contentInsetEndAdjustment: 0, | 196 | contentInsetEndAdjustment: 0, |
| 197 | liveFollowEnabled: true, | ||
| 197 | onIsAtEndChange: () => {}, | 198 | onIsAtEndChange: () => {}, |
| 198 | onManualNavigation: () => {}, | 199 | onManualNavigation: () => {}, |
| 199 | }; | 200 | }; |
| ... | @@ -296,7 +297,7 @@ describe("MessagesTimeline", () => { | ... | @@ -296,7 +297,7 @@ describe("MessagesTimeline", () => { |
| 296 | expect(markup).toContain("1 changed file"); | 297 | expect(markup).toContain("1 changed file"); |
| 297 | }); | 298 | }); |
| 298 | 299 | ||
| 299 | it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { | 300 | it("treats only the strict list end as the live edge", async () => { |
| 300 | const { | 301 | const { |
| 301 | resolveTimelineIsAtEnd, | 302 | resolveTimelineIsAtEnd, |
| 302 | resolveTimelineMinimapHasPersistentGutter, | 303 | resolveTimelineMinimapHasPersistentGutter, |
| ... | @@ -307,10 +308,36 @@ describe("MessagesTimeline", () => { | ... | @@ -307,10 +308,36 @@ describe("MessagesTimeline", () => { |
| 307 | resolveTimelineMinimapTopPercent, | 308 | resolveTimelineMinimapTopPercent, |
| 308 | } = await import("./MessagesTimeline.logic"); | 309 | } = await import("./MessagesTimeline.logic"); |
| 309 | 310 | ||
| 310 | expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); | ||
| 311 | expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); | ||
| 312 | expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); | 311 | expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); |
| 313 | expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); | 312 | expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); |
| 313 | // Within the pixel band above the content bottom counts as the end... | ||
| 314 | expect( | ||
| 315 | resolveTimelineIsAtEnd({ | ||
| 316 | isAtEnd: false, | ||
| 317 | contentLength: 2000, | ||
| 318 | scroll: 1170, | ||
| 319 | scrollLength: 800, | ||
| 320 | }), | ||
| 321 | ).toBe(true); | ||
| 322 | // ...but half a viewport up (LegendList's isNearEnd territory) does not. | ||
| 323 | expect( | ||
| 324 | resolveTimelineIsAtEnd({ | ||
| 325 | isAtEnd: false, | ||
| 326 | contentLength: 2000, | ||
| 327 | scroll: 900, | ||
| 328 | scrollLength: 800, | ||
| 329 | }), | ||
| 330 | ).toBe(false); | ||
| 331 | // The composer inset is part of contentLength and must not count as | ||
| 332 | // distance-to-end. | ||
| 333 | expect( | ||
| 334 | resolveTimelineIsAtEnd( | ||
| 335 | { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 }, | ||
| 336 | 100, | ||
| 337 | ), | ||
| 338 | ).toBe(true); | ||
| 339 | // Geometry missing (older state shape): fall back to the strict flag. | ||
| 340 | expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); | ||
| 314 | 341 | ||
| 315 | expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); | 342 | expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); |
| 316 | expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); | 343 | expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); |
apps/web/src/components/chat/MessagesTimeline.tsx+11-3| ... | @@ -219,6 +219,13 @@ interface MessagesTimelineProps { | ... | @@ -219,6 +219,13 @@ interface MessagesTimelineProps { |
| 219 | onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; | 219 | onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; |
| 220 | onAnchorSizeChanged: (messageId: MessageId, size: number) => void; | 220 | onAnchorSizeChanged: (messageId: MessageId, size: number) => void; |
| 221 | contentInsetEndAdjustment: number; | 221 | contentInsetEndAdjustment: number; |
| 222 | /** | ||
| 223 | * Whether the timeline should keep pinning to the live edge as content | ||
| 224 | * grows. Off while the user is reading history; LegendList's own | ||
| 225 | * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's | ||
| 226 | * scroll-mode refs whenever the user drifts near the bottom. | ||
| 227 | */ | ||
| 228 | liveFollowEnabled: boolean; | ||
| 222 | onIsAtEndChange: (isAtEnd: boolean) => void; | 229 | onIsAtEndChange: (isAtEnd: boolean) => void; |
| 223 | onManualNavigation: () => void; | 230 | onManualNavigation: () => void; |
| 224 | hideEmptyPlaceholder?: boolean; | 231 | hideEmptyPlaceholder?: boolean; |
| ... | @@ -258,6 +265,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -258,6 +265,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 258 | onAnchorReady, | 265 | onAnchorReady, |
| 259 | onAnchorSizeChanged, | 266 | onAnchorSizeChanged, |
| 260 | contentInsetEndAdjustment, | 267 | contentInsetEndAdjustment, |
| 268 | liveFollowEnabled, | ||
| 261 | onIsAtEndChange, | 269 | onIsAtEndChange, |
| 262 | onManualNavigation, | 270 | onManualNavigation, |
| 263 | hideEmptyPlaceholder = false, | 271 | hideEmptyPlaceholder = false, |
| ... | @@ -401,7 +409,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -401,7 +409,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 401 | 409 | ||
| 402 | const handleScroll = useCallback(() => { | 410 | const handleScroll = useCallback(() => { |
| 403 | const state = listRef.current?.getState?.(); | 411 | const state = listRef.current?.getState?.(); |
| 404 | const isAtEnd = resolveTimelineIsAtEnd(state); | 412 | const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); |
| 405 | if (isAtEnd !== undefined) { | 413 | if (isAtEnd !== undefined) { |
| 406 | onIsAtEndChange(isAtEnd); | 414 | onIsAtEndChange(isAtEnd); |
| 407 | } | 415 | } |
| ... | @@ -427,7 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -427,7 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 427 | 435 | ||
| 428 | strip.dataset.inView = inView ? "true" : "false"; | 436 | strip.dataset.inView = inView ? "true" : "false"; |
| 429 | } | 437 | } |
| 430 | }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); | 438 | }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); |
| 431 | 439 | ||
| 432 | useEffect(() => { | 440 | useEffect(() => { |
| 433 | const frame = requestAnimationFrame(handleScroll); | 441 | const frame = requestAnimationFrame(handleScroll); |
| ... | @@ -543,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -543,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 543 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} | 551 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 544 | contentInsetEndAdjustment={contentInsetEndAdjustment} | 552 | contentInsetEndAdjustment={contentInsetEndAdjustment} |
| 545 | maintainScrollAtEnd={ | 553 | maintainScrollAtEnd={ |
| 546 | anchoredEndSpace | 554 | anchoredEndSpace || !liveFollowEnabled |
| 547 | ? false | 555 | ? false |
| 548 | : { | 556 | : { |
| 549 | animated: false, | 557 | animated: false, |