fix(ui): cache control ui markdown

This commit is contained in:
Peter Steinberger
2026-01-24 03:27:03 +00:00
parent b697374ce5
commit d57cb2e1a8
7 changed files with 157 additions and 73 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514) - Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. - Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast. - UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies. - Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies. - Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.

View File

@@ -7,8 +7,8 @@ import type { MessageGroup } from "../types/chat-types";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer"; import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
import { import {
extractText, extractTextCached,
extractThinking, extractThinkingCached,
formatReasoningMarkdown, formatReasoningMarkdown,
} from "./message-extract"; } from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards"; import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
@@ -180,9 +180,11 @@ function renderGroupedMessage(
const toolCards = extractToolCards(message); const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0; const hasToolCards = toolCards.length > 0;
const extractedText = extractText(message); const extractedText = extractTextCached(message);
const extractedThinking = const extractedThinking =
opts.showReasoning && role === "assistant" ? extractThinking(message) : null; opts.showReasoning && role === "assistant"
? extractThinkingCached(message)
: null;
const markdownBase = extractedText?.trim() ? extractedText : null; const markdownBase = extractedText?.trim() ? extractedText : null;
const reasoningMarkdown = extractedThinking const reasoningMarkdown = extractedThinking
? formatReasoningMarkdown(extractedThinking) ? formatReasoningMarkdown(extractedThinking)

View File

@@ -1,34 +1,46 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { stripEnvelope } from "./message-extract";
describe("stripEnvelope", () => { import {
it("strips UTC envelope", () => { extractText,
const text = "[WebChat agent:main:main 2026-01-18T05:19Z] hello world"; extractTextCached,
expect(stripEnvelope(text)).toBe("hello world"); extractThinking,
extractThinkingCached,
} from "./message-extract";
describe("extractTextCached", () => {
it("matches extractText output", () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "Hello there" }],
};
expect(extractTextCached(message)).toBe(extractText(message));
}); });
it("strips local-time envelope", () => { it("returns consistent output for repeated calls", () => {
const text = "[Telegram Ada Lovelace (@ada) id:1234 2026-01-18 19:29 GMT+1] test"; const message = {
expect(stripEnvelope(text)).toBe("test"); role: "user",
}); content: "plain text",
};
it("strips envelopes without timestamps for known channels", () => { expect(extractTextCached(message)).toBe("plain text");
const text = "[WhatsApp +1234567890] hi there"; expect(extractTextCached(message)).toBe("plain text");
expect(stripEnvelope(text)).toBe("hi there"); });
}); });
it("handles multi-line messages", () => { describe("extractThinkingCached", () => {
const text = "[Slack #general 2026-01-18T05:19Z] first line\nsecond line"; it("matches extractThinking output", () => {
expect(stripEnvelope(text)).toBe("first line\nsecond line"); const message = {
}); role: "assistant",
content: [{ type: "thinking", thinking: "Plan A" }],
it("returns text as-is when no envelope present", () => { };
const text = "just a regular message"; expect(extractThinkingCached(message)).toBe(extractThinking(message));
expect(stripEnvelope(text)).toBe("just a regular message"); });
});
it("returns consistent output for repeated calls", () => {
it("does not strip non-envelope brackets", () => { const message = {
expect(stripEnvelope("[OK] hello")).toBe("[OK] hello"); role: "assistant",
expect(stripEnvelope("[1/2] step one")).toBe("[1/2] step one"); content: [{ type: "thinking", thinking: "Plan A" }],
};
expect(extractThinkingCached(message)).toBe("Plan A");
expect(extractThinkingCached(message)).toBe("Plan A");
}); });
}); });

View File

