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

View File

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

View File

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

View File

@@ -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<LogLevel, boolean> = {
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<string>();
@state() nodesLoading = false;
@state() nodes: Array<Record<string, unknown>> = [];
@@ -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<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> {
const content: Array<Record<string, unknown>> = [];
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) {

View File

@@ -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);
})}
</div>
@@ -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<ChatProps, "isToolOutputExpanded" | "onToolOutputToggle">,
opts?: { streaming?: boolean },
) {
const m = message as Record<string, unknown>;
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`
<div class="chat-line ${klass}">
<div class="chat-msg">
@@ -294,7 +325,15 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: 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 class="chat-stamp mono">
${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`
<div class="chat-tool-card">
<div class="chat-tool-card__title">${display.emoji} ${display.label}</div>
${detail
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${card.text
? html`<div class="chat-tool-card__output chat-text">
${unsafeHTML(toSanitizedMarkdownHtml(card.text))}
</div>`
${hasOutput
? html`
<details
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}
</div>
`;