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 type { Accessor, JSXElement } from "solid-js"
|
||||
import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||
import { escapeHtml } from "../../lib/text-render-utils"
|
||||
@@ -11,6 +11,97 @@ type CacheHandle = {
|
||||
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: {
|
||||
ansiRunningCache: CacheHandle
|
||||
ansiFinalCache: CacheHandle
|
||||
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
|
||||
const isRunningVariant = options.variant === "running"
|
||||
const disableScrollTracking = !isRunningVariant
|
||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||
let updateMode: "replace" | "append" | "noop" = "replace"
|
||||
let htmlChunk = ""
|
||||
|
||||
let nextCache: AnsiRenderCache
|
||||
|
||||
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
|
||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||
|
||||
if (resetStreaming) {
|
||||
updateMode = "replace"
|
||||
const detectedAnsi = hasAnsi(content)
|
||||
if (detectedAnsi) {
|
||||
runningAnsiRenderer.reset()
|
||||
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
|
||||
} else {
|
||||
const delta = content.slice(cached.text.length)
|
||||
if (delta.length === 0) {
|
||||
updateMode = "noop"
|
||||
nextCache = { ...cached, mode }
|
||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||
updateMode = "replace"
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else if (cached.hasAnsi) {
|
||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
||||
const appendedHtml = runningAnsiRenderer.render(delta)
|
||||
updateMode = "append"
|
||||
htmlChunk = appendedHtml
|
||||
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
|
||||
} else {
|
||||
updateMode = "append"
|
||||
htmlChunk = escapeHtml(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||
}
|
||||
}
|
||||
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
|
||||
|
||||
return (
|
||||
<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 })}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -129,9 +129,7 @@ export function createDiffContentRenderer(params: {
|
||||
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (!disableScrollTracking) {
|
||||
params.handleScrollRendered()
|
||||
}
|
||||
params.handleScrollRendered()
|
||||
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 { 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 = {
|
||||
tools: ["bash"],
|
||||
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
|
||||
const timeoutLabel = `${timeout}ms`
|
||||
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
||||
const state = toolState()
|
||||
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 })
|
||||
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
|
||||
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
const { input } = readToolStatePayload(state)
|
||||
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 [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 (
|
||||
<div class="tool-call-task-sections">
|
||||
<Show when={promptContent()}>
|
||||
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={childToolKeys()}>
|
||||
<Index each={childToolKeys()}>
|
||||
{(key) => (
|
||||
<Show when={renderToolCall}>
|
||||
{(render) => (
|
||||
<TaskToolCallRow
|
||||
toolKey={key}
|
||||
toolKey={key()}
|
||||
store={store}
|
||||
sessionId={childSessionId()}
|
||||
renderToolCall={render()}
|
||||
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Index>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
|
||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||
restoreAfterRender(options?: { forceBottom?: boolean }): void
|
||||
}
|
||||
|
||||
export interface ToolRendererContext {
|
||||
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
|
||||
forceCollapsed?: boolean
|
||||
}) => JSXElement | null
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
export interface ToolRenderer {
|
||||
|
||||
Reference in New Issue
Block a user