From 40ab39b5ea6a6f1b6ab6338167a0920d247cadb9 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:03:19 -0500 Subject: [PATCH] fix(ui): keep oversized chat replies readable (#45559) * fix(ui): keep oversized chat replies readable * Update ui/src/ui/markdown.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(ui): preserve oversized markdown whitespace --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + ui/src/styles/components.css | 8 +++++++ ui/src/ui/markdown.test.ts | 41 ++++++++++++++++++++++++++++++++++++ ui/src/ui/markdown.ts | 10 +++++++-- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb9a2fa960..0f0ff273d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08. +- Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes. ## 2026.3.12 diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index b2806f3208f..e1373744be3 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1302,6 +1302,14 @@ background: var(--bg); } +.markdown-plain-text-fallback { + display: block; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + font: inherit; +} + /* =========================================== Lists =========================================== */ diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 90bce3b65f5..8c2f37cbea4 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -105,6 +105,47 @@ describe("toSanitizedMarkdownHtml", () => { expect(html).toContain("link"); }); + it("keeps oversized plain-text replies readable instead of forcing code-block chrome", () => { + const input = + Array.from( + { length: 320 }, + (_, i) => `Paragraph ${i + 1}: ${"Long plain-text reply. ".repeat(8)}`, + ).join("\n\n") + "\n"; + + const html = toSanitizedMarkdownHtml(input); + + expect(html).not.toContain('
');
+    expect(html).toContain('class="markdown-plain-text-fallback"');
+    expect(html).toContain("Paragraph 1:");
+    expect(html).toContain("Paragraph 320:");
+  });
+
+  it("preserves indentation in oversized plain-text replies", () => {
+    const input = `${"Header line\n".repeat(5000)}\n    indented log line\n        deeper indent`;
+    const html = toSanitizedMarkdownHtml(input);
+
+    expect(html).toContain('class="markdown-plain-text-fallback"');
+    expect(html).toContain("    indented log line");
+    expect(html).toContain("        deeper indent");
+  });
+
+  it("exercises the cached oversized fallback branch", () => {
+    const input =
+      Array.from(
+        { length: 240 },
+        (_, i) => `Paragraph ${i + 1}: ${"Cacheable long reply. ".repeat(8)}`,
+      ).join("\n\n") + "\n";
+
+    expect(input.length).toBeGreaterThan(40_000);
+    expect(input.length).toBeLessThan(50_000);
+
+    const first = toSanitizedMarkdownHtml(input);
+    const second = toSanitizedMarkdownHtml(input);
+
+    expect(first).toContain('class="markdown-plain-text-fallback"');
+    expect(second).toBe(first);
+  });
+
   it("falls back to escaped plain text if marked.parse throws (#36213)", () => {
     const parseSpy = vi.spyOn(marked, "parse").mockImplementation(() => {
       throw new Error("forced parse failure");
diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts
index 3030376fcd5..2b324037713 100644
--- a/ui/src/ui/markdown.ts
+++ b/ui/src/ui/markdown.ts
@@ -124,8 +124,10 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
     ? `\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}
`; + // Large plain-text replies should stay readable without inheriting the + // capped code-block chrome, while still preserving whitespace for logs + // and other structured text that commonly trips the parse guard. + const html = renderEscapedPlainTextHtml(`${truncated.text}${suffix}`); const sanitized = DOMPurify.sanitize(html, sanitizeOptions); if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { setCachedMarkdown(input, sanitized); @@ -218,3 +220,7 @@ function escapeHtml(value: string): string { .replace(/"/g, """) .replace(/'/g, "'"); } + +function renderEscapedPlainTextHtml(value: string): string { + return `
${escapeHtml(value.replace(/\r\n?/g, "\n"))}
`; +}