Revert "perf(ui): start streams at newest"
This reverts commit 13802537b4.
This commit is contained in:
@@ -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" }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user