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>
This commit is contained in:
Val Alexander 2026-03-13 20:03:19 -05:00 committed by GitHub
parent 89e52d6178
commit 40ab39b5ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 58 additions and 2 deletions

View File

@ -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

View File

@ -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
=========================================== */

View File

@ -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('<pre class="code-block">');
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");

View File

@ -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 = `<pre class="code-block">${escaped}</pre>`;
// 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, "&quot;")
.replace(/'/g, "&#39;");
}
function renderEscapedPlainTextHtml(value: string): string {
return `<div class="markdown-plain-text-fallback">${escapeHtml(value.replace(/\r\n?/g, "\n"))}</div>`;
}