Why
Six reported bugs share three interrelated root causes in the webchat UI's run state management, history loading, and stream rendering:
All six trace back to the same design assumption: runs and their streaming state are tracked per-tab rather than per-session, and history is loaded via full replacement rather than incremental merge.
What's happening
Run state is tab-local, not session-scoped
chatRunId is only set by the tab that called sendChatMessage(). Passive tabs keep chatRunId = null, which causes:
isChatBusy() returns true on passive tabs because chatStream !== null (deltas still arrive), but canAbort is false — the UI shows "busy" without a stop button and blocks the message queue from draining (flushChatQueue returns early when busy)
handleChatEvent drops non-final events from runs where payload.runId !== chatRunId — this silently discards deltas from runs started in another tab or after a session reset mid-stream
Relevant code:
// app-chat.ts:47
isChatBusy(host) = host.chatSending || Boolean(host.chatRunId)
// chat.ts:314
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) {
// only "final" state handled — everything else returns null (dropped)
return null;
}
History full-replace creates a race condition
When a run completes with tool events, handleTerminalChatEvent fires loadChatHistory() which does a full replace of chatMessages:
// app-gateway.ts:343
if (hadToolEvents && state === "final") {
void loadChatHistory(host); // async, fire-and-forget
return true;
}
// controllers/chat.ts
state.chatMessages = messages.filter(m => !isAssistantSilentReply(m));
This is async and void-cast. If the gateway hasn't persisted the transcript yet when chat.history responds, the final message is missing from the response. The streaming text was visible, then gets wiped by incomplete history.
This is the root cause of #11139, #37083, and #66316 RC#1.
Stream segments are parsed as independent markdown blocks
When the model outputs text → triggers a tool → continues outputting, the streaming text before each tool call gets committed as a chatStreamSegment. Each segment renders through its own toSanitizedMarkdownHtml call:
// app-tool-stream.ts:505
host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }];
host.chatStream = null;
// views/chat.ts — each segment rendered independently
if (item.kind === "stream") {
return renderStreamingGroup(item.text, ...); // independent markdown parse
}
If segment 1 ends with an unclosed code fence, the markdown renderer treats everything after it as code. The next segment or live stream renders below with its own independent parse, but visually the entire response appears broken — either wrapped in a code block or missing content that was before the tool call.
This is the root cause of #29472 and #66316 RC#2.
How to fix
Three changes, all in the webchat UI layer (ui/src/ui/):
Fix 1: Adopt runId session-wide
When a tab receives a chat delta event for its current session and doesn't have a chatRunId, adopt the event's runId:
// handleChatEvent — add before the runId mismatch check
if (!state.chatRunId && payload.runId && payload.state === "delta") {
state.chatRunId = payload.runId;
state.chatStreamStartedAt = Date.now();
}
This makes passive tabs track the active run, enabling the stop button and preventing the isChatBusy false-positive. Input and queue draining work normally.
Fix 2: Incremental history merge
Replace the full-swap in loadChatHistory with a merge:
// Instead of:
state.chatMessages = messages.filter(m => !isAssistantSilentReply(m));
// Merge: keep existing messages, add only new ones
const existingKeys = new Set(state.chatMessages.map(m => m.timestamp + ':' + (m.role || '')));
const newMsgs = messages.filter(m => !isAssistantSilentReply(m) && !existingKeys.has(m.timestamp + ':' + (m.role || '')));
state.chatMessages = [...state.chatMessages, ...newMsgs];
If the history response is stale (missing the final message), the streaming-rendered message already in chatMessages is preserved instead of being wiped.
Fix 3: Concatenate stream segments before markdown rendering
Instead of rendering each chatStreamSegment as an independent markdown block, join all segments + the live chatStream into a single text and render once:
const allStreamText = [
...(props.streamSegments ?? []).map(s => s.text),
...(props.stream ? [props.stream] : []),
].join('\n');
if (allStreamText.trim()) {
items.push({
kind: "stream",
key: `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`,
text: allStreamText,
startedAt: props.streamStartedAt ?? Date.now(),
});
}
Alternatively (lighter change): before committing a segment, auto-close unclosed code fences by counting ``` occurrences.
Files to change
ui/src/ui/controllers/chat.ts — Fix 1 (runId adoption) + Fix 2 (merge logic)
ui/src/ui/app-chat.ts — Fix 1 (isChatBusy adjustments for adopted runId)
ui/src/ui/app-gateway.ts — Fix 2 (remove/adjust full-replace calls)
ui/src/ui/app-tool-stream.ts — segment commit logic for Fix 3
ui/src/ui/views/chat.ts — Fix 3 (concatenate segments before rendering)
Closes
Closes #66212, #37083, #11139, #29472
Supersedes #66316 (frontend portions)
Why
Six reported bugs share three interrelated root causes in the webchat UI's run state management, history loading, and stream rendering:
loadChatHistoryreturns stale dataAll six trace back to the same design assumption: runs and their streaming state are tracked per-tab rather than per-session, and history is loaded via full replacement rather than incremental merge.
What's happening
Run state is tab-local, not session-scoped
chatRunIdis only set by the tab that calledsendChatMessage(). Passive tabs keepchatRunId = null, which causes:isChatBusy()returnstrueon passive tabs becausechatStream !== null(deltas still arrive), butcanAbortisfalse— the UI shows "busy" without a stop button and blocks the message queue from draining (flushChatQueuereturns early when busy)handleChatEventdrops non-final events from runs wherepayload.runId !== chatRunId— this silently discards deltas from runs started in another tab or after a session reset mid-streamRelevant code:
History full-replace creates a race condition
When a run completes with tool events,
handleTerminalChatEventfiresloadChatHistory()which does a full replace ofchatMessages:This is async and void-cast. If the gateway hasn't persisted the transcript yet when
chat.historyresponds, the final message is missing from the response. The streaming text was visible, then gets wiped by incomplete history.This is the root cause of #11139, #37083, and #66316 RC#1.
Stream segments are parsed as independent markdown blocks
When the model outputs text → triggers a tool → continues outputting, the streaming text before each tool call gets committed as a
chatStreamSegment. Each segment renders through its owntoSanitizedMarkdownHtmlcall:If segment 1 ends with an unclosed code fence, the markdown renderer treats everything after it as code. The next segment or live stream renders below with its own independent parse, but visually the entire response appears broken — either wrapped in a code block or missing content that was before the tool call.
This is the root cause of #29472 and #66316 RC#2.
How to fix
Three changes, all in the webchat UI layer (
ui/src/ui/):Fix 1: Adopt runId session-wide
When a tab receives a
chatdelta event for its current session and doesn't have achatRunId, adopt the event'srunId:This makes passive tabs track the active run, enabling the stop button and preventing the
isChatBusyfalse-positive. Input and queue draining work normally.Fix 2: Incremental history merge
Replace the full-swap in
loadChatHistorywith a merge:If the history response is stale (missing the final message), the streaming-rendered message already in
chatMessagesis preserved instead of being wiped.Fix 3: Concatenate stream segments before markdown rendering
Instead of rendering each
chatStreamSegmentas an independent markdown block, join all segments + the livechatStreaminto a single text and render once:Alternatively (lighter change): before committing a segment, auto-close unclosed code fences by counting ``` occurrences.
Files to change
ui/src/ui/controllers/chat.ts— Fix 1 (runId adoption) + Fix 2 (merge logic)ui/src/ui/app-chat.ts— Fix 1 (isChatBusy adjustments for adopted runId)ui/src/ui/app-gateway.ts— Fix 2 (remove/adjust full-replace calls)ui/src/ui/app-tool-stream.ts— segment commit logic for Fix 3ui/src/ui/views/chat.ts— Fix 3 (concatenate segments before rendering)Closes
Closes #66212, #37083, #11139, #29472
Supersedes #66316 (frontend portions)