fix(ui): preserve stream scroll on session switch
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user