From 2c268cabbb1e2f9d7ed9ed2666371eba0bf5e77b Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 16:47:27 +0000 Subject: [PATCH 1/9] test: add jsdom render-test harness Adds jsdom + @testing-library/react + jest-dom and a vitest.config.ts with a jsdom environment so component render tests can assert DOM behavior. Excludes stale .worktrees/ duplicate suites. Existing pure-logic suites stay green. --- package-lock.json | 751 ++++++++++++++++++++++++++++++++++++++++ package.json | 4 + src/test/setup-dom.ts | 10 + src/test/smoke.test.tsx | 10 + vitest.config.ts | 25 ++ 5 files changed, 800 insertions(+) create mode 100644 src/test/setup-dom.ts create mode 100644 src/test/smoke.test.tsx create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 2754558..377126b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/http-proxy": "^1.17.17", "@types/node": "^24.12.2", "@types/react": "^19.2.14", @@ -39,12 +42,71 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", "vite": "^8.0.10", "vitest": "^4.1.8" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -237,6 +299,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -285,6 +357,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -444,6 +669,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2940,6 +3183,81 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2950,6 +3268,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3524,6 +3849,29 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3536,6 +3884,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3579,6 +3937,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -3766,12 +4134,47 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3789,6 +4192,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -3846,6 +4256,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -3866,6 +4283,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -4334,6 +4764,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4364,6 +4807,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -4449,6 +4902,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isbot": { "version": "5.1.40", "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.40.tgz", @@ -4481,6 +4941,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4846,6 +5357,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5147,6 +5668,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -5710,6 +6238,16 @@ ], "license": "MIT" }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5853,6 +6391,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5936,6 +6487,21 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -6072,6 +6638,13 @@ "react": "^19.2.6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -6178,6 +6751,20 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -6244,6 +6831,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", @@ -6283,6 +6880,19 @@ "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6397,6 +7007,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -6415,6 +7038,13 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -6487,6 +7117,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -6577,6 +7253,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6959,6 +7645,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7002,6 +7736,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 6d16961..d840dc3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/http-proxy": "^1.17.17", "@types/node": "^24.12.2", "@types/react": "^19.2.14", @@ -42,6 +45,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", "vite": "^8.0.10", diff --git a/src/test/setup-dom.ts b/src/test/setup-dom.ts new file mode 100644 index 0000000..2c90531 --- /dev/null +++ b/src/test/setup-dom.ts @@ -0,0 +1,10 @@ +// Global test setup for jsdom component tests. Extends Vitest's expect with +// @testing-library/jest-dom matchers (toBeInTheDocument, toHaveAttribute, …) +// and cleans up the DOM between tests. +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); diff --git a/src/test/smoke.test.tsx b/src/test/smoke.test.tsx new file mode 100644 index 0000000..9c6eefc --- /dev/null +++ b/src/test/smoke.test.tsx @@ -0,0 +1,10 @@ +// Smoke test proving the jsdom + @testing-library render harness works. +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; + +describe("render harness", () => { + it("renders a component into jsdom and queries its text", () => { + render(
harness ok
); + expect(screen.getByText("harness ok")).toBeInTheDocument(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..15a4407 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, configDefaults } from "vitest/config"; +import path from "path"; + +// Vitest reads this file in preference to vite.config.ts, so the `@` alias the +// app relies on must be redeclared here. The dev server / proxy config in +// vite.config.ts is irrelevant to tests and intentionally not carried over. +// +// jsdom environment: the pure-logic suites (reducer, visibility, string utils) +// run fine under jsdom, and it's required for the component render tests +// (react-markdown safety behavior). See docs/plans — U1. +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup-dom.ts"], + // Stale checkouts under .worktrees/ carry duplicate *.test.ts copies that + // aren't part of the live tree — keep vitest's defaults and add them. + exclude: [...configDefaults.exclude, "**/.worktrees/**"], + }, +}); From 7a580ea5c1e0935a17e04e12a72e36c699621076 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 16:48:33 +0000 Subject: [PATCH 2/9] feat(ui): add shared Markdown renderer with XSS-safe defaults Wires the already-installed react-markdown + remark-gfm into one shared component for all message text. Raw HTML is escaped (no rehype-raw); links are protocol-allowlisted and open in a new tab with noopener/noreferrer/ nofollow. Tests assert formatting, GFM tables/task lists, HTML-injection inertness, and javascript: link rejection. --- src/components/ui/markdown.test.tsx | 65 +++++++++++++++++++++++++++++ src/components/ui/markdown.tsx | 64 ++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/components/ui/markdown.test.tsx create mode 100644 src/components/ui/markdown.tsx diff --git a/src/components/ui/markdown.test.tsx b/src/components/ui/markdown.test.tsx new file mode 100644 index 0000000..63aad38 --- /dev/null +++ b/src/components/ui/markdown.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Markdown } from "./markdown"; + +describe("Markdown", () => { + it("renders headings, lists, bold, and inline code", () => { + const { container } = render( + {"## Heading\n\n- a\n- b\n\n**bold** and `x`"}, + ); + expect(container.querySelector("h2")).toHaveTextContent("Heading"); + const items = container.querySelectorAll("ul > li"); + expect(items).toHaveLength(2); + expect(container.querySelector("strong")).toHaveTextContent("bold"); + expect(container.querySelector("code")).toHaveTextContent("x"); + }); + + it("renders GFM tables and task lists", () => { + const { container } = render( + + {"| A | B |\n| - | - |\n| 1 | 2 |\n\n- [x] done\n- [ ] todo"} + , + ); + expect(container.querySelector("table")).toBeInTheDocument(); + const checkboxes = container.querySelectorAll( + 'input[type="checkbox"]', + ); + expect(checkboxes).toHaveLength(2); + }); + + it("escapes raw HTML instead of rendering it (no injection)", () => { + const { container } = render( + {'text more'}, + ); + // The raw must not become a real element in the DOM. + expect(container.querySelector("img")).toBeNull(); + expect(container).toHaveTextContent("onerror"); + }); + + it("does not emit a javascript: link", () => { + const { container } = render( + {"[click](javascript:alert(1))"}, + ); + const anchors = container.querySelectorAll("a"); + anchors.forEach((a) => { + expect(a.getAttribute("href") ?? "").not.toContain("javascript:"); + }); + // The link text is still shown to the reader. + expect(container).toHaveTextContent("click"); + }); + + it("opens http links in a new tab with safe rel", () => { + render({"[docs](https://example.dev)"}); + const link = screen.getByRole("link", { name: "docs" }); + expect(link).toHaveAttribute("href", "https://example.dev"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link.getAttribute("rel")).toContain("noopener"); + expect(link.getAttribute("rel")).toContain("noreferrer"); + }); + + it("renders blank for empty or whitespace-only input without throwing", () => { + const { container } = render({" "}); + expect(container.querySelector(".md")).toBeInTheDocument(); + expect(container.textContent?.trim()).toBe(""); + }); +}); diff --git a/src/components/ui/markdown.tsx b/src/components/ui/markdown.tsx new file mode 100644 index 0000000..05c5c5d --- /dev/null +++ b/src/components/ui/markdown.tsx @@ -0,0 +1,64 @@ +// Markdown — the single shared prose renderer for all message text (agent +// replies, system notices, human chat messages). Wraps react-markdown with +// remark-gfm (tables, task lists, strikethrough, autolinks). +// +// Safety: raw HTML is NOT rendered — react-markdown escapes it by default (we +// deliberately do not add rehype-raw), and its default urlTransform strips +// dangerous URL protocols. On top of that, the `a` override enforces a +// protocol allowlist and opens links in a new tab with safe rel attributes. +// All message content — model-generated and human-typed — is treated as +// untrusted for rendering. + +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { cn } from "@/lib/utils"; + +// Only these protocols produce a real anchor. Relative links (no protocol) +// resolve against a dummy base to https and are allowed; javascript:, data:, +// vbscript:, etc. fall through to a plain . +const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +function isSafeHref(href: string | undefined): href is string { + if (!href) return false; + try { + const url = new URL(href, "https://deuce.local/"); + return SAFE_LINK_PROTOCOLS.has(url.protocol); + } catch { + return false; + } +} + +const components: Components = { + a({ node: _node, href, children, ...props }) { + if (!isSafeHref(href)) { + // Unsafe or unparseable protocol — render the link text, no anchor. + return {children}; + } + return ( + + {children} + + ); + }, +}; + +export function Markdown({ + children, + className, +}: { + children: string; + className?: string; +}) { + return ( +
+ + {children} + +
+ ); +} From 92e4ea0a2e30037be7e30c621dd05420f48e0490 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 16:50:24 +0000 Subject: [PATCH 3/9] feat(ui): syntax-highlight fenced code in Markdown Adds react-syntax-highlighter (Prism async light + curated language set and common aliases) as the code renderer for . Language-tagged fences are highlighted with a dark theme; unknown/absent languages fall back to a styled plain block; inline code stays a simple . --- package-lock.json | 144 ++++++++++++++++++++++- package.json | 2 + src/components/ui/markdown-code.test.tsx | 42 +++++++ src/components/ui/markdown-code.tsx | 101 ++++++++++++++++ src/components/ui/markdown.tsx | 2 + 5 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/markdown-code.test.tsx create mode 100644 src/components/ui/markdown-code.tsx diff --git a/package-lock.json b/package-lock.json index 377126b..1252b5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.0", + "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.3.0", @@ -37,6 +38,7 @@ "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", @@ -303,7 +305,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3375,6 +3376,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3394,6 +3401,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4565,6 +4582,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4633,6 +4663,14 @@ "dev": true, "license": "ISC" }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4707,6 +4745,19 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -4747,6 +4798,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4764,6 +4832,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -5338,6 +5421,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6502,6 +6599,15 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -6751,6 +6857,26 @@ } } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -6765,6 +6891,22 @@ "node": ">=8" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/package.json b/package.json index d840dc3..297f719 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.0", + "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.3.0", @@ -40,6 +41,7 @@ "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/src/components/ui/markdown-code.test.tsx b/src/components/ui/markdown-code.test.tsx new file mode 100644 index 0000000..c97cbca --- /dev/null +++ b/src/components/ui/markdown-code.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { Markdown } from "./markdown"; + +// Prism's async light build registers/highlights on mount; findBy* waits for +// the resulting token spans to appear. +describe("Markdown fenced code", () => { + it("highlights a language-tagged block", async () => { + const { container, findByText } = render( + {"```ts\nconst x: number = 1\n```"}, + ); + // Highlighter emits token s; wait for one carrying the keyword. + await findByText("const"); + expect(container.querySelectorAll("span").length).toBeGreaterThan(1); + expect(container).toHaveTextContent("const x: number = 1"); + }); + + it("renders a no-language fenced block as a plain styled block", () => { + const { container } = render( + {"```\njust text\nmore\n```"}, + ); + const pre = container.querySelector("pre.md-code--plain"); + expect(pre).toBeInTheDocument(); + expect(pre).toHaveTextContent("just text"); + }); + + it("falls back gracefully for an unregistered language", () => { + const { container } = render( + {"```brainfuck\n+[----->+++<]>++.\n```"}, + ); + const pre = container.querySelector("pre.md-code--plain"); + expect(pre).toBeInTheDocument(); + expect(pre).toHaveTextContent("+[----->+++<]>++."); + }); + + it("keeps inline code as a simple inline , not a block", () => { + const { container } = render({"use `npm test` now"}); + const code = container.querySelector("code"); + expect(code).toHaveTextContent("npm test"); + expect(container.querySelector("pre")).toBeNull(); + }); +}); diff --git a/src/components/ui/markdown-code.tsx b/src/components/ui/markdown-code.tsx new file mode 100644 index 0000000..026ae3a --- /dev/null +++ b/src/components/ui/markdown-code.tsx @@ -0,0 +1,101 @@ +// markdown-code — the `code`/`pre` renderer overrides for . Fenced +// blocks with a supported language are syntax-highlighted with Prism (async +// light build + a curated language set to keep bundle weight down); fenced +// blocks with no/unknown language fall back to a styled but unhighlighted +// block; inline `code` stays a simple styled span handled by prose CSS. + +import { PrismAsyncLight as SyntaxHighlighter } from "react-syntax-highlighter"; +import oneDark from "react-syntax-highlighter/dist/esm/styles/prism/one-dark"; +import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash"; +import diff from "react-syntax-highlighter/dist/esm/languages/prism/diff"; +import go from "react-syntax-highlighter/dist/esm/languages/prism/go"; +import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript"; +import jsx from "react-syntax-highlighter/dist/esm/languages/prism/jsx"; +import json from "react-syntax-highlighter/dist/esm/languages/prism/json"; +import markdown from "react-syntax-highlighter/dist/esm/languages/prism/markdown"; +import sql from "react-syntax-highlighter/dist/esm/languages/prism/sql"; +import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx"; +import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript"; +import yaml from "react-syntax-highlighter/dist/esm/languages/prism/yaml"; +import type { Components } from "react-markdown"; + +// Curated language set. Aliases map onto the registered canonical names so +// common fence tags (```js, ```sh, ```yml) resolve. +const LANGUAGES: Record[1]> = { + bash, + diff, + go, + javascript, + jsx, + json, + markdown, + sql, + tsx, + typescript, + yaml, +}; + +for (const [name, lang] of Object.entries(LANGUAGES)) { + SyntaxHighlighter.registerLanguage(name, lang); +} + +const ALIASES: Record = { + js: "javascript", + ts: "typescript", + sh: "bash", + shell: "bash", + yml: "yaml", + md: "markdown", + golang: "go", +}; + +function resolveLang(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const lower = raw.toLowerCase(); + const canonical = ALIASES[lower] ?? lower; + return canonical in LANGUAGES ? canonical : undefined; +} + +export const codeComponents: Components = { + // Unwrap the markdown-emitted
 so a highlighted block (which brings its
+  // own container) isn't nested inside a second 
.
+  pre({ children }) {
+    return <>{children};
+  },
+  code({ node: _node, className, children, ...props }) {
+    const text = String(children ?? "");
+    const match = /language-(\w+)/.exec(className ?? "");
+    // A fenced block is either language-tagged or multiline; everything else is
+    // inline code and stays a simple  styled by prose CSS.
+    const isBlock = match !== null || text.includes("\n");
+    if (!isBlock) {
+      return (
+        
+          {children}
+        
+      );
+    }
+
+    const lang = resolveLang(match?.[1]);
+    const code = text.replace(/\n$/, "");
+    if (!lang) {
+      // Unknown/absent language — styled but unhighlighted block.
+      return (
+        
+          {code}
+        
+ ); + } + return ( + + {code} + + ); + }, +}; diff --git a/src/components/ui/markdown.tsx b/src/components/ui/markdown.tsx index 05c5c5d..e48ae94 100644 --- a/src/components/ui/markdown.tsx +++ b/src/components/ui/markdown.tsx @@ -12,6 +12,7 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "@/lib/utils"; +import { codeComponents } from "./markdown-code"; // Only these protocols produce a real anchor. Relative links (no protocol) // resolve against a dummy base to https and are allowed; javascript:, data:, @@ -29,6 +30,7 @@ function isSafeHref(href: string | undefined): href is string { } const components: Components = { + ...codeComponents, a({ node: _node, href, children, ...props }) { if (!isSafeHref(href)) { // Unsafe or unparseable protocol — render the link text, no anchor. From eb0f04662eb244252cd600baa5cf159c90b06c4f Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 16:51:52 +0000 Subject: [PATCH 4/9] style(ui): add scoped .md prose styles for rendered markdown Scoped under .md so prose rhythm doesn't leak into chat/card chrome. Styles headings, lists (incl. GFM task lists), blockquotes, tables, links, inline code, and fenced code blocks (frame + horizontal scroll) using Primer tokens. Supersedes the old .q-resp code rule. --- src/styles/globals.css | 153 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 8 deletions(-) diff --git a/src/styles/globals.css b/src/styles/globals.css index 9587ce8..a3b5cd1 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -631,14 +631,8 @@ button, .q-resp .q-actsum { margin-left: 32px; } -.q-resp code { - font-family: var(--font-mono); - font-size: 12px; - color: var(--color-foreground-emphasis); - background: var(--color-background-subtle); - padding: 1px 5px; - border-radius: 4px; -} +/* Inline code inside agent replies is now handled by the shared `.md` prose + styles below (the reply body renders through ). */ .q-typingline { display: flex; @@ -919,3 +913,146 @@ button, .q-actsum:hover { color: var(--color-foreground); } + +/* --------------------------------------------------------------------------- + Markdown prose (.md) — shared styling for all rendered message content + (agent replies, system notices, human chat messages). Scoped under `.md` + so prose rhythm never leaks into the surrounding chat/card chrome. Dark + mode only, using the Primer color tokens. See (markdown.tsx). +--------------------------------------------------------------------------- */ +.md { + font-size: 13px; + line-height: 1.55; + color: var(--color-foreground); + min-width: 0; + overflow-wrap: anywhere; +} +/* Collapse outer margins so a message renders flush inside its bubble. */ +.md > :first-child { + margin-top: 0; +} +.md > :last-child { + margin-bottom: 0; +} +.md p { + margin: 0 0 8px; +} +.md h1, +.md h2, +.md h3, +.md h4 { + margin: 14px 0 6px; + line-height: 1.3; + font-weight: 600; + color: var(--color-foreground-emphasis); +} +.md h1 { + font-size: 18px; +} +.md h2 { + font-size: 16px; +} +.md h3 { + font-size: 14px; +} +.md h4 { + font-size: 13px; +} +.md ul, +.md ol { + margin: 0 0 8px; + padding-left: 22px; +} +.md li { + margin: 2px 0; +} +.md li > ul, +.md li > ol { + margin: 2px 0; +} +/* GFM task-list items drop the bullet and align the checkbox. */ +.md li:has(> input[type="checkbox"]) { + list-style: none; + margin-left: -18px; +} +.md li > input[type="checkbox"] { + margin-right: 6px; +} +.md a { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 2px; +} +.md a:hover { + color: var(--color-accent-emphasis); +} +.md strong { + font-weight: 600; + color: var(--color-foreground-emphasis); +} +.md blockquote { + margin: 0 0 8px; + padding: 2px 0 2px 12px; + border-left: 3px solid var(--color-border-emphasis); + color: var(--color-foreground-muted); +} +.md hr { + margin: 12px 0; + border: none; + border-top: 1px solid var(--color-border-muted); +} +/* Inline code (parent is not a code block). */ +.md :not(pre) > code { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-foreground-emphasis); + background: var(--color-background-subtle); + padding: 1px 5px; + border-radius: var(--radius-sm); + overflow-wrap: anywhere; +} +/* Fenced code blocks — the highlighter renders token spans on a transparent + background; this container supplies the frame and horizontal scroll. The + plain (unhighlighted) fallback shares the same frame. */ +.md .md-code { + margin: 0 0 8px; + padding: 8px 10px; + border: 1px solid var(--color-border-muted); + border-radius: var(--radius-md); + background: var(--color-background-inset); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + overflow-x: auto; + max-width: 100%; +} +.md pre.md-code--plain { + white-space: pre; + color: var(--color-foreground); +} +.md pre.md-code--plain code { + font-family: inherit; +} +.md table { + margin: 0 0 8px; + border-collapse: collapse; + display: block; + overflow-x: auto; + max-width: 100%; + font-size: 12px; +} +.md th, +.md td { + border: 1px solid var(--color-border-muted); + padding: 4px 8px; + text-align: left; +} +.md th { + background: var(--color-background-subtle); + font-weight: 600; + color: var(--color-foreground-emphasis); +} +.md img { + max-width: 100%; + border-radius: var(--radius-sm); +} From 234429157e4b356c8df7deb2f3abd4433af88081 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 16:54:14 +0000 Subject: [PATCH 5/9] feat(chat): render message content and agent replies as markdown Routes the thread-drawer agent reply and chat message bubbles (human + system notices) through the shared renderer. Terminal fallback strings, tool output, expandable content, and the Mentioned prompt echo are untouched. Exports MessageBubble for testing; adds ^_ unused-var ignore and drops .worktrees from lint. --- eslint.config.js | 10 ++- src/components/chat/ChatView.tsx | 5 +- src/components/chat/MessageBubble.test.tsx | 71 +++++++++++++++++++ .../super-threads/AgentThreadDrawer.test.tsx | 55 ++++++++++++++ .../super-threads/AgentThreadDrawer.tsx | 16 +++-- 5 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 src/components/chat/MessageBubble.test.tsx create mode 100644 src/components/super-threads/AgentThreadDrawer.test.tsx diff --git a/eslint.config.js b/eslint.config.js index ef614d2..7643c04 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', '.worktrees']), { files: ['**/*.{ts,tsx}'], extends: [ @@ -18,5 +18,13 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + // Allow intentionally-unused bindings prefixed with `_` (e.g. destructuring + // out react-markdown's `node` prop so it isn't spread onto the DOM). + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, }, ]) diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 9759e96..ae74622 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -18,6 +18,7 @@ import { tasksByAnchor, queuePositions } from "@/stores/agent-runs"; import { AgentTaskCard } from "@/components/super-threads/AgentTaskCard"; import { visibleChatMessages } from "@/components/chat/message-visibility"; import { DEUCE } from "@/lib/deuce"; +import { Markdown } from "@/components/ui/markdown"; import type { Message, User, Session, WorkspaceStatus } from "@/types"; function isWorkspaceLive(status: WorkspaceStatus | undefined): boolean { @@ -183,7 +184,7 @@ function JoinSessionGate({ session }: { session: Session }) { ); } -function MessageBubble({ +export function MessageBubble({ message, author, showHeader, @@ -246,7 +247,7 @@ function MessageBubble({ )}
-

