From b7e708c764b0da1627c6738b48343515a6fcc356 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 17:15:17 +0000 Subject: [PATCH] fix(chat): stabilize web UI tool runs --- CHANGELOG.md | 1 + src/agents/pi-embedded-subscribe.ts | 17 +---- src/auto-reply/reply/commands.ts | 1 + src/gateway/server-bridge.ts | 9 ++- src/gateway/server-methods/chat.ts | 9 ++- src/gateway/server.chat.test.ts | 4 +- ui/src/ui/app-render.ts | 6 +- ui/src/ui/app.ts | 54 +++++++++++++-- ui/src/ui/controllers/chat.ts | 17 ++++- ui/src/ui/views/chat.ts | 100 ++++++++++++++++++++++++---- ui/vite.config.ts | 3 + 11 files changed, 176 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 022b55f21..d8c9b9558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. - Control UI: show a reading indicator bubble while the assistant is responding. +- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 845f62dce..4ad3a0649 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -650,23 +650,10 @@ export function subscribeEmbeddedPiSession(params: { if (evtType === "text_end" && blockReplyBreak === "text_end") { if (blockChunking && blockBuffer.length > 0) { drainBlockBuffer(true); - } else if (next && next !== lastBlockReplyText) { - lastBlockReplyText = next || undefined; - if (next) assistantTexts.push(next); - if (next && params.onBlockReply) { - const { text: cleanedText, mediaUrls } = - splitMediaFromOutput(next); - if (cleanedText || (mediaUrls && mediaUrls.length > 0)) { - void params.onBlockReply({ - text: cleanedText, - mediaUrls: mediaUrls?.length ? mediaUrls : undefined, - }); - } - } + } else if (blockBuffer.length > 0) { + emitBlockChunk(blockBuffer); } - deltaBuffer = ""; blockBuffer = ""; - lastStreamedAssistant = undefined; } } } diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index d7c90788c..d225e28bf 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -205,6 +205,7 @@ export async function handleCommands(params: { resolvedVerboseLevel, resolvedElevatedLevel, resolveDefaultThinkingLevel, + provider, model, contextTokens, isGroup, diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 5dcbf34a7..71eec2329 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -41,6 +41,7 @@ import { type SessionEntry, saveSessionStore, } from "../config/sessions.js"; +import { registerAgentRunContext } from "../infra/agent-events.js"; import { loadVoiceWakeConfig, setVoiceWakeTriggers, @@ -844,12 +845,12 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { ctx.chatAbortControllers.delete(runId); ctx.chatRunBuffers.delete(runId); ctx.chatDeltaSentAt.delete(runId); - ctx.removeChatRun(active.sessionId, runId, sessionKey); + ctx.removeChatRun(runId, runId, sessionKey); const payload = { runId, sessionKey, - seq: (ctx.agentRunSeq.get(active.sessionId) ?? 0) + 1, + seq: (ctx.agentRunSeq.get(runId) ?? 0) + 1, state: "aborted" as const, }; ctx.broadcast("chat", payload); @@ -940,6 +941,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { lastTo: entry?.lastTo, }; const clientRunId = p.idempotencyKey; + registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); const cached = ctx.dedupe.get(`chat:${clientRunId}`); if (cached) { @@ -962,7 +964,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { sessionId, sessionKey: p.sessionKey, }); - ctx.addChatRun(sessionId, { + ctx.addChatRun(clientRunId, { sessionKey: p.sessionKey, clientRunId, }); @@ -978,6 +980,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { { message: messageWithAttachments, sessionId, + runId: clientRunId, thinking: p.thinking, deliver: p.deliver, timeout: Math.ceil(timeoutMs / 1000).toString(), diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index bed2ff0e5..8d21cccd4 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { agentCommand } from "../../commands/agent.js"; import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { buildMessageWithAttachments } from "../chat-attachments.js"; @@ -115,12 +116,12 @@ export const chatHandlers: GatewayRequestHandlers = { context.chatAbortControllers.delete(runId); context.chatRunBuffers.delete(runId); context.chatDeltaSentAt.delete(runId); - context.removeChatRun(active.sessionId, runId, sessionKey); + context.removeChatRun(runId, runId, sessionKey); const payload = { runId, sessionKey, - seq: (context.agentRunSeq.get(active.sessionId) ?? 0) + 1, + seq: (context.agentRunSeq.get(runId) ?? 0) + 1, state: "aborted" as const, }; context.broadcast("chat", payload); @@ -201,6 +202,7 @@ export const chatHandlers: GatewayRequestHandlers = { lastTo: entry?.lastTo, }; const clientRunId = p.idempotencyKey; + registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); const sendPolicy = resolveSendPolicy({ cfg, @@ -236,7 +238,7 @@ export const chatHandlers: GatewayRequestHandlers = { sessionId, sessionKey: p.sessionKey, }); - context.addChatRun(sessionId, { + context.addChatRun(clientRunId, { sessionKey: p.sessionKey, clientRunId, }); @@ -252,6 +254,7 @@ export const chatHandlers: GatewayRequestHandlers = { { message: messageWithAttachments, sessionId, + runId: clientRunId, thinking: p.thinking, deliver: p.deliver, timeout: Math.ceil(timeoutMs / 1000).toString(), diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index c1907c10d..ffb8e09a8 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -830,7 +830,7 @@ describe("gateway server chat", () => { ); emitAgentEvent({ - runId: "sess-main", + runId: "idem-1", stream: "lifecycle", data: { phase: "end" }, }); @@ -852,7 +852,7 @@ describe("gateway server chat", () => { ); emitAgentEvent({ - runId: "sess-main", + runId: "idem-2", stream: "lifecycle", data: { phase: "end" }, }); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c38fc8544..de9654498 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -378,16 +378,20 @@ export function renderApp(state: AppViewState) { state.sessionKey = next; state.chatMessage = ""; state.chatStream = null; + state.chatStreamStartedAt = null; state.chatRunId = null; state.resetToolStream(); + state.resetChatScroll(); state.applySettings({ ...state.settings, sessionKey: next }); void loadChatHistory(state); }, thinkingLevel: state.chatThinkingLevel, loading: state.chatLoading, sending: state.chatSending, - messages: [...state.chatMessages, ...state.chatToolMessages], + messages: state.chatMessages, + toolMessages: state.chatToolMessages, stream: state.chatStream, + streamStartedAt: state.chatStreamStartedAt, draft: state.chatMessage, connected: state.connected, canSend: state.connected, diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index f50d9542f..2c0dab719 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -178,6 +178,7 @@ export class ClawdbotApp extends LitElement { @state() hello: GatewayHelloOk | null = null; @state() lastError: string | null = null; @state() eventLog: EventLogEntry[] = []; + private eventLogBuffer: EventLogEntry[] = []; @state() sessionKey = this.settings.sessionKey; @state() chatLoading = false; @@ -186,6 +187,7 @@ export class ClawdbotApp extends LitElement { @state() chatMessages: unknown[] = []; @state() chatToolMessages: unknown[] = []; @state() chatStream: string | null = null; + @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; @state() chatThinkingLevel: string | null = null; @@ -341,6 +343,7 @@ export class ClawdbotApp extends LitElement { client: GatewayBrowserClient | null = null; private chatScrollFrame: number | null = null; private chatScrollTimeout: number | null = null; + private chatHasAutoScrolled = false; private nodesPollInterval: number | null = null; private toolStreamById = new Map(); private toolStreamOrder: string[] = []; @@ -386,10 +389,14 @@ export class ClawdbotApp extends LitElement { changed.has("chatToolMessages") || changed.has("chatStream") || changed.has("chatLoading") || - changed.has("chatMessage") || changed.has("tab")) ) { - this.scheduleChatScroll(); + const forcedByTab = changed.has("tab"); + const forcedByLoad = + changed.has("chatLoading") && + changed.get("chatLoading") === true && + this.chatLoading === false; + this.scheduleChatScroll(forcedByTab || forcedByLoad || !this.chatHasAutoScrolled); } } @@ -424,7 +431,7 @@ export class ClawdbotApp extends LitElement { this.client.start(); } - private scheduleChatScroll() { + private scheduleChatScroll(force = false) { if (this.chatScrollFrame) cancelAnimationFrame(this.chatScrollFrame); if (this.chatScrollTimeout != null) { clearTimeout(this.chatScrollTimeout); @@ -434,11 +441,19 @@ export class ClawdbotApp extends LitElement { this.chatScrollFrame = null; const container = this.querySelector(".chat-thread") as HTMLElement | null; if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + const shouldStick = force || distanceFromBottom < 140; + if (!shouldStick) return; + if (force) this.chatHasAutoScrolled = true; container.scrollTop = container.scrollHeight; this.chatScrollTimeout = window.setTimeout(() => { this.chatScrollTimeout = null; const latest = this.querySelector(".chat-thread") as HTMLElement | null; if (!latest) return; + const latestDistanceFromBottom = + latest.scrollHeight - latest.scrollTop - latest.clientHeight; + if (!force && latestDistanceFromBottom >= 180) return; latest.scrollTop = latest.scrollHeight; }, 120); }); @@ -477,6 +492,10 @@ export class ClawdbotApp extends LitElement { this.chatToolMessages = []; } + resetChatScroll() { + this.chatHasAutoScrolled = false; + } + private trimToolStream() { if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return; const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT; @@ -520,6 +539,8 @@ export class ClawdbotApp extends LitElement { if (sessionKey && sessionKey !== this.sessionKey) return; // Fallback: only accept session-less events for the active run. if (!sessionKey && this.chatRunId && payload.runId !== this.chatRunId) return; + if (this.chatRunId && payload.runId !== this.chatRunId) return; + if (!this.chatRunId) return; const data = payload.data ?? {}; const toolCallId = @@ -564,10 +585,13 @@ export class ClawdbotApp extends LitElement { } private onEvent(evt: GatewayEventFrame) { - this.eventLog = [ + this.eventLogBuffer = [ { ts: Date.now(), event: evt.event, payload: evt.payload }, - ...this.eventLog, + ...this.eventLogBuffer, ].slice(0, 250); + if (this.tab === "debug") { + this.eventLog = this.eventLogBuffer; + } if (evt.event === "agent") { this.handleAgentEvent(evt.payload as AgentEventPayload | undefined); @@ -577,6 +601,9 @@ export class ClawdbotApp extends LitElement { if (evt.event === "chat") { const payload = evt.payload as ChatEventPayload | undefined; const state = handleChatEvent(this, payload); + if (state === "final" || state === "error" || state === "aborted") { + this.resetToolStream(); + } if (state === "final") void loadChatHistory(this); return; } @@ -633,6 +660,7 @@ export class ClawdbotApp extends LitElement { setTab(next: Tab) { if (this.tab !== next) this.tab = next; + if (next === "chat") this.chatHasAutoScrolled = false; void this.refreshActiveTab(); this.syncUrlWithTab(next, false); } @@ -667,7 +695,10 @@ export class ClawdbotApp extends LitElement { await loadConfigSchema(this); await loadConfig(this); } - if (this.tab === "debug") await loadDebug(this); + if (this.tab === "debug") { + await loadDebug(this); + this.eventLog = this.eventLogBuffer; + } } private inferBasePath() { @@ -740,6 +771,7 @@ export class ClawdbotApp extends LitElement { private setTabFromRoute(next: Tab) { if (this.tab !== next) this.tab = next; + if (next === "chat") this.chatHasAutoScrolled = false; if (this.connected) void this.refreshActiveTab(); } @@ -776,8 +808,16 @@ export class ClawdbotApp extends LitElement { } async handleSendChat() { if (!this.connected) return; + this.resetToolStream(); const ok = await sendChat(this); - if (ok) void loadChatHistory(this); + if (ok && this.chatRunId) { + // chat.send returned (run finished), but we missed the chat final event. + this.chatRunId = null; + this.chatStream = null; + this.chatStreamStartedAt = null; + this.resetToolStream(); + void loadChatHistory(this); + } this.scheduleChatScroll(); } diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index c5b36f257..6ad248620 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -12,6 +12,7 @@ export type ChatState = { chatMessage: string; chatRunId: string | null; chatStream: string | null; + chatStreamStartedAt: number | null; lastError: string | null; }; @@ -62,6 +63,7 @@ export async function sendChat(state: ChatState): Promise { const runId = generateUUID(); state.chatRunId = runId; state.chatStream = ""; + state.chatStreamStartedAt = now; try { await state.client.request("chat.send", { sessionKey: state.sessionKey, @@ -74,6 +76,7 @@ export async function sendChat(state: ChatState): Promise { const error = String(err); state.chatRunId = null; state.chatStream = null; + state.chatStreamStartedAt = null; state.chatMessage = msg; state.lastError = error; state.chatMessages = [ @@ -100,13 +103,25 @@ export function handleChatEvent( return null; if (payload.state === "delta") { - state.chatStream = extractText(payload.message) ?? state.chatStream; + const next = extractText(payload.message); + if (typeof next === "string") { + const current = state.chatStream ?? ""; + if (!current || next.length >= current.length) { + state.chatStream = next; + } + } } else if (payload.state === "final") { state.chatStream = null; state.chatRunId = null; + state.chatStreamStartedAt = null; + } else if (payload.state === "aborted") { + state.chatStream = null; + state.chatRunId = null; + state.chatStreamStartedAt = null; } else if (payload.state === "error") { state.chatStream = null; state.chatRunId = null; + state.chatStreamStartedAt = null; state.lastError = payload.errorMessage ?? "chat error"; } return payload.state; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index f78a67a8f..dd20de44b 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { repeat } from "lit/directives/repeat.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { SessionsListResult } from "../types"; @@ -12,7 +13,9 @@ export type ChatProps = { loading: boolean; sending: boolean; messages: unknown[]; + toolMessages: unknown[]; stream: string | null; + streamStartedAt: number | null; draft: string; connected: boolean; canSend: boolean; @@ -77,19 +80,20 @@ export function renderChat(props: ChatProps) {
${props.loading ? html`
Loading chat…
` : nothing} - ${props.messages.map((m) => renderMessage(m))} - ${props.stream !== null - ? props.stream.trim().length > 0 - ? renderMessage( - { - role: "assistant", - content: [{ type: "text", text: props.stream }], - timestamp: Date.now(), - }, - { streaming: true }, - ) - : renderReadingIndicator() - : nothing} + ${repeat(buildChatItems(props), (item) => item.key, (item) => { + if (item.kind === "reading-indicator") return renderReadingIndicator(); + if (item.kind === "stream") { + return renderMessage( + { + role: "assistant", + content: [{ type: "text", text: item.text }], + timestamp: item.startedAt, + }, + { streaming: true }, + ); + } + return renderMessage(item.message); + })}
@@ -123,6 +127,76 @@ export function renderChat(props: ChatProps) { `; } +type ChatItem = + | { kind: "message"; key: string; message: unknown } + | { kind: "stream"; key: string; text: string; startedAt: number } + | { kind: "reading-indicator"; key: string }; + +function buildChatItems(props: ChatProps): ChatItem[] { + const items: ChatItem[] = []; + const history = Array.isArray(props.messages) ? props.messages : []; + const tools = Array.isArray(props.toolMessages) ? props.toolMessages : []; + for (let i = 0; i < history.length; i++) { + items.push({ kind: "message", key: messageKey(history[i], i), message: history[i] }); + } + for (let i = 0; i < tools.length; i++) { + items.push({ + kind: "message", + key: messageKey(tools[i], i + history.length), + message: tools[i], + }); + } + + if (props.stream !== null) { + const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`; + if (props.stream.trim().length > 0) { + items.push({ + kind: "stream", + key, + text: props.stream, + startedAt: props.streamStartedAt ?? Date.now(), + }); + } else { + items.push({ kind: "reading-indicator", key }); + } + } + + return items; +} + +function messageKey(message: unknown, index: number): string { + const m = message as Record; + const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; + if (toolCallId) return `tool:${toolCallId}`; + const id = typeof m.id === "string" ? m.id : ""; + if (id) return `msg:${id}`; + const messageId = typeof m.messageId === "string" ? m.messageId : ""; + if (messageId) return `msg:${messageId}`; + const timestamp = typeof m.timestamp === "number" ? m.timestamp : null; + const role = typeof m.role === "string" ? m.role : "unknown"; + const fingerprint = extractText(message) ?? (typeof m.content === "string" ? m.content : null); + const seed = fingerprint ?? safeJson(message) ?? String(index); + const hash = fnv1a(seed); + return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`; +} + +function safeJson(value: unknown): string | null { + try { + return JSON.stringify(value); + } catch { + return null; + } +} + +function fnv1a(input: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + type SessionOption = { key: string; updatedAt?: number | null; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 7730b211e..9ff1c4525 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -17,6 +17,9 @@ export default defineConfig(({ command }) => { const base = envBase ? normalizeBase(envBase) : "/"; return { base, + optimizeDeps: { + include: ["lit/directives/repeat.js"], + }, build: { outDir: path.resolve(here, "../dist/control-ui"), emptyOutDir: true,