Revert "perf(ui): start streams at newest"

This reverts commit 13802537b4.
This commit is contained in:
Shantur Rathore
2026-03-01 12:41:22 +00:00
parent 13802537b4
commit 594809538d
5 changed files with 258 additions and 227 deletions

View File

@@ -1,4 +1,4 @@
import { Index, createMemo, type Accessor } from "solid-js"
import { Index, type Accessor } from "solid-js"
import VirtualItem from "./virtual-item"
import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -20,6 +20,7 @@ interface MessageBlockListProps {
thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean
scrollContainer: Accessor<HTMLDivElement | undefined>
loading?: boolean
onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void
@@ -28,35 +29,22 @@ interface MessageBlockListProps {
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: Accessor<Set<string>>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
setNewestSentinel: (element: HTMLDivElement | null) => void
setOldestSentinel: (element: HTMLDivElement | null) => void
setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean
}
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 (
<>
<div ref={props.setNewestSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Index each={reversedMessageIds()}>
{(messageId) => (
<Index each={props.messageIds()}>
{(messageId, index) => (
<VirtualItem
id={getMessageAnchorId(messageId())}
cacheKey={messageId()}
scrollContainer={props.scrollContainer}
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements}
>
<MessageBlock
@@ -64,7 +52,7 @@ export default function MessageBlockList(props: MessageBlockListProps) {
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndex={indexByMessageId().get(messageId()) ?? 0}
messageIndex={index}
lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
@@ -81,7 +69,7 @@ export default function MessageBlockList(props: MessageBlockListProps) {
</VirtualItem>
)}
</Index>
<div ref={props.setOldestSentinel} aria-hidden="true" style={{ height: "1px" }} />
<div ref={props.setBottomSentinel} 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_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 codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_CHASE_CHECK_FRAMES = 10
export interface MessageSectionProps {
instanceId: string
sessionId: string
@@ -219,124 +219,80 @@ export default function MessageSection(props: MessageSectionProps) {
})
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [oldestSentinel, setOldestSentinel] = createSignal<HTMLDivElement | null>(null)
const [newestSentinelSignal, setNewestSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const newestSentinel = () => newestSentinelSignal()
const setNewestSentinel = (element: HTMLDivElement | null) => {
setNewestSentinelSignal(element)
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const bottomSentinel = () => bottomSentinelSignal()
const setBottomSentinel = (element: HTMLDivElement | null) => {
setBottomSentinelSignal(element)
resolvePendingActiveScroll()
}
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const [oldestSentinelVisible, setOldestSentinelVisible] = createSignal(true)
const [newestSentinelVisible, setNewestSentinelVisible] = createSignal(true)
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
let containerRef: HTMLDivElement | undefined
let shellRef: HTMLDivElement | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let pendingScrollPersist: number | null = null
let userScrollIntentUntil = 0
let detachScrollIntentListeners: (() => void) | undefined
let hasRestoredScroll = false
let suppressAutoScrollOnce = false
let pendingActiveScroll = false
let scrollToBottomFrame: number | null = null
let scrollToBottomDelayedFrame: number | null = null
let pendingInitialScroll = true
let chaseFrame: number | null = null
let chaseMode: "newest" | "oldest" | null = null
let detachChaseIntent: (() => void) | undefined
function clearScrollChase() {
if (chaseFrame !== null) {
cancelAnimationFrame(chaseFrame)
chaseFrame = null
}
chaseMode = null
if (detachChaseIntent) {
detachChaseIntent()
detachChaseIntent = undefined
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
}
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 })
function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
if (!element) return
const handlePointerIntent = () => markUserScrollIntent()
const handleKeyIntent = (event: KeyboardEvent) => {
if (SCROLL_INTENT_KEYS.has(event.key)) {
markUserScrollIntent()
}
return
}
// Oldest
const sentinel = oldestSentinel()
if (sentinel) {
sentinel.scrollIntoView({ block: "start", inline: "nearest", behavior })
} else {
// Best-effort: jump to far edge.
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
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) {
containerRef = element || undefined
setScrollElement(containerRef)
attachScrollIntentListeners(containerRef)
if (!containerRef) {
clearQuoteSelection()
return
}
resolvePendingActiveScroll()
}
function setShellElement(element: HTMLDivElement | null) {
@@ -349,10 +305,10 @@ export default function MessageSection(props: MessageSectionProps) {
function updateScrollIndicatorsFromVisibility() {
const hasItems = messageIds().length > 0
const latestVisible = newestSentinelVisible()
const oldestVisible = oldestSentinelVisible()
setShowScrollBottomButton(hasItems && !latestVisible)
setShowScrollTopButton(hasItems && !oldestVisible)
const bottomVisible = bottomSentinelVisible()
const topVisible = topSentinelVisible()
setShowScrollBottomButton(hasItems && !bottomVisible)
setShowScrollTopButton(hasItems && !topVisible)
}
function scheduleScrollPersist() {
@@ -364,20 +320,86 @@ export default function MessageSection(props: MessageSectionProps) {
})
}
function scrollToBottom(immediate = false) {
// In reversed mode, the visual "latest" position is scrollTop=0.
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
if (!containerRef) return
startScrollChase("newest")
const sentinel = bottomSentinel()
const behavior = immediate ? "auto" : "smooth"
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true
}
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
setAutoScroll(true)
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) {
if (!containerRef) return
startScrollChase("oldest")
const behavior = immediate ? "auto" : "smooth"
setAutoScroll(false)
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
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() {
setQuoteSelection(null)
}
@@ -465,7 +487,10 @@ export default function MessageSection(props: MessageSectionProps) {
}
function handleContentRendered() {
// No-op: scroll behavior is handled by explicit jumps + chase.
if (props.loading) {
return
}
scheduleAnchorScroll()
}
function handleScroll() {
@@ -474,9 +499,20 @@ export default function MessageSection(props: MessageSectionProps) {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
if (!containerRef) return
const atBottom = bottomSentinelVisible()
if (isUserScroll) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
clearQuoteSelection()
scheduleScrollPersist()
})
@@ -486,10 +522,42 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => {
if (props.registerScrollToBottom) {
props.registerScrollToBottom(() => scrollToBottom(true))
props.registerScrollToBottom(() => requestScrollToBottom(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[] = []
createEffect(() => {
@@ -721,28 +789,58 @@ export default function MessageSection(props: MessageSectionProps) {
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(() => {
if (messageIds().length === 0) {
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setAutoScroll(true)
return
}
updateScrollIndicatorsFromVisibility()
})
createEffect(() => {
const container = scrollElement()
const topTarget = oldestSentinel()
const bottomTarget = newestSentinel()
const topTarget = topSentinel()
const bottomTarget = bottomSentinel()
if (!container || !topTarget || !bottomTarget) return
const observer = new IntersectionObserver(
(entries) => {
let visibilityChanged = false
for (const entry of entries) {
if (entry.target === topTarget) {
setOldestSentinelVisible(entry.isIntersecting)
setTopSentinelVisible(entry.isIntersecting)
visibilityChanged = true
} else if (entry.target === bottomTarget) {
setNewestSentinelVisible(entry.isIntersecting)
setBottomSentinelVisible(entry.isIntersecting)
visibilityChanged = true
}
}
@@ -800,8 +898,14 @@ export default function MessageSection(props: MessageSectionProps) {
if (pendingScrollPersist !== null) {
cancelAnimationFrame(pendingScrollPersist)
}
clearScrollChase()
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
}
clearScrollToBottomFrames()
clearPendingTimelinePartUpdateFrame()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}
if (containerRef) {
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
}
@@ -833,34 +937,8 @@ export default function MessageSection(props: MessageSectionProps) {
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
>
<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}
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 ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Show when={!props.loading && messageIds().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">
@@ -881,17 +959,40 @@ export default function MessageSection(props: MessageSectionProps) {
</ul>
</div>
</div>
</div>
</Show>
<Show when={props.loading}>
<div class="message-stream-overlay">
</Show>
<Show when={props.loading}>
<div class="loading-state">
<div class="spinner" />
<p>{t("messageSection.loading.messages")}</p>
</div>
</div>
</Show>
</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()}>
<div class="message-scroll-button-wrapper">
@@ -904,7 +1005,7 @@ export default function MessageSection(props: MessageSectionProps) {
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom(false)}
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<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 DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 100
const MIN_PLACEHOLDER_HEIGHT = 32
const VISIBILITY_BUFFER_PX = 48
type ObserverRoot = Element | Document | null
@@ -156,12 +156,10 @@ interface VirtualItemProps {
export default function VirtualItem(props: VirtualItemProps) {
const resolved = resolveChildren(() => props.children)
const cachedHeight = sizeCache.get(props.cacheKey)
// Default to hidden until we can determine visibility.
// This avoids keeping heavy DOM alive when IntersectionObserver
// doesn't fire (common for hidden/zero-sized scroll roots).
const [isIntersecting, setIsIntersecting] = createSignal(false)
const [hasWrapper, setHasWrapper] = createSignal(false)
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
@@ -193,9 +191,6 @@ export default function VirtualItem(props: VirtualItemProps) {
const shouldHideContent = createMemo(() => {
if (props.forceVisible?.()) 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()
})
@@ -205,8 +200,6 @@ export default function VirtualItem(props: VirtualItemProps) {
let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | undefined
let precheckRetryFrame: number | null = null
let precheckRetryCount = 0
function cleanupResizeObserver() {
if (resizeObserver) {
@@ -215,13 +208,6 @@ export default function VirtualItem(props: VirtualItemProps) {
}
}
function cleanupPrecheckRetry() {
if (precheckRetryFrame !== null) {
cancelAnimationFrame(precheckRetryFrame)
precheckRetryFrame = null
}
}
function cleanupIntersectionObserver() {
if (intersectionCleanup) {
intersectionCleanup()
@@ -281,20 +267,10 @@ export default function VirtualItem(props: VirtualItemProps) {
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver()
cleanupPrecheckRetry()
if (!wrapperRef) {
setIsIntersecting(false)
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") {
setIsIntersecting(true)
return
@@ -323,25 +299,9 @@ export default function VirtualItem(props: VirtualItemProps) {
? (targetRoot as Element).getBoundingClientRect()
: null
const bounds = rootRect ? { top: rootRect.top, bottom: rootRect.bottom } : getViewportRect()
const wrapperRect = wrapperRef.getBoundingClientRect()
// 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)
setIsIntersecting(
shouldRenderByRects({ wrapperRect: wrapperRef.getBoundingClientRect(), rootRect: bounds, margin }),
)
} catch {
// Ignore measurement failures; IntersectionObserver will correct us.
}
@@ -354,7 +314,6 @@ export default function VirtualItem(props: VirtualItemProps) {
function setWrapperRef(element: HTMLDivElement | null) {
wrapperRef = element ?? undefined
setHasWrapper(Boolean(wrapperRef))
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
}
@@ -384,7 +343,6 @@ export default function VirtualItem(props: VirtualItemProps) {
}
})
createEffect(() => {
const key = props.cacheKey
@@ -417,7 +375,6 @@ export default function VirtualItem(props: VirtualItemProps) {
onCleanup(() => {
cleanupResizeObserver()
cleanupIntersectionObserver()
cleanupPrecheckRetry()
flushVisibility()
})

View File

@@ -1,10 +1,7 @@
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col-reverse gap-0.5;
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
background-color: var(--surface-base);
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 {

View File

@@ -215,18 +215,6 @@
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 {
@apply inline-flex items-center justify-center;
width: 2.75rem;