From 288d6690e26d3f9a06104be6a21bc922a3ccdd96 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 10 Sep 2026 14:42:05 -0400 Subject: [PATCH] perf(web): keep chat markdown mounted while text streams Streamed assistant text recreated the ReactMarkdown renderer map on every token, which remounted code controls and details and re-ran highlighting for unchanged fences. Named renderer types now live in a module-level map and read message state from context, so live bot and group ChatMarkdown stays mounted while the text grows. --- apps/web/src/components/ChatMarkdown.test.tsx | 27 + apps/web/src/components/ChatMarkdown.tsx | 716 ++++++++++-------- 2 files changed, 410 insertions(+), 333 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index e0441a25200d..f28712184101 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off - Source guard reads this module's renderer map. +import * as NodeFS from "node:fs"; + import { EnvironmentId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -35,6 +38,30 @@ import ChatMarkdown, { shouldUseMarkdownFileBrowserPrimaryAction, } from "./ChatMarkdown"; +describe("ChatMarkdown streaming renderers", () => { + it("keeps one renderer map instead of recreating types when text streams", () => { + const source = NodeFS.readFileSync(new URL("./ChatMarkdown.tsx", import.meta.url), "utf8"); + + expect(source).toContain("const ChatMarkdownRendererContext"); + expect(source).toContain("const CHAT_MARKDOWN_COMPONENTS: Components"); + expect(source).toContain("components={CHAT_MARKDOWN_COMPONENTS}"); + expect(source).toContain("pre: function MarkdownPre"); + expect(source).toContain("details: function MarkdownDetailsRenderer"); + expect(source).not.toMatch(/useMemo/); + }); + + it("still renders streamed fences and details after extra trailing text", () => { + const fence = ["```text", "First code block", "```", "", "Streaming reply"].join("\n"); + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-language="text"'); + expect(html).toContain("First code block"); + expect(html).toContain("Streaming reply 9"); + }); +}); + describe("canUseMarkdownFileShellActions", () => { const environmentId = EnvironmentId.make("environment-1"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8d13dc9b5149..383200204a69 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1737,7 +1737,7 @@ function areMarkdownFileLinkPropsEqual( ); } -function ChatMarkdown({ +function useChatMarkdownState({ text, cwd, threadRef, @@ -1745,9 +1745,6 @@ function ChatMarkdown({ onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, - className, - lineBreaks = false, - parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1979,15 +1976,8 @@ function ChatMarkdown({ }, [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); - /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component - * renderers that close over this message's metadata. useMemo keeps them stable until that - * metadata changes. */ - const markdownComponents = useMemo(() => { - const fileLinkChip = ( - fileLinkMeta: MarkdownFileLinkMeta, - copyMarkdown: string, - className?: string, - ) => { + const fileLinkChip = useCallback( + (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), ); @@ -2032,321 +2022,379 @@ function ChatMarkdown({ className={className} /> ); - }; + }, + [ + canUseShellActions, + fileLinkParentSuffixByPath, + openFileInPanel, + openInPreferredEditor, + openMarkdownFileInPreview, + preferredEditorMenuLabel, + resolvedTheme, + revealInFileManagerLabel, + revealMarkdownFileInFileManager, + threadRef, + ], + ); - return { - p({ node: _node, children, ...props }) { - return

{renderSkillInlineMarkdownChildren(children, skills)}

; - }, - blockquote({ node: _node, children, ...props }) { - const alert = - GITHUB_ALERT_PRESENTATIONS[ - String((props as Record)["data-alert"] ?? "") - ]; - if (!alert) { - return
{children}
; - } - // Not a
: the stylesheet mutes those, and an alert's body is ordinary - // text under a colored title — which is how the host renders it. - return ( -
-

- - {alert.label} -

- {children} -
- ); - }, - ol({ node, start, style, ...props }) { - const itemCount = - node?.children?.filter((child) => child.type === "element" && child.tagName === "li") - .length ?? 0; - const gutterStyle = orderedListGutterStyle(itemCount, start); - return ( -
    - ); - }, - li({ node, children, ...props }) { - const listItemStart = node?.position?.start.offset; - const markerOffset = - typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; - return ( -
  1. - {renderSkillInlineMarkdownChildren(children, skills)} -
  2. - ); - }, - input({ node: _node, type, checked, disabled: _disabled, ...props }) { - if (type !== "checkbox" || !onTaskListChange) { - return ( - - ); - } - return ( - { - const markerOffset = Number( - event.currentTarget.closest("li")?.dataset.taskMarkerOffset, - ); - if (!Number.isSafeInteger(markerOffset)) return; - onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); - }} - /> - ); - }, - a({ node, href, children, title: _title, ...props }) { - const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const settingsDestination = parseSettingsDeepLink(normalizedHref); - if (settingsDestination) { - return ( - - {children} - - ); - } - const fileLinkMeta = normalizedHref - ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? - resolveMarkdownFileLinkMeta(normalizedHref, cwd)) - : null; - if (!fileLinkMeta) { - const faviconHost = resolveExternalWebLinkHost(href); - const isSameDocumentLink = href?.startsWith("#") ?? false; - const onClick = props.onClick; - const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); - const link = ( - { - onClick?.(event); - if (isSameDocumentLink && href) { - handleMarkdownFragmentClick(event, href); - return; + const componentState = useMemo( + () => ({ + cwd, + diffThemeName, + environmentId, + fileLinkChip, + inlineCodeFileLinkMetaByText, + isStreaming, + markdownFileLinkMetaByHref, + onTaskListChange, + openChangeRequestLink, + openExternalLinkInPreview, + resolveThreadPullRequest, + resolvedTheme, + skills, + text, + threadRef, + updateThreadPullRequestLink, + }), + [ + cwd, + diffThemeName, + environmentId, + fileLinkChip, + inlineCodeFileLinkMetaByText, + isStreaming, + markdownFileLinkMetaByHref, + onTaskListChange, + openChangeRequestLink, + openExternalLinkInPreview, + resolveThreadPullRequest, + resolvedTheme, + skills, + text, + threadRef, + updateThreadPullRequestLink, + ], + ); + + return { + componentState, + handleCopy, + markdownUrlTransform, + }; +} + +const ChatMarkdownRendererContext = React.createContext< + ReturnType["componentState"] +>(null!); + +// Keep component types stable when streaming changes the message state. +const CHAT_MARKDOWN_COMPONENTS: Components = { + p: function MarkdownParagraph({ node: _node, children, ...props }) { + const { skills } = use(ChatMarkdownRendererContext); + return

    {renderSkillInlineMarkdownChildren(children, skills)}

    ; + }, + blockquote: function MarkdownBlockquote({ node: _node, children, ...props }) { + const alert = + GITHUB_ALERT_PRESENTATIONS[String((props as Record)["data-alert"] ?? "")]; + if (!alert) { + return
    {children}
    ; + } + // Not a
    : the stylesheet mutes those, and an alert's body is ordinary + // text under a colored title — which is how the host renders it. + return ( +
    +

    + + {alert.label} +

    + {children} +
    + ); + }, + ol: function MarkdownOrderedList({ node, start, style, ...props }) { + const itemCount = + node?.children?.filter((child) => child.type === "element" && child.tagName === "li") + .length ?? 0; + const gutterStyle = orderedListGutterStyle(itemCount, start); + return ( +
      + ); + }, + li: function MarkdownListItem({ node, children, ...props }) { + const { text, skills } = use(ChatMarkdownRendererContext); + const listItemStart = node?.position?.start.offset; + const markerOffset = + typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + return ( +
    1. + {renderSkillInlineMarkdownChildren(children, skills)} +
    2. + ); + }, + input: function MarkdownInput({ node: _node, type, checked, disabled: _disabled, ...props }) { + const { onTaskListChange } = use(ChatMarkdownRendererContext); + if (type !== "checkbox" || !onTaskListChange) { + return ( + + ); + } + return ( + { + const markerOffset = Number(event.currentTarget.closest("li")?.dataset.taskMarkerOffset); + if (!Number.isSafeInteger(markerOffset)) return; + onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); + }} + /> + ); + }, + a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { + const { + cwd, + environmentId, + markdownFileLinkMetaByHref, + threadRef, + openChangeRequestLink, + openExternalLinkInPreview, + resolveThreadPullRequest, + updateThreadPullRequestLink, + fileLinkChip, + } = use(ChatMarkdownRendererContext); + const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; + const settingsDestination = parseSettingsDeepLink(normalizedHref); + if (settingsDestination) { + return ( + + {children} + + ); + } + const fileLinkMeta = normalizedHref + ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? + resolveMarkdownFileLinkMeta(normalizedHref, cwd)) + : null; + if (!fileLinkMeta) { + const faviconHost = resolveExternalWebLinkHost(href); + const isSameDocumentLink = href?.startsWith("#") ?? false; + const onClick = props.onClick; + const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const link = ( +
      { + onClick?.(event); + if (isSameDocumentLink && href) { + handleMarkdownFragmentClick(event, href); + return; + } + // A link to a change request in a workspace project opens beside the + // conversation instead of in a browser: it is the thing being talked about, and + // the panel it opens offers the browser as one of its actions. Anything else is + // an ordinary link and keeps the `_blank` the shell already handles. + if (href) openChangeRequestLink(event, href); + }} + onContextMenu={(event) => { + if (!href || !faviconHost) return; + event.preventDefault(); + event.stopPropagation(); + const api = readLocalApi(); + if (!api) return; + const pullRequest = resolveThreadPullRequest(href); + const currentPullRequest = + threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; + const threadLinkAction = + currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) + ? "unlink-from-thread" + : pullRequest === null + ? undefined + : "link-to-thread"; + void showExternalLinkContextMenu({ + href, + canOpenInPreview, + threadLinkAction, + position: { x: event.clientX, y: event.clientY }, + showContextMenu: (items, position) => api.contextMenu.show(items, position), + openInPreview: async (target) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); } - // A link to a change request in a workspace project opens beside the - // conversation instead of in a browser: it is the thing being talked about, and - // the panel it opens offers the browser as one of its actions. Anything else is - // an ordinary link and keeps the `_blank` the shell already handles. - if (href) openChangeRequestLink(event, href); - }} - onContextMenu={(event) => { - if (!href || !faviconHost) return; - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (!api) return; - const pullRequest = resolveThreadPullRequest(href); - const currentPullRequest = - threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; - const threadLinkAction = - currentPullRequest != null && - matchesLinkedPullRequestUrl(currentPullRequest, href) - ? "unlink-from-thread" - : pullRequest === null - ? undefined - : "link-to-thread"; - void showExternalLinkContextMenu({ - href, - canOpenInPreview, - threadLinkAction, - position: { x: event.clientX, y: event.clientY }, - showContextMenu: (items, position) => api.contextMenu.show(items, position), - openInPreview: async (target) => { - const result = await openExternalLinkInPreview(target); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure( - { operation: "open-link-in-preview", target }, - result.cause, - ); - } - }, - openExternal: (target) => api.shell.openExternal(target), - copyLink: (target) => writeTextToClipboard(target, "link"), - updateThreadLink: updateThreadPullRequestLink, - reportFailure: (operation, cause) => { - reportMarkdownActionFailure({ operation, target: href }, cause); - if ( - operation === "link-pull-request-to-thread" || - operation === "unlink-pull-request-from-thread" - ) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: - operation === "link-pull-request-to-thread" - ? "Unable to link pull request" - : "Unable to unlink pull request", - description: - cause instanceof Error ? cause.message : "The request failed.", - }), - ); - } - }, - }); - }} - > - {faviconHost && hastHasText(node) ? ( - - {children} - - ) : ( - children - )} - - ); - if (!faviconHost || !href) { - return link; - } - return ( - - - - {href} - - - ); - } + }, + openExternal: (target) => api.shell.openExternal(target), + copyLink: (target) => writeTextToClipboard(target, "link"), + updateThreadLink: updateThreadPullRequestLink, + reportFailure: (operation, cause) => { + reportMarkdownActionFailure({ operation, target: href }, cause); + if ( + operation === "link-pull-request-to-thread" || + operation === "unlink-pull-request-from-thread" + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: + operation === "link-pull-request-to-thread" + ? "Unable to link pull request" + : "Unable to unlink pull request", + description: cause instanceof Error ? cause.message : "The request failed.", + }), + ); + } + }, + }); + }} + > + {faviconHost && hastHasText(node) ? ( + + {children} + + ) : ( + children + )} + + ); + if (!faviconHost || !href) { + return link; + } + return ( + + + + {href} + + + ); + } - return fileLinkChip( - fileLinkMeta, - `[${fileLinkMeta.basename}](${normalizedHref})`, - props.className, - ); - }, - code({ node, children, className, ...props }) { - const mathExpression = node?.properties?.dataMathExpression; - if (typeof mathExpression === "string") { - return ; - } - if (node?.properties?.dataInlineCode != null) { - const codeText = nodeToPlainText(children); - const fileLinkMeta = - inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? - resolveInlineCodeFileLinkMeta(codeText, cwd); - if (fileLinkMeta) { - return fileLinkChip(fileLinkMeta, `\`${codeText}\``); - } - } - return ( - - {children} - - ); - }, - img({ node: _node, title: _title, src, alt, ...props }) { - const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; - const altText = alt ?? ""; - const imageSource = classifyMarkdownImageSource(srcString, cwd); - if (imageSource._tag === "Direct") { - return ( - {altText} - ); - } - if (imageSource._tag === "WorkspaceFile" && threadRef) { - return ( - ; + } + if (node?.properties?.dataInlineCode != null) { + const codeText = nodeToPlainText(children); + const fileLinkMeta = + inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? + resolveInlineCodeFileLinkMeta(codeText, cwd); + if (fileLinkMeta) { + return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + } + } + return ( + + {children} + + ); + }, + img: function MarkdownImage({ node: _node, title: _title, src, alt, ...props }) { + const { cwd, threadRef } = use(ChatMarkdownRendererContext); + const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; + const altText = alt ?? ""; + const imageSource = classifyMarkdownImageSource(srcString, cwd); + if (imageSource._tag === "Direct") { + return ( + {altText} + ); + } + if (imageSource._tag === "WorkspaceFile" && threadRef) { + return ( + + ); + } + return ; + }, + table: function MarkdownTableRenderer({ node: _node, ...props }) { + return ; + }, + details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) { + return {children}; + }, + pre: function MarkdownPre({ node, children, ...props }) { + const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext); + const codeBlock = extractCodeBlock(children); + if (!codeBlock) { + return
      {children}
      ; + } + + const language = extractFenceLanguage(codeBlock.className); + if (language.toLowerCase() === "math") { + return ; + } + if (language.toLowerCase() === "mermaid") { + return ; + } + const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); + return ( + + {children}}> + {children}}> + - ); - } - return ; - }, - table({ node: _node, ...props }) { - return ; - }, - details({ node: _node, children, open: detailsOpen }) { - return {children}; - }, - pre({ node, children, ...props }) { - const codeBlock = extractCodeBlock(children); - if (!codeBlock) { - return
      {children}
      ; - } +
      +
      +
      + ); + }, +}; - const language = extractFenceLanguage(codeBlock.className); - if (language.toLowerCase() === "math") { - return ; - } - if (language.toLowerCase() === "mermaid") { - return ; - } - const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); - return ( - - {children}}> - {children}}> - - - - - ); - }, - }; - }, [ - canUseShellActions, - cwd, - diffThemeName, - fileLinkParentSuffixByPath, - inlineCodeFileLinkMetaByText, - isStreaming, - markdownFileLinkMetaByHref, - onTaskListChange, - openFileInPanel, - openInPreferredEditor, - openChangeRequestLink, - openExternalLinkInPreview, - openMarkdownFileInPreview, - preferredEditorMenuLabel, - resolveThreadPullRequest, - resolvedTheme, - revealMarkdownFileInFileManager, - revealInFileManagerLabel, - skills, +function ChatMarkdown({ + text, + className, + lineBreaks = false, + parseRawHtml = true, + ...props +}: ChatMarkdownProps) { + const { componentState, handleCopy, markdownUrlTransform } = useChatMarkdownState({ text, - threadRef, - updateThreadPullRequestLink, - ]); - /* eslint-enable react/no-unstable-nested-components */ + ...props, + }); // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the @@ -2359,17 +2407,19 @@ function ChatMarkdown({ )} onCopy={handleCopy} > - - {text} - + + + {text} + + ); }