fix(ui): preserve stream scroll on session switch

This commit is contained in:
Shantur Rathore
2026-03-03 22:44:18 +00:00
parent 8f955cf21c
commit f050273a8e
3 changed files with 221 additions and 29 deletions

View File

@@ -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<InstanceMessageStore>(() => 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()}
>
<VirtualFollowList
items={messageIds}
getKey={(messageId) => 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()
}}
<VirtualFollowList
items={messageIds}
getKey={(messageId) => 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()

View File

@@ -56,12 +56,22 @@ export const SessionView: Component<SessionViewProps> = (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<SessionViewProps> = (props) => {
}
createEffect(() => {
if (!props.isActive) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom()
})
@@ -321,7 +332,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn
if (props.isActive) {
scheduleScrollToBottom()
if (shouldScrollToBottomOnActivate()) {
scheduleScrollToBottom()
}
}
}}

View File

@@ -51,6 +51,34 @@ export interface VirtualFollowListProps<T> {
loading?: Accessor<boolean>
isActive?: Accessor<boolean>
/**
* 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<boolean>
/**
* 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<boolean>
/**
* Initial value for the internal autoScroll signal.
* Useful when restoring scroll state (e.g. start in non-follow mode).
*/
initialAutoScroll?: Accessor<boolean>
/**
* When this value changes, the list resets internal follow/anchor state.
* Useful when reusing the same list instance across different datasets.
*/
resetKey?: Accessor<string | number>
/**
* 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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
})
})
}
} else if (autoScroll()) {
} else if (autoScroll() && scrollToBottomOnActivate()) {
pendingActiveScroll = true
}
lastActiveState = active
@@ -569,6 +676,12 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
suppressAutoScrollOnce = false
return
}
if (autoScroll()) {
scheduleAnchorScroll(true)
}
if (autoScroll()) scheduleAnchorScroll(true)
})
// Drop anchor lock if the anchored key is removed.