perf(chat): coalesce stream events per frame and pace text release

Every NDJSON event used to commit its own React update and re-parse the
whole partial answer through react-markdown, so long replies grew
quadratically slower. Stream events now land in a frame buffer that
flushes at most once per animation frame, releases answer and thinking
text at a steady pace with a twelve-frame catch-up, and settles
synchronously on completion, failure and abort. Streaming markdown is
split at the last completed block so only the tail is re-parsed each
frame. Applied to both the consultation hook and the rectification chat.

BUG-473

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-02 04:29:01 +00:00
co-authored by Claude Fable 5.1
parent 02f06255c1
commit ad9dba5c79
10 changed files with 778 additions and 93 deletions
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { memo, useEffect, useState, type ReactNode } from "react";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { plainParagraphs } from "@/components/chat-message-paragraphs";
import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure";
import { splitStableMarkdown } from "@/lib/chat-markdown-split";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
resolveTechniqueAuditRows,
@@ -41,14 +42,56 @@ function useMarkdownRenderer() {
return renderer;
}
function renderProse(text: string, renderMarkdown: MarkdownRenderer | undefined): ReactNode {
if (!text) return null;
return renderMarkdown
? renderMarkdown(text)
: (plainParagraphs(text) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
));
}
/**
* The completed part of a streaming answer. `memo` keeps React from calling the
* markdown parser again while `text` is unchanged, so a frame that only grew
* the tail costs one small parse instead of one over the whole answer.
*/
const StableMarkdownPrefix = memo(function StableMarkdownPrefix({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
return <>{renderProse(text, renderMarkdown)}</>;
});
export function StreamingMarkdown({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
const split = splitStableMarkdown(text);
return (
<>
{split.stable ? <StableMarkdownPrefix text={split.stable} renderMarkdown={renderMarkdown} /> : null}
{renderProse(split.tail, renderMarkdown)}
</>
);
}
export function ChatMessageContent({
text,
auditRows,
vargaSentence,
streaming = false,
}: {
text: string;
auditRows?: readonly TechniqueAuditRow[];
vargaSentence?: string | null;
streaming?: boolean;
}) {
const renderMarkdown = useMarkdownRenderer();
const split = splitSpokenAnswerAndTechniqueAudit(text);
@@ -60,11 +103,13 @@ export function ChatMessageContent({
<div className="message-answer">
{spoken ? (
<div className="message-markdown">
{renderMarkdown
? renderMarkdown(spoken)
: (plainParagraphs(spoken) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
{streaming
? <StreamingMarkdown text={spoken} renderMarkdown={renderMarkdown} />
: renderMarkdown
? renderMarkdown(spoken)
: (plainParagraphs(spoken) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
) : null}
{vargaSentence ? <p className="message-varga-sentence">{vargaSentence}</p> : null}