diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index d5ea6a943c0c0..e4dfceefd91da 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -165,6 +165,21 @@ it("reuses the parse of a streaming reply's finished blocks when it remounts", a expect(parsedSources.filter((source) => source.includes("Finished block"))).toHaveLength(1); }); +it("reuses the parse of a remounted message that has stable extra plugins", async () => { + setup(); + const parsed = vi.fn(); + const countingPlugins = [() => () => void parsed()]; + const message = ( + + ); + { + await using _first = await render(message); + } + await using screen = await render(message); + await expect.element(screen.getByText("Cached text")).toBeVisible(); + expect(parsed).toHaveBeenCalledTimes(1); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 93e182fb408b5..9413b13a088cf 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -241,6 +241,7 @@ interface ChatMarkdownProps { imageBaseDir?: string | undefined; workspaceMutationId?: string | null | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + /** Keep one list per configuration (a module constant): it keys the shared parse cache. */ extraRemarkPlugins?: NonNullable; renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; /** Loads media a source control host keeps behind a credential through the server, which holds @@ -1647,6 +1648,18 @@ const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { mathBreaks: [REMARK_MATH, ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS], } satisfies Record; +const combinedPlugins = new WeakMap>(); + +// One combined list per pair, so a stable extra list shares a renderer and its parse cache. +function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList { + if (extra.length === 0) return shared; + let byShared = combinedPlugins.get(extra); + if (!byShared) combinedPlugins.set(extra, (byShared = new WeakMap())); + let combined = byShared.get(shared); + if (!combined) byShared.set(shared, (combined = [...shared, ...extra])); + return combined; +} + const markdownRenderers = new WeakMap< PluggableList, WeakMap> @@ -1735,15 +1748,11 @@ function ChatMarkdown({ ? "breaks" : "plain" ]; - // Extra and incremental plugins are per instance, so their output is not shared. - const uncachedPlugins = extraRemarkPlugins.length > 0 || incrementalParsing; - const remarkPlugins = uncachedPlugins - ? [ - ...sharedRemarkPlugins, - ...extraRemarkPlugins, - ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), - ] - : sharedRemarkPlugins; + // The incremental parser keeps per-instance state, so its output is not shared. + const uncachedPlugins = incrementalParsing; + const remarkPlugins = incrementalParsing + ? [...sharedRemarkPlugins, ...extraRemarkPlugins, createIncrementalMarkdownPlugin()] + : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); const blocks = streamingBlocks ?? [text]; return ( diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts index 12b9a6f197346..4a067b7de1ce5 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts @@ -1,3 +1,4 @@ +import type { PluggableList } from "unified"; import type { Root, RootContent } from "mdast"; import { resolveWorkEntryToolPresentation, @@ -237,3 +238,6 @@ export function remarkThoughtPreview(fallback: string) { : [{ type: "text", value: fallback }]; }; } + +export const THOUGHT_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thought"]]; +export const THINKING_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thinking"]]; diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index f485f62c9c398..76f2f604ba85f 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -64,7 +64,8 @@ import { workEntryRawCommand, buildToolCallExpandedBody, workEntryIconName, - remarkThoughtPreview, + THINKING_PREVIEW_PLUGINS, + THOUGHT_PREVIEW_PLUGINS, } from "./TimelineWorkEntryRow.logic"; export function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) { @@ -421,12 +422,11 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { text={workEntry.detail ?? previewText} cwd={ctx.markdownCwd} parseRawHtml={false} - extraRemarkPlugins={[ - [ - remarkThoughtPreview, - workEntry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought", - ], - ]} + extraRemarkPlugins={ + workEntry.toolLifecycleStatus === "inProgress" + ? THINKING_PREVIEW_PLUGINS + : THOUGHT_PREVIEW_PLUGINS + } /> ) : threadLabel ? ( } @@ -558,7 +559,7 @@ export function LiveWorkEntryTimelineRow({ text={row.entry.detail ?? label} cwd={ctx.markdownCwd} parseRawHtml={false} - extraRemarkPlugins={[[remarkThoughtPreview, row.active ? "Thinking" : "Thought"]]} + extraRemarkPlugins={row.active ? THINKING_PREVIEW_PLUGINS : THOUGHT_PREVIEW_PLUGINS} /> ) : threadLabel ? ( ReadonlyArray) | null; + /** Stable per repository, so comments share one renderer and its parse cache. */ + remarkPlugins: PluggableList; lineBreaks: boolean; threadRef: ScopedThreadRef | null; } | null>(null); @@ -72,11 +70,7 @@ export function PullRequestMarkdown({ }) { const segments = splitPullRequestBody(text); const context = useContext(PullRequestMarkdownContext); - const autolinks = context?.autolinks; const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; - const extraRemarkPlugins: NonNullable = autolinks - ? [[remarkPullRequestAutolinks, { autolinks }]] - : []; return (
diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index bb38f3d6caf96..2cace61666685 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,4 +1,9 @@ -import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import { + sourceControlClients, + type ReferenceAutolink, +} from "@t3tools/client-runtime/source-control-clients"; +import type { PluggableList } from "unified"; export { splitPullRequestBody, @@ -52,3 +57,27 @@ export function remarkPullRequestAutolinks(options: { }; return (tree: MarkdownNode) => visit(tree); } + +const NO_AUTOLINK_PLUGINS: PluggableList = []; +const autolinkPlugins = new Map(); + +/** + * One plugin list per repository, so every comment of a pull request shares one markdown + * renderer and its parse cache instead of parsing again on each render. + */ +export function pullRequestAutolinkPlugins( + changeRequest: { readonly provider: string; readonly url: string } | null, +): PluggableList { + if (changeRequest === null) return NO_AUTOLINK_PLUGINS; + const repositoryUrl = sourceControlClients.parseChangeRequestUrl(changeRequest.url)?.link + .repositoryUrl; + if (repositoryUrl === undefined) return NO_AUTOLINK_PLUGINS; + const key = `${changeRequest.provider}\u0000${repositoryUrl}`; + let plugins = autolinkPlugins.get(key); + if (plugins === undefined) { + const autolinks = changeRequestReferenceAutolinker(changeRequest); + plugins = autolinks ? [[remarkPullRequestAutolinks, { autolinks }]] : NO_AUTOLINK_PLUGINS; + autolinkPlugins.set(key, plugins); + } + return plugins; +}