diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 4eb655a9..e3d4eba6 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -18,6 +18,9 @@ const log = getLogger("session") type ToolState = import("@opencode-ai/sdk").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, @@ -284,26 +287,177 @@ export default function ToolCall(props: ToolCallProps) { if (override !== undefined) return override return diagnosticsDefaultExpanded() } - const diagnosticsEntries = createMemo(() => { const state = toolState() if (!state) return [] return extractDiagnostics(state) }) + const [scrollContainer, setScrollContainer] = createSignal() + const [bottomSentinel, setBottomSentinel] = createSignal(null) + const [autoScroll, setAutoScroll] = createSignal(true) + const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) - let scrollContainerRef: HTMLDivElement | undefined let toolCallRootRef: HTMLDivElement | undefined + let scrollContainerRef: HTMLDivElement | undefined + let detachScrollIntentListeners: (() => void) | undefined + + let pendingScrollFrame: number | null = null + let pendingAnchorScroll: number | null = null + let userScrollIntentUntil = 0 + let lastKnownScrollTop = 0 + + function restoreScrollPosition(forceBottom = false) { + const container = scrollContainerRef + if (!container) return + if (forceBottom) { + container.scrollTop = container.scrollHeight + lastKnownScrollTop = container.scrollTop + } else { + container.scrollTop = lastKnownScrollTop + } + } + + const persistScrollSnapshot = (element?: HTMLElement | null) => { + if (!element) return + lastKnownScrollTop = element.scrollTop + } + + const handleScrollRendered = () => { + requestAnimationFrame(() => { + restoreScrollPosition(autoScroll()) + if (!expanded()) return + scheduleAnchorScroll() + }) + } - const persistScrollSnapshot = (_element?: HTMLElement | null) => {} - - const handleScrollRendered = () => {} - const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { scrollContainerRef = element || undefined + setScrollContainer(scrollContainerRef) + if (scrollContainerRef) { + restoreScrollPosition(autoScroll()) + } } + 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 + }) + } + + 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) + } + + createEffect(() => { + const container = scrollContainer() + if (!container) return + + attachScrollIntentListeners(container) + onCleanup(() => { + if (detachScrollIntentListeners) { + detachScrollIntentListeners() + detachScrollIntentListeners = undefined + } + }) + }) + + createEffect(() => { + const container = scrollContainer() + const sentinel = bottomSentinel() + if (!container || !sentinel) return + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.target === sentinel) { + setBottomSentinelVisible(entry.isIntersecting) + } + }) + }, + { root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` }, + ) + observer.observe(sentinel) + onCleanup(() => observer.disconnect()) + }) + + createEffect(() => { + if (!expanded()) { + setScrollContainer(undefined) + scrollContainerRef = undefined + setBottomSentinel(null) + setAutoScroll(true) + } + }) createEffect(() => { const permission = permissionDetails() @@ -315,7 +469,6 @@ export default function ToolCall(props: ToolCallProps) { } }) - createEffect(() => { const activeKey = activePermissionKey() if (!activeKey) return @@ -343,11 +496,6 @@ export default function ToolCall(props: ToolCallProps) { onCleanup(() => document.removeEventListener("keydown", handler)) }) - createEffect(() => { - if (!expanded()) { - scrollContainerRef = undefined - } - }) const statusIcon = () => { const status = toolState()?.status || "" @@ -421,7 +569,7 @@ export default function ToolCall(props: ToolCallProps) { if (options?.disableScrollTracking) return initializeScrollContainer(element) }} - onScroll={options?.disableScrollTracking ? undefined : (event) => persistScrollSnapshot(event.currentTarget)} + onScroll={options?.disableScrollTracking ? undefined : handleScrollEvent} >
{toolbarLabel} @@ -453,6 +601,9 @@ export default function ToolCall(props: ToolCallProps) { cacheEntryParams={cacheHandle.params()} onRendered={handleDiffRendered} /> + + ) } @@ -479,21 +630,19 @@ export default function ToolCall(props: ToolCallProps) { } return ( -
initializeScrollContainer(element)} - onScroll={(event) => persistScrollSnapshot(event.currentTarget)} - > +
initializeScrollContainer(element)} onScroll={handleScrollEvent}> + ) } + const messageVersionAccessor = createMemo(() => props.messageVersion) const partVersionAccessor = createMemo(() => props.partVersion) @@ -507,8 +656,31 @@ export default function ToolCall(props: ToolCallProps) { renderDiff: renderDiffContent, } + let previousPartVersion: number | undefined + createEffect(() => { + const version = partVersionAccessor() + if (!expanded()) { + return + } + if (version === undefined) { + return + } + if (previousPartVersion !== undefined && version === previousPartVersion) { + return + } + previousPartVersion = version + scheduleAnchorScroll() + }) + + createEffect(() => { + if (expanded() && autoScroll()) { + scheduleAnchorScroll(true) + } + }) + const getRendererAction = () => renderer().getAction?.(rendererContext) ?? getDefaultToolAction(toolName()) + const renderToolTitle = () => { const state = toolState() if (!state) return getRendererAction() @@ -653,8 +825,24 @@ export default function ToolCall(props: ToolCallProps) { const status = () => toolState()?.status || "" + onCleanup(() => { + if (pendingScrollFrame !== null) { + cancelAnimationFrame(pendingScrollFrame) + pendingScrollFrame = null + } + if (pendingAnchorScroll !== null) { + cancelAnimationFrame(pendingAnchorScroll) + pendingAnchorScroll = null + } + if (detachScrollIntentListeners) { + detachScrollIntentListeners() + detachScrollIntentListeners = undefined + } + }) + return (
{ toolCallRootRef = element || undefined }}