fix(chat): stabilize web UI tool runs

This commit is contained in:
Peter Steinberger
2026-01-05 17:15:17 +00:00
parent 86c404c48b
commit b7e708c764
11 changed files with 176 additions and 45 deletions

View File

@@ -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.

View File

@@ -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;
}
}
}

View File

@@ -205,6 +205,7 @@ export async function handleCommands(params: {
resolvedVerboseLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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" },
});

View File

@@ -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,

View File

@@ -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<string, ToolStreamEntry>();
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();
}

View File

@@ -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<boolean> {
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<boolean> {
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;

View File

@@ -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) {
<div class="chat-thread" role="log" aria-live="polite">
${props.loading ? html`<div class="muted">Loading chat…</div>` : 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);
})}
</div>
<div class="chat-compose">
@@ -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<string, unknown>;
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;

View File

@@ -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,