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;
+}