From 25ac5634069bbe750492a71593fc9ede4dc4255f Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:54:13 +0800 Subject: [PATCH 1/4] feat(web): add syntax highlighting for markdown code blocks --- web/frontend/package.json | 1 + web/frontend/pnpm-lock.yaml | 54 ++++++++++++++++ .../components/agent/skills/detail-sheet.tsx | 3 +- .../src/components/chat/assistant-message.tsx | 3 +- web/frontend/src/index.css | 63 +++++++++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 40d5cf3d8..a8a963ca9 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -35,6 +35,7 @@ "react-i18next": "^17.0.2", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", + "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index e104eaee6..e12e6b351 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: react-textarea-autosize: specifier: ^8.5.9 version: 8.5.9(@types/react@19.2.14)(react@19.2.5) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -2433,6 +2436,9 @@ packages: hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -2448,6 +2454,9 @@ packages: hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -2463,6 +2472,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hono@4.12.12: resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} engines: {node: '>=16.9.0'} @@ -2807,6 +2820,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3371,6 +3387,9 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -3686,6 +3705,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -6253,6 +6275,10 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -6309,6 +6335,13 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -6329,6 +6362,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + highlight.js@11.11.1: {} + hono@4.12.12: {} html-parse-stringify@3.0.1: @@ -6574,6 +6609,12 @@ snapshots: longest-streak@3.1.0: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7350,6 +7391,14 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 @@ -7744,6 +7793,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx index e6f2c75a6..41f56b057 100644 --- a/web/frontend/src/components/agent/skills/detail-sheet.tsx +++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx @@ -7,6 +7,7 @@ import { import type { ReactNode } from "react" import { useTranslation } from "react-i18next" import ReactMarkdown from "react-markdown" +import rehypeHighlight from "rehype-highlight" import rehypeRaw from "rehype-raw" import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" @@ -174,7 +175,7 @@ export function DetailSheet({
{selectedSkillDetail.content} diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 8dcbe15a1..9732a6b0f 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -2,6 +2,7 @@ import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react" import { useState } from "react" import { useTranslation } from "react-i18next" import ReactMarkdown from "react-markdown" +import rehypeHighlight from "rehype-highlight" import rehypeRaw from "rehype-raw" import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" @@ -71,7 +72,7 @@ export function AssistantMessage({ > {content} diff --git a/web/frontend/src/index.css b/web/frontend/src/index.css index fc55a3a32..7958233f3 100644 --- a/web/frontend/src/index.css +++ b/web/frontend/src/index.css @@ -157,6 +157,69 @@ height: calc(100svh - 3.5rem); } +/* Markdown code highlighting (rehype-highlight / highlight.js classes) */ +.prose pre code.hljs, +.prose pre code[class*="language-"] { + display: block; + overflow-x: auto; + background: transparent; + padding: 0; + color: #e4e4e7; +} + +.prose pre code .hljs-comment, +.prose pre code .hljs-quote { + color: #71717a; +} + +.prose pre code .hljs-keyword, +.prose pre code .hljs-selector-tag, +.prose pre code .hljs-subst { + color: #f472b6; +} + +.prose pre code .hljs-string, +.prose pre code .hljs-doctag, +.prose pre code .hljs-regexp, +.prose pre code .hljs-addition, +.prose pre code .hljs-attribute, +.prose pre code .hljs-template-tag, +.prose pre code .hljs-template-variable { + color: #34d399; +} + +.prose pre code .hljs-number, +.prose pre code .hljs-literal, +.prose pre code .hljs-bullet, +.prose pre code .hljs-meta, +.prose pre code .hljs-built_in, +.prose pre code .hljs-builtin-name, +.prose pre code .hljs-symbol, +.prose pre code .hljs-variable, +.prose pre code .hljs-link, +.prose pre code .hljs-type, +.prose pre code .hljs-selector-class, +.prose pre code .hljs-selector-attr, +.prose pre code .hljs-selector-pseudo { + color: #22d3ee; +} + +.prose pre code .hljs-title, +.prose pre code .hljs-section, +.prose pre code .hljs-name, +.prose pre code .hljs-selector-id, +.prose pre code .hljs-deletion { + color: #60a5fa; +} + +.prose pre code .hljs-emphasis { + font-style: italic; +} + +.prose pre code .hljs-strong { + font-weight: 700; +} + /* Typing indicator animations */ @keyframes shimmer { 0% { From 389f492d8ce6bd8c02c19830ef731191a72ea91d Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:19:48 +0800 Subject: [PATCH 2/4] refactor(web): use official highlight themes for markdown --- web/frontend/package.json | 1 + web/frontend/pnpm-lock.yaml | 3 + .../components/agent/skills/detail-sheet.tsx | 2 +- .../src/components/chat/assistant-message.tsx | 2 +- web/frontend/src/hooks/use-highlight-theme.ts | 45 +++++++++++++ web/frontend/src/index.css | 63 ------------------- web/frontend/src/main.tsx | 15 ++++- 7 files changed, 63 insertions(+), 68 deletions(-) create mode 100644 web/frontend/src/hooks/use-highlight-theme.ts diff --git a/web/frontend/package.json b/web/frontend/package.json index a8a963ca9..7595c46bf 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", + "highlight.js": "^11.11.1", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.19.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index e12e6b351..721bd7e75 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: dayjs: specifier: ^1.11.20 version: 1.11.20 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 i18next: specifier: ^26.0.3 version: 26.0.3(typescript@5.9.3) diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx index 41f56b057..4579926d8 100644 --- a/web/frontend/src/components/agent/skills/detail-sheet.tsx +++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx @@ -172,7 +172,7 @@ export function DetailSheet({
{detailView === "preview" ? ( -
+
{ + const root = document.documentElement + const styleElement = getOrCreateThemeStyleElement() + + const applyTheme = () => { + const nextThemeCss = root.classList.contains("dark") + ? githubDarkCss + : githubLightCss + styleElement.textContent = nextThemeCss + } + + applyTheme() + + const observer = new MutationObserver(() => { + applyTheme() + }) + + observer.observe(root, { + attributes: true, + attributeFilter: ["class"], + }) + + return () => { + observer.disconnect() + } + }, []) +} diff --git a/web/frontend/src/index.css b/web/frontend/src/index.css index 7958233f3..fc55a3a32 100644 --- a/web/frontend/src/index.css +++ b/web/frontend/src/index.css @@ -157,69 +157,6 @@ height: calc(100svh - 3.5rem); } -/* Markdown code highlighting (rehype-highlight / highlight.js classes) */ -.prose pre code.hljs, -.prose pre code[class*="language-"] { - display: block; - overflow-x: auto; - background: transparent; - padding: 0; - color: #e4e4e7; -} - -.prose pre code .hljs-comment, -.prose pre code .hljs-quote { - color: #71717a; -} - -.prose pre code .hljs-keyword, -.prose pre code .hljs-selector-tag, -.prose pre code .hljs-subst { - color: #f472b6; -} - -.prose pre code .hljs-string, -.prose pre code .hljs-doctag, -.prose pre code .hljs-regexp, -.prose pre code .hljs-addition, -.prose pre code .hljs-attribute, -.prose pre code .hljs-template-tag, -.prose pre code .hljs-template-variable { - color: #34d399; -} - -.prose pre code .hljs-number, -.prose pre code .hljs-literal, -.prose pre code .hljs-bullet, -.prose pre code .hljs-meta, -.prose pre code .hljs-built_in, -.prose pre code .hljs-builtin-name, -.prose pre code .hljs-symbol, -.prose pre code .hljs-variable, -.prose pre code .hljs-link, -.prose pre code .hljs-type, -.prose pre code .hljs-selector-class, -.prose pre code .hljs-selector-attr, -.prose pre code .hljs-selector-pseudo { - color: #22d3ee; -} - -.prose pre code .hljs-title, -.prose pre code .hljs-section, -.prose pre code .hljs-name, -.prose pre code .hljs-selector-id, -.prose pre code .hljs-deletion { - color: #60a5fa; -} - -.prose pre code .hljs-emphasis { - font-style: italic; -} - -.prose pre code .hljs-strong { - font-weight: 700; -} - /* Typing indicator animations */ @keyframes shimmer { 0% { diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx index 81e72c29f..17eb18291 100644 --- a/web/frontend/src/main.tsx +++ b/web/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router" import { StrictMode } from "react" import ReactDOM from "react-dom/client" +import { useHighlightTheme } from "./hooks/use-highlight-theme" import "./i18n" import "./index.css" import { routeTree } from "./routeTree.gen" @@ -22,14 +23,22 @@ declare module "@tanstack/react-router" { } } +function AppProviders() { + useHighlightTheme() + + return ( + + + + ) +} + const rootElement = document.getElementById("root")! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) root.render( - - - + , ) } From acbe65467483e5b40b8e16d25cdc24e03f3c6e31 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:36:22 +0800 Subject: [PATCH 3/4] chore(web): move app providers out of main entry --- web/frontend/src/app-providers.tsx | 13 +++++++++++++ web/frontend/src/main.tsx | 18 ++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 web/frontend/src/app-providers.tsx diff --git a/web/frontend/src/app-providers.tsx b/web/frontend/src/app-providers.tsx new file mode 100644 index 000000000..bfb5dfb38 --- /dev/null +++ b/web/frontend/src/app-providers.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react" + +import { useHighlightTheme } from "./hooks/use-highlight-theme" + +interface AppProvidersProps { + children: ReactNode +} + +export function AppProviders({ children }: AppProvidersProps) { + useHighlightTheme() + + return <>{children} +} diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx index 17eb18291..313daf62d 100644 --- a/web/frontend/src/main.tsx +++ b/web/frontend/src/main.tsx @@ -3,7 +3,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router" import { StrictMode } from "react" import ReactDOM from "react-dom/client" -import { useHighlightTheme } from "./hooks/use-highlight-theme" +import { AppProviders } from "./app-providers" import "./i18n" import "./index.css" import { routeTree } from "./routeTree.gen" @@ -23,22 +23,16 @@ declare module "@tanstack/react-router" { } } -function AppProviders() { - useHighlightTheme() - - return ( - - - - ) -} - const rootElement = document.getElementById("root")! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) root.render( - + + + + + , ) } From 5a2e7795cd5d855c97b4f7ab913e5c45f6024bb2 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:30:43 +0800 Subject: [PATCH 4/4] refactor(web): improve theme style element management in useHighlightTheme hook --- web/frontend/src/hooks/use-highlight-theme.ts | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/web/frontend/src/hooks/use-highlight-theme.ts b/web/frontend/src/hooks/use-highlight-theme.ts index 47b782679..1e4517c3f 100644 --- a/web/frontend/src/hooks/use-highlight-theme.ts +++ b/web/frontend/src/hooks/use-highlight-theme.ts @@ -4,14 +4,39 @@ import githubDarkCss from "highlight.js/styles/github-dark.css?inline" import githubLightCss from "highlight.js/styles/github.css?inline" const THEME_STYLE_ID = "hljs-theme-style" +const THEME_STYLE_OWNER_ATTR = "data-picoclaw-highlight-theme" +const THEME_STYLE_OWNER_VALUE = "true" +const MANAGED_THEME_STYLE_SELECTOR = `style[${THEME_STYLE_OWNER_ATTR}="${THEME_STYLE_OWNER_VALUE}"]` +const ID_THEME_STYLE_SELECTOR = `style#${THEME_STYLE_ID}` -function getOrCreateThemeStyleElement() { - let styleElement = document.getElementById(THEME_STYLE_ID) - if (!styleElement) { - styleElement = document.createElement("style") - styleElement.id = THEME_STYLE_ID - document.head.appendChild(styleElement) +function getOrCreateThemeStyleElement(): HTMLStyleElement { + const managedStyleElement = document.head.querySelector( + MANAGED_THEME_STYLE_SELECTOR, + ) + if (managedStyleElement) { + return managedStyleElement } + + const existingStyleElement = + document.querySelector(ID_THEME_STYLE_SELECTOR) + if (existingStyleElement) { + existingStyleElement.setAttribute( + THEME_STYLE_OWNER_ATTR, + THEME_STYLE_OWNER_VALUE, + ) + return existingStyleElement + } + + const conflictingElement = document.getElementById(THEME_STYLE_ID) + const styleElement = document.createElement("style") + if (!conflictingElement) { + styleElement.id = THEME_STYLE_ID + } + + // Leave conflicting non-style nodes untouched and track the injected style explicitly. + styleElement.setAttribute(THEME_STYLE_OWNER_ATTR, THEME_STYLE_OWNER_VALUE) + document.head.appendChild(styleElement) + return styleElement }