From 8b5ffa1905224e1d9ad3ede0fc157c843f7d2689 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 16 Jun 2026 15:56:41 +0100 Subject: [PATCH] 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 --- .../components/tool-call/renderers/bash.tsx | 68 +++++++++++++++++-- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/tool-call/renderers/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx index 4c80278d..4d899e8d 100644 --- a/packages/ui/src/components/tool-call/renderers/bash.tsx +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -10,17 +10,36 @@ import { getBashToolSearchText } from "../search-text" function RunningBashOutput(props: { content: Accessor 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 ( +
+
+    
+ ) +} + function BashToolBody(props: { toolState: Accessor renderMarkdown: (options: { content: string }) => ReturnType scrollHelpers?: ToolScrollHelpers + onContentRendered?: () => void }) { const state = createMemo(() => props.toolState()) @@ -91,14 +149,12 @@ function BashToolBody(props: { fallback={ {(html) => ( -
-
-              
+ )}
} > - + ) @@ -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 + renderBody({ toolState, renderMarkdown, scrollHelpers, onContentRendered }) { + return }, }