{message.content}

+ {message.content} {/* Expandable content */} {message.expandableContent?.map((item, i) => ( diff --git a/src/components/chat/MessageBubble.test.tsx b/src/components/chat/MessageBubble.test.tsx new file mode 100644 index 0000000..562829f --- /dev/null +++ b/src/components/chat/MessageBubble.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { MessageBubble } from "./ChatView"; +import type { Message, User } from "@/types"; + +const author: User = { + id: "u1", + name: "Alice", + email: "a@x.dev", + avatar: "", + status: "online" as User["status"], +}; + +function humanMessage(content: string): Message { + return { + id: "m1", + sessionId: "s1", + authorId: "u1", + authorType: "human", + content, + createdAt: new Date(0).toISOString(), + status: "sent", + }; +} + +describe("MessageBubble", () => { + it("renders markdown in a human message", () => { + const { container } = render( + , + ); + expect(container.querySelector("strong")).toHaveTextContent("bold"); + expect(container.querySelectorAll("ul > li")).toHaveLength(2); + // No raw literal markdown left over. + expect(container.textContent).not.toContain("**bold**"); + }); + + it("renders markdown in a system notice (agent-typed, nil author)", () => { + const msg: Message = { + ...humanMessage("## Notice\n\nWorkspace restarted."), + authorType: "agent", + authorId: "00000000-0000-0000-0000-000000000000", + }; + const { container } = render( + , + ); + expect(container.querySelector("h2")).toHaveTextContent("Notice"); + }); + + it("still renders expandable content untouched (R6)", () => { + const msg: Message = { + ...humanMessage("see diff"), + expandableContent: [ + { + type: "diff", + title: "changes", + summary: "1 file", + content: "- old\n+ new", + }, + ], + }; + const { getByText } = render( + , + ); + // The toggle button from expandableContent is still present. + expect(getByText(/changes/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/super-threads/AgentThreadDrawer.test.tsx b/src/components/super-threads/AgentThreadDrawer.test.tsx new file mode 100644 index 0000000..73b526d --- /dev/null +++ b/src/components/super-threads/AgentThreadDrawer.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { AgentThreadDrawer } from "./AgentThreadDrawer"; +import type { AgentTask } from "@/types"; + +function task(overrides: Partial): AgentTask { + return { + id: "t1", + sessionId: "s1", + prompt: "@deuce explain this repo", + state: "done", + seq: 1, + actions: [], + ...overrides, + }; +} + +function renderDrawer(tasks: AgentTask[]) { + return render( + ({ name: "Alice", avatar: "" })} + onClose={() => {}} + onSend={() => {}} + />, + ); +} + +describe("AgentThreadDrawer reply rendering", () => { + it("renders a markdown reply as formatted markup", () => { + const { container } = renderDrawer([ + task({ reply: "## Summary\n\n- one\n- two\n\n**done**" }), + ]); + expect(container.querySelector("h2")).toHaveTextContent("Summary"); + expect(container.querySelectorAll("ul > li")).toHaveLength(2); + expect(container.querySelector("strong")).toHaveTextContent("done"); + }); + + it("shows the fallback string when a failed task has no reply", () => { + const { container } = renderDrawer([ + task({ state: "failed", reply: undefined }), + ]); + expect(container).toHaveTextContent("Run failed."); + expect(container.querySelector(".bd h2")).toBeNull(); + }); + + it("shows the cancelled fallback when a cancelled task has no reply", () => { + const { container } = renderDrawer([ + task({ state: "cancelled", reply: undefined }), + ]); + expect(container).toHaveTextContent("Run cancelled."); + }); +}); diff --git a/src/components/super-threads/AgentThreadDrawer.tsx b/src/components/super-threads/AgentThreadDrawer.tsx index 434c490..2aec8bf 100644 --- a/src/components/super-threads/AgentThreadDrawer.tsx +++ b/src/components/super-threads/AgentThreadDrawer.tsx @@ -20,6 +20,7 @@ import type { AgentTask, User } from "@/types"; import { DEUCE } from "@/lib/deuce"; import { statusOfTasks } from "@/stores/agent-runs"; import { AgentAvatar, StopButton, TypingDots, Mentioned, ActionLog } from "./atoms"; +import { Markdown } from "@/components/ui/markdown"; type UserLookup = (id?: string) => Pick | undefined; @@ -182,12 +183,15 @@ function Turn({ )}
- {task.reply ?? - (task.state === "failed" - ? "Run failed." - : task.state === "cancelled" - ? "Run cancelled." - : "Done.")} + {task.reply ? ( + {task.reply} + ) : task.state === "failed" ? ( + "Run failed." + ) : task.state === "cancelled" ? ( + "Run cancelled." + ) : ( + "Done." + )}
)} From 71105e441ec8115f27ceca515bc6bc71b9ba5b02 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 16:55:40 +0000 Subject: [PATCH 6/9] feat(super-threads): flatten markdown in the compact task-card summary Adds toPlainText() (strips markdown syntax, collapses whitespace) and applies it to the terminal task-card one-line summary so a markdown reply stays a clean single line instead of leaking ## and list markers into the card. --- .../super-threads/AgentTaskCard.tsx | 8 ++-- src/lib/markdown-plain.test.ts | 41 +++++++++++++++++++ src/lib/markdown-plain.ts | 24 +++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/lib/markdown-plain.test.ts create mode 100644 src/lib/markdown-plain.ts diff --git a/src/components/super-threads/AgentTaskCard.tsx b/src/components/super-threads/AgentTaskCard.tsx index 750d8c4..26e1c06 100644 --- a/src/components/super-threads/AgentTaskCard.tsx +++ b/src/components/super-threads/AgentTaskCard.tsx @@ -15,6 +15,7 @@ import type { AgentTask } from "@/types"; import { DEUCE } from "@/lib/deuce"; import { AgentAvatar, StopButton, TypingDots } from "./atoms"; import { askUserQuestion, stripMention } from "./utils"; +import { toPlainText } from "@/lib/markdown-plain"; export function AgentTaskCard({ sessionId, @@ -124,12 +125,13 @@ export function AgentTaskCard({ {DEUCE.name}{" "} - {task.reply ?? - (state === "failed" + {task.reply + ? toPlainText(task.reply) + : state === "failed" ? "Run failed." : state === "cancelled" ? "Run cancelled." - : "Done.")} + : "Done."} diff --git a/src/lib/markdown-plain.test.ts b/src/lib/markdown-plain.test.ts new file mode 100644 index 0000000..ad1654a --- /dev/null +++ b/src/lib/markdown-plain.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { toPlainText } from "./markdown-plain"; + +describe("toPlainText", () => { + it("flattens a heading + list to one line", () => { + expect(toPlainText("## Title\n\n- one\n- two")).toBe("Title one two"); + }); + + it("strips emphasis and inline code markers", () => { + expect(toPlainText("See **bold** and `code` here")).toBe( + "See bold and code here", + ); + }); + + it("reduces a link to its text", () => { + expect(toPlainText("[docs](https://x.dev)")).toBe("docs"); + }); + + it("reduces an image to its alt text", () => { + expect(toPlainText("![a diagram](/img.png) after")).toBe("a diagram after"); + }); + + it("collapses a fenced code block to its inner text with no backticks", () => { + const out = toPlainText("Run:\n```ts\nconst x = 1\n```"); + expect(out).toBe("Run: const x = 1"); + expect(out).not.toContain("`"); + }); + + it("strips ordered-list markers", () => { + expect(toPlainText("1. first\n2. second")).toBe("first second"); + }); + + it("returns empty string for empty or whitespace-only input", () => { + expect(toPlainText("")).toBe(""); + expect(toPlainText(" \n\t ")).toBe(""); + }); + + it("passes plain text through modulo whitespace collapse", () => { + expect(toPlainText("just some\n\ntext")).toBe("just some text"); + }); +}); diff --git a/src/lib/markdown-plain.ts b/src/lib/markdown-plain.ts new file mode 100644 index 0000000..c2fbc7d --- /dev/null +++ b/src/lib/markdown-plain.ts @@ -0,0 +1,24 @@ +// toPlainText strips common markdown syntax and collapses whitespace to a +// single line. Used for compact, single-line surfaces (e.g. the terminal +// task-card summary) where a reply's block markdown would otherwise blow out +// the layout. Dependency-free — this is a display flattening, not a parser. +export function toPlainText(markdown: string): string { + if (!markdown) return ""; + let s = markdown; + // Images then links: ![alt](url) / [text](url) -> alt / text. + s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1"); + s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"); + // Fenced code delimiters (```lang / ```) -> drop the fence, keep the code text. + s = s.replace(/```[^\n]*\n?/g, " "); + // Remaining backticks (inline code). + s = s.replace(/`+/g, ""); + // Leading block markers at line starts: headings, blockquotes, list bullets. + s = s.replace(/^[ \t]*#{1,6}[ \t]+/gm, ""); + s = s.replace(/^[ \t]*>[ \t]?/gm, ""); + s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, ""); + // Emphasis / strikethrough markers. + s = s.replace(/\*\*|__|~~|\*|_/g, ""); + // Collapse all whitespace (incl. newlines) to single spaces. + s = s.replace(/\s+/g, " ").trim(); + return s; +} From e9600b332600c7bf7d7d86ed67b0c917885bc150 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 16:58:33 +0000 Subject: [PATCH 7/9] refactor(super-threads): extract shared taskFallbackMessage helper The terminal no-reply fallback ('Run failed.'/'Run cancelled.'/'Done.') was duplicated across the inline card and the thread drawer. Centralize it in utils.ts so the phrasing lives in one place. --- src/components/super-threads/AgentTaskCard.tsx | 10 ++-------- src/components/super-threads/AgentThreadDrawer.tsx | 7 ++----- src/components/super-threads/utils.ts | 13 +++++++++++++ 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/components/super-threads/AgentTaskCard.tsx b/src/components/super-threads/AgentTaskCard.tsx index 26e1c06..518d124 100644 --- a/src/components/super-threads/AgentTaskCard.tsx +++ b/src/components/super-threads/AgentTaskCard.tsx @@ -14,7 +14,7 @@ import { import type { AgentTask } from "@/types"; import { DEUCE } from "@/lib/deuce"; import { AgentAvatar, StopButton, TypingDots } from "./atoms"; -import { askUserQuestion, stripMention } from "./utils"; +import { askUserQuestion, stripMention, taskFallbackMessage } from "./utils"; import { toPlainText } from "@/lib/markdown-plain"; export function AgentTaskCard({ @@ -125,13 +125,7 @@ export function AgentTaskCard({ {DEUCE.name}{" "} - {task.reply - ? toPlainText(task.reply) - : state === "failed" - ? "Run failed." - : state === "cancelled" - ? "Run cancelled." - : "Done."} + {task.reply ? toPlainText(task.reply) : taskFallbackMessage(state)} diff --git a/src/components/super-threads/AgentThreadDrawer.tsx b/src/components/super-threads/AgentThreadDrawer.tsx index 2aec8bf..31f873f 100644 --- a/src/components/super-threads/AgentThreadDrawer.tsx +++ b/src/components/super-threads/AgentThreadDrawer.tsx @@ -21,6 +21,7 @@ import { DEUCE } from "@/lib/deuce"; import { statusOfTasks } from "@/stores/agent-runs"; import { AgentAvatar, StopButton, TypingDots, Mentioned, ActionLog } from "./atoms"; import { Markdown } from "@/components/ui/markdown"; +import { taskFallbackMessage } from "./utils"; type UserLookup = (id?: string) => Pick | undefined; @@ -185,12 +186,8 @@ function Turn({
{task.reply ? ( {task.reply} - ) : task.state === "failed" ? ( - "Run failed." - ) : task.state === "cancelled" ? ( - "Run cancelled." ) : ( - "Done." + taskFallbackMessage(task.state) )}
diff --git a/src/components/super-threads/utils.ts b/src/components/super-threads/utils.ts index 65560bb..b8d55c0 100644 --- a/src/components/super-threads/utils.ts +++ b/src/components/super-threads/utils.ts @@ -1,11 +1,24 @@ // Super Threads helpers shared across the card/drawer components. Kept separate // from the .tsx component modules so fast-refresh's "components only" rule holds. +import type { TaskState } from "@/types"; + // stripMention drops the leading @agent token from a prompt for compact display. export function stripMention(text: string): string { return text.replace(/@\w+/, "").replace(/^[\s,]+/, "").trim(); } +// taskFallbackMessage is the display text for a terminal task that produced no +// reply. Shared by the inline card and the thread drawer so the phrasing stays +// in one place. +export function taskFallbackMessage(state: TaskState): string { + return state === "failed" + ? "Run failed." + : state === "cancelled" + ? "Run cancelled." + : "Done."; +} + // askUserQuestion returns the display question for an ask_user tool action, or // null when the action isn't one. Two shapes exist (R9 — a question must never // render as raw JSON): From a9589df3adef36857b0624d4fdc100447fc0bb05 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 17:04:14 +0000 Subject: [PATCH 8/9] fix(ui): preserve soft line breaks and snake_case in markdown rendering Code-review follow-ups: - Add remark-breaks so a human message's single newlines render as line breaks (matching the old whitespace-pre-wrap behavior) instead of collapsing. - toPlainText no longer strips underscores inside words, so snake_case identifiers survive in the compact card summary. --- package-lock.json | 30 +++++++++++++++++++++++++++++ package.json | 1 + src/components/ui/markdown.test.tsx | 9 +++++++++ src/components/ui/markdown.tsx | 6 +++++- src/lib/markdown-plain.test.ts | 6 ++++++ src/lib/markdown-plain.ts | 8 ++++++-- 6 files changed, 57 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1252b5e..d39735e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.0", "react-syntax-highlighter": "^16.1.1", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.3.0", @@ -5696,6 +5697,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -6907,6 +6922,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/package.json b/package.json index 297f719..9a2be8c 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.0", "react-syntax-highlighter": "^16.1.1", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.3.0", diff --git a/src/components/ui/markdown.test.tsx b/src/components/ui/markdown.test.tsx index 63aad38..955a766 100644 --- a/src/components/ui/markdown.test.tsx +++ b/src/components/ui/markdown.test.tsx @@ -57,6 +57,15 @@ describe("Markdown", () => { expect(link.getAttribute("rel")).toContain("noreferrer"); }); + it("preserves single newlines as line breaks (chat soft-break behavior)", () => { + const { container } = render({"line one\nline two"}); + // remark-breaks turns a single newline into a hard break rather than + // collapsing the two lines onto one. + expect(container.querySelector("br")).toBeInTheDocument(); + expect(container).toHaveTextContent("line one"); + expect(container).toHaveTextContent("line two"); + }); + it("renders blank for empty or whitespace-only input without throwing", () => { const { container } = render({" "}); expect(container.querySelector(".md")).toBeInTheDocument(); diff --git a/src/components/ui/markdown.tsx b/src/components/ui/markdown.tsx index e48ae94..5cc4455 100644 --- a/src/components/ui/markdown.tsx +++ b/src/components/ui/markdown.tsx @@ -11,6 +11,7 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; +import remarkBreaks from "remark-breaks"; import { cn } from "@/lib/utils"; import { codeComponents } from "./markdown-code"; @@ -58,7 +59,10 @@ export function Markdown({ }) { return (
- + {children}
diff --git a/src/lib/markdown-plain.test.ts b/src/lib/markdown-plain.test.ts index ad1654a..2c93934 100644 --- a/src/lib/markdown-plain.test.ts +++ b/src/lib/markdown-plain.test.ts @@ -26,6 +26,12 @@ describe("toPlainText", () => { expect(out).not.toContain("`"); }); + it("preserves snake_case identifiers while stripping emphasis", () => { + expect(toPlainText("call _my_ function_name here")).toBe( + "call my function_name here", + ); + }); + it("strips ordered-list markers", () => { expect(toPlainText("1. first\n2. second")).toBe("first second"); }); diff --git a/src/lib/markdown-plain.ts b/src/lib/markdown-plain.ts index c2fbc7d..28b3ecf 100644 --- a/src/lib/markdown-plain.ts +++ b/src/lib/markdown-plain.ts @@ -16,8 +16,12 @@ export function toPlainText(markdown: string): string { s = s.replace(/^[ \t]*#{1,6}[ \t]+/gm, ""); s = s.replace(/^[ \t]*>[ \t]?/gm, ""); s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, ""); - // Emphasis / strikethrough markers. - s = s.replace(/\*\*|__|~~|\*|_/g, ""); + // Emphasis / strikethrough markers. Paired bold/strike and single asterisks + // drop unconditionally; underscores only drop when they wrap a run as + // emphasis, so snake_case identifiers (function_name) survive intact. + s = s.replace(/\*\*|__|~~/g, ""); + s = s.replace(/\*/g, ""); + s = s.replace(/\b_([^_]+?)_\b/g, "$1"); // Collapse all whitespace (incl. newlines) to single spaces. s = s.replace(/\s+/g, " ").trim(); return s; From 8b839c4d34ea4ca9b851891f92f90d24324094d8 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 17:04:31 +0000 Subject: [PATCH 9/9] docs(plans): mark markdown-message-rendering plan completed --- ...01-feat-markdown-message-rendering-plan.md | 446 ++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 docs/plans/2026-07-28-001-feat-markdown-message-rendering-plan.md diff --git a/docs/plans/2026-07-28-001-feat-markdown-message-rendering-plan.md b/docs/plans/2026-07-28-001-feat-markdown-message-rendering-plan.md new file mode 100644 index 0000000..a59f06b --- /dev/null +++ b/docs/plans/2026-07-28-001-feat-markdown-message-rendering-plan.md @@ -0,0 +1,446 @@ +--- +title: "feat: Render markdown in agent replies and chat messages" +type: feat +status: completed +date: 2026-07-28 +depth: standard +--- + +# feat: Render markdown in agent replies and chat messages + +## Summary + +Deuce's agent replies and chat messages currently render as raw, unformatted +text. When `@deuce` returns a response full of markdown (`##` headers, `**bold**`, +`###`, numbered lists, code fences), the user sees it all crammed together — +`…what this repo is.## This is the **Unify Agent Workspace**…` — because the +reply is dropped into a plain `
`/`

` with no markdown parsing and no +whitespace preservation. + +The fix wires up `react-markdown` + `remark-gfm` — **already in `package.json` +since the initial commit but never referenced in `src/`** — into a single shared +`` renderer, adds fenced-code syntax highlighting, adds scoped prose +CSS using existing Primer tokens, and applies the renderer across all message +surfaces (agent thread-drawer replies, chat bubbles for human + system messages). +The compact task-card summary stays a single truncated line via a pure +`toPlainText()` helper, since block markdown would break that layout. + +This is a **frontend-only rendering change**. No backend, message-storage, or Pi +agent-output changes. + +--- + +## Problem Frame + +**Reported symptom:** the agent's reply "looks pretty raw, but it seems like it +should have some formatting." The example is a full `@deuce` answer where every +markdown construct is inert and newlines have collapsed into a wall of text. + +**Root cause (confirmed by code read):** + +- The agent's reply text (`task.reply`) is rendered as a plain string in + `src/components/super-threads/AgentThreadDrawer.tsx:184` (the full reply, inside + `.q-resp .bd`) and `src/components/super-threads/AgentTaskCard.tsx:127` (a + one-line card summary). +- `.q-resp .bd` in `src/styles/globals.css:622` sets `line-height` but **no + `white-space` preservation** — so even literal `\n` between a sentence and a + `## ` header collapse to a single space, producing the run-together output. +- Chat bubbles in `src/components/chat/ChatView.tsx:249` render + `message.content` inside `

` — newlines + survive, but no markdown is parsed, so `**bold**` and lists stay literal. +- `react-markdown@^10.1.0` and `remark-gfm@^4.0.1` are dependencies but a repo + grep finds **zero references** in `src/`. There is even a leftover + `.q-resp code { … }` rule (`src/styles/globals.css:634`) that anticipated + markdown code spans. The wiring was simply never done. + +**Scope decisions (confirmed with requester):** + +- Render markdown across **all message text** via one shared component — agent + replies, system notices, and human-typed chat messages. +- Include **syntax highlighting** for fenced code blocks. + +--- + +## Requirements + +- **R1** — Agent replies in the thread drawer render markdown: headers, ordered + and unordered lists, bold/italic, inline code, fenced code blocks, blockquotes, + tables, and links display as formatted output, not literal syntax. +- **R2** — Human-typed chat messages and system notices render through the same + shared renderer, so formatting is consistent everywhere a message body appears. +- **R3** — Fenced code blocks are syntax-highlighted with a dark-mode theme that + fits the existing Primer palette; an unknown or missing language degrades + gracefully to an unhighlighted styled block. +- **R4** — Rendering is XSS-safe: model- and user-generated markdown cannot + inject executable HTML or `javascript:` URLs. Links open in a new tab with safe + `rel` attributes. +- **R5** — The compact task-card summary + (`AgentTaskCard.tsx`, terminal state) stays a single line — markdown is stripped + to readable plain text so headers/lists don't blow out the card layout. +- **R6** — No regression to intentionally-verbatim surfaces: tool output + (`.q-out` / Bash stdout / diffs) and the terminal/logs panels keep rendering as + raw monospace, untouched. + +--- + +## Key Technical Decisions + +- **KTD1 — One shared `` component, reusing installed deps.** Build a + single presentational `src/components/ui/markdown.tsx` wrapping `react-markdown` + with `remark-gfm`. Every message surface routes through it. No new + markdown-core dependency is added (the two are already present). This keeps + formatting behavior identical across chat and the agent drawer and gives one + place to enforce safety. + +- **KTD2 — XSS-safe by default; treat all message content as untrusted.** Do + **not** add `rehype-raw` and do **not** disable escaping. `react-markdown` + escapes raw HTML out of the box, so embedded `