authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 07:01:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 07:01:57-07:00
log887ece307131bdc853cc10f3b82067dee77c4ecf
treed1720d21a7e38a1b20f3a5ddc5b29104d4393cf8
parent3b6be3ef4daa848e10c095d8a088064ac836be7f
signature Signed by PGP key B5690EEEBB952194

perf(web): keep Markdown mounted during streaming (#9677)


5 files changed, 691 insertions(+), 436 deletions(-)

apps/web/package.json+2
...@@ -61,10 +61,12 @@...@@ -61,10 +61,12 @@
61 "@types/culori": "^4.0.1",61 "@types/culori": "^4.0.1",
62 "@types/react": "~19.2.14",62 "@types/react": "~19.2.14",
63 "@types/react-dom": "~19.2.3",63 "@types/react-dom": "~19.2.3",
64 "@types/react-test-renderer": "19.1.0",
64 "@vercel/config": "^0.3.0",65 "@vercel/config": "^0.3.0",
65 "@vitejs/plugin-react": "^6.0.0",66 "@vitejs/plugin-react": "^6.0.0",
66 "babel-plugin-react-compiler": "1.0.0",67 "babel-plugin-react-compiler": "1.0.0",
67 "compression": "^1.8.1",68 "compression": "^1.8.1",
69 "react-test-renderer": "19.2.6",
68 "tailwindcss": "^4.0.0",70 "tailwindcss": "^4.0.0",
69 "vite": "catalog:",71 "vite": "catalog:",
70 "vite-plus": "catalog:"72 "vite-plus": "catalog:"
apps/web/src/components/ChatMarkdown.test.tsx+170
...@@ -1,9 +1,38 @@...@@ -1,9 +1,38 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import { act, type ComponentProps, type ReactNode } from "react";
2import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
4import { create, type ReactTestRenderer } from "react-test-renderer";
3import { describe, expect, it, vi } from "vite-plus/test";5import { describe, expect, it, vi } from "vite-plus/test";
46
7import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
8import { Button } from "./ui/button";
9import { setMarkdownTaskChecked } from "./files/filePreviewMode";
10
5vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));11vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
6vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));12vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
13vi.mock("../hooks/useSettings", async (importOriginal) => {
14 const actual = await importOriginal<typeof import("../hooks/useSettings")>();
15 const settings = actual.getClientSettings();
16 return {
17 ...actual,
18 useClientSettings: (select?: (value: typeof settings) => unknown) =>
19 select ? select(settings) : settings,
20 };
21});
22vi.mock("./ui/tooltip", async () => {
23 const { cloneElement, isValidElement } = await import("react");
24 return {
25 Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
26 TooltipTrigger({
27 render,
28 children,
29 }: ComponentProps<typeof import("./ui/tooltip").TooltipTrigger>) {
30 if (!isValidElement(render)) return <>{children}</>;
31 return children === undefined ? render : cloneElement(render, undefined, children);
32 },
33 TooltipPopup: () => null,
34 };
35});
7vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));36vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
8vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));37vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
9vi.mock("../state/session", async (importOriginal) => ({38vi.mock("../state/session", async (importOriginal) => ({
...@@ -35,6 +64,147 @@ import ChatMarkdown, {...@@ -35,6 +64,147 @@ import ChatMarkdown, {
35 shouldUseMarkdownFileBrowserPrimaryAction,64 shouldUseMarkdownFileBrowserPrimaryAction,
36} from "./ChatMarkdown";65} from "./ChatMarkdown";
3766
67function codeButton(renderer: ReactTestRenderer, label: string) {
68 const button = renderer.root
69 .findAllByType(Button)
70 .find((instance) => instance.props["aria-label"] === label);
71 if (!button) throw new Error(`Missing code button: ${label}`);
72 return button.props as ComponentProps<typeof Button>;
73}
74
75describe("ChatMarkdown streaming", () => {
76 it("preserves code controls and details without highlighting an unchanged fence again", async () => {
77 const highlighter = await getSyntaxHighlighterPromise("text");
78 const highlight = vi.spyOn(highlighter, "codeToHtml");
79 const writeText = vi.fn(async (_text: string) => {});
80 vi.stubGlobal("navigator", { clipboard: { writeText } });
81 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
82 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
83 let renderer: ReactTestRenderer | undefined;
84 const text = [
85 "```text",
86 "First code block",
87 "```",
88 "",
89 "<details><summary>More</summary>",
90 "",
91 "Details content",
92 "",
93 "</details>",
94 "",
95 "Streaming reply",
96 ].join("\n");
97
98 try {
99 await act(async () => {
100 renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} isStreaming />);
101 });
102 const mounted = renderer!;
103 const codeBlock = mounted.root.findByProps({ "data-language": "text" });
104 const initialWrap = codeBlock.props["data-wrap"] === "true";
105 const wrap = codeButton(mounted, initialWrap ? "Disable line wrap" : "Wrap lines");
106 const copy = codeButton(mounted, "Copy code");
107 await act(async () => {
108 wrap.onClick?.({} as Parameters<NonNullable<typeof wrap.onClick>>[0]);
109 copy.onClick?.({} as Parameters<NonNullable<typeof copy.onClick>>[0]);
110 });
111
112 const detailsButton = mounted.root.find(
113 (instance) =>
114 instance.type === "button" && instance.props["data-markdown-details-summary"] === "",
115 );
116 await act(async () => {
117 detailsButton.props.onClick({ nativeEvent: new Event("click") });
118 });
119 const details = mounted.root.findByProps({ "data-markdown-details": "" });
120 expect(details.props["data-markdown-details-open"]).toBe("true");
121 expect(writeText).toHaveBeenCalledWith("First code block\n");
122 expect(highlight).toHaveBeenCalledTimes(1);
123
124 for (let index = 0; index < 10; index += 1) {
125 await act(async () => {
126 mounted.update(<ChatMarkdown cwd="/tmp/project" text={`${text} ${index}`} isStreaming />);
127 });
128 }
129
130 expect(highlight).toHaveBeenCalledTimes(1);
131 expect(mounted.root.findByProps({ "data-language": "text" })).toBe(codeBlock);
132 expect(codeBlock.props["data-wrap"]).toBe(String(!initialWrap));
133 expect(mounted.root.findByProps({ "data-markdown-details": "" })).toBe(details);
134 expect(details.props["data-markdown-details-open"]).toBe("true");
135 await act(async () => {
136 mounted.update(
137 <ChatMarkdown
138 cwd="/tmp/project"
139 text={text.replace("First code block", "Updated code block")}
140 isStreaming
141 />,
142 );
143 });
144 const copyUpdated = codeButton(mounted, "Copied");
145 await act(async () => {
146 copyUpdated.onClick?.({} as Parameters<NonNullable<typeof copyUpdated.onClick>>[0]);
147 });
148 expect(writeText).toHaveBeenLastCalledWith("Updated code block\n");
149 expect(highlight).toHaveBeenCalledTimes(2);
150 } finally {
151 await act(async () => renderer?.unmount());
152 vi.useRealTimers();
153 vi.unstubAllGlobals();
154 vi.restoreAllMocks();
155 }
156 });
157
158 it("edits the current task text and marker after reusing a renderer", async () => {
159 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
160 let renderer: ReactTestRenderer | undefined;
161 let editedText: string | undefined;
162 const message = (text: string) => (
163 <ChatMarkdown
164 cwd="/tmp/project"
165 text={text}
166 onTaskListChange={({ markerOffset, checked }) => {
167 editedText = setMarkdownTaskChecked(text, markerOffset, checked);
168 renderer!.update(message(editedText));
169 }}
170 />
171 );
172
173 try {
174 await act(async () => {
175 renderer = create(message("- [ ] First\n- [ ] Second"));
176 });
177 const mounted = renderer!;
178 const originalInput = mounted.root.findAllByType("input")[1]!;
179 await act(async () => {
180 mounted.update(message("- [ ] A longer first task\n- [ ] Second"));
181 });
182
183 const input = mounted.root.findAllByType("input")[1]!;
184 const listItem = mounted.root.findAllByType("li")[1]!;
185 const { onChange } = input.props as ComponentProps<"input">;
186 if (!onChange) throw new Error("Task checkbox has no edit handler");
187 await act(async () => {
188 onChange({
189 currentTarget: {
190 checked: true,
191 closest: () => ({
192 dataset: { taskMarkerOffset: String(listItem.props["data-task-marker-offset"]) },
193 }),
194 },
195 } as unknown as Parameters<typeof onChange>[0]);
196 });
197
198 expect(input).toBe(originalInput);
199 expect(editedText).toBe("- [ ] A longer first task\n- [x] Second");
200 expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true);
201 } finally {
202 await act(async () => renderer?.unmount());
203 vi.unstubAllGlobals();
204 }
205 });
206});
207
38describe("canUseMarkdownFileShellActions", () => {208describe("canUseMarkdownFileShellActions", () => {
39 const environmentId = EnvironmentId.make("environment-1");209 const environmentId = EnvironmentId.make("environment-1");
40210
apps/web/src/components/ChatMarkdown.tsx+501-432
...@@ -1952,7 +1952,7 @@ function areMarkdownFileLinkPropsEqual(...@@ -1952,7 +1952,7 @@ function areMarkdownFileLinkPropsEqual(
1952 );1952 );
1953}1953}
19541954
1955function ChatMarkdown({1955function useChatMarkdownState({
1956 text,1956 text,
1957 cwd,1957 cwd,
1958 threadRef,1958 threadRef,
...@@ -1960,13 +1960,9 @@ function ChatMarkdown({...@@ -1960,13 +1960,9 @@ function ChatMarkdown({
1960 onTaskListChange,1960 onTaskListChange,
1961 isStreaming = false,1961 isStreaming = false,
1962 skills = EMPTY_MARKDOWN_SKILLS,1962 skills = EMPTY_MARKDOWN_SKILLS,
1963 className,
1964 lineBreaks = false,
1965 parseRawHtml = true,
1966 onUseArtifactTemplate,1963 onUseArtifactTemplate,
1967 imageBaseDir,1964 imageBaseDir,
1968 onImageExpand,1965 onImageExpand,
1969 extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
1970}: ChatMarkdownProps) {1966}: ChatMarkdownProps) {
1971 const { resolvedTheme } = useTheme();1967 const { resolvedTheme } = useTheme();
1972 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);1968 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
...@@ -2270,11 +2266,8 @@ function ChatMarkdown({...@@ -2270,11 +2266,8 @@ function ChatMarkdown({
2270 },2266 },
2271 [cwd, findWorkspaceBasenameMatch, revealFileInFileManager],2267 [cwd, findWorkspaceBasenameMatch, revealFileInFileManager],
2272 );2268 );
2273 /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component2269 const fileLinkChip = useCallback(
2274 * renderers that close over this message's metadata. useMemo keeps them stable until that2270 (
2275 * metadata changes. */
2276 const markdownComponents = useMemo<Components>(() => {
2277 const fileLinkChip = (
2278 fileLinkMeta: MarkdownFileLinkMeta,2271 fileLinkMeta: MarkdownFileLinkMeta,
2279 copyMarkdown: string,2272 copyMarkdown: string,
2280 className?: string,2273 className?: string,
...@@ -2339,433 +2332,507 @@ function ChatMarkdown({...@@ -2339,433 +2332,507 @@ function ChatMarkdown({
2339 className={className}2332 className={className}
2340 />2333 />
2341 );2334 );
2342 };2335 },
2336 [
2337 canUseShellActions,
2338 fileLinkParentSuffixByPath,
2339 openFileInPanel,
2340 openInPreferredEditor,
2341 openMarkdownFileInPreview,
2342 openMarkdownMedia,
2343 preferredEditorMenuLabel,
2344 resolvedTheme,
2345 revealInFileManagerLabel,
2346 revealMarkdownFileInFileManager,
2347 threadRef,
2348 ],
2349 );
23432350
2344 return {2351 const componentState = useMemo(
2345 div({ node, children, ...props }) {2352 () => ({
2346 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);2353 cwd,
2347 if (artifactTemplate) {2354 diffThemeName,
2348 return (2355 expandMedia,
2349 <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} />2356 fileLinkChip,
2350 );2357 imageBaseDir,
2351 }2358 inlineCodeFileLinkMetaByText,
2352 return <div {...props}>{children}</div>;2359 isStreaming,
2353 },2360 linkTargetPreference,
2354 p({ node: _node, children, ...props }) {2361 markdownFileLinkMetaByHref,
2355 return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;2362 onTaskListChange,
2356 },2363 onUseArtifactTemplate,
2357 blockquote({ node: _node, children, ...props }) {2364 openChangeRequestLink,
2358 const alert =2365 openExternalLinkInPreview,
2359 GITHUB_ALERT_PRESENTATIONS[2366 openMarkdownMedia,
2360 String((props as Record<string, unknown>)["data-alert"] ?? "")2367 resolveThreadPullRequest,
2361 ];2368 resolvedTheme,
2362 if (!alert) {2369 skills,
2363 return <blockquote {...props}>{children}</blockquote>;2370 text,
2364 }2371 threadRef,
2365 // Not a <blockquote>: the stylesheet mutes those, and an alert's body is ordinary2372 updateThreadPullRequestLink,
2366 // text under a colored title — which is how the host renders it.2373 }),
2367 return (2374 [
2368 <div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>2375 cwd,
2369 <p className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}>2376 diffThemeName,
2370 <alert.Icon aria-hidden className="size-3.5 shrink-0" />2377 expandMedia,
2371 {alert.label}2378 fileLinkChip,
2372 </p>2379 imageBaseDir,
2373 {children}2380 inlineCodeFileLinkMetaByText,
2374 </div>2381 isStreaming,
2375 );2382 linkTargetPreference,
2376 },2383 markdownFileLinkMetaByHref,
2377 ol({ node, start, style, ...props }) {2384 onTaskListChange,
2378 const itemCount =2385 onUseArtifactTemplate,
2379 node?.children?.filter((child) => child.type === "element" && child.tagName === "li")2386 openChangeRequestLink,
2380 .length ?? 0;2387 openExternalLinkInPreview,
2381 const gutterStyle = orderedListGutterStyle(itemCount, start);2388 openMarkdownMedia,
2382 return (2389 resolveThreadPullRequest,
2383 <ol {...props} start={start} style={gutterStyle ? { ...style, ...gutterStyle } : style} />2390 resolvedTheme,
2384 );2391 skills,
2385 },2392 text,
2386 li({ node, children, ...props }) {2393 threadRef,
2387 const listItemStart = node?.position?.start.offset;2394 updateThreadPullRequestLink,
2388 const markerOffset =2395 ],
2389 typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null;2396 );
2390 return (2397 return {
2391 <li {...props} data-task-marker-offset={markerOffset ?? undefined}>2398 componentState,
2392 {renderSkillInlineMarkdownChildren(children, skills)}2399 handleCopy,
2393 </li>2400 markdownUrlTransform,
2394 );2401 localMediaPreview,
2395 },2402 setLocalMediaPreview,
2396 input({ node: _node, type, checked, disabled: _disabled, ...props }) {2403 };
2397 if (type !== "checkbox" || !onTaskListChange) {2404}
2398 return (2405
2399 <input2406const ChatMarkdownRendererContext = React.createContext<
2400 {...props}2407 ReturnType<typeof useChatMarkdownState>["componentState"]
2401 type={type}2408>(null!);
2402 checked={checked}2409
2403 disabled={_disabled}2410// Keep component types stable when streaming changes the message state.
2404 readOnly={type === "checkbox"}2411const CHAT_MARKDOWN_COMPONENTS = {
2405 />2412 div: function MarkdownDiv({ node, children, ...props }) {
2406 );2413 const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext);
2407 }2414 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
2408 return (2415 if (artifactTemplate) {
2409 <input2416 return (
2410 {...props}2417 <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} />
2411 type="checkbox"2418 );
2412 name="markdown-task"2419 }
2413 aria-label="Toggle task"2420 return <div {...props}>{children}</div>;
2414 checked={checked}2421 },
2415 onChange={(event) => {2422 p: function MarkdownParagraph({ node: _node, children, ...props }) {
2416 const markerOffset = Number(2423 const { skills } = use(ChatMarkdownRendererContext);
2417 event.currentTarget.closest("li")?.dataset.taskMarkerOffset,2424 return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
2425 },
2426 blockquote: function MarkdownBlockquote({ node: _node, children, ...props }) {
2427 const alert =
2428 GITHUB_ALERT_PRESENTATIONS[String((props as Record<string, unknown>)["data-alert"] ?? "")];
2429 if (!alert) {
2430 return <blockquote {...props}>{children}</blockquote>;
2431 }
2432 // Not a <blockquote>: the stylesheet mutes those, and an alert's body is ordinary
2433 // text under a colored title — which is how the host renders it.
2434 return (
2435 <div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>
2436 <p className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}>
2437 <alert.Icon aria-hidden className="size-3.5 shrink-0" />
2438 {alert.label}
2439 </p>
2440 {children}
2441 </div>
2442 );
2443 },
2444 ol: function MarkdownOrderedList({ node, start, style, ...props }) {
2445 const itemCount =
2446 node?.children?.filter((child) => child.type === "element" && child.tagName === "li")
2447 .length ?? 0;
2448 const gutterStyle = orderedListGutterStyle(itemCount, start);
2449 return (
2450 <ol {...props} start={start} style={gutterStyle ? { ...style, ...gutterStyle } : style} />
2451 );
2452 },
2453 li: function MarkdownListItem({ node, children, ...props }) {
2454 const { text, skills } = use(ChatMarkdownRendererContext);
2455 const listItemStart = node?.position?.start.offset;
2456 const markerOffset =
2457 typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null;
2458 return (
2459 <li {...props} data-task-marker-offset={markerOffset ?? undefined}>
2460 {renderSkillInlineMarkdownChildren(children, skills)}
2461 </li>
2462 );
2463 },
2464 input: function MarkdownInput({ node: _node, type, checked, disabled: _disabled, ...props }) {
2465 const { onTaskListChange } = use(ChatMarkdownRendererContext);
2466 if (type !== "checkbox" || !onTaskListChange) {
2467 return (
2468 <input
2469 {...props}
2470 type={type}
2471 checked={checked}
2472 disabled={_disabled}
2473 readOnly={type === "checkbox"}
2474 />
2475 );
2476 }
2477 return (
2478 <input
2479 {...props}
2480 type="checkbox"
2481 name="markdown-task"
2482 aria-label="Toggle task"
2483 checked={checked}
2484 onChange={(event) => {
2485 const markerOffset = Number(event.currentTarget.closest("li")?.dataset.taskMarkerOffset);
2486 if (!Number.isSafeInteger(markerOffset)) return;
2487 onTaskListChange({ markerOffset, checked: event.currentTarget.checked });
2488 }}
2489 />
2490 );
2491 },
2492 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
2493 const {
2494 cwd,
2495 imageBaseDir,
2496 markdownFileLinkMetaByHref,
2497 threadRef,
2498 openMarkdownMedia,
2499 openChangeRequestLink,
2500 linkTargetPreference,
2501 openExternalLinkInPreview,
2502 resolveThreadPullRequest,
2503 updateThreadPullRequestLink,
2504 fileLinkChip,
2505 } = use(ChatMarkdownRendererContext);
2506 const citation = href ? parseAssistantCitationHref(href) : null;
2507 if (citation) return <AssistantCitationChip citation={citation} />;
2508 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
2509 const fileLinkMeta = normalizedHref
2510 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??
2511 resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd))
2512 : null;
2513 if (!fileLinkMeta) {
2514 const faviconHost = resolveExternalWebLinkHost(href);
2515 const pullRequestAutolink = String(
2516 (props as Record<string, unknown>)["data-pull-request-autolink"] ?? "",
2517 );
2518 const pullRequestCopy =
2519 pullRequestAutolink === "commit"
2520 ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1]
2521 : pullRequestAutolink === "reference"
2522 ? plainHastText(node)
2523 : undefined;
2524 const isPullRequestAutolink = pullRequestCopy !== undefined;
2525 const isSameDocumentLink = href?.startsWith("#") ?? false;
2526 const onClick = props.onClick;
2527 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
2528 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
2529 const link = (
2530 <a
2531 {...props}
2532 className={cn(props.className, pullRequestAutolink === "commit" && "font-mono")}
2533 data-markdown-copy={pullRequestCopy}
2534 href={href}
2535 target={isSameDocumentLink ? undefined : "_blank"}
2536 rel={isSameDocumentLink ? undefined : "noopener noreferrer"}
2537 onClick={(event) => {
2538 onClick?.(event);
2539 if (isSameDocumentLink && href) {
2540 handleMarkdownFragmentClick(event, href);
2541 return;
2542 }
2543 if (
2544 href &&
2545 faviconHost !== null &&
2546 mediaKindFromPath(href) !== null &&
2547 !event.defaultPrevented &&
2548 !event.metaKey &&
2549 !event.ctrlKey &&
2550 !event.shiftKey &&
2551 !event.altKey
2552 ) {
2553 event.preventDefault();
2554 event.stopPropagation();
2555 openMarkdownMedia(href);
2556 return;
2557 }
2558 // A link to a change request in a workspace project opens beside the
2559 // conversation instead of in a browser: it is the thing being talked about, and
2560 // the panel it opens offers the browser as one of its actions.
2561 if (!href || openChangeRequestLink(event, href)) return;
2562 // Anything else follows the "Open links in" setting. The system browser
2563 // keeps the `_blank` the shell already handles; the in-app browser needs
2564 // the click intercepted here. A modifier click is the way out of the
2565 // in-app default, so it is left to the shell too.
2566 if (
2567 event.defaultPrevented ||
2568 resolveLinkTarget({
2569 url: href,
2570 event,
2571 preference: linkTargetPreference,
2572 canOpenInApp: canOpenInPreview,
2573 }) !== "app"
2574 ) {
2575 return;
2576 }
2577 event.preventDefault();
2578 event.stopPropagation();
2579 // The click was taken from the shell, so an in-app open that fails
2580 // hands the link to the system browser instead of dropping it.
2581 void openExternalLinkInPreview(href).then((result) => {
2582 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
2583 reportMarkdownActionFailure(
2584 { operation: "open-link-in-preview", target: href },
2585 result.cause,
2418 );2586 );
2419 if (!Number.isSafeInteger(markerOffset)) return;2587 void readLocalApi()?.shell.openExternal(href);
2420 onTaskListChange({ markerOffset, checked: event.currentTarget.checked });2588 });
2421 }}2589 }}
2422 />2590 onContextMenu={(event) => {
2423 );2591 if (!href || !faviconHost) return;
2424 },2592 event.preventDefault();
2425 a({ node, href, children, title: _title, ...props }) {2593 event.stopPropagation();
2426 const citation = href ? parseAssistantCitationHref(href) : null;2594 const api = readLocalApi();
2427 if (citation) return <AssistantCitationChip citation={citation} />;2595 if (!api) return;
2428 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";2596 const pullRequest = resolveThreadPullRequest(href);
2429 const fileLinkMeta = normalizedHref2597 const currentPullRequest =
2430 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??2598 threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;
2431 resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd))2599 const threadLinkAction =
2432 : null;2600 currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href)
2433 if (!fileLinkMeta) {2601 ? "unlink-from-thread"
2434 const faviconHost = resolveExternalWebLinkHost(href);2602 : pullRequest === null
2435 const pullRequestAutolink = String(2603 ? undefined
2436 (props as Record<string, unknown>)["data-pull-request-autolink"] ?? "",2604 : "link-to-thread";
2437 );2605 void showExternalLinkContextMenu({
2438 const pullRequestCopy =2606 href,
2439 pullRequestAutolink === "commit"2607 canOpenInPreview,
2440 ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1]2608 threadLinkAction,
2441 : pullRequestAutolink === "reference"2609 position: { x: event.clientX, y: event.clientY },
2442 ? plainHastText(node)2610 showContextMenu: (items, position) => api.contextMenu.show(items, position),
2443 : undefined;2611 openInPreview: async (target) => {
2444 const isPullRequestAutolink = pullRequestCopy !== undefined;2612 const result = await openExternalLinkInPreview(target);
2445 const isSameDocumentLink = href?.startsWith("#") ?? false;2613 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2446 const onClick = props.onClick;2614 reportMarkdownActionFailure(
2447 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();2615 { operation: "open-link-in-preview", target },
2448 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;2616 result.cause,
2449 const link = (2617 );
2450 <a
2451 {...props}
2452 className={cn(props.className, pullRequestAutolink === "commit" && "font-mono")}
2453 data-markdown-copy={pullRequestCopy}
2454 href={href}
2455 target={isSameDocumentLink ? undefined : "_blank"}
2456 rel={isSameDocumentLink ? undefined : "noopener noreferrer"}
2457 onClick={(event) => {
2458 onClick?.(event);
2459 if (isSameDocumentLink && href) {
2460 handleMarkdownFragmentClick(event, href);
2461 return;
2462 }
2463 if (
2464 href &&
2465 faviconHost !== null &&
2466 mediaKindFromPath(href) !== null &&
2467 !event.defaultPrevented &&
2468 !event.metaKey &&
2469 !event.ctrlKey &&
2470 !event.shiftKey &&
2471 !event.altKey
2472 ) {
2473 event.preventDefault();
2474 event.stopPropagation();
2475 openMarkdownMedia(href);
2476 return;
2477 }2618 }
2478 // A link to a change request in a workspace project opens beside the2619 },
2479 // conversation instead of in a browser: it is the thing being talked about, and2620 openExternal: (target) => api.shell.openExternal(target),
2480 // the panel it opens offers the browser as one of its actions.2621 copyLink: (target) => writeTextToClipboard(target, "link"),
2481 if (!href || openChangeRequestLink(event, href)) return;2622 updateThreadLink: updateThreadPullRequestLink,
2482 // Anything else follows the "Open links in" setting. The system browser2623 reportFailure: (operation, cause) => {
2483 // keeps the `_blank` the shell already handles; the in-app browser needs2624 reportMarkdownActionFailure({ operation, target: href }, cause);
2484 // the click intercepted here. A modifier click is the way out of the
2485 // in-app default, so it is left to the shell too.
2486 if (2625 if (
2487 event.defaultPrevented ||2626 operation === "link-pull-request-to-thread" ||
2488 resolveLinkTarget({2627 operation === "unlink-pull-request-from-thread"
2489 url: href,
2490 event,
2491 preference: linkTargetPreference,
2492 canOpenInApp: canOpenInPreview,
2493 }) !== "app"
2494 ) {2628 ) {
2495 return;2629 toastManager.add(
2496 }2630 stackedThreadToast({
2497 event.preventDefault();2631 type: "error",
2498 event.stopPropagation();2632 title:
2499 // The click was taken from the shell, so an in-app open that fails2633 operation === "link-pull-request-to-thread"
2500 // hands the link to the system browser instead of dropping it.2634 ? "Unable to link pull request"
2501 void openExternalLinkInPreview(href).then((result) => {2635 : "Unable to unlink pull request",
2502 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;2636 description: cause instanceof Error ? cause.message : "The request failed.",
2503 reportMarkdownActionFailure(2637 }),
2504 { operation: "open-link-in-preview", target: href },
2505 result.cause,
2506 );2638 );
2507 void readLocalApi()?.shell.openExternal(href);2639 }
2508 });2640 },
2509 }}2641 });
2510 onContextMenu={(event) => {2642 }}
2511 if (!href || !faviconHost) return;2643 >
2512 event.preventDefault();2644 {faviconHost && hastHasText(node) && !isPullRequestAutolink ? (
2513 event.stopPropagation();2645 <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>
2514 const api = readLocalApi();2646 {linkChildren}
2515 if (!api) return;2647 </MarkdownExternalLinkContent>
2516 const pullRequest = resolveThreadPullRequest(href);2648 ) : (
2517 const currentPullRequest =2649 linkChildren
2518 threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;2650 )}
2519 const threadLinkAction =2651 </a>
2520 currentPullRequest != null &&2652 );
2521 matchesLinkedPullRequestUrl(currentPullRequest, href)2653 if (!faviconHost || !href) {
2522 ? "unlink-from-thread"2654 return link;
2523 : pullRequest === null2655 }
2524 ? undefined2656 return (
2525 : "link-to-thread";2657 <Tooltip>
2526 void showExternalLinkContextMenu({2658 <TooltipTrigger render={link} />
2527 href,2659 <TooltipPopup
2528 canOpenInPreview,2660 side="top"
2529 threadLinkAction,2661 className="max-w-[min(36rem,calc(100vw-2rem))] whitespace-normal leading-tight wrap-anywhere"
2530 position: { x: event.clientX, y: event.clientY },2662 >
2531 showContextMenu: (items, position) => api.contextMenu.show(items, position),2663 {href}
2532 openInPreview: async (target) => {2664 </TooltipPopup>
2533 const result = await openExternalLinkInPreview(target);2665 </Tooltip>
2534 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {2666 );
2535 reportMarkdownActionFailure(2667 }
2536 { operation: "open-link-in-preview", target },
2537 result.cause,
2538 );
2539 }
2540 },
2541 openExternal: (target) => api.shell.openExternal(target),
2542 copyLink: (target) => writeTextToClipboard(target, "link"),
2543 updateThreadLink: updateThreadPullRequestLink,
2544 reportFailure: (operation, cause) => {
2545 reportMarkdownActionFailure({ operation, target: href }, cause);
2546 if (
2547 operation === "link-pull-request-to-thread" ||
2548 operation === "unlink-pull-request-from-thread"
2549 ) {
2550 toastManager.add(
2551 stackedThreadToast({
2552 type: "error",
2553 title:
2554 operation === "link-pull-request-to-thread"
2555 ? "Unable to link pull request"
2556 : "Unable to unlink pull request",
2557 description:
2558 cause instanceof Error ? cause.message : "The request failed.",
2559 }),
2560 );
2561 }
2562 },
2563 });
2564 }}
2565 >
2566 {faviconHost && hastHasText(node) && !isPullRequestAutolink ? (
2567 <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>
2568 {linkChildren}
2569 </MarkdownExternalLinkContent>
2570 ) : (
2571 linkChildren
2572 )}
2573 </a>
2574 );
2575 if (!faviconHost || !href) {
2576 return link;
2577 }
2578 return (
2579 <Tooltip>
2580 <TooltipTrigger render={link} />
2581 <TooltipPopup
2582 side="top"
2583 className="max-w-[min(36rem,calc(100vw-2rem))] whitespace-normal leading-tight wrap-anywhere"
2584 >
2585 {href}
2586 </TooltipPopup>
2587 </Tooltip>
2588 );
2589 }
25902668
2669 return fileLinkChip(
2670 fileLinkMeta,
2671 `[${fileLinkMeta.basename}](${normalizedHref})`,
2672 props.className,
2673 normalizedHref,
2674 );
2675 },
2676 code: function MarkdownCode({ node, children, className, ...props }) {
2677 const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip } = use(
2678 ChatMarkdownRendererContext,
2679 );
2680 if (node?.properties?.dataInlineCode != null) {
2681 const codeText = nodeToPlainText(children);
2682 const fileLinkMeta =
2683 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
2684 resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd);
2685 if (fileLinkMeta) {
2591 return fileLinkChip(2686 return fileLinkChip(
2592 fileLinkMeta,2687 fileLinkMeta,
2593 `[${fileLinkMeta.basename}](${normalizedHref})`,2688 `\`${codeText}\``,
2594 props.className,2689 undefined,
2595 normalizedHref,2690 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),
2596 );2691 );
2597 },2692 }
2598 code({ node, children, className, ...props }) {2693 }
2599 if (node?.properties?.dataInlineCode != null) {2694 return (
2600 const codeText = nodeToPlainText(children);2695 <code {...props} className={className}>
2601 const fileLinkMeta =2696 {children}
2602 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??2697 </code>
2603 resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd);2698 );
2604 if (fileLinkMeta) {2699 },
2605 return fileLinkChip(2700 img: function MarkdownImage({ node, title, src, alt, ...props }) {
2606 fileLinkMeta,2701 const { expandMedia, cwd, imageBaseDir, threadRef } = use(ChatMarkdownRendererContext);
2607 `\`${codeText}\``,2702 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
2608 undefined,2703 const localSrc = node?.properties?.dataLocalSrc;
2609 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),2704 const markdownTitle = node?.properties?.dataMarkdownTitle;
2610 );2705 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
2611 }2706 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
2612 }2707 const srcString =
2708 typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : "";
2709 const classifiedSrc =
2710 typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString;
2711 const altText = alt ?? "";
2712 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);
2713 const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height);
2714 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
2715 const kind = mediaKindFromPath(classifiedSrc) ?? "image";
2716 if (imageSource._tag === "Direct") {
2717 const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri);
2718 const originalUrl =
2719 resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined;
2720 const reference = mediaUrlReference(imageSource.uri);
2721 const actionsSource: MediaActionSource = {
2722 kind,
2723 name: altText || kind,
2724 src: mediaSrc,
2725 ...(reference ? { reference } : {}),
2726 };
2727 if (kind === "video") {
2613 return (2728 return (
2614 <code {...props} className={className}>2729 <ChatMarkdownVideo
2615 {children}2730 src={mediaSrc}
2616 </code>2731 alt={altText}
2732 copyMarkdown={copyMarkdown}
2733 originalUrl={originalUrl}
2734 style={authoredSizeStyle}
2735 actionsSource={actionsSource}
2736 />
2617 );2737 );
2618 },2738 }
2619 img: function MarkdownImage({ node, title, src, alt, ...props }) {2739 return (
2620 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;2740 <MediaActions source={actionsSource}>
2621 const localSrc = node?.properties?.dataLocalSrc;2741 <img
2622 const markdownTitle = node?.properties?.dataMarkdownTitle;2742 {...props}
2623 const authoredSrc = typeof localSrc === "string" ? localSrc : src;2743 src={mediaSrc}
2624 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;2744 alt={altText}
2625 const srcString =2745 loading="lazy"
2626 typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : "";2746 className={cn(
2627 const classifiedSrc =2747 props.className,
2628 typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString;2748 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
2629 const altText = alt ?? "";2749 imageExpand && "cursor-zoom-in",
2630 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);2750 )}
2631 const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height);2751 style={authoredSizeStyle}
2632 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);2752 {...expandableMarkdownImageProps(
2633 const kind = mediaKindFromPath(classifiedSrc) ?? "image";2753 imageExpand,
2634 if (imageSource._tag === "Direct") {2754 mediaSrc,
2635 const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri);2755 altText,
2636 const originalUrl =2756 originalUrl,
2637 resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined;2757 actionsSource,
2638 const reference = mediaUrlReference(imageSource.uri);2758 )}
2639 const actionsSource: MediaActionSource = {2759 />
2640 kind,2760 </MediaActions>
2641 name: altText || kind,2761 );
2642 src: mediaSrc,2762 }
2643 ...(reference ? { reference } : {}),2763 if (imageSource._tag === "WorkspaceFile" && threadRef) {
2644 };2764 return (
2645 if (kind === "video") {2765 <ChatMarkdownAssetImage
2646 return (2766 environmentId={threadRef.environmentId}
2647 <ChatMarkdownVideo2767 resource={{
2648 src={mediaSrc}2768 _tag: "media-file",
2649 alt={altText}2769 threadId: threadRef.threadId,
2650 copyMarkdown={copyMarkdown}2770 path: imageSource.path,
2651 originalUrl={originalUrl}2771 }}
2652 style={authoredSizeStyle}2772 alt={altText}
2653 actionsSource={actionsSource}2773 kind={kind}
2654 />2774 copyMarkdown={copyMarkdown}
2655 );2775 srcFragment={markdownImageSourceFragment(classifiedSrc)}
2656 }2776 style={authoredSizeStyle}
2657 return (2777 workspaceRoot={cwd}
2658 <MediaActions source={actionsSource}>2778 onImageExpand={imageExpand}
2659 <img2779 />
2660 {...props}2780 );
2661 src={mediaSrc}2781 }
2662 alt={altText}2782 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} kind={kind} />;
2663 loading="lazy"2783 },
2664 className={cn(2784 table: function MarkdownTableRenderer({ node: _node, ...props }) {
2665 props.className,2785 return <MarkdownTable {...props} />;
2666 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,2786 },
2667 imageExpand && "cursor-zoom-in",2787 details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) {
2668 )}2788 return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
2669 style={authoredSizeStyle}2789 },
2670 {...expandableMarkdownImageProps(2790 pre: function MarkdownPre({ node, children, ...props }) {
2671 imageExpand,2791 const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext);
2672 mediaSrc,2792 const codeBlock = extractCodeBlock(children);
2673 altText,2793 if (!codeBlock) {
2674 originalUrl,2794 return <pre {...props}>{children}</pre>;
2675 actionsSource,2795 }
2676 )}
2677 />
2678 </MediaActions>
2679 );
2680 }
2681 if (imageSource._tag === "WorkspaceFile" && threadRef) {
2682 return (
2683 <ChatMarkdownAssetImage
2684 environmentId={threadRef.environmentId}
2685 resource={{
2686 _tag: "media-file",
2687 threadId: threadRef.threadId,
2688 path: imageSource.path,
2689 }}
2690 alt={altText}
2691 kind={kind}
2692 copyMarkdown={copyMarkdown}
2693 srcFragment={markdownImageSourceFragment(classifiedSrc)}
2694 style={authoredSizeStyle}
2695 workspaceRoot={cwd}
2696 onImageExpand={imageExpand}
2697 />
2698 );
2699 }
2700 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} kind={kind} />;
2701 },
2702 table({ node: _node, ...props }) {
2703 return <MarkdownTable {...props} />;
2704 },
2705 details({ node: _node, children, open: detailsOpen }) {
2706 return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
2707 },
2708 pre({ node, children, ...props }) {
2709 const codeBlock = extractCodeBlock(children);
2710 if (!codeBlock) {
2711 return <pre {...props}>{children}</pre>;
2712 }
27132796
2714 const language = extractFenceLanguage(codeBlock.className);2797 const language = extractFenceLanguage(codeBlock.className);
2715 const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));2798 const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
2716 return (2799 return (
2717 <MarkdownCodeBlock2800 <MarkdownCodeBlock
2718 code={codeBlock.code}2801 code={codeBlock.code}
2719 language={language}2802 language={language}
2720 fenceTitle={fenceTitle}2803 fenceTitle={fenceTitle}
2721 theme={resolvedTheme}2804 theme={resolvedTheme}
2722 >2805 >
2723 <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>2806 <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
2724 <Suspense fallback={<pre {...props}>{children}</pre>}>2807 <Suspense fallback={<pre {...props}>{children}</pre>}>
2725 <SuspenseShikiCodeBlock2808 <SuspenseShikiCodeBlock
2726 className={codeBlock.className}2809 className={codeBlock.className}
2727 code={codeBlock.code}2810 code={codeBlock.code}
2728 themeName={diffThemeName}2811 themeName={diffThemeName}
2729 isStreaming={isStreaming}2812 isStreaming={isStreaming}
2730 />2813 />
2731 </Suspense>2814 </Suspense>
2732 </RenderErrorBoundary>2815 </RenderErrorBoundary>
2733 </MarkdownCodeBlock>2816 </MarkdownCodeBlock>
2734 );2817 );
2735 },2818 },
2736 };2819} satisfies Components;
2737 }, [
2738 canUseShellActions,
2739 cwd,
2740 diffThemeName,
2741 fileLinkParentSuffixByPath,
2742 inlineCodeFileLinkMetaByText,
2743 imageBaseDir,
2744 isStreaming,
2745 linkTargetPreference,
2746 markdownFileLinkMetaByHref,
2747 onTaskListChange,
2748 onUseArtifactTemplate,
2749 onImageExpand,
2750 expandMedia,
2751 openMarkdownMedia,
2752 openFileInPanel,
2753 openInPreferredEditor,
2754 openChangeRequestLink,
2755 openExternalLinkInPreview,
2756 openMarkdownFileInPreview,
2757 preferredEditorMenuLabel,
2758 resolveThreadPullRequest,
2759 resolvedTheme,
2760 revealMarkdownFileInFileManager,
2761 revealInFileManagerLabel,
2762 skills,
2763 text,
2764 threadRef,
2765 updateThreadPullRequestLink,
2766 ]);
2767 /* eslint-enable react/no-unstable-nested-components */
27682820
2821function ChatMarkdown({
2822 text,
2823 className,
2824 lineBreaks = false,
2825 parseRawHtml = true,
2826 extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
2827 ...props
2828}: ChatMarkdownProps) {
2829 const {
2830 componentState,
2831 handleCopy,
2832 markdownUrlTransform,
2833 localMediaPreview,
2834 setLocalMediaPreview,
2835 } = useChatMarkdownState({ text, ...props });
2769 const remarkPlugins = useMemo(2836 const remarkPlugins = useMemo(
2770 () => [2837 () => [
2771 ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),2838 ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
...@@ -2785,15 +2852,17 @@ function ChatMarkdown({...@@ -2785,15 +2852,17 @@ function ChatMarkdown({
2785 )}2852 )}
2786 onCopy={handleCopy}2853 onCopy={handleCopy}
2787 >2854 >
2788 <ReactMarkdown2855 <ChatMarkdownRendererContext value={componentState}>
2789 remarkPlugins={remarkPlugins}2856 <ReactMarkdown
2790 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}2857 remarkPlugins={remarkPlugins}
2791 skipHtml={false}2858 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
2792 components={markdownComponents}2859 skipHtml={false}
2793 urlTransform={markdownUrlTransform}2860 components={CHAT_MARKDOWN_COMPONENTS}
2794 >2861 urlTransform={markdownUrlTransform}
2795 {text}2862 >
2796 </ReactMarkdown>2863 {text}
2864 </ReactMarkdown>
2865 </ChatMarkdownRendererContext>
2797 {localMediaPreview ? (2866 {localMediaPreview ? (
2798 <ExpandedImageDialog2867 <ExpandedImageDialog
2799 preview={localMediaPreview}2868 preview={localMediaPreview}
apps/web/src/components/chat/MessagesTimeline.tsx+1-4
...@@ -639,10 +639,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -639,10 +639,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
639 listRef,639 listRef,
640 timestampFormat,640 timestampFormat,
641 routeThreadKey,641 routeThreadKey,
642 // Must be referentially stable: ChatMarkdown keys its react-markdown642 // Keep Markdown callbacks memoized during unrelated activity updates.
643 // component map on threadRef, and a fresh object here remounts every
644 // rendered markdown node whenever this memo recomputes (e.g. on each
645 // activity delta while the thread is working).
646 threadRef: citationThreadRef,643 threadRef: citationThreadRef,
647 markdownCwd,644 markdownCwd,
648 resolvedTheme,645 resolvedTheme,
pnpm-lock.yaml+17
...@@ -690,6 +690,9 @@ importers:...@@ -690,6 +690,9 @@ importers:
690 '@types/react-dom':690 '@types/react-dom':
691 specifier: ~19.2.3691 specifier: ~19.2.3
692 version: 19.2.3(@types/react@19.2.16)692 version: 19.2.3(@types/react@19.2.16)
693 '@types/react-test-renderer':
694 specifier: 19.1.0
695 version: 19.1.0
693 '@vercel/config':696 '@vercel/config':
694 specifier: ^0.3.0697 specifier: ^0.3.0
695 version: 0.3.0698 version: 0.3.0
...@@ -702,6 +705,9 @@ importers:...@@ -702,6 +705,9 @@ importers:
702 compression:705 compression:
703 specifier: ^1.8.1706 specifier: ^1.8.1
704 version: 1.8.1707 version: 1.8.1
708 react-test-renderer:
709 specifier: 19.2.6
710 version: 19.2.6(react@19.2.6)
705 tailwindcss:711 tailwindcss:
706 specifier: 4.3.3712 specifier: 4.3.3
707 version: 4.3.3713 version: 4.3.3
...@@ -9092,6 +9098,11 @@ packages:...@@ -9092,6 +9098,11 @@ packages:
9092 '@types/react':9098 '@types/react':
9093 optional: true9099 optional: true
90949100
9101 react-test-renderer@19.2.6:
9102 resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==}
9103 peerDependencies:
9104 react: ^19.2.6
9105
9095 react@19.2.3:9106 react@19.2.3:
9096 resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}9107 resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
9097 engines: {node: '>=0.10.0'}9108 engines: {node: '>=0.10.0'}
...@@ -19795,6 +19806,12 @@ snapshots:...@@ -19795,6 +19806,12 @@ snapshots:
19795 optionalDependencies:19806 optionalDependencies:
19796 '@types/react': 19.2.1619807 '@types/react': 19.2.16
1979719808
19809 react-test-renderer@19.2.6(react@19.2.6):
19810 dependencies:
19811 react: 19.2.6
19812 react-is: 19.2.7
19813 scheduler: 0.27.0
19814
19798 react@19.2.3: {}19815 react@19.2.3: {}
1979919816
19800 react@19.2.6: {}19817 react@19.2.6: {}