feat: render markdown in AI Chat assistant responses

Replace regex-based bold/newline rendering with react-markdown + remark-gfm
+ rehype-highlight for full markdown support: bold, code blocks with syntax
highlighting, bullet/ordered lists, headers, blockquotes, links, tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-03 20:33:11 +01:00
parent 3f2a1516bc
commit 49092eaa8a
6 changed files with 416 additions and 8 deletions

View File

@@ -55,7 +55,10 @@
"react": "^19.2.0",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-virtuoso": "^4.18.1",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"

197
pnpm-lock.yaml generated
View File

@@ -122,9 +122,18 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.4(react@19.2.4)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
react-virtuoso:
specifier: ^4.18.1
version: 4.18.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
tailwind-merge:
specifier: ^3.4.1
version: 3.4.1
@@ -1984,6 +1993,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -2019,6 +2031,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -2269,6 +2284,9 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -2507,6 +2525,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -2699,6 +2720,9 @@ packages:
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-mdast@10.1.2:
resolution: {integrity: sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==}
@@ -2732,6 +2756,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -2782,6 +2809,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
ip-address@10.0.1:
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
engines: {node: '>= 12'}
@@ -2790,6 +2820,15 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-extendable@0.1.1:
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
engines: {node: '>=0.10.0'}
@@ -2802,6 +2841,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
@@ -3063,6 +3105,15 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
mdast-util-mdx-jsx@3.2.0:
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -3247,6 +3298,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -3456,6 +3510,12 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-number-format@5.4.4:
resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==}
peerDependencies:
@@ -3519,6 +3579,9 @@ packages:
rehype-format@5.0.1:
resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==}
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
rehype-minify-whitespace@6.0.2:
resolution: {integrity: sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==}
@@ -3662,6 +3725,12 @@ packages:
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -5895,6 +5964,10 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
'@types/estree@1.0.8': {}
'@types/hast@3.0.4':
@@ -5930,6 +6003,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
@@ -6229,6 +6304,8 @@ snapshots:
character-entities@2.0.2: {}
character-reference-invalid@2.0.1: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -6478,6 +6555,8 @@ snapshots:
estraverse@5.3.0: {}
estree-util-is-identifier-name@3.0.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
@@ -6727,6 +6806,26 @@ snapshots:
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.1
mdast-util-mdx-jsx: 3.2.0
mdast-util-mdxjs-esm: 2.0.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.21
unist-util-position: 5.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
hast-util-to-mdast@10.1.2:
dependencies:
'@types/hast': 3.0.4
@@ -6781,6 +6880,8 @@ snapshots:
html-escaper@2.0.2: {}
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
html-whitespace-sensitive-tag-names@3.0.1: {}
@@ -6828,10 +6929,21 @@ snapshots:
inherits@2.0.4: {}
inline-style-parser@0.2.7: {}
ip-address@10.0.1: {}
ipaddr.js@1.9.1: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-decimal@2.0.1: {}
is-extendable@0.1.1: {}
is-extglob@2.1.1: {}
@@ -6840,6 +6952,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@2.0.1: {}
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
@@ -7133,6 +7247,45 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-jsx@3.2.0:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-stringify-position: 4.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
mdast-util-mdxjs-esm@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -7428,6 +7581,16 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
character-entities-legacy: 3.0.0
character-reference-invalid: 2.0.1
decode-named-character-reference: 1.3.0
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -7693,6 +7856,24 @@ snapshots:
react-is@17.0.2: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.14
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.4
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-number-format@5.4.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -7753,6 +7934,14 @@ snapshots:
'@types/hast': 3.0.4
hast-util-format: 1.1.0
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-minify-whitespace@6.0.2:
dependencies:
'@types/hast': 3.0.4
@@ -7966,6 +8155,14 @@ snapshots:
style-mod@4.1.3: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0

View File

