From 6a684fdf6ccdefdc380d16b2ebc371b801d1d4ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 04:32:48 +0000 Subject: [PATCH] perf(ui): window chat + lazy tool output --- CHANGELOG.md | 2 + ui/src/styles/components.css | 28 ++++++++++++ ui/src/ui/app-render.ts | 3 ++ ui/src/ui/app.ts | 37 ++++++++++++++- ui/src/ui/views/chat.ts | 89 ++++++++++++++++++++++++++++++++---- 5 files changed, 148 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d260759df..4e8c8b660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ - Web UI: add Logs tab for gateway file logs with filtering, auto-follow, and export. - Web UI: cap tool output + large markdown rendering to avoid UI freezes on huge tool results. - Web UI: keep config form edits synced to raw JSON so form saves persist. +- Web UI: window chat history rendering and throttle tool stream updates to reduce UI churn. +- Web UI: collapse tool output by default and lazy-render tool markdown on expand. - ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398. - Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353. - Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409. diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 6baea286e..f864b845d 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -883,6 +883,34 @@ color: var(--muted); } +.chat-tool-card__details { + margin-top: 6px; +} + +.chat-tool-card__summary { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); + cursor: pointer; + list-style: none; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.chat-tool-card__summary::-webkit-details-marker { + display: none; +} + +.chat-tool-card__summary-meta { + color: var(--muted); + opacity: 0.8; +} + +.chat-tool-card__details[open] .chat-tool-card__summary { + color: var(--chat-text); +} + .chat-tool-card__output { margin-top: 6px; font-family: var(--font-mono); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 4b977098f..c9a39c3d8 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -440,6 +440,9 @@ export function renderApp(state: AppViewState) { disabledReason: chatDisabledReason, error: state.lastError, sessions: state.sessionsResult, + isToolOutputExpanded: (id) => state.toolOutputExpanded.has(id), + onToolOutputToggle: (id, expanded) => + state.toggleToolOutput(id, expanded), onRefresh: () => { state.resetToolStream(); return loadChatHistory(state); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7254738e4..cd014e16b 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -89,6 +89,7 @@ type EventLogEntry = { }; const TOOL_STREAM_LIMIT = 50; +const TOOL_STREAM_THROTTLE_MS = 80; const TOOL_OUTPUT_CHAR_LIMIT = 120_000; const DEFAULT_LOG_LEVEL_FILTERS: Record = { trace: true, @@ -200,6 +201,7 @@ export class ClawdbotApp extends LitElement { @state() lastError: string | null = null; @state() eventLog: EventLogEntry[] = []; private eventLogBuffer: EventLogEntry[] = []; + private toolStreamSyncTimer: number | null = null; @state() sessionKey = this.settings.sessionKey; @state() chatLoading = false; @@ -211,6 +213,7 @@ export class ClawdbotApp extends LitElement { @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; @state() chatThinkingLevel: string | null = null; + @state() toolOutputExpanded = new Set(); @state() nodesLoading = false; @state() nodes: Array> = []; @@ -608,12 +611,24 @@ export class ClawdbotApp extends LitElement { this.toolStreamById.clear(); this.toolStreamOrder = []; this.chatToolMessages = []; + this.toolOutputExpanded = new Set(); + this.flushToolStreamSync(); } resetChatScroll() { this.chatHasAutoScrolled = false; } + toggleToolOutput(id: string, expanded: boolean) { + const next = new Set(this.toolOutputExpanded); + if (expanded) { + next.add(id); + } else { + next.delete(id); + } + this.toolOutputExpanded = next; + } + private trimToolStream() { if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return; const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT; @@ -627,6 +642,26 @@ export class ClawdbotApp extends LitElement { .filter((msg): msg is Record => Boolean(msg)); } + private scheduleToolStreamSync(force = false) { + if (force) { + this.flushToolStreamSync(); + return; + } + if (this.toolStreamSyncTimer != null) return; + this.toolStreamSyncTimer = window.setTimeout( + () => this.flushToolStreamSync(), + TOOL_STREAM_THROTTLE_MS, + ); + } + + private flushToolStreamSync() { + if (this.toolStreamSyncTimer != null) { + clearTimeout(this.toolStreamSyncTimer); + this.toolStreamSyncTimer = null; + } + this.syncToolStreamMessages(); + } + private buildToolStreamMessage(entry: ToolStreamEntry): Record { const content: Array> = []; content.push({ @@ -699,7 +734,7 @@ export class ClawdbotApp extends LitElement { entry.message = this.buildToolStreamMessage(entry); this.trimToolStream(); - this.syncToolStreamMessages(); + this.scheduleToolStreamSync(phase === "result"); } private onEvent(evt: GatewayEventFrame) { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index c456555f7..4ade5051e 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -22,6 +22,8 @@ export type ChatProps = { disabledReason: string | null; error: string | null; sessions: SessionsListResult | null; + isToolOutputExpanded: (id: string) => boolean; + onToolOutputToggle: (id: string, expanded: boolean) => void; onRefresh: () => void; onDraftChange: (next: string) => void; onSend: () => void; @@ -88,10 +90,11 @@ export function renderChat(props: ChatProps) { content: [{ type: "text", text: item.text }], timestamp: item.startedAt, }, + props, { streaming: true }, ); } - return renderMessage(item.message); + return renderMessage(item.message, props); })} @@ -131,12 +134,30 @@ type ChatItem = | { kind: "stream"; key: string; text: string; startedAt: number } | { kind: "reading-indicator"; key: string }; +const CHAT_HISTORY_RENDER_LIMIT = 200; + 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] }); + const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT); + if (historyStart > 0) { + items.push({ + kind: "message", + key: "chat:history:notice", + message: { + role: "system", + content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`, + timestamp: Date.now(), + }, + }); + } + for (let i = historyStart; 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({ @@ -260,7 +281,11 @@ function renderReadingIndicator() { `; } -function renderMessage(message: unknown, opts?: { streaming?: boolean }) { +function renderMessage( + message: unknown, + props?: Pick, + opts?: { streaming?: boolean }, +) { const m = message as Record; const role = typeof m.role === "string" ? m.role : "unknown"; const toolCards = extractToolCards(message); @@ -287,6 +312,12 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) { typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; const klass = role === "assistant" ? "assistant" : role === "user" ? "user" : "other"; const who = role === "assistant" ? "Assistant" : role === "user" ? "You" : role; + const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; + const toolCardBase = + toolCallId || + (typeof m.id === "string" ? m.id : "") || + (typeof m.messageId === "string" ? m.messageId : "") || + (typeof m.timestamp === "number" ? String(m.timestamp) : "tool-card"); return html`
@@ -294,7 +325,15 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) { ${markdown ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` : nothing} - ${toolCards.map((card) => renderToolCard(card))} + ${toolCards.map((card, index) => + renderToolCard(card, { + id: `${toolCardBase}:${index}`, + expanded: props?.isToolOutputExpanded + ? props.isToolOutputExpanded(`${toolCardBase}:${index}`) + : false, + onToggle: props?.onToolOutputToggle, + }), + )}
${who}${timestamp ? html` ยท ${timestamp}` : nothing} @@ -368,19 +407,49 @@ function extractToolCards(message: unknown): ToolCard[] { return cards; } -function renderToolCard(card: ToolCard) { +function renderToolCard( + card: ToolCard, + opts?: { + id: string; + expanded: boolean; + onToggle?: (id: string, expanded: boolean) => void; + }, +) { const display = resolveToolDisplay({ name: card.name, args: card.args }); const detail = formatToolDetail(display); + const hasOutput = typeof card.text === "string" && card.text.length > 0; + const expanded = opts?.expanded ?? false; + const id = opts?.id ?? `${card.name}-${Math.random()}`; return html`
${display.emoji} ${display.label}
${detail ? html`
${detail}
` : nothing} - ${card.text - ? html`
- ${unsafeHTML(toSanitizedMarkdownHtml(card.text))} -
` + ${hasOutput + ? html` +
{ + if (!opts?.onToggle) return; + const target = e.currentTarget as HTMLDetailsElement; + opts.onToggle(id, target.open); + }} + > + + ${expanded ? "Hide output" : "Show output"} + + (${card.text?.length ?? 0} chars) + + + ${expanded + ? html`
+ ${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))} +
` + : nothing} +
+ ` : nothing}
`;