diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 1ae00a07b1f..4ca317d0bff 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -168,6 +168,60 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan return { block: changed ? entry : block, changed }; } +/** + * Validate that a value is a finite number, returning undefined otherwise. + */ +function toFiniteNumber(x: unknown): number | undefined { + return typeof x === "number" && Number.isFinite(x) ? x : undefined; +} + +/** + * Sanitize usage metadata to ensure only finite numeric fields are included. + * Prevents UI crashes from malformed transcript JSON. + */ +function sanitizeUsage(raw: unknown): Record | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const u = raw as Record; + const out: Record = {}; + + // Whitelist known usage fields and validate they're finite numbers + const knownFields = [ + "input", + "output", + "totalTokens", + "inputTokens", + "outputTokens", + "cacheRead", + "cacheWrite", + "cache_read_input_tokens", + "cache_creation_input_tokens", + ]; + + for (const k of knownFields) { + const n = toFiniteNumber(u[k]); + if (n !== undefined) { + out[k] = n; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Sanitize cost metadata to ensure only finite numeric fields are included. + * Prevents UI crashes from calling .toFixed() on non-numbers. + */ +function sanitizeCost(raw: unknown): { total?: number } | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const c = raw as Record; + const total = toFiniteNumber(c.total); + return total !== undefined ? { total } : undefined; +} + function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { if (!message || typeof message !== "object") { return { message, changed: false }; @@ -181,6 +235,37 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang } // Keep usage/cost so the chat UI can render per-message token and cost badges. + // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. + if (entry.role !== "assistant") { + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; + } + } else { + // Validate and sanitize usage/cost for assistant messages + if ("usage" in entry) { + const sanitized = sanitizeUsage(entry.usage); + if (sanitized) { + entry.usage = sanitized; + } else { + delete entry.usage; + } + changed = true; + } + if ("cost" in entry) { + const sanitized = sanitizeCost(entry.cost); + if (sanitized) { + entry.cost = sanitized; + } else { + delete entry.cost; + } + changed = true; + } + } if (typeof entry.content === "string") { const stripped = stripInlineDirectiveTagsForDisplay(entry.content); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index e8505830776..29574704d12 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -36,7 +36,11 @@ import { startThemeTransition, type ThemeTransitionContext } from "./theme-trans import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts"; import { cleanupChatModuleState } from "./views/chat.ts"; -let systemThemeCleanup: (() => void) | null = null; +/** + * Per-host theme listener cleanup functions. + * Prevents stale closures after component remount by keying cleanup by host instance. + */ +const systemThemeCleanupMap = new WeakMap void>(); import type { AgentsListResult, AttentionItem } from "./types.ts"; type SettingsHost = { @@ -282,10 +286,11 @@ export function attachThemeListener(host: SettingsHost) { syncSystemThemeListener(host); } -export function detachThemeListener(_host: SettingsHost) { - if (systemThemeCleanup) { - systemThemeCleanup(); - systemThemeCleanup = null; +export function detachThemeListener(host: SettingsHost) { + const cleanup = systemThemeCleanupMap.get(host); + if (cleanup) { + cleanup(); + systemThemeCleanupMap.delete(host); } } @@ -300,16 +305,21 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) } function syncSystemThemeListener(host: SettingsHost) { + // Clean up existing listener if mode is not "system" if (host.themeMode !== "system") { - if (systemThemeCleanup) { - systemThemeCleanup(); - systemThemeCleanup = null; + const cleanup = systemThemeCleanupMap.get(host); + if (cleanup) { + cleanup(); + systemThemeCleanupMap.delete(host); } return; } - if (systemThemeCleanup) { + + // Skip if listener already attached for this host + if (systemThemeCleanupMap.has(host)) { return; } + if (typeof globalThis.matchMedia !== "function") { return; } @@ -322,7 +332,7 @@ function syncSystemThemeListener(host: SettingsHost) { applyResolvedTheme(host, resolveTheme(host.theme, "system")); }; mql.addEventListener("change", onChange); - systemThemeCleanup = () => mql.removeEventListener("change", onChange); + systemThemeCleanupMap.set(host, () => mql.removeEventListener("change", onChange)); } export function syncTabWithLocation(host: SettingsHost, replace: boolean) { diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7b74ccb38de..992e5f59126 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -53,6 +53,7 @@ import { } from "./app-tool-stream.ts"; import type { AppViewState } from "./app-view-state.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts"; +import { exportChatMarkdown } from "./chat-export.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; @@ -94,28 +95,6 @@ declare global { const bootAssistantIdentity = normalizeAssistantIdentity({}); -function exportChatMarkdown(messages: unknown[], assistantName: string): void { - const history = Array.isArray(messages) ? messages : []; - if (history.length === 0) { - return; - } - const lines: string[] = [`# Chat with ${assistantName}`, ""]; - for (const msg of history) { - const m = msg as Record; - const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; - const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; - lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); - } - const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `chat-${assistantName}-${Date.now()}.md`; - a.click(); - URL.revokeObjectURL(url); -} - function resolveOnboardingMode(): boolean { if (!window.location.search) { return false; diff --git a/ui/src/ui/chat-export.ts b/ui/src/ui/chat-export.ts new file mode 100644 index 00000000000..0611257ad98 --- /dev/null +++ b/ui/src/ui/chat-export.ts @@ -0,0 +1,25 @@ +/** + * Export chat history as markdown file. + * Shared utility to prevent code duplication between chat.ts and app.ts. + */ +export function exportChatMarkdown(messages: unknown[], assistantName: string): void { + const history = Array.isArray(messages) ? messages : []; + if (history.length === 0) { + return; + } + const lines: string[] = [`# Chat with ${assistantName}`, ""]; + for (const msg of history) { + const m = msg as Record; + const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; + const content = typeof m.content === "string" ? m.content : ""; + const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; + lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); + } + const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `chat-${assistantName}-${Date.now()}.md`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index fcf13c8047c..134d96ff60d 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,6 +1,7 @@ import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; +import { exportChatMarkdown } from "../chat-export.ts"; import { DeletedMessages } from "../chat/deleted-messages.ts"; import { renderMessageGroup, @@ -533,26 +534,11 @@ function tokenEstimate(draft: string): string | null { return `~${Math.ceil(draft.length / 4)} tokens`; } +/** + * Export chat markdown - delegates to shared utility. + */ function exportMarkdown(props: ChatProps): void { - const history = Array.isArray(props.messages) ? props.messages : []; - if (history.length === 0) { - return; - } - const lines: string[] = [`# Chat with ${props.assistantName}`, ""]; - for (const msg of history) { - const m = msg as Record; - const role = m.role === "user" ? "You" : m.role === "assistant" ? props.assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; - const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; - lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); - } - const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `chat-${props.assistantName}-${Date.now()}.md`; - a.click(); - URL.revokeObjectURL(url); + exportChatMarkdown(props.messages, props.assistantName); } const WELCOME_SUGGESTIONS = [ diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index ea052c019cc..8b4b4e688d5 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -522,6 +522,20 @@ function renderDiffValue( return truncateValue(value); } +/** + * Lightweight scan for sensitive keywords in raw config text. + * Used when stream mode is on and formValue hasn't been parsed yet. + */ +function containsSensitiveKeywords(raw: string): boolean { + // Match key patterns from SENSITIVE_PATTERNS in config-form.shared.ts + return ( + /["']?\w*token["']?\s*:/i.test(raw) || + /["']?\w*password["']?\s*:/i.test(raw) || + /["']?\w*secret["']?\s*:/i.test(raw) || + /["']?\w*api.?key["']?\s*:/i.test(raw) + ); +} + type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult }; const THEME_OPTIONS: ThemeOption[] = [ { id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap }, @@ -610,7 +624,7 @@ function renderAppearanceSection(props: ConfigProps) {
Gateway - ${props.gatewayUrl || "—"} + ${props.gatewayUrl || "-"}
Status @@ -679,7 +693,7 @@ export function renderConfig(props: ConfigProps) { const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false; const envSensitiveVisible = !props.streamMode && envRevealed; - // Build categorised nav from schema — only include sections that exist in the schema + // Build categorised nav from schema - only include sections that exist in the schema const schemaProps = analysis.schema?.properties ?? {}; const VIRTUAL_SECTIONS = new Set(["__appearance__"]); @@ -1052,8 +1066,15 @@ export function renderConfig(props: ConfigProps) { [], props.uiHints, ); - const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed); - const canReveal = sensitiveCount > 0 && !props.streamMode; + // In stream mode, also check raw content for sensitive keywords + // to prevent newly-entered secrets from being visible before parse + const rawHasSensitiveKeywords = + props.streamMode && containsSensitiveKeywords(props.raw); + const blurred = + (sensitiveCount > 0 || rawHasSensitiveKeywords) && + (props.streamMode || !rawRevealed); + const canReveal = + (sensitiveCount > 0 || rawHasSensitiveKeywords) && !props.streamMode; return html`