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"))}`;
+}