diff --git a/packages/ui/src/components/message-stream-v2.tsx b/packages/ui/src/components/message-stream-v2.tsx index 33849261..73e69777 100644 --- a/packages/ui/src/components/message-stream-v2.tsx +++ b/packages/ui/src/components/message-stream-v2.tsx @@ -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 @@ -295,9 +297,12 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { sessionId: () => props.sessionId, scope: SCROLL_SCOPE, }) - + + const [scrollElement, setScrollElement] = createSignal() 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) { {(messageId) => ( - preferences().showThinkingBlocks} - thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"} - showUsageMetrics={showUsagePreference} - onRevert={props.onRevert} - onFork={props.onFork} - onContentRendered={handleContentRendered} - /> - - + !props.loading} + > + preferences().showThinkingBlocks} + thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"} + showUsageMetrics={showUsagePreference} + onRevert={props.onRevert} + onFork={props.onFork} + onContentRendered={handleContentRendered} + /> + )} diff --git a/packages/ui/src/components/virtual-item.tsx b/packages/ui/src/components/virtual-item.tsx new file mode 100644 index 00000000..b28e4f39 --- /dev/null +++ b/packages/ui/src/components/virtual-item.tsx @@ -0,0 +1,179 @@ +import { JSX, Show, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js" + +const sizeCache = new Map() +const DEFAULT_MARGIN_PX = 600 +const MIN_PLACEHOLDER_HEIGHT = 32 + +interface VirtualItemProps { + cacheKey: string + children: JSX.Element + scrollContainer?: Accessor + threshold?: number + minPlaceholderHeight?: number + class?: string + contentClass?: string + placeholderClass?: string + virtualizationEnabled?: Accessor +} + +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 ( +
+
+
+ {resolved()} +
+
+
+ ) +} + diff --git a/packages/ui/src/styles/messaging/message-stream.css b/packages/ui/src/styles/messaging/message-stream.css index a8fe2e2e..51cd8790 100644 --- a/packages/ui/src/styles/messaging/message-stream.css +++ b/packages/ui/src/styles/messaging/message-stream.css @@ -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; +}