Preserve session scroll when returning

This commit is contained in:
Shantur Rathore
2025-12-09 21:29:48 +00:00
parent 67a12d6126
commit c323667729
2 changed files with 40 additions and 9 deletions

View File

@@ -125,6 +125,7 @@ export default function MessageSection(props: MessageSectionProps) {
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null) const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
const changeToken = createMemo(() => String(sessionRevision())) const changeToken = createMemo(() => String(sessionRevision()))
const isActive = createMemo(() => props.isActive !== false)
const scrollCache = useScrollCache({ const scrollCache = useScrollCache({
@@ -236,11 +237,12 @@ export default function MessageSection(props: MessageSectionProps) {
}) })
} }
function scrollToBottom(immediate = false) { function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
if (!containerRef) return if (!containerRef) return
const sentinel = bottomSentinel() const sentinel = bottomSentinel()
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
if (!immediate) { const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true suppressAutoScrollOnce = true
} }
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior }) sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
@@ -260,6 +262,10 @@ export default function MessageSection(props: MessageSectionProps) {
} }
function requestScrollToBottom(immediate = true) { function requestScrollToBottom(immediate = true) {
if (!isActive()) {
pendingActiveScroll = true
return
}
if (!containerRef || !bottomSentinel()) { if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true pendingActiveScroll = true
return return
@@ -277,7 +283,7 @@ export default function MessageSection(props: MessageSectionProps) {
function resolvePendingActiveScroll() { function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return if (!pendingActiveScroll) return
if (!props.isActive) return if (!isActive()) return
requestScrollToBottom(true) requestScrollToBottom(true)
} }
@@ -292,8 +298,15 @@ export default function MessageSection(props: MessageSectionProps) {
function scheduleAnchorScroll(immediate = false) { function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return if (!autoScroll()) return
if (!isActive()) {
pendingActiveScroll = true
return
}
const sentinel = bottomSentinel() const sentinel = bottomSentinel()
if (!sentinel) return if (!sentinel) {
pendingActiveScroll = true
return
}
if (pendingAnchorScroll !== null) { if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll) cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null pendingAnchorScroll = null
@@ -415,9 +428,14 @@ export default function MessageSection(props: MessageSectionProps) {
let lastActiveState = false let lastActiveState = false
createEffect(() => { createEffect(() => {
const active = Boolean(props.isActive) const active = isActive()
if (active && !lastActiveState) { if (active) {
requestScrollToBottom(true) resolvePendingActiveScroll()
if (!lastActiveState && autoScroll()) {
requestScrollToBottom(true)
}
} else if (autoScroll()) {
pendingActiveScroll = true
} }
lastActiveState = active lastActiveState = active
}) })
@@ -670,7 +688,7 @@ export default function MessageSection(props: MessageSectionProps) {
onFork={props.onFork} onFork={props.onFork}
onContentRendered={handleContentRendered} onContentRendered={handleContentRendered}
setBottomSentinel={setBottomSentinel} setBottomSentinel={setBottomSentinel}
suspendMeasurements={() => props.isActive === false} suspendMeasurements={() => !isActive()}
onInitialRenderComplete={handleInitialRenderComplete} onInitialRenderComplete={handleInitialRenderComplete}
/> />
@@ -688,7 +706,7 @@ export default function MessageSection(props: MessageSectionProps) {
<button <button
type="button" type="button"
class="message-scroll-button" class="message-scroll-button"
onClick={() => scrollToBottom()} onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label="Scroll to latest message" aria-label="Scroll to latest message"
> >
<span class="message-scroll-icon" aria-hidden="true"></span> <span class="message-scroll-icon" aria-hidden="true"></span>

View File

@@ -167,6 +167,18 @@ export default function VirtualItem(props: VirtualItemProps) {
return return
} }
const normalized = nextHeight const normalized = nextHeight
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous))
if (shouldKeepPrevious) {
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
setHasMeasured(true)
sizeCache.set(props.cacheKey, previous)
setMeasuredHeight(previous)
return
}
if (normalized > 0) { if (normalized > 0) {
sizeCache.set(props.cacheKey, normalized) sizeCache.set(props.cacheKey, normalized)
setHasMeasured(true) setHasMeasured(true)
@@ -262,6 +274,7 @@ export default function VirtualItem(props: VirtualItemProps) {
}) })
createEffect(() => { createEffect(() => {
measurementsSuspended()
const root = props.scrollContainer ? props.scrollContainer() : null const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null) refreshIntersectionObserver(root ?? null)
}) })