@@ -9,6 +9,7 @@ import {
buildSystemPrompt,
} from '../utils/ai-chat'
import { useAIChat } from '../hooks/useAIChat'
import { MarkdownContent } from './MarkdownContent'
// --- Sub-components ---
@@ -101,10 +102,7 @@ function ContextSearchDropdown({
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
return (
<div>
<div style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{
__html: msg.content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br/>'),
}} />
<MarkdownContent content={msg.content} />
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
@@ -126,10 +124,7 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
function StreamingContent({ content }: { content: string }) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{
__html: content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br/>'),
}} />
<MarkdownContent content={content} />
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MarkdownContent } from './MarkdownContent'
describe('MarkdownContent', () => {
it('renders bold text', () => {
render(<MarkdownContent content="Hello **world**" />)
const strong = screen.getByText('world')
expect(strong.tagName).toBe('STRONG')
})
it('renders inline code', () => {
render(<MarkdownContent content="Use `console.log`" />)
const code = screen.getByText('console.log')
expect(code.tagName).toBe('CODE')
})
it('renders fenced code blocks', () => {
const { container } = render(<MarkdownContent content={'```js\nconst x = 1\n```'} />)
const pre = container.querySelector('pre')
expect(pre).toBeTruthy()
expect(pre!.textContent).toContain('const x = 1')
})
it('renders unordered lists', () => {
const { container } = render(<MarkdownContent content={'- one\n- two\n- three'} />)
const items = container.querySelectorAll('li')
expect(items).toHaveLength(3)
expect(items[0].textContent).toBe('one')
})
it('renders ordered lists', () => {
const { container } = render(<MarkdownContent content={'1. first\n2. second'} />)
const ol = container.querySelector('ol')
expect(ol).toBeTruthy()
expect(ol!.querySelectorAll('li')).toHaveLength(2)
})
it('renders headers', () => {
render(<MarkdownContent content="## Section Title" />)
const h2 = screen.getByText('Section Title')
expect(h2.tagName).toBe('H2')
})
it('renders links', () => {
render(<MarkdownContent content="[Click here](https://example.com)" />)
const link = screen.getByText('Click here') as HTMLAnchorElement
expect(link.tagName).toBe('A')
expect(link.getAttribute('href')).toBe('https://example.com')
})
it('renders mixed markdown', () => {
const { container } = render(<MarkdownContent content={'**Bold** and `code` and\n\n- item'} />)
expect(screen.getByText('Bold').tagName).toBe('STRONG')
expect(screen.getByText('code').tagName).toBe('CODE')
expect(container.querySelector('li')).toBeTruthy()
})
it('wraps content in .ai-markdown container', () => {
const { container } = render(<MarkdownContent content="Hello" />)
expect(container.querySelector('.ai-markdown')).toBeTruthy()
})
it('renders plain text without crashing', () => {
render(<MarkdownContent content="Just plain text" />)
expect(screen.getByText('Just plain text')).toBeTruthy()
})
it('renders blockquotes', () => {
const { container } = render(<MarkdownContent content="> A quote" />)
const bq = container.querySelector('blockquote')
expect(bq).toBeTruthy()
expect(bq!.textContent).toContain('A quote')
})
})

View File

@@ -0,0 +1,18 @@
import { memo, useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
const REMARK_PLUGINS = [remarkGfm]
const REHYPE_PLUGINS = [rehypeHighlight]
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
const rendered = useMemo(() => (
<div className="ai-markdown">
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
{content}
</Markdown>
</div>
), [content])
return rendered
})

View File

@@ -169,3 +169,123 @@
text-decoration: underline;
text-decoration-style: dotted;
}
/* --- AI Chat markdown rendering --- */
.ai-markdown {
font-size: 13px;
line-height: 1.6;
color: var(--foreground);
}
.ai-markdown > :first-child { margin-top: 0; }
.ai-markdown > :last-child { margin-bottom: 0; }
.ai-markdown p { margin: 0.5em 0; }
.ai-markdown strong { font-weight: 600; }
.ai-markdown h1,
.ai-markdown h2,
.ai-markdown h3,
.ai-markdown h4 {
font-weight: 600;
margin: 0.8em 0 0.4em;
line-height: 1.3;
}
.ai-markdown h1 { font-size: 1.25em; }
.ai-markdown h2 { font-size: 1.15em; }
.ai-markdown h3 { font-size: 1.05em; }
.ai-markdown h4 { font-size: 1em; }
.ai-markdown ul,
.ai-markdown ol {
margin: 0.4em 0;
padding-left: 1.5em;
}
.ai-markdown li { margin: 0.15em 0; }
.ai-markdown li > ul,
.ai-markdown li > ol { margin: 0.1em 0; }
.ai-markdown code {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.9em;
background: var(--muted);
border-radius: 3px;
padding: 0.15em 0.35em;
}
.ai-markdown pre {
margin: 0.5em 0;
border-radius: 6px;
background: var(--muted);
overflow-x: auto;
max-height: 400px;
}
.ai-markdown pre code {
display: block;
padding: 0.75em 1em;
background: none;
border-radius: 0;
font-size: 12px;
line-height: 1.5;
white-space: pre;
}
.ai-markdown blockquote {
margin: 0.5em 0;
padding: 0.25em 0.75em;
border-left: 3px solid var(--border);
color: var(--muted-foreground);
}
.ai-markdown a {
color: var(--link-color);
text-decoration: none;
}
.ai-markdown a:hover { text-decoration: underline; }
.ai-markdown hr {
border: none;
border-top: 1px solid var(--border);
margin: 0.75em 0;
}
.ai-markdown table {
border-collapse: collapse;
margin: 0.5em 0;
font-size: 12px;
width: 100%;
}
.ai-markdown th,
.ai-markdown td {
border: 1px solid var(--border);
padding: 0.35em 0.6em;
text-align: left;
}
.ai-markdown th {
background: var(--muted);
font-weight: 600;
}
/* --- highlight.js light theme (GitHub-style) --- */
.ai-markdown .hljs { color: var(--foreground); }
.ai-markdown .hljs-comment,
.ai-markdown .hljs-quote { color: #6a737d; font-style: italic; }
.ai-markdown .hljs-keyword,
.ai-markdown .hljs-selector-tag { color: #d73a49; }
.ai-markdown .hljs-string,
.ai-markdown .hljs-addition { color: #032f62; }
.ai-markdown .hljs-number,
.ai-markdown .hljs-literal { color: #005cc5; }
.ai-markdown .hljs-title,
.ai-markdown .hljs-section { color: #6f42c1; }
.ai-markdown .hljs-built_in,
.ai-markdown .hljs-type { color: #e36209; }
.ai-markdown .hljs-attr,
.ai-markdown .hljs-name,
.ai-markdown .hljs-attribute { color: #005cc5; }
.ai-markdown .hljs-deletion { color: #b31d28; background: #ffeef0; }
.ai-markdown .hljs-meta { color: #6a737d; }