Skip to content

fix(webchat): session-scoped run tracking, incremental history merge, and unified stream rendering #66332

@jaruesink

Description

@jaruesink

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions