Scroll fixes - Improve scroll to bottom handling for reasoning, bash and task tools (#288)
Fixes #286 and more
This commit is contained in:
@@ -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 { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
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 type { DeleteHoverState } from "../types/delete-hover"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
function DeleteUpToIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
|
|||||||
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 REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||||
|
|
||||||
@@ -803,19 +805,19 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
data-message-id={resolvedBlock().record.id}
|
data-message-id={resolvedBlock().record.id}
|
||||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<For each={resolvedBlock().items}>
|
<Index each={resolvedBlock().items}>
|
||||||
{(item, index) => (
|
{(item, index) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={item.type === "content"}>
|
<Match when={item().type === "content"}>
|
||||||
<MessageContentItem
|
<MessageContentItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={(item as ContentDisplayItem).messageId}
|
messageId={(item() as ContentDisplayItem).messageId}
|
||||||
startPartId={(item as ContentDisplayItem).startPartId}
|
startPartId={(item() as ContentDisplayItem).startPartId}
|
||||||
messageIndex={props.messageIndex}
|
messageIndex={props.messageIndex}
|
||||||
lastAssistantIndex={props.lastAssistantIndex}
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -825,9 +827,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "tool"}>
|
<Match when={item().type === "tool"}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const toolItem = item as ToolDisplayItem
|
const toolItem = item() as ToolDisplayItem
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-message" data-key={toolItem.key}>
|
<div class="tool-call-message" data-key={toolItem.key}>
|
||||||
<ToolCallItem
|
<ToolCallItem
|
||||||
@@ -836,7 +838,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={toolItem.messageId}
|
messageId={toolItem.messageId}
|
||||||
partId={toolItem.partId}
|
partId={toolItem.partId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
deleteHover={props.deleteHover}
|
deleteHover={props.deleteHover}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -849,13 +851,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-start"}>
|
<Match when={item().type === "step-start"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="start"
|
kind="start"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showAgentMeta
|
showAgentMeta
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -865,14 +867,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-finish"}>
|
<Match when={item().type === "step-finish"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="finish"
|
kind="finish"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showUsage={props.showUsageMetrics()}
|
showUsage={props.showUsageMetrics()}
|
||||||
borderColor={(item as StepDisplayItem).accentColor}
|
borderColor={(item() as StepDisplayItem).accentColor}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -882,31 +884,31 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "compaction"}>
|
<Match when={item().type === "compaction"}>
|
||||||
<CompactionCard
|
<CompactionCard
|
||||||
part={(item as CompactionDisplayItem).part}
|
part={(item() as CompactionDisplayItem).part}
|
||||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
messageInfo={(item() as CompactionDisplayItem).messageInfo}
|
||||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
borderColor={(item() as CompactionDisplayItem).accentColor}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as CompactionDisplayItem).messageId}
|
messageId={(item() as CompactionDisplayItem).messageId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "reasoning"}>
|
<Match when={item().type === "reasoning"}>
|
||||||
<ReasoningCard
|
<ReasoningCard
|
||||||
part={(item as ReasoningDisplayItem).part}
|
part={(item() as ReasoningDisplayItem).part}
|
||||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as ReasoningDisplayItem).messageId}
|
messageId={(item() as ReasoningDisplayItem).messageId}
|
||||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
|
||||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
@@ -916,7 +918,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1293,14 +1295,23 @@ interface ReasoningCardProps {
|
|||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningStreamOutput(props: {
|
||||||
const { t } = useI18n()
|
text: Accessor<string>
|
||||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
scrollTopSnapshot: Accessor<number>
|
||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
setScrollTopSnapshot: (next: number) => void
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
onContentRendered?: () => void
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
ariaLabel: string
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
let pendingRenderNotificationFrame: number | null = null
|
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 = () => {
|
const notifyContentRendered = () => {
|
||||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
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(() => {
|
onCleanup(() => {
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
if (pendingRenderNotificationFrame !== null) {
|
||||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||||
@@ -1319,6 +1341,37 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={followScroll.registerContainer}
|
||||||
|
class="message-reasoning-output"
|
||||||
|
role="region"
|
||||||
|
aria-label={props.ariaLabel}
|
||||||
|
onScroll={followScroll.handleScroll}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
ref={(element) => {
|
||||||
|
preRef = element || undefined
|
||||||
|
if (preRef) {
|
||||||
|
preRef.textContent = props.text() || ""
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="message-reasoning-text"
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
{followScroll.renderSentinel()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1393,12 +1446,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!expanded()) return
|
|
||||||
reasoningText()
|
|
||||||
notifyContentRendered()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
|
|
||||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
@@ -1553,9 +1600,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
<ReasoningStreamOutput
|
||||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
text={reasoningText}
|
||||||
</div>
|
scrollTopSnapshot={scrollTopSnapshot}
|
||||||
|
setScrollTopSnapshot={setScrollTopSnapshot}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -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 { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
||||||
import { stringify as stringifyYaml } from "yaml"
|
import { stringify as stringifyYaml } from "yaml"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
|
|||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
const log = getLogger("session")
|
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_CALL_CACHE_SCOPE = "tool-call"
|
||||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
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,
|
||||||
@@ -82,6 +81,27 @@ interface ToolCallProps {
|
|||||||
forceCollapsed?: boolean
|
forceCollapsed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ToolStatusIndicator(props: { status: Accessor<string> }) {
|
||||||
|
const isVisible = (value: string) => props.status() === value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
|
||||||
|
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
|
||||||
|
<Hourglass class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
|
||||||
|
<XCircle class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ToolCallDetails(props: {
|
function ToolCallDetails(props: {
|
||||||
toolCallMemo: () => ToolCallPart
|
toolCallMemo: () => ToolCallPart
|
||||||
toolState: () => ToolState | undefined
|
toolState: () => ToolState | undefined
|
||||||
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
|
|||||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
const followScroll = createFollowScroll({
|
||||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
|
||||||
|
sentinelClassName: "tool-call-scroll-sentinel",
|
||||||
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 scrollHelpers: ToolScrollHelpers = {
|
const scrollHelpers: ToolScrollHelpers = {
|
||||||
registerContainer: (element, options) => {
|
registerContainer: (element, options) => {
|
||||||
if (options?.disableTracking) return
|
followScroll.registerContainer(element, options)
|
||||||
initializeScrollContainer(element)
|
|
||||||
},
|
|
||||||
handleScroll: handleScrollEvent,
|
|
||||||
renderSentinel: (options) => {
|
|
||||||
if (options?.disableTracking) return null
|
|
||||||
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
|
|
||||||
},
|
},
|
||||||
|
handleScroll: followScroll.handleScroll,
|
||||||
|
renderSentinel: followScroll.renderSentinel,
|
||||||
|
restoreAfterRender: followScroll.restoreAfterRender,
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
const handleScrollRendered = () => {
|
||||||
const container = scrollContainer()
|
scrollHelpers.restoreAfterRender()
|
||||||
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(() => {
|
createEffect(() => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
|
|||||||
partVersion={options.partVersion}
|
partVersion={options.partVersion}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={options.sessionId}
|
sessionId={options.sessionId}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
forceCollapsed={options.forceCollapsed}
|
forceCollapsed={options.forceCollapsed}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollHelpers,
|
scrollHelpers,
|
||||||
|
onContentRendered: props.onContentRendered,
|
||||||
}
|
}
|
||||||
|
|
||||||
let previousPartVersion: number | undefined
|
let previousPartVersion: number | undefined
|
||||||
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousPartVersion = version
|
previousPartVersion = version
|
||||||
scheduleAnchorScroll(true)
|
scrollHelpers.restoreAfterRender()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (autoScroll()) {
|
if (followScroll.autoScroll()) {
|
||||||
scheduleAnchorScroll(true)
|
scrollHelpers.restoreAfterRender({ forceBottom: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
pendingScrollFrame = null
|
|
||||||
}
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-details">
|
<div class="tool-call-details">
|
||||||
<Show
|
<Show
|
||||||
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return !current
|
return !current
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const statusIcon = () => {
|
|
||||||
const status = toolState()?.status || ""
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return <Hourglass class="w-4 h-4" />
|
|
||||||
case "running":
|
|
||||||
return <Loader2 class="w-4 h-4 animate-spin" />
|
|
||||||
case "completed":
|
|
||||||
return <Check class="w-4 h-4" />
|
|
||||||
case "error":
|
|
||||||
return <XCircle class="w-4 h-4" />
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusClass = () => {
|
const statusClass = () => {
|
||||||
const status = toolState()?.status || "pending"
|
const status = toolState()?.status || "pending"
|
||||||
return `tool-call-status-${status}`
|
return `tool-call-status-${status}`
|
||||||
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<span class="tool-call-header-status" aria-hidden="true">
|
<ToolStatusIndicator status={status} />
|
||||||
{statusIcon()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
import { escapeHtml } from "../../lib/text-render-utils"
|
import { escapeHtml } from "../../lib/text-render-utils"
|
||||||
@@ -11,6 +11,97 @@ type CacheHandle = {
|
|||||||
set(value: unknown): void
|
set(value: unknown): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StableAnsiStreamUpdater {
|
||||||
|
update: (element: HTMLElement, content: string) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
|
||||||
|
const renderer = createAnsiStreamRenderer()
|
||||||
|
let previousContent = ""
|
||||||
|
let ansiActive = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(element: HTMLElement, content: string) {
|
||||||
|
const resetStreaming = !previousContent || !content.startsWith(previousContent)
|
||||||
|
|
||||||
|
if (resetStreaming) {
|
||||||
|
ansiActive = hasAnsi(content)
|
||||||
|
renderer.reset()
|
||||||
|
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
|
||||||
|
previousContent = content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = content.slice(previousContent.length)
|
||||||
|
if (delta.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ansiActive && hasAnsi(delta)) {
|
||||||
|
ansiActive = true
|
||||||
|
renderer.reset()
|
||||||
|
element.innerHTML = renderer.render(content)
|
||||||
|
previousContent = content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ansiActive) {
|
||||||
|
const htmlChunk = renderer.render(delta)
|
||||||
|
if (htmlChunk.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", htmlChunk)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const escapedDelta = escapeHtml(delta)
|
||||||
|
if (escapedDelta.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", escapedDelta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousContent = content
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
previousContent = ""
|
||||||
|
ansiActive = false
|
||||||
|
renderer.reset()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamingAnsiContent(props: {
|
||||||
|
html: string
|
||||||
|
htmlChunk?: string
|
||||||
|
updateMode: "replace" | "append" | "noop"
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = preRef
|
||||||
|
if (!element) return
|
||||||
|
if (props.updateMode === "noop") return
|
||||||
|
if (props.updateMode === "append") {
|
||||||
|
if (element.innerHTML.length === 0) {
|
||||||
|
element.innerHTML = props.html
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunk = props.htmlChunk ?? ""
|
||||||
|
if (chunk.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", chunk)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (element.innerHTML !== props.html) {
|
||||||
|
element.innerHTML = props.html
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
preRef = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||||
|
}
|
||||||
|
|
||||||
export function createAnsiContentRenderer(params: {
|
export function createAnsiContentRenderer(params: {
|
||||||
ansiRunningCache: CacheHandle
|
ansiRunningCache: CacheHandle
|
||||||
ansiFinalCache: CacheHandle
|
ansiFinalCache: CacheHandle
|
||||||
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const isRunningVariant = options.variant === "running"
|
const isRunningVariant = options.variant === "running"
|
||||||
const disableScrollTracking = !isRunningVariant
|
const disableScrollTracking = !isRunningVariant
|
||||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
|
let updateMode: "replace" | "append" | "noop" = "replace"
|
||||||
|
let htmlChunk = ""
|
||||||
|
|
||||||
let nextCache: AnsiRenderCache
|
let nextCache: AnsiRenderCache
|
||||||
|
|
||||||
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||||
|
|
||||||
if (resetStreaming) {
|
if (resetStreaming) {
|
||||||
|
updateMode = "replace"
|
||||||
const detectedAnsi = hasAnsi(content)
|
const detectedAnsi = hasAnsi(content)
|
||||||
if (detectedAnsi) {
|
if (detectedAnsi) {
|
||||||
runningAnsiRenderer.reset()
|
runningAnsiRenderer.reset()
|
||||||
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
|
|||||||
} else {
|
} else {
|
||||||
const delta = content.slice(cached.text.length)
|
const delta = content.slice(cached.text.length)
|
||||||
if (delta.length === 0) {
|
if (delta.length === 0) {
|
||||||
|
updateMode = "noop"
|
||||||
nextCache = { ...cached, mode }
|
nextCache = { ...cached, mode }
|
||||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||||
|
updateMode = "replace"
|
||||||
runningAnsiRenderer.reset()
|
runningAnsiRenderer.reset()
|
||||||
const html = runningAnsiRenderer.render(content)
|
const html = runningAnsiRenderer.render(content)
|
||||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||||
} else if (cached.hasAnsi) {
|
} else if (cached.hasAnsi) {
|
||||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
const appendedHtml = runningAnsiRenderer.render(delta)
|
||||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
updateMode = "append"
|
||||||
|
htmlChunk = appendedHtml
|
||||||
|
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
|
||||||
} else {
|
} else {
|
||||||
|
updateMode = "append"
|
||||||
|
htmlChunk = escapeHtml(delta)
|
||||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
||||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
|
<StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -129,9 +129,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!disableScrollTracking) {
|
|
||||||
params.handleScrollRendered()
|
params.handleScrollRendered()
|
||||||
}
|
|
||||||
params.onContentRendered?.()
|
params.onContentRendered?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,107 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { ToolRenderer, ToolScrollHelpers } from "../types"
|
||||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||||
import { tGlobal } from "../../../lib/i18n"
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
import { createStableAnsiStreamUpdater } from "../ansi-render"
|
||||||
|
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
|
||||||
|
|
||||||
|
function RunningBashOutput(props: {
|
||||||
|
content: Accessor<string>
|
||||||
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
|
const updater = createStableAnsiStreamUpdater()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = preRef
|
||||||
|
if (!element) return
|
||||||
|
updater.update(element, props.content())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
preRef = undefined
|
||||||
|
updater.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="message-text tool-call-markdown"
|
||||||
|
ref={props.scrollHelpers?.registerContainer}
|
||||||
|
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||||
|
>
|
||||||
|
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||||
|
{props.scrollHelpers?.renderSentinel?.()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BashToolBody(props: {
|
||||||
|
toolState: Accessor<ToolState | undefined>
|
||||||
|
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
|
||||||
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
}) {
|
||||||
|
const state = createMemo(() => props.toolState())
|
||||||
|
|
||||||
|
const joinedContent = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
if (!current || current.status === "pending") return ""
|
||||||
|
|
||||||
|
const { input, metadata } = readToolStatePayload(current)
|
||||||
|
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||||
|
const outputResult = formatUnknown(
|
||||||
|
isToolStateCompleted(current)
|
||||||
|
? current.output
|
||||||
|
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
|
||||||
|
? metadata.output
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
return [command, outputResult?.text].filter(Boolean).join("\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalMarkdown = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
const content = joinedContent()
|
||||||
|
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (hasAnsi(content)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ensureMarkdownContent(content, "bash", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalAnsiHtml = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
const content = joinedContent()
|
||||||
|
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!hasAnsi(content)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ansiToHtml(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={state() && joinedContent().length > 0}>
|
||||||
|
<Show
|
||||||
|
when={state()?.status === "running"}
|
||||||
|
fallback={
|
||||||
|
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
|
||||||
|
{(html) => (
|
||||||
|
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
|
||||||
|
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const bashRenderer: ToolRenderer = {
|
export const bashRenderer: ToolRenderer = {
|
||||||
tools: ["bash"],
|
tools: ["bash"],
|
||||||
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
|
|||||||
const timeoutLabel = `${timeout}ms`
|
const timeoutLabel = `${timeout}ms`
|
||||||
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||||
},
|
},
|
||||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
|
||||||
const state = toolState()
|
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
|
||||||
if (!state || state.status === "pending") return null
|
|
||||||
|
|
||||||
const { input, metadata } = readToolStatePayload(state)
|
|
||||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
|
||||||
const outputResult = formatUnknown(
|
|
||||||
isToolStateCompleted(state)
|
|
||||||
? state.output
|
|
||||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
|
||||||
? metadata.output
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
const parts = [command, outputResult?.text].filter(Boolean)
|
|
||||||
if (parts.length === 0) return null
|
|
||||||
|
|
||||||
const joined = parts.join("\n")
|
|
||||||
if (state.status === "running") {
|
|
||||||
return renderAnsi({ content: joined, variant: "running" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
|
|
||||||
if (ansiBody) {
|
|
||||||
return ansiBody
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = ensureMarkdownContent(joined, "bash", true)
|
|
||||||
if (!content) return null
|
|
||||||
|
|
||||||
return renderMarkdown({ content })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
return describeTaskTitle(input)
|
return describeTaskTitle(input)
|
||||||
},
|
},
|
||||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
||||||
|
|
||||||
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const childCount = childToolKeys().length
|
||||||
|
const legacyCount = legacyItems().length
|
||||||
|
if (childCount === 0 && legacyCount === 0) return
|
||||||
|
scrollHelpers?.restoreAfterRender()
|
||||||
|
onContentRendered?.()
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-task-sections">
|
<div class="tool-call-task-sections">
|
||||||
<Show when={promptContent()}>
|
<Show when={promptContent()}>
|
||||||
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="tool-call-task-summary">
|
<div class="tool-call-task-summary">
|
||||||
<For each={childToolKeys()}>
|
<Index each={childToolKeys()}>
|
||||||
{(key) => (
|
{(key) => (
|
||||||
<Show when={renderToolCall}>
|
<Show when={renderToolCall}>
|
||||||
{(render) => (
|
{(render) => (
|
||||||
<TaskToolCallRow
|
<TaskToolCallRow
|
||||||
toolKey={key}
|
toolKey={key()}
|
||||||
store={store}
|
store={store}
|
||||||
sessionId={childSessionId()}
|
sessionId={childSessionId()}
|
||||||
renderToolCall={render()}
|
renderToolCall={render()}
|
||||||
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
{scrollHelpers?.renderSentinel?.()}
|
{scrollHelpers?.renderSentinel?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
|
|||||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||||
|
restoreAfterRender(options?: { forceBottom?: boolean }): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRendererContext {
|
export interface ToolRendererContext {
|
||||||
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
|
|||||||
forceCollapsed?: boolean
|
forceCollapsed?: boolean
|
||||||
}) => JSXElement | null
|
}) => JSXElement | null
|
||||||
scrollHelpers?: ToolScrollHelpers
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRenderer {
|
export interface ToolRenderer {
|
||||||
|
|||||||
259
packages/ui/src/lib/follow-scroll.tsx
Normal file
259
packages/ui/src/lib/follow-scroll.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||||
|
|
||||||
|
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
|
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
|
||||||
|
interface FollowScrollOptions {
|
||||||
|
getScrollTopSnapshot: Accessor<number>
|
||||||
|
setScrollTopSnapshot: (next: number) => void
|
||||||
|
sentinelMarginPx: number
|
||||||
|
sentinelClassName: string
|
||||||
|
intentWindowMs?: number
|
||||||
|
intentKeys?: ReadonlySet<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FollowScrollHelpers {
|
||||||
|
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
||||||
|
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
||||||
|
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
||||||
|
restoreAfterRender: (options?: { forceBottom?: boolean }) => void
|
||||||
|
autoScroll: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
|
||||||
|
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 detachScrollIntentListeners: (() => void) | undefined
|
||||||
|
|
||||||
|
let pendingScrollFrame: number | null = null
|
||||||
|
let pendingAnchorScroll: number | null = null
|
||||||
|
let userScrollIntentUntil = 0
|
||||||
|
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||||
|
let pointerInteractionActive = false
|
||||||
|
let suppressNextScrollHandling = false
|
||||||
|
|
||||||
|
function restoreScrollPosition(forceBottom = false) {
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (!container) return
|
||||||
|
suppressNextScrollHandling = true
|
||||||
|
if (forceBottom) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
lastKnownScrollTop = container.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
} else {
|
||||||
|
container.scrollTop = lastKnownScrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistScrollSnapshot(element?: HTMLElement | null) {
|
||||||
|
if (!element) return
|
||||||
|
lastKnownScrollTop = element.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markUserScrollIntent() {
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserScrollIntent() {
|
||||||
|
if (pointerInteractionActive) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
return now <= userScrollIntentUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
||||||
|
const handlePointerIntent = () => {
|
||||||
|
pointerInteractionActive = true
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
const clearPointerIntent = () => {
|
||||||
|
pointerInteractionActive = false
|
||||||
|
}
|
||||||
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||||
|
if (intentKeys.has(event.key)) {
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("keydown", handleKeyIntent)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("pointerup", clearPointerIntent)
|
||||||
|
window.addEventListener("pointercancel", clearPointerIntent)
|
||||||
|
window.addEventListener("mouseup", clearPointerIntent)
|
||||||
|
window.addEventListener("touchend", clearPointerIntent)
|
||||||
|
window.addEventListener("touchcancel", clearPointerIntent)
|
||||||
|
}
|
||||||
|
detachScrollIntentListeners = () => {
|
||||||
|
element.removeEventListener("wheel", handlePointerIntent)
|
||||||
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.removeEventListener("touchstart", handlePointerIntent)
|
||||||
|
element.removeEventListener("keydown", handleKeyIntent)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.removeEventListener("pointerup", clearPointerIntent)
|
||||||
|
window.removeEventListener("pointercancel", clearPointerIntent)
|
||||||
|
window.removeEventListener("mouseup", clearPointerIntent)
|
||||||
|
window.removeEventListener("touchend", clearPointerIntent)
|
||||||
|
window.removeEventListener("touchcancel", clearPointerIntent)
|
||||||
|
}
|
||||||
|
pointerInteractionActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + options.sentinelMarginPx
|
||||||
|
if (Math.abs(delta) > 1) {
|
||||||
|
suppressNextScrollHandling = true
|
||||||
|
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||||
|
}
|
||||||
|
lastKnownScrollTop = container.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtBottom(container: HTMLDivElement) {
|
||||||
|
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
||||||
|
const container = containerOverride ?? scrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
if (suppressNextScrollHandling) {
|
||||||
|
suppressNextScrollHandling = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isUserScroll = hasUserScrollIntent()
|
||||||
|
const atBottomFromScroll = isAtBottom(container)
|
||||||
|
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
||||||
|
|
||||||
|
if (isUserScroll || !atBottom) {
|
||||||
|
if (atBottom) {
|
||||||
|
if (!autoScroll()) setAutoScroll(true)
|
||||||
|
} else if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
|
updateFollowModeFromScroll(event.currentTarget)
|
||||||
|
persistScrollSnapshot(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
||||||
|
const next = element || undefined
|
||||||
|
if (next === scrollContainerRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollContainerRef = next
|
||||||
|
setScrollContainer(scrollContainerRef)
|
||||||
|
if (scrollContainerRef) {
|
||||||
|
lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||||
|
restoreScrollPosition(autoScroll())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSentinel = (config?: { disableTracking?: boolean }) => {
|
||||||
|
if (config?.disableTracking) return null
|
||||||
|
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreAfterRender = (config?: { forceBottom?: boolean }) => {
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||||
|
if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition(false)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldFollow = config?.forceBottom ?? autoScroll()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition(shouldFollow)
|
||||||
|
if (shouldFollow) {
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ${options.sentinelMarginPx}px 0px` },
|
||||||
|
)
|
||||||
|
observer.observe(sentinel)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
pendingScrollFrame = null
|
||||||
|
}
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerContainer,
|
||||||
|
handleScroll,
|
||||||
|
renderSentinel,
|
||||||
|
restoreAfterRender,
|
||||||
|
autoScroll,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user