diff --git a/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx b/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx
index 61894ad7..98b88d27 100644
--- a/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx
+++ b/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx
@@ -27,6 +27,7 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
import MessageItem from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
+import { splitPlainChunks } from "./streaming-plain-text";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
@@ -114,3 +115,89 @@ describe("MessageItem markdown memoization", () => {
expect(queryByText("streamed answer")).not.toBeNull();
});
});
+
+// PERF SMOKE (#492): the whole point of the incremental streaming render is that
+// the ANSWER path costs O(number of markdown blocks), NOT O(number of throttled
+// ~20Hz ticks). Pre-#492 the finalized MarkdownPart re-parsed the WHOLE growing
+// answer on every delta — a synthetic ~100 KB stream measured 394 renderChatMarkdown
+// calls (one per tick). With the incremental render each STABILIZED block is parsed
+// exactly once (memoized in MarkdownChunk) and the live tail is cheap plain text, so
+// the call count collapses to ~= the block count regardless of tick granularity.
+describe("MessageItem streaming answer render is O(blocks), not O(ticks)", () => {
+ // ~100 KB answer. Each section is a heading + a paragraph — TWO blank-line
+ // delimited markdown blocks — so the safe-cut block count is ~2× the section
+ // count. The perf claim is about the BLOCK count (the memoization granularity),
+ // measured directly with splitPlainChunks below, not the section count.
+ const buildAnswer = () => {
+ const SECTIONS = 100;
+ const paragraphs: string[] = [];
+ for (let i = 0; i < SECTIONS; i++) {
+ paragraphs.push(`## Section ${i}\n\n` + "lorem ipsum dolor ".repeat(55));
+ }
+ const full = paragraphs.join("\n\n");
+ // The number of memoized markdown blocks the incremental render splits into
+ // (all but the live tail are parsed once each).
+ return { full, blocks: splitPlainChunks(full).length };
+ };
+
+ const streamMsg = (text: string, state: "streaming" | "done"): UIMessage =>
+ ({
+ id: "m1",
+ role: "assistant",
+ parts: [{ type: "text", text, state }],
+ }) as UIMessage;
+
+ it("parses each block ~once over a 100KB stream (≈blocks, ≪ ticks)", () => {
+ renderChatMarkdownSpy.mockClear();
+ const { full, blocks } = buildAnswer();
+ const CHUNK = 128; // a realistic ~20Hz throttled delta size
+ const ticks = Math.ceil(full.length / CHUNK);
+
+ let msg = streamMsg(full.slice(0, CHUNK), "streaming");
+ const { rerender } = render(
+
+
+ ,
+ );
+ for (let end = 2 * CHUNK; end < full.length; end += CHUNK) {
+ msg = streamMsg(full.slice(0, end), "streaming");
+ rerender(
+
+
+ ,
+ );
+ }
+ // Finalize: the streaming→done flip renders the whole answer through ONE
+ // canonical pass (visual parity), so the finished DOM matches the pre-#492
+ // output. This is the single extra parse on top of the per-block ones.
+ const done = streamMsg(full, "done");
+ rerender(
+
+
+ ,
+ );
+
+ const calls = renderChatMarkdownSpy.mock.calls.length;
+ // Sanity: the stream really had far more ticks than blocks (else the test is
+ // vacuous — the point is that calls scale with blocks, not ticks).
+ expect(ticks).toBeGreaterThan(blocks * 3);
+ // O(blocks): each stabilized block parsed once + the single final whole-text
+ // parse. A small constant absorbs the finalize render and the live-tail block;
+ // the load-bearing claim is the bound below.
+ expect(calls).toBeLessThanOrEqual(blocks + 2);
+ // ≪ ticks — and, non-vacuously, the blocks WERE parsed (not skipped entirely).
+ expect(calls).toBeLessThan(ticks / 3);
+ expect(calls).toBeGreaterThan(blocks / 2);
+ // MUTATION-VERIFY (documented, not run here): dropping the `memo()` wrapper on
+ // MarkdownChunk (so every stable block re-parses each tick) drives `calls`
+ // toward `ticks` (~394), reddening both upper-bound assertions above.
+ });
+});
diff --git a/apps/client/src/features/ai-chat/components/message-item.render.test.tsx b/apps/client/src/features/ai-chat/components/message-item.render.test.tsx
new file mode 100644
index 00000000..9e4266b8
--- /dev/null
+++ b/apps/client/src/features/ai-chat/components/message-item.render.test.tsx
@@ -0,0 +1,112 @@
+import { describe, expect, it, vi } from "vitest";
+import { render } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+import type { UIMessage } from "@ai-sdk/react";
+
+// Stub react-i18next (the component reads `useTranslation`). Mirrors the other
+// message-item specs.
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import MessageItem from "./message-item";
+import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
+// The REAL canonical renderer (NOT the spy the memo test installs): this file
+// exercises the actual markdown output so the visual-regression assertions below
+// compare against genuine HTML (incl. the schema's `
` wrappers).
+import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
+import classes from "./ai-chat.module.css";
+
+const msg = (
+ parts: UIMessage["parts"],
+ extra?: Partial,
+): UIMessage =>
+ ({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage;
+
+const renderRow = (message: UIMessage, turnStreaming = false) =>
+ render(
+
+
+ ,
+ );
+
+// A rich multi-block answer that exercises headings, a list (the `` case
+// the scoped CSS tightens), inline emphasis, and multiple paragraphs.
+const ANSWER = [
+ "# Заголовок",
+ "",
+ "Первый абзац с **жирным** и `кодом`.",
+ "",
+ "- пункт один",
+ "- пункт два",
+ "",
+ "Второй абзац.",
+].join("\n");
+
+describe("MessageItem final render — visual parity with the canonical pipeline", () => {
+ it("a finalized text part renders exactly renderChatMarkdown(text)", () => {
+ const { container } = renderRow(
+ msg([{ type: "text", text: ANSWER, state: "done" }]),
+ );
+ const block = container.querySelector(`.${classes.markdown}`);
+ expect(block).not.toBeNull();
+ // Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492
+ // MarkdownPart produced), including `
…
` wrappers.
+ expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
+ // The list wrapper is really present (guards against a vacuous empty render).
+ expect(container.querySelectorAll("li p").length).toBe(2);
+ });
+
+ it("the streaming incremental view CONVERGES to the canonical render on finish", () => {
+ // Mount mid-stream (live tail) — the DOM here is the incremental view.
+ const { container, rerender } = render(
+
+
+ ,
+ );
+ // Finish the turn: state flips to done AND the turn is no longer streaming.
+ const done = msg([{ type: "text", text: ANSWER, state: "done" }]);
+ rerender(
+
+
+ ,
+ );
+ // After finish there is exactly ONE canonical markdown container whose HTML is
+ // the whole-text render — identical to the non-streaming path above.
+ const blocks = container.querySelectorAll(`.${classes.markdown}`);
+ expect(blocks.length).toBe(1);
+ expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
+ });
+
+ it("neutralizeInternalLinks is honored on the finalized render", () => {
+ const linkAnswer = "См. [страницу](/p/abc).";
+ const { container } = render(
+
+
+ ,
+ );
+ const block = container.querySelector(`.${classes.markdown}`);
+ expect(block!.innerHTML).toBe(
+ renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }),
+ );
+ // The internal link was made inert (no href) by the neutralization flag.
+ const a = container.querySelector("a");
+ expect(a?.hasAttribute("href")).toBe(false);
+ });
+});
diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx
index 878477b4..0cf7b7a4 100644
--- a/apps/client/src/features/ai-chat/components/message-item.tsx
+++ b/apps/client/src/features/ai-chat/components/message-item.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
+import { StreamingMarkdownText } from "@/features/ai-chat/components/streaming-markdown-text.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
@@ -86,17 +87,39 @@ interface MessageItemProps {
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
* so a finalized text part is NOT re-parsed on every streamed delta: during a
* turn only the actively-growing tail part changes its `text`, so every earlier
- * part hits the memo and skips the expensive marked + DOMPurify pass. Props are
- * primitives, so React.memo's default shallow compare is exactly right (the
- * `text` string is compared by value).
+ * part hits the memo and skips the expensive canonical parse + DOMPurify pass.
+ * Props are primitives, so React.memo's default shallow compare is exactly right
+ * (the `text` string is compared by value).
+ *
+ * Streaming gate (#492) — mirrors ReasoningBlock:
+ * - `streaming` (this is the live, actively-growing tail part of an in-flight
+ * turn): render incrementally via StreamingMarkdownText — the stabilized blocks
+ * go through the canonical pipeline (each parsed ONCE, memoized) and only the
+ * live tail is cheap plain text. This makes the per-tick cost O(new blocks),
+ * not the pre-#492 O(ticks) whole-answer re-parse on every ~20Hz delta.
+ * - finalized (the common case, and the turn-end flip): render the WHOLE text
+ * through ONE canonical pass — byte-identical to the pre-#492 output (visual
+ * parity). The row re-renders on the streaming→done flip because
+ * `messageSignature` tracks each part's `state` (and `turnStreaming` flips at
+ * turn end), so the incremental view always converges to this single render.
*/
const MarkdownPart = memo(function MarkdownPart({
text,
neutralizeInternalLinks,
+ streaming,
}: {
text: string;
neutralizeInternalLinks: boolean;
+ streaming: boolean;
}) {
+ if (streaming) {
+ return (
+
+ );
+ }
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
if (html) {
return (
@@ -179,47 +202,10 @@ function MessageItem({
{resolveAssistantName(assistantName) ?? t("AI agent")}
{message.parts.map((part, index) => {
- if (part.type === "reasoning") {
- // Reasoning ("thinking") -> a collapsible block with its own token
- // count. Empty/whitespace reasoning with no authoritative count carries
- // nothing to show, so skip it (avoids an empty 0-token block).
- const text = (part as { text?: string }).text ?? "";
- if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
- return null;
- // Absent state (persisted rows) and "done" both mean finalized.
- // `messageSignature` already includes each part's `state`, so the
- // streaming→done flip changes the row signature and re-renders this
- // row — which is what lets ReasoningBlock switch from chunked plain
- // text to its one-time markdown parse (see reasoning-block.tsx).
- // ALSO require the turn to be live: a part stranded at
- // `state:"streaming"` after the turn ended (no `reasoning-end` — see
- // the `turnStreaming` prop doc) must still finalize and parse.
- const streaming =
- turnStreaming && (part as { state?: string }).state === "streaming";
- return (
-
- );
- }
-
- if (part.type === "text") {
- // Skip empty/whitespace-only text parts (a streaming message often
- // starts with an empty text part before the first token arrives); the
- // typing indicator covers that gap until real content streams in.
- if (!part.text.trim()) return null;
- return (
-
- );
- }
-
+ // Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so
+ // they cannot be a `switch` case; the runtime guard handles them, and the
+ // switch below covers every CLOSED (literal-typed) part kind with a
+ // compile-time exhaustiveness check in its default.
if (isToolPart(part.type)) {
return (
a collapsible block with its own token
+ // count. Empty/whitespace reasoning with no authoritative count
+ // carries nothing to show, so skip it (avoids an empty 0-token block).
+ const text = part.text ?? "";
+ if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
+ return null;
+ // Absent state (persisted rows) and "done" both mean finalized.
+ // `messageSignature` already includes each part's `state`, so the
+ // streaming→done flip changes the row signature and re-renders this
+ // row — which is what lets ReasoningBlock switch from chunked plain
+ // text to its one-time markdown parse (see reasoning-block.tsx).
+ // ALSO require the turn to be live: a part stranded at
+ // `state:"streaming"` after the turn ended (no `reasoning-end` — see
+ // the `turnStreaming` prop doc) must still finalize and parse.
+ const streaming = turnStreaming && part.state === "streaming";
+ return (
+
+ );
+ }
+
+ case "text": {
+ // Skip empty/whitespace-only text parts (a streaming message often
+ // starts with an empty text part before the first token arrives); the
+ // typing indicator covers that gap until real content streams in.
+ if (!part.text.trim()) return null;
+ // The live, actively-growing tail part of the in-flight turn renders
+ // incrementally (see MarkdownPart); a finalized part (persisted, or
+ // the turn-end flip) renders the whole text through one canonical
+ // pass. Same liveness rule as the reasoning branch above.
+ const streaming = turnStreaming && part.state === "streaming";
+ return (
+
+ );
+ }
+
+ case "source-url":
+ case "source-document":
+ case "file":
+ case "step-start":
+ // Not surfaced in the chat bubble (v1) — same as the pre-#492 default.
+ return null;
+
+ default: {
+ // Compile-time exhaustiveness over the CLOSED union members: every
+ // literal-typed part kind is handled above, so the only kinds that
+ // can reach here are the OPEN template-literal ones (`tool-*` — caught
+ // by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding
+ // a NEW closed part kind to UIMessagePart makes this assignment fail
+ // to compile, forcing it to be handled instead of silently ignored
+ // (this replaces the pre-#492 fall-through `return null` + WARNING).
+ const _exhaustive:
+ | `tool-${string}`
+ | "dynamic-tool"
+ | `data-${string}` = part.type;
+ void _exhaustive;
+ return null;
+ }
+ }
})}
{/* A persisted turn error (server stored it in metadata.error). Rendered
here so it survives a thread remount and shows in reopened history. */}
diff --git a/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx b/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx
new file mode 100644
index 00000000..7a6fdca6
--- /dev/null
+++ b/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx
@@ -0,0 +1,96 @@
+import { memo, useMemo } from "react";
+import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx";
+import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
+import classes from "@/features/ai-chat/components/ai-chat.module.css";
+
+/**
+ * One STABILIZED markdown block, rendered through the canonical pipeline and
+ * memoized on its string prop. During streaming only the TAIL chunk grows (the
+ * `splitPlainChunks` append-only invariant guarantees every earlier chunk is
+ * byte-identical across deltas), so React skips every stable block and each one
+ * is parsed by `renderChatMarkdown` EXACTLY ONCE — turning the pre-#492
+ * "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into
+ * O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown
+ * before it reaches `dangerouslySetInnerHTML`.
+ *
+ * NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary,
+ * so a construct that legitimately contains a blank line (e.g. a fenced code block
+ * with an empty line) can be split across chunks and render oddly WHILE it is still
+ * streaming. This is cosmetic and self-heals: the moment the part finalizes,
+ * MarkdownPart renders the WHOLE text through one canonical pass (visual parity
+ * with the pre-#492 output). The reasoning path makes the same trade (plain text
+ * while streaming, one markdown parse at the end).
+ */
+const MarkdownChunk = memo(function MarkdownChunk({
+ text,
+ neutralizeInternalLinks,
+}: {
+ text: string;
+ neutralizeInternalLinks: boolean;
+}) {
+ const html = renderChatMarkdown(text, { neutralizeInternalLinks });
+ if (html) {
+ return (
+
+ );
+ }
+ // Malformed/unsupported markdown could not render synchronously: raw text.
+ return (
+
+ {text}
+
+ );
+});
+
+/**
+ * The cheap streaming-time stand-in for the finalized answer's one-time markdown
+ * parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's
+ * chunked-memo pattern but renders the STABILIZED prefix as real markdown (each
+ * block parsed once, memoized) and only the LIVE tail as flat plain text — so the
+ * user sees formatted output for everything up to the last safe cut, and the not-
+ * yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a
+ * single cheap escaped text node until it stabilizes into a new block.
+ *
+ * `splitPlainChunks` yields chunks where, under append-only growth, every chunk
+ * except the LAST is immutable; the last chunk is the live tail. Index keys are
+ * therefore stable (a given index never changes to a different chunk's content).
+ */
+export function StreamingMarkdownText({
+ text,
+ neutralizeInternalLinks,
+}: {
+ text: string;
+ neutralizeInternalLinks: boolean;
+}) {
+ const chunks = useMemo(() => splitPlainChunks(text), [text]);
+ return (
+ <>
+ {chunks.map((chunk, index) =>
+ index < chunks.length - 1 ? (
+
+ ) : (
+ // The live tail: flat, React-escaped plain text (no markdown parse, no
+ // sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing
+ // separator newlines are dropped at display time so the block gap comes
+ // from the markdown margins, not a doubled empty line (mirrors
+ // PlainChunk in streaming-plain-text.tsx).
+
+ {chunk.replace(/\n+$/, "")}
+
+ ),
+ )}
+ >
+ );
+}