@@ -16,6 +16,9 @@ const ENVELOPE_CHANNELS = [
"BlueBubbles", "BlueBubbles",
]; ];
const textCache = new WeakMap<object, string | null>();
const thinkingCache = new WeakMap<object, string | null>();
function looksLikeEnvelopeHeader(header: string): boolean { function looksLikeEnvelopeHeader(header: string): boolean {
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
@@ -59,6 +62,15 @@ export function extractText(message: unknown): string | null {
return null; return null;
} }
export function extractTextCached(message: unknown): string | null {
if (!message || typeof message !== "object") return extractText(message);
const obj = message as object;
if (textCache.has(obj)) return textCache.get(obj) ?? null;
const value = extractText(message);
textCache.set(obj, value);
return value;
}
export function extractThinking(message: unknown): string | null { export function extractThinking(message: unknown): string | null {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const content = m.content; const content = m.content;
@@ -88,6 +100,15 @@ export function extractThinking(message: unknown): string | null {
return extracted.length > 0 ? extracted.join("\n") : null; return extracted.length > 0 ? extracted.join("\n") : null;
} }
export function extractThinkingCached(message: unknown): string | null {
if (!message || typeof message !== "object") return extractThinking(message);
const obj = message as object;
if (thinkingCache.has(obj)) return thinkingCache.get(obj) ?? null;
const value = extractThinking(message);
thinkingCache.set(obj, value);
return value;
}
export function extractRawText(message: unknown): string | null { export function extractRawText(message: unknown): string | null {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const content = m.content; const content = m.content;

View File

@@ -8,7 +8,7 @@ import {
getTruncatedPreview, getTruncatedPreview,
} from "./tool-helpers"; } from "./tool-helpers";
import { isToolResultMessage } from "./message-normalizer"; import { isToolResultMessage } from "./message-normalizer";
import { extractText } from "./message-extract"; import { extractTextCached } from "./message-extract";
export function extractToolCards(message: unknown): ToolCard[] { export function extractToolCards(message: unknown): ToolCard[] {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
@@ -45,7 +45,7 @@ export function extractToolCards(message: unknown): ToolCard[] {
(typeof m.toolName === "string" && m.toolName) || (typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) || (typeof m.tool_name === "string" && m.tool_name) ||
"tool"; "tool";
const text = extractText(message) ?? undefined; const text = extractTextCached(message) ?? undefined;
cards.push({ kind: "result", name, text }); cards.push({ kind: "result", name, text });
} }

View File

@@ -41,6 +41,24 @@ const allowedAttrs = ["class", "href", "rel", "target", "title", "start"];
let hooksInstalled = false; let hooksInstalled = false;
const MARKDOWN_CHAR_LIMIT = 140_000; const MARKDOWN_CHAR_LIMIT = 140_000;
const MARKDOWN_PARSE_LIMIT = 40_000; const MARKDOWN_PARSE_LIMIT = 40_000;
const MARKDOWN_CACHE_LIMIT = 200;
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
const markdownCache = new Map<string, string>();
function getCachedMarkdown(key: string): string | null {
const cached = markdownCache.get(key);
if (cached === undefined) return null;
markdownCache.delete(key);
markdownCache.set(key, cached);
return cached;
}
function setCachedMarkdown(key: string, value: string) {
markdownCache.set(key, value);
if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) return;
const oldest = markdownCache.keys().next().value;
if (oldest) markdownCache.delete(oldest);
}
function installHooks() { function installHooks() {
if (hooksInstalled) return; if (hooksInstalled) return;
@@ -59,6 +77,10 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
const input = markdown.trim(); const input = markdown.trim();
if (!input) return ""; if (!input) return "";
installHooks(); installHooks();
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
const cached = getCachedMarkdown(input);
if (cached !== null) return cached;
}
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT); const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
const suffix = truncated.truncated const suffix = truncated.truncated
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).` ? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
@@ -66,16 +88,24 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) { if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
const escaped = escapeHtml(`${truncated.text}${suffix}`); const escaped = escapeHtml(`${truncated.text}${suffix}`);
const html = `<pre class="code-block">${escaped}</pre>`; const html = `<pre class="code-block">${escaped}</pre>`;
return DOMPurify.sanitize(html, { const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags, ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs, ALLOWED_ATTR: allowedAttrs,
}); });
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);
}
return sanitized;
} }
const rendered = marked.parse(`${truncated.text}${suffix}`) as string; const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
return DOMPurify.sanitize(rendered, { const sanitized = DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: allowedTags, ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs, ALLOWED_ATTR: allowedAttrs,
}); });
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);
}
return sanitized;
} }
function escapeHtml(value: string): string { function escapeHtml(value: string): string {

View File

@@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { guard } from "lit/directives/guard.js";
import { repeat } from "lit/directives/repeat.js"; import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types"; import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types"; import type { ChatQueueItem } from "../ui-types";
@@ -7,7 +8,7 @@ import {
normalizeMessage, normalizeMessage,
normalizeRoleForGrouping, normalizeRoleForGrouping,
} from "../chat/message-normalizer"; } from "../chat/message-normalizer";
import { extractText } from "../chat/message-extract"; import { extractTextCached } from "../chat/message-extract";
import { import {
renderMessageGroup, renderMessageGroup,
renderReadingIndicatorGroup, renderReadingIndicatorGroup,
@@ -114,6 +115,56 @@ export function renderChat(props: ChatProps) {
const splitRatio = props.splitRatio ?? 0.6; const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const thread = guard(
[
props.loading,
props.messages,
props.toolMessages,
props.stream,
props.streamStartedAt,
props.sessionKey,
props.showThinking,
reasoningLevel,
props.assistantName,
props.assistantAvatar,
props.assistantAvatarUrl,
],
() => html`
<div
class="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
return nothing;
})}
</div>
`,
);
return html` return html`
<section class="card chat"> <section class="card chat">
@@ -148,41 +199,7 @@ export function renderChat(props: ChatProps) {
class="chat-main" class="chat-main"
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}" style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
> >
<div ${thread}
class="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading
? html`<div class="muted">Loading chat…</div>`
: nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
return nothing;
})}
</div>
</div> </div>
${sidebarOpen ${sidebarOpen
@@ -379,7 +396,8 @@ function messageKey(message: unknown, index: number): string {
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null; const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
const role = typeof m.role === "string" ? m.role : "unknown"; const role = typeof m.role === "string" ? m.role : "unknown";
const fingerprint = const fingerprint =
extractText(message) ?? (typeof m.content === "string" ? m.content : null); extractTextCached(message) ??
(typeof m.content === "string" ? m.content : null);
const seed = fingerprint ?? safeJson(message) ?? String(index); const seed = fingerprint ?? safeJson(message) ?? String(index);
const hash = fnv1a(seed); const hash = fnv1a(seed);
return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`; return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`;