Skip to content

Commit 1fbb4e4

Browse files
spacegeologistclawsweeper[bot]Takhoffman
authored
ui: highlight WebChat code blocks (#83569)
Summary: - The PR adds highlight.js-backed WebChat code-block highlighting, scoped token CSS, regression tests, a type shim, and a direct UI dependency. - Reproducibility: not applicable. as a bug reproduction; this is a feature addition. The feature gap is source-evident because current main renders code blocks as escaped plaintext without hljs token markup. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head 7bb95c4. - Required merge gates passed before the squash merge. Prepared head SHA: 7bb95c4 Review: #83569 (comment) Co-authored-by: zhengzuo0-ai <zheng.zuo0@gmail.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 46c622a commit 1fbb4e4

7 files changed

Lines changed: 365 additions & 16 deletions

File tree

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"@create-markdown/preview": "2.0.3",
1313
"@noble/ed25519": "3.1.0",
1414
"dompurify": "3.4.3",
15+
"highlight.js": "10.7.3",
1516
"json5": "2.2.3",
1617
"lit": "3.3.3",
1718
"markdown-it": "14.1.1",

ui/src/styles/components.css

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2004,6 +2004,159 @@
20042004
background: var(--bg);
20052005
}
20062006

2007+
:is(.code-block .hljs, .code-block-wrapper pre code.hljs) {
2008+
color: var(--text);
2009+
}
2010+
2011+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-comment,
2012+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-quote {
2013+
color: var(--muted);
2014+
font-style: italic;
2015+
}
2016+
2017+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword,
2018+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-tag,
2019+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-subst {
2020+
color: #ff8a80;
2021+
}
2022+
2023+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-number,
2024+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-literal,
2025+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-variable,
2026+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-template-variable,
2027+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-tag .hljs-attr {
2028+
color: #79c0ff;
2029+
}
2030+
2031+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string,
2032+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-doctag {
2033+
color: #a5d6ff;
2034+
}
2035+
2036+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-title,
2037+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-section,
2038+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-id,
2039+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-function .hljs-title {
2040+
color: #d2a8ff;
2041+
}
2042+
2043+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-type,
2044+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-class .hljs-title,
2045+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-built_in,
2046+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-builtin-name {
2047+
color: #ffa657;
2048+
}
2049+
2050+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attr,
2051+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attribute,
2052+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-name,
2053+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-class,
2054+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-attr,
2055+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-pseudo {
2056+
color: #7ee787;
2057+
}
2058+
2059+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-symbol,
2060+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-bullet,
2061+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-link {
2062+
color: var(--accent-2);
2063+
}
2064+
2065+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-meta {
2066+
color: #c9d1d9;
2067+
}
2068+
2069+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-deletion {
2070+
color: #ffa198;
2071+
background: rgba(248, 81, 73, 0.12);
2072+
}
2073+
2074+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-addition {
2075+
color: #7ee787;
2076+
background: rgba(46, 160, 67, 0.12);
2077+
}
2078+
2079+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-emphasis {
2080+
font-style: italic;
2081+
}
2082+
2083+
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-strong {
2084+
font-weight: 600;
2085+
}
2086+
2087+
:root[data-theme-mode="light"] :is(.code-block .hljs, .code-block-wrapper pre code.hljs) {
2088+
color: var(--text);
2089+
}
2090+
2091+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword,
2092+
:root[data-theme-mode="light"]
2093+
:is(.code-block, .code-block-wrapper pre code.hljs)
2094+
.hljs-selector-tag,
2095+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-subst {
2096+
color: #cf222e;
2097+
}
2098+
2099+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-number,
2100+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-literal,
2101+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-variable,
2102+
:root[data-theme-mode="light"]
2103+
:is(.code-block, .code-block-wrapper pre code.hljs)
2104+
.hljs-template-variable,
2105+
:root[data-theme-mode="light"]
2106+
:is(.code-block, .code-block-wrapper pre code.hljs)
2107+
.hljs-tag
2108+
.hljs-attr {
2109+
color: #0550ae;
2110+
}
2111+
2112+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string,
2113+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-doctag {
2114+
color: #0a3069;
2115+
}
2116+
2117+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-title,
2118+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-section,
2119+
:root[data-theme-mode="light"]
2120+
:is(.code-block, .code-block-wrapper pre code.hljs)
2121+
.hljs-selector-id,
2122+
:root[data-theme-mode="light"]
2123+
:is(.code-block, .code-block-wrapper pre code.hljs)
2124+
.hljs-function
2125+
.hljs-title {
2126+
color: #8250df;
2127+
}
2128+
2129+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-type,
2130+
:root[data-theme-mode="light"]
2131+
:is(.code-block, .code-block-wrapper pre code.hljs)
2132+
.hljs-class
2133+
.hljs-title,
2134+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-built_in,
2135+
:root[data-theme-mode="light"]
2136+
:is(.code-block, .code-block-wrapper pre code.hljs)
2137+
.hljs-builtin-name {
2138+
color: #953800;
2139+
}
2140+
2141+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attr,
2142+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attribute,
2143+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-name,
2144+
:root[data-theme-mode="light"]
2145+
:is(.code-block, .code-block-wrapper pre code.hljs)
2146+
.hljs-selector-class,
2147+
:root[data-theme-mode="light"]
2148+
:is(.code-block, .code-block-wrapper pre code.hljs)
2149+
.hljs-selector-attr,
2150+
:root[data-theme-mode="light"]
2151+
:is(.code-block, .code-block-wrapper pre code.hljs)
2152+
.hljs-selector-pseudo {
2153+
color: #116329;
2154+
}
2155+
2156+
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-meta {
2157+
color: #57606a;
2158+
}
2159+
20072160
.markdown-plain-text-fallback {
20082161
display: block;
20092162
white-space: pre-wrap;

ui/src/styles/components.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ function readComponentsCss(): string {
55
return readStyleSheet("ui/src/styles/components.css");
66
}
77

8+
describe("code block highlight styles", () => {
9+
it("targets the markdown renderer's generated code block wrapper", () => {
10+
const css = readComponentsCss();
11+
12+
expect(css).toContain(":is(.code-block .hljs, .code-block-wrapper pre code.hljs)");
13+
expect(css).toContain(":is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword");
14+
expect(css).toContain(
15+
':root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string',
16+
);
17+
});
18+
});
19+
820
describe("agent fallback chip styles", () => {
921
it("styles the chip remove control inside the agent model input", () => {
1022
const css = readComponentsCss();
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
declare module "highlight.js/lib/core.js" {
2+
import hljs = require("highlight.js");
3+
4+
export default hljs;
5+
}
6+
7+
declare module "highlight.js/lib/languages/*.js" {
8+
export default function language(hljs?: HLJSApi): LanguageDetail;
9+
}

ui/src/ui/markdown.test.ts

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,14 @@ describe("toSanitizedMarkdownHtml", () => {
332332
describe("code blocks", () => {
333333
it("renders fenced code blocks", () => {
334334
const html = toSanitizedMarkdownHtml("```ts\nconsole.log(1)\n```");
335-
expect(html).toBe(
336-
'<div class="code-block-wrapper"><div class="code-block-header"><span class="code-block-lang">ts</span><button type="button" class="code-block-copy" data-code="console.log(1)" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code class="language-ts">console.log(1)\n</code></pre></div>',
337-
);
335+
const fragment = htmlFragment(html);
336+
const code = fragment.querySelector("pre code");
337+
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
338+
339+
expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("ts");
340+
expect(copy?.dataset.code).toBe("console.log(1)");
341+
expect(code?.classList.contains("language-ts")).toBe(true);
342+
expect(code?.textContent).toBe("console.log(1)\n");
338343
});
339344

340345
it("renders indented code blocks", () => {
@@ -352,6 +357,52 @@ describe("toSanitizedMarkdownHtml", () => {
352357
);
353358
});
354359

360+
it("highlights fenced code blocks while preserving copy text", () => {
361+
const source = 'const answer = "yes";\nconsole.log(answer);\n';
362+
const html = toSanitizedMarkdownHtml(`\`\`\`js\n${source}\`\`\``);
363+
const fragment = htmlFragment(html);
364+
const code = fragment.querySelector("pre code");
365+
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
366+
367+
expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("js");
368+
expect(copy?.dataset.code).toBe(source.trimEnd());
369+
expect(code?.textContent).toBe(source);
370+
expect(code?.querySelector(".hljs-keyword")?.textContent).toBe("const");
371+
expect(code?.querySelector(".hljs-string")?.textContent).toBe('"yes"');
372+
});
373+
374+
it("highlights collapsed JSON code blocks", () => {
375+
const html = toSanitizedMarkdownHtml('```json\n{"ok": true}\n```');
376+
const fragment = htmlFragment(html);
377+
const details = fragment.querySelector("details.json-collapse");
378+
const code = details?.querySelector("pre code");
379+
380+
expect(details?.querySelector("summary")?.textContent).toBe("JSON · 2 lines");
381+
expect(code?.textContent).toBe('{"ok": true}\n');
382+
expect(code?.innerHTML).toContain("hljs-");
383+
});
384+
385+
it("auto-highlights unlabeled code blocks only when detection is confident", () => {
386+
const html = toSanitizedMarkdownHtml("```\n#include <vector>\nstd::vector<int> nums;\n```");
387+
const fragment = htmlFragment(html);
388+
const code = fragment.querySelector("pre code");
389+
390+
expect(code?.classList.contains("hljs")).toBe(true);
391+
expect(code?.textContent).toBe("#include <vector>\nstd::vector<int> nums;\n");
392+
expect(code?.innerHTML).toContain("hljs-meta");
393+
expect(code?.innerHTML).toContain("hljs-keyword");
394+
});
395+
396+
it("keeps highlighted HTML code escaped", () => {
397+
const html = toSanitizedMarkdownHtml("```html\n<script>alert(1)</script>\n```");
398+
const fragment = htmlFragment(html);
399+
const code = fragment.querySelector("pre code");
400+
401+
expect(code?.querySelector("script")).toBeNull();
402+
expect(code?.textContent).toBe("<script>alert(1)</script>\n");
403+
expect(code?.innerHTML).not.toContain("<script>");
404+
});
405+
355406
it("keeps localized copy labels fresh after locale changes", async () => {
356407
const markdown = "```ts\nconst localizedCopy = true;\n```";
357408
await i18n.setLocale("en");
@@ -360,12 +411,25 @@ describe("toSanitizedMarkdownHtml", () => {
360411
try {
361412
await i18n.setLocale("zh-CN");
362413
const chinese = toSanitizedMarkdownHtml(markdown);
363-
364-
expect(english).toBe(
365-
'<div class="code-block-wrapper"><div class="code-block-header"><span class="code-block-lang">ts</span><button type="button" class="code-block-copy" data-code="const localizedCopy = true;" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code class="language-ts">const localizedCopy = true;\n</code></pre></div>',
414+
const englishFragment = htmlFragment(english);
415+
const chineseFragment = htmlFragment(chinese);
416+
const englishCopy = englishFragment.querySelector<HTMLButtonElement>(".code-block-copy");
417+
const chineseCopy = chineseFragment.querySelector<HTMLButtonElement>(".code-block-copy");
418+
419+
expect(englishCopy?.dataset.code).toBe("const localizedCopy = true;");
420+
expect(englishCopy?.getAttribute("aria-label")).toBe("Copy code");
421+
expect(englishCopy?.querySelector(".code-block-copy__idle")?.textContent).toBe("Copy");
422+
expect(englishCopy?.querySelector(".code-block-copy__done")?.textContent).toBe("Copied!");
423+
expect(englishFragment.querySelector("pre code")?.textContent).toBe(
424+
"const localizedCopy = true;\n",
366425
);
367-
expect(chinese).toBe(
368-
'<div class="code-block-wrapper"><div class="code-block-header"><span class="code-block-lang">ts</span><button type="button" class="code-block-copy" data-code="const localizedCopy = true;" aria-label="复制代码"><span class="code-block-copy__idle">复制</span><span class="code-block-copy__done">已复制!</span></button></div><pre><code class="language-ts">const localizedCopy = true;\n</code></pre></div>',
426+
427+
expect(chineseCopy?.dataset.code).toBe("const localizedCopy = true;");
428+
expect(chineseCopy?.getAttribute("aria-label")).toBe("复制代码");
429+
expect(chineseCopy?.querySelector(".code-block-copy__idle")?.textContent).toBe("复制");
430+
expect(chineseCopy?.querySelector(".code-block-copy__done")?.textContent).toBe("已复制!");
431+
expect(chineseFragment.querySelector("pre code")?.textContent).toBe(
432+
"const localizedCopy = true;\n",
369433
);
370434
} finally {
371435
await i18n.setLocale("en");
@@ -374,9 +438,16 @@ describe("toSanitizedMarkdownHtml", () => {
374438

375439
it("collapses JSON code blocks", () => {
376440
const html = toSanitizedMarkdownHtml('```json\n{"key": "value"}\n```');
377-
expect(html).toBe(
378-
'<details class="json-collapse"><summary>JSON · 2 lines</summary><div class="code-block-wrapper"><div class="code-block-header"><span class="code-block-lang">json</span><button type="button" class="code-block-copy" data-code="{&quot;key&quot;: &quot;value&quot;}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code class="language-json">{"key": "value"}\n</code></pre></div></details>',
379-
);
441+
const fragment = htmlFragment(html);
442+
const details = fragment.querySelector("details.json-collapse");
443+
const code = details?.querySelector("pre code");
444+
const copy = details?.querySelector<HTMLButtonElement>(".code-block-copy");
445+
446+
expect(details?.querySelector("summary")?.textContent).toBe("JSON · 2 lines");
447+
expect(details?.querySelector(".code-block-lang")?.textContent).toBe("json");
448+
expect(copy?.dataset.code).toBe('{"key": "value"}');
449+
expect(code?.classList.contains("language-json")).toBe(true);
450+
expect(code?.textContent).toBe('{"key": "value"}\n');
380451
});
381452
});
382453

0 commit comments

Comments
 (0)