Skip to content

Commit f5482ef

Browse files
vincentkocemg110
andauthored
UI: render half-block QR output in web chat (#93869)
Co-authored-by: MG <[email protected]>
1 parent e7d6566 commit f5482ef

14 files changed

Lines changed: 450 additions & 68 deletions

ui/src/styles/chat/sidebar.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@
697697
border: 1px solid var(--border);
698698
border-radius: var(--radius-md);
699699
padding: 14px 16px;
700+
font-family: var(--mono);
700701
overflow-x: auto;
701702
margin-top: 0.75em;
702703
}
@@ -705,14 +706,33 @@
705706
background: rgba(0, 0, 0, 0.04);
706707
}
707708

709+
.sidebar-markdown pre:has(> code.markdown-block-art) {
710+
background: var(--bg);
711+
}
712+
708713
.sidebar-markdown :where(pre code) {
709714
background: none;
710715
border: none;
711716
padding: 0;
717+
font-family: inherit;
712718
font-size: 12.5px;
713719
line-height: 1.55;
714720
}
715721

722+
.sidebar-markdown :where(pre code.markdown-block-art) {
723+
display: block;
724+
min-width: 100%;
725+
width: max-content;
726+
color: var(--text);
727+
font-size: 12px;
728+
font-variant-ligatures: none;
729+
letter-spacing: 0;
730+
line-height: 0.86;
731+
overflow-wrap: normal;
732+
white-space: pre;
733+
word-break: normal;
734+
}
735+
716736
/* ── Blockquotes ── */
717737

718738
.sidebar-markdown :where(blockquote) {

ui/src/styles/chat/text.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,30 @@
112112
overflow-x: auto;
113113
}
114114

115+
.chat-text :where(pre:has(> code.markdown-block-art)) {
116+
background: var(--bg);
117+
}
118+
115119
.chat-text :where(pre code) {
116120
background: none;
117121
padding: 0;
118122
}
119123

124+
.chat-text :where(pre code.markdown-block-art) {
125+
display: block;
126+
min-width: 100%;
127+
width: max-content;
128+
color: var(--text);
129+
font-family: var(--mono);
130+
font-size: 12px;
131+
font-variant-ligatures: none;
132+
letter-spacing: 0;
133+
line-height: 0.86;
134+
overflow-wrap: normal;
135+
white-space: pre;
136+
word-break: normal;
137+
}
138+
120139
.chat-text :where(blockquote) {
121140
border-left: 3px solid var(--border-strong);
122141
padding-left: 12px;

ui/src/styles/chat/tool-cards.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,25 @@
471471
word-break: inherit;
472472
}
473473

