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
}