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 { For, Index, Match, Show, Switch, createMemo, createSignal, createEffect, onCleanup } from "solid-js"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
|
import VirtualItem from "./virtual-item"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
import Kbd from "./kbd"
|
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 USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||||
|
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
@@ -296,8 +298,11 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
scope: SCROLL_SCOPE,
|
scope: SCROLL_SCOPE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
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)
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let lastKnownScrollTop = 0
|
let lastKnownScrollTop = 0
|
||||||
@@ -348,6 +353,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
|
|
||||||
function setContainerRef(element: HTMLDivElement | null) {
|
function setContainerRef(element: HTMLDivElement | null) {
|
||||||
containerRef = element || undefined
|
containerRef = element || undefined
|
||||||
|
setScrollElement(containerRef)
|
||||||
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
||||||
lastMeasuredScrollHeight = containerRef?.scrollHeight ?? 0
|
lastMeasuredScrollHeight = containerRef?.scrollHeight ?? 0
|
||||||
attachScrollIntentListeners(containerRef)
|
attachScrollIntentListeners(containerRef)
|
||||||
@@ -644,22 +650,28 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
|
|
||||||
<Index each={messageIds()}>
|
<Index each={messageIds()}>
|
||||||
{(messageId) => (
|
{(messageId) => (
|
||||||
<MessageBlock
|
<VirtualItem
|
||||||
messageId={messageId()}
|
cacheKey={messageId()}
|
||||||
instanceId={props.instanceId}
|
scrollContainer={scrollElement}
|
||||||
sessionId={props.sessionId}
|
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||||
store={store}
|
placeholderClass="message-stream-placeholder"
|
||||||
messageIndexMap={messageIndexMap}
|
virtualizationEnabled={() => !props.loading}
|
||||||
lastAssistantIndex={lastAssistantIndex}
|
>
|
||||||
showThinking={() => preferences().showThinkingBlocks}
|
<MessageBlock
|
||||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
messageId={messageId()}
|
||||||
showUsageMetrics={showUsagePreference}
|
instanceId={props.instanceId}
|
||||||
onRevert={props.onRevert}
|
sessionId={props.sessionId}
|
||||||
onFork={props.onFork}
|
store={store}
|
||||||
onContentRendered={handleContentRendered}
|
messageIndexMap={messageIndexMap}
|
||||||
/>
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
|
showThinking={() => preferences().showThinkingBlocks}
|
||||||
|
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||||
|
showUsageMetrics={showUsagePreference}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
onContentRendered={handleContentRendered}
|
||||||
|
/>
|
||||||
|
</VirtualItem>
|
||||||
)}
|
)}
|
||||||
</Index>
|
</Index>
|
||||||
</div>
|
</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);
|
font-size: var(--font-size-lg);
|
||||||
color: var(--accent-primary);
|
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