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

View File

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

View File

@@ -1,34 +1,46 @@
import { describe, expect, it } from "vitest";
import { stripEnvelope } from "./message-extract";
describe("stripEnvelope", () => {
it("strips UTC envelope", () => {
const text = "[WebChat agent:main:main 2026-01-18T05:19Z] hello world";
expect(stripEnvelope(text)).toBe("hello world");
import {
extractText,
extractTextCached,
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", () => {
const text = "[Telegram Ada Lovelace (@ada) id:1234 2026-01-18 19:29 GMT+1] test";
expect(stripEnvelope(text)).toBe("test");
it("returns consistent output for repeated calls", () => {
const message = {
role: "user",
content: "plain text",
};
expect(extractTextCached(message)).toBe("plain text");
expect(extractTextCached(message)).toBe("plain text");
});
});
it("strips envelopes without timestamps for known channels", () => {
const text = "[WhatsApp +1234567890] hi there";
expect(stripEnvelope(text)).toBe("hi there");
describe("extractThinkingCached", () => {
it("matches extractThinking output", () => {
const message = {
role: "assistant",
content: [{ type: "thinking", thinking: "Plan A" }],
};
expect(extractThinkingCached(message)).toBe(extractThinking(message));
});
it("handles multi-line messages", () => {
const text = "[Slack #general 2026-01-18T05:19Z] first line\nsecond line";
expect(stripEnvelope(text)).toBe("first line\nsecond line");
});
it("returns text as-is when no envelope present", () => {
const text = "just a regular message";
expect(stripEnvelope(text)).toBe("just a regular message");
});
it("does not strip non-envelope brackets", () => {
expect(stripEnvelope("[OK] hello")).toBe("[OK] hello");
expect(stripEnvelope("[1/2] step one")).toBe("[1/2] step one");
it("returns consistent output for repeated calls", () => {
const message = {
role: "assistant",
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",
];
const textCache = new WeakMap<object, string | null>();
const thinkingCache = new WeakMap<object, string | null>();
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} \d{2}:\d{2}\b/.test(header)) return true;
@@ -59,6 +62,15 @@ export function extractText(message: unknown): string | 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 {
const m = message as Record<string, unknown>;
const content = m.content;
@@ -88,6 +100,15 @@ export function extractThinking(message: unknown): string | 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 {
const m = message as Record<string, unknown>;
const content = m.content;

View File

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

View File

@@ -41,6 +41,24 @@ const allowedAttrs = ["class", "href", "rel", "target", "title", "start"];
let hooksInstalled = false;
const MARKDOWN_CHAR_LIMIT = 140_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() {
if (hooksInstalled) return;
@@ -59,6 +77,10 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
const input = markdown.trim();
if (!input) return "";
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 suffix = truncated.truncated
? `\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) {
const escaped = escapeHtml(`${truncated.text}${suffix}`);
const html = `<pre class="code-block">${escaped}</pre>`;
return DOMPurify.sanitize(html, {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
});
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);
}
return sanitized;
}
const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
return DOMPurify.sanitize(rendered, {
const sanitized = DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
});
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);
}
return sanitized;
}
function escapeHtml(value: string): string {

View File

@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { guard } from "lit/directives/guard.js";
import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types";
@@ -7,7 +8,7 @@ import {
normalizeMessage,
normalizeRoleForGrouping,
} from "../chat/message-normalizer";
import { extractText } from "../chat/message-extract";
import { extractTextCached } from "../chat/message-extract";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
@@ -114,6 +115,56 @@ export function renderChat(props: ChatProps) {
const splitRatio = props.splitRatio ?? 0.6;
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`
<section class="card chat">
@@ -148,41 +199,7 @@ export function renderChat(props: ChatProps) {
class="chat-main"
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
>
<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>
${thread}
</div>
${sidebarOpen
@@ -379,7 +396,8 @@ function messageKey(message: unknown, index: number): string {
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
const role = typeof m.role === "string" ? m.role : "unknown";
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 hash = fnv1a(seed);
return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`;