diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..9210298f6da 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,7 +87,13 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { + getAnchoredTurnMetrics, + isTimelineLayoutFollowEnabled, + reduceTimelineLayoutFollowState, + type TimelineLayoutFollowAction, + type TimelineScrollMode, +} from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -1278,6 +1284,22 @@ function ChatViewContent(props: ChatViewProps) { const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const layoutScrollThreadKeyRef = useRef(routeThreadKey); + layoutScrollThreadKeyRef.current = routeThreadKey; + const [layoutScrollState, setLayoutScrollState] = useState(() => ({ + threadKey: routeThreadKey, + enabled: true, + })); + const maintainScrollAtEndOnLayout = isTimelineLayoutFollowEnabled( + layoutScrollState, + routeThreadKey, + ); + const updateTimelineLayoutFollow = useCallback((action: TimelineLayoutFollowAction) => { + setLayoutScrollState((current) => { + const threadKey = layoutScrollThreadKeyRef.current; + return reduceTimelineLayoutFollowState(current, threadKey, action); + }); + }, []); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -3522,6 +3544,10 @@ function ChatViewContent(props: ChatViewProps) { } | null>(null); const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { + updateTimelineLayoutFollow({ + type: "manual-navigation", + isAtEnd: isAtEndRef.current, + }); anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; @@ -3535,13 +3561,6 @@ function ChatViewContent(props: ChatViewProps) { anchorScrollRestoreFrameRef.current = null; } }, []); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( - cancelTimelineLiveFollowForUserNavigation, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigation; - }, [cancelTimelineLiveFollowForUserNavigation]); const getActiveTimelineTurnMetrics = useCallback( (list?: LegendListRef | null) => { const resolvedList = list ?? legendListRef.current; @@ -3594,6 +3613,7 @@ function ChatViewContent(props: ChatViewProps) { // gesture opts out. const scrollToEnd = useCallback((animated = false) => { isAtEndRef.current = true; + updateTimelineLayoutFollow({ type: "return-to-end" }); timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; @@ -3602,38 +3622,6 @@ function ChatViewContent(props: ChatViewProps) { setShowScrollToBottom(false); void legendListRef.current?.scrollToEnd?.({ animated }); }, []); - useEffect(() => { - let removeListeners: (() => void) | null = null; - const frame = requestAnimationFrame(() => { - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { - passive: true, - }); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); - }; - }); - - return () => { - cancelAnimationFrame(frame); - removeListeners?.(); - }; - }, [activeThread?.id]); - const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; @@ -3735,6 +3723,7 @@ function ChatViewContent(props: ChatViewProps) { setShowScrollToBottom(false); return; } + updateTimelineLayoutFollow({ type: "at-end-change", isAtEnd }); if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { @@ -3818,6 +3807,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; + updateTimelineLayoutFollow({ type: "return-to-end" }); timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; @@ -4736,6 +4726,7 @@ function ChatViewContent(props: ChatViewProps) { // anchored end-space target so it lands near the top while the response // streams into the reserved space below it. isAtEndRef.current = true; + updateTimelineLayoutFollow({ type: "return-to-end" }); timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; @@ -5179,6 +5170,7 @@ function ChatViewContent(props: ChatViewProps) { // Position this sent row once LegendList has measured the anchored tail. isAtEndRef.current = true; + updateTimelineLayoutFollow({ type: "return-to-end" }); timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; @@ -5827,6 +5819,7 @@ function ChatViewContent(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} + maintainScrollAtEndOnLayout={maintainScrollAtEndOnLayout} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3227bac2413..5cefc21db2a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -15,6 +15,7 @@ export const TIMELINE_MINIMAP_MIN_ITEMS = 2; export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; export const TIMELINE_CONTENT_MAX_WIDTH = 768; export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; +export const TIMELINE_OVERLAY_SCROLLBAR_HIT_WIDTH = 16; export interface TimelineEndState { readonly isAtEnd?: boolean; @@ -25,6 +26,31 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo return state?.isNearEnd ?? state?.isAtEnd; } +export function isTimelineScrollbarPointerDown(input: { + readonly pointerType: string; + readonly targetIsCurrentTarget: boolean; + readonly clientX: number; + readonly viewportRight: number; + readonly offsetWidth: number; + readonly clientWidth: number; + readonly scrollHeight: number; + readonly clientHeight: number; +}): boolean { + if ( + input.pointerType !== "mouse" || + !input.targetIsCurrentTarget || + input.scrollHeight <= input.clientHeight || + !Number.isFinite(input.clientX) || + !Number.isFinite(input.viewportRight) + ) { + return false; + } + + const measuredScrollbarWidth = Math.max(0, input.offsetWidth - input.clientWidth); + const hitWidth = Math.max(TIMELINE_OVERLAY_SCROLLBAR_HIT_WIDTH, measuredScrollbarWidth); + return input.clientX >= input.viewportRight - hitWidth && input.clientX <= input.viewportRight; +} + export function resolveTimelineMinimapHeightStyle(itemCount: number): string { const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING); return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..833eff049ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -39,6 +39,9 @@ vi.mock("@legendapp/list/react", async () => { size?: boolean; shouldRestorePosition?: (item: { id: string }) => boolean; }; + onWheel?: () => void; + onTouchMove?: () => void; + onPointerDown?: () => void; ref?: Ref; }) => { if (props.anchoredEndSpace) { @@ -90,6 +93,9 @@ vi.mock("@legendapp/list/react", async () => { ? props.maintainVisibleContentPosition.size : undefined } + data-manual-navigation-wheel={Boolean(props.onWheel)} + data-manual-navigation-touch={Boolean(props.onTouchMove)} + data-manual-navigation-pointer={Boolean(props.onPointerDown)} > {props.ListHeaderComponent} {props.data.map((item) => ( @@ -194,6 +200,7 @@ function buildProps() { onAnchorReady: () => {}, onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, + maintainScrollAtEndOnLayout: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; @@ -357,6 +364,35 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); + it("only treats a press on the vertical scrollbar as pointer navigation", async () => { + const { isTimelineScrollbarPointerDown } = await import("./MessagesTimeline.logic"); + const scrollbarPointer = { + pointerType: "mouse", + targetIsCurrentTarget: true, + clientX: 994, + viewportRight: 1000, + offsetWidth: 800, + clientWidth: 800, + scrollHeight: 1200, + clientHeight: 600, + }; + + expect(isTimelineScrollbarPointerDown(scrollbarPointer)).toBe(true); + expect( + isTimelineScrollbarPointerDown({ ...scrollbarPointer, targetIsCurrentTarget: false }), + ).toBe(false); + expect(isTimelineScrollbarPointerDown({ ...scrollbarPointer, clientX: 900 })).toBe(false); + expect(isTimelineScrollbarPointerDown({ ...scrollbarPointer, pointerType: "touch" })).toBe( + false, + ); + expect( + isTimelineScrollbarPointerDown({ + ...scrollbarPointer, + scrollHeight: scrollbarPointer.clientHeight, + }), + ).toBe(false); + }); + it("anchors a sent attachment message using its measured height", () => { const onAnchorReady = vi.fn(); const onAnchorSizeChanged = vi.fn(); @@ -424,6 +460,24 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-footer="true"'); }); + it("preserves the reader's position when the composer resizes away from the live edge", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); + expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"'); + expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"'); + expect(markup).toContain('data-maintain-scroll-at-end-layout="false"'); + expect(markup).toContain('data-manual-navigation-wheel="true"'); + expect(markup).toContain('data-manual-navigation-touch="true"'); + expect(markup).toContain('data-manual-navigation-pointer="true"'); + }); + it("does not render collapse controls for short user messages", () => { const markup = renderToStaticMarkup( void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + maintainScrollAtEndOnLayout: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; @@ -215,6 +218,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + maintainScrollAtEndOnLayout, onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, @@ -385,6 +389,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + const handlePointerDown = useCallback( + (event: PointerEvent) => { + const scrollNode = event.currentTarget; + if ( + isTimelineScrollbarPointerDown({ + pointerType: event.pointerType, + targetIsCurrentTarget: event.target === scrollNode, + clientX: event.clientX, + viewportRight: scrollNode.getBoundingClientRect().right, + offsetWidth: scrollNode.offsetWidth, + clientWidth: scrollNode.clientWidth, + scrollHeight: scrollNode.scrollHeight, + clientHeight: scrollNode.clientHeight, + }) + ) { + onManualNavigation(); + } + }, + [onManualNavigation], + ); + useEffect(() => { const frame = requestAnimationFrame(handleScroll); return () => cancelAnimationFrame(frame); @@ -502,7 +527,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ on: { dataChange: true, itemLayout: true, - layout: true, + layout: maintainScrollAtEndOnLayout, }, } } @@ -511,6 +536,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ size: false, }} onScroll={handleScroll} + onWheel={onManualNavigation} + onTouchMove={onManualNavigation} + onPointerDown={handlePointerDown} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "chat-timeline-scroll-fade", diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 1bf82c47a61..879a54a3937 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,5 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; +import { + getAnchoredTurnMetrics, + getRowBottom, + isTimelineLayoutFollowEnabled, + reduceTimelineLayoutFollowState, +} from "./timelineScrollAnchoring"; function buildState({ positions, @@ -22,6 +27,51 @@ function buildState({ } describe("timeline scroll anchoring", () => { + it("hands layout following off across scroll-away and return-to-end actions", () => { + const threadKey = "environment-local:thread-1"; + let state = { threadKey, enabled: true }; + + state = reduceTimelineLayoutFollowState(state, threadKey, { + type: "manual-navigation", + isAtEnd: true, + }); + expect(isTimelineLayoutFollowEnabled(state, threadKey)).toBe(true); + + state = reduceTimelineLayoutFollowState(state, threadKey, { + type: "at-end-change", + isAtEnd: false, + }); + expect(isTimelineLayoutFollowEnabled(state, threadKey)).toBe(false); + + state = reduceTimelineLayoutFollowState(state, threadKey, { + type: "at-end-change", + isAtEnd: true, + }); + expect(isTimelineLayoutFollowEnabled(state, threadKey)).toBe(true); + + state = reduceTimelineLayoutFollowState(state, threadKey, { + type: "at-end-change", + isAtEnd: false, + }); + state = reduceTimelineLayoutFollowState(state, threadKey, { type: "return-to-end" }); + expect(isTimelineLayoutFollowEnabled(state, threadKey)).toBe(true); + }); + + it("starts a different thread at the live edge on its first render", () => { + const previousThreadState = { + threadKey: "environment-local:thread-1", + enabled: false, + }; + const nextThreadKey = "environment-local:thread-2"; + + expect(isTimelineLayoutFollowEnabled(previousThreadState, nextThreadKey)).toBe(true); + expect( + reduceTimelineLayoutFollowState(previousThreadState, nextThreadKey, { + type: "return-to-end", + }), + ).toEqual({ threadKey: nextThreadKey, enabled: true }); + }); + it("measures row bottoms from LegendList row position and size", () => { const state = buildState({ positions: [0, 120], diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 48d3fc7542d..2a915eeef5f 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,5 +1,41 @@ export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; +export interface TimelineLayoutFollowState { + readonly threadKey: string; + readonly enabled: boolean; +} + +export type TimelineLayoutFollowAction = + | { readonly type: "manual-navigation"; readonly isAtEnd: boolean } + | { readonly type: "at-end-change"; readonly isAtEnd: boolean } + | { readonly type: "return-to-end" }; + +export function isTimelineLayoutFollowEnabled( + state: TimelineLayoutFollowState, + threadKey: string, +): boolean { + return state.threadKey === threadKey ? state.enabled : true; +} + +export function reduceTimelineLayoutFollowState( + state: TimelineLayoutFollowState, + threadKey: string, + action: TimelineLayoutFollowAction, +): TimelineLayoutFollowState { + const enabled = + action.type === "manual-navigation" + ? action.isAtEnd + ? isTimelineLayoutFollowEnabled(state, threadKey) + : false + : action.type === "at-end-change" + ? action.isAtEnd + : true; + + return state.threadKey === threadKey && state.enabled === enabled + ? state + : { threadKey, enabled }; +} + export interface TimelineListMeasurementState { readonly data: readonly unknown[]; readonly scroll: number;