diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 08a3afda..6bea6b99 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js" +import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import MessageItem from "./message-item" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" @@ -16,6 +16,7 @@ import { useI18n } from "../lib/i18n" import type { DeleteHoverState } from "../types/delete-hover" import { useSpeech } from "../lib/hooks/use-speech" import SpeechActionButton from "./speech-action-button" +import { createFollowScroll } from "../lib/follow-scroll" function DeleteUpToIcon() { return ( @@ -29,6 +30,7 @@ const TOOL_ICON = "🔧" 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 REASONING_SCROLL_SENTINEL_MARGIN_PX = 48 const LazyToolCall = lazy(() => import("./tool-call")) @@ -803,19 +805,19 @@ export default function MessageBlock(props: MessageBlockProps) { data-message-id={resolvedBlock().record.id} data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined} > - + {(item, index) => ( - + - + {(() => { - const toolItem = item as ToolDisplayItem + const toolItem = item() as ToolDisplayItem return (
- - + - + - + - + )} - +
)} @@ -1293,14 +1295,23 @@ interface ReasoningCardProps { onContentRendered?: () => void } -function ReasoningCard(props: ReasoningCardProps) { - const { t } = useI18n() - const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) - const [deletingMessage, setDeletingMessage] = createSignal(false) - const [deletingUpTo, setDeletingUpTo] = createSignal(false) - const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) +function ReasoningStreamOutput(props: { + text: Accessor + scrollTopSnapshot: Accessor + setScrollTopSnapshot: (next: number) => void + onContentRendered?: () => void + ariaLabel: string +}) { + let preRef: HTMLPreElement | undefined let pendingRenderNotificationFrame: number | null = null + const followScroll = createFollowScroll({ + getScrollTopSnapshot: props.scrollTopSnapshot, + setScrollTopSnapshot: props.setScrollTopSnapshot, + sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX, + sentinelClassName: "reasoning-scroll-sentinel", + }) + const notifyContentRendered = () => { if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return if (pendingRenderNotificationFrame !== null) { @@ -1312,6 +1323,17 @@ function ReasoningCard(props: ReasoningCardProps) { }) } + createEffect(() => { + const nextText = props.text() + if (preRef && preRef.textContent !== nextText) { + preRef.textContent = nextText + } + if (followScroll.autoScroll()) { + followScroll.restoreAfterRender({ forceBottom: true }) + } + notifyContentRendered() + }) + onCleanup(() => { if (pendingRenderNotificationFrame !== null) { cancelAnimationFrame(pendingRenderNotificationFrame) @@ -1319,6 +1341,37 @@ function ReasoningCard(props: ReasoningCardProps) { } }) + return ( +
+
 {
+          preRef = element || undefined
+          if (preRef) {
+            preRef.textContent = props.text() || ""
+          }
+        }}
+        class="message-reasoning-text"
+        dir="auto"
+      />
+      {followScroll.renderSentinel()}
+    
+ ) +} + +function ReasoningCard(props: ReasoningCardProps) { + const { t } = useI18n() + const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) + const [deletingMessage, setDeletingMessage] = createSignal(false) + const [deletingUpTo, setDeletingUpTo] = createSignal(false) + const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0) + const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) + createEffect(() => { setExpanded(Boolean(props.defaultExpanded)) }) @@ -1393,12 +1446,6 @@ function ReasoningCard(props: ReasoningCardProps) { const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech() - createEffect(() => { - if (!expanded()) return - reasoningText() - notifyContentRendered() - }) - const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const handleDeleteMessage = async (event: MouseEvent) => { @@ -1553,9 +1600,13 @@ function ReasoningCard(props: ReasoningCardProps) {
-
-
{reasoningText() || ""}
-
+
diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 572d178c..a21ff418 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" +import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" import { stringify as stringifyYaml } from "yaml" import { messageStoreBus } from "../stores/message-v2/bus" @@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title" import { getLogger } from "../lib/logger" import { useSpeech } from "../lib/hooks/use-speech" import SpeechActionButton from "./speech-action-button" +import { createFollowScroll } from "../lib/follow-scroll" const log = getLogger("session") @@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 -const TOOL_SCROLL_INTENT_WINDOW_MS = 600 -const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) function makeRenderCacheKey( toolCallId?: string | null, @@ -82,6 +81,27 @@ interface ToolCallProps { forceCollapsed?: boolean } +function ToolStatusIndicator(props: { status: Accessor }) { + const isVisible = (value: string) => props.status() === value + + return ( + + ) +} + function ToolCallDetails(props: { toolCallMemo: () => ToolCallPart toolState: () => ToolState | undefined @@ -166,179 +186,25 @@ function ToolCallDetails(props: { const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionError, setPermissionError] = createSignal(null) - const [scrollContainer, setScrollContainer] = createSignal() - const [bottomSentinel, setBottomSentinel] = createSignal(null) - const [autoScroll, setAutoScroll] = createSignal(true) - const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) - - let scrollContainerRef: HTMLDivElement | undefined - let detachScrollIntentListeners: (() => void) | undefined - - let pendingScrollFrame: number | null = null - let pendingAnchorScroll: number | null = null - let userScrollIntentUntil = 0 - let lastKnownScrollTop = props.scrollTopSnapshot() - - function restoreScrollPosition(forceBottom = false) { - const container = scrollContainerRef - if (!container) return - if (forceBottom) { - container.scrollTop = container.scrollHeight - lastKnownScrollTop = container.scrollTop - props.setScrollTopSnapshot(lastKnownScrollTop) - } else { - container.scrollTop = lastKnownScrollTop - } - } - - const persistScrollSnapshot = (element?: HTMLElement | null) => { - if (!element) return - lastKnownScrollTop = element.scrollTop - props.setScrollTopSnapshot(lastKnownScrollTop) - } - - function markUserScrollIntent() { - const now = typeof performance !== "undefined" ? performance.now() : Date.now() - userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS - } - - function hasUserScrollIntent() { - const now = typeof performance !== "undefined" ? performance.now() : Date.now() - return now <= userScrollIntentUntil - } - - function attachScrollIntentListeners(element: HTMLDivElement) { - if (detachScrollIntentListeners) { - detachScrollIntentListeners() - detachScrollIntentListeners = undefined - } - const handlePointerIntent = () => markUserScrollIntent() - const handleKeyIntent = (event: KeyboardEvent) => { - if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) { - markUserScrollIntent() - } - } - element.addEventListener("wheel", handlePointerIntent, { passive: true }) - element.addEventListener("pointerdown", handlePointerIntent) - element.addEventListener("touchstart", handlePointerIntent, { passive: true }) - element.addEventListener("keydown", handleKeyIntent) - detachScrollIntentListeners = () => { - element.removeEventListener("wheel", handlePointerIntent) - element.removeEventListener("pointerdown", handlePointerIntent) - element.removeEventListener("touchstart", handlePointerIntent) - element.removeEventListener("keydown", handleKeyIntent) - } - } - - function scheduleAnchorScroll(immediate = false) { - if (!autoScroll()) return - const sentinel = bottomSentinel() - const container = scrollContainerRef - if (!sentinel || !container) return - if (pendingAnchorScroll !== null) { - cancelAnimationFrame(pendingAnchorScroll) - pendingAnchorScroll = null - } - pendingAnchorScroll = requestAnimationFrame(() => { - pendingAnchorScroll = null - const containerRect = container.getBoundingClientRect() - const sentinelRect = sentinel.getBoundingClientRect() - const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX - if (Math.abs(delta) > 1) { - container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" }) - } - lastKnownScrollTop = container.scrollTop - props.setScrollTopSnapshot(lastKnownScrollTop) - }) - } - - function handleScroll() { - const container = scrollContainer() - if (!container) return - if (pendingScrollFrame !== null) { - cancelAnimationFrame(pendingScrollFrame) - } - const isUserScroll = hasUserScrollIntent() - pendingScrollFrame = requestAnimationFrame(() => { - pendingScrollFrame = null - const atBottom = bottomSentinelVisible() - if (isUserScroll) { - if (atBottom) { - if (!autoScroll()) setAutoScroll(true) - } else if (autoScroll()) { - setAutoScroll(false) - } - } - }) - } - - const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => { - handleScroll() - persistScrollSnapshot(event.currentTarget) - } - - const handleScrollRendered = () => { - requestAnimationFrame(() => { - restoreScrollPosition(autoScroll()) - scheduleAnchorScroll(true) - }) - } - - const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { - const next = element || undefined - if (next === scrollContainerRef) { - return - } - scrollContainerRef = next - setScrollContainer(scrollContainerRef) - if (scrollContainerRef) { - // Refresh our snapshot on mount (e.g. when remounting after collapse) - lastKnownScrollTop = props.scrollTopSnapshot() - restoreScrollPosition(autoScroll()) - } - } + const followScroll = createFollowScroll({ + getScrollTopSnapshot: props.scrollTopSnapshot, + setScrollTopSnapshot: props.setScrollTopSnapshot, + sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX, + sentinelClassName: "tool-call-scroll-sentinel", + }) const scrollHelpers: ToolScrollHelpers = { registerContainer: (element, options) => { - if (options?.disableTracking) return - initializeScrollContainer(element) - }, - handleScroll: handleScrollEvent, - renderSentinel: (options) => { - if (options?.disableTracking) return null - return