diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash
index cb16a408d..c955eb427 100644
--- a/nix/npm-deps.hash
+++ b/nix/npm-deps.hash
@@ -1 +1 @@
-sha256-tB0WZG6F6VFMS5zFqo73giSAn7SqfkJISF5fMjDioJQ=
+sha256-m6anpp3ESvWCVQAoTsOhnTMpoewhpJTeVha2Gv4Xnc4=
diff --git a/package-lock.json b/package-lock.json
index 2147d093a..de6b745bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -109,6 +109,19 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
+ "node_modules/@antfu/install-pkg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
+ "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "package-manager-detector": "^1.3.0",
+ "tinyexec": "^1.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
"node_modules/@anthropic-ai/sdk": {
"version": "0.104.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz",
@@ -2362,6 +2375,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@braintree/sanitize-url": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
+ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
+ "license": "MIT"
+ },
+ "node_modules/@chevrotain/types": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
+ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
+ "license": "Apache-2.0"
+ },
"node_modules/@clack/core": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz",
@@ -2523,14 +2548,6 @@
"node": ">=16"
}
},
- "node_modules/@cloudflare/workers-types": {
- "version": "4.20260509.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260509.1.tgz",
- "integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==",
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "peer": true
- },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
@@ -6422,6 +6439,23 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "license": "MIT"
+ },
+ "node_modules/@iconify/utils": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz",
+ "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@antfu/install-pkg": "^1.1.0",
+ "@iconify/types": "^2.0.0",
+ "import-meta-resolve": "^4.2.0"
+ }
+ },
"node_modules/@ide/backoff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
@@ -7728,6 +7762,15 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
+ "node_modules/@mermaid-js/parser": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
+ "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
+ "license": "MIT",
+ "dependencies": {
+ "@chevrotain/types": "~11.1.2"
+ }
+ },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
@@ -12313,6 +12356,259 @@
"@types/node": "*"
}
},
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz",
+ "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -12380,6 +12676,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -12684,6 +12986,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
@@ -13459,6 +13768,16 @@
"win32"
]
},
+ "node_modules/@upsetjs/venn.js": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
+ "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "d3-selection": "^3.0.0",
+ "d3-transition": "^3.0.1"
+ }
+ },
"node_modules/@urql/core": {
"version": "4.0.11",
"resolved": "https://registry.npmjs.org/@urql/core/-/core-4.0.11.tgz",
@@ -16389,6 +16708,15 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/cose-base": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
+ "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==",
+ "license": "MIT",
+ "dependencies": {
+ "layout-base": "^1.0.0"
+ }
+ },
"node_modules/crc": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
@@ -16612,6 +16940,505 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/cytoscape": {
+ "version": "3.34.0",
+ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
+ "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/cytoscape-cose-bilkent": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz",
+ "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cose-base": "^1.0.0"
+ },
+ "peerDependencies": {
+ "cytoscape": "^3.2.0"
+ }
+ },
+ "node_modules/cytoscape-fcose": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
+ "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cose-base": "^2.2.0"
+ },
+ "peerDependencies": {
+ "cytoscape": "^3.2.0"
+ }
+ },
+ "node_modules/cytoscape-fcose/node_modules/cose-base": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz",
+ "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
+ "license": "MIT",
+ "dependencies": {
+ "layout-base": "^2.0.0"
+ }
+ },
+ "node_modules/cytoscape-fcose/node_modules/layout-base": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz",
+ "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
+ "license": "MIT"
+ },
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-sankey": {
+ "version": "0.12.3",
+ "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
+ "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "1 - 2",
+ "d3-shape": "^1.2.0"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/d3-array": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "internmap": "^1.0.0"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/d3-path": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
+ "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-sankey/node_modules/d3-shape": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
+ "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-path": "1"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/internmap": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
+ "license": "ISC"
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/dagre-d3-es": {
+ "version": "7.0.14",
+ "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
+ "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
+ "license": "MIT",
+ "dependencies": {
+ "d3": "^7.9.0",
+ "lodash-es": "^4.17.21"
+ }
+ },
"node_modules/data-urls": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
@@ -16737,6 +17564,12 @@
"node": "*"
}
},
+ "node_modules/dayjs": {
+ "version": "1.11.21",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -16979,6 +17812,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/delaunator": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
+ "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -17288,6 +18130,15 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/dompurify": {
+ "version": "3.4.12",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
+ "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -18118,6 +18969,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es-toolkit": {
+ "version": "1.49.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
+ "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -18337,21 +19198,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/eslint-config-prettier": {
- "version": "8.10.2",
- "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz",
- "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "bin": {
- "eslint-config-prettier": "bin/cli.js"
- },
- "peerDependencies": {
- "eslint": ">=7.0.0"
- }
- },
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -22002,6 +22848,12 @@
}
}
},
+ "node_modules/hachure-fill": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
+ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
+ "license": "MIT"
+ },
"node_modules/hard-rejection": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
@@ -22561,7 +23413,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -22649,6 +23500,16 @@
"node": ">=4"
}
},
+ "node_modules/import-meta-resolve": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
+ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -22737,6 +23598,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -24344,6 +25214,31 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/katex": {
+ "version": "0.16.47",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
+ "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/katex/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/keychain": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/keychain/-/keychain-1.5.0.tgz",
@@ -24361,6 +25256,11 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/khroma": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
+ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
+ },
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -24442,6 +25342,12 @@
"lan-network": "dist/lan-network-cli.js"
}
},
+ "node_modules/layout-base": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
+ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
+ "license": "MIT"
+ },
"node_modules/lazy-val": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
@@ -24965,6 +25871,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
@@ -25247,6 +26159,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/marked": {
+ "version": "16.4.2",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
+ "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/marky": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
@@ -25710,6 +26634,48 @@
"node": ">= 8"
}
},
+ "node_modules/mermaid": {
+ "version": "11.16.0",
+ "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
+ "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
+ "license": "MIT",
+ "dependencies": {
+ "@braintree/sanitize-url": "^7.1.2",
+ "@iconify/utils": "^3.0.2",
+ "@mermaid-js/parser": "^1.2.0",
+ "@types/d3": "^7.4.3",
+ "@upsetjs/venn.js": "^2.0.0",
+ "cytoscape": "^3.33.3",
+ "cytoscape-cose-bilkent": "^4.1.0",
+ "cytoscape-fcose": "^2.2.0",
+ "d3": "^7.9.0",
+ "d3-sankey": "^0.12.3",
+ "dagre-d3-es": "7.0.14",
+ "dayjs": "^1.11.20",
+ "dompurify": "^3.3.3",
+ "es-toolkit": "^1.45.1",
+ "katex": "^0.16.45",
+ "khroma": "^2.1.0",
+ "marked": "^16.3.0",
+ "roughjs": "^4.6.6",
+ "stylis": "^4.3.6",
+ "ts-dedent": "^2.2.0",
+ "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
+ }
+ },
+ "node_modules/mermaid/node_modules/uuid": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
+ "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -28213,6 +29179,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
+ "node_modules/package-manager-detector": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
+ "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
+ "license": "MIT"
+ },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -28378,6 +29350,12 @@
"npm": ">5"
}
},
+ "node_modules/path-data-parser": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
+ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
+ "license": "MIT"
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -28744,6 +29722,22 @@
"node": ">=14.19.0"
}
},
+ "node_modules/points-on-curve": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
+ "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==",
+ "license": "MIT"
+ },
+ "node_modules/points-on-path": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz",
+ "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==",
+ "license": "MIT",
+ "dependencies": {
+ "path-data-parser": "0.1.0",
+ "points-on-curve": "0.2.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -29673,18 +30667,6 @@
"react-native-worklets": "0.8.x"
}
},
- "node_modules/react-native-reanimated/node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/react-native-safe-area-context": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
@@ -29836,18 +30818,6 @@
"react-native": "0.81 - 0.85"
}
},
- "node_modules/react-native-worklets/node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/react-native/node_modules/@react-native/normalize-colors": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz",
@@ -30729,6 +31699,12 @@
"license": "BSD-3-Clause",
"optional": true
},
+ "node_modules/robust-predicates": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+ "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
+ "license": "Unlicense"
+ },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -30791,6 +31767,18 @@
"integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==",
"license": "MIT"
},
+ "node_modules/roughjs": {
+ "version": "4.6.6",
+ "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
+ "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "hachure-fill": "^0.5.2",
+ "path-data-parser": "^0.1.0",
+ "points-on-curve": "^0.2.0",
+ "points-on-path": "^0.2.1"
+ }
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -30847,6 +31835,12 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -32318,6 +33312,12 @@
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
"license": "MIT"
},
+ "node_modules/stylis": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
+ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
+ "license": "MIT"
+ },
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -32765,7 +33765,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -32980,6 +33979,15 @@
"typescript": ">=4.0.0"
}
},
+ "node_modules/ts-dedent": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz",
+ "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.10"
+ }
+ },
"node_modules/ts-deepmerge": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz",
@@ -35145,6 +36153,7 @@
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
+ "mermaid": "^11.16.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -35187,6 +36196,7 @@
"@xterm/headless": "^6.1.0-beta.213",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
+ "esbuild": "0.28.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"expo-gradle-jvmargs": "^1.1.2",
@@ -35285,15 +36295,6 @@
"node": ">=16"
}
},
- "packages/app/node_modules/@cloudflare/workers-types": {
- "version": "4.20260627.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260627.1.tgz",
- "integrity": "sha512-QhVvier1eW9Ydw96N/Ez79i5CSGy7h39JucrUejQmAWQw9qBdlB1aOTjaD93EGYrV0t9U81YWetI3LcaxxILNw==",
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "peer": true
- },
"packages/app/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -37569,209 +38570,6 @@
"vitest": "^4.1.6"
}
},
- "packages/highlight/node_modules/@vitest/expect": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
- "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@standard-schema/spec": "^1.1.0",
- "@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.6",
- "@vitest/utils": "4.1.6",
- "chai": "^6.2.2",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "packages/highlight/node_modules/@vitest/pretty-format": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
- "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "packages/highlight/node_modules/@vitest/runner": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
- "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "4.1.6",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "packages/highlight/node_modules/@vitest/snapshot": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
- "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.6",
- "@vitest/utils": "4.1.6",
- "magic-string": "^0.30.21",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "packages/highlight/node_modules/@vitest/spy": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
- "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "packages/highlight/node_modules/@vitest/utils": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
- "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.6",
- "convert-source-map": "^2.0.0",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "packages/highlight/node_modules/vitest": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
- "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "4.1.6",
- "@vitest/mocker": "4.1.6",
- "@vitest/pretty-format": "4.1.6",
- "@vitest/runner": "4.1.6",
- "@vitest/snapshot": "4.1.6",
- "@vitest/spy": "4.1.6",
- "@vitest/utils": "4.1.6",
- "es-module-lexer": "^2.0.0",
- "expect-type": "^1.3.0",
- "magic-string": "^0.30.21",
- "obug": "^2.1.1",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.3",
- "std-env": "^4.0.0-rc.1",
- "tinybench": "^2.9.0",
- "tinyexec": "^1.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.1.0",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@opentelemetry/api": "^1.9.0",
- "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.6",
- "@vitest/browser-preview": "4.1.6",
- "@vitest/browser-webdriverio": "4.1.6",
- "@vitest/coverage-istanbul": "4.1.6",
- "@vitest/coverage-v8": "4.1.6",
- "@vitest/ui": "4.1.6",
- "happy-dom": "*",
- "jsdom": "*",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser-playwright": {
- "optional": true
- },
- "@vitest/browser-preview": {
- "optional": true
- },
- "@vitest/browser-webdriverio": {
- "optional": true
- },
- "@vitest/coverage-istanbul": {
- "optional": true
- },
- "@vitest/coverage-v8": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- },
- "vite": {
- "optional": false
- }
- }
- },
- "packages/highlight/node_modules/vitest/node_modules/@vitest/mocker": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
- "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "4.1.6",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.3.1",
@@ -39525,115 +40323,6 @@
"undici-types": "~6.21.0"
}
},
- "packages/website/node_modules/@vitest/browser": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz",
- "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@blazediff/core": "1.9.1",
- "@vitest/mocker": "4.1.10",
- "@vitest/utils": "4.1.10",
- "magic-string": "^0.30.21",
- "pngjs": "^7.0.0",
- "sirv": "^3.0.2",
- "tinyrainbow": "^3.1.0",
- "ws": "^8.19.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "vitest": "4.1.10"
- }
- },
- "packages/website/node_modules/@vitest/browser-playwright": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
- "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@vitest/browser": "4.1.10",
- "@vitest/mocker": "4.1.10",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "playwright": "*",
- "vitest": "4.1.10"
- },
- "peerDependenciesMeta": {
- "playwright": {
- "optional": false
- }
- }
- },
- "packages/website/node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
- "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@vitest/spy": "4.1.10",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "packages/website/node_modules/@vitest/browser/node_modules/@vitest/mocker": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
- "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@vitest/spy": "4.1.10",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
"packages/website/node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
diff --git a/packages/app/e2e/browser/mermaid-streaming.spec.ts b/packages/app/e2e/browser/mermaid-streaming.spec.ts
new file mode 100644
index 000000000..a9f87862c
--- /dev/null
+++ b/packages/app/e2e/browser/mermaid-streaming.spec.ts
@@ -0,0 +1,68 @@
+import { test } from "../support/fixtures";
+import {
+ expectCompletedDiagram,
+ expectDiagramRemainsRenderedWhileStreaming,
+ expectDiagramWithLabels,
+ reloadConversation,
+ requestDiagram,
+ waitForDiagramTurnToComplete,
+} from "../support/helpers/diagram";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+
+const STREAMED_MERMAID = [
+ "```mermaid",
+ "flowchart LR",
+ " Start --> Middle",
+ ' Middle --> Done["Done"]',
+ " Middle --> Review",
+ " Review --> Verify",
+ " Verify --> Ship",
+ " Ship --> Package",
+ " Package --> Sign",
+ " Sign --> Upload",
+ " Upload --> Publish",
+ " Publish --> Deploy",
+ " Deploy --> Observe",
+ " Observe --> Validate",
+ " Validate --> Announce",
+ " Announce --> Document",
+ " Document --> Archive",
+ " Archive --> Release",
+ "```",
+].join("\n");
+
+test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "mermaid-streaming-",
+ title: "Streaming Mermaid",
+ featureValues: {
+ mockStreamingAssistantResponse: STREAMED_MERMAID,
+ mockStreamingAssistantIntervalMs: 150,
+ },
+ });
+ try {
+ await test.step("Open the conversation and request a diagram", async () => {
+ await openAgentRoute(page, agent);
+ await requestDiagram(agent);
+ });
+
+ await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
+ await expectDiagramWithLabels(page, ["Start", "Middle"]);
+ await expectDiagramRemainsRenderedWhileStreaming(page);
+ });
+
+ await test.step("The completed diagram shows the final streamed content", async () => {
+ await waitForDiagramTurnToComplete(agent);
+ await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
+ });
+
+ await test.step("The completed diagram remains rendered after reload", async () => {
+ await reloadConversation(page);
+ await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
+ });
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/support/helpers/diagram.ts b/packages/app/e2e/support/helpers/diagram.ts
new file mode 100644
index 000000000..f3d2742c1
--- /dev/null
+++ b/packages/app/e2e/support/helpers/diagram.ts
@@ -0,0 +1,48 @@
+import { expect, type Page } from "@playwright/test";
+import type { MockAgentWorkspace } from "./mock-agent";
+
+const DIAGRAM_NAME = "Diagram";
+
+function renderedDiagram(page: Page) {
+ const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
+ const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg");
+ return { diagram, svg };
+}
+
+export async function requestDiagram(agent: MockAgentWorkspace): Promise {
+ await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram.");
+}
+
+export async function expectDiagramWithLabels(
+ page: Page,
+ labels: readonly string[],
+): Promise {
+ const { diagram, svg } = renderedDiagram(page);
+ await expect(diagram).toBeVisible({ timeout: 30_000 });
+ await expect(svg).toBeVisible({ timeout: 30_000 });
+ for (const label of labels) {
+ await expect(svg).toContainText(label);
+ }
+}
+
+export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise {
+ const { diagram, svg } = renderedDiagram(page);
+ const samples = 80;
+ for (let sample = 0; sample < samples; sample += 1) {
+ await expect(diagram).toBeVisible({ timeout: 100 });
+ await expect(svg).toBeVisible({ timeout: 100 });
+ await page.waitForTimeout(25);
+ }
+}
+
+export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise {
+ await agent.client.waitForFinish(agent.agentId, 30_000);
+}
+
+export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise {
+ await expectDiagramWithLabels(page, labels);
+}
+
+export async function reloadConversation(page: Page): Promise {
+ await page.reload();
+}
diff --git a/packages/app/package.json b/packages/app/package.json
index 6b1901f7c..cd15f23a4 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -29,7 +29,8 @@
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
- "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
+ "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
+ "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -92,6 +93,7 @@
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
+ "mermaid": "^11.16.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -134,6 +136,7 @@
"@xterm/headless": "^6.1.0-beta.213",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
+ "esbuild": "0.28.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"expo-gradle-jvmargs": "^1.1.2",
diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts
index 2fce5c437..1cc645f16 100644
--- a/packages/app/src/agent-stream/layout.test.ts
+++ b/packages/app/src/agent-stream/layout.test.ts
@@ -143,6 +143,30 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
describe("layoutStream", () => {
+ it.each(["web", "android"] as const)(
+ "marks only the active live-head assistant block as streaming on %s",
+ (platform) => {
+ const completed = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
+ const live = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 });
+ const active = layoutFor({
+ platform,
+ isTurnActive: true,
+ tail: [userMessage("u1", 1), completed],
+ head: [live],
+ });
+ const complete = layoutFor({
+ platform,
+ isTurnActive: false,
+ tail: [userMessage("u1", 1), completed],
+ head: [live],
+ });
+
+ expect(findLayoutItem(active, completed.id).phase).toBe("complete");
+ expect(findLayoutItem(active, live.id).phase).toBe("streaming");
+ expect(findLayoutItem(complete, live.id).phase).toBe("complete");
+ },
+ );
+
it.each(["web", "android"] as const)(
"keeps split assistant block spacing identical to unsplit history on %s",
(platform) => {
diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts
index 9249f8159..990b1f807 100644
--- a/packages/app/src/agent-stream/layout.ts
+++ b/packages/app/src/agent-stream/layout.ts
@@ -26,6 +26,7 @@ export interface StreamLayoutItem {
isLastInUserGroup: boolean;
isLastInToolSequence: boolean;
frameOrder: StreamFrameChildOrder;
+ phase: "streaming" | "complete";
}
export interface StreamLayout {
@@ -53,6 +54,7 @@ interface LayoutSegmentInput {
boundaryBelowItem: StreamItem | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
+ phase: "streaming" | "complete";
}
interface AssistantFooterSource {
@@ -273,6 +275,7 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
frameOrder: input.frameOrder,
+ phase: input.phase,
};
});
}
@@ -324,6 +327,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryBelowItem: liveHeadBoundaryItem,
boundaryAboveItems: null,
boundaryAboveIndex: null,
+ phase: "complete",
});
byKey.set(historyCacheKey, history);
}
@@ -342,6 +346,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryBelowItem: null,
boundaryAboveItems: input.history,
boundaryAboveIndex: historyBoundaryIndex,
+ phase: input.isTurnActive ? "streaming" : "complete",
});
return {
diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx
index 61777cb21..98be7dd91 100644
--- a/packages/app/src/agent-stream/strategy-web.test.tsx
+++ b/packages/app/src/agent-stream/strategy-web.test.tsx
@@ -206,6 +206,78 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
+ it("keeps a row mounted when it moves from the live head into mounted history", () => {
+ const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
+ const viewportRef = React.createRef();
+ const item = userMessage(1);
+ const mounted = vi.fn();
+ const unmounted = vi.fn();
+ function StatefulRow() {
+ React.useEffect(() => {
+ mounted();
+ return unmounted;
+ }, []);
+ return {item.id}
;
+ }
+ const renderers: StreamSegmentRenderers = {
+ ...createRenderers(vi.fn()),
+ renderHistoryMountedRow: () => ,
+ renderLiveHeadRow: () => ,
+ };
+ const renderInput: Omit = {
+ agentId: "agent",
+ renderers,
+ listEmptyComponent: null,
+ viewportRef,
+ routeBottomAnchorRequest: null,
+ isAuthoritativeHistoryReady: true,
+ onNearBottomChange: vi.fn(),
+ onNearHistoryStart: vi.fn().mockReturnValue(true),
+ isLoadingOlderHistory: false,
+ hasOlderHistory: false,
+ olderHistoryProgressKey: null,
+ scrollEnabled: true,
+ listStyle: null,
+ baseListContentContainerStyle: null,
+ forwardListContentContainerStyle: null,
+ };
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ act(() => {
+ root?.render(
+ strategy.render({
+ ...renderInput,
+ segments: { historyVirtualized: [], historyMounted: [], liveHead: [item] },
+ boundary: {
+ hasVirtualizedHistory: false,
+ hasMountedHistory: false,
+ hasLiveHead: true,
+ },
+ }),
+ );
+ });
+ expect(mounted).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ root?.render(
+ strategy.render({
+ ...renderInput,
+ segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
+ boundary: {
+ hasVirtualizedHistory: false,
+ hasMountedHistory: true,
+ hasLiveHead: false,
+ },
+ }),
+ );
+ });
+
+ expect(mounted).toHaveBeenCalledTimes(1);
+ expect(unmounted).not.toHaveBeenCalled();
+ });
+
it("reports the live-head row as the reading position", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef();
diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx
index 08781f80b..db244719b 100644
--- a/packages/app/src/agent-stream/strategy-web.tsx
+++ b/packages/app/src/agent-stream/strategy-web.tsx
@@ -1068,6 +1068,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
));
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
+ const mountedRows = useMemo(
+ () => [...mountedHistoryRows, ...liveHeadRows],
+ [liveHeadRows, mountedHistoryRows],
+ );
const liveAuxiliary = useMemo(() => {
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
@@ -1119,8 +1123,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
})}
) : null}
- {mountedHistoryRows}
- {liveHeadRows}
+ {mountedRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx
index 35a8afef5..7203ca380 100644
--- a/packages/app/src/agent-stream/view.tsx
+++ b/packages/app/src/agent-stream/view.tsx
@@ -653,6 +653,7 @@ const AgentStreamViewComponent = forwardRef
);
diff --git a/packages/app/src/components/markdown/fence/index.tsx b/packages/app/src/components/markdown/fence/index.tsx
new file mode 100644
index 000000000..846aa9135
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/index.tsx
@@ -0,0 +1,42 @@
+import type { ComponentType } from "react";
+import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
+import { getMarkdownFenceLanguage } from "./language";
+import { MermaidFence } from "./mermaid";
+import type { MarkdownFenceRendererProps } from "./types";
+
+export interface MarkdownFenceBlockProps extends MarkdownFenceRendererProps {
+ info: string | null | undefined;
+}
+
+const diagramFences: Partial>> = {
+ mermaid: MermaidFence,
+};
+
+export function MarkdownFenceBlock({
+ code,
+ info,
+ phase,
+ inheritedStyles,
+ textStyle,
+}: MarkdownFenceBlockProps) {
+ const language = getMarkdownFenceLanguage(info);
+ const DiagramFence = language ? diagramFences[language] : undefined;
+ if (DiagramFence) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/markdown/fence/language.test.ts b/packages/app/src/components/markdown/fence/language.test.ts
new file mode 100644
index 000000000..172f72c31
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/language.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import { getMarkdownFenceLanguage } from "./language";
+
+describe("getMarkdownFenceLanguage", () => {
+ it("normalizes the first info-string token", () => {
+ expect(getMarkdownFenceLanguage(" Sirena theme=dark ")).toBe("sirena");
+ expect(getMarkdownFenceLanguage("TypeScript title=example")).toBe("typescript");
+ });
+
+ it("returns null when no language is declared", () => {
+ expect(getMarkdownFenceLanguage("")).toBeNull();
+ expect(getMarkdownFenceLanguage(null)).toBeNull();
+ expect(getMarkdownFenceLanguage(undefined)).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/markdown/fence/language.ts b/packages/app/src/components/markdown/fence/language.ts
new file mode 100644
index 000000000..fd414a5ac
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/language.ts
@@ -0,0 +1,3 @@
+export function getMarkdownFenceLanguage(info: string | null | undefined): string | null {
+ return info?.trim().split(/\s+/)[0]?.toLowerCase() || null;
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs b/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs
new file mode 100644
index 000000000..e4f61b889
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs
@@ -0,0 +1,76 @@
+import esbuild from "esbuild";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const appRoot = path.resolve(__dirname, "../../../../..");
+const repoRoot = path.resolve(appRoot, "../..");
+const entry = path.join(__dirname, "runtime/entry.ts");
+const output = path.join(__dirname, "runtime/html.gen.ts");
+
+const result = await esbuild.build({
+ entryPoints: [entry],
+ bundle: true,
+ write: false,
+ format: "iife",
+ platform: "browser",
+ target: ["ios15", "chrome100"],
+ minify: true,
+ // The app's deployment floor is iOS 15.1 but structuredClone (used by
+ // mermaid) only exists from iOS 15.4; esbuild targets transform syntax,
+ // not runtime APIs. Mermaid only clones plain config objects, so a
+ // JSON-based fallback is sufficient.
+ banner: {
+ js: 'if(typeof globalThis.structuredClone!=="function"){globalThis.structuredClone=function(v){return v===undefined?v:JSON.parse(JSON.stringify(v));};}',
+ },
+ logLevel: "info",
+});
+
+const rawJs = result.outputFiles[0]?.text;
+if (!rawJs) {
+ throw new Error("Mermaid runtime bundle produced no JavaScript");
+}
+// JSON.stringify below escapes the TypeScript representation, not the runtime
+// HTML — an embedded closing-script sequence would still terminate the inline
+//
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+const contents = `// Generated by packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs.
+// Do not edit by hand.
+
+export const mermaidRuntimeHtml = ${JSON.stringify(html)};
+`;
+
+await fs.writeFile(output, contents);
+console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
diff --git a/packages/app/src/components/markdown/fence/mermaid/host.native.tsx b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx
new file mode 100644
index 000000000..044341f3a
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx
@@ -0,0 +1,379 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Modal,
+ Pressable,
+ StyleSheet as RNStyleSheet,
+ ScrollView,
+ View,
+ type TextStyle,
+ type ViewStyle,
+} from "react-native";
+import { WebView, type WebViewMessageEvent } from "react-native-webview";
+import { Code, Workflow, X } from "lucide-react-native";
+import { useTranslation } from "react-i18next";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
+import type { Theme } from "@/styles/theme";
+import type { MarkdownFenceRendererProps } from "../types";
+import type { MermaidRenderRequest } from "./render-model";
+import { mermaidRuntimeHtml } from "./runtime/html.gen";
+import {
+ parseMermaidRuntimeMessage,
+ serializeMermaidRuntimeRenderMessage,
+ type MermaidRuntimeRenderMessage,
+} from "./runtime/messages";
+import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
+import { useMermaidRenderModel } from "./use-render-model";
+import { getDiagramBoxStyle } from "./presentation";
+
+interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
+ colorScheme?: "light" | "dark";
+}
+
+const WEBVIEW_SOURCE = { html: mermaidRuntimeHtml };
+const WEBVIEW_ORIGIN_WHITELIST = ["*"];
+const MAX_PREVIEW_HEIGHT = 480;
+
+interface MermaidWebViewProps {
+ request: MermaidRenderRequest | null;
+ interactive: boolean;
+ onRendered: (message: {
+ revision: number;
+ source: string;
+ colorScheme: "light" | "dark";
+ height: number;
+ width: number;
+ }) => void;
+ onRenderFailed: (revision: number) => void;
+ style?: ViewStyle;
+}
+
+function MermaidWebView({
+ request,
+ interactive,
+ onRendered,
+ onRenderFailed,
+ style,
+}: MermaidWebViewProps) {
+ const webViewRef = useRef(null);
+ const driverRef = useRef(null);
+ driverRef.current ??= new MermaidRuntimeRequestDriver();
+
+ const sendRequest = useCallback(
+ (current: MermaidRenderRequest | null) => {
+ if (!current || !webViewRef.current) {
+ return;
+ }
+ const message: MermaidRuntimeRenderMessage = {
+ type: "render",
+ revision: current.revision,
+ source: current.source,
+ colorScheme: current.colorScheme,
+ interactive,
+ };
+ const payload = serializeMermaidRuntimeRenderMessage(message);
+ webViewRef.current.injectJavaScript(
+ `window.__PASEO_MERMAID_RUNTIME_RECEIVE__ && window.__PASEO_MERMAID_RUNTIME_RECEIVE__(${payload}); true;`,
+ );
+ },
+ [interactive],
+ );
+
+ useEffect(() => {
+ sendRequest(driverRef.current?.update(request) ?? null);
+ }, [request, sendRequest]);
+
+ const handleMessage = useCallback(
+ (event: WebViewMessageEvent) => {
+ let value: unknown;
+ try {
+ value = JSON.parse(event.nativeEvent.data);
+ } catch {
+ return;
+ }
+ const message = parseMermaidRuntimeMessage(value);
+ if (!message) {
+ return;
+ }
+ if (message.type === "bridgeReady") {
+ sendRequest(driverRef.current?.ready() ?? null);
+ return;
+ }
+ if (message.type === "renderError") {
+ onRenderFailed(message.revision);
+ sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
+ return;
+ }
+ onRendered(message);
+ sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
+ },
+ [onRenderFailed, onRendered, sendRequest],
+ );
+ const handleShouldStartLoad = useCallback(
+ (load: { url: string }) => load.url === "about:blank" || load.url.startsWith("data:"),
+ [],
+ );
+
+ return (
+
+ );
+}
+
+const webViewStyles = RNStyleSheet.create({
+ webView: { backgroundColor: "transparent" },
+});
+
+interface MermaidDiagramViewerProps {
+ code: string;
+ colorScheme: "light" | "dark";
+ onClose: () => void;
+ inheritedStyles: TextStyle;
+ textStyle: TextStyle;
+}
+
+function MermaidDiagramViewer({
+ code,
+ colorScheme,
+ onClose,
+ inheritedStyles,
+ textStyle,
+}: MermaidDiagramViewerProps) {
+ const { t } = useTranslation();
+ const [showSource, setShowSource] = useState(false);
+ const toggleSource = useCallback(() => setShowSource((current) => !current), []);
+ const { request, rendered, renderFailed } = useMermaidRenderModel({
+ source: code,
+ phase: "complete",
+ colorScheme,
+ });
+ const handleRendered = useCallback(
+ (message: {
+ revision: number;
+ source: string;
+ colorScheme: "light" | "dark";
+ height: number;
+ width: number;
+ }) => {
+ rendered({
+ revision: message.revision,
+ source: message.source,
+ colorScheme: message.colorScheme,
+ dimensions: { height: message.height, width: message.width },
+ });
+ },
+ [rendered],
+ );
+
+ return (
+
+
+
+
+ {showSource ? (
+
+
+
+ ) : null}
+
+
+
+ {showSource ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+const ThemedCloseIcon = withUnistyles(X);
+const ThemedSourceIcon = withUnistyles(Code);
+const ThemedDiagramIcon = withUnistyles(Workflow);
+const closeIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
+
+const viewerStyles = StyleSheet.create((theme, runtime) => ({
+ backdrop: { flex: 1, backgroundColor: theme.colors.surface0 },
+ webView: {
+ flex: 1,
+ marginTop: runtime.insets.top,
+ marginBottom: runtime.insets.bottom,
+ },
+ runtime: { flex: 1 },
+ sourceOverlay: {
+ position: "absolute",
+ inset: 0,
+ backgroundColor: theme.colors.surface0,
+ },
+ source: {
+ paddingTop: theme.spacing[12],
+ paddingHorizontal: theme.spacing[4],
+ paddingBottom: theme.spacing[4],
+ },
+ actions: {
+ position: "absolute",
+ top: runtime.insets.top + theme.spacing[3],
+ right: theme.spacing[4],
+ flexDirection: "row",
+ gap: theme.spacing[2],
+ },
+ actionButton: {
+ padding: theme.spacing[2],
+ borderRadius: theme.borderRadius.lg,
+ backgroundColor: theme.colors.surface2,
+ },
+}));
+
+function MermaidFenceHostImpl({
+ code,
+ phase,
+ inheritedStyles,
+ textStyle,
+ colorScheme = "dark",
+}: MermaidFenceHostImplProps) {
+ const { t } = useTranslation();
+ const { state, request, rendered, renderFailed } = useMermaidRenderModel({
+ source: code,
+ phase,
+ colorScheme,
+ });
+ const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
+ const handleRendered = useCallback(
+ (message: {
+ revision: number;
+ source: string;
+ colorScheme: "light" | "dark";
+ height: number;
+ width: number;
+ }) => {
+ setHasRuntimeContent(true);
+ rendered({
+ revision: message.revision,
+ source: message.source,
+ colorScheme: message.colorScheme,
+ dimensions: { height: message.height, width: message.width },
+ });
+ },
+ [rendered],
+ );
+ const [viewerOpen, setViewerOpen] = useState(false);
+ const openViewer = useCallback(() => setViewerOpen(true), []);
+ const closeViewer = useCallback(() => setViewerOpen(false), []);
+ const visible = state.visible;
+ const canShowDiagram = visible !== null && hasRuntimeContent;
+ const previewInnerStyle = useMemo(
+ () =>
+ canShowDiagram && visible
+ ? { height: Math.min(visible.height, MAX_PREVIEW_HEIGHT) }
+ : previewStyles.measuringInner,
+ [canShowDiagram, visible],
+ );
+ const diagramBoxStyle = getDiagramBoxStyle(textStyle);
+
+ return (
+ <>
+ {!canShowDiagram ? (
+
+ ) : null}
+
+
+
+
+
+ {viewerOpen && canShowDiagram && visible ? (
+
+ ) : null}
+ >
+ );
+}
+
+const previewStyles = RNStyleSheet.create({
+ measuring: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ opacity: 0,
+ pointerEvents: "none",
+ },
+ measuringInner: { height: 240 },
+ preview: { overflow: "hidden" },
+});
+
+const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
+const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
+
+export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
+ return ;
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/host.tsx b/packages/app/src/components/markdown/fence/mermaid/host.tsx
new file mode 100644
index 000000000..50e959674
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/host.tsx
@@ -0,0 +1,13 @@
+import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
+import type { MarkdownFenceRendererProps } from "../types";
+
+export function MermaidFenceHost({ code, inheritedStyles, textStyle }: MarkdownFenceRendererProps) {
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx
new file mode 100644
index 000000000..c51ab7542
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx
@@ -0,0 +1,490 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentType,
+} from "react";
+import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
+import { useTranslation } from "react-i18next";
+import { Code, Scan, Workflow, ZoomIn, ZoomOut } from "lucide-react-native";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
+import { useIsCompactFormFactor } from "@/constants/layout";
+import type { Theme } from "@/styles/theme";
+import type { MarkdownFenceRendererProps } from "../types";
+import type { MermaidRenderRequest } from "./render-model";
+import { mermaidRuntimeHtml } from "./runtime/html.gen";
+import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
+import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
+import { useMermaidRenderModel } from "./use-render-model";
+import { getDiagramBoxStyle } from "./presentation";
+
+interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
+ colorScheme?: "light" | "dark";
+}
+
+interface MermaidIframeRuntimeProps {
+ request: MermaidRenderRequest | null;
+ height: number;
+ onRendered: (message: {
+ revision: number;
+ source: string;
+ colorScheme: "light" | "dark";
+ height: number;
+ width: number;
+ }) => void;
+ onRenderFailed: (revision: number) => void;
+}
+
+function MermaidIframeRuntime({
+ request,
+ height,
+ onRendered,
+ onRenderFailed,
+}: MermaidIframeRuntimeProps) {
+ const iframeRef = useRef(null);
+ const driverRef = useRef(null);
+ driverRef.current ??= new MermaidRuntimeRequestDriver();
+ const iframeStyle = useMemo(
+ () => ({
+ display: "block",
+ width: "100%",
+ height,
+ border: 0,
+ pointerEvents: "none",
+ background: "transparent",
+ }),
+ [height],
+ );
+
+ const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
+ const target = iframeRef.current?.contentWindow;
+ if (!current || !target) {
+ return;
+ }
+ const message: MermaidRuntimeRenderMessage = {
+ type: "render",
+ revision: current.revision,
+ source: current.source,
+ colorScheme: current.colorScheme,
+ interactive: false,
+ };
+ target.postMessage(message, "*");
+ }, []);
+
+ useEffect(() => {
+ sendRequest(driverRef.current?.update(request) ?? null);
+ }, [request, sendRequest]);
+
+ useEffect(() => {
+ function receiveMessage(event: MessageEvent): void {
+ if (event.source !== iframeRef.current?.contentWindow) {
+ return;
+ }
+ const message = parseMermaidRuntimeMessage(event.data);
+ if (!message) {
+ return;
+ }
+ if (message.type === "bridgeReady") {
+ sendRequest(driverRef.current?.ready() ?? null);
+ return;
+ }
+ if (message.type === "renderError") {
+ onRenderFailed(message.revision);
+ sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
+ return;
+ }
+ onRendered(message);
+ sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
+ }
+ window.addEventListener("message", receiveMessage);
+ return () => window.removeEventListener("message", receiveMessage);
+ }, [onRenderFailed, onRendered, sendRequest]);
+
+ return (
+
+ );
+}
+
+const MIN_SCALE = 0.25;
+const MAX_SCALE = 8;
+
+function MermaidFenceHostImpl({
+ code,
+ phase,
+ inheritedStyles,
+ textStyle,
+ colorScheme = "dark",
+}: MermaidFenceHostImplProps) {
+ const { t } = useTranslation();
+ const { state, request, rendered, renderFailed } = useMermaidRenderModel({
+ source: code,
+ phase,
+ colorScheme,
+ });
+ const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
+ const handleRendered = useCallback(
+ (message: {
+ revision: number;
+ source: string;
+ colorScheme: "light" | "dark";
+ height: number;
+ width: number;
+ }) => {
+ setHasRuntimeContent(true);
+ rendered({
+ revision: message.revision,
+ source: message.source,
+ colorScheme: message.colorScheme,
+ dimensions: { height: message.height, width: message.width },
+ });
+ },
+ [rendered],
+ );
+ const visible = state.visible;
+ const canShowDiagram = visible !== null && hasRuntimeContent;
+ const runtimeHeight = Math.max(visible?.height ?? 240, 1);
+
+ const viewportRef = useRef(null);
+ const contentRef = useRef(null);
+ const controlsRegionRef = useRef(null);
+ const transformRef = useRef({ x: 0, y: 0, scale: 1 });
+ const dragRef = useRef<{
+ pointerId: number;
+ startX: number;
+ startY: number;
+ originX: number;
+ originY: number;
+ } | null>(null);
+
+ const applyTransformToElement = useCallback(
+ (element: HTMLDivElement, transform: { x: number; y: number; scale: number }) => {
+ const { x, y, scale } = transform;
+ element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
+ },
+ [],
+ );
+ const applyTransform = useCallback(() => {
+ if (contentRef.current) {
+ applyTransformToElement(contentRef.current, transformRef.current);
+ }
+ }, [applyTransformToElement]);
+ const setContentRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ contentRef.current = node;
+ if (node) {
+ applyTransformToElement(node, transformRef.current);
+ }
+ },
+ [applyTransformToElement],
+ );
+ const setControlsRegionRef = useCallback((node: unknown) => {
+ controlsRegionRef.current = node instanceof HTMLDivElement ? node : null;
+ }, []);
+ const hasFocusedControls = useCallback(() => {
+ const region = controlsRegionRef.current;
+ const active = document.activeElement;
+ return Boolean(region && active instanceof Node && region.contains(active));
+ }, []);
+ const [isFocusWithin, setIsFocusWithin] = useState(false);
+ const handleFocusWithin = useCallback(() => setIsFocusWithin(true), []);
+ const handleBlurWithin = useCallback(() => {
+ window.requestAnimationFrame(() => setIsFocusWithin(hasFocusedControls()));
+ }, [hasFocusedControls]);
+
+ useEffect(() => {
+ const viewport = viewportRef.current;
+ if (!viewport || !visible) {
+ return;
+ }
+ const activeViewport = viewport;
+ function zoomWithWheel(event: WheelEvent): void {
+ if (!event.ctrlKey && !event.metaKey) {
+ return;
+ }
+ event.preventDefault();
+ const transform = transformRef.current;
+ const factor = Math.min(1.25, Math.max(0.8, Math.exp(-event.deltaY * 0.01)));
+ const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
+ const rect = activeViewport.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ const y = event.clientY - rect.top;
+ transform.x = x - ((x - transform.x) / transform.scale) * scale;
+ transform.y = y - ((y - transform.y) / transform.scale) * scale;
+ transform.scale = scale;
+ applyTransform();
+ }
+ viewport.addEventListener("wheel", zoomWithWheel, { passive: false });
+ return () => viewport.removeEventListener("wheel", zoomWithWheel);
+ }, [applyTransform, visible]);
+
+ const onPointerDown = useCallback((event: React.PointerEvent) => {
+ if (event.button !== 0 || event.pointerType !== "mouse") {
+ return;
+ }
+ const transform = transformRef.current;
+ dragRef.current = {
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ originX: transform.x,
+ originY: transform.y,
+ };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ }, []);
+ const onPointerMove = useCallback(
+ (event: React.PointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag || event.pointerId !== drag.pointerId) {
+ return;
+ }
+ transformRef.current.x = drag.originX + event.clientX - drag.startX;
+ transformRef.current.y = drag.originY + event.clientY - drag.startY;
+ applyTransform();
+ },
+ [applyTransform],
+ );
+ const onPointerUp = useCallback((event: React.PointerEvent) => {
+ if (dragRef.current?.pointerId === event.pointerId) {
+ dragRef.current = null;
+ }
+ }, []);
+ const resetTransform = useCallback(() => {
+ transformRef.current = { x: 0, y: 0, scale: 1 };
+ applyTransform();
+ }, [applyTransform]);
+ const zoomBy = useCallback(
+ (factor: number) => {
+ const viewport = viewportRef.current;
+ if (!viewport) {
+ return;
+ }
+ const rect = viewport.getBoundingClientRect();
+ const x = rect.width / 2;
+ const y = rect.height / 2;
+ const transform = transformRef.current;
+ const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
+ transform.x = x - ((x - transform.x) / transform.scale) * scale;
+ transform.y = y - ((y - transform.y) / transform.scale) * scale;
+ transform.scale = scale;
+ applyTransform();
+ },
+ [applyTransform],
+ );
+ const zoomInPress = useCallback(() => zoomBy(1.25), [zoomBy]);
+ const zoomOutPress = useCallback(() => zoomBy(0.8), [zoomBy]);
+
+ const [showSource, setShowSource] = useState(false);
+ const showSourcePress = useCallback(() => setShowSource(true), []);
+ const showDiagramPress = useCallback(() => setShowSource(false), []);
+ const [isHovered, setIsHovered] = useState(false);
+ const handlePointerEnter = useCallback(() => setIsHovered(true), []);
+ const handlePointerLeave = useCallback(() => setIsHovered(false), []);
+ const isCompact = useIsCompactFormFactor();
+ const controlsVisible = isHovered || isCompact || isFocusWithin;
+
+ useEffect(() => {
+ if (!showSource && contentRef.current) {
+ applyTransformToElement(contentRef.current, transformRef.current);
+ }
+ }, [applyTransformToElement, showSource]);
+
+ const sourceView = useMemo(() => {
+ const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
+ const margins: ViewStyle = {
+ marginTop: marginTop ?? marginVertical,
+ marginBottom: marginBottom ?? marginVertical,
+ };
+ const text: TextStyle = sourceTextStyle;
+ return {
+ container: [margins, sourceContainerStyle],
+ text,
+ };
+ }, [textStyle]);
+ const diagramBoxStyle = getDiagramBoxStyle(textStyle);
+
+ const diagramVisible = canShowDiagram && !showSource;
+ const sourceVisible = !canShowDiagram || showSource;
+ let rootStyle: StyleProp = sourceContainerStyle;
+ if (diagramVisible) {
+ rootStyle = [diagramBoxStyle, containerStyle];
+ } else if (showSource) {
+ rootStyle = sourceView.container;
+ }
+ const sourceTextStyle = showSource ? sourceView.text : textStyle;
+
+ const runtime = (
+
+ );
+ return (
+
+ {sourceVisible ? (
+
+ ) : null}
+
+ {diagramVisible ? (
+
+
+
+
+
+
+ ) : null}
+ {showSource && canShowDiagram ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+interface DiagramControlButtonProps {
+ icon: ComponentType<{ size?: number; color?: string }>;
+ label: string;
+ onPress: () => void;
+ visible: boolean;
+ plain?: boolean;
+}
+
+const DiagramControlButton = React.memo(function DiagramControlButton({
+ icon: Icon,
+ label,
+ onPress,
+ visible,
+ plain = false,
+}: DiagramControlButtonProps) {
+ const pillStyle = visible ? controlStyles.button : controlStyles.buttonHidden;
+ const plainStyle = visible ? controlStyles.plainButton : controlStyles.plainButtonHidden;
+ return (
+
+ {({ hovered }) => (
+
+ )}
+
+ );
+});
+
+const controlStyles = StyleSheet.create((theme) => ({
+ cluster: {
+ position: "absolute",
+ top: theme.spacing[2],
+ right: theme.spacing[2],
+ flexDirection: "row",
+ gap: theme.spacing[1],
+ },
+ clusterSourceOffset: { right: 40 },
+ button: {
+ padding: theme.spacing[1],
+ borderRadius: theme.borderRadius.md,
+ backgroundColor: theme.colors.surface2,
+ opacity: 1,
+ pointerEvents: "auto",
+ },
+ buttonHidden: {
+ padding: theme.spacing[1],
+ borderRadius: theme.borderRadius.md,
+ backgroundColor: theme.colors.surface2,
+ opacity: 0,
+ pointerEvents: "none",
+ },
+ plainButton: { padding: theme.spacing[1], opacity: 1, pointerEvents: "auto" },
+ plainButtonHidden: { padding: theme.spacing[1], opacity: 0, pointerEvents: "none" },
+ icon: { color: theme.colors.foregroundMuted },
+ iconHovered: { color: theme.colors.foreground },
+}));
+
+const sourceContainerStyle: ViewStyle = { position: "relative" };
+const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
+const viewportDomStyle: React.CSSProperties = { cursor: "grab", userSelect: "none" };
+const contentDomStyle: React.CSSProperties = { transformOrigin: "0 0" };
+const measuringRuntimeStyle: React.CSSProperties = {
+ position: "absolute",
+ inset: 0,
+ opacity: 0,
+ pointerEvents: "none",
+ overflow: "hidden",
+};
+const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
+const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
+
+export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
+ return ;
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/index.tsx b/packages/app/src/components/markdown/fence/mermaid/index.tsx
new file mode 100644
index 000000000..0f56d5f74
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/index.tsx
@@ -0,0 +1,6 @@
+import type { MarkdownFenceRendererProps } from "../types";
+import { MermaidFenceHost } from "./host";
+
+export function MermaidFence(props: MarkdownFenceRendererProps) {
+ return ;
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/presentation.ts b/packages/app/src/components/markdown/fence/mermaid/presentation.ts
new file mode 100644
index 000000000..48d0d879f
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/presentation.ts
@@ -0,0 +1,14 @@
+import type { TextStyle, ViewStyle } from "react-native";
+
+export function getDiagramBoxStyle(style: TextStyle): ViewStyle {
+ return {
+ backgroundColor: style.backgroundColor,
+ borderColor: style.borderColor,
+ borderRadius: style.borderRadius,
+ borderWidth: style.borderWidth,
+ marginBottom: style.marginBottom,
+ marginTop: style.marginTop,
+ marginVertical: style.marginVertical,
+ padding: style.padding,
+ };
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/render-model.test.ts b/packages/app/src/components/markdown/fence/mermaid/render-model.test.ts
new file mode 100644
index 000000000..3cb661d25
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/render-model.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from "vitest";
+import {
+ createMermaidRenderModel,
+ getMermaidRenderRequest,
+ reduceMermaidRenderModel,
+ type MermaidRenderInput,
+ type MermaidRenderModel,
+} from "./render-model";
+
+function input(overrides: Partial = {}): MermaidRenderInput {
+ return {
+ source: "flowchart TD\nA --> B",
+ phase: "streaming",
+ colorScheme: "dark",
+ rejected: false,
+ cached: null,
+ ...overrides,
+ };
+}
+
+function renderCurrent(state: MermaidRenderModel): MermaidRenderModel {
+ return reduceMermaidRenderModel(state, {
+ type: "rendered",
+ revision: state.revision,
+ source: state.source,
+ colorScheme: state.colorScheme,
+ dimensions: { height: 120, width: 240 },
+ });
+}
+
+describe("Mermaid render model", () => {
+ it("shows each valid streamed revision", () => {
+ const first = renderCurrent(createMermaidRenderModel(input()));
+ const secondPending = reduceMermaidRenderModel(first, {
+ type: "inputChanged",
+ input: input({ source: "flowchart TD\nA --> B\nB --> C" }),
+ });
+ const second = renderCurrent(secondPending);
+
+ expect(first.visible?.source).toBe("flowchart TD\nA --> B");
+ expect(second.visible?.source).toBe("flowchart TD\nA --> B\nB --> C");
+ });
+
+ it("keeps the last valid preview across a transient streaming failure", () => {
+ const rendered = renderCurrent(createMermaidRenderModel(input()));
+ const pending = reduceMermaidRenderModel(rendered, {
+ type: "inputChanged",
+ input: input({ source: "flowchart TD\nA --" }),
+ });
+ const failed = reduceMermaidRenderModel(pending, {
+ type: "renderFailed",
+ revision: pending.revision,
+ });
+
+ expect(failed.status).toBe("failed");
+ expect(failed.visible?.source).toBe("flowchart TD\nA --> B");
+ });
+
+ it("clears a stale preview when failed source becomes final", () => {
+ const rendered = renderCurrent(createMermaidRenderModel(input()));
+ const pending = reduceMermaidRenderModel(rendered, {
+ type: "inputChanged",
+ input: input({ source: "flowchart TD\nA --" }),
+ });
+ const failed = reduceMermaidRenderModel(pending, {
+ type: "renderFailed",
+ revision: pending.revision,
+ });
+ const complete = reduceMermaidRenderModel(failed, {
+ type: "inputChanged",
+ input: input({ source: "flowchart TD\nA --", phase: "complete" }),
+ });
+
+ expect(complete.status).toBe("failed");
+ expect(complete.visible).toBeNull();
+ });
+
+ it("uses the completion phase when an in-flight final render fails", () => {
+ const rendered = renderCurrent(createMermaidRenderModel(input()));
+ const pending = reduceMermaidRenderModel(rendered, {
+ type: "inputChanged",
+ input: input({ source: "flowchart TD\nA --" }),
+ });
+ const completePending = reduceMermaidRenderModel(pending, {
+ type: "inputChanged",
+ input: input({ source: "flowchart TD\nA --", phase: "complete" }),
+ });
+ const failed = reduceMermaidRenderModel(completePending, {
+ type: "renderFailed",
+ revision: completePending.revision,
+ });
+
+ expect(failed.visible).toBeNull();
+ });
+
+ it("ignores obsolete render results", () => {
+ const initial = createMermaidRenderModel(input());
+ const newer = reduceMermaidRenderModel(initial, {
+ type: "inputChanged",
+ input: input({ source: "flowchart TD\nA --> C" }),
+ });
+ const obsolete = reduceMermaidRenderModel(newer, {
+ type: "rendered",
+ revision: initial.revision,
+ source: initial.source,
+ colorScheme: initial.colorScheme,
+ dimensions: { height: 90, width: 180 },
+ });
+
+ expect(obsolete).toBe(newer);
+ expect(getMermaidRenderRequest(obsolete)).toEqual({
+ revision: newer.revision,
+ source: "flowchart TD\nA --> C",
+ colorScheme: "dark",
+ });
+ });
+
+ it("uses an obsolete successful prefix as the first streaming preview", () => {
+ const initial = createMermaidRenderModel(input());
+ const newer = reduceMermaidRenderModel(initial, {
+ type: "inputChanged",
+ input: input({ source: "flowchart TD\nA --> B\nB --" }),
+ });
+ const preview = reduceMermaidRenderModel(newer, {
+ type: "rendered",
+ revision: initial.revision,
+ source: initial.source,
+ colorScheme: initial.colorScheme,
+ dimensions: { height: 90, width: 180 },
+ });
+
+ expect(preview.status).toBe("pending");
+ expect(preview.visible?.source).toBe(initial.source);
+ expect(getMermaidRenderRequest(preview)?.source).toBe("flowchart TD\nA --> B\nB --");
+ });
+
+ it("keeps the last preview when source policy rejects a streaming revision", () => {
+ const rendered = renderCurrent(createMermaidRenderModel(input()));
+ const rejected = reduceMermaidRenderModel(rendered, {
+ type: "inputChanged",
+ input: input({ source: 'flowchart TD\nA@{ img: "https://example.com/x" }', rejected: true }),
+ });
+
+ expect(rejected.status).toBe("rejected");
+ expect(rejected.visible).toBe(rendered.visible);
+ expect(getMermaidRenderRequest(rejected)).toBeNull();
+ });
+
+ it("clears the last preview when the rejected revision is complete", () => {
+ const rendered = renderCurrent(createMermaidRenderModel(input()));
+ const rejected = reduceMermaidRenderModel(rendered, {
+ type: "inputChanged",
+ input: input({
+ source: 'flowchart TD\nA@{ img: "https://example.com/x" }',
+ phase: "complete",
+ rejected: true,
+ }),
+ });
+
+ expect(rejected.status).toBe("rejected");
+ expect(rejected.visible).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/markdown/fence/mermaid/render-model.ts b/packages/app/src/components/markdown/fence/mermaid/render-model.ts
new file mode 100644
index 000000000..da5176dbe
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/render-model.ts
@@ -0,0 +1,170 @@
+import type { MarkdownPhase } from "../types";
+
+export type DiagramColorScheme = "light" | "dark";
+
+export interface DiagramDimensions {
+ height: number;
+ width: number;
+}
+
+export interface RenderedDiagram extends DiagramDimensions {
+ source: string;
+ colorScheme: DiagramColorScheme;
+}
+
+export interface MermaidRenderInput {
+ source: string;
+ phase: MarkdownPhase;
+ colorScheme: DiagramColorScheme;
+ rejected: boolean;
+ cached: RenderedDiagram | null;
+}
+
+export interface MermaidRenderRequest {
+ revision: number;
+ source: string;
+ colorScheme: DiagramColorScheme;
+}
+
+export interface MermaidRenderModel {
+ revision: number;
+ source: string;
+ phase: MarkdownPhase;
+ colorScheme: DiagramColorScheme;
+ status: "pending" | "rendered" | "failed" | "rejected";
+ visible: RenderedDiagram | null;
+}
+
+export type MermaidRenderAction =
+ | { type: "inputChanged"; input: MermaidRenderInput }
+ | {
+ type: "rendered";
+ revision: number;
+ source: string;
+ colorScheme: DiagramColorScheme;
+ dimensions: DiagramDimensions;
+ }
+ | { type: "renderFailed"; revision: number };
+
+function reduceRendered(
+ state: MermaidRenderModel,
+ action: Extract,
+): MermaidRenderModel {
+ if (action.colorScheme !== state.colorScheme) {
+ return state;
+ }
+ if (action.revision !== state.revision || action.source !== state.source) {
+ const isStreamingPrefix =
+ state.phase === "streaming" &&
+ action.revision < state.revision &&
+ state.source.startsWith(action.source);
+ const isNewerPreview =
+ state.visible === null || action.source.length > state.visible.source.length;
+ if (!isStreamingPrefix || !isNewerPreview) {
+ return state;
+ }
+ return {
+ ...state,
+ visible: {
+ source: action.source,
+ colorScheme: action.colorScheme,
+ ...action.dimensions,
+ },
+ };
+ }
+ return {
+ ...state,
+ status: "rendered",
+ visible: {
+ source: action.source,
+ colorScheme: action.colorScheme,
+ ...action.dimensions,
+ },
+ };
+}
+
+export function createMermaidRenderModel(input: MermaidRenderInput): MermaidRenderModel {
+ if (input.rejected) {
+ return {
+ revision: 1,
+ source: input.source,
+ phase: input.phase,
+ colorScheme: input.colorScheme,
+ status: "rejected",
+ visible: input.phase === "complete" ? null : input.cached,
+ };
+ }
+ return {
+ revision: 1,
+ source: input.source,
+ phase: input.phase,
+ colorScheme: input.colorScheme,
+ status: "pending",
+ visible: input.cached,
+ };
+}
+
+export function reduceMermaidRenderModel(
+ state: MermaidRenderModel,
+ action: MermaidRenderAction,
+): MermaidRenderModel {
+ if (action.type === "rendered") {
+ return reduceRendered(state, action);
+ }
+
+ if (action.type === "renderFailed") {
+ if (action.revision !== state.revision) {
+ return state;
+ }
+ return {
+ ...state,
+ status: "failed",
+ visible: state.phase === "complete" ? null : state.visible,
+ };
+ }
+
+ const { input } = action;
+ const isSameRender = input.source === state.source && input.colorScheme === state.colorScheme;
+ if (isSameRender) {
+ if (input.phase === state.phase) {
+ return state;
+ }
+ const finalFailure = input.phase === "complete" && state.status === "failed";
+ return {
+ ...state,
+ phase: input.phase,
+ visible: finalFailure ? null : state.visible,
+ };
+ }
+
+ if (input.rejected) {
+ return {
+ revision: state.revision + 1,
+ source: input.source,
+ phase: input.phase,
+ colorScheme: input.colorScheme,
+ status: "rejected",
+ visible: input.phase === "complete" ? null : (input.cached ?? state.visible),
+ };
+ }
+
+ return {
+ revision: state.revision + 1,
+ source: input.source,
+ phase: input.phase,
+ colorScheme: input.colorScheme,
+ status: "pending",
+ visible: input.cached ?? state.visible,
+ };
+}
+
+export function getMermaidRenderRequest(state: MermaidRenderModel): MermaidRenderRequest | null {
+ if (state.status !== "pending") {
+ return null;
+ }
+ return {
+ revision: state.revision,
+ source: state.source,
+ colorScheme: state.colorScheme,
+ };
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/entry.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/entry.ts
new file mode 100644
index 000000000..ec9f88794
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/runtime/entry.ts
@@ -0,0 +1,131 @@
+import mermaid from "mermaid";
+import {
+ parseMermaidRuntimeRenderMessage,
+ type MermaidRuntimeMessage,
+ type MermaidRuntimeRenderMessage,
+} from "./messages";
+
+declare global {
+ interface Window {
+ ReactNativeWebView?: {
+ postMessage?: (data: string) => void;
+ };
+ __PASEO_MERMAID_RUNTIME_RECEIVE__?: (message: unknown) => void;
+ }
+}
+
+function sendToHost(message: MermaidRuntimeMessage): void {
+ window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
+ if (window.parent !== window) {
+ window.parent.postMessage(message, "*");
+ }
+}
+
+function initializeMermaid(colorScheme: "light" | "dark"): void {
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "strict",
+ suppressErrorRendering: true,
+ theme: colorScheme === "dark" ? "dark" : "default",
+ secure: [
+ "secure",
+ "securityLevel",
+ "startOnLoad",
+ "maxTextSize",
+ "suppressErrorRendering",
+ "maxEdges",
+ "theme",
+ "themeVariables",
+ "themeCSS",
+ ],
+ flowchart: { htmlLabels: false },
+ class: { htmlLabels: false },
+ });
+}
+
+function setViewport(interactive: boolean): void {
+ document
+ .querySelector('meta[name="viewport"]')
+ ?.setAttribute(
+ "content",
+ interactive
+ ? "width=device-width, initial-scale=1, maximum-scale=8"
+ : "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
+ );
+}
+
+let latestRevision = 0;
+let pendingRender: MermaidRuntimeRenderMessage | null = null;
+let isRendering = false;
+let isDrainScheduled = false;
+
+async function render(message: MermaidRuntimeRenderMessage): Promise {
+ try {
+ initializeMermaid(message.colorScheme);
+ const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
+ if (message.revision !== latestRevision) {
+ return;
+ }
+ setViewport(message.interactive);
+ const host = document.getElementById("diagram");
+ if (!host) {
+ return;
+ }
+ host.innerHTML = svg;
+ const rect = host.querySelector("svg")?.getBoundingClientRect();
+ sendToHost({
+ type: "rendered",
+ revision: message.revision,
+ source: message.source,
+ colorScheme: message.colorScheme,
+ height: Math.ceil(rect?.height ?? host.scrollHeight),
+ width: Math.ceil(rect?.width ?? host.scrollWidth),
+ });
+ } catch {
+ if (message.revision === latestRevision) {
+ sendToHost({ type: "renderError", revision: message.revision });
+ }
+ }
+}
+
+async function drainRenderQueue(): Promise {
+ if (isRendering) {
+ return;
+ }
+ isRendering = true;
+ try {
+ while (pendingRender) {
+ const next = pendingRender;
+ pendingRender = null;
+ await render(next);
+ }
+ } finally {
+ isRendering = false;
+ }
+}
+
+function receiveRender(value: unknown): void {
+ const message = parseMermaidRuntimeRenderMessage(value);
+ if (!message) {
+ return;
+ }
+ latestRevision = message.revision;
+ pendingRender = message;
+ if (isRendering || isDrainScheduled) {
+ return;
+ }
+ isDrainScheduled = true;
+ window.setTimeout(() => {
+ isDrainScheduled = false;
+ void drainRenderQueue();
+ }, 0);
+}
+
+window.__PASEO_MERMAID_RUNTIME_RECEIVE__ = receiveRender;
+window.addEventListener("message", (event) => {
+ if (event.source === window.parent) {
+ receiveRender(event.data);
+ }
+});
+
+sendToHost({ type: "bridgeReady" });
diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/html.gen.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/html.gen.ts
new file mode 100644
index 000000000..e8bbdd633
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/runtime/html.gen.ts
@@ -0,0 +1,4 @@
+// Generated by packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs.
+// Do not edit by hand.
+
+export const mermaidRuntimeHtml = "\n\n \n \n \n \n \n \n \n \n \n \n";
diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/messages.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/messages.ts
new file mode 100644
index 000000000..99b23f9d6
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/runtime/messages.ts
@@ -0,0 +1,91 @@
+import type { DiagramColorScheme, DiagramDimensions } from "../render-model";
+
+export interface MermaidRuntimeRenderMessage {
+ type: "render";
+ revision: number;
+ source: string;
+ colorScheme: DiagramColorScheme;
+ interactive: boolean;
+}
+
+export type MermaidRuntimeMessage =
+ | { type: "bridgeReady" }
+ | ({
+ type: "rendered";
+ revision: number;
+ source: string;
+ colorScheme: DiagramColorScheme;
+ } & DiagramDimensions)
+ | { type: "renderError"; revision: number };
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function isColorScheme(value: unknown): value is DiagramColorScheme {
+ return value === "light" || value === "dark";
+}
+
+export function parseMermaidRuntimeRenderMessage(
+ value: unknown,
+): MermaidRuntimeRenderMessage | null {
+ if (
+ !isRecord(value) ||
+ value.type !== "render" ||
+ typeof value.revision !== "number" ||
+ !Number.isInteger(value.revision) ||
+ typeof value.source !== "string" ||
+ !isColorScheme(value.colorScheme) ||
+ typeof value.interactive !== "boolean"
+ ) {
+ return null;
+ }
+ return {
+ type: "render",
+ revision: value.revision,
+ source: value.source,
+ colorScheme: value.colorScheme,
+ interactive: value.interactive,
+ };
+}
+
+export function parseMermaidRuntimeMessage(value: unknown): MermaidRuntimeMessage | null {
+ if (!isRecord(value) || typeof value.type !== "string") {
+ return null;
+ }
+ if (value.type === "bridgeReady") {
+ return { type: "bridgeReady" };
+ }
+ if (
+ value.type === "renderError" &&
+ typeof value.revision === "number" &&
+ Number.isInteger(value.revision)
+ ) {
+ return { type: "renderError", revision: value.revision };
+ }
+ if (
+ value.type === "rendered" &&
+ typeof value.revision === "number" &&
+ Number.isInteger(value.revision) &&
+ typeof value.source === "string" &&
+ isColorScheme(value.colorScheme) &&
+ typeof value.height === "number" &&
+ Number.isFinite(value.height) &&
+ typeof value.width === "number" &&
+ Number.isFinite(value.width)
+ ) {
+ return {
+ type: "rendered",
+ revision: value.revision,
+ source: value.source,
+ colorScheme: value.colorScheme,
+ height: value.height,
+ width: value.width,
+ };
+ }
+ return null;
+}
+
+export function serializeMermaidRuntimeRenderMessage(message: MermaidRuntimeRenderMessage): string {
+ return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.test.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.test.ts
new file mode 100644
index 000000000..f2e93e243
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import type { MermaidRenderRequest } from "../render-model";
+import { MermaidRuntimeRequestDriver } from "./request-driver";
+
+function request(revision: number, source: string): MermaidRenderRequest {
+ return { revision, source, colorScheme: "dark" };
+}
+
+describe("MermaidRuntimeRequestDriver", () => {
+ it("tries queued streaming prefixes in order until the runtime renders one", () => {
+ const driver = new MermaidRuntimeRequestDriver();
+ const first = request(1, "flow");
+ const valid = request(2, "flowchart TD\nA --> B\n");
+ const transient = request(3, "flowchart TD\nA --> B\nB --");
+
+ expect(driver.update(first)).toBeNull();
+ expect(driver.update(valid)).toBeNull();
+ expect(driver.update(transient)).toBeNull();
+ expect(driver.ready()).toBe(valid);
+ expect(driver.settled(valid.revision, true)).toBe(transient);
+ });
+
+ it("keeps completed lines but collapses incomplete token revisions before readiness", () => {
+ const driver = new MermaidRuntimeRequestDriver();
+ const partial = request(1, "flowchart TD\nA --");
+ const completed = request(2, "flowchart TD\nA --> B\n");
+ const nextPartial = request(3, "flowchart TD\nA --> B\nB --");
+ const latestPartial = request(4, "flowchart TD\nA --> B\nB -->");
+
+ driver.update(partial);
+ driver.update(completed);
+ driver.update(nextPartial);
+ driver.update(latestPartial);
+
+ expect(driver.ready()).toBe(completed);
+ expect(driver.settled(completed.revision, true)).toBe(latestPartial);
+ });
+
+ it("collapses later updates to the newest request after the first render", () => {
+ const driver = new MermaidRuntimeRequestDriver();
+ const first = request(1, "flowchart TD\nA --> B");
+ const second = request(2, "flowchart TD\nA --> B\nB --> C");
+ const latest = request(3, "flowchart TD\nA --> B\nB --> D");
+
+ driver.update(first);
+ expect(driver.ready()).toBe(first);
+ expect(driver.settled(first.revision, true)).toBeNull();
+ expect(driver.update(second)).toBe(second);
+ expect(driver.update(latest)).toBeNull();
+ expect(driver.settled(second.revision, false)).toBe(latest);
+ });
+});
diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.ts
new file mode 100644
index 000000000..8e3058701
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.ts
@@ -0,0 +1,63 @@
+import type { MermaidRenderRequest } from "../render-model";
+
+function isCompletedLineCandidate(source: string): boolean {
+ return source.endsWith("\n") && source.trim().includes("\n");
+}
+
+export class MermaidRuntimeRequestDriver {
+ private readyState = false;
+ private renderedOnce = false;
+ private inFlight: MermaidRenderRequest | null = null;
+ private queued: MermaidRenderRequest[] = [];
+
+ update(request: MermaidRenderRequest | null): MermaidRenderRequest | null {
+ if (!request || this.hasRevision(request.revision)) {
+ return null;
+ }
+ if (this.renderedOnce) {
+ this.queued = [request];
+ } else {
+ const lastIndex = this.queued.length - 1;
+ const last = this.queued[lastIndex];
+ if (last && !isCompletedLineCandidate(last.source)) {
+ this.queued[lastIndex] = request;
+ } else {
+ this.queued.push(request);
+ }
+ }
+ return this.takeNext();
+ }
+
+ ready(): MermaidRenderRequest | null {
+ this.readyState = true;
+ return this.takeNext();
+ }
+
+ settled(revision: number, rendered: boolean): MermaidRenderRequest | null {
+ if (this.inFlight?.revision !== revision) {
+ return null;
+ }
+ this.inFlight = null;
+ if (rendered) {
+ this.renderedOnce = true;
+ const latest = this.queued.at(-1);
+ this.queued = latest ? [latest] : [];
+ }
+ return this.takeNext();
+ }
+
+ private hasRevision(revision: number): boolean {
+ return (
+ this.inFlight?.revision === revision ||
+ this.queued.some((request) => request.revision === revision)
+ );
+ }
+
+ private takeNext(): MermaidRenderRequest | null {
+ if (!this.readyState || this.inFlight || this.queued.length === 0) {
+ return null;
+ }
+ this.inFlight = this.queued.shift() ?? null;
+ return this.inFlight;
+ }
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/runtime-html.test.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime-html.test.ts
new file mode 100644
index 000000000..a11ffd509
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime-html.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+import { mermaidRuntimeHtml } from "./html.gen";
+
+describe("Mermaid runtime document", () => {
+ it("ships a closed network policy", () => {
+ expect(mermaidRuntimeHtml).toContain("default-src 'none'");
+ expect(mermaidRuntimeHtml).toContain("connect-src 'none'");
+ expect(mermaidRuntimeHtml).toContain("img-src data: blob:");
+ expect(mermaidRuntimeHtml).toContain("font-src 'none'");
+ expect(mermaidRuntimeHtml).toContain("frame-src 'none'");
+ });
+});
diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts
new file mode 100644
index 000000000..1dda832eb
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { mermaidRuntimeHtml } from "./html.gen";
+import { parseMermaidRuntimeMessage, type MermaidRuntimeMessage } from "./messages";
+
+const mountedFrames: HTMLIFrameElement[] = [];
+
+function waitForRuntimeMessage(
+ frame: HTMLIFrameElement,
+ predicate: (message: MermaidRuntimeMessage) => boolean,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = window.setTimeout(() => {
+ window.removeEventListener("message", receive);
+ reject(new Error("Timed out waiting for Mermaid runtime"));
+ }, 10_000);
+ function receive(event: MessageEvent): void {
+ if (event.source !== frame.contentWindow) {
+ return;
+ }
+ const message = parseMermaidRuntimeMessage(event.data);
+ if (!message || !predicate(message)) {
+ return;
+ }
+ window.clearTimeout(timeout);
+ window.removeEventListener("message", receive);
+ resolve(message);
+ }
+ window.addEventListener("message", receive);
+ });
+}
+
+async function mountRuntime(): Promise {
+ const frame = document.createElement("iframe");
+ frame.sandbox.add("allow-scripts");
+ const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
+ frame.srcdoc = mermaidRuntimeHtml;
+ document.body.append(frame);
+ mountedFrames.push(frame);
+ await ready;
+ return frame;
+}
+
+function render(
+ frame: HTMLIFrameElement,
+ input: { revision: number; source: string },
+): Promise {
+ const response = waitForRuntimeMessage(
+ frame,
+ (message) =>
+ message.type !== "bridgeReady" &&
+ "revision" in message &&
+ message.revision === input.revision,
+ );
+ frame.contentWindow?.postMessage(
+ {
+ type: "render",
+ revision: input.revision,
+ source: input.source,
+ colorScheme: "dark",
+ interactive: false,
+ },
+ "*",
+ );
+ return response;
+}
+
+afterEach(() => {
+ for (const frame of mountedFrames.splice(0)) {
+ frame.remove();
+ }
+});
+
+describe("Mermaid sandbox runtime", () => {
+ it("renders successive valid streaming prefixes and reports an invalid prefix", async () => {
+ const frame = await mountRuntime();
+ const firstSource = "flowchart TD\nA --> B";
+ const secondSource = `${firstSource}\nB --> C`;
+
+ const first = await render(frame, { revision: 1, source: firstSource });
+ const invalid = await render(frame, { revision: 2, source: "not a mermaid diagram" });
+ const second = await render(frame, { revision: 3, source: secondSource });
+
+ expect(first).toMatchObject({ type: "rendered", revision: 1, source: firstSource });
+ expect(invalid).toEqual({ type: "renderError", revision: 2 });
+ expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
+ });
+
+ it("coalesces queued input and never reports an obsolete result", async () => {
+ const frame = await mountRuntime();
+ const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
+ const currentSource = "flowchart TD\nCurrent --> Result";
+ const obsoleteResponses: MermaidRuntimeMessage[] = [];
+ function collect(event: MessageEvent): void {
+ if (event.source !== frame.contentWindow) {
+ return;
+ }
+ const message = parseMermaidRuntimeMessage(event.data);
+ if (message && message.type !== "bridgeReady" && message.revision === 10) {
+ obsoleteResponses.push(message);
+ }
+ }
+ window.addEventListener("message", collect);
+ frame.contentWindow?.postMessage(
+ {
+ type: "render",
+ revision: 10,
+ source: obsoleteSource,
+ colorScheme: "dark",
+ interactive: false,
+ },
+ "*",
+ );
+ const current = await render(frame, { revision: 11, source: currentSource });
+ window.removeEventListener("message", collect);
+
+ expect(current).toMatchObject({ type: "rendered", revision: 11, source: currentSource });
+ expect(obsoleteResponses).toEqual([]);
+ });
+});
diff --git a/packages/app/src/components/markdown/fence/mermaid/source-policy.test.ts b/packages/app/src/components/markdown/fence/mermaid/source-policy.test.ts
new file mode 100644
index 000000000..48b844e1e
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/source-policy.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { containsUnsafeMermaidSource } from "./source-policy";
+
+describe("containsUnsafeMermaidSource", () => {
+ it("rejects resource-bearing constructs", () => {
+ expect(containsUnsafeMermaidSource('flowchart TD\n A@{ img: "https://x/y.png" }')).toBe(true);
+ expect(containsUnsafeMermaidSource("flowchart TD\n A@{ icon: 'pack:name' }")).toBe(true);
+ expect(
+ containsUnsafeMermaidSource('%%{init: {"themeCSS": "a { color: red }"}}%%\ngraph TD'),
+ ).toBe(true);
+ expect(containsUnsafeMermaidSource("graph TD\n A[url(http://x)]")).toBe(true);
+ expect(containsUnsafeMermaidSource("graph TD\n A[@import 'x']")).toBe(true);
+ expect(containsUnsafeMermaidSource('graph TD\n A["
"]')).toBe(true);
+ expect(containsUnsafeMermaidSource('graph TD\n A["styled"]')).toBe(true);
+ expect(containsUnsafeMermaidSource('graph TD\n A["<img src=x>"]')).toBe(true);
+ expect(containsUnsafeMermaidSource('graph TD\n A[""]')).toBe(true);
+ });
+
+ it("rejects all shape-data constructs, including yaml key evasions", () => {
+ expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "img": "https://x/y.png" }')).toBe(
+ true,
+ );
+ expect(containsUnsafeMermaidSource("flowchart TD\n A@{ 'img': 'https://x/y.png' }")).toBe(
+ true,
+ );
+ expect(
+ containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u0069mg": "https://x/y.png" }'),
+ ).toBe(true);
+ expect(
+ containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u{69}mg": "https://x/y.png" }'),
+ ).toBe(true);
+ expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "\\x69mg": "https://x/y.png" }')).toBe(
+ true,
+ );
+ expect(
+ containsUnsafeMermaidSource('flowchart TD\n A@{ "\\U00000069mg": "https://attacker/x" }'),
+ ).toBe(true);
+ expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "icon": "pack:name" }')).toBe(true);
+ expect(
+ containsUnsafeMermaidSource(
+ 'flowchart TD\n A@{ ? img # comment\n : "https://attacker/x" }',
+ ),
+ ).toBe(true);
+ expect(
+ containsUnsafeMermaidSource(
+ 'flowchart TD\n A@{ dummy: &k img\n ? *k # comment\n : "https://attacker/x" }',
+ ),
+ ).toBe(true);
+ });
+
+ it("fails closed for malformed or out-of-range escapes without throwing", () => {
+ expect(() =>
+ containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]'),
+ ).not.toThrow();
+ expect(containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]')).toBe(true);
+ expect(() =>
+ containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]'),
+ ).not.toThrow();
+ expect(containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]')).toBe(true);
+ });
+
+ it("allows ordinary diagrams including formatting-only labels", () => {
+ expect(containsUnsafeMermaidSource("flowchart TD\n A[Start] --> B{Choice}")).toBe(false);
+ expect(containsUnsafeMermaidSource('graph TD\n A["line one
line two"]')).toBe(false);
+ expect(containsUnsafeMermaidSource('graph TD\n A["line one
line two"]')).toBe(false);
+ expect(containsUnsafeMermaidSource('graph TD\n A["formatted"]')).toBe(false);
+ expect(containsUnsafeMermaidSource("sequenceDiagram\n Alice->>Bob: a < b and x > y")).toBe(
+ false,
+ );
+ expect(containsUnsafeMermaidSource("flowchart TD\n A@{ shape: rect }")).toBe(true);
+ });
+});
diff --git a/packages/app/src/components/markdown/fence/mermaid/source-policy.ts b/packages/app/src/components/markdown/fence/mermaid/source-policy.ts
new file mode 100644
index 000000000..df6ad0312
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/source-policy.ts
@@ -0,0 +1,52 @@
+// Mermaid can fetch external resources while *rendering* — image shapes
+// (`A@{ img: "url" }`) construct an Image and await decode before any output
+// sanitization runs, and CSS can pull url()/@import — so a prompt-injected
+// diagram could exfiltrate data in a request URL. securityLevel "strict" does
+// not prevent this (mermaid-js/mermaid#7645). Mermaid 11.16 parses the shape
+// metadata object with yaml.JSON_SCHEMA semantics, including aliases and
+// explicit keys; matching those keys safely would require a real parser. We
+// therefore reject any `@{ ... }` shape-data construct up front and fall back
+// to the source code block. Formatting-only `
` and `` tags are common
+// in generated labels and carry no resource-bearing attributes; all other
+// tags (and entity-encoded text that could smuggle one) are rejected.
+const UNSAFE_MERMAID_SOURCE =
+ /@\s*\{|url\s*\(|@import\b|themeCSS||<(?!\/?(?:br|i)\s*\/?>)[a-z!/]/i;
+
+// Mermaid labels can contain escaped text. Decode the escape forms we care
+// about before running the denylist so disguised HTML still gets caught.
+// Invalid escape sequences fail closed and fall back to the source block.
+function normalizeMermaidSource(code: string): string | null {
+ try {
+ return code
+ .replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (_, hex: string) =>
+ decodeCodePointEscape(Number.parseInt(hex, 16)),
+ )
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
+ String.fromCharCode(Number.parseInt(hex, 16)),
+ )
+ .replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
+ String.fromCharCode(Number.parseInt(hex, 16)),
+ )
+ .replace(/["'`\\]/g, "");
+ } catch {
+ return null;
+ }
+}
+
+function decodeCodePointEscape(value: number): string {
+ if (!Number.isInteger(value) || value < 0 || value > 0x10ffff) {
+ throw new RangeError("Invalid Unicode code point");
+ }
+ return String.fromCodePoint(value);
+}
+
+export function containsUnsafeMermaidSource(code: string): boolean {
+ if (UNSAFE_MERMAID_SOURCE.test(code)) {
+ return true;
+ }
+ const normalized = normalizeMermaidSource(code);
+ if (normalized === null) {
+ return true;
+ }
+ return UNSAFE_MERMAID_SOURCE.test(normalized);
+}
diff --git a/packages/app/src/components/markdown/fence/mermaid/use-render-model.ts b/packages/app/src/components/markdown/fence/mermaid/use-render-model.ts
new file mode 100644
index 000000000..cb35549b1
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/mermaid/use-render-model.ts
@@ -0,0 +1,96 @@
+import { useCallback, useEffect, useMemo, useReducer } from "react";
+import {
+ createMermaidRenderModel,
+ getMermaidRenderRequest,
+ reduceMermaidRenderModel,
+ type DiagramColorScheme,
+ type DiagramDimensions,
+ type RenderedDiagram,
+} from "./render-model";
+import { containsUnsafeMermaidSource } from "./source-policy";
+import type { MarkdownPhase } from "../types";
+
+const renderCache = new Map();
+const RENDER_CACHE_LIMIT = 50;
+
+function cacheKey(source: string, colorScheme: DiagramColorScheme): string {
+ return `${colorScheme}\u0000${source}`;
+}
+
+function readCachedRender(source: string, colorScheme: DiagramColorScheme): RenderedDiagram | null {
+ return renderCache.get(cacheKey(source, colorScheme)) ?? null;
+}
+
+function cacheRender(rendered: RenderedDiagram): void {
+ if (renderCache.size >= RENDER_CACHE_LIMIT) {
+ const oldest = renderCache.keys().next().value;
+ if (oldest !== undefined) {
+ renderCache.delete(oldest);
+ }
+ }
+ renderCache.set(cacheKey(rendered.source, rendered.colorScheme), rendered);
+}
+
+export function useMermaidRenderModel({
+ source,
+ phase,
+ colorScheme,
+}: {
+ source: string;
+ phase: MarkdownPhase;
+ colorScheme: DiagramColorScheme;
+}) {
+ const renderInput = useMemo(
+ () => ({
+ source,
+ phase,
+ colorScheme,
+ rejected: containsUnsafeMermaidSource(source),
+ cached: readCachedRender(source, colorScheme),
+ }),
+ [colorScheme, phase, source],
+ );
+ const [state, dispatch] = useReducer(
+ reduceMermaidRenderModel,
+ renderInput,
+ createMermaidRenderModel,
+ );
+
+ useEffect(() => {
+ dispatch({ type: "inputChanged", input: renderInput });
+ }, [renderInput]);
+
+ const rendered = useCallback(
+ (response: {
+ revision: number;
+ source: string;
+ colorScheme: DiagramColorScheme;
+ dimensions: DiagramDimensions;
+ }) => {
+ const cached: RenderedDiagram = {
+ source: response.source,
+ colorScheme: response.colorScheme,
+ ...response.dimensions,
+ };
+ cacheRender(cached);
+ dispatch({
+ type: "rendered",
+ revision: response.revision,
+ source: response.source,
+ colorScheme: response.colorScheme,
+ dimensions: response.dimensions,
+ });
+ },
+ [],
+ );
+ const renderFailed = useCallback((revision: number) => {
+ dispatch({ type: "renderFailed", revision });
+ }, []);
+
+ return {
+ state,
+ request: getMermaidRenderRequest(state),
+ rendered,
+ renderFailed,
+ };
+}
diff --git a/packages/app/src/components/markdown/fence/types.ts b/packages/app/src/components/markdown/fence/types.ts
new file mode 100644
index 000000000..b499bc893
--- /dev/null
+++ b/packages/app/src/components/markdown/fence/types.ts
@@ -0,0 +1,10 @@
+import type { TextStyle } from "react-native";
+
+export type MarkdownPhase = "streaming" | "complete";
+
+export interface MarkdownFenceRendererProps {
+ code: string;
+ phase: MarkdownPhase;
+ inheritedStyles: TextStyle;
+ textStyle: TextStyle;
+}
diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx
index 292ea6f76..ddb82ee33 100644
--- a/packages/app/src/components/markdown/renderer.tsx
+++ b/packages/app/src/components/markdown/renderer.tsx
@@ -23,6 +23,7 @@ import Markdown, {
} from "react-native-markdown-display";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
+import { MarkdownFenceBlock } from "@/components/markdown/fence";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
@@ -606,10 +607,11 @@ export function createSharedMarkdownRules(): RenderRules {
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
-
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index e2e54fa77..1b19994c7 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -71,6 +71,8 @@ import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-
import { markdownNodeContainsType } from "@/utils/markdown-ast";
import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
+import { MarkdownFenceBlock } from "@/components/markdown/fence";
+import type { MarkdownPhase } from "@/components/markdown/fence/types";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
@@ -739,6 +741,7 @@ interface AssistantMessageProps {
serverId?: string;
client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
+ phase: MarkdownPhase;
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -1450,6 +1453,7 @@ export const AssistantMessage = memo(function AssistantMessage({
serverId,
client,
spacing = "default",
+ phase,
}: AssistantMessageProps) {
const markdownParser = useMemo(createAssistantMarkdownParser, []);
@@ -1695,10 +1699,11 @@ export const AssistantMessage = memo(function AssistantMessage({
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
-
@@ -1893,11 +1898,11 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
};
- }, [client, fileLinkActions, markdownParser, occurrenceKey, serverId, workspaceRoot]);
+ }, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
- () => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })),
+ () => blocks.map((block, index) => ({ key: `block:${index}`, block })),
[blocks],
);
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index c38381b72..810c8d3a1 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -272,6 +272,14 @@ export const ar: TranslationResources = {
},
},
message: {
+ diagram: {
+ diagram: "مخطط",
+ zoomIn: "تكبير",
+ zoomOut: "تصغير",
+ resetZoom: "إعادة تعيين العرض",
+ viewSource: "عرض المصدر",
+ viewDiagram: "عرض المخطط",
+ },
actions: {
copyCode: "نسخ الرمز",
copyTurn: "نسخ بدوره",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index c6954b975..9caf7292b 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -271,6 +271,14 @@ export const en = {
},
},
message: {
+ diagram: {
+ diagram: "Diagram",
+ zoomIn: "Zoom in",
+ zoomOut: "Zoom out",
+ resetZoom: "Reset view",
+ viewSource: "View source",
+ viewDiagram: "View diagram",
+ },
actions: {
copyCode: "Copy code",
copyTurn: "Copy turn",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index 7b0fa3875..c5d4a2d27 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -275,6 +275,14 @@ export const es: TranslationResources = {
},
},
message: {
+ diagram: {
+ diagram: "Diagrama",
+ zoomIn: "Acercar",
+ zoomOut: "Alejar",
+ resetZoom: "Restablecer vista",
+ viewSource: "Ver fuente",
+ viewDiagram: "Ver diagrama",
+ },
actions: {
copyCode: "Copiar código",
copyTurn: "Copiar turno",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 70d7e2cc1..bc22d3afe 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -276,6 +276,14 @@ export const fr: TranslationResources = {
},
},
message: {
+ diagram: {
+ diagram: "Diagramme",
+ zoomIn: "Zoomer",
+ zoomOut: "Dézoomer",
+ resetZoom: "Réinitialiser la vue",
+ viewSource: "Afficher la source",
+ viewDiagram: "Afficher le diagramme",
+ },
actions: {
copyCode: "Copier le code",
copyTurn: "Copier le tour",
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index 85b95d85e..07308bb8f 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -275,6 +275,14 @@ export const ja: TranslationResources = {
},
},
message: {
+ diagram: {
+ diagram: "図",
+ zoomIn: "拡大",
+ zoomOut: "縮小",
+ resetZoom: "表示をリセット",
+ viewSource: "ソースを表示",
+ viewDiagram: "図を表示",
+ },
actions: {
copyCode: "コードをコピー",
copyTurn: "ターンをコピー",
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index cd8969955..2762f0174 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -273,6 +273,14 @@ export const ko: TranslationResources = {
},
},
message: {
+ diagram: {
+ diagram: "다이어그램",
+ zoomIn: "확대",
+ zoomOut: "축소",
+ resetZoom: "보기 초기화",
+ viewSource: "소스 보기",
+ viewDiagram: "다이어그램 보기",
+ },
actions: {
copyCode: "코드 복사",
copyTurn: "턴 복사",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index aa9fb7a9a..4aeba0209 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -275,6 +275,14 @@ export const ptBR: TranslationResources = {
},
},
message: {
+ diagram: {
+ diagram: "Diagrama",
+ zoomIn: "Aproximar",
+ zoomOut: "Afastar",
+ resetZoom: "Redefinir visualização",
+ viewSource: "Ver fonte",
+ viewDiagram: "Ver diagrama",
+ },
actions: {
copyCode: "Copiar código",
copyTurn: "Copiar turno",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 753ddafc2..37ab6b1cf 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -274,6 +274,14 @@ export const ru: TranslationResources = {
},
},
message: {
+ diagram: {
+ diagram: "Диаграмма",
+ zoomIn: "Приблизить",
+ zoomOut: "Отдалить",
+ resetZoom: "Сбросить вид",
+ viewSource: "Показать код",
+ viewDiagram: "Показать диаграмму",
+ },
actions: {
copyCode: "Скопировать код",
copyTurn: "Копировать ход",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index 29167ff8e..1d2bed925 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -272,6 +272,14 @@ export const zhCN: TranslationResources = {
},
},
message: {
+ diagram: {
+ diagram: "图表",
+ zoomIn: "放大",
+ zoomOut: "缩小",
+ resetZoom: "重置视图",
+ viewSource: "查看源码",
+ viewDiagram: "查看图表",
+ },
actions: {
copyCode: "复制代码",
copyTurn: "复制回合",
diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts
index 99ce0596c..3e2374282 100644
--- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts
+++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts
@@ -68,6 +68,47 @@ describe("MockLoadTestAgentClient", () => {
await session.interrupt();
});
+ test("streams a configured assistant response through the normal timeline", async () => {
+ vi.useFakeTimers();
+ const response = [
+ "```mermaid",
+ "flowchart LR",
+ " Start --> Middle",
+ ' Middle --> End["Done"]',
+ "```",
+ ].join("\n");
+ const client = new MockLoadTestAgentClient();
+ const session = await client.createSession({
+ provider: "mock",
+ cwd: process.cwd(),
+ model: "ten-second-stream",
+ featureValues: {
+ mockStreamingAssistantResponse: response,
+ mockStreamingAssistantIntervalMs: 20,
+ },
+ });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+
+ const resultPromise = session.run("Render a diagram while streaming.");
+ await vi.runAllTimersAsync();
+
+ await expect(resultPromise).resolves.toMatchObject({ finalText: response, canceled: false });
+ const streamedText = events
+ .flatMap((event) =>
+ event.type === "timeline" && event.item.type === "assistant_message"
+ ? [event.item.text]
+ : [],
+ )
+ .join("");
+ expect(streamedText).toBe(response);
+ expect(
+ events.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
+ .length,
+ ).toBeGreaterThan(5);
+ expect(events.at(-1)).toMatchObject({ type: "turn_completed", provider: "mock" });
+ });
+
test("can withhold the provider user-message echo until an immediate interrupt", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();
diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts
index 147045ea0..78a13ea18 100644
--- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts
+++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts
@@ -644,6 +644,8 @@ export class MockLoadTestAgentSession implements AgentSession {
private modeId: string | null;
private modelId: string | null;
private readonly assistantResponse: string | null;
+ private readonly streamingAssistantResponse: string | null;
+ private readonly streamingAssistantIntervalMs: number;
private readonly rewindError: string | null;
private remainingPromptRejections: number;
@@ -656,6 +658,18 @@ export class MockLoadTestAgentSession implements AgentSession {
typeof options.config.featureValues?.mockAssistantResponse === "string"
? options.config.featureValues.mockAssistantResponse
: null;
+ this.streamingAssistantResponse =
+ typeof options.config.featureValues?.mockStreamingAssistantResponse === "string"
+ ? options.config.featureValues.mockStreamingAssistantResponse
+ : null;
+ const requestedStreamingInterval =
+ options.config.featureValues?.mockStreamingAssistantIntervalMs;
+ this.streamingAssistantIntervalMs =
+ typeof requestedStreamingInterval === "number" &&
+ Number.isFinite(requestedStreamingInterval) &&
+ requestedStreamingInterval >= 1
+ ? Math.min(requestedStreamingInterval, 1_000)
+ : MOCK_LOAD_TEST_INTERVAL_MS;
this.rewindError =
typeof options.config.featureValues?.mockRewindError === "string"
? options.config.featureValues.mockRewindError
@@ -721,6 +735,8 @@ export class MockLoadTestAgentSession implements AgentSession {
const scheduleTurn = () => {
if (shouldEmitTurnFailure(prompt)) {
this.scheduleFailedTurn(turn);
+ } else if (this.streamingAssistantResponse !== null) {
+ this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse);
} else if (this.assistantResponse !== null) {
this.scheduleSettledAssistantTurn(turn, this.assistantResponse);
} else if (structuredBranchName) {
@@ -1040,6 +1056,32 @@ export class MockLoadTestAgentSession implements AgentSession {
turn.timer.unref?.();
}
+ private scheduleStreamingAssistantTurn(turn: ActiveTurn, finalText: string): void {
+ const tokens = tokenize(finalText);
+ const emitNext = () => {
+ if (this.activeTurn !== turn) {
+ return;
+ }
+ this.clearTurnTimer(turn);
+ this.emitTurnStarted(turn);
+ const token = tokens.shift();
+ if (token === undefined) {
+ this.finishTurnWithText(turn, finalText);
+ return;
+ }
+ turn.emittedTokens += 1;
+ this.emitTimeline(turn.turnId, {
+ type: "assistant_message",
+ text: token,
+ messageId: turn.assistantMessageId,
+ });
+ turn.timer = setTimeout(emitNext, this.streamingAssistantIntervalMs);
+ turn.timer.unref?.();
+ };
+ turn.timer = setTimeout(emitNext, 0);
+ turn.timer.unref?.();
+ }
+
private emitSettledAssistantTurn(turn: ActiveTurn, finalText: string): void {
if (this.activeTurn !== turn) {
return;