diff --git a/eslint.config.mjs b/eslint.config.mjs index dae5a271..52ce1bb6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -195,6 +195,7 @@ const pureChatStateRestrictions = [ ]; const chatExternalDomBridgeFiles = [ "src/features/chat/ui/message-stream/markdown-renderer.ts", + "src/features/chat/ui/message-stream/stream-markdown-renderer.ts", "src/features/chat/ui/message-stream/virtualizer.ts", ]; const chatPreactDomBridgeFiles = [ diff --git a/package-lock.json b/package-lock.json index 63e6d46c..b9d59e3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@preact/signals": "^2.9.1", "@tanstack/query-core": "^5.101.0", "@tanstack/virtual-core": "3.17.0", + "micromark": "^4.0.2", "preact": "^10.29.2" }, "devDependencies": { @@ -2627,6 +2628,15 @@ "@types/tern": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2673,6 +2683,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -3546,6 +3562,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3746,7 +3772,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3767,6 +3792,19 @@ "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", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3810,6 +3848,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3820,6 +3867,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -7015,6 +7075,426 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7089,7 +7569,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { diff --git a/package.json b/package.json index def107d1..3a304d7d 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@preact/signals": "^2.9.1", "@tanstack/query-core": "^5.101.0", "@tanstack/virtual-core": "3.17.0", + "micromark": "^4.0.2", "preact": "^10.29.2" } } diff --git a/src/features/chat/panel/surface/message-stream-presenter.ts b/src/features/chat/panel/surface/message-stream-presenter.ts index 49ef8341..6fa86201 100644 --- a/src/features/chat/panel/surface/message-stream-presenter.ts +++ b/src/features/chat/panel/surface/message-stream-presenter.ts @@ -6,6 +6,7 @@ import type { ChatAction } from "../../application/state/root-reducer"; import type { ChatStateStore } from "../../application/state/store"; import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../../ui/message-stream/virtualizer"; import { MarkdownMessageRenderer } from "../../ui/message-stream/markdown-renderer"; +import { StreamMarkdownMessageRenderer } from "../../ui/message-stream/stream-markdown-renderer"; import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport"; import { messageStreamBlocks } from "../../ui/message-stream/stream-blocks"; import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../shell-state"; @@ -77,14 +78,19 @@ export interface MessageStreamPresenterOptions { } export class MessageStreamPresenter { - private readonly markdownRenderer: MarkdownMessageRenderer; + private readonly obsidianMarkdownRenderer: MarkdownMessageRenderer; + private readonly streamMarkdownRenderer: StreamMarkdownMessageRenderer; constructor(private readonly options: MessageStreamPresenterOptions) { - this.markdownRenderer = new MarkdownMessageRenderer({ + this.obsidianMarkdownRenderer = new MarkdownMessageRenderer({ app: options.obsidian.app, owner: options.obsidian.owner, vaultPath: options.workspace.vaultPath, }); + this.streamMarkdownRenderer = new StreamMarkdownMessageRenderer({ + app: options.obsidian.app, + vaultPath: options.workspace.vaultPath, + }); } private dispatch(action: ChatAction): void { @@ -114,8 +120,11 @@ export class MessageStreamPresenter { loadOlderTurns: () => { this.options.history.loadOlderTurns(); }, - renderMarkdown: (element, text) => { - this.markdownRenderer.renderMarkdown(element, text); + renderObsidianMarkdown: (element, text) => { + this.obsidianMarkdownRenderer.renderObsidianMarkdown(element, text); + }, + renderStreamMarkdown: (element, text) => { + this.streamMarkdownRenderer.renderStreamMarkdown(element, text); }, copyMessageText: (text) => void this.copyMessageText(text), actions: this.options.actions, diff --git a/src/features/chat/panel/surface/message-stream-projection.ts b/src/features/chat/panel/surface/message-stream-projection.ts index b6fdde06..0e256b6e 100644 --- a/src/features/chat/panel/surface/message-stream-projection.ts +++ b/src/features/chat/panel/surface/message-stream-projection.ts @@ -42,7 +42,8 @@ export interface ChatMessageStreamSurfaceContext { setDisclosureOpen: (bucket: ChatDisclosureBucket, id: string, open: boolean) => void; setForkActionsItem: (itemId: string | null) => void; loadOlderTurns: () => void; - renderMarkdown: (element: HTMLElement, text: string) => void; + renderObsidianMarkdown: (element: HTMLElement, text: string) => void; + renderStreamMarkdown: (element: HTMLElement, text: string) => void; copyMessageText: (text: string) => void; actions: ChatMessageStreamActions; requests: ChatMessageStreamRequests; @@ -52,7 +53,8 @@ export interface MessageStreamSurfaceContextOptions { vaultPath: string; dispatch: (action: ChatAction) => void; loadOlderTurns: () => void; - renderMarkdown: (element: HTMLElement, text: string) => void; + renderObsidianMarkdown: (element: HTMLElement, text: string) => void; + renderStreamMarkdown: (element: HTMLElement, text: string) => void; copyMessageText: (text: string) => void; actions: ChatMessageStreamActions; requests: ChatMessageStreamRequests; @@ -81,7 +83,8 @@ export function createMessageStreamSurfaceContext(options: MessageStreamSurfaceC options.dispatch({ type: "ui/message-fork-actions-set", itemId }); }, loadOlderTurns: options.loadOlderTurns, - renderMarkdown: options.renderMarkdown, + renderObsidianMarkdown: options.renderObsidianMarkdown, + renderStreamMarkdown: options.renderStreamMarkdown, copyMessageText: options.copyMessageText, actions: options.actions, requests: options.requests, @@ -111,7 +114,8 @@ function messageStreamContextFromProjection( forkActionsItemId: projection.forkActionsItemId, onForkActionsToggle: context.setForkActionsItem, loadOlderTurns: context.loadOlderTurns, - renderMarkdown: context.renderMarkdown, + renderObsidianMarkdown: context.renderObsidianMarkdown, + renderStreamMarkdown: context.renderStreamMarkdown, copyText: context.copyMessageText, onImplementPlan: (target) => { context.actions.implementPlan(target.itemId); diff --git a/src/features/chat/presentation/message-stream/text-view.ts b/src/features/chat/presentation/message-stream/text-view.ts index d2bfe4c7..81780fed 100644 --- a/src/features/chat/presentation/message-stream/text-view.ts +++ b/src/features/chat/presentation/message-stream/text-view.ts @@ -63,27 +63,29 @@ export interface MessageStreamTextView { body: string; className: string; contentKey: string; - contentMode: "markdown" | "text"; + renderMode: MessageStreamTextRenderMode; collapsible: boolean; copyText?: string; actions: MessageStreamTextActions; metadata: MessageStreamTextMetadataView; } +type MessageStreamTextRenderMode = "text" | "streamMarkdown" | "obsidianMarkdown"; + export function messageStreamTextView( item: MessageStreamItem, annotations?: MessageStreamItemAnnotations, options: { activeTurnId?: string | null; actions?: MessageStreamTextActions } = {}, ): MessageStreamTextView { - const contentMode = textContentMode(item); + const renderMode = textRenderMode(item); const body = bodyForTextItem(item); return { id: item.id, roleLabel: roleLabelForTextItem(item), body, className: `${textItemClass(item)}${executionClassName(item.executionState ?? null)}`, - contentKey: `${item.id}\u001f${contentMode}`, - contentMode, + contentKey: `${item.id}\u001f${renderMode}`, + renderMode, collapsible: item.kind === "message" && item.role === "user", ...definedProp("copyText", copyTextForTextItem(item, options.activeTurnId ?? null)), actions: options.actions ?? {}, @@ -91,8 +93,10 @@ export function messageStreamTextView( }; } -function textContentMode(item: MessageStreamItem): "markdown" | "text" { - return item.kind === "message" && (item.messageKind !== "proposedPlan" || item.messageState === "completed") ? "markdown" : "text"; +function textRenderMode(item: MessageStreamItem): MessageStreamTextRenderMode { + if (item.kind !== "message") return "text"; + if (item.messageKind === "assistantResponse" && item.messageState === "streaming") return "streamMarkdown"; + return item.messageKind !== "proposedPlan" || item.messageState === "completed" ? "obsidianMarkdown" : "text"; } function bodyForTextItem(item: MessageStreamItem): string { diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/message-stream/context.ts index 66d5a8dc..97dd002c 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/message-stream/context.ts @@ -41,7 +41,8 @@ export interface TextItemDetailStateContext { } export interface TextItemContentContext extends TextItemDetailStateContext { - renderMarkdown: (parent: HTMLElement, text: string) => void; + renderObsidianMarkdown: (parent: HTMLElement, text: string) => void; + renderStreamMarkdown: (parent: HTMLElement, text: string) => void; } export interface TextItemActionContext extends TextItemDetailStateContext { diff --git a/src/features/chat/ui/message-stream/markdown-renderer.ts b/src/features/chat/ui/message-stream/markdown-renderer.ts index 00172d89..16d80e44 100644 --- a/src/features/chat/ui/message-stream/markdown-renderer.ts +++ b/src/features/chat/ui/message-stream/markdown-renderer.ts @@ -14,7 +14,7 @@ export class MarkdownMessageRenderer { constructor(private readonly options: MarkdownMessageRendererOptions) {} - renderMarkdown(parent: HTMLElement, text: string): void { + renderObsidianMarkdown(parent: HTMLElement, text: string): void { const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? ""; const generation = (this.renderGenerations.get(parent) ?? 0) + 1; this.renderGenerations.set(parent, generation); diff --git a/src/features/chat/ui/message-stream/stream-markdown-renderer.ts b/src/features/chat/ui/message-stream/stream-markdown-renderer.ts new file mode 100644 index 00000000..bb65a00a --- /dev/null +++ b/src/features/chat/ui/message-stream/stream-markdown-renderer.ts @@ -0,0 +1,53 @@ +import { Notice, type App } from "obsidian"; +import { micromark } from "micromark"; + +import { isAbsoluteFileHref, vaultFileLinkTarget, vaultRelativeFileLinkTarget } from "../../../../shared/obsidian/file-links"; + +export interface StreamMarkdownMessageRendererOptions { + app: App; + vaultPath: string; +} + +export class StreamMarkdownMessageRenderer { + constructor(private readonly options: StreamMarkdownMessageRendererOptions) {} + + renderStreamMarkdown(parent: HTMLElement, text: string): void { + const html = micromark(text); + const DOMParserConstructor = parent.ownerDocument.defaultView?.DOMParser ?? DOMParser; + const parser = new DOMParserConstructor(); + const parsed = parser.parseFromString(html, "text/html"); + const nodes = Array.from(parsed.body.childNodes).map((node) => parent.ownerDocument.importNode(node, true)); + parent.replaceChildren(...nodes); + bindStreamMarkdownFileLinks(parent, this.options); + } +} + +function bindStreamMarkdownFileLinks(parent: HTMLElement, context: StreamMarkdownMessageRendererOptions): void { + const sourcePath = context.app.workspace.getActiveFile()?.path ?? ""; + parent.querySelectorAll("a[href]").forEach((link) => { + const href = link.getAttribute("href") ?? ""; + const target = vaultFileLinkTarget(context.app, context.vaultPath, href); + if (target) { + link.addClass("codex-panel__filelink"); + link.onclick = (event) => { + event.preventDefault(); + void context.app.workspace.openLinkText(target, sourcePath, false); + }; + return; + } + + const vaultRelativeTarget = vaultRelativeFileLinkTarget(context.vaultPath, context.app.vault.configDir, href); + if (vaultRelativeTarget) { + link.onclick = (event) => { + event.preventDefault(); + }; + return; + } + + if (!isAbsoluteFileHref(href)) return; + link.onclick = (event) => { + event.preventDefault(); + new Notice("Cannot open files outside the vault."); + }; + }); +} diff --git a/src/features/chat/ui/message-stream/text-content.tsx b/src/features/chat/ui/message-stream/text-content.tsx index 2ee8d448..0fa2bc6a 100644 --- a/src/features/chat/ui/message-stream/text-content.tsx +++ b/src/features/chat/ui/message-stream/text-content.tsx @@ -25,7 +25,7 @@ export function CollapsibleTextContent({ view, context }: { view: MessageStreamT return () => { content.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update); }; - }, [view.id, view.body, view.contentMode]); + }, [view.id, view.body, view.renderMode]); useEffect(() => { if (!overflows || !expanded) return; @@ -76,7 +76,7 @@ interface TextContentProps { } export function TextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode { - const rendersMarkdown = view.contentMode === "markdown"; + const rendersMarkdown = view.renderMode !== "text"; const text = view.body; const localRef = useRef(null); const contextRef = useRef(context); @@ -87,12 +87,14 @@ export function TextContent({ view, context, contentRef, collapsed = false }: Te const content = localRef.current; if (!content) return; const currentContext = contextRef.current; - if (rendersMarkdown) { - currentContext.renderMarkdown(content, text); + if (view.renderMode === "obsidianMarkdown") { + currentContext.renderObsidianMarkdown(content, text); + } else if (view.renderMode === "streamMarkdown") { + currentContext.renderStreamMarkdown(content, text); } else { content.textContent = text; } - }, [view.contentMode, rendersMarkdown, text]); + }, [view.renderMode, text]); return (
{ diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index e0c9b508..d45537a0 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -582,7 +582,8 @@ function messageStreamSurfaceContext(options: { options.dispatch({ type: "ui/message-fork-actions-set", itemId }); }, loadOlderTurns: vi.fn(), - renderMarkdown: vi.fn(), + renderObsidianMarkdown: vi.fn(), + renderStreamMarkdown: vi.fn(), copyMessageText: vi.fn(), actions: { rollbackThread: vi.fn(), @@ -637,7 +638,7 @@ async function renderedInternalLink( vaultPath: context.vaultPath, }); - markdownRenderer.renderMarkdown(parent, "[[Link]]"); + markdownRenderer.renderObsidianMarkdown(parent, "[[Link]]"); await Promise.resolve(); await Promise.resolve(); renderMarkdown.mockRestore(); diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx index 5f4b8371..db8fbea4 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -615,7 +615,7 @@ describe("message stream rendering and message actions", () => { forkActionsItemId: null, loadOlderTurns: vi.fn(), renderMarkdown: (element: HTMLElement, text: string) => { - markdownRenderer.renderMarkdown(element, text); + markdownRenderer.renderObsidianMarkdown(element, text); }, }; @@ -670,24 +670,15 @@ describe("message stream rendering and message actions", () => { unmountUiRootInAct(parent); }); - it("ignores stale async markdown renders after streaming content updates", async () => { + it("uses stream markdown for streaming assistant responses without calling the Obsidian renderer", async () => { const parent = document.createElement("div"); - const firstRender = deferred(); const renderMarkdown = vi.spyOn(MarkdownRenderer, "render"); - renderMarkdown - .mockImplementationOnce((_app, text: string, element: HTMLElement) => - firstRender.promise.then(() => { - element.textContent = `stale:${text}`; - }), - ) - .mockImplementation((_app, text: string, element: HTMLElement) => { - element.textContent = `fresh:${text}`; - return Promise.resolve(); - }); - const markdownRenderer = new MarkdownMessageRenderer({ - app: { workspace: { getActiveFile: vi.fn(() => null) } } as never, - owner: {} as never, - vaultPath: "/vault", + renderMarkdown.mockImplementation((_app, text: string, element: HTMLElement) => { + element.textContent = `obsidian:${text}`; + return Promise.resolve(); + }); + const renderStreamMarkdown = vi.fn((element: HTMLElement, text: string) => { + element.replaceChildren(element.createEl("strong", { text: `stream:${text.replace(/\*/g, "")}` })); }); const baseContext = { activeThreadId: "thread", @@ -697,9 +688,8 @@ describe("message stream rendering and message actions", () => { disclosures: emptyDisclosures(), forkActionsItemId: null, loadOlderTurns: vi.fn(), - renderMarkdown: (element: HTMLElement, text: string) => { - markdownRenderer.renderMarkdown(element, text); - }, + renderObsidianMarkdown: vi.fn(), + renderStreamMarkdown, }; renderMessageStreamBlocksInAct( @@ -719,34 +709,59 @@ describe("message stream rendering and message actions", () => { ], }), ); - const staleContent = expectPresent(parent.querySelector(".codex-panel__message-content")); + + expect(parent.querySelector(".codex-panel__message-content")?.innerHTML).toBe("stream:old"); + expect(renderStreamMarkdown).toHaveBeenCalledWith(expect.any(HTMLElement), "old"); + expect(baseContext.renderObsidianMarkdown).not.toHaveBeenCalled(); + expect(renderMarkdown).not.toHaveBeenCalled(); + renderMarkdown.mockRestore(); + unmountUiRootInAct(parent); + }); + + it("uses Obsidian markdown for completed assistant responses", async () => { + const parent = document.createElement("div"); + const renderMarkdown = vi.spyOn(MarkdownRenderer, "render"); + renderMarkdown.mockImplementationOnce((_app, text: string, element: HTMLElement) => { + element.textContent = `obsidian:${text}`; + return Promise.resolve(); + }); + const markdownRenderer = new MarkdownMessageRenderer({ + app: { workspace: { getActiveFile: vi.fn(() => null) } } as never, + owner: {} as never, + vaultPath: "/vault", + }); renderMessageStreamBlocksInAct( parent, messageStreamBlocks({ - ...baseContext, + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + disclosures: emptyDisclosures(), + forkActionsItemId: null, + loadOlderTurns: vi.fn(), + renderObsidianMarkdown: (element: HTMLElement, text: string) => { + markdownRenderer.renderObsidianMarkdown(element, text); + }, + renderStreamMarkdown: vi.fn(), items: [ { id: "a1", kind: "message", role: "assistant", - text: "new", + text: "**done**", turnId: "turn-1", messageKind: "assistantResponse", - messageState: "streaming", + messageState: "completed", }, ], }), ); await Promise.resolve(); - expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("fresh:new"); - firstRender.resolve(undefined); - await Promise.resolve(); - await Promise.resolve(); - - expect(staleContent.isConnected).toBe(true); - expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("fresh:new"); + expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("obsidian:**done**"); + expect(renderMarkdown).toHaveBeenCalledOnce(); renderMarkdown.mockRestore(); unmountUiRootInAct(parent); }); @@ -772,8 +787,8 @@ describe("message stream rendering and message actions", () => { vaultPath: "/vault", }); - markdownRenderer.renderMarkdown(parent, "old"); - markdownRenderer.renderMarkdown(parent, "new"); + markdownRenderer.renderObsidianMarkdown(parent, "old"); + markdownRenderer.renderObsidianMarkdown(parent, "new"); await Promise.resolve(); expect(parent.textContent).toBe("fresh:new"); diff --git a/tests/features/chat/ui/message-stream/stream-markdown-renderer.test.ts b/tests/features/chat/ui/message-stream/stream-markdown-renderer.test.ts new file mode 100644 index 00000000..642270a9 --- /dev/null +++ b/tests/features/chat/ui/message-stream/stream-markdown-renderer.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TFile } from "obsidian"; + +import { StreamMarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/stream-markdown-renderer"; +import { notices } from "../../../../mocks/obsidian"; +import { installObsidianDomShims } from "../../../../support/dom"; + +installObsidianDomShims(); + +describe("StreamMarkdownMessageRenderer", () => { + beforeEach(() => { + notices.length = 0; + }); + + it("renders basic markdown through micromark", () => { + const renderer = streamMarkdownRenderer(); + const parent = document.createElement("div"); + + renderer.renderStreamMarkdown(parent, "# Title\n\n**bold** text"); + + expect(parent.querySelector("h1")?.textContent).toBe("Title"); + expect(parent.querySelector("strong")?.textContent).toBe("bold"); + expect(parent.textContent).toBe("Title\nbold text"); + }); + + it("leaves wikilinks as plain text while streaming", () => { + const renderer = streamMarkdownRenderer(); + const parent = document.createElement("div"); + + renderer.renderStreamMarkdown(parent, "[[Project Note]]"); + + expect(parent.textContent).toBe("[[Project Note]]"); + expect(parent.querySelector("a")).toBeNull(); + }); + + it("opens safe vault file links through Obsidian", () => { + const openLinkText = vi.fn(); + const renderer = streamMarkdownRenderer({ openLinkText, vaultPath: "/Users/showhey/Vault", vaultFiles: ["docs/Guide.md"] }); + const parent = document.createElement("div"); + + renderer.renderStreamMarkdown(parent, "[Guide](/Users/showhey/Vault/docs/Guide.md)"); + parent.querySelector("a")?.click(); + + expect(openLinkText).toHaveBeenCalledWith("docs/Guide.md", "Inbox.md", false); + }); + + it("does not open absolute file links outside the vault", () => { + const openLinkText = vi.fn(); + const renderer = streamMarkdownRenderer({ openLinkText, vaultPath: "/Users/showhey/Vault" }); + const parent = document.createElement("div"); + + renderer.renderStreamMarkdown(parent, "[Readme](/Users/showhey/Other/README.md)"); + parent.querySelector("a")?.click(); + + expect(openLinkText).not.toHaveBeenCalled(); + expect(notices).toEqual(["Cannot open files outside the vault."]); + }); + + it("prevents unresolved vault file links from navigating", () => { + const openLinkText = vi.fn(); + const renderer = streamMarkdownRenderer({ openLinkText, vaultPath: "/Users/showhey/Vault" }); + const parent = document.createElement("div"); + + renderer.renderStreamMarkdown(parent, "[Missing](/Users/showhey/Vault/docs/Missing.md)"); + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + parent.querySelector("a")?.dispatchEvent(event); + + expect(openLinkText).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + expect(notices).toEqual([]); + }); +}); + +function streamMarkdownRenderer(options: { openLinkText?: ReturnType; vaultPath?: string; vaultFiles?: string[] } = {}) { + const files = new Map((options.vaultFiles ?? []).map((path) => [path, tFile(path)])); + return new StreamMarkdownMessageRenderer({ + app: { + workspace: { + getActiveFile: vi.fn(() => tFile("Inbox.md")), + openLinkText: options.openLinkText ?? vi.fn(), + }, + vault: { + configDir: "vault-config", + getAbstractFileByPath: (path: string) => files.get(path) ?? null, + }, + } as never, + vaultPath: options.vaultPath ?? "/vault", + }); +} + +function tFile(path: string): TFile { + const basename = path.split("/").pop()?.replace(/\.md$/, "") ?? path; + return Object.assign(new TFile(), { path, basename }); +} diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx index 173f4f71..2921db35 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -59,8 +59,12 @@ function messageStreamBlockItemsEmpty(context: TestMessageStreamContext): boolea return (context.stableItems?.length ?? 0) === 0 && (context.activeItems?.length ?? 0) === 0; } -type TestMessageStreamContext = Omit & - Partial> & { +type TestMessageStreamContext = Omit< + MessageStreamContext, + "disclosures" | "forkActionsItemId" | "renderObsidianMarkdown" | "renderStreamMarkdown" +> & + Partial> & { + renderMarkdown?: (parent: HTMLElement, text: string) => void; turnLifecycle: MessageStreamTurnLifecycleState; historyCursor: string | null; loadingHistory: boolean; @@ -94,10 +98,19 @@ export function testDisclosures( } function normalizeMessageStreamContext(context: TestMessageStreamContext): MessageStreamContext { + const renderObsidianMarkdown = + context.renderObsidianMarkdown ?? + context.renderMarkdown ?? + ((parent: HTMLElement, text: string) => { + parent.createDiv({ text }); + }); + const renderStreamMarkdown = context.renderStreamMarkdown ?? context.renderMarkdown ?? renderObsidianMarkdown; return { ...context, disclosures: context.disclosures ?? emptyDisclosures(), forkActionsItemId: context.forkActionsItemId ?? null, + renderObsidianMarkdown, + renderStreamMarkdown, }; }