Add local virtualization wrapper to message stream

This commit is contained in:
Shantur Rathore
2025-12-02 11:13:12 +00:00
parent 52ee196103
commit a2d8ea0dfd
3 changed files with 232 additions and 17 deletions

View File

@@ -1,5 +1,6 @@
import { For, Index, Match, Show, Switch, createMemo, createSignal, createEffect, onCleanup } from "solid-js"
import MessageItem from "./message-item"
import VirtualItem from "./virtual-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import ToolCall from "./tool-call"
import Kbd from "./kbd"
@@ -26,6 +27,7 @@ const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).h
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const VIRTUAL_ITEM_MARGIN_PX = 800
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -295,9 +297,12 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
sessionId: () => props.sessionId,
scope: SCROLL_SCOPE,
})
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
let containerRef: HTMLDivElement | undefined
let lastKnownScrollTop = 0
@@ -348,6 +353,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined
setScrollElement(containerRef)
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastMeasuredScrollHeight = containerRef?.scrollHeight ?? 0
attachScrollIntentListeners(containerRef)
@@ -644,22 +650,28 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
<Index each={messageIds()}>
{(messageId) => (
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
messageIndexMap={messageIndexMap}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
showUsageMetrics={showUsagePreference}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={handleContentRendered}
/>
<VirtualItem
cacheKey={messageId()}
scrollContainer={scrollElement}
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
>
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
messageIndexMap={messageIndexMap}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
showUsageMetrics={showUsagePreference}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={handleContentRendered}
/>
</VirtualItem>
)}
</Index>
</div>

View File

@@ -0,0 +1,179 @@
import { JSX, Show, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 32
interface VirtualItemProps {
cacheKey: string
children: JSX.Element
scrollContainer?: Accessor<HTMLElement | undefined | null>
threshold?: number
minPlaceholderHeight?: number
class?: string
contentClass?: string
placeholderClass?: string
virtualizationEnabled?: Accessor<boolean>
}
export default function VirtualItem(props: VirtualItemProps) {
const resolved = resolveChildren(() => props.children)
const [isIntersecting, setIsIntersecting] = createSignal(true)
const [measuredHeight, setMeasuredHeight] = createSignal(sizeCache.get(props.cacheKey) ?? 0)
const [hasMeasured, setHasMeasured] = createSignal(sizeCache.has(props.cacheKey))
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionObserver: IntersectionObserver | undefined
function cleanupResizeObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = undefined
}
}
function cleanupIntersectionObserver() {
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = undefined
}
}
function persistMeasurement(nextHeight: number) {
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
return
}
const normalized = nextHeight
if (normalized > 0) {
sizeCache.set(props.cacheKey, normalized)
setHasMeasured(true)
}
setMeasuredHeight(normalized)
}
function updateMeasuredHeight() {
if (!contentRef) return
const next = contentRef.offsetHeight
if (next === measuredHeight()) return
persistMeasurement(next)
}
function setupResizeObserver() {
if (!contentRef) return
cleanupResizeObserver()
if (typeof ResizeObserver === "undefined") {
updateMeasuredHeight()
return
}
resizeObserver = new ResizeObserver(() => updateMeasuredHeight())
resizeObserver.observe(contentRef)
}
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver()
if (!wrapperRef || typeof IntersectionObserver === "undefined") {
setIsIntersecting(true)
return
}
const margin = props.threshold ?? DEFAULT_MARGIN_PX
intersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.target === wrapperRef) {
setIsIntersecting(entry.isIntersecting)
}
}
},
{
root: targetRoot,
rootMargin: `${margin}px 0px ${margin}px 0px`,
},
)
intersectionObserver.observe(wrapperRef)
}
function setWrapperRef(element: HTMLDivElement | null) {
wrapperRef = element ?? undefined
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
}
function setContentRef(element: HTMLDivElement | null) {
contentRef = element ?? undefined
if (contentRef) {
queueMicrotask(() => {
updateMeasuredHeight()
setupResizeObserver()
})
} else {
cleanupResizeObserver()
}
}
createEffect(() => {
const key = props.cacheKey
const cached = sizeCache.get(key)
if (cached !== undefined) {
setMeasuredHeight(cached)
setHasMeasured(true)
} else {
setMeasuredHeight(0)
setHasMeasured(false)
}
})
createEffect(() => {
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
})
const shouldHideContent = createMemo(() => {
if (!virtualizationEnabled()) return false
if (!hasMeasured()) return false
return !isIntersecting()
})
const placeholderHeight = createMemo(() => {
const seenHeight = measuredHeight()
if (seenHeight > 0) {
return seenHeight
}
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
})
onCleanup(() => {
cleanupResizeObserver()
cleanupIntersectionObserver()
})
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
const contentClass = () => {
const classes = ["virtual-item-content", props.contentClass]
if (shouldHideContent()) {
classes.push("virtual-item-content-hidden")
}
return classes.filter(Boolean).join(" ")
}
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
return (
<div ref={setWrapperRef} class={wrapperClass()} style={{ width: "100%" }}>
<div
class={placeholderClass()}
style={{
width: "100%",
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
"min-height": hasMeasured() ? undefined : `${props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT}px`,
}}
>
<div ref={setContentRef} class={contentClass()}>
{resolved()}
</div>
</div>
</div>
)
}

View File

@@ -112,3 +112,27 @@
font-size: var(--font-size-lg);
color: var(--accent-primary);
}
.virtual-item-wrapper {
width: 100%;
}
.virtual-item-placeholder,
.message-stream-placeholder {
display: block;
width: 100%;
position: relative;
background-color: transparent;
}
.virtual-item-content {
width: 100%;
position: relative;
}
.virtual-item-content-hidden {
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
}