From 7253228dc537de4b612a26621e21a958f05de708 Mon Sep 17 00:00:00 2001 From: Damon <17677912+dmeledon@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:46:05 +0000 Subject: [PATCH] feat(app): render mermaid diagrams in agent chat with pan/zoom (#2306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): render mermaid diagrams in agent chat with pan/zoom Fenced mermaid blocks in assistant messages now render as diagrams instead of code blocks. Web: mermaid renders inline via dynamic import (code-split, loads on first diagram). Drag to pan, ctrl/cmd+wheel or trackpad pinch to zoom about the cursor, double-click to reset; plain wheel still scrolls the chat. Rendered SVGs are cached across virtualization remounts. iOS/Android: diagrams render in a WebView running a self-contained generated bundle (build:mermaid-webview, mirrors the native terminal's xterm pattern — no CDN, mermaid ships in the app). The inline preview is touch-shielded and height-driven by a bridge message; tapping opens a fullscreen viewer where the platform WebView owns pinch-zoom. Streaming source that doesn't parse yet falls back to the highlighted code block and keeps the last good render; renders are serialized through one queue with stale-task skipping so token-by-token updates cost at most one in-flight layout. Security: mermaid can fetch external resources during layout (image shapes, CSS url()) before output sanitization runs — see mermaid-js/mermaid#7645 — so resource-bearing source is rejected up front (containsUnsafeMermaidSource) on every platform, htmlLabels stay off, securityLevel is strict, and the mermaid secure list is extended so directives can't override app theming or themeCSS. The lockfile diff includes npm 11.17 normalization churn beyond the mermaid closure; a no-op npm install produces the same rewrite. Co-Authored-By: Claude Fable 5 * fix(app): serialize native mermaid renders and pin viewer to last good source Serialize render messages inside the mermaid webview entry so a newer streamed update can't re-initialize global mermaid state (e.g. flip the theme) while a prior layout is still running; superseded queued renders are skipped. Track the last successfully rendered source on the RN side and open the fullscreen viewer with that, so tapping mid-stream while the latest chunk is still invalid can't show a blank viewer. Addresses PR review feedback. Co-Authored-By: Claude Fable 5 * fix(app): pair native mermaid renders with their source via request id A "rendered" bridge event was credited to the newest sent source, but an older render can complete while a newer (possibly invalid) chunk's send is in flight, mis-crediting the older render's success to the newer source. Render messages now carry a request id that the entry echoes on rendered/renderError; the RN side resolves the source by id, so the fullscreen viewer always opens the exact source that produced the visible render. Addresses PR review feedback. Co-Authored-By: Claude Fable 5 * fix(app): harden mermaid guard and native preview per adversarial review - Guard bypass (blocker): mermaid accepts quoted and unicode-escaped object keys, so quoted "img": evaded the resource denylist while still parsing into a real img property. The guard now decodes u/x escapes and strips quoting characters before matching, and tests both raw and normalized source. - structuredClone polyfill banner in the webview bundle: the app's iOS floor is 15.1 but structuredClone exists only from 15.4; esbuild targets don't polyfill runtime APIs. - Preview height sizes the inner content view so fence padding/borders no longer clip the reported SVG height, and measurement happens at the final content width (measuring overlay keeps the fence insets). - The invisible measuring overlay is hit-test transparent, so an invalid diagram's fallback code block keeps its copy button and gestures. - renderError now prunes all superseded source-map entries, matching the rendered path, so a long broken stream can't accumulate sources. - The entry's render chain can no longer be poisoned: the whole task body is inside try/catch and the chain appends a trailing catch. - "Mermaid diagram" accessibility label is translated in all locales (message.mermaidDiagram) on web and native. - esbuild pinned in packages/app devDependencies so the generated webview artifact doesn't depend on hoisting layout; * feat(app): mermaid source toggle and visible zoom controls Web: a hover control cluster on the diagram (zoom in/out anchored to the viewport center, reset view, view source) mirroring the code block's hover-copy idiom; always visible on compact form factors where hover doesn't exist. View source swaps to the highlighted code block (with its own copy button) and a view-diagram button swaps back. Native: the fullscreen viewer gains a source toggle beside the close button, swapping the webview for the highlighted source in a scrollable view. All control labels are translated in every locale under message.mermaid.*; the flat mermaidDiagram key from the previous commit is folded into the same group. Co-Authored-By: Claude Fable 5 * fix(app): align mermaid control icons with the app's icon vocabulary Network is the settings Connections icon — reusing it for "view diagram" gave one glyph two meanings; use Workflow instead. RotateCw is the app's established reset/reload glyph (RotateCcw had no precedent). Co-Authored-By: Claude Fable 5 * fix(app): use Scan for mermaid reset-view control RotateCw is the app's reload glyph (19 uses) — a reset-zoom button wearing it invites a reload misread. Scan (corner-bracket fit-to-frame) is unused and carries the intended fit/reset semantics. Co-Authored-By: Claude Fable 5 * fix(app): remove raw NUL byte from mermaid cache key separator A literal 0x00 in the template string made git classify the file as binary, rendering the PR diff for it unreviewable. Use the escaped sequence instead — identical runtime key, plain-text source. Co-Authored-By: Claude Fable 5 * fix(app): align mermaid source-view toggle with the copy button The source view wrapped the code block while the fence margins stayed inside it, so the floating view-diagram toggle hung in the margin whitespace above the visible box — misaligned with the code block's copy button and outside its hover region. Hoist the margins onto the wrapper (both buttons now share the box edge) and render the toggle in the copy button's plain style so the pair reads as one control row. Co-Authored-By: Claude Fable 5 * fix(app): close mermaid review findings * chore(app): finish Mermaid PR rebase cleanup * refactor(app): isolate diagram rendering and stabilize streaming * fix(app): keep Mermaid previews stable while streaming * fix(nix): update npm dependency hash * fix(app): preserve streamed rows through completion --------- Co-authored-by: Damon Meledones Co-authored-by: Claude Fable 5 Co-authored-by: Mohamed Boudra --- nix/npm-deps.hash | 2 +- package-lock.json | 1429 ++++++++++++----- .../app/e2e/browser/mermaid-streaming.spec.ts | 68 + packages/app/e2e/support/helpers/diagram.ts | 48 + packages/app/package.json | 5 +- packages/app/src/agent-stream/layout.test.ts | 24 + packages/app/src/agent-stream/layout.ts | 5 + .../src/agent-stream/strategy-web.test.tsx | 72 + .../app/src/agent-stream/strategy-web.tsx | 7 +- packages/app/src/agent-stream/view.tsx | 1 + .../src/components/markdown/fence/index.tsx | 42 + .../markdown/fence/language.test.ts | 15 + .../src/components/markdown/fence/language.ts | 3 + .../markdown/fence/mermaid/build-runtime.mjs | 76 + .../markdown/fence/mermaid/host.native.tsx | 379 +++++ .../markdown/fence/mermaid/host.tsx | 13 + .../markdown/fence/mermaid/host.web.tsx | 490 ++++++ .../markdown/fence/mermaid/index.tsx | 6 + .../markdown/fence/mermaid/presentation.ts | 14 + .../fence/mermaid/render-model.test.ts | 163 ++ .../markdown/fence/mermaid/render-model.ts | 170 ++ .../markdown/fence/mermaid/runtime/entry.ts | 131 ++ .../fence/mermaid/runtime/html.gen.ts | 4 + .../fence/mermaid/runtime/messages.ts | 91 ++ .../mermaid/runtime/request-driver.test.ts | 52 + .../fence/mermaid/runtime/request-driver.ts | 63 + .../mermaid/runtime/runtime-html.test.ts | 12 + .../mermaid/runtime/runtime.browser.test.ts | 119 ++ .../fence/mermaid/source-policy.test.ts | 72 + .../markdown/fence/mermaid/source-policy.ts | 52 + .../fence/mermaid/use-render-model.ts | 96 ++ .../src/components/markdown/fence/types.ts | 10 + .../app/src/components/markdown/renderer.tsx | 6 +- packages/app/src/components/message.tsx | 13 +- packages/app/src/i18n/resources/ar.ts | 8 + packages/app/src/i18n/resources/en.ts | 8 + packages/app/src/i18n/resources/es.ts | 8 + packages/app/src/i18n/resources/fr.ts | 8 + packages/app/src/i18n/resources/ja.ts | 8 + packages/app/src/i18n/resources/ko.ts | 8 + packages/app/src/i18n/resources/pt-BR.ts | 8 + packages/app/src/i18n/resources/ru.ts | 8 + packages/app/src/i18n/resources/zh-CN.ts | 8 + .../providers/mock-load-test-agent.test.ts | 41 + .../agent/providers/mock-load-test-agent.ts | 42 + 45 files changed, 3528 insertions(+), 380 deletions(-) create mode 100644 packages/app/e2e/browser/mermaid-streaming.spec.ts create mode 100644 packages/app/e2e/support/helpers/diagram.ts create mode 100644 packages/app/src/components/markdown/fence/index.tsx create mode 100644 packages/app/src/components/markdown/fence/language.test.ts create mode 100644 packages/app/src/components/markdown/fence/language.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs create mode 100644 packages/app/src/components/markdown/fence/mermaid/host.native.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/host.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/host.web.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/index.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/presentation.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/render-model.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/render-model.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/entry.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/html.gen.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/messages.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/runtime-html.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/source-policy.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/source-policy.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/use-render-model.ts create mode 100644 packages/app/src/components/markdown/fence/types.ts 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 ( + `},\"putIntoIFrame\"),n$e=v((t,e,r,n,i)=>{let a=t.append(\"div\");a.attr(\"id\",r),n&&a.attr(\"style\",n);let s=a.append(\"svg\").attr(\"id\",e).attr(\"width\",\"100%\").attr(\"xmlns\",gNt);return i&&s.attr(\"xmlns:xlink\",i),s.append(\"g\"),t},\"appendDivSvgG\");function VK(t,e){return t.append(\"iframe\").attr(\"id\",e).attr(\"style\",\"width: 100%; height: 100%;\").attr(\"sandbox\",\"\")}v(VK,\"sandboxedIframe\");var INt=v((t,e,r,n)=>{t.getElementById(e)?.remove(),t.getElementById(r)?.remove(),t.getElementById(n)?.remove()},\"removeExistingElements\"),MNt=v(async function(t,e,r){lM();let n=qK(e);e=n.code;let i=It();ne.debug(i),e.length>(i?.maxTextSize??hNt)&&(e=dNt);let a=`#${t}`,s=\"i\"+t,o=\"#\"+s,l=\"d\"+t,u=\"#\"+l,h=v(()=>{let _=tt(f?o:u).node();_&&\"remove\"in _&&_.remove()},\"removeTempElements\"),d=tt(document.body),f=i.securityLevel===fNt,p=i.securityLevel===pNt,g=i.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=\"\"),f){let R=VK(tt(r),s);d=tt(R.nodes()[0].contentDocument.body),d.node().style.margin=\"0\"}else d=tt(r);n$e(d,t,l,`font-family: ${g}`,mNt)}else{if(INt(document,t,l,s),f){let R=VK(tt(document.body),s);d=tt(R.nodes()[0].contentDocument.body),d.node().style.margin=\"0\"}else d=tt(\"body\");n$e(d,t,l)}let m,y;try{m=await GK.fromText(e,{title:n.title})}catch(R){if(i.suppressErrorRendering)throw h(),R;m=await GK.fromText(\"error\"),y=R}let x=d.select(u).node(),b=m.type,T=x.firstChild,k=T.firstChild,C=m.renderer.getClasses?.(e,m),w=_Nt(i,b,C,a),S=document.createElement(\"style\");S.innerHTML=w,T.insertBefore(S,k);try{await m.renderer.draw(e,t,\"11.16.0\",m)}catch(R){throw i.suppressErrorRendering?h():$Mt.draw(e,t,\"11.16.0\"),R}let A=d.select(`${u} svg`),L=m.db.getAccTitle?.(),M=m.db.getAccDescription?.();K$e(b,A,L,M),d.select(`[id=\"${t}\"]`).selectAll(\"foreignobject > *\").attr(\"xmlns\",yNt);let P=d.select(u).node().innerHTML;if(ne.debug(\"config.arrowMarkerAbsolute\",i.arrowMarkerAbsolute),P=RNt(P,f,yl(i.arrowMarkerAbsolute)),f){let R=d.select(u+\" svg\").node();P=LNt(P,R)}else p||(P=to.sanitize(P,{ADD_TAGS:kNt,ADD_ATTR:SNt,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(sNt(),y)throw y;return h(),{diagramType:b,svg:P,bindFunctions:m.db.bindFunctions}},\"render\");function X$e(t={}){let e=Jn({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),NJ(e),e?.theme&&e.theme in qc?e.themeVariables=qc[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=qc.default.getThemeVariables(e.themeVariables));let r=typeof e==\"object\"?DJ(e):AD();rC(r.logLevel),lM()}v(X$e,\"initialize\");var j$e=v((t,e={})=>{let{code:r}=WK(t);return GK.fromText(r,e)},\"getDiagramFromText\");function K$e(t,e,r,n){W$e(e,t),q$e(e,r,n,e.attr(\"id\"))}v(K$e,\"addA11yInfo\");var Im=Object.freeze({render:MNt,parse:Y$e,getDiagramFromText:j$e,initialize:X$e,getConfig:It,setConfig:_D,getSiteConfig:AD,updateSiteConfig:OJ,reset:v(()=>{CC()},\"reset\"),globalReset:v(()=>{CC(Gp)},\"globalReset\"),defaultConfig:Gp});rC(It().logLevel);CC(It());var DNt=v((t,e,r)=>{ne.warn(t),fA(t)?(r&&r(t.str,t.hash),e.push({...t,message:t.str,error:t})):(r&&r(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},\"handleError\"),Z$e=v(async function(t={querySelector:\".mermaid\"}){try{await NNt(t)}catch(e){if(fA(e)&&ne.error(e.str),wd.parseError&&wd.parseError(e),!t.suppressErrors)throw ne.error(\"Use the suppressErrors option to suppress these errors\"),e}},\"run\"),NNt=v(async function({postRenderCallback:t,querySelector:e,nodes:r}={querySelector:\".mermaid\"}){let n=Im.getConfig();ne.debug(`${t?\"\":\"No \"}Callback function found`);let i;if(r)i=r;else if(e)i=document.querySelectorAll(e);else throw new Error(\"Nodes and querySelector are both undefined\");ne.debug(`Found ${i.length} diagrams`),n?.startOnLoad!==void 0&&(ne.debug(\"Start On Load: \"+n?.startOnLoad),Im.updateSiteConfig({startOnLoad:n?.startOnLoad}));let a=new Zt.InitIDGenerator(n.deterministicIds,n.deterministicIDSeed),s,o=[];for(let l of Array.from(i)){if(ne.info(\"Rendering diagram: \"+l.id),l.getAttribute(\"data-processed\"))continue;l.setAttribute(\"data-processed\",\"true\");let u=`mermaid-${a.next()}`;s=l.innerHTML,s=CA(Zt.entityDecode(s)).trim().replace(//gi,\"
\");let h=Zt.detectInit(s);h&&ne.debug(\"Detected early reinit: \",h);try{let{svg:d,bindFunctions:f}=await tFe(u,s,l);l.innerHTML=d,t&&await t(u),f&&f(l)}catch(d){DNt(d,o,wd.parseError)}}if(o.length>0)throw o[0]},\"runThrowsErrors\"),Q$e=v(function(t){Im.initialize(t)},\"initialize\"),ONt=v(async function(t,e,r){ne.warn(\"mermaid.init is deprecated. Please use run instead.\"),t&&Q$e(t);let n={postRenderCallback:r,querySelector:\".mermaid\"};typeof e==\"string\"?n.querySelector=e:e&&(e instanceof HTMLElement?n.nodes=[e]:n.nodes=e),await Z$e(n)},\"init\"),PNt=v(async(t,{lazyLoad:e=!0}={})=>{lM(),c5(...t),e===!1&&await iNt()},\"registerExternalDiagrams\"),J$e=v(function(){if(wd.startOnLoad){let{startOnLoad:t}=Im.getConfig();t&&wd.run().catch(e=>ne.error(\"Mermaid failed to initialize\",e))}},\"contentLoaded\");typeof document<\"u\"&&window.addEventListener(\"load\",J$e,!1);var BNt=v(function(t){wd.parseError=t},\"setParseErrorHandler\"),oM=[],zK=!1,eFe=v(async()=>{if(!zK){for(zK=!0;oM.length>0;){let t=oM.shift();if(t)try{await t()}catch(e){ne.error(\"Error executing queue\",e)}}zK=!1}},\"executeQueue\"),$Nt=v(async(t,e)=>new Promise((r,n)=>{let i=v(()=>new Promise((a,s)=>{Im.parse(t,e).then(o=>{a(o),r(o)},o=>{ne.error(\"Error parsing\",o),wd.parseError?.(o),s(o),n(o)})}),\"performCall\");oM.push(i),eFe().catch(n)}),\"parse\"),tFe=v((t,e,r)=>new Promise((n,i)=>{let a=v(()=>new Promise((s,o)=>{Im.render(t,e,r).then(l=>{s(l),n(l)},l=>{ne.error(\"Error parsing\",l),wd.parseError?.(l),o(l),i(l)})}),\"performCall\");oM.push(a),eFe().catch(i)}),\"render\"),FNt=v(()=>Object.keys(Bd).map(t=>({id:t})),\"getRegisteredDiagramsMetadata\"),wd={startOnLoad:!0,mermaidAPI:Im,parse:$Nt,render:tFe,init:ONt,run:Z$e,registerExternalDiagrams:PNt,registerLayoutLoaders:_G,initialize:Q$e,parseError:void 0,contentLoaded:J$e,setParseErrorHandler:BNt,detectType:wC,registerIconPacks:qy,getRegisteredDiagramsMetadata:FNt},UK=wd;function zNt(t){return typeof t==\"object\"&&t!==null}function GNt(t){return t===\"light\"||t===\"dark\"}function rFe(t){return!zNt(t)||t.type!==\"render\"||typeof t.revision!=\"number\"||!Number.isInteger(t.revision)||typeof t.source!=\"string\"||!GNt(t.colorScheme)||typeof t.interactive!=\"boolean\"?null:{type:\"render\",revision:t.revision,source:t.source,colorScheme:t.colorScheme,interactive:t.interactive}}function YK(t){window.ReactNativeWebView?.postMessage?.(JSON.stringify(t)),window.parent!==window&&window.parent.postMessage(t,\"*\")}function VNt(t){UK.initialize({startOnLoad:!1,securityLevel:\"strict\",suppressErrorRendering:!0,theme:t===\"dark\"?\"dark\":\"default\",secure:[\"secure\",\"securityLevel\",\"startOnLoad\",\"maxTextSize\",\"suppressErrorRendering\",\"maxEdges\",\"theme\",\"themeVariables\",\"themeCSS\"],flowchart:{htmlLabels:!1},class:{htmlLabels:!1}})}function WNt(t){document.querySelector('meta[name=\"viewport\"]')?.setAttribute(\"content\",t?\"width=device-width, initial-scale=1, maximum-scale=8\":\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no\")}var XK=0,cM=null,uM=!1,HK=!1;async function qNt(t){try{VNt(t.colorScheme);let{svg:e}=await UK.render(`paseo-mermaid-${t.revision}`,t.source);if(t.revision!==XK)return;WNt(t.interactive);let r=document.getElementById(\"diagram\");if(!r)return;r.innerHTML=e;let n=r.querySelector(\"svg\")?.getBoundingClientRect();YK({type:\"rendered\",revision:t.revision,source:t.source,colorScheme:t.colorScheme,height:Math.ceil(n?.height??r.scrollHeight),width:Math.ceil(n?.width??r.scrollWidth)})}catch{t.revision===XK&&YK({type:\"renderError\",revision:t.revision})}}async function UNt(){if(!uM){uM=!0;try{for(;cM;){let t=cM;cM=null,await qNt(t)}}finally{uM=!1}}}function nFe(t){let e=rFe(t);e&&(XK=e.revision,cM=e,!(uM||HK)&&(HK=!0,window.setTimeout(()=>{HK=!1,UNt()},0)))}window.__PASEO_MERMAID_RUNTIME_RECEIVE__=nFe;window.addEventListener(\"message\",t=>{t.source===window.parent&&nFe(t.data)});YK({type:\"bridgeReady\"});})();\n/*! Bundled license information:\n\ndompurify/dist/purify.es.mjs:\n (*! @license DOMPurify 3.4.12 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.12/LICENSE *)\n\nmermaid/dist/chunks/mermaid.core/chunk-ZIRB5QZD.mjs:\n (*! Bundled license information:\n \n js-yaml/dist/js-yaml.mjs:\n (*! js-yaml 4.1.1 https://github.com/nodeca/js-yaml @license MIT *)\n *)\n\nlodash-es/lodash.js:\n (**\n * @license\n * Lodash (Custom Build) \n * Build: `lodash modularize exports=\"es\" --repo lodash/lodash#4.18.1 -o ./`\n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n *)\n\ncytoscape/dist/cytoscape.esm.mjs:\n (*!\n Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable\n Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com)\n Licensed under The MIT License (http://opensource.org/licenses/MIT)\n *)\n (*!\n Event object based on jQuery events, MIT license\n \n https://jquery.org/license/\n https://tldrlegal.com/license/mit-license\n https://github.com/jquery/jquery/blob/master/src/event.js\n *)\n (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *)\n (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *)\n\n@mermaid-js/parser/dist/chunks/mermaid-parser.core/chunk-KEIR6QF5.mjs:\n (*! Bundled license information:\n \n lodash-es/lodash.js:\n (**\n * @license\n * Lodash (Custom Build) \n * Build: `lodash modularize exports=\"es\" -o ./`\n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n *)\n *)\n\nmermaid/dist/mermaid.core.mjs:\n (*! Check if previously processed *)\n (*!\n * Wait for document loaded before starting the execution\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;