fix(ui): stabilize streaming message/tool rendering

Avoid remounting message blocks on part updates so tool call UI state persists. Render tool/message content from store and stabilize tool output scrolling during streaming.
This commit is contained in:
Shantur Rathore
2026-01-28 17:55:44 +00:00
parent d9bcc66930
commit a401eeec11
8 changed files with 289 additions and 100 deletions

View File

@@ -87,7 +87,7 @@ export function createAnsiContentRenderer(params: {
}
return (
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel()}
</div>

View File

@@ -26,6 +26,14 @@ export function createDiffContentRenderer(params: {
handleScrollRendered: () => void
onContentRendered?: () => void
}) {
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
const registerUntracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element, { disableTracking: true })
}
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = options?.label || (relativePath
@@ -35,6 +43,8 @@ export function createDiffContentRenderer(params: {
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const themeKey = params.isDark() ? "dark" : "light"
const disableScrollTracking = Boolean(options?.disableScrollTracking)
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const baseEntryParams = cacheHandle.params() as any
const cacheEntryParams = (() => {
@@ -58,7 +68,7 @@ export function createDiffContentRenderer(params: {
}
const handleDiffRendered = () => {
if (!options?.disableScrollTracking) {
if (!disableScrollTracking) {
params.handleScrollRendered()
}
params.onContentRendered?.()
@@ -67,8 +77,8 @@ export function createDiffContentRenderer(params: {
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
@@ -100,7 +110,7 @@ export function createDiffContentRenderer(params: {
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)
}

View File

@@ -15,6 +15,14 @@ export function createMarkdownContentRenderer(params: {
handleScrollRendered: () => void
onContentRendered?: () => void
}) {
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
const registerUntracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element, { disableTracking: true })
}
function renderMarkdownContent(options: MarkdownRenderOptions): JSXElement | null {
if (!options.content) {
return null
@@ -24,6 +32,7 @@ export function createMarkdownContentRenderer(params: {
const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const disableScrollTracking = options.disableScrollTracking || false
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const state = params.toolState()
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
@@ -31,7 +40,7 @@ export function createMarkdownContentRenderer(params: {
return (
<div
class={messageClass}
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
@@ -56,7 +65,7 @@ export function createMarkdownContentRenderer(params: {
return (
<div
class={messageClass}
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<Markdown

View File

@@ -176,7 +176,7 @@ export const taskRenderer: ToolRenderer = {
<div class="tool-call-task-section-body">
<div
class="message-text tool-call-markdown tool-call-task-container"
ref={(element) => scrollHelpers?.registerContainer(element)}
ref={scrollHelpers?.registerContainer}
onScroll={
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
}