diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index c4897842..84b35839 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js" import { CheckSquare, Trash, X } from "lucide-solid" import Kbd from "./kbd" import MessageBlock from "./message-block" @@ -9,6 +9,7 @@ import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { useI18n } from "../lib/i18n" +import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" import { showAlertDialog } from "../stores/alerts" @@ -17,6 +18,7 @@ import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { DeleteHoverState } from "../types/delete-hover" const SCROLL_SENTINEL_MARGIN_PX = 48 +const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const QUOTE_SELECTION_MAX_LENGTH = 2000 const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href @@ -43,6 +45,12 @@ export default function MessageSection(props: MessageSectionProps) { const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId)) + const scrollCache = useScrollCache({ + instanceId: props.instanceId, + sessionId: props.sessionId, + scope: MESSAGE_SCROLL_CACHE_SCOPE, + }) + const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId)) const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId)) const sessionInfo = createMemo(() => @@ -221,6 +229,32 @@ export default function MessageSection(props: MessageSectionProps) { const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`) + const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE)) + const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true) + + const [didRestoreScroll, setDidRestoreScroll] = createSignal(false) + createEffect( + on( + () => props.sessionId, + () => { + setDidRestoreScroll(false) + }, + ), + ) + + // Persist scroll position when switching sessions. This effect's cleanup runs + // when `props.sessionId` changes, before the next session is rendered. + createEffect(() => { + const sessionId = props.sessionId + onCleanup(() => { + const element = streamElement() + if (!element) return + const scrollTop = element.scrollTop + const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48 + store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, { scrollTop, atBottom }) + }) + }) + const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) createEffect(() => { @@ -231,6 +265,33 @@ export default function MessageSection(props: MessageSectionProps) { } }) + // Restore scroll position when the stream element is available. + createEffect(() => { + const element = streamElement() + const api = listApi() + if (!element || !api) return + if (props.loading) return + if (messageIds().length === 0) return + if (didRestoreScroll()) return + + scrollCache.restore(element, { + behavior: "auto", + fallback: () => { + api.setAutoScroll(true) + api.scrollToBottom({ immediate: true }) + }, + onApplied: (snapshot) => { + // Keep follow mode consistent with the restored state. + api.setAutoScroll(snapshot?.atBottom ?? true) + setDidRestoreScroll(true) + }, + }) + }) + + onCleanup(() => { + scrollCache.persist(streamElement()) + }) + function clearQuoteSelection() { setQuoteSelection(null) } @@ -551,24 +612,31 @@ export default function MessageSection(props: MessageSectionProps) { class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`} data-scroll-buttons={scrollButtonsCount()} > - messageId} - getAnchorId={getMessageAnchorId} - getKeyFromAnchorId={getMessageIdFromAnchorId} - overscanPx={800} - scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} - suspendMeasurements={() => !isActive()} - loading={() => Boolean(props.loading)} - isActive={isActive} - followToken={followToken} - onScroll={() => clearQuoteSelection()} - onMouseUp={() => handleStreamMouseUp()} - onActiveKeyChange={setActiveMessageId} - onScrollElementChange={(element) => { - setStreamElement(element) - if (!element) clearQuoteSelection() - }} + messageId} + getAnchorId={getMessageAnchorId} + getKeyFromAnchorId={getMessageIdFromAnchorId} + overscanPx={800} + scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} + suspendMeasurements={() => !isActive()} + loading={() => Boolean(props.loading)} + isActive={isActive} + scrollToBottomOnActivate={() => false} + initialScrollToBottom={() => false} + initialAutoScroll={initialAutoScroll} + resetKey={() => props.sessionId} + followToken={followToken} + onScroll={() => { + clearQuoteSelection() + scrollCache.persist(streamElement()) + }} + onMouseUp={() => handleStreamMouseUp()} + onActiveKeyChange={setActiveMessageId} + onScrollElementChange={(element) => { + setStreamElement(element) + if (!element) clearQuoteSelection() + }} onShellElementChange={(element) => { setStreamShellElement(element) if (!element) clearQuoteSelection() diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index cc58aa0a..ba78382c 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -56,12 +56,22 @@ export const SessionView: Component = (props) => { const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId)) + const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" + let promptInputApi: PromptInputApi | null = null let pendingPromptText: string | null = null let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null let scrollToBottomHandle: (() => void) | undefined let rootRef: HTMLDivElement | undefined + + function shouldScrollToBottomOnActivate() { + const current = session() + if (!current) return true + const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_SCROLL_CACHE_SCOPE) + return !snapshot || snapshot.atBottom + } + function scheduleScrollToBottom() { if (!scrollToBottomHandle) return requestAnimationFrame(() => { @@ -70,6 +80,7 @@ export const SessionView: Component = (props) => { } createEffect(() => { if (!props.isActive) return + if (!shouldScrollToBottomOnActivate()) return scheduleScrollToBottom() }) @@ -321,7 +332,9 @@ export const SessionView: Component = (props) => { registerScrollToBottom={(fn) => { scrollToBottomHandle = fn if (props.isActive) { - scheduleScrollToBottom() + if (shouldScrollToBottomOnActivate()) { + scheduleScrollToBottom() + } } }} diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index b6020c7e..ceb522f9 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -51,6 +51,34 @@ export interface VirtualFollowListProps { loading?: Accessor isActive?: Accessor + /** + * When switching back to an inactive (cached) pane, the list historically + * re-pinned to the bottom if autoScroll was enabled. + * + * Disable this to preserve the existing scroll position across pane switches. + */ + scrollToBottomOnActivate?: Accessor + + /** + * Controls whether the list should scroll to bottom the first time items + * appear (default behavior for chat streams). + * + * Set to false when an outer component restores scroll from a cache. + */ + initialScrollToBottom?: Accessor + + /** + * Initial value for the internal autoScroll signal. + * Useful when restoring scroll state (e.g. start in non-follow mode). + */ + initialAutoScroll?: Accessor + + /** + * When this value changes, the list resets internal follow/anchor state. + * Useful when reusing the same list instance across different datasets. + */ + resetKey?: Accessor + /** * If this value changes and autoScroll is enabled, the list will * anchor-scroll to the bottom (unless suppressed). @@ -103,11 +131,14 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const bottomSentinel = () => bottomSentinelSignal() const isActive = () => (props.isActive ? props.isActive() : true) + const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true) + const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true) + const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true) const isLoading = () => Boolean(props.loading?.()) const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) const measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) - const [autoScroll, setAutoScroll] = createSignal(true) + const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll())) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [topSentinelVisible, setTopSentinelVisible] = createSignal(true) @@ -138,6 +169,8 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let userScrollIntentUntil = 0 let detachScrollIntentListeners: (() => void) | undefined + let lastResetKey: string | number | undefined + const state: VirtualFollowListState = { autoScroll, showScrollTopButton, @@ -352,21 +385,49 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { pendingScrollFrame = requestAnimationFrame(() => { pendingScrollFrame = null if (!containerRef) return + const previousScrollTop = lastKnownScrollTop const currentScrollTop = containerRef.scrollTop + const deltaScrollTop = currentScrollTop - previousScrollTop if (currentScrollTop !== lastKnownScrollTop) { lastKnownScrollTop = currentScrollTop } const atBottom = bottomSentinelVisible() + const beforeAutoScroll = autoScroll() + + const inferredDirection: "up" | "down" | null = + lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null) + // If the user scrolls manually, exit key-anchored mode. if (isUserScroll && anchorLock()) { clearAnchorLock() } if (isUserScroll) { - if (atBottom) { - if (!autoScroll()) setAutoScroll(true) - } else if (autoScroll()) { + // If the user is actively scrolling upward, exit follow-to-bottom mode + // immediately. The bottom sentinel can remain "visible" for a short + // distance due to its observer margin, which otherwise keeps autoScroll + // enabled and makes the list feel stuck. + if (inferredDirection === "up" && deltaScrollTop < -0.5 && autoScroll()) { + if (pendingAnchorScroll !== null) { + cancelAnimationFrame(pendingAnchorScroll) + pendingAnchorScroll = null + } + setAutoScroll(false) + } + + // Do not re-enable follow mode while the user's current scroll intent + // is upward. This prevents transient anchor/pin scrolls from pulling + // the list back into autoScroll(true). + if (inferredDirection !== "up") { + if (atBottom) { + if (!autoScroll()) setAutoScroll(true) + } else if (autoScroll()) { + setAutoScroll(false) + } + } else if (!atBottom && autoScroll()) { + // If the user is scrolling up and we are no longer at the bottom, + // ensure follow mode is disabled. setAutoScroll(false) } } @@ -532,12 +593,58 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { props.registerState?.(state) }) + createEffect(() => { + const nextKey = props.resetKey?.() + if (nextKey === undefined) return + if (lastResetKey === undefined) { + lastResetKey = nextKey + return + } + if (nextKey === lastResetKey) return + lastResetKey = nextKey + + // Reset internal state when consumers swap datasets (e.g. session switch). + if (pendingScrollFrame !== null) { + cancelAnimationFrame(pendingScrollFrame) + pendingScrollFrame = null + } + if (pendingAnchorScroll !== null) { + cancelAnimationFrame(pendingAnchorScroll) + pendingAnchorScroll = null + } + if (pendingAnchorCorrectionFrame !== null) { + cancelAnimationFrame(pendingAnchorCorrectionFrame) + pendingAnchorCorrectionFrame = null + } + clearScrollToBottomFrames() + + scrollCompensationGen += 1 + pendingScrollCompensationScheduled = false + pendingScrollCompensations = new Map() + pendingAutoPin = false + + suppressAutoScrollOnce = false + pendingActiveScroll = false + pendingInitialScroll = true + + setAnchorLock(null) + setActiveKey(null) + setShowScrollTopButton(false) + setShowScrollBottomButton(false) + setTopSentinelVisible(true) + setBottomSentinelVisible(true) + setAutoScroll(Boolean(initialAutoScroll())) + + lastKnownScrollTop = containerRef?.scrollTop ?? 0 + lastUserScrollIntentDirection = null + }) + let lastActiveState = false createEffect(() => { const active = isActive() if (active) { resolvePendingActiveScroll() - if (!lastActiveState && autoScroll()) { + if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) { requestScrollToBottom(true) // When switching back to a cached session pane, items can mount/measure @@ -549,7 +656,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { }) }) } - } else if (autoScroll()) { + } else if (autoScroll() && scrollToBottomOnActivate()) { pendingActiveScroll = true } lastActiveState = active @@ -569,6 +676,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const sentinel = bottomSentinel() if (!container || !sentinel || props.items().length === 0) return + if (!initialScrollToBottom()) { + // An outer component is responsible for restoring scroll. + pendingInitialScroll = false + return + } + // Ensure we're in follow-to-bottom mode for the initial position. if (anchorLock()) { clearAnchorLock() @@ -599,9 +712,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { suppressAutoScrollOnce = false return } - if (autoScroll()) { - scheduleAnchorScroll(true) - } + if (autoScroll()) scheduleAnchorScroll(true) }) // Drop anchor lock if the anchored key is removed.