add tool call auto scroll sentinels
This commit is contained in:
@@ -18,6 +18,9 @@ const log = getLogger("session")
|
|||||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||||
|
|
||||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
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(
|
function makeRenderCacheKey(
|
||||||
toolCallId?: string | null,
|
toolCallId?: string | null,
|
||||||
@@ -284,26 +287,177 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (override !== undefined) return override
|
if (override !== undefined) return override
|
||||||
return diagnosticsDefaultExpanded()
|
return diagnosticsDefaultExpanded()
|
||||||
}
|
}
|
||||||
|
|
||||||
const diagnosticsEntries = createMemo(() => {
|
const diagnosticsEntries = createMemo(() => {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return []
|
if (!state) return []
|
||||||
return extractDiagnostics(state)
|
return extractDiagnostics(state)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
|
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||||
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
|
||||||
let toolCallRootRef: 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) => {
|
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
||||||
scrollContainerRef = element || 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(() => {
|
createEffect(() => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
@@ -315,7 +469,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeKey = activePermissionKey()
|
const activeKey = activePermissionKey()
|
||||||
if (!activeKey) return
|
if (!activeKey) return
|
||||||
@@ -343,11 +496,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
onCleanup(() => document.removeEventListener("keydown", handler))
|
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!expanded()) {
|
|
||||||
scrollContainerRef = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusIcon = () => {
|
const statusIcon = () => {
|
||||||
const status = toolState()?.status || ""
|
const status = toolState()?.status || ""
|
||||||
@@ -421,7 +569,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (options?.disableScrollTracking) return
|
if (options?.disableScrollTracking) return
|
||||||
initializeScrollContainer(element)
|
initializeScrollContainer(element)
|
||||||
}}
|
}}
|
||||||
onScroll={options?.disableScrollTracking ? undefined : (event) => persistScrollSnapshot(event.currentTarget)}
|
onScroll={options?.disableScrollTracking ? undefined : handleScrollEvent}
|
||||||
>
|
>
|
||||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||||
@@ -453,6 +601,9 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
cacheEntryParams={cacheHandle.params()}
|
cacheEntryParams={cacheHandle.params()}
|
||||||
onRendered={handleDiffRendered}
|
onRendered={handleDiffRendered}
|
||||||
/>
|
/>
|
||||||
|
<Show when={!options?.disableScrollTracking}>
|
||||||
|
<div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -479,21 +630,19 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class={messageClass} ref={(element) => initializeScrollContainer(element)} onScroll={handleScrollEvent}>
|
||||||
class={messageClass}
|
|
||||||
ref={(element) => initializeScrollContainer(element)}
|
|
||||||
onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
|
|
||||||
>
|
|
||||||
<Markdown
|
<Markdown
|
||||||
part={markdownPart}
|
part={markdownPart}
|
||||||
isDark={isDark()}
|
isDark={isDark()}
|
||||||
disableHighlight={disableHighlight}
|
disableHighlight={disableHighlight}
|
||||||
onRendered={handleMarkdownRendered}
|
onRendered={handleMarkdownRendered}
|
||||||
/>
|
/>
|
||||||
|
<div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
||||||
const partVersionAccessor = createMemo(() => props.partVersion)
|
const partVersionAccessor = createMemo(() => props.partVersion)
|
||||||
|
|
||||||
@@ -507,8 +656,31 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
renderDiff: renderDiffContent,
|
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 getRendererAction = () => renderer().getAction?.(rendererContext) ?? getDefaultToolAction(toolName())
|
||||||
|
|
||||||
|
|
||||||
const renderToolTitle = () => {
|
const renderToolTitle = () => {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return getRendererAction()
|
if (!state) return getRendererAction()
|
||||||
@@ -653,8 +825,24 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const status = () => toolState()?.status || ""
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
toolCallRootRef = element || undefined
|
toolCallRootRef = element || undefined
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user