diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad5ddd5c..d260759df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ - Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414. - Web UI: add Connect button on Overview to apply connection changes. Thanks @wizaj for PR #385. - Web UI: keep Focus toggle on the top bar (swap with theme toggle) so it stays visible. Thanks @RobOK2050 for reporting. (#440) +- 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. - 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/ui/app.ts b/ui/src/ui/app.ts index cb6eac52b..7254738e4 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -17,6 +17,7 @@ import { type ResolvedTheme, type ThemeMode, } from "./theme"; +import { truncateText } from "./format"; import { startThemeTransition, type ThemeTransitionContext, @@ -88,6 +89,7 @@ type EventLogEntry = { }; const TOOL_STREAM_LIMIT = 50; +const TOOL_OUTPUT_CHAR_LIMIT = 120_000; const DEFAULT_LOG_LEVEL_FILTERS: Record = { trace: true, debug: true, @@ -138,17 +140,25 @@ function extractToolOutputText(value: unknown): string | null { function formatToolOutput(value: unknown): string | null { if (value === null || value === undefined) return null; - if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") { return String(value); } const contentText = extractToolOutputText(value); - if (contentText) return contentText; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); + let text: string; + if (typeof value === "string") { + text = value; + } else if (contentText) { + text = contentText; + } else { + try { + text = JSON.stringify(value, null, 2); + } catch { + text = String(value); + } } + const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT); + if (!truncated.truncated) return truncated.text; + return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`; } declare global { diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 0007d377b..e81c91f00 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -145,6 +145,25 @@ describe("applyConfigSnapshot", () => { expect(state.slackForm.botToken).toBe(""); expect(state.slackForm.actions).toEqual(defaultSlackActions); }); + + it("does not clobber form edits while dirty", () => { + const state = createState(); + state.configFormMode = "form"; + state.configFormDirty = true; + state.configForm = { gateway: { mode: "local", port: 18789 } }; + state.configRaw = "{\n}\n"; + + applyConfigSnapshot(state, { + config: { gateway: { mode: "remote", port: 9999 } }, + valid: true, + issues: [], + raw: "{\n \"gateway\": { \"mode\": \"remote\", \"port\": 9999 }\n}\n", + }); + + expect(state.configRaw).toBe( + "{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n", + ); + }); }); describe("updateConfigFormValue", () => { @@ -165,6 +184,22 @@ describe("updateConfigFormValue", () => { gateway: { mode: "local", port: 18789 }, }); }); + + it("keeps raw in sync while editing the form", () => { + const state = createState(); + state.configSnapshot = { + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: "{\n}\n", + }; + + updateConfigFormValue(state, ["gateway", "port"], 18789); + + expect(state.configRaw).toBe( + "{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n", + ); + }); }); describe("applyConfig", () => { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 325bcadfc..249f70bf4 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -92,10 +92,18 @@ export function applyConfigSchema( export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) { state.configSnapshot = snapshot; - if (typeof snapshot.raw === "string") { - state.configRaw = snapshot.raw; - } else if (snapshot.config && typeof snapshot.config === "object") { - state.configRaw = `${JSON.stringify(snapshot.config, null, 2).trimEnd()}\n`; + const rawFromSnapshot = + typeof snapshot.raw === "string" + ? snapshot.raw + : snapshot.config && typeof snapshot.config === "object" + ? serializeConfigForm(snapshot.config as Record) + : state.configRaw; + if (!state.configFormDirty || state.configFormMode === "raw") { + state.configRaw = rawFromSnapshot; + } else if (state.configForm) { + state.configRaw = serializeConfigForm(state.configForm); + } else { + state.configRaw = rawFromSnapshot; } state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null; state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : []; @@ -388,7 +396,7 @@ export async function saveConfig(state: ConfigState) { try { const raw = state.configFormMode === "form" && state.configForm - ? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n` + ? serializeConfigForm(state.configForm) : state.configRaw; await state.client.request("config.set", { raw }); state.configFormDirty = false; @@ -407,7 +415,7 @@ export async function applyConfig(state: ConfigState) { try { const raw = state.configFormMode === "form" && state.configForm - ? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n` + ? serializeConfigForm(state.configForm) : state.configRaw; await state.client.request("config.apply", { raw, @@ -448,6 +456,9 @@ export function updateConfigFormValue( setPathValue(base, path, value); state.configForm = base; state.configFormDirty = true; + if (state.configFormMode === "form") { + state.configRaw = serializeConfigForm(base); + } } export function removeConfigFormValue( @@ -460,6 +471,9 @@ export function removeConfigFormValue( removePathValue(base, path); state.configForm = base; state.configFormDirty = true; + if (state.configFormMode === "form") { + state.configRaw = serializeConfigForm(base); + } } function cloneConfigObject(value: T): T { @@ -469,6 +483,10 @@ function cloneConfigObject(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function serializeConfigForm(form: Record): string { + return `${JSON.stringify(form, null, 2).trimEnd()}\n`; +} + function setPathValue( obj: Record | unknown[], path: Array, diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index e1f55e817..26ebf7a8a 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -40,6 +40,21 @@ export function clampText(value: string, max = 120): string { return `${value.slice(0, Math.max(0, max - 1))}…`; } +export function truncateText(value: string, max: number): { + text: string; + truncated: boolean; + total: number; +} { + if (value.length <= max) { + return { text: value, truncated: false, total: value.length }; + } + return { + text: value.slice(0, Math.max(0, max)), + truncated: true, + total: value.length, + }; +} + export function toNumber(value: string, fallback: number): number { const n = Number(value); return Number.isFinite(n) ? n : fallback; @@ -51,4 +66,3 @@ export function parseList(input: string): string[] { .map((v) => v.trim()) .filter((v) => v.length > 0); } - diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 6c638d22f..f83fd18a7 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -1,5 +1,6 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; +import { truncateText } from "./format"; marked.setOptions({ gfm: true, @@ -39,6 +40,8 @@ const allowedTags = [ const allowedAttrs = ["class", "href", "rel", "target", "title"]; let hooksInstalled = false; +const MARKDOWN_CHAR_LIMIT = 140_000; +const MARKDOWN_PARSE_LIMIT = 40_000; function installHooks() { if (hooksInstalled) return; @@ -57,10 +60,30 @@ export function toSanitizedMarkdownHtml(markdown: string): string { const input = markdown.trim(); if (!input) return ""; installHooks(); - const rendered = marked.parse(input) as string; + const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT); + const suffix = truncated.truncated + ? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).` + : ""; + if (truncated.text.length > MARKDOWN_PARSE_LIMIT) { + const escaped = escapeHtml(`${truncated.text}${suffix}`); + const html = `
${escaped}
`; + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: allowedTags, + ALLOWED_ATTR: allowedAttrs, + }); + } + const rendered = marked.parse(`${truncated.text}${suffix}`) as string; return DOMPurify.sanitize(rendered, { ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: allowedAttrs, }); } +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}