Add local virtualization wrapper to message stream
This commit is contained in:
@@ -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>
|
||||
|
||||
179
packages/ui/src/components/virtual-item.tsx
Normal file
179
packages/ui/src/components/virtual-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user