474+
.chat-tool-card__block-content:has(> code.markdown-block-art) {
475+
background: var(--bg);
476+
}
477+
478+
.chat-tool-card__block-content code.markdown-block-art {
479+
display: block;
480+
min-width: 100%;
481+
width: max-content;
482+
color: var(--text);
483+
font-family: var(--mono);
484+
font-size: 12px;
485+
font-variant-ligatures: none;
486+
letter-spacing: 0;
487+
line-height: 0.86;
488+
overflow-wrap: normal;
489+
white-space: pre;
490+
word-break: normal;
491+
}
492+
474493
.chat-tool-card__inline {
475494
margin-top: 10px;
476495
white-space: pre-wrap;

ui/src/styles/components.css

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2210,13 +2210,35 @@
22102210
.code-block-wrapper {
22112211
position: relative;
22122212
border-radius: var(--radius-sm);
2213-
overflow: hidden;
2213+
overflow: visible;
22142214
margin-top: 0.75em;
22152215
}
22162216

22172217
.code-block-wrapper pre {
22182218
margin: 0;
22192219
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
2220+
overflow-x: auto;
2221+
}
2222+
2223+
.code-block-wrapper pre:has(> code.markdown-block-art),
2224+
.chat-text :where(pre:has(> code.markdown-block-art)),
2225+
.sidebar-markdown :where(pre:has(> code.markdown-block-art)) {
2226+
background: var(--bg);
2227+
}
2228+
2229+
:is(.code-block-wrapper, .chat-text, .sidebar-markdown) :where(pre code.markdown-block-art) {
2230+
display: block;
2231+
min-width: 100%;
2232+
width: max-content;
2233+
color: var(--text);
2234+
font-family: var(--mono);
2235+
font-size: 12px;
2236+
font-variant-ligatures: none;
2237+
letter-spacing: 0;
2238+
line-height: 0.86;
2239+
overflow-wrap: normal;
2240+
white-space: pre;
2241+
word-break: normal;
22202242
}
22212243

22222244
.code-block-header {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const blockArtCopyPayloadPrefix = "openclaw:block-art-code:";
2+
export const blockArtCodeBlockCopyPayloadEncoding = "block-art-json";
3+
4+
export function encodeBlockArtCodeBlockCopyPayload(value: string): string {
5+
return `${blockArtCopyPayloadPrefix}${JSON.stringify(value)}`;
6+
}
7+
8+
export function decodeCodeBlockCopyPayload(value: string, encoding?: string): string {
9+
if (
10+
encoding !== blockArtCodeBlockCopyPayloadEncoding ||
11+
!value.startsWith(blockArtCopyPayloadPrefix)
12+
) {
13+
return value;
14+
}
15+
try {
16+
const decoded = JSON.parse(value.slice(blockArtCopyPayloadPrefix.length));
17+
return typeof decoded === "string" ? decoded : value;
18+
} catch {
19+
return value;
20+
}
21+
}

ui/src/ui/chat/grouped-render.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ vi.mock("../../local-storage.ts", () => ({
3232
}));
3333

3434
vi.mock("../markdown.ts", () => ({
35+
isMarkdownBlockArtText: () => false,
3536
toSanitizedMarkdownHtml: markdownRenderMock,
3637
toStreamingMarkdownHtml: streamingMarkdownRenderMock,
3738
toStreamingPlainTextHtml: streamingTextRenderMock,

ui/src/ui/chat/tool-cards.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ describe("tool-cards", () => {
252252
expect(rawToggle!.getAttribute("aria-expanded")).toBe("true");
253253
expect(rawBody!.hidden).toBe(false);
254254
expect(rawBody!.querySelector(".chat-tool-card__block-label")?.textContent).toBe("Tool output");
255+
expect(rawBody!.querySelector("code.markdown-block-art")).toBeNull();
255256
expect(JSON.parse(rawBody!.querySelector("code")?.textContent ?? "{}")).toEqual({
256257
kind: "canvas",
257258
presentation: {
@@ -267,6 +268,36 @@ describe("tool-cards", () => {
267268
});
268269
});
269270

271+
it("marks expanded raw block-art output so QR whitespace uses block-art rendering", () => {
272+
const container = document.createElement("div");
273+
const blockArt = " ▄▄▄▄▄▄▄ \n █ ▄▄▄ █ \n █▄▄▄▄▄█ ";
274+
render(
275+
renderToolCard(
276+
{
277+
id: "msg:view:block-art",
278+
name: "canvas_render",
279+
outputText: blockArt,
280+
preview: {
281+
kind: "canvas",
282+
surface: "assistant_message",
283+
render: "url",
284+
viewId: "qr_preview",
285+
url: "/__openclaw__/canvas/documents/qr_preview/index.html",
286+
},
287+
},
288+
{ expanded: true, onToggleExpanded: vi.fn() },
289+
),
290+
container,
291+
);
292+
293+
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
294+
rawToggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
295+
296+
const code = container.querySelector("code.markdown-block-art");
297+
expect(code).not.toBeNull();
298+
expect(code?.textContent).toBe(blockArt);
299+
});
300+
270301
it("opens assistant-surface canvas payloads in the sidebar when explicitly requested", () => {
271302
const container = document.createElement("div");
272303
const onOpenSidebar = vi.fn();

ui/src/ui/chat/tool-cards.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { t } from "../../i18n/index.ts";
66
import { resolveCanvasIframeUrl } from "../canvas-url.ts";
77
import { resolveEmbedSandbox, type EmbedSandboxMode } from "../embed-sandbox.ts";
88
import { icons } from "../icons.ts";
9+
import { isMarkdownBlockArtText } from "../markdown.ts";
910
import type { SidebarContent } from "../sidebar-content.ts";
1011
import { formatToolDetail, resolveToolDisplay } from "../tool-display.ts";
1112
import type { ToolCard } from "../types/chat-types.ts";
@@ -550,6 +551,7 @@ function renderToolDataBlock(params: {
550551
empty?: boolean;
551552
}) {
552553
const { label, text, expanded, empty } = params;
554+
const codeClass = isMarkdownBlockArtText(text) ? "markdown-block-art" : "";
553555
return html`
554556
<div class="chat-tool-card__block ${expanded ? "chat-tool-card__block--expanded" : ""}">
555557
<div class="chat-tool-card__block-header">
@@ -559,7 +561,9 @@ function renderToolDataBlock(params: {
559561
${empty
560562
? html`<div class="chat-tool-card__block-empty muted">${text}</div>`
561563
: expanded
562-
? html`<pre class="chat-tool-card__block-content"><code>${text}</code></pre>`
564+
? html`<pre
565+
class="chat-tool-card__block-content"
566+
><code class=${codeClass}>${text}</code></pre>`
563567
: html`<div class="chat-tool-card__block-preview mono">
564568
${getTruncatedPreview(text)}
565569
</div>`}

ui/src/ui/chat/tool-helpers.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ describe("tool-helpers", () => {
6060
expect(result).toBe("This is plain text output");
6161
});
6262

63+
it("wraps block art output in a fence while preserving quiet-zone whitespace", () => {
64+
const input = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
65+
const result = formatToolOutputForSidebar(input);
66+
67+
expect(result).toBe(`\`\`\`
68+
${input}
69+
\`\`\``);
70+
});
71+
6372
it("returns as-is for invalid JSON starting with {", () => {
6473
const input = "{not valid json";
6574
const result = formatToolOutputForSidebar(input);

ui/src/ui/chat/tool-helpers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22
* Helper functions for tool card rendering.
33
*/
44

5+
import { isMarkdownBlockArtText } from "../markdown.ts";
56
import { PREVIEW_MAX_CHARS, PREVIEW_MAX_LINES } from "./constants.ts";
67

78
/**
89
* Format tool output content for display in the sidebar.
9-
* Detects JSON and wraps it in a code block with formatting.
10+
* Detects block art and JSON, wrapping content in code blocks when needed.
1011
*/
1112
export function formatToolOutputForSidebar(text: string): string {
13+
if (isMarkdownBlockArtText(text)) {
14+
return "```\n" + text + "\n```";
15+
}
16+
1217
const trimmed = text.trim();
1318
// Try to detect and format JSON
1419
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {

0 commit comments

Comments
 (0)