perf(ui): window chat + lazy tool output

This commit is contained in:
Peter Steinberger
2026-01-08 04:32:48 +00:00
parent 55c8b8182c
commit 6a684fdf6c
5 changed files with 148 additions and 11 deletions

View File

@@ -54,6 +54,8 @@
- Web UI: add Logs tab for gateway file logs with filtering, auto-follow, and export. - 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: 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: 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. - 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: 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. - Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.

View File

@@ -883,6 +883,34 @@
color: var(--muted); 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 { .chat-tool-card__output {
margin-top: 6px; margin-top: 6px;
font-family: var(--font-mono); font-family: var(--font-mono);

View File

@@ -440,6 +440,9 @@ export function renderApp(state: AppViewState) {
disabledReason: chatDisabledReason, disabledReason: chatDisabledReason,
error: state.lastError, error: state.lastError,
sessions: state.sessionsResult, sessions: state.sessionsResult,
isToolOutputExpanded: (id) => state.toolOutputExpanded.has(id),
onToolOutputToggle: (id, expanded) =>
state.toggleToolOutput(id, expanded),
onRefresh: () => { onRefresh: () => {
state.resetToolStream(); state.resetToolStream();
return loadChatHistory(state); return loadChatHistory(state);

View File

@@ -89,6 +89,7 @@ type EventLogEntry = {
}; };
const TOOL_STREAM_LIMIT = 50; const TOOL_STREAM_LIMIT = 50;
const TOOL_STREAM_THROTTLE_MS = 80;
const TOOL_OUTPUT_CHAR_LIMIT = 120_000; const TOOL_OUTPUT_CHAR_LIMIT = 120_000;
const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = { const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
trace: true, trace: true,
@@ -200,6 +201,7 @@ export class ClawdbotApp extends LitElement {
@state() lastError: string | null = null; @state() lastError: string | null = null;
@state() eventLog: EventLogEntry[] = []; @state() eventLog: EventLogEntry[] = [];
private eventLogBuffer: EventLogEntry[] = []; private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null;
@state() sessionKey = this.settings.sessionKey; @state() sessionKey = this.settings.sessionKey;
@state() chatLoading = false; @state() chatLoading = false;
@@ -211,6 +213,7 @@ export class ClawdbotApp extends LitElement {
@state() chatStreamStartedAt: number | null = null; @state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null; @state() chatRunId: string | null = null;
@state() chatThinkingLevel: string | null = null; @state() chatThinkingLevel: string | null = null;
@state() toolOutputExpanded = new Set<string>();
@state() nodesLoading = false; @state() nodesLoading = false;
@state() nodes: Array<Record<string, unknown>> = []; @state() nodes: Array<Record<string, unknown>> = [];
@@ -608,12 +611,24 @@ export class ClawdbotApp extends LitElement {
this.toolStreamById.clear(); this.toolStreamById.clear();
this.toolStreamOrder = []; this.toolStreamOrder = [];
this.chatToolMessages = []; this.chatToolMessages = [];
this.toolOutputExpanded = new Set();
this.flushToolStreamSync();
} }
resetChatScroll() { resetChatScroll() {
this.chatHasAutoScrolled = false; 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() { private trimToolStream() {
if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return; if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT; const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
@@ -627,6 +642,26 @@ export class ClawdbotApp extends LitElement {
.filter((msg): msg is Record<string, unknown> => Boolean(msg)); .filter((msg): msg is Record<string, unknown> => 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<string, unknown> { private buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
const content: Array<Record<string, unknown>> = []; const content: Array<Record<string, unknown>> = [];
content.push({ content.push({
@@ -699,7 +734,7 @@ export class ClawdbotApp extends LitElement {
entry.message = this.buildToolStreamMessage(entry); entry.message = this.buildToolStreamMessage(entry);
this.trimToolStream(); this.trimToolStream();
this.syncToolStreamMessages(); this.scheduleToolStreamSync(phase === "result");
} }
private onEvent(evt: GatewayEventFrame) { private onEvent(evt: GatewayEventFrame) {

View File

@@ -22,6 +22,8 @@ export type ChatProps = {
disabledReason: string | null; disabledReason: string | null;
error: string | null; error: string | null;
sessions: SessionsListResult | null; sessions: SessionsListResult | null;
isToolOutputExpanded: (id: string) => boolean;
onToolOutputToggle: (id: string, expanded: boolean) => void;
onRefresh: () => void; onRefresh: () => void;
onDraftChange: (next: string) => void; onDraftChange: (next: string) => void;
onSend: () => void; onSend: () => void;
@@ -88,10 +90,11 @@ export function renderChat(props: ChatProps) {
content: [{ type: "text", text: item.text }], content: [{ type: "text", text: item.text }],
timestamp: item.startedAt, timestamp: item.startedAt,
}, },
props,
{ streaming: true }, { streaming: true },
); );
} }
return renderMessage(item.message); return renderMessage(item.message, props);
})} })}
</div> </div>
@@ -131,12 +134,30 @@ type ChatItem =
| { kind: "stream"; key: string; text: string; startedAt: number } | { kind: "stream"; key: string; text: string; startedAt: number }
| { kind: "reading-indicator"; key: string }; | { kind: "reading-indicator"; key: string };
const CHAT_HISTORY_RENDER_LIMIT = 200;
function buildChatItems(props: ChatProps): ChatItem[] { function buildChatItems(props: ChatProps): ChatItem[] {
const items: ChatItem[] = []; const items: ChatItem[] = [];
const history = Array.isArray(props.messages) ? props.messages : []; const history = Array.isArray(props.messages) ? props.messages : [];
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : []; const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
for (let i = 0; i < history.length; i++) { const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
items.push({ kind: "message", key: messageKey(history[i], i), message: history[i] }); 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++) { for (let i = 0; i < tools.length; i++) {
items.push({ items.push({
@@ -260,7 +281,11 @@ function renderReadingIndicator() {
`; `;
} }
function renderMessage(message: unknown, opts?: { streaming?: boolean }) { function renderMessage(
message: unknown,
props?: Pick<ChatProps, "isToolOutputExpanded" | "onToolOutputToggle">,
opts?: { streaming?: boolean },
) {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown"; const role = typeof m.role === "string" ? m.role : "unknown";
const toolCards = extractToolCards(message); const toolCards = extractToolCards(message);
@@ -287,6 +312,12 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
const klass = role === "assistant" ? "assistant" : role === "user" ? "user" : "other"; const klass = role === "assistant" ? "assistant" : role === "user" ? "user" : "other";
const who = role === "assistant" ? "Assistant" : role === "user" ? "You" : role; 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` return html`
<div class="chat-line ${klass}"> <div class="chat-line ${klass}">
<div class="chat-msg"> <div class="chat-msg">
@@ -294,7 +325,15 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
${markdown ${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>` ? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing} : 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,
}),
)}
</div> </div>
<div class="chat-stamp mono"> <div class="chat-stamp mono">
${who}${timestamp ? html` · ${timestamp}` : nothing} ${who}${timestamp ? html` · ${timestamp}` : nothing}
@@ -368,19 +407,49 @@ function extractToolCards(message: unknown): ToolCard[] {
return cards; 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 display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display); 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` return html`
<div class="chat-tool-card"> <div class="chat-tool-card">
<div class="chat-tool-card__title">${display.emoji} ${display.label}</div> <div class="chat-tool-card__title">${display.emoji} ${display.label}</div>
${detail ${detail
? html`<div class="chat-tool-card__detail">${detail}</div>` ? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing} : nothing}
${card.text ${hasOutput
? html`<div class="chat-tool-card__output chat-text"> ? html`
${unsafeHTML(toSanitizedMarkdownHtml(card.text))} <details
</div>` class="chat-tool-card__details"
?open=${expanded}
@toggle=${(e: Event) => {
if (!opts?.onToggle) return;
const target = e.currentTarget as HTMLDetailsElement;
opts.onToggle(id, target.open);
}}
>
<summary class="chat-tool-card__summary">
${expanded ? "Hide output" : "Show output"}
<span class="chat-tool-card__summary-meta">
(${card.text?.length ?? 0} chars)
</span>
</summary>
${expanded
? html`<div class="chat-tool-card__output chat-text">
${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))}
</div>`
: nothing}
</details>
`
: nothing} : nothing}
</div> </div>
`; `;