diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7b59530c955..1e9e970277e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1152,6 +1152,56 @@ type LocalThreadErrorEntry = { readonly at: number; }; +// Module state survives route changes but resets when the client reloads. +type TimelineScrollPosition = + | { readonly kind: "automatic"; readonly offset: number } + | { + readonly kind: "manual"; + readonly offset: number; + readonly turnId: TurnId | null; + }; + +type TimelineEntryScrollMode = + | { readonly kind: "follow-end" } + | { readonly kind: "restore-position"; readonly offset: number } + | { readonly kind: "anchor-response"; readonly turnId: TurnId }; + +const timelineScrollPositionByThreadKey = new Map(); +const TIMELINE_SCROLL_NAVIGATION_KEYS = new Set([ + "ArrowDown", + "ArrowUp", + "End", + "Home", + "PageDown", + "PageUp", + " ", +]); + +function resolveTimelineEntryScrollMode(input: { + readonly latestTurnId: TurnId | null; + readonly runningTurnId: TurnId | null; + readonly savedPosition: TimelineScrollPosition | undefined; +}): TimelineEntryScrollMode { + if (input.runningTurnId !== null) { + if ( + input.savedPosition?.kind === "manual" && + input.savedPosition.turnId === input.runningTurnId + ) { + return { kind: "restore-position", offset: input.savedPosition.offset }; + } + return { kind: "anchor-response", turnId: input.runningTurnId }; + } + if (input.savedPosition?.kind === "manual") { + return { kind: "restore-position", offset: input.savedPosition.offset }; + } + if (input.latestTurnId) { + return { kind: "anchor-response", turnId: input.latestTurnId }; + } + return input.savedPosition + ? { kind: "restore-position", offset: input.savedPosition.offset } + : { kind: "follow-end" }; +} + function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } @@ -1174,6 +1224,10 @@ function ChatViewContent(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const restoredTimelineScrollPosition = useMemo( + () => timelineScrollPositionByThreadKey.get(routeThreadKey), + [routeThreadKey], + ); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, @@ -1511,6 +1565,47 @@ function ChatViewContent(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const activeRunningTurnId = + activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null; + const runningTurnOnEntryRef = useRef<{ + readonly captured: boolean; + readonly threadKey: string | null; + readonly turnId: TurnId | null; + }>({ + captured: !threadDetailLoading, + threadKey: activeThreadKey, + turnId: threadDetailLoading ? null : activeRunningTurnId, + }); + if ( + runningTurnOnEntryRef.current.threadKey !== activeThreadKey || + (!runningTurnOnEntryRef.current.captured && !threadDetailLoading) + ) { + runningTurnOnEntryRef.current = { + captured: !threadDetailLoading, + threadKey: activeThreadKey, + turnId: threadDetailLoading ? null : activeRunningTurnId, + }; + } + const runningTurnOnEntry = runningTurnOnEntryRef.current.turnId; + const timelineEntryScrollMode = useMemo( + () => + resolveTimelineEntryScrollMode({ + latestTurnId: activeThread?.latestTurn?.turnId ?? null, + runningTurnId: runningTurnOnEntry, + savedPosition: restoredTimelineScrollPosition, + }), + [activeThread?.latestTurn?.turnId, restoredTimelineScrollPosition, runningTurnOnEntry], + ); + const [manuallyNavigatedTimelineEntry, setManuallyNavigatedTimelineEntry] = useState<{ + readonly threadKey: string; + readonly turnId: TurnId | null; + } | null>(null); + const entryResponseAnchorActive = + timelineEntryScrollMode.kind === "anchor-response" && + !( + manuallyNavigatedTimelineEntry?.threadKey === routeThreadKey && + manuallyNavigatedTimelineEntry.turnId === timelineEntryScrollMode.turnId + ); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -2399,6 +2494,18 @@ function ChatViewContent(props: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); + const entryResponseAnchorMessageId = useMemo(() => { + if (!entryResponseAnchorActive) { + return null; + } + for (let index = timelineEntries.length - 1; index >= 0; index -= 1) { + const entry = timelineEntries[index]; + if (entry?.kind === "message" && entry.message.role === "user") { + return entry.message.id; + } + } + return null; + }, [entryResponseAnchorActive, timelineEntries]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3529,6 +3636,7 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); + const preserveInitialResponsePositionRef = useRef(false); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -3540,10 +3648,16 @@ function ChatViewContent(props: ChatViewProps) { readonly offset: number; readonly userScrollGeneration: number; } | null>(null); + const pendingTimelineManualScrollRef = useRef<{ + readonly initialOffset: number; + readonly threadKey: string; + readonly turnId: TurnId | null; + } | null>(null); const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; + preserveInitialResponsePositionRef.current = false; liveFollowUserScrollGenerationRef.current = null; pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; @@ -3555,13 +3669,26 @@ function ChatViewContent(props: ChatViewProps) { anchorScrollRestoreFrameRef.current = null; } }, []); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( + const beginTimelineManualNavigation = useCallback(() => { + const currentOffset = legendListRef.current?.getState().scroll; + if (typeof currentOffset === "number" && Number.isFinite(currentOffset)) { + pendingTimelineManualScrollRef.current = { + initialOffset: currentOffset, + threadKey: routeThreadKey, + turnId: + activeRunningTurnId ?? + (timelineEntryScrollMode.kind === "anchor-response" + ? timelineEntryScrollMode.turnId + : null), + }; + } + cancelTimelineLiveFollowForUserNavigation(); + }, [ + activeRunningTurnId, cancelTimelineLiveFollowForUserNavigation, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigation; - }, [cancelTimelineLiveFollowForUserNavigation]); + routeThreadKey, + timelineEntryScrollMode, + ]); const getActiveTimelineTurnMetrics = useCallback( (list?: LegendListRef | null) => { const resolvedList = list ?? legendListRef.current; @@ -3615,6 +3742,7 @@ function ChatViewContent(props: ChatViewProps) { const scrollToEnd = useCallback((animated = false) => { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; + preserveInitialResponsePositionRef.current = false; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; @@ -3629,22 +3757,38 @@ function ChatViewContent(props: ChatViewProps) { if (!scrollNode) { return; } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + beginTimelineManualNavigation(); + } + }; + const clearPendingManualScroll = () => { + pendingTimelineManualScrollRef.current = null; }; - scrollNode.addEventListener("wheel", handleManualNavigation, { + const handleKeyDown = (event: KeyboardEvent) => { + if (TIMELINE_SCROLL_NAVIGATION_KEYS.has(event.key)) { + beginTimelineManualNavigation(); + } + }; + scrollNode.addEventListener("wheel", beginTimelineManualNavigation, { passive: true, }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { + scrollNode.addEventListener("touchmove", beginTimelineManualNavigation, { passive: true, }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { + scrollNode.addEventListener("pointerdown", handlePointerDown, { passive: true, }); + scrollNode.addEventListener("pointerup", clearPendingManualScroll, { passive: true }); + scrollNode.addEventListener("pointercancel", clearPendingManualScroll, { passive: true }); + scrollNode.addEventListener("keydown", handleKeyDown); removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); + scrollNode.removeEventListener("wheel", beginTimelineManualNavigation); + scrollNode.removeEventListener("touchmove", beginTimelineManualNavigation); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("pointerup", clearPendingManualScroll); + scrollNode.removeEventListener("pointercancel", clearPendingManualScroll); + scrollNode.removeEventListener("keydown", handleKeyDown); }; }); @@ -3652,7 +3796,7 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(frame); removeListeners?.(); }; - }, [activeThread?.id]); + }, [activeThread?.id, beginTimelineManualNavigation]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { @@ -3758,6 +3902,13 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { + if (preserveInitialResponsePositionRef.current) { + timelineScrollModeRef.current = "free-scrolling"; + liveFollowUserScrollGenerationRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + return; + } timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; showScrollDebouncer.current.cancel(); @@ -3769,6 +3920,43 @@ function ChatViewContent(props: ChatViewProps) { } }, []); + const onTimelineScrollOffsetChange = useCallback( + (offset: number) => { + const existing = timelineScrollPositionByThreadKey.get(routeThreadKey); + const pendingManualScroll = pendingTimelineManualScrollRef.current; + const manualScrollLanded = + pendingManualScroll?.threadKey === routeThreadKey && + Math.abs(offset - pendingManualScroll.initialOffset) > 0.5; + + if (manualScrollLanded) { + const manualPosition: TimelineScrollPosition = { + kind: "manual", + offset, + turnId: pendingManualScroll.turnId, + }; + timelineScrollPositionByThreadKey.set(routeThreadKey, manualPosition); + pendingTimelineManualScrollRef.current = null; + setTimelineAnchor((current) => + current.threadKey === routeThreadKey && current.messageId !== null + ? { threadKey: routeThreadKey, messageId: null } + : current, + ); + setManuallyNavigatedTimelineEntry((current) => + current?.threadKey === routeThreadKey && current.turnId === manualPosition.turnId + ? current + : { threadKey: routeThreadKey, turnId: manualPosition.turnId }, + ); + return; + } + + timelineScrollPositionByThreadKey.set( + routeThreadKey, + existing?.kind === "manual" ? { ...existing, offset } : { kind: "automatic", offset }, + ); + }, + [routeThreadKey], + ); + useEffect(() => { if (!activeThread?.id) { return; @@ -3838,9 +4026,13 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + preserveInitialResponsePositionRef.current = timelineEntryScrollMode.kind === "anchor-response"; + timelineScrollModeRef.current = + timelineEntryScrollMode.kind === "follow-end" ? "following-end" : "free-scrolling"; + liveFollowUserScrollGenerationRef.current = + timelineEntryScrollMode.kind === "follow-end" ? anchorUserScrollGenerationRef.current : null; pendingTimelineAnchorRef.current = null; + pendingTimelineManualScrollRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; @@ -3854,7 +4046,7 @@ function ChatViewContent(props: ChatViewProps) { } } // activeThreadRef resets transitively with the active thread. - }, [activeThread?.id]); + }, [routeThreadKey, timelineEntryScrollMode.kind]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). @@ -4897,6 +5089,7 @@ function ChatViewContent(props: ChatViewProps) { // streams into the reserved space below it. isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; + preserveInitialResponsePositionRef.current = false; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; @@ -5341,6 +5534,7 @@ function ChatViewContent(props: ChatViewProps) { // Position this sent row once LegendList has measured the anchored tail. isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; + preserveInitialResponsePositionRef.current = false; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; @@ -5979,7 +6173,7 @@ function ChatViewContent(props: ChatViewProps) { @@ -6024,7 +6224,10 @@ function ChatViewContent(props: ChatViewProps) { type="button" aria-label="Scroll to end" title="Scroll to end" - onClick={() => scrollToEnd(true)} + onClick={() => { + beginTimelineManualNavigation(); + scrollToEnd(true); + }} className="chat-composer-glass pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" > diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c952eb3d128..0e59d81bfc2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,6 +14,7 @@ import { const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); const NOOP_OPEN_AGENTS = () => {}; +const NOOP_SCROLL_OFFSET_CHANGE = () => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { createContext, @@ -192,8 +193,10 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + initialScrollOffset?: number | undefined; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; + onScrollOffsetChange?: (offset: number) => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; } @@ -229,8 +232,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + initialScrollOffset, onIsAtEndChange, onManualNavigation, + onScrollOffsetChange = NOOP_SCROLL_OFFSET_CHANGE, hideEmptyPlaceholder = false, topFadeEnabled = false, }: MessagesTimelineProps) { @@ -371,6 +376,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); + if (typeof state?.scroll === "number" && Number.isFinite(state.scroll)) { + onScrollOffsetChange(state.scroll); + } const isAtEnd = resolveTimelineIsAtEnd(state); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); @@ -397,7 +405,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, onScrollOffsetChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -509,7 +517,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} - initialScrollAtEnd + initialScrollAtEnd={initialScrollOffset === undefined} + {...(initialScrollOffset === undefined ? {} : { initialScrollOffset })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={