fix(ui): keep streaming bash output pinned (#556)

## Summary
- notify the existing virtual follow-list path after streaming bash ANSI
output updates
- restore the inner bash output scroll immediately after direct pre
updates
- apply the same coalesced notification pattern to final ANSI output

## Validation
- npm run typecheck --workspace @codenomad/ui
This commit is contained in:
Shantur Rathore 2026-06-16 15:56:41 +01:00 committed by GitHub
parent 9c8baf86ca
commit 8b5ffa1905
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -10,17 +10,36 @@ import { getBashToolSearchText } from "../search-text"
function RunningBashOutput(props: {
content: Accessor<string>
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}) {
let preRef: HTMLPreElement | undefined
let pendingRenderNotificationFrame: number | null = null
const updater = createStableAnsiStreamUpdater()
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
createEffect(() => {
const element = preRef
if (!element) return
updater.update(element, props.content())
props.scrollHelpers?.restoreAfterRender()
notifyContentRendered()
})
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
preRef = undefined
updater.reset()
})
@ -37,10 +56,49 @@ function RunningBashOutput(props: {
)
}
function FinalAnsiOutput(props: {
html: string
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}) {
let pendingRenderNotificationFrame: number | null = null
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
createEffect(() => {
props.html
props.scrollHelpers?.restoreAfterRender()
notifyContentRendered()
})
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
return (
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={props.html} />
</div>
)
}
function BashToolBody(props: {
toolState: Accessor<ToolState | undefined>
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}) {
const state = createMemo(() => props.toolState())
@ -91,14 +149,12 @@ function BashToolBody(props: {
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>
<FinalAnsiOutput html={html()} scrollHelpers={props.scrollHelpers} onContentRendered={props.onContentRendered} />
)}
</Show>
}
>
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} onContentRendered={props.onContentRendered} />
</Show>
</Show>
)
@ -124,7 +180,7 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms`
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
},
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
renderBody({ toolState, renderMarkdown, scrollHelpers, onContentRendered }) {
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} onContentRendered={onContentRendered} />
},
}