diff --git a/README.md b/README.md
index 35ff485cb..5d678a259 100644
--- a/README.md
+++ b/README.md
@@ -684,6 +684,8 @@ The Web UI composer also supports browser-based voice dictation when the browser
Interrupted first-turn runs still persist a fallback conversation title, so stopping a streaming response does not leave the thread as "Untitled" after refresh.
+Streaming Markdown responses animate only newly arrived words; text that is already visible is not faded out and replayed when the next chunk extends the same block.
+
In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation.
Web UI chat links percent-encode custom thread identifiers before placing them in route segments, so reserved URL characters such as `#` and `?` do not change which conversation is opened.
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 07babdb86..e0864427d 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -86,6 +86,7 @@ Tool-calling AI messages can contain user-visible text as well as `tool_calls`.
- **Thread hooks** (`useThreadStream`, `useSubmitThread`, `useThreads`) are the primary API interface
- **Thread routes** — construct Web UI chat paths through `core/threads/utils.ts::pathOfThread()`, which percent-encodes both custom agent names and thread IDs before inserting them into route segments
- **LangGraph client** is a singleton obtained via `getAPIClient()` in `core/api/`
+- **Streaming Markdown rendering** is owned by `core/streamdown`: Streamdown's `animated` / `isAnimating` API handles incremental word animation, while the shared `streamdownRenderingPlugins` config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation.
- **Environment validation** uses `@t3-oss/env-nextjs` with Zod schemas (`src/env.js`). Skip with `SKIP_ENV_VALIDATION=1`
- **Subtask step history and runtime metadata** (`core/tasks/`) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. `Subtask.steps[]` is accumulated live from `task_running` events (appended via `mergeSteps`, not overwritten) and backfilled on expand for historical runs by `fetchSubtaskSteps`, which pages the events endpoint scoped to one task (GET `/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…`) until a short page, so the run-wide limit can't truncate the timeline. `task_started` carries the effective `model_name`; `task_running` carries a cumulative usage snapshot after each completed LLM call. `core/tasks/lifecycle.ts` normalizes these additive events, and `computeNextSubtask` keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (`subagent_model_name` / `subagent_token_usage`) restores the same values from normal history after reload; no per-card event fetch is needed. `core/tasks/steps.ts` is the pure step model: `messageToStep` (live), `eventsToSteps` (reload), `mergeSteps` (dedup by `message_index`), and `stepsForDisplay` (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as `result`). `core/tasks/context.tsx`'s `useUpdateSubtask` applies updates against a `tasksRef` mirroring the latest state (not a closure snapshot), so a late-resolving `fetchSubtaskSteps` backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning `run_id` is carried onto history content messages in `buildVisibleHistoryMessages` so the card can resolve the events endpoint.
diff --git a/frontend/package.json b/frontend/package.json
index 7378d018a..f0297280c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -45,6 +45,8 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
+ "@streamdown/code": "1.1.1",
+ "@streamdown/mermaid": "1.0.2",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.90.17",
"@types/hast": "^3.0.4",
@@ -84,9 +86,9 @@
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
- "shiki": "3.15.0",
+ "shiki": "3.23.0",
"sonner": "^2.0.7",
- "streamdown": "1.4.0",
+ "streamdown": "2.5.0",
"tailwind-merge": "^3.4.0",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 001fe7156..a79934bda 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -86,6 +86,12 @@ importers:
'@radix-ui/react-use-controllable-state':
specifier: ^1.2.2
version: 1.2.2(@types/react@19.2.13)(react@19.2.4)
+ '@streamdown/code':
+ specifier: 1.1.1
+ version: 1.1.1(react@19.2.4)
+ '@streamdown/mermaid':
+ specifier: 1.0.2
+ version: 1.0.2(react@19.2.4)
'@t3-oss/env-nextjs':
specifier: ^0.12.0
version: 0.12.0(typescript@5.9.3)(zod@3.25.76)
@@ -204,14 +210,14 @@ importers:
specifier: ^6.0.0
version: 6.0.0
shiki:
- specifier: 3.15.0
- version: 3.15.0
+ specifier: 3.23.0
+ version: 3.23.0
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
streamdown:
- specifier: 1.4.0
- version: 1.4.0(@types/react@19.2.13)(react@19.2.4)
+ specifier: 2.5.0
+ version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -738,105 +744,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -996,42 +986,36 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@napi-rs/simple-git-linux-arm64-musl@0.1.22':
resolution: {integrity: sha512-MOs7fPyJiU/wqOpKzAOmOpxJ/TZfP4JwmvPad/cXTOWYwwyppMlXFRms3i98EU3HOazI/wMU2Ksfda3+TBluWA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@napi-rs/simple-git-linux-ppc64-gnu@0.1.22':
resolution: {integrity: sha512-L59dR30VBShRUIZ5/cQHU25upNgKS0AMQ7537J6LCIUEFwwXrKORZKJ8ceR+s3Sr/4jempWVvMdjEpFDE4HYww==}
engines: {node: '>= 10'}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@napi-rs/simple-git-linux-s390x-gnu@0.1.22':
resolution: {integrity: sha512-4FHkPlCSIZUGC6HiADffbe6NVoTBMd65pIwcd40IDbtFKOgFMBA+pWRqKiQ21FERGH16Zed7XHJJoY3jpOqtmQ==}
engines: {node: '>= 10'}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@napi-rs/simple-git-linux-x64-gnu@0.1.22':
resolution: {integrity: sha512-Ei1tM5Ho/dwknF3pOzqkNW9Iv8oFzRxE8uOhrITcdlpxRxVrBVptUF6/0WPdvd7R9747D/q61QG/AVyWsWLFKw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@napi-rs/simple-git-linux-x64-musl@0.1.22':
resolution: {integrity: sha512-zRYxg7it0p3rLyEJYoCoL2PQJNgArVLyNavHW03TFUAYkYi5bxQ/UFNVpgxMaXohr5yu7qCBqeo9j4DWeysalg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@napi-rs/simple-git-win32-arm64-msvc@0.1.22':
resolution: {integrity: sha512-XGFR1fj+Y9cWACcovV2Ey/R2xQOZKs8t+7KHPerYdJ4PtjVzGznI4c2EBHXtdOIYvkw7tL5rZ7FN1HJKdD5Quw==}
@@ -1087,28 +1071,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.6':
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@next/swc-linux-x64-gnu@16.2.6':
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-x64-musl@16.2.6':
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.6':
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
@@ -1736,28 +1716,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@resvg/resvg-js-linux-x64-musl@2.6.2':
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
@@ -1819,79 +1795,66 @@ packages:
resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.2':
resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.2':
resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.2':
resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.2':
resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.2':
resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.2':
resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.2':
resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.2':
resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.2':
resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.2':
resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.2':
resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.2':
resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.2':
resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
@@ -1955,25 +1918,21 @@ packages:
resolution: {integrity: sha512-pSI+npPQE/uDtiboqvcOIRJbEV2+B+H1xffmko/gw50la92oTUW60kVULFwsb6L0+GVCzIcwX3yq60GtYIn+Ug==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rspack/binding-linux-arm64-musl@2.0.8':
resolution: {integrity: sha512-igjJ43yxWQ72GZqjDDZSSHax9/Vg+6rLMmOvFglTJUkQpB4Tyvu/YjW+WRjYj2xRw6blOjLxUSJWASvuSqqlvg==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rspack/binding-linux-x64-gnu@2.0.8':
resolution: {integrity: sha512-zrkoEOnqj1hOEBO5T2I/2Ts2HSJsYFh1qXwMpK4dMJFGGNWDfNeUa6/LF5uq3VINF3JUl7RL47AgrucoSZJXPA==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rspack/binding-linux-x64-musl@2.0.8':
resolution: {integrity: sha512-6CtDaGZjNDvJd9TBp7a9zABbrPORO21W96+3ZcGBn0YNUPUk4ARxIxrTTpeJ/1F41QDM8AYIkGDdqEYMqTYBsA==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rspack/binding-wasm32-wasi@2.0.8':
resolution: {integrity: sha512-Yf4SiqTUroT5Ju+te0YAY2xxKOb35tECsO21v7hYyGa705wrgoAK/MmF7enOvs9GR1iZIqgiLD/wxsIxl8GjJw==}
@@ -2049,15 +2008,27 @@ packages:
'@shikijs/engine-javascript@3.15.0':
resolution: {integrity: sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==}
+ '@shikijs/engine-javascript@3.23.0':
+ resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
+
'@shikijs/engine-oniguruma@3.15.0':
resolution: {integrity: sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==}
+ '@shikijs/engine-oniguruma@3.23.0':
+ resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
+
'@shikijs/langs@3.15.0':
resolution: {integrity: sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==}
+ '@shikijs/langs@3.23.0':
+ resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
+
'@shikijs/themes@3.15.0':
resolution: {integrity: sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==}
+ '@shikijs/themes@3.23.0':
+ resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
+
'@shikijs/twoslash@3.23.0':
resolution: {integrity: sha512-pNaLJWMA3LU7PhT8tm9OQBZ1epy0jmdgeJzntBtr1EVXLbHxGzTj3mnf9vOdcl84l96qnlJXkJ/NGXZYBpXl5g==}
peerDependencies:
@@ -2084,6 +2055,16 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+ '@streamdown/code@1.1.1':
+ resolution: {integrity: sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+
+ '@streamdown/mermaid@1.0.2':
+ resolution: {integrity: sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -2159,28 +2140,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -2584,49 +2561,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3772,6 +3741,9 @@ packages:
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
+ hast-util-sanitize@5.0.2:
+ resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
+
hast-util-to-estree@3.1.3:
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
@@ -4165,28 +4137,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -4231,11 +4199,6 @@ packages:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
- lucide-react@0.542.0:
- resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==}
- peerDependencies:
- react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
-
lucide-react@0.562.0:
resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==}
peerDependencies:
@@ -4256,6 +4219,11 @@ packages:
engines: {node: '>= 20'}
hasBin: true
+ marked@17.0.6:
+ resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
marky@1.3.0:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
@@ -4942,12 +4910,6 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
- react-markdown@10.1.0:
- resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
- peerDependencies:
- '@types/react': '>=18'
- react: '>=18'
-
react-medium-image-zoom@5.4.1:
resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==}
peerDependencies:
@@ -5036,8 +4998,8 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
- rehype-harden@1.1.7:
- resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==}
+ rehype-harden@1.1.8:
+ resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==}
rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
@@ -5057,6 +5019,9 @@ packages:
rehype-recma@1.0.0:
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
+ rehype-sanitize@6.0.0:
+ resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
+
rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
@@ -5088,6 +5053,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+ remend@1.3.0:
+ resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -5212,6 +5180,9 @@ packages:
shiki@3.15.0:
resolution: {integrity: sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==}
+ shiki@3.23.0:
+ resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
+
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -5279,10 +5250,11 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
- streamdown@1.4.0:
- resolution: {integrity: sha512-ylhDSQ4HpK5/nAH9v7OgIIdGJxlJB2HoYrYkJNGrO8lMpnWuKUcrz/A8xAMwA6eILA27469vIavcOTjmxctrKg==}
+ streamdown@2.5.0:
+ resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
string.prototype.codepointat@0.2.1:
resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
@@ -6622,7 +6594,7 @@ snapshots:
'@mdx-js/mdx@3.1.1':
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdx': 2.0.13
@@ -7602,19 +7574,38 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
+ '@shikijs/engine-javascript@3.23.0':
+ dependencies:
+ '@shikijs/types': 3.23.0
+ '@shikijs/vscode-textmate': 10.0.2
+ oniguruma-to-es: 4.3.4
+
'@shikijs/engine-oniguruma@3.15.0':
dependencies:
'@shikijs/types': 3.15.0
'@shikijs/vscode-textmate': 10.0.2
+ '@shikijs/engine-oniguruma@3.23.0':
+ dependencies:
+ '@shikijs/types': 3.23.0
+ '@shikijs/vscode-textmate': 10.0.2
+
'@shikijs/langs@3.15.0':
dependencies:
'@shikijs/types': 3.15.0
+ '@shikijs/langs@3.23.0':
+ dependencies:
+ '@shikijs/types': 3.23.0
+
'@shikijs/themes@3.15.0':
dependencies:
'@shikijs/types': 3.15.0
+ '@shikijs/themes@3.23.0':
+ dependencies:
+ '@shikijs/types': 3.23.0
+
'@shikijs/twoslash@3.23.0(typescript@5.9.3)':
dependencies:
'@shikijs/core': 3.23.0
@@ -7645,6 +7636,16 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
+ '@streamdown/code@1.1.1(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+ shiki: 3.23.0
+
+ '@streamdown/mermaid@1.0.2(react@19.2.4)':
+ dependencies:
+ mermaid: 11.12.2
+ react: 19.2.4
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -7920,7 +7921,7 @@ snapshots:
'@types/estree-jsx@1.0.5':
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/estree@1.0.8': {}
@@ -9301,7 +9302,7 @@ snapshots:
estree-util-attach-comments@3.0.0:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
estree-util-build-jsx@3.0.1:
dependencies:
@@ -9314,7 +9315,7 @@ snapshots:
estree-util-scope@1.0.0:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
devlop: 1.1.0
estree-util-to-js@2.0.0:
@@ -9325,7 +9326,7 @@ snapshots:
estree-util-value-to-estree@3.5.0:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
estree-util-visit@2.0.0:
dependencies:
@@ -9336,7 +9337,7 @@ snapshots:
estree-walker@3.0.3:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
esutils@2.0.3: {}
@@ -9643,9 +9644,15 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
+ hast-util-sanitize@5.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@ungap/structured-clone': 1.3.0
+ unist-util-position: 5.0.0
+
hast-util-to-estree@3.1.3:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
@@ -9680,7 +9687,7 @@ snapshots:
hast-util-to-jsx-runtime@2.3.6:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
@@ -10102,10 +10109,6 @@ snapshots:
lru-cache@11.5.2: {}
- lucide-react@0.542.0(react@19.2.4):
- dependencies:
- react: 19.2.4
-
lucide-react@0.562.0(react@19.2.4):
dependencies:
react: 19.2.4
@@ -10120,6 +10123,8 @@ snapshots:
marked@16.4.2: {}
+ marked@17.0.6: {}
+
marky@1.3.0: {}
math-intrinsics@1.1.0: {}
@@ -10442,7 +10447,7 @@ snapshots:
micromark-extension-mdx-expression@3.0.1:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
devlop: 1.1.0
micromark-factory-mdx-expression: 2.0.3
micromark-factory-space: 2.0.1
@@ -10453,7 +10458,7 @@ snapshots:
micromark-extension-mdx-jsx@3.0.2:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
micromark-factory-mdx-expression: 2.0.3
@@ -10470,7 +10475,7 @@ snapshots:
micromark-extension-mdxjs-esm@3.0.0:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-util-character: 2.1.1
@@ -10506,7 +10511,7 @@ snapshots:
micromark-factory-mdx-expression@2.0.3:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
@@ -10570,7 +10575,7 @@ snapshots:
micromark-util-events-to-acorn@2.0.3:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/unist': 3.0.3
devlop: 1.1.0
estree-util-visit: 2.0.0
@@ -11155,24 +11160,6 @@ snapshots:
react-is@16.13.1: {}
- react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4):
- dependencies:
- '@types/hast': 3.0.4
- '@types/mdast': 4.0.4
- '@types/react': 19.2.13
- devlop: 1.1.0
- hast-util-to-jsx-runtime: 2.3.6
- html-url-attributes: 3.0.1
- mdast-util-to-hast: 13.2.1
- react: 19.2.4
- remark-parse: 11.0.0
- remark-rehype: 11.1.2
- unified: 11.0.5
- unist-util-visit: 5.1.0
- vfile: 6.0.3
- transitivePeerDependencies:
- - supports-color
-
react-medium-image-zoom@5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -11220,7 +11207,7 @@ snapshots:
recma-build-jsx@1.0.0:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
estree-util-build-jsx: 3.0.1
vfile: 6.0.3
@@ -11235,14 +11222,14 @@ snapshots:
recma-parse@1.0.0:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
esast-util-from-js: 2.0.1
unified: 11.0.5
vfile: 6.0.3
recma-stringify@1.0.0:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
estree-util-to-js: 2.0.0
unified: 11.0.5
vfile: 6.0.3
@@ -11277,7 +11264,7 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
- rehype-harden@1.1.7:
+ rehype-harden@1.1.8:
dependencies:
unist-util-visit: 5.1.0
@@ -11315,12 +11302,17 @@ snapshots:
rehype-recma@1.0.0:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
'@types/hast': 3.0.4
hast-util-to-estree: 3.1.3
transitivePeerDependencies:
- supports-color
+ rehype-sanitize@6.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-sanitize: 5.0.2
+
rehype-slug@6.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -11402,6 +11394,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
+ remend@1.3.0: {}
+
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -11617,6 +11611,17 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
+ shiki@3.23.0:
+ dependencies:
+ '@shikijs/core': 3.23.0
+ '@shikijs/engine-javascript': 3.23.0
+ '@shikijs/engine-oniguruma': 3.23.0
+ '@shikijs/langs': 3.23.0
+ '@shikijs/themes': 3.23.0
+ '@shikijs/types': 3.23.0
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -11688,24 +11693,27 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
- streamdown@1.4.0(@types/react@19.2.13)(react@19.2.4):
+ streamdown@2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
clsx: 2.1.1
- katex: 0.16.28
- lucide-react: 0.542.0(react@19.2.4)
- marked: 16.4.2
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ marked: 17.0.6
mermaid: 11.12.2
react: 19.2.4
- react-markdown: 10.1.0(@types/react@19.2.13)(react@19.2.4)
- rehype-harden: 1.1.7
- rehype-katex: 7.0.1
+ react-dom: 19.2.4(react@19.2.4)
+ rehype-harden: 1.1.8
rehype-raw: 7.0.0
+ rehype-sanitize: 6.0.0
remark-gfm: 4.0.1
- remark-math: 6.0.0
- shiki: 3.15.0
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ remend: 1.3.0
tailwind-merge: 3.4.0
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ unist-util-visit-parents: 6.0.2
transitivePeerDependencies:
- - '@types/react'
- supports-color
string.prototype.codepointat@0.2.1: {}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 3a816c435..d35069fbe 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,4 +1,5 @@
import "katex/dist/katex.min.css";
+import "streamdown/styles.css";
import "@/styles/globals.css";
import { type Metadata } from "next";
diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
index d77d75031..3d257e226 100644
--- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
+++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
@@ -44,7 +44,10 @@ import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { findToolCallResult } from "@/core/messages/utils";
import { installSkill, SkillRequestError } from "@/core/skills/api";
-import { SafeStreamdown } from "@/core/streamdown/components";
+import {
+ SafeStreamdown,
+ toStreamdownComponents,
+} from "@/core/streamdown/components";
import {
canBrowserPreviewFile,
checkCodeFile,
@@ -521,7 +524,7 @@ export function ArtifactFilePreview({
{content ?? ""}
diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx
index 0fe2609e7..1f3d81480 100644
--- a/frontend/src/components/workspace/messages/markdown-content.tsx
+++ b/frontend/src/components/workspace/messages/markdown-content.tsx
@@ -17,8 +17,13 @@ import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/stre
import {
preprocessStreamdownMarkdown,
streamdownPluginsWithoutRawHtml,
+ streamdownWordAnimation,
} from "@/core/streamdown";
-import { SafeMessageResponse } from "@/core/streamdown/components";
+import {
+ SafeMessageResponse,
+ type StreamdownComponentOverrides,
+ toStreamdownComponents,
+} from "@/core/streamdown/components";
import { cn } from "@/lib/utils";
import { createMarkdownLinkComponent } from "./markdown-link";
@@ -29,7 +34,7 @@ export type MarkdownContentProps = {
rehypePlugins?: ClipboardSafeStreamdownProps["rehypePlugins"];
className?: string;
remarkPlugins?: ClipboardSafeStreamdownProps["remarkPlugins"];
- components?: ClipboardSafeStreamdownProps["components"];
+ components?: StreamdownComponentOverrides;
};
type StreamingCodeProps = ComponentProps<"code"> & {
@@ -260,8 +265,10 @@ export function MarkdownContent({
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={effectiveRehypePlugins}
- components={components}
- parseIncompleteMarkdown={isStreamingRender}
+ components={toStreamdownComponents(components)}
+ parseIncompleteMarkdown={isLoading}
+ animated={streamdownWordAnimation}
+ isAnimating={isLoading}
>
{normalizedContent}
diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx
index 4340814c1..e75106b60 100644
--- a/frontend/src/components/workspace/messages/message-group.tsx
+++ b/frontend/src/components/workspace/messages/message-group.tsx
@@ -35,7 +35,6 @@ import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
-import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -121,7 +120,6 @@ function MessageGroupComponent({
return filteredSteps[filteredSteps.length - 1];
}
}, [lastToolCallStep, steps]);
- const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const firstEligibleDebugSummaryStepIndexByMessageId = useMemo(() => {
const firstIndices = new Map();
@@ -242,13 +240,7 @@ function MessageGroupComponent({
- }
+ label={}
>
);
@@ -269,7 +261,6 @@ function MessageGroupComponent({
}
>,
@@ -390,7 +381,6 @@ function MessageGroupComponent({
}
>
diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx
index 6209bc722..0b28bfc2a 100644
--- a/frontend/src/components/workspace/messages/message-list-item.tsx
+++ b/frontend/src/components/workspace/messages/message-list-item.tsx
@@ -45,7 +45,6 @@ import {
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
-import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { readReferenceMessageContexts } from "@/core/sidecar";
import {
parseSlashSkillReference,
@@ -295,7 +294,6 @@ function MessageContent_({
runId?: string;
turnStartTime?: number | null;
}) {
- const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human";
const rawTurnDuration = message.additional_kwargs?.turn_duration as
| number
@@ -478,7 +476,6 @@ function MessageContent_({
diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx
index 08501c3ab..b1591a5ad 100644
--- a/frontend/src/components/workspace/messages/message-list.tsx
+++ b/frontend/src/components/workspace/messages/message-list.tsx
@@ -58,7 +58,6 @@ import {
isHiddenFromUIMessage,
type MessageGroup as ThreadMessageGroup,
} from "@/core/messages/utils";
-import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
buildMessageSidecarContext,
type SidecarContext,
@@ -434,7 +433,6 @@ export function MessageList({
.slice(lastHumanIndex)
.some((g) => g.type === "assistant");
}, [groupedMessages]);
- const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const lastGroupIndex = groupedMessages.length - 1;
const turnUsageMessagesByGroupIndex =
@@ -1036,7 +1034,6 @@ export function MessageList({
{renderTokenUsage({
messages: group.messages,
@@ -1060,7 +1057,6 @@ export function MessageList({
)}
diff --git a/frontend/src/components/workspace/messages/subtask-card.tsx b/frontend/src/components/workspace/messages/subtask-card.tsx
index 3f5c37f0b..bced00578 100644
--- a/frontend/src/components/workspace/messages/subtask-card.tsx
+++ b/frontend/src/components/workspace/messages/subtask-card.tsx
@@ -20,9 +20,14 @@ import { ShineBorder } from "@/components/ui/shine-border";
import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
-import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
-import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
-import { SafeStreamdown } from "@/core/streamdown/components";
+import {
+ streamdownPluginsWithoutRawHtml,
+ streamdownWordAnimation,
+} from "@/core/streamdown";
+import {
+ SafeStreamdown,
+ toStreamdownComponents,
+} from "@/core/streamdown/components";
import { fetchSubtaskSteps } from "@/core/tasks/api";
import { useSubtask, useUpdateSubtask } from "@/core/tasks/context";
import {
@@ -53,7 +58,6 @@ export function SubtaskCard({
}) {
const { t } = useI18n();
const [collapsed, setCollapsed] = useState(true);
- const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const task = useSubtask(taskId)!;
const { models, tokenUsageEnabled } = useModels();
const updateSubtask = useUpdateSubtask();
@@ -195,8 +199,10 @@ export function SubtaskCard({
{task.prompt}
@@ -221,11 +227,7 @@ export function SubtaskCard({
(step.tool_name ?? t.subtasks[task.status])
) : (
-
+
)
}
@@ -242,11 +244,7 @@ export function SubtaskCard({
+
) : null
}
>
diff --git a/frontend/src/core/rehype/index.ts b/frontend/src/core/rehype/index.ts
deleted file mode 100644
index bac5d497e..000000000
--- a/frontend/src/core/rehype/index.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { Element, Root, ElementContent } from "hast";
-import { useMemo } from "react";
-import { visit } from "unist-util-visit";
-import type { BuildVisitor } from "unist-util-visit";
-
-const CJK_TEXT_RE =
- /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
-
-export function rehypeSplitWordsIntoSpans() {
- return (tree: Root) => {
- visit(tree, "element", ((node: Element) => {
- if (
- ["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "strong"].includes(
- node.tagName,
- ) &&
- node.children
- ) {
- const newChildren: Array = [];
- node.children.forEach((child) => {
- if (child.type === "text") {
- if (CJK_TEXT_RE.test(child.value)) {
- newChildren.push(child);
- return;
- }
- const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
- const segments = segmenter.segment(child.value);
- const words = Array.from(segments)
- .map((segment) => segment.segment)
- .filter(Boolean);
- words.forEach((word: string) => {
- newChildren.push({
- type: "element",
- tagName: "span",
- properties: {
- className: "animate-fade-in",
- },
- children: [{ type: "text", value: word }],
- });
- });
- } else {
- newChildren.push(child);
- }
- });
- node.children = newChildren;
- }
- }) as BuildVisitor);
- };
-}
-
-export function useRehypeSplitWordsIntoSpans(enabled = true) {
- const rehypePlugins = useMemo(
- () => (enabled ? [rehypeSplitWordsIntoSpans] : []),
- [enabled],
- );
- return rehypePlugins;
-}
diff --git a/frontend/src/core/streamdown/components.tsx b/frontend/src/core/streamdown/components.tsx
index 0dcc6c668..ce8d2ba21 100644
--- a/frontend/src/core/streamdown/components.tsx
+++ b/frontend/src/core/streamdown/components.tsx
@@ -1,9 +1,8 @@
"use client";
-import {
- MessageResponse,
- type MessageResponseProps,
-} from "@/components/ai-elements/message";
+import type { ComponentType, JSX } from "react";
+import type { Components, ExtraProps } from "streamdown";
+
import {
ReasoningContent,
type ReasoningContentProps,
@@ -12,30 +11,73 @@ import {
ClipboardSafeStreamdown,
type ClipboardSafeStreamdownProps,
} from "@/components/ai-elements/streamdown";
+import { cn } from "@/lib/utils";
+import { streamdownRenderingPlugins } from "./plugins";
import {
useSafeStreamdownChildren,
useSafeStreamdownMarkdown,
} from "./safe-children";
+export type StreamdownComponentOverrides = {
+ [Key in keyof JSX.IntrinsicElements]?:
+ | ComponentType
+ | keyof JSX.IntrinsicElements;
+} & {
+ inlineCode?: ComponentType;
+};
+
+/**
+ * Adapts normal intrinsic-element overrides to Streamdown's component map.
+ *
+ * Streamdown 2.5's public `Components` type combines those overrides with a
+ * catch-all index signature whose generic props reject correctly typed React
+ * components. The runtime contract still accepts this standard component map,
+ * so keep the compatibility cast at this boundary instead of every caller.
+ */
+export function toStreamdownComponents(
+ components: StreamdownComponentOverrides,
+): Components {
+ return components as unknown as Components;
+}
+
export function SafeStreamdown({
children,
+ plugins = streamdownRenderingPlugins,
...props
}: ClipboardSafeStreamdownProps) {
const safeChildren = useSafeStreamdownChildren(children);
return (
- {safeChildren}
+
+ {safeChildren}
+
);
}
export function SafeMessageResponse({
children,
+ className,
+ plugins = streamdownRenderingPlugins,
...props
-}: MessageResponseProps) {
+}: ClipboardSafeStreamdownProps) {
const safeChildren = useSafeStreamdownChildren(children);
- return {safeChildren};
+ // Keep this wrapper outside the registry-generated MessageResponse component.
+ // That component memoizes only by children, while Streamdown's animation
+ // lifecycle also needs isAnimating/animated prop changes to render.
+ return (
+ *:first-child]:mt-0 [&>*:last-child]:mb-0",
+ className,
+ )}
+ plugins={plugins}
+ {...props}
+ >
+ {safeChildren}
+
+ );
}
export function SafeReasoningContent({
diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts
index 3eda6373d..b0784cb69 100644
--- a/frontend/src/core/streamdown/plugins.ts
+++ b/frontend/src/core/streamdown/plugins.ts
@@ -1,11 +1,11 @@
+import { code } from "@streamdown/code";
+import { mermaid } from "@streamdown/mermaid";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import type { StreamdownProps } from "streamdown";
-import { rehypeSplitWordsIntoSpans } from "../rehype";
-
const katexOptions = {
output: "html",
throwOnError: false,
@@ -17,7 +17,13 @@ const sharedRemarkPlugins = [
[remarkMath, { singleDollarTextMath: true }],
] as StreamdownProps["remarkPlugins"];
+export const streamdownRenderingPlugins = {
+ code,
+ mermaid,
+} satisfies NonNullable;
+
export const streamdownPlugins = {
+ plugins: streamdownRenderingPlugins,
remarkPlugins: sharedRemarkPlugins,
rehypePlugins: [
rehypeRaw,
@@ -25,15 +31,14 @@ export const streamdownPlugins = {
] as StreamdownProps["rehypePlugins"],
};
-export const streamdownPluginsWithWordAnimation = {
- remarkPlugins: sharedRemarkPlugins,
- rehypePlugins: [
- [rehypeKatex, katexOptions],
- rehypeSplitWordsIntoSpans,
- ] as StreamdownProps["rehypePlugins"],
-};
+export const streamdownWordAnimation = {
+ animation: "fadeIn",
+ duration: 200,
+ sep: "word",
+} as const satisfies Exclude;
export const streamdownPluginsWithoutRawHtml = {
+ plugins: streamdownPlugins.plugins,
remarkPlugins: streamdownPlugins.remarkPlugins,
rehypePlugins: streamdownPlugins.rehypePlugins?.filter(
(p) => p !== rehypeRaw,
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index f3d79eaa7..cecc2ab7e 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -1,7 +1,9 @@
@import "tailwindcss";
@import "tw-animate-css";
-@source "../node_modules/streamdown/dist/index.js";
+@source "../../node_modules/streamdown/dist/index.js";
+@source "../../node_modules/@streamdown/code/dist/*.js";
+@source "../../node_modules/@streamdown/mermaid/dist/*.js";
/* Heading */
@source inline("text-{xs,sm,base,lg,xl,2xl,3xl,4xl,5xl,6xl}");
diff --git a/frontend/tests/e2e/thread-history-mermaid.spec.ts b/frontend/tests/e2e/thread-history-mermaid.spec.ts
index 254b90f6c..e5eb60e6d 100644
--- a/frontend/tests/e2e/thread-history-mermaid.spec.ts
+++ b/frontend/tests/e2e/thread-history-mermaid.spec.ts
@@ -8,6 +8,10 @@ import {
const mermaidContent = `Here is a relationship diagram.
+\`\`\`typescript
+const answer: number = 42;
+\`\`\`
+
\`\`\`mermaid
flowchart TD
A[Lin
protagonist]
@@ -87,4 +91,22 @@ test("historical run messages preview labelled dotted Mermaid arrows", async ({
timeout: 15_000,
});
await expect(page.getByText("Mermaid Error:")).toHaveCount(0);
+ const highlightedTokens = page.locator(
+ '[data-streamdown="code-block-body"] code > span > span',
+ );
+ await expect
+ .poll(() => highlightedTokens.count(), { timeout: 15_000 })
+ .toBeGreaterThan(1);
+ const tokenStyles = await highlightedTokens.evaluateAll((tokens) =>
+ tokens.map((token) => ({
+ color: getComputedStyle(token).color,
+ style: token.getAttribute("style"),
+ })),
+ );
+ expect(new Set(tokenStyles.map(({ style }) => style)).size).toBeGreaterThan(
+ 1,
+ );
+ expect(new Set(tokenStyles.map(({ color }) => color)).size).toBeGreaterThan(
+ 1,
+ );
});
diff --git a/frontend/tests/unit/components/workspace/messages/markdown-content.test.ts b/frontend/tests/unit/components/workspace/messages/markdown-content.test.ts
index 314024492..a0dd79429 100644
--- a/frontend/tests/unit/components/workspace/messages/markdown-content.test.ts
+++ b/frontend/tests/unit/components/workspace/messages/markdown-content.test.ts
@@ -43,9 +43,9 @@ describe("MarkdownContent streaming code blocks", () => {
expect(html).not.toContain('data-streaming-inline-code="true"');
});
- it("restores Streamdown highlighting after streaming finishes", () => {
+ it("restores Streamdown code rendering after streaming finishes", () => {
const html = renderMarkdown(
- ["```html", 'Hello', "```"].join("\n"),
+ ["```typescript", "const answer: number = 42;", "```"].join("\n"),
false,
);
@@ -91,6 +91,23 @@ describe("MarkdownContent streaming code blocks", () => {
});
});
+describe("MarkdownContent streaming animation", () => {
+ it("uses Streamdown animation only for newly streamed words", () => {
+ const html = renderMarkdown("Hello streaming world", true);
+
+ expect(html).toContain("data-sd-animate");
+ expect(html).toContain("--sd-animation:sd-fadeIn");
+ expect(html).toContain("--sd-duration:200ms");
+ expect(html).not.toContain("animate-fade-in");
+ });
+
+ it("does not animate completed markdown", () => {
+ const html = renderMarkdown("Hello completed world", false);
+
+ expect(html).not.toContain("data-sd-animate");
+ });
+});
+
describe("MarkdownContent strikethrough", () => {
it("preserves single tildes in temperature ranges", () => {
const html = renderMarkdown("周六23~30℃;周日22~30℃", false);
diff --git a/frontend/tests/unit/core/streamdown-plugins.test.ts b/frontend/tests/unit/core/streamdown-plugins.test.ts
index a2f8a5e39..83227744b 100644
--- a/frontend/tests/unit/core/streamdown-plugins.test.ts
+++ b/frontend/tests/unit/core/streamdown-plugins.test.ts
@@ -4,13 +4,20 @@ import { renderToStaticMarkup } from "react-dom/server";
import { artifactMarkdownPlugins } from "@/components/workspace/artifacts/markdown-preview-plugins";
import { ArtifactLink } from "@/components/workspace/citations/artifact-link";
-import { SafeStreamdown, streamdownPlugins } from "@/core/streamdown";
+import {
+ SafeStreamdown,
+ streamdownPlugins,
+ toStreamdownComponents,
+} from "@/core/streamdown";
function renderArtifactMarkdown(content: string) {
return renderToStaticMarkup(
createElement(
SafeStreamdown,
- { ...artifactMarkdownPlugins, components: { a: ArtifactLink } },
+ {
+ ...artifactMarkdownPlugins,
+ components: toStreamdownComponents({ a: ArtifactLink }),
+ },
content,
),
);
diff --git a/frontend/tests/unit/core/streamdown/plugins.test.ts b/frontend/tests/unit/core/streamdown/plugins.test.ts
index cb27294ad..804866e8f 100644
--- a/frontend/tests/unit/core/streamdown/plugins.test.ts
+++ b/frontend/tests/unit/core/streamdown/plugins.test.ts
@@ -1,26 +1,65 @@
import { expect, test } from "@rstest/core";
+import { code, type HighlightResult } from "@streamdown/code";
+import { mermaid } from "@streamdown/mermaid";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import {
reasoningPlugins,
streamdownPlugins,
- streamdownPluginsWithWordAnimation,
+ streamdownRenderingPlugins,
+ streamdownWordAnimation,
} from "@/core/streamdown/plugins";
test("shared streamdown configs disable single-tilde strikethrough", () => {
const expectedGfmPlugin = [remarkGfm, { singleTilde: false }];
expect(streamdownPlugins.remarkPlugins).toContainEqual(expectedGfmPlugin);
- expect(streamdownPluginsWithWordAnimation.remarkPlugins).toContainEqual(
- expectedGfmPlugin,
- );
+});
+
+test("streaming word animation uses Streamdown's stable incremental animation", () => {
+ expect(streamdownWordAnimation).toEqual({
+ animation: "fadeIn",
+ duration: 200,
+ sep: "word",
+ });
});
test("streamdownPlugins includes rehypeRaw", () => {
expect(streamdownPlugins.rehypePlugins).toContain(rehypeRaw);
});
+test("shared streamdown configs register code highlighting and Mermaid", () => {
+ expect(streamdownRenderingPlugins).toEqual({ code, mermaid });
+ expect(streamdownPlugins.plugins).toBe(streamdownRenderingPlugins);
+ expect(reasoningPlugins.plugins).toBe(streamdownRenderingPlugins);
+});
+
+test("the shared code plugin produces highlighted Shiki tokens", async () => {
+ const source = "const answer: number = 42;";
+ const highlighted = await new Promise((resolve) => {
+ const cached = code.highlight(
+ {
+ code: source,
+ language: "typescript",
+ themes: code.getThemes(),
+ },
+ resolve,
+ );
+ if (cached) {
+ resolve(cached);
+ }
+ });
+
+ expect(
+ highlighted.tokens
+ .flat()
+ .some((token) =>
+ /^#[0-9A-Fa-f]{6}$/.test(String(token.htmlStyle?.color ?? "")),
+ ),
+ ).toBe(true);
+});
+
test("reasoningPlugins does not include rehypeRaw", () => {
const flat = reasoningPlugins.rehypePlugins?.flat();
expect(flat).not.toContain(rehypeRaw);