perf(ui): start streams at newest

Reverse the message stream scroll layout so the viewport naturally starts at the newest messages and keeps older content virtualized. Use sentinel-based edge chasing to make jump-to-top/bottom land reliably despite VirtualItem mounts.
This commit is contained in:
Shantur Rathore
2026-03-01 12:40:18 +00:00
parent ca2b3c232f
commit 13802537b4
5 changed files with 227 additions and 258 deletions

View File

@@ -1,4 +1,4 @@
import { Index, type Accessor } from "solid-js" import { Index, createMemo, type Accessor } from "solid-js"
import VirtualItem from "./virtual-item" import VirtualItem from "./virtual-item"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -20,7 +20,6 @@ interface MessageBlockListProps {
thinkingDefaultExpanded: () => boolean thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean showUsageMetrics: () => boolean
scrollContainer: Accessor<HTMLDivElement | undefined> scrollContainer: Accessor<HTMLDivElement | undefined>
loading?: boolean
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void> onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
@@ -29,22 +28,35 @@ interface MessageBlockListProps {
onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: Accessor<Set<string>> selectedMessageIds?: Accessor<Set<string>>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
setBottomSentinel: (element: HTMLDivElement | null) => void setNewestSentinel: (element: HTMLDivElement | null) => void
setOldestSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean suspendMeasurements?: () => boolean
} }
export default function MessageBlockList(props: MessageBlockListProps) { export default function MessageBlockList(props: MessageBlockListProps) {
// Render newest messages first in the DOM so the reversed scroll container
// starts at the newest messages without any imperative scrolling.
const reversedMessageIds = createMemo(() => props.messageIds().slice().reverse())
const indexByMessageId = createMemo(() => {
const ids = props.messageIds()
const map = new Map<string, number>()
for (let i = 0; i < ids.length; i++) {
map.set(ids[i], i)
}
return map
})
return ( return (
<> <>
<Index each={props.messageIds()}> <div ref={props.setNewestSentinel} aria-hidden="true" style={{ height: "1px" }} />
{(messageId, index) => ( <Index each={reversedMessageIds()}>
{(messageId) => (
<VirtualItem <VirtualItem
id={getMessageAnchorId(messageId())} id={getMessageAnchorId(messageId())}
cacheKey={messageId()} cacheKey={messageId()}
scrollContainer={props.scrollContainer} scrollContainer={props.scrollContainer}
threshold={VIRTUAL_ITEM_MARGIN_PX} threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder" placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements} suspendMeasurements={props.suspendMeasurements}
> >
<MessageBlock <MessageBlock
@@ -52,7 +64,7 @@ export default function MessageBlockList(props: MessageBlockListProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={props.store} store={props.store}
messageIndex={index} messageIndex={indexByMessageId().get(messageId()) ?? 0}
lastAssistantIndex={props.lastAssistantIndex} lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking} showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded} thinkingDefaultExpanded={props.thinkingDefaultExpanded}
@@ -69,7 +81,7 @@ export default function MessageBlockList(props: MessageBlockListProps) {
</VirtualItem> </VirtualItem>
)} )}
</Index> </Index>
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} /> <div ref={props.setOldestSentinel} aria-hidden="true" style={{ height: "1px" }} />
</> </>
) )
} }

View File

