Skip to content

Commit 8b5ffa1

Browse files
authored
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
1 parent 9c8baf8 commit 8b5ffa1

1 file changed

Lines changed: 62 additions & 6 deletions

File tree

  • packages/ui/src/components/tool-call/renderers

packages/ui/src/components/tool-call/renderers/bash.tsx

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,36 @@ import { getBashToolSearchText } from "../search-text"
1010
function RunningBashOutput(props: {
1111
content: Accessor<string>
1212
scrollHelpers?: ToolScrollHelpers
13+
onContentRendered?: () => void
1314
}) {
1415
let preRef: HTMLPreElement | undefined
16+
let pendingRenderNotificationFrame: number | null = null
1517
const updater = createStableAnsiStreamUpdater()
1618

19+
const notifyContentRendered = () => {
20+
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
21+
if (pendingRenderNotificationFrame !== null) {
22+
cancelAnimationFrame(pendingRenderNotificationFrame)
23+
}
24+
pendingRenderNotificationFrame = requestAnimationFrame(() => {
25+
pendingRenderNotificationFrame = null
26+
props.onContentRendered?.()
27+
})
28+
}
29+
1730
createEffect(() => {
1831
const element = preRef
1932
if (!element) return
2033
updater.update(element, props.content())
34+
props.scrollHelpers?.restoreAfterRender()
35+
notifyContentRendered()
2136
})
2237

2338
onCleanup(() => {
39+
if (pendingRenderNotificationFrame !== null) {
40+
cancelAnimationFrame(pendingRenderNotificationFrame)
41+
pendingRenderNotificationFrame = null
42+
}
2443
preRef = undefined
2544
updater.reset()
2645
})
@@ -37,10 +56,49 @@ function RunningBashOutput(props: {
3756
)
3857
}
3958

59+
function FinalAnsiOutput(props: {
60+
html: string
61+
scrollHelpers?: ToolScrollHelpers
62+
onContentRendered?: () => void
63+
}) {
64+
let pendingRenderNotificationFrame: number | null = null
65+
66+
const notifyContentRendered = () => {
67+
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
68+
if (pendingRenderNotificationFrame !== null) {
69+
cancelAnimationFrame(pendingRenderNotificationFrame)
70+
}
71+
pendingRenderNotificationFrame = requestAnimationFrame(() => {
72+
pendingRenderNotificationFrame = null
73+
props.onContentRendered?.()
74+
})
75+
}
76+
77+
createEffect(() => {
78+
props.html
79+
props.scrollHelpers?.restoreAfterRender()
80+
notifyContentRendered()
81+
})
82+
83+
onCleanup(() => {
84+
if (pendingRenderNotificationFrame !== null) {
85+
cancelAnimationFrame(pendingRenderNotificationFrame)
86+
pendingRenderNotificationFrame = null
87+
}
88+
})
89+
90+
return (
91+
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
92+
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={props.html} />
93+
</div>
94+
)
95+
}
96+
4097
function BashToolBody(props: {
4198
toolState: Accessor<ToolState | undefined>
4299
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
43100
scrollHelpers?: ToolScrollHelpers
101+
onContentRendered?: () => void
44102
}) {
45103
const state = createMemo(() => props.toolState())
46104

@@ -91,14 +149,12 @@ function BashToolBody(props: {
91149
fallback={
92150
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
93151
{(html) => (
94-
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
95-
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
96-
</div>
152+
<FinalAnsiOutput html={html()} scrollHelpers={props.scrollHelpers} onContentRendered={props.onContentRendered} />
97153
)}
98154
</Show>
99155
}
100156
>
101-
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
157+
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} onContentRendered={props.onContentRendered} />
102158
</Show>
103159
</Show>
104160
)
@@ -124,7 +180,7 @@ export const bashRenderer: ToolRenderer = {
124180
const timeoutLabel = `${timeout}ms`
125181
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
126182
},
127-
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
128-
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
183+
renderBody({ toolState, renderMarkdown, scrollHelpers, onContentRendered }) {
184+
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} onContentRendered={onContentRendered} />
129185
},
130186
}

0 commit comments

Comments
 (0)