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:
@@ -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" }} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user