@@ -17,11 +17,11 @@ import type { DeleteHoverState } from "../types/delete-hover"
const SCROLL_SCOPE = "session" const SCROLL_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48 const SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
const QUOTE_SELECTION_MAX_LENGTH = 2000 const QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_CHASE_CHECK_FRAMES = 10
export interface MessageSectionProps { export interface MessageSectionProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
@@ -219,80 +219,124 @@ export default function MessageSection(props: MessageSectionProps) {
}) })
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>() const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null) const [oldestSentinel, setOldestSentinel] = createSignal<HTMLDivElement | null>(null)
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null) const [newestSentinelSignal, setNewestSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const bottomSentinel = () => bottomSentinelSignal() const newestSentinel = () => newestSentinelSignal()
const setBottomSentinel = (element: HTMLDivElement | null) => { const setNewestSentinel = (element: HTMLDivElement | null) => {
setBottomSentinelSignal(element) setNewestSentinelSignal(element)
resolvePendingActiveScroll()
} }
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true) const [oldestSentinelVisible, setOldestSentinelVisible] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) const [newestSentinelVisible, setNewestSentinelVisible] = createSignal(true)
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
let shellRef: HTMLDivElement | undefined let shellRef: HTMLDivElement | undefined
let pendingScrollFrame: number | null = null let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let pendingScrollPersist: number | null = null let pendingScrollPersist: number | null = null
let userScrollIntentUntil = 0
let detachScrollIntentListeners: (() => void) | undefined
let hasRestoredScroll = false let hasRestoredScroll = false
let suppressAutoScrollOnce = false
let pendingActiveScroll = false
let scrollToBottomFrame: number | null = null
let scrollToBottomDelayedFrame: number | null = null
let pendingInitialScroll = true
function markUserScrollIntent() { let chaseFrame: number | null = null
const now = typeof performance !== "undefined" ? performance.now() : Date.now() let chaseMode: "newest" | "oldest" | null = null
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS let detachChaseIntent: (() => void) | undefined
}
function hasUserScrollIntent() { function clearScrollChase() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now() if (chaseFrame !== null) {
return now <= userScrollIntentUntil cancelAnimationFrame(chaseFrame)
} chaseFrame = null
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
} }
if (!element) return chaseMode = null
const handlePointerIntent = () => markUserScrollIntent() if (detachChaseIntent) {
const handleKeyIntent = (event: KeyboardEvent) => { detachChaseIntent()
if (SCROLL_INTENT_KEYS.has(event.key)) { detachChaseIntent = undefined
markUserScrollIntent() }
}
function performEdgeScroll(mode: "newest" | "oldest", behavior: ScrollBehavior) {
if (!containerRef) return
if (mode === "newest") {
const sentinel = newestSentinel()
if (sentinel) {
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior })
} else {
// With the reversed scroll container, newest corresponds to scrollTop=0.
containerRef.scrollTo({ top: 0, behavior })
} }
return
} }
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent) // Oldest
element.addEventListener("touchstart", handlePointerIntent, { passive: true }) const sentinel = oldestSentinel()
element.addEventListener("keydown", handleKeyIntent) if (sentinel) {
detachScrollIntentListeners = () => { sentinel.scrollIntoView({ block: "start", inline: "nearest", behavior })
element.removeEventListener("wheel", handlePointerIntent) } else {
element.removeEventListener("pointerdown", handlePointerIntent) // Best-effort: jump to far edge.
element.removeEventListener("touchstart", handlePointerIntent) containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
element.removeEventListener("keydown", handleKeyIntent)
} }
} }
function isEdgeVisible(mode: "newest" | "oldest") {
return mode === "newest" ? newestSentinelVisible() : oldestSentinelVisible()
}
function startScrollChase(mode: "newest" | "oldest") {
if (!containerRef) return
clearScrollChase()
chaseMode = mode
// If the user starts interacting, stop chasing.
const element = containerRef
const cancel = () => clearScrollChase()
element.addEventListener("wheel", cancel, { passive: true })
element.addEventListener("pointerdown", cancel)
element.addEventListener("touchstart", cancel, { passive: true })
detachChaseIntent = () => {
element.removeEventListener("wheel", cancel)
element.removeEventListener("pointerdown", cancel)
element.removeEventListener("touchstart", cancel)
}
// Always use instant scroll.
performEdgeScroll(mode, "auto")
// After the click-triggered scroll, give layout a few frames to settle.
// If the sentinel still isn't visible, request another scrollIntoView.
let framesRemaining = SCROLL_CHASE_CHECK_FRAMES
const tick = () => {
chaseFrame = null
if (!containerRef || !chaseMode) return
framesRemaining -= 1
if (framesRemaining > 0) {
chaseFrame = requestAnimationFrame(tick)
return
}
if (isEdgeVisible(chaseMode)) {
clearScrollChase()
return
}
// Retry with instant behavior.
performEdgeScroll(chaseMode, "auto")
framesRemaining = SCROLL_CHASE_CHECK_FRAMES
chaseFrame = requestAnimationFrame(tick)
}
chaseFrame = requestAnimationFrame(tick)
}
function setContainerRef(element: HTMLDivElement | null) { function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined containerRef = element || undefined
setScrollElement(containerRef) setScrollElement(containerRef)
attachScrollIntentListeners(containerRef)
if (!containerRef) { if (!containerRef) {
clearQuoteSelection() clearQuoteSelection()
return return
} }
resolvePendingActiveScroll()
} }
function setShellElement(element: HTMLDivElement | null) { function setShellElement(element: HTMLDivElement | null) {
@@ -305,10 +349,10 @@ export default function MessageSection(props: MessageSectionProps) {
function updateScrollIndicatorsFromVisibility() { function updateScrollIndicatorsFromVisibility() {
const hasItems = messageIds().length > 0 const hasItems = messageIds().length > 0
const bottomVisible = bottomSentinelVisible() const latestVisible = newestSentinelVisible()
const topVisible = topSentinelVisible() const oldestVisible = oldestSentinelVisible()
setShowScrollBottomButton(hasItems && !bottomVisible) setShowScrollBottomButton(hasItems && !latestVisible)
setShowScrollTopButton(hasItems && !topVisible) setShowScrollTopButton(hasItems && !oldestVisible)
} }
function scheduleScrollPersist() { function scheduleScrollPersist() {
@@ -320,86 +364,20 @@ export default function MessageSection(props: MessageSectionProps) {
}) })
} }
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) { function scrollToBottom(immediate = false) {
// In reversed mode, the visual "latest" position is scrollTop=0.
if (!containerRef) return if (!containerRef) return
const sentinel = bottomSentinel() startScrollChase("newest")
const behavior = immediate ? "auto" : "smooth"
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true
}
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
setAutoScroll(true)
scheduleScrollPersist() scheduleScrollPersist()
} }
function clearScrollToBottomFrames() {
if (scrollToBottomFrame !== null) {
cancelAnimationFrame(scrollToBottomFrame)
scrollToBottomFrame = null
}
if (scrollToBottomDelayedFrame !== null) {
cancelAnimationFrame(scrollToBottomDelayedFrame)
scrollToBottomDelayedFrame = null
}
}
function requestScrollToBottom(immediate = true) {
if (!isActive()) {
pendingActiveScroll = true
return
}
if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true
return
}
pendingActiveScroll = false
clearScrollToBottomFrames()
scrollToBottomFrame = requestAnimationFrame(() => {
scrollToBottomFrame = null
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
scrollToBottomDelayedFrame = null
scrollToBottom(immediate)
})
})
}
function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return
if (!isActive()) return
requestScrollToBottom(true)
}
function scrollToTop(immediate = false) { function scrollToTop(immediate = false) {
if (!containerRef) return if (!containerRef) return
const behavior = immediate ? "auto" : "smooth" startScrollChase("oldest")
setAutoScroll(false)
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
scheduleScrollPersist() scheduleScrollPersist()
} }
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
if (!isActive()) {
pendingActiveScroll = true
return
}
const sentinel = bottomSentinel()
if (!sentinel) {
pendingActiveScroll = true
return
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
})
}
function clearQuoteSelection() { function clearQuoteSelection() {
setQuoteSelection(null) setQuoteSelection(null)
} }
@@ -487,10 +465,7 @@ export default function MessageSection(props: MessageSectionProps) {
} }
function handleContentRendered() { function handleContentRendered() {
if (props.loading) { // No-op: scroll behavior is handled by explicit jumps + chase.
return
}
scheduleAnchorScroll()
} }
function handleScroll() { function handleScroll() {
@@ -499,20 +474,9 @@ export default function MessageSection(props: MessageSectionProps) {
if (pendingScrollFrame !== null) { if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame) cancelAnimationFrame(pendingScrollFrame)
} }
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => { pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null pendingScrollFrame = null
if (!containerRef) return if (!containerRef) return
const atBottom = bottomSentinelVisible()
if (isUserScroll) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
clearQuoteSelection() clearQuoteSelection()
scheduleScrollPersist() scheduleScrollPersist()
}) })
@@ -522,42 +486,10 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => { createEffect(() => {
if (props.registerScrollToBottom) { if (props.registerScrollToBottom) {
props.registerScrollToBottom(() => requestScrollToBottom(true)) props.registerScrollToBottom(() => scrollToBottom(true))
} }
}) })
let lastActiveState = false
createEffect(() => {
const active = isActive()
if (active) {
resolvePendingActiveScroll()
if (!lastActiveState && autoScroll()) {
requestScrollToBottom(true)
}
} else if (autoScroll()) {
pendingActiveScroll = true
}
lastActiveState = active
})
createEffect(() => {
const loading = Boolean(props.loading)
if (loading) {
pendingInitialScroll = true
return
}
if (!pendingInitialScroll) {
return
}
const container = scrollElement()
const sentinel = bottomSentinel()
if (!container || !sentinel || messageIds().length === 0) {
return
}
pendingInitialScroll = false
requestScrollToBottom(true)
})
let previousTimelineIds: string[] = [] let previousTimelineIds: string[] = []
createEffect(() => { createEffect(() => {
@@ -789,58 +721,28 @@ export default function MessageSection(props: MessageSectionProps) {
hasRestoredScroll = true hasRestoredScroll = true
}) })
let previousToken: string | undefined
createEffect(() => {
const token = changeToken()
const loading = props.loading
if (loading || !token || token === previousToken) {
return
}
previousToken = token
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
if (autoScroll()) {
scheduleAnchorScroll(true)
}
})
createEffect(() => {
preferenceSignature()
if (props.loading || !autoScroll()) {
return
}
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
scheduleAnchorScroll(true)
})
createEffect(() => { createEffect(() => {
if (messageIds().length === 0) { if (messageIds().length === 0) {
setShowScrollTopButton(false) setShowScrollTopButton(false)
setShowScrollBottomButton(false) setShowScrollBottomButton(false)
setAutoScroll(true)
return return
} }
updateScrollIndicatorsFromVisibility() updateScrollIndicatorsFromVisibility()
}) })
createEffect(() => { createEffect(() => {
const container = scrollElement() const container = scrollElement()
const topTarget = topSentinel() const topTarget = oldestSentinel()
const bottomTarget = bottomSentinel() const bottomTarget = newestSentinel()
if (!container || !topTarget || !bottomTarget) return if (!container || !topTarget || !bottomTarget) return
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
let visibilityChanged = false let visibilityChanged = false
for (const entry of entries) { for (const entry of entries) {
if (entry.target === topTarget) { if (entry.target === topTarget) {
setTopSentinelVisible(entry.isIntersecting) setOldestSentinelVisible(entry.isIntersecting)
visibilityChanged = true visibilityChanged = true
} else if (entry.target === bottomTarget) { } else if (entry.target === bottomTarget) {
setBottomSentinelVisible(entry.isIntersecting) setNewestSentinelVisible(entry.isIntersecting)
visibilityChanged = true visibilityChanged = true
} }
} }
@@ -898,14 +800,8 @@ export default function MessageSection(props: MessageSectionProps) {
if (pendingScrollPersist !== null) { if (pendingScrollPersist !== null) {
cancelAnimationFrame(pendingScrollPersist) cancelAnimationFrame(pendingScrollPersist)
} }
if (pendingAnchorScroll !== null) { clearScrollChase()
cancelAnimationFrame(pendingAnchorScroll)
}
clearScrollToBottomFrames()
clearPendingTimelinePartUpdateFrame() clearPendingTimelinePartUpdateFrame()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}
if (containerRef) { if (containerRef) {
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX }) // scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
} }
@@ -937,8 +833,34 @@ export default function MessageSection(props: MessageSectionProps) {
data-instance-id={props.instanceId} data-instance-id={props.instanceId}
data-session-id={props.sessionId} data-session-id={props.sessionId}
> >
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} /> <MessageBlockList
<Show when={!props.loading && messageIds().length === 0}> instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
messageIds={messageIds}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
showUsageMetrics={showUsagePreference}
scrollContainer={scrollElement}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork}
onContentRendered={handleContentRendered}
deleteHover={deleteHover}
onDeleteHoverChange={setDeleteHover}
selectedMessageIds={selectedForDeletion}
onToggleSelectedMessage={setMessageSelectedForDeletion}
setNewestSentinel={setNewestSentinel}
setOldestSentinel={setOldestSentinel}
suspendMeasurements={() => !isActive()}
/>
</div>
<Show when={!props.loading && messageIds().length === 0}>
<div class="message-stream-overlay">
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-content"> <div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6"> <div class="flex flex-col items-center gap-3 mb-6">
@@ -959,40 +881,17 @@ export default function MessageSection(props: MessageSectionProps) {
</ul> </ul>
</div> </div>
</div> </div>
</Show> </div>
</Show>
<Show when={props.loading}>
<Show when={props.loading}>
<div class="message-stream-overlay">
<div class="loading-state"> <div class="loading-state">
<div class="spinner" /> <div class="spinner" />
<p>{t("messageSection.loading.messages")}</p> <p>{t("messageSection.loading.messages")}</p>
</div> </div>
</Show> </div>
</Show>
<MessageBlockList
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
messageIds={messageIds}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
showUsageMetrics={showUsagePreference}
scrollContainer={scrollElement}
loading={props.loading}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork}
onContentRendered={handleContentRendered}
deleteHover={deleteHover}
onDeleteHoverChange={setDeleteHover}
selectedMessageIds={selectedForDeletion}
onToggleSelectedMessage={setMessageSelectedForDeletion}
setBottomSentinel={setBottomSentinel}
suspendMeasurements={() => !isActive()}
/>
</div>
<Show when={showScrollTopButton() || showScrollBottomButton()}> <Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper"> <div class="message-scroll-button-wrapper">
@@ -1005,7 +904,7 @@ export default function MessageSection(props: MessageSectionProps) {
<button <button
type="button" type="button"
class="message-scroll-button" class="message-scroll-button"
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })} onClick={() => scrollToBottom(false)}
aria-label={t("messageSection.scroll.toLatestAriaLabel")} aria-label={t("messageSection.scroll.toLatestAriaLabel")}
> >
<span class="message-scroll-icon" aria-hidden="true"></span> <span class="message-scroll-icon" aria-hidden="true"></span>

