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

@@ -172,21 +172,212 @@ messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
interface ContentDisplayItem { interface ContentDisplayItem {
type: "content" type: "content"
key: string key: string
record: MessageRecord messageId: string
parts: ClientPart[] startPartId: string
messageInfo?: MessageInfo
isQueued: boolean
showAgentMeta?: boolean
} }
interface ToolDisplayItem { interface ToolDisplayItem {
type: "tool" type: "tool"
key: string key: string
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string messageId: string
messageVersion: number partId: string
partVersion: number }
interface MessageContentItemProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageId: string
startPartId: string
messageIndex: number
lastAssistantIndex: () => number
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
}
function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const isQueued = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "user") return false
const lastAssistant = props.lastAssistantIndex()
return lastAssistant === -1 || props.messageIndex > lastAssistant
})
const parts = createMemo<ClientPart[]>(() => {
const current = record()
if (!current) return []
const ids = current.partIds
const startIndex = ids.indexOf(props.startPartId)
if (startIndex === -1) return []
const resolved: ClientPart[] = []
for (let idx = startIndex; idx < ids.length; idx++) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
break
}
resolved.push(part)
}
return resolved
})
const showAgentMeta = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "assistant") return false
const currentParts = parts()
if (!currentParts.some((part) => partHasRenderableText(part))) {
return false
}
const ids = current.partIds
const startIndex = ids.indexOf(props.startPartId)
if (startIndex === -1) return false
// Only show agent meta on the first content segment that contains renderable content.
for (let idx = 0; idx < startIndex; idx++) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
continue
}
if (partHasRenderableText(part)) {
return false
}
}
return true
})
return (
<Show when={record()}>
{(resolvedRecord) => (
<MessageItem
record={resolvedRecord()}
messageInfo={messageInfo()}
parts={parts()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={isQueued()}
showAgentMeta={showAgentMeta()}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
)}
</Show>
)
}
interface ToolCallItemProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageId: string
partId: string
onContentRendered?: () => void
}
function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n()
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const partEntry = createMemo(() => record()?.parts?.[props.partId])
const toolPart = createMemo(() => {
const part = partEntry()?.data as ClientPart | undefined
if (!part || part.type !== "tool") return undefined
return part as ToolCallPart
})
const toolState = createMemo(() => toolPart()?.state as ToolState | undefined)
const toolName = createMemo(() => toolPart()?.tool || "")
const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
const taskSessionId = createMemo(() => {
const state = toolState()
if (!state) return ""
if (!(isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))) {
return ""
}
return extractTaskSessionId(state)
})
const taskLocation = createMemo(() => {
const id = taskSessionId()
if (!id) return null
return findTaskSessionLocation(id, props.instanceId)
})
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const location = taskLocation()
if (!location) return
navigateToTaskSession(location)
}
return (
<Show when={toolPart()}>
{(resolvedToolPart) => (
<>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div>
<Show when={taskSessionId()}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation()}
onClick={handleGoToTaskSession}
title={!taskLocation() ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
>
{t("messageBlock.tool.goToSession.label")}
</button>
</Show>
</div>
<ToolCall
toolCall={resolvedToolPart()}
toolCallId={props.partId}
messageId={props.messageId}
messageVersion={messageVersion()}
partVersion={partVersion()}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
</>
)}
</Show>
)
} }
interface StepDisplayItem { interface StepDisplayItem {
@@ -272,7 +463,6 @@ export default function MessageBlock(props: MessageBlockProps) {
const items: MessageBlockItem[] = [] const items: MessageBlockItem[] = []
const blockContentKeys: string[] = [] const blockContentKeys: string[] = []
const blockToolKeys: string[] = [] const blockToolKeys: string[] = []
let segmentIndex = 0
let pendingParts: ClientPart[] = [] let pendingParts: ClientPart[] = []
let agentMetaAttached = current.role !== "assistant" let agentMetaAttached = current.role !== "assistant"
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
@@ -280,34 +470,28 @@ export default function MessageBlock(props: MessageBlockProps) {
const flushContent = () => { const flushContent = () => {
if (pendingParts.length === 0) return if (pendingParts.length === 0) return
const segmentKey = `${current.id}:segment:${segmentIndex}` const startPartId = typeof (pendingParts[0] as any)?.id === "string" ? ((pendingParts[0] as any).id as string) : ""
segmentIndex += 1 if (!startPartId) {
const shouldShowAgentMeta = pendingParts = []
current.role === "assistant" && return
!agentMetaAttached && }
pendingParts.some((part) => partHasRenderableText(part))
if (!agentMetaAttached && pendingParts.some((part) => partHasRenderableText(part))) {
agentMetaAttached = true
}
const segmentKey = `${current.id}:content:${startPartId}`
let cached = sessionCache.messageItems.get(segmentKey) let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) { if (!cached) {
cached = { cached = {
type: "content", type: "content",
key: segmentKey, key: segmentKey,
record: current, messageId: current.id,
parts: pendingParts.slice(), startPartId,
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
} }
sessionCache.messageItems.set(segmentKey, cached) sessionCache.messageItems.set(segmentKey, cached)
} else {
cached.record = current
cached.parts = pendingParts.slice()
cached.messageInfo = info
cached.isQueued = isQueued
cached.showAgentMeta = shouldShowAgentMeta
}
if (shouldShowAgentMeta) {
agentMetaAttached = true
} }
items.push(cached) items.push(cached)
blockContentKeys.push(segmentKey) blockContentKeys.push(segmentKey)
lastAccentColor = defaultAccentColor lastAccentColor = defaultAccentColor
@@ -317,28 +501,26 @@ export default function MessageBlock(props: MessageBlockProps) {
orderedParts.forEach((part, partIndex) => { orderedParts.forEach((part, partIndex) => {
if (part.type === "tool") { if (part.type === "tool") {
flushContent() flushContent()
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0 const partId = part.id
const messageVersion = current.revision if (!partId) {
const key = `${current.id}:${part.id ?? partIndex}` // Tool parts are required to have ids; if one slips through, skip rendering
// to avoid unstable keys and accidental remount cascades.
return
}
const key = `${current.id}:${partId}`
let toolItem = sessionCache.toolItems.get(key) let toolItem = sessionCache.toolItems.get(key)
if (!toolItem) { if (!toolItem) {
toolItem = { toolItem = {
type: "tool", type: "tool",
key, key,
toolPart: part as ToolCallPart,
messageInfo: info,
messageId: current.id, messageId: current.id,
messageVersion, partId,
partVersion,
} }
sessionCache.toolItems.set(key, toolItem) sessionCache.toolItems.set(key, toolItem)
} else { } else {
toolItem.key = key toolItem.key = key
toolItem.toolPart = part as ToolCallPart
toolItem.messageInfo = info
toolItem.messageId = current.id toolItem.messageId = current.id
toolItem.messageVersion = messageVersion toolItem.partId = partId
toolItem.partVersion = partVersion
} }
items.push(toolItem) items.push(toolItem)
blockToolKeys.push(key) blockToolKeys.push(key)
@@ -427,21 +609,21 @@ export default function MessageBlock(props: MessageBlockProps) {
}) })
return ( return (
<Show when={block()} keyed> <Show when={block()}>
{(resolvedBlock) => ( {(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}> <div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
<For each={resolvedBlock.items}> <For each={resolvedBlock().items}>
{(item) => ( {(item) => (
<Switch> <Switch>
<Match when={item.type === "content"}> <Match when={item.type === "content"}>
<MessageItem <MessageContentItem
record={(item as ContentDisplayItem).record}
messageInfo={(item as ContentDisplayItem).messageInfo}
parts={(item as ContentDisplayItem).parts}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
isQueued={(item as ContentDisplayItem).isQueued} store={props.store}
showAgentMeta={(item as ContentDisplayItem).showAgentMeta} messageId={(item as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
onRevert={props.onRevert} onRevert={props.onRevert}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
@@ -450,46 +632,14 @@ export default function MessageBlock(props: MessageBlockProps) {
<Match when={item.type === "tool"}> <Match when={item.type === "tool"}>
{(() => { {(() => {
const toolItem = item as ToolDisplayItem const toolItem = item as ToolDisplayItem
const toolState = toolItem.toolPart.state as ToolState | undefined
const hasToolState =
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!taskLocation) return
navigateToTaskSession(taskLocation)
}
return ( return (
<div class="tool-call-message" data-key={toolItem.key}> <div class="tool-call-message" data-key={toolItem.key}>
<div class="tool-call-header-label"> <ToolCallItem
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolItem.toolPart.tool || t("messageBlock.tool.unknown")}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
>
{t("messageBlock.tool.goToSession.label")}
</button>
</Show>
</div>
<ToolCall
toolCall={toolItem.toolPart}
toolCallId={toolItem.toolPart.id}
messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</div> </div>

View File

@@ -137,8 +137,17 @@ export default function MessageItem(props: MessageItemProps) {
} }
const isGenerating = () => { const isGenerating = () => {
if (hasContent()) {
return false
}
// Prefer the local record status for streaming placeholders.
if (!isUser() && props.record.status === "streaming") {
return true
}
const info = props.messageInfo const info = props.messageInfo
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0 return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
} }
const handleRevert = () => { const handleRevert = () => {
@@ -163,7 +172,7 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
if (!isUser() && !hasContent()) { if (!isUser() && !hasContent() && !isGenerating()) {
return null return null
} }

View File

@@ -25,6 +25,13 @@ interface MessagePartProps {
const isAssistantMessage = () => props.messageType === "assistant" const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text") const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
// Keep optimistic user prompts visible; hide synthetic assistant text.
return Boolean((part as any).synthetic) && props.messageType !== "user"
}
const plainTextContent = () => { const plainTextContent = () => {
const part = props.part const part = props.part
@@ -94,7 +101,7 @@ interface MessagePartProps {
return ( return (
<Switch> <Switch>
<Match when={partType() === "text"}> <Match when={partType() === "text"}>
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}> <div class={textContainerClass()}>
<Show <Show
when={isAssistantMessage()} when={isAssistantMessage()}

View File

@@ -235,12 +235,16 @@ export default function ToolCall(props: ToolCallProps) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
restoreScrollPosition(autoScroll()) restoreScrollPosition(autoScroll())
if (!expanded()) return if (!expanded()) return
scheduleAnchorScroll() scheduleAnchorScroll(true)
}) })
} }
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
scrollContainerRef = element || undefined const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef) setScrollContainer(scrollContainerRef)
if (scrollContainerRef) { if (scrollContainerRef) {
restoreScrollPosition(autoScroll()) restoreScrollPosition(autoScroll())
@@ -593,7 +597,7 @@ export default function ToolCall(props: ToolCallProps) {
return return
} }
previousPartVersion = version previousPartVersion = version
scheduleAnchorScroll() scheduleAnchorScroll(true)
}) })
createEffect(() => { createEffect(() => {

View File

@@ -87,7 +87,7 @@ export function createAnsiContentRenderer(params: {
} }
return ( 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} /> <pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel()} {params.scrollHelpers.renderSentinel()}
</div> </div>

View File

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

View File

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

View File

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