refactor: replace chat markdown renderer

- Replace Streamdown-based chat markdown rendering with the new renderer path
- Preserve OpenChamber table, code block, and mermaid presentation
- Remove leftover Streamdown dependencies and compatibility hooks
This commit is contained in:
Bohdan Triapitsyn
2026-04-05 19:24:23 +03:00
parent 3bf9947388
commit dfc61d7671
8 changed files with 438 additions and 438 deletions
+8 -28
View File
@@ -146,7 +146,6 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@streamdown/code": "^1.0.2",
"@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13",
"beautiful-mermaid": "^1.1.3",
@@ -154,12 +153,15 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror-lang-elixir": "^4.0.0",
"dompurify": "^3.2.7",
"express": "^5.1.0",
"fuse.js": "^7.1.0",
"ghostty-web": "^0.4.0",
"heic2any": "^0.0.4",
"html-to-image": "^1.11.13",
"http-proxy-middleware": "^3.0.5",
"marked": "^17.0.3",
"morphdom": "^2.7.7",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
@@ -167,9 +169,9 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-syntax-highlighter": "^15.6.6",
"remend": "^1.2.1",
"simple-git": "^3.28.0",
"sonner": "^2.0.7",
"streamdown": "^2.2.0",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
"yaml": "^2.8.1",
@@ -1027,8 +1029,6 @@
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="],
"@streamdown/code": ["@streamdown/code@1.0.3", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3Ym5TCLcGhrHY2qBaUVWpqNRtxnZvqh4Y5Qm/pTIKA4AmEWwAAoYjZnxG7mOsvOpWVWiDwETjUtchNL1XzQEAw=="],
"@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="],
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
@@ -1461,6 +1461,8 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -1669,20 +1671,12 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
@@ -2073,6 +2067,8 @@
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.3", "", { "dependencies": { "framer-motion": "^12.34.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw=="],
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
@@ -2281,12 +2277,6 @@
"regjsparser": ["regjsparser@0.13.0", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q=="],
"rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
@@ -2417,8 +2407,6 @@
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"streamdown": ["streamdown@2.3.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.2.1", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-OqS3by/lt91lSicE8RQP2nTsYI6Q/dQgGP2vcyn9YesCmRHhNjswAuBAZA1z0F4+oBU3II/eV51LqjCqwTb1lw=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
@@ -2597,8 +2585,6 @@
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
@@ -2607,8 +2593,6 @@
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
@@ -2813,8 +2797,6 @@
"globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
"hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="],
@@ -2911,8 +2893,6 @@
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+4 -2
View File
@@ -52,7 +52,6 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@streamdown/code": "^1.0.2",
"@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13",
"beautiful-mermaid": "^1.1.3",
@@ -60,6 +59,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror-lang-elixir": "^4.0.0",
"dompurify": "^3.2.7",
"express": "^5.1.0",
"fuse.js": "^7.1.0",
"ghostty-web": "^0.4.0",
@@ -67,15 +67,17 @@
"html-to-image": "^1.11.13",
"http-proxy-middleware": "^3.0.5",
"motion": "^12.23.24",
"morphdom": "^2.7.7",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-syntax-highlighter": "^15.6.6",
"remend": "^1.2.1",
"simple-git": "^3.28.0",
"marked": "^17.0.3",
"sonner": "^2.0.7",
"streamdown": "^2.2.0",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
"yaml": "^2.8.1",
@@ -1,20 +1,22 @@
import React from 'react';
import { Streamdown } from 'streamdown';
import { createCodePlugin } from '@streamdown/code';
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import 'streamdown/styles.css';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { marked, type Tokens } from 'marked';
import remend from 'remend';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import type { ToolPopupContent } from './message/types';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
@@ -22,126 +24,6 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { EditorAPI } from '@/lib/api/types';
const withStableStringId = <T extends object>(value: T, id: string): T => {
const existingPrimitive = (value as Record<symbol, unknown>)[Symbol.toPrimitive];
if (typeof existingPrimitive === 'function') {
try {
if ((existingPrimitive as () => unknown)() === id) {
return value;
}
} catch {
// Ignore and attempt to define below.
}
}
try {
Object.defineProperty(value, 'toString', {
value: () => id,
enumerable: false,
configurable: true,
});
} catch {
// Ignore if non-configurable or frozen.
}
try {
Object.defineProperty(value, Symbol.toPrimitive, {
value: () => id,
enumerable: false,
configurable: true,
});
} catch {
// Ignore if non-configurable or frozen.
}
return value;
};
const useMarkdownShikiThemes = (): readonly [string | object, string | object] => {
const themeSystem = useOptionalThemeSystem();
const isVSCode = isVSCodeRuntime() && typeof window !== 'undefined';
const fallbackLight = getDefaultTheme(false);
const fallbackDark = getDefaultTheme(true);
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLight.metadata.id;
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDark.metadata.id;
const lightTheme =
themeSystem?.availableThemes.find((theme) => theme.metadata.id === lightThemeId) ??
fallbackLight;
const darkTheme =
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
fallbackDark;
const fallbackThemes = React.useMemo(
() => getStreamdownThemePair(lightTheme, darkTheme),
[darkTheme, lightTheme],
);
const getThemes = React.useCallback((): readonly [string | object, string | object] => {
if (!isVSCode) {
return fallbackThemes;
}
const provided = window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__;
if (provided?.light && provided?.dark) {
const light = withStableStringId(
{ ...(provided.light as Record<string, unknown>) },
`vscode-shiki-light:${String((provided.light as { name?: unknown })?.name ?? 'theme')}`,
);
const dark = withStableStringId(
{ ...(provided.dark as Record<string, unknown>) },
`vscode-shiki-dark:${String((provided.dark as { name?: unknown })?.name ?? 'theme')}`,
);
return [light, dark] as const;
}
return fallbackThemes;
}, [fallbackThemes, isVSCode]);
const [themes, setThemes] = React.useState(getThemes);
React.useEffect(() => {
if (!isVSCode) {
return;
}
setThemes(getThemes());
}, [getThemes, isVSCode]);
React.useEffect(() => {
if (!isVSCode) return;
const handler = (event: Event) => {
// Rely on the canonical `window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__` that the webview updates
// before dispatching this event, so we always apply stable cache keys and avoid stale token reuse.
void event;
setThemes(getThemes());
};
window.addEventListener('openchamber:vscode-shiki-themes', handler as EventListener);
return () => window.removeEventListener('openchamber:vscode-shiki-themes', handler as EventListener);
}, [getThemes, isVSCode]);
return isVSCode ? themes : fallbackThemes;
};
type StreamdownCodeThemes = NonNullable<Parameters<typeof createCodePlugin>[0]>['themes'];
const useStreamdownPlugins = (shikiThemes: readonly [string | object, string | object]) => {
return React.useMemo(
() => ({
code: createCodePlugin({
// Streamdown code plugin runtime accepts theme objects, but current type only models bundled theme names.
themes: shikiThemes as unknown as StreamdownCodeThemes,
}),
}),
[shikiThemes],
);
};
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
const fallbackLight = getDefaultTheme(false);
@@ -425,13 +307,13 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
const tableRef = React.useRef<HTMLDivElement>(null);
return (
<div className="group my-4 flex flex-col space-y-2" data-streamdown="table-wrapper" ref={tableRef}>
<div className="group my-4 flex flex-col space-y-2" data-markdown="table-wrapper" ref={tableRef}>
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<TableCopyButton tableRef={tableRef} />
<TableDownloadButton tableRef={tableRef} />
</div>
<div className="overflow-x-auto">
<table className={cn('w-full border-collapse border border-border', className)} data-streamdown="table">
<div className="overflow-x-auto rounded-lg border border-border/80 bg-[var(--surface-elevated)]">
<table className={cn('w-full border-collapse text-sm', className)} data-markdown="table">
{children}
</table>
</div>
@@ -439,27 +321,6 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
);
};
type CodeBlockWrapperProps = React.HTMLAttributes<HTMLPreElement> & {
children?: React.ReactNode;
};
const getMermaidInfo = (children: React.ReactNode): { isMermaid: boolean; source: string } => {
if (!React.isValidElement(children)) return { isMermaid: false, source: '' };
const props = children.props as Record<string, unknown> | undefined;
const className = typeof props?.className === 'string' ? props.className : '';
if (!className.includes('language-mermaid')) return { isMermaid: false, source: '' };
// Extract raw mermaid source from the code element's children
const codeChildren = props?.children;
let source = '';
if (typeof codeChildren === 'string') {
source = codeChildren;
} else if (React.isValidElement(codeChildren)) {
const innerProps = codeChildren.props as Record<string, unknown> | undefined;
if (typeof innerProps?.children === 'string') source = innerProps.children;
}
return { isMermaid: true, source };
};
const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
const currentTheme = useCurrentMermaidTheme();
const { isMobile } = useDeviceInfo();
@@ -537,9 +398,9 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
const asciiText = ascii || source;
return (
<div data-streamdown="mermaid-block" className="group">
<div data-streamdown="mermaid-scroll">
<pre data-streamdown="mermaid-ascii">{asciiText}</pre>
<div data-markdown="mermaid-block" className="group">
<div data-markdown="mermaid-scroll">
<pre data-markdown="mermaid-ascii">{asciiText}</pre>
</div>
<div
className={cn(
@@ -561,9 +422,9 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
if (!svg) {
return (
<div data-streamdown="mermaid-block" className="group">
<div data-streamdown="mermaid-scroll">
<pre data-streamdown="mermaid-ascii">{source}</pre>
<div data-markdown="mermaid-block" className="group">
<div data-markdown="mermaid-scroll">
<pre data-markdown="mermaid-ascii">{source}</pre>
</div>
<div
className={cn(
@@ -584,9 +445,9 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
}
return (
<div data-streamdown="mermaid-block" className="group">
<div data-streamdown="mermaid-scroll">
<div data-streamdown="mermaid" dangerouslySetInnerHTML={{ __html: svg }} />
<div data-markdown="mermaid-block" className="group">
<div data-markdown="mermaid-scroll">
<div data-markdown="mermaid" dangerouslySetInnerHTML={{ __html: svg }} />
</div>
<div
className={cn(
@@ -613,85 +474,6 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
);
};
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
const mermaidInfo = getMermaidInfo(children);
const mermaidRenderingMode = useUIStore((state) => state.mermaidRenderingMode);
const codeChild = React.useMemo(
() => (
React.isValidElement(children)
? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, { 'data-block': true })
: children
),
[children],
);
const normalizedStyle = React.useMemo<React.CSSProperties | undefined>(() => {
if (!style) return style;
const next: React.CSSProperties = { ...style };
const normalizeDeclarationString = (
raw: unknown
): { value?: string; vars: Record<string, string> } => {
if (typeof raw !== 'string') return { value: undefined, vars: {} };
const [valuePart, ...rest] = raw.split(';').map((p) => p.trim()).filter(Boolean);
const vars: Record<string, string> = {};
for (const decl of rest) {
const idx = decl.indexOf(':');
if (idx === -1) continue;
const prop = decl.slice(0, idx).trim();
const value = decl.slice(idx + 1).trim();
if (!prop.startsWith('--') || value.length === 0) continue;
vars[prop] = value;
}
return { value: valuePart, vars };
};
const bg = normalizeDeclarationString((style as React.CSSProperties).backgroundColor);
if (bg.value) {
next.backgroundColor = bg.value;
}
for (const [k, v] of Object.entries(bg.vars)) {
(next as Record<string, string>)[k] = v;
}
const fg = normalizeDeclarationString((style as React.CSSProperties).color);
if (fg.value) {
next.color = fg.value;
}
for (const [k, v] of Object.entries(fg.vars)) {
(next as Record<string, string>)[k] = v;
}
return next;
}, [style]);
if (mermaidInfo.isMermaid) {
return <MermaidBlock source={mermaidInfo.source} mode={mermaidRenderingMode} />;
}
return (
<pre
{...props}
className={cn(className, 'w-full min-w-full')}
style={normalizedStyle}
>
{codeChild}
</pre>
);
};
const streamdownComponents = {
pre: CodeBlockWrapper,
table: TableWrapper,
};
const streamdownControls = {
code: true,
table: false,
};
type MermaidControlOptions = {
download: boolean;
copy: boolean;
@@ -727,6 +509,293 @@ const stripLeadingFrontmatter = (markdown: string): string => {
export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning';
type MarkdownStreamBlock = {
key: string;
raw: string;
src: string;
mode: 'full' | 'live';
};
const hasReferenceDefinitions = (text: string): boolean => {
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text);
};
const hasOpenFence = (raw: string): boolean => {
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/);
if (!match) return false;
const marker = match[1];
if (!marker) return false;
const char = marker[0];
const size = marker.length;
const last = raw.trimEnd().split('\n').at(-1)?.trim() ?? '';
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
};
const healMarkdown = (text: string): string => {
return remend(text, { linkMode: 'text-only' });
};
const fnv1a32 = (input: string): string => {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
}
return hash.toString(16);
};
const buildMarkdownCacheKey = (baseKey: string, raw: string, index: number, mode: 'full' | 'live'): string => {
const sample = raw.length > 400 ? `${raw.slice(0, 200)}${raw.slice(-200)}` : raw;
return `${baseKey}:${index}:${mode}:${raw.length}:${fnv1a32(sample)}`;
};
const streamMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => {
if (!live) {
return [{
key: buildMarkdownCacheKey(baseKey, text, 0, 'full'),
raw: text,
src: text,
mode: 'full',
}];
}
const healed = healMarkdown(text);
if (hasReferenceDefinitions(text)) {
return [{
key: buildMarkdownCacheKey(baseKey, text, 0, 'live'),
raw: text,
src: healed,
mode: 'live',
}];
}
const tokens = marked.lexer(text);
const blocks: MarkdownStreamBlock[] = [];
let blockIndex = 0;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index] as Tokens.Generic;
if (token.type === 'space') {
continue;
}
const raw = token.raw ?? '';
const isLast = index === tokens.length - 1 || tokens.slice(index + 1).every((nextToken) => nextToken.type === 'space');
const mode: 'full' | 'live' = isLast ? 'live' : 'full';
const src = isLast && token.type === 'code' && hasOpenFence(raw)
? raw
: healMarkdown(raw);
blocks.push({
key: buildMarkdownCacheKey(baseKey, raw, blockIndex, mode),
raw,
src,
mode,
});
blockIndex += 1;
}
if (blocks.length === 0) {
return [{
key: buildMarkdownCacheKey(baseKey, text, 0, 'live'),
raw: text,
src: healed,
mode: 'live',
}];
}
return blocks;
};
const useStableMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => {
const previousRef = React.useRef<MarkdownStreamBlock[]>([]);
return React.useMemo(() => {
const nextBlocks = streamMarkdownBlocks(text, live, baseKey);
const previousBlocks = previousRef.current;
const stabilized = nextBlocks.map((block, index) => {
const previous = previousBlocks[index];
if (previous && previous.key === block.key && previous.src === block.src) {
return previous;
}
return block;
});
const unchanged = stabilized.length === previousBlocks.length
&& stabilized.every((block, index) => block === previousBlocks[index]);
if (unchanged) {
return previousBlocks;
}
previousRef.current = stabilized;
return stabilized;
}, [baseKey, live, text]);
};
const extractCodeText = (children: React.ReactNode): string => {
if (typeof children === 'string') return children;
if (Array.isArray(children)) {
return children.map((child) => extractCodeText(child)).join('');
}
if (React.isValidElement(children)) {
return extractCodeText((children.props as { children?: React.ReactNode }).children);
}
return '';
};
const getCodeLanguage = (className: string | undefined): string => {
const match = className?.match(/language-([\w-]+)/);
return match?.[1]?.toLowerCase() ?? 'text';
};
const MarkdownCodeBlock: React.FC<{
code: string;
language: string;
syntaxTheme: { [key: string]: React.CSSProperties };
}> = ({ code, language, syntaxTheme }) => {
const [copied, setCopied] = React.useState(false);
const handleCopy = React.useCallback(async () => {
const result = await copyTextToClipboard(code);
if (!result.ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}, [code]);
return (
<div data-component="markdown-code" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
<span className="font-mono text-[13px] text-muted-foreground">{language}</span>
<div className="opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={() => { void handleCopy(); }}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={copied ? 'Copied' : 'Copy code'}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
</div>
</div>
<div className="px-3 py-2.5">
<SyntaxHighlighter
language={language}
style={syntaxTheme}
customStyle={{ margin: 0, background: 'transparent', padding: 0, fontSize: 'var(--text-code)', lineHeight: 'var(--markdown-code-block-line-height)' }}
codeTagProps={{ style: { fontSize: 'var(--text-code)', lineHeight: 'var(--markdown-code-block-line-height)' } }}
PreTag="pre"
>
{code}
</SyntaxHighlighter>
</div>
</div>
);
};
const buildMarkdownComponents = ({
syntaxTheme,
}: {
syntaxTheme: { [key: string]: React.CSSProperties };
}): Components => ({
table({ children, ...props }) {
return <TableWrapper className={props.className}>{children}</TableWrapper>;
},
h1({ children, ...props }) {
return <h1 {...props} className={cn('typography-markdown-h1 mt-4 mb-2 text-[var(--markdown-heading1,var(--primary))] font-semibold', props.className)}>{children}</h1>;
},
h2({ children, ...props }) {
return <h2 {...props} className={cn('typography-markdown-h2 mt-3.5 mb-1.5 text-[var(--markdown-heading2,var(--primary))] font-semibold', props.className)}>{children}</h2>;
},
h3({ children, ...props }) {
return <h3 {...props} className={cn('typography-markdown-h3 mt-3 mb-1 text-[var(--markdown-heading3,var(--primary))] font-semibold', props.className)}>{children}</h3>;
},
h4({ children, ...props }) {
return <h4 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h4>;
},
h5({ children, ...props }) {
return <h5 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h5>;
},
h6({ children, ...props }) {
return <h6 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h6>;
},
p({ children, ...props }) {
return <p {...props} className={cn('typography-markdown-body my-2 text-foreground/90', props.className)}>{children}</p>;
},
thead({ children, ...props }) {
return <thead {...props} className={cn('[&_tr]:border-b [&_tr]:border-border/80', props.className)}>{children}</thead>;
},
tbody({ children, ...props }) {
return <tbody {...props} className={cn('[&_tr:last-child]:border-0', props.className)}>{children}</tbody>;
},
tr({ children, ...props }) {
return <tr {...props} className={cn('border-b border-border/60', props.className)}>{children}</tr>;
},
th({ children, ...props }) {
return <th {...props} className={cn('border-r border-border/60 px-4 py-2.5 text-left align-middle font-semibold text-foreground last:border-r-0', props.className)}>{children}</th>;
},
td({ children, ...props }) {
return <td {...props} className={cn('border-r border-border/60 px-4 py-2.5 align-middle text-foreground/90 last:border-r-0', props.className)}>{children}</td>;
},
ul({ children, ...props }) {
return <ul {...props} className={cn('typography-markdown-body my-2 pl-6', props.className)}>{children}</ul>;
},
ol({ children, ...props }) {
return <ol {...props} className={cn('typography-markdown-body my-2 pl-6', props.className)}>{children}</ol>;
},
li({ children, ...props }) {
return <li {...props} className={cn('typography-markdown-body my-0.5 text-foreground/90', props.className)}>{children}</li>;
},
blockquote({ children, ...props }) {
return <blockquote {...props} className={cn('my-3 border-l-2 border-[var(--markdown-blockquote-border,var(--border))] pl-4 typography-markdown-body text-[var(--markdown-blockquote,var(--muted-foreground))]', props.className)}>{children}</blockquote>;
},
pre({ children, ...props }) {
const child = React.Children.only(children) as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
const className = child.props.className;
const code = extractCodeText(child.props.children).replace(/\n$/, '');
const language = getCodeLanguage(className);
if (language === 'mermaid') {
return <MermaidBlock source={code} mode={useUIStore.getState().mermaidRenderingMode} />;
}
return <MarkdownCodeBlock code={code} language={language} syntaxTheme={syntaxTheme} {...props} />;
},
code({ className, children, ...props }) {
return (
<code
{...props}
className={cn('rounded bg-[var(--surface-elevated)] px-1 py-0.5 font-mono text-[0.95em]', className)}
data-markdown="inline-code"
>
{children}
</code>
);
},
a({ href, children, ...props }) {
return (
<a
{...props}
href={href}
target={isExternalHttpUrl(href ?? '') ? '_blank' : undefined}
rel={isExternalHttpUrl(href ?? '') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
);
},
});
const MarkdownBlockView: React.FC<{
block: MarkdownStreamBlock;
components: Components;
}> = React.memo(({ block, components }) => {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{block.src}
</ReactMarkdown>
);
}, (prev, next) => prev.block === next.block && prev.components === next.components);
MarkdownBlockView.displayName = 'MarkdownBlockView';
interface MarkdownRendererProps {
content: string;
part?: Part;
@@ -740,7 +809,7 @@ interface MarkdownRendererProps {
onShowPopup?: (content: ToolPopupContent) => void;
}
const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]';
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
type ParsedFileReference = {
@@ -1039,7 +1108,7 @@ const useFileReferenceInteractions = ({
}
const annotateFileLinks = () => {
const candidates = container.querySelectorAll<HTMLElement>('[data-streamdown="inline-code"], a');
const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a');
for (const candidate of Array.from(candidates)) {
const rawCandidate = extractPathCandidateFromElement(candidate);
@@ -1279,49 +1348,37 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
variant = 'assistant',
onShowPopup,
}) => {
const currentTheme = useCurrentMermaidTheme();
const { editor, runtime } = useRuntimeAPIs();
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup });
useFileReferenceInteractions({
containerRef: streamdownContainerRef,
containerRef,
effectiveDirectory,
editor,
preferRuntimeEditor: runtime.isVSCode,
});
useExternalLinkInteractions({ containerRef: streamdownContainerRef });
const shikiThemes = useMarkdownShikiThemes();
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
const currentMermaidTheme = useCurrentMermaidTheme();
useExternalLinkInteractions({ containerRef });
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]);
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
const markdownBlocks = useStableMarkdownBlocks(content, isStreaming && !disableStreamAnimation, componentKey);
const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool'
const markdownClassName = variant === 'tool'
? 'markdown-content markdown-tool'
: variant === 'reasoning'
? 'streamdown-content streamdown-reasoning'
: 'streamdown-content';
const streamdownAnimated = undefined;
? 'markdown-content markdown-reasoning'
: 'markdown-content leading-relaxed';
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
<Streamdown
key={`streamdown-${componentKey}-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
mode={isStreaming && !disableStreamAnimation ? 'streaming' : 'static'}
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={streamdownControls}
plugins={streamdownPlugins}
components={streamdownComponents}
animated={disableStreamAnimation ? undefined : streamdownAnimated}
isAnimating={disableStreamAnimation ? false : isStreaming}
// @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={{ enabled: false }}
>
{content}
</Streamdown>
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
<div className={markdownClassName}>
{markdownBlocks.map((block) => (
<MarkdownBlockView key={block.key} block={block} components={markdownComponents} />
))}
</div>
</div>
);
@@ -1359,48 +1416,40 @@ export const SimpleMarkdownRenderer: React.FC<{
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
[content, stripFrontmatter],
);
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
const currentTheme = useCurrentMermaidTheme();
const containerRef = React.useRef<HTMLDivElement>(null);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
useMermaidInlineInteractions({
containerRef: streamdownContainerRef,
containerRef,
mermaidBlocks,
onShowPopup,
allowWheelZoom: allowMermaidWheelZoom,
});
useFileReferenceInteractions({
containerRef: streamdownContainerRef,
containerRef,
effectiveDirectory,
editor,
preferRuntimeEditor: runtime.isVSCode,
});
useExternalLinkInteractions({ containerRef: streamdownContainerRef, enabled: !disableLinkSafety });
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]);
const markdownBlocks = useStableMarkdownBlocks(renderedContent, false, `simple:${variant}`);
const shikiThemes = useMarkdownShikiThemes();
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
const currentMermaidTheme = useCurrentMermaidTheme();
const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool'
const markdownClassName = variant === 'tool'
? 'markdown-content markdown-tool'
: variant === 'reasoning'
? 'streamdown-content streamdown-reasoning'
: 'streamdown-content';
? 'markdown-content markdown-reasoning'
: 'markdown-content leading-relaxed';
return (
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
<Streamdown
key={`streamdown-simple-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
mode="static"
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={streamdownControls}
plugins={streamdownPlugins}
components={streamdownComponents}
// @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={{ enabled: false }}
>
{renderedContent}
</Streamdown>
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
<div className={markdownClassName}>
{markdownBlocks.map((block) => (
<MarkdownBlockView key={block.key} block={block} components={markdownComponents} />
))}
</div>
</div>
);
};
@@ -962,7 +962,7 @@ const MermaidPreviewDialog: React.FC<{
content={mermaidMarkdown}
variant="tool"
allowMermaidWheelZoom
className="streamdown-mermaid-fullscreen h-full [&_[data-streamdown='mermaid-block']_button]:hidden"
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
mermaidControls={{
download: false,
copy: false,
+79 -70
View File
@@ -870,61 +870,70 @@ html:not(.dark) .chat-scroll {
}
/* Text font: IBM Plex Sans */
.streamdown-content {
.markdown-content {
font-family: var(--font-sans);
font-size: var(--text-markdown);
line-height: inherit;
}
.markdown-content p,
.markdown-content li,
.markdown-content blockquote,
.markdown-content td,
.markdown-content th {
line-height: inherit;
}
/* Tool markdown should render at code size to match tool card typography. */
.streamdown-content.streamdown-tool {
.markdown-content.markdown-tool {
font-size: var(--text-code) !important;
}
/* Reasoning markdown renders at meta size, dimmed. */
.streamdown-content.streamdown-reasoning {
.markdown-content.markdown-reasoning {
font-size: var(--text-meta);
color: var(--muted-foreground);
}
.streamdown-content.streamdown-reasoning strong,
.streamdown-content.streamdown-reasoning b,
.streamdown-content.streamdown-reasoning [data-streamdown="strong"],
.streamdown-content.streamdown-reasoning [data-streamdown="bold"] {
.markdown-content.markdown-reasoning strong,
.markdown-content.markdown-reasoning b,
.markdown-content.markdown-reasoning [data-markdown="strong"],
.markdown-content.markdown-reasoning [data-markdown="bold"] {
font-weight: inherit !important;
color: inherit !important;
font-style: inherit !important;
}
.streamdown-content.streamdown-tool code,
.streamdown-content.streamdown-tool pre {
.markdown-content.markdown-tool code,
.markdown-content.markdown-tool pre {
font-size: inherit !important;
}
/* Streamdown headers: keep proportional but not oversized */
.streamdown-content h1 {
.markdown-content h1 {
font-size: 1.125em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
}
.streamdown-content h2 {
.markdown-content h2 {
font-size: 1.0625em;
font-weight: 600;
margin-top: 0.875em;
margin-bottom: 0.375em;
}
.streamdown-content h3 {
.markdown-content h3 {
font-size: 1em;
font-weight: 600;
margin-top: 0.75em;
margin-bottom: 0.25em;
}
.streamdown-content h4,
.streamdown-content h5,
.streamdown-content h6 {
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
font-size: 1em;
font-weight: 600;
margin-top: 0.625em;
@@ -932,14 +941,14 @@ html:not(.dark) .chat-scroll {
}
/* Code font: IBM Plex Mono */
.streamdown-content code,
.streamdown-content pre {
.markdown-content code,
.markdown-content pre {
font-family: var(--font-mono);
font-size: var(--text-code);
}
/* Override Streamdown's hardcoded bg-muted for inline code - use theme colors instead */
.streamdown-content code[data-streamdown="inline-code"] {
.markdown-content code[data-markdown="inline-code"] {
background-color: var(--markdown-inline-code-bg, var(--surface-muted)) !important;
color: var(--markdown-inline-code, var(--foreground)) !important;
word-break: break-all;
@@ -947,123 +956,123 @@ html:not(.dark) .chat-scroll {
}
/* Markdown headings - use theme colors */
.streamdown-content h1 {
.markdown-content h1 {
color: var(--markdown-heading1, var(--primary));
}
.streamdown-content h2 {
.markdown-content h2 {
color: var(--markdown-heading2, var(--primary));
}
.streamdown-content h3 {
.markdown-content h3 {
color: var(--markdown-heading3, var(--primary));
}
.streamdown-content h4,
.streamdown-content h5,
.streamdown-content h6 {
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
color: var(--markdown-heading4, var(--foreground));
}
/* Markdown links - use theme colors */
.streamdown-content a {
.markdown-content a {
color: var(--markdown-link, var(--primary));
}
.streamdown-content a:hover {
.markdown-content a:hover {
color: var(--markdown-link-hover, var(--primary));
}
.streamdown-content [data-openchamber-file-link="true"] {
.markdown-content [data-openchamber-file-link="true"] {
color: var(--markdown-link, var(--primary));
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.streamdown-content [data-openchamber-file-link="true"]:hover {
.markdown-content [data-openchamber-file-link="true"]:hover {
color: var(--markdown-link-hover, var(--primary));
}
/* Markdown blockquote - use theme colors */
.streamdown-content blockquote {
.markdown-content blockquote {
color: var(--markdown-blockquote, var(--muted-foreground));
border-left-color: var(--markdown-blockquote-border, var(--border));
}
/* Markdown horizontal rule - use theme colors */
.streamdown-content hr {
.markdown-content hr {
border-color: var(--markdown-hr, var(--border));
}
/* Markdown bold/italic/strikethrough - use theme colors */
.streamdown-content strong {
.markdown-content strong {
color: var(--markdown-bold, var(--foreground));
}
.streamdown-content em {
.markdown-content em {
color: var(--markdown-italic, var(--foreground));
}
.streamdown-content del {
.markdown-content del {
color: var(--markdown-strikethrough, var(--muted-foreground));
}
/* Streamdown code blocks: ensure light/dark Shiki vars work even when Tailwind doesn't scan Streamdown's internal class names. */
.streamdown-content [data-streamdown="code-block-body"] {
.markdown-content [data-markdown="code-block-body"] {
font-size: var(--text-code);
background-color: transparent;
color: var(--shiki-light, inherit);
}
.streamdown-content [data-streamdown="code-block-body"] span[style*="--sdm-c"] {
.markdown-content [data-markdown="code-block-body"] span[style*="--sdm-c"] {
color: var(--sdm-c) !important;
}
.streamdown-content [data-streamdown="code-block-body"] span[style*="--sdm-tbg"] {
.markdown-content [data-markdown="code-block-body"] span[style*="--sdm-tbg"] {
background-color: var(--sdm-tbg) !important;
}
.streamdown-content [data-streamdown="code-block-body"] span[style*="--shiki-light"] {
.markdown-content [data-markdown="code-block-body"] span[style*="--shiki-light"] {
color: var(--shiki-light) !important;
}
.streamdown-content [data-streamdown="code-block-body"] span[style*="--shiki-light-bg"] {
.markdown-content [data-markdown="code-block-body"] span[style*="--shiki-light-bg"] {
background-color: var(--shiki-light-bg) !important;
}
.dark .streamdown-content [data-streamdown="code-block-body"] {
.dark .markdown-content [data-markdown="code-block-body"] {
background-color: transparent;
color: var(--shiki-dark, var(--shiki-light, inherit));
}
.dark .streamdown-content [data-streamdown="code-block-body"] span[style*="--shiki-dark"] {
.dark .markdown-content [data-markdown="code-block-body"] span[style*="--shiki-dark"] {
color: var(--shiki-dark) !important;
}
.dark .streamdown-content [data-streamdown="code-block-body"] span[style*="--shiki-dark-bg"] {
.dark .markdown-content [data-markdown="code-block-body"] span[style*="--shiki-dark-bg"] {
background-color: var(--shiki-dark-bg) !important;
}
/* Fix list styling - override Tailwind reset */
.streamdown-content ul {
.markdown-content ul {
list-style-type: disc;
list-style-position: outside;
padding-left: 1.5em;
}
.streamdown-content ol {
.markdown-content ol {
list-style-type: decimal;
list-style-position: outside;
padding-left: 1.5em;
}
.streamdown-content li {
.markdown-content li {
display: list-item;
}
/* Reduce code block header height */
[data-streamdown="code-block-header"] {
[data-markdown="code-block-header"] {
margin: 0;
padding: 0.375rem 0.75rem;
display: flex;
@@ -1078,7 +1087,7 @@ html:not(.dark) .chat-scroll {
}
/* Disable Streamdown's code block virtualization and keep block chrome fixed */
.streamdown-content [data-streamdown="code-block"] {
.markdown-content [data-markdown="code-block"] {
content-visibility: visible !important;
contain-intrinsic-size: none !important;
position: relative;
@@ -1095,7 +1104,7 @@ html:not(.dark) .chat-scroll {
border-radius: 0.75rem;
}
.streamdown-content [data-streamdown="code-block"] > div:not([data-streamdown]) {
.markdown-content [data-markdown="code-block"] > div:not([data-markdown]) {
position: absolute;
top: 0;
right: 0;
@@ -1106,7 +1115,7 @@ html:not(.dark) .chat-scroll {
}
/* Streamdown code block body: scroll text content within the card body */
.streamdown-content [data-streamdown="code-block-body"] {
.markdown-content [data-markdown="code-block-body"] {
display: block;
inline-size: 100%;
min-width: 0;
@@ -1122,7 +1131,7 @@ html:not(.dark) .chat-scroll {
border-radius: 0;
}
.streamdown-content [data-streamdown="code-block-body"] pre {
.markdown-content [data-markdown="code-block-body"] pre {
display: block;
width: 100%;
min-width: 100%;
@@ -1135,21 +1144,21 @@ html:not(.dark) .chat-scroll {
background: transparent !important;
}
.streamdown-content [data-streamdown="code-block-body"] > * {
.markdown-content [data-markdown="code-block-body"] > * {
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
.streamdown-content [data-streamdown="code-block-body"] code {
.markdown-content [data-markdown="code-block-body"] code {
white-space: pre;
overflow-wrap: normal;
word-break: normal;
background: transparent !important;
}
.streamdown-content [data-streamdown="code-block-actions"] {
.markdown-content [data-markdown="code-block-actions"] {
pointer-events: auto;
transition: opacity 0.15s ease;
border: 0 !important;
@@ -1158,7 +1167,7 @@ html:not(.dark) .chat-scroll {
gap: 0.125rem;
}
.streamdown-content [data-streamdown="code-block-actions"] button {
.markdown-content [data-markdown="code-block-actions"] button {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
@@ -1167,26 +1176,26 @@ html:not(.dark) .chat-scroll {
min-height: 1.5rem;
}
.streamdown-content [data-streamdown="code-block-actions"] button svg {
.markdown-content [data-markdown="code-block-actions"] button svg {
width: 0.875rem;
height: 0.875rem;
}
@media (hover: hover) and (pointer: fine) {
.streamdown-content [data-streamdown="code-block"] [data-streamdown="code-block-actions"] {
.markdown-content [data-markdown="code-block"] [data-markdown="code-block-actions"] {
opacity: 0;
pointer-events: none;
}
.streamdown-content [data-streamdown="code-block"]:hover [data-streamdown="code-block-actions"],
.streamdown-content [data-streamdown="code-block"]:focus-within [data-streamdown="code-block-actions"] {
.markdown-content [data-markdown="code-block"]:hover [data-markdown="code-block-actions"],
.markdown-content [data-markdown="code-block"]:focus-within [data-markdown="code-block-actions"] {
opacity: 1;
pointer-events: auto;
}
}
/* Mermaid block: position context for controls wrapper. */
[data-streamdown="mermaid-block"] {
[data-markdown="mermaid-block"] {
position: relative;
border-radius: 0.75rem;
border: 1px solid var(--interactive-border);
@@ -1194,17 +1203,17 @@ html:not(.dark) .chat-scroll {
padding: 0.5rem;
}
[data-streamdown="mermaid-block"] [data-streamdown="mermaid-scroll"] {
[data-markdown="mermaid-block"] [data-markdown="mermaid-scroll"] {
overflow: auto;
}
[data-streamdown="mermaid-block"] [data-streamdown="mermaid"] {
[data-markdown="mermaid-block"] [data-markdown="mermaid"] {
width: max-content;
min-width: 100%;
display: block;
}
[data-streamdown="mermaid-block"] [data-streamdown="mermaid-ascii"] {
[data-markdown="mermaid-block"] [data-markdown="mermaid-ascii"] {
margin: 0;
white-space: pre;
width: max-content;
@@ -1214,30 +1223,30 @@ html:not(.dark) .chat-scroll {
background: transparent;
}
[data-streamdown="mermaid-block"] [data-streamdown="mermaid"] svg {
[data-markdown="mermaid-block"] [data-markdown="mermaid"] svg {
width: auto !important;
min-width: 100%;
max-width: none !important;
height: auto !important;
}
[data-streamdown="mermaid-block"] svg {
[data-markdown="mermaid-block"] svg {
display: block;
background: var(--surface-elevated);
}
/* Fullscreen Mermaid popup: scale diagrams to fill available viewport panel area. */
.streamdown-mermaid-fullscreen {
.markdown-mermaid-fullscreen {
height: 100%;
width: 100%;
}
.streamdown-mermaid-fullscreen .streamdown-content {
.markdown-mermaid-fullscreen .markdown-content {
height: 100%;
width: 100%;
}
.streamdown-mermaid-fullscreen [data-streamdown="mermaid-block"] {
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] {
width: 100%;
height: 100%;
min-height: 100%;
@@ -1247,20 +1256,20 @@ html:not(.dark) .chat-scroll {
background: var(--surface-elevated);
}
.streamdown-mermaid-fullscreen [data-streamdown="mermaid-block"] [data-streamdown="mermaid-scroll"] {
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] [data-markdown="mermaid-scroll"] {
width: 100%;
height: 100%;
overflow: auto;
}
.streamdown-mermaid-fullscreen [data-streamdown="mermaid-block"] [data-streamdown="mermaid"] {
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] [data-markdown="mermaid"] {
width: max-content;
min-width: 100%;
min-height: 100%;
display: block;
}
.streamdown-mermaid-fullscreen [data-streamdown="mermaid-block"] [data-streamdown="mermaid-ascii"] {
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] [data-markdown="mermaid-ascii"] {
margin: 0;
white-space: pre;
width: max-content;
@@ -1271,7 +1280,7 @@ html:not(.dark) .chat-scroll {
background: transparent;
}
.streamdown-mermaid-fullscreen [data-streamdown="mermaid-block"] svg {
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] svg {
width: auto !important;
height: auto !important;
max-width: none !important;
@@ -89,7 +89,3 @@ export const ensurePierreThemeRegistered = (theme: Theme): void => {
registerCustomTheme(id, async () => resolved);
registeredPierreThemes.add(id);
};
export const getStreamdownThemePair = (light: Theme, dark: Theme) => {
return [getResolvedShikiTheme(light), getResolvedShikiTheme(dark)] as const;
};
+2 -2
View File
@@ -150,8 +150,8 @@
-webkit-overflow-scrolling: touch;
}
/* Keep Streamdown code blocks horizontally scrollable on mobile. */
:root.mobile-pointer:not(.desktop-runtime) .streamdown-content [data-streamdown="code-block-body"] {
/* Keep markdown code blocks horizontally scrollable on mobile. */
:root.mobile-pointer:not(.desktop-runtime) .markdown-content [data-markdown="code-block-body"] {
overflow-x: auto !important;
overflow-y: hidden !important;
-webkit-overflow-scrolling: touch;
-36
View File
@@ -1,36 +0,0 @@
declare module 'streamdown' {
import type { FC, ReactNode, ComponentType } from 'react';
export interface StreamdownProps {
children: string;
mode?: 'streaming' | 'static';
isAnimating?: boolean;
animated?: {
animation?: string;
duration?: number;
easing?: string;
sep?: 'word' | 'char';
};
className?: string;
shikiTheme?: readonly [string | object, string | object];
controls?: boolean | {
code?: boolean;
table?: boolean;
mermaid?: boolean | {
download?: boolean;
copy?: boolean;
fullscreen?: boolean;
panZoom?: boolean;
};
};
plugins?: Record<string, unknown>;
mermaid?: Record<string, unknown>;
components?: {
[key: string]: ComponentType<{ children?: ReactNode; className?: string; [key: string]: unknown }>;
};
}
export const Streamdown: FC<StreamdownProps>;
}