View File

@@ -2,7 +2,7 @@ import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, c
const sizeCache = new Map<string, number>() const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600 const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 32 const MIN_PLACEHOLDER_HEIGHT = 100
const VISIBILITY_BUFFER_PX = 48 const VISIBILITY_BUFFER_PX = 48
type ObserverRoot = Element | Document | null type ObserverRoot = Element | Document | null
@@ -156,10 +156,12 @@ interface VirtualItemProps {
export default function VirtualItem(props: VirtualItemProps) { export default function VirtualItem(props: VirtualItemProps) {
const resolved = resolveChildren(() => props.children) const resolved = resolveChildren(() => props.children)
const cachedHeight = sizeCache.get(props.cacheKey) const cachedHeight = sizeCache.get(props.cacheKey)
// Default to hidden until we can determine visibility. // Default to hidden until we can determine visibility.
// This avoids keeping heavy DOM alive when IntersectionObserver // This avoids keeping heavy DOM alive when IntersectionObserver
// doesn't fire (common for hidden/zero-sized scroll roots). // doesn't fire (common for hidden/zero-sized scroll roots).
const [isIntersecting, setIsIntersecting] = createSignal(false) const [isIntersecting, setIsIntersecting] = createSignal(false)
const [hasWrapper, setHasWrapper] = createSignal(false)
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0) const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined) const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0) let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
@@ -191,6 +193,9 @@ export default function VirtualItem(props: VirtualItemProps) {
const shouldHideContent = createMemo(() => { const shouldHideContent = createMemo(() => {
if (props.forceVisible?.()) return false if (props.forceVisible?.()) return false
if (!virtualizationEnabled()) return false if (!virtualizationEnabled()) return false
// Avoid mounting everything on first paint; wait until the wrapper ref is
// attached so we can run the rect pre-check and/or observer.
if (!hasWrapper()) return true
return !isIntersecting() return !isIntersecting()
}) })
@@ -200,6 +205,8 @@ export default function VirtualItem(props: VirtualItemProps) {
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | undefined let intersectionCleanup: (() => void) | undefined
let precheckRetryFrame: number | null = null
let precheckRetryCount = 0
function cleanupResizeObserver() { function cleanupResizeObserver() {
if (resizeObserver) { if (resizeObserver) {
@@ -208,6 +215,13 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
} }
function cleanupPrecheckRetry() {
if (precheckRetryFrame !== null) {
cancelAnimationFrame(precheckRetryFrame)
precheckRetryFrame = null
}
}
function cleanupIntersectionObserver() { function cleanupIntersectionObserver() {
if (intersectionCleanup) { if (intersectionCleanup) {
intersectionCleanup() intersectionCleanup()
@@ -267,10 +281,20 @@ export default function VirtualItem(props: VirtualItemProps) {
function refreshIntersectionObserver(targetRoot: Element | Document | null) { function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver() cleanupIntersectionObserver()
cleanupPrecheckRetry()
if (!wrapperRef) { if (!wrapperRef) {
setIsIntersecting(false) setIsIntersecting(false)
return return
} }
// If the caller provided an explicit scroll root but it isn't available yet
// (common during pane/session switches), don't fall back to viewport-based
// visibility. Hidden/display:none panes can yield 0x0 wrapper rects which
// would incorrectly mark every item as visible and mount heavy DOM.
if (props.scrollContainer && !targetRoot) {
setIsIntersecting(false)
return
}
if (typeof IntersectionObserver === "undefined") { if (typeof IntersectionObserver === "undefined") {
setIsIntersecting(true) setIsIntersecting(true)
return return
@@ -299,9 +323,25 @@ export default function VirtualItem(props: VirtualItemProps) {
? (targetRoot as Element).getBoundingClientRect() ? (targetRoot as Element).getBoundingClientRect()
: null : null
const bounds = rootRect ? { top: rootRect.top, bottom: rootRect.bottom } : getViewportRect() const bounds = rootRect ? { top: rootRect.top, bottom: rootRect.bottom } : getViewportRect()
setIsIntersecting( const wrapperRect = wrapperRef.getBoundingClientRect()
shouldRenderByRects({ wrapperRect: wrapperRef.getBoundingClientRect(), rootRect: bounds, margin }), // During display toggles (e.g. session switches), layout can momentarily
) // report 0x0 rects even though the root is renderable. Treat that as
// unknown visibility and defer to the observer (or a retry) instead of
// marking everything visible and mounting heavy DOM.
if (wrapperRect.width === 0 && wrapperRect.height === 0) {
setIsIntersecting(false)
if (precheckRetryCount < 3 && typeof requestAnimationFrame !== "undefined") {
precheckRetryCount += 1
precheckRetryFrame = requestAnimationFrame(() => {
precheckRetryFrame = null
refreshIntersectionObserver(targetRoot)
})
}
return
}
precheckRetryCount = 0
const visibleNow = shouldRenderByRects({ wrapperRect, rootRect: bounds, margin })
setIsIntersecting(visibleNow)
} catch { } catch {
// Ignore measurement failures; IntersectionObserver will correct us. // Ignore measurement failures; IntersectionObserver will correct us.
} }
@@ -314,6 +354,7 @@ export default function VirtualItem(props: VirtualItemProps) {
function setWrapperRef(element: HTMLDivElement | null) { function setWrapperRef(element: HTMLDivElement | null) {
wrapperRef = element ?? undefined wrapperRef = element ?? undefined
setHasWrapper(Boolean(wrapperRef))
const root = props.scrollContainer ? props.scrollContainer() : null const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null) refreshIntersectionObserver(root ?? null)
} }
@@ -343,6 +384,7 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
}) })
createEffect(() => { createEffect(() => {
const key = props.cacheKey const key = props.cacheKey
@@ -375,6 +417,7 @@ export default function VirtualItem(props: VirtualItemProps) {
onCleanup(() => { onCleanup(() => {
cleanupResizeObserver() cleanupResizeObserver()
cleanupIntersectionObserver() cleanupIntersectionObserver()
cleanupPrecheckRetry()
flushVisibility() flushVisibility()
}) })

View File

@@ -1,7 +1,10 @@
.message-stream { .message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5; @apply flex-1 min-h-0 overflow-y-auto flex flex-col-reverse gap-0.5;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
/* Disable browser scroll anchoring; VirtualItem mounts can otherwise
re-anchor the viewport and create "jumpiness" near the newest edge. */
overflow-anchor: none;
} }
.message-stream-block { .message-stream-block {

View File

@@ -215,6 +215,18 @@
align-items: flex-end; align-items: flex-end;
} }
.message-stream-overlay {
position: absolute;
inset: 0;
display: flex;
min-height: 0;
pointer-events: none;
}
.message-stream-overlay > * {
pointer-events: auto;
}
.message-scroll-button { .message-scroll-button {
@apply inline-flex items-center justify-center; @apply inline-flex items-center justify-center;
width: 2.75rem; width: 2.75rem;