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 @@
6161 "@types/culori": "^4.0.1",
6262 "@types/react": "~19.2.14",
6363 "@types/react-dom": "~19.2.3",
64 "@types/react-test-renderer": "19.1.0",
6465 "@vercel/config": "^0.3.0",
6566 "@vitejs/plugin-react": "^6.0.0",
6667 "babel-plugin-react-compiler": "1.0.0",
6768 "compression": "^1.8.1",
69 "react-test-renderer": "19.2.6",
6870 "tailwindcss": "^4.0.0",
6971 "vite": "catalog:",
7072 "vite-plus": "catalog:"
apps/web/src/components/ChatMarkdown.test.tsx+170
......@@ -1,9 +1,38 @@
11import { EnvironmentId } from "@t3tools/contracts";
2import { act, type ComponentProps, type ReactNode } from "react";
23import { renderToStaticMarkup } from "react-dom/server";
4import { create, type ReactTestRenderer } from "react-test-renderer";
35import { 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
511vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
612vi.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});
736vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
837vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
938vi.mock("../state/session", async (importOriginal) => ({
......@@ -35,6 +64,147 @@ import ChatMarkdown, {
3564 shouldUseMarkdownFileBrowserPrimaryAction,
3665} 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
38208describe("canUseMarkdownFileShellActions", () => {
39209 const environmentId = EnvironmentId.make("environment-1");
40210
apps/web/src/components/ChatMarkdown.tsx+501-432
......@@ -1952,7 +1952,7 @@ function areMarkdownFileLinkPropsEqual(
19521952 );
19531953}
19541954
1955function ChatMarkdown({
1955function useChatMarkdownState({
19561956 text,
19571957 cwd,
19581958 threadRef,
......@@ -1960,13 +1960,9 @@ function ChatMarkdown({
19601960 onTaskListChange,
19611961 isStreaming = false,
19621962 skills = EMPTY_MARKDOWN_SKILLS,
1963 className,
1964 lineBreaks = false,
1965 parseRawHtml = true,
19661963 onUseArtifactTemplate,
19671964 imageBaseDir,
19681965 onImageExpand,
1969 extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
19701966}: ChatMarkdownProps) {
19711967 const { resolvedTheme } = useTheme();
19721968 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
......@@ -2270,11 +2266,8 @@ function ChatMarkdown({
22702266 },
22712267 [cwd, findWorkspaceBasenameMatch, revealFileInFileManager],
22722268 );
2273 /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component
2274 * renderers that close over this message's metadata. useMemo keeps them stable until that
2275 * metadata changes. */
2276 const markdownComponents = useMemo<Components>(() => {
2277 const fileLinkChip = (
2269 const fileLinkChip = useCallback(
2270 (
22782271 fileLinkMeta: MarkdownFileLinkMeta,
22792272 copyMarkdown: string,
22802273 className?: string,
......@@ -2339,433 +2332,507 @@ function ChatMarkdown({
23392332 className={className}
23402333 />
23412334 );
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 {
2345 div({ node, children, ...props }) {
2346 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
2347 if (artifactTemplate) {
2348 return (
2349 <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} />
2350 );
2351 }
2352 return <div {...props}>{children}</div>;
2353 },
2354 p({ node: _node, children, ...props }) {
2355 return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
2356 },
2357 blockquote({ node: _node, children, ...props }) {
2358 const alert =
2359 GITHUB_ALERT_PRESENTATIONS[
2360 String((props as Record<string, unknown>)["data-alert"] ?? "")
2361 ];
2362 if (!alert) {
2363 return <blockquote {...props}>{children}</blockquote>;
2364 }
2365 // Not a <blockquote>: the stylesheet mutes those, and an alert's body is ordinary
2366 // text under a colored title — which is how the host renders it.
2367 return (
2368 <div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>
2369 <p className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}>
2370 <alert.Icon aria-hidden className="size-3.5 shrink-0" />
2371 {alert.label}
2372 </p>
2373 {children}
2374 </div>
2375 );
2376 },
2377 ol({ node, start, style, ...props }) {
2378 const itemCount =
2379 node?.children?.filter((child) => child.type === "element" && child.tagName === "li")
2380 .length ?? 0;
2381 const gutterStyle = orderedListGutterStyle(itemCount, start);
2382 return (
2383 <ol {...props} start={start} style={gutterStyle ? { ...style, ...gutterStyle } : style} />
2384 );
2385 },
2386 li({ node, children, ...props }) {
2387 const listItemStart = node?.position?.start.offset;
2388 const markerOffset =
2389 typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null;
2390 return (
2391 <li {...props} data-task-marker-offset={markerOffset ?? undefined}>
2392 {renderSkillInlineMarkdownChildren(children, skills)}
2393 </li>
2394 );
2395 },
2396 input({ node: _node, type, checked, disabled: _disabled, ...props }) {
2397 if (type !== "checkbox" || !onTaskListChange) {
2398 return (
2399 <input
2400 {...props}
2401 type={type}
2402 checked={checked}
2403 disabled={_disabled}
2404 readOnly={type === "checkbox"}
2405 />
2406 );
2407 }
2408 return (
2409 <input
2410 {...props}
2411 type="checkbox"
2412 name="markdown-task"
2413 aria-label="Toggle task"
2414 checked={checked}
2415 onChange={(event) => {
2416 const markerOffset = Number(
2417 event.currentTarget.closest("li")?.dataset.taskMarkerOffset,
2351 const componentState = useMemo(
2352 () => ({
2353 cwd,
2354 diffThemeName,
2355 expandMedia,
2356 fileLinkChip,
2357 imageBaseDir,
2358 inlineCodeFileLinkMetaByText,
2359 isStreaming,
2360 linkTargetPreference,
2361 markdownFileLinkMetaByHref,
2362 onTaskListChange,
2363 onUseArtifactTemplate,
2364 openChangeRequestLink,
2365 openExternalLinkInPreview,
2366 openMarkdownMedia,
2367 resolveThreadPullRequest,
2368 resolvedTheme,
2369 skills,
2370 text,
2371 threadRef,
2372 updateThreadPullRequestLink,
2373 }),
2374 [
2375 cwd,
2376 diffThemeName,
2377 expandMedia,
2378 fileLinkChip,
2379 imageBaseDir,
2380 inlineCodeFileLinkMetaByText,
2381 isStreaming,
2382 linkTargetPreference,
2383 markdownFileLinkMetaByHref,
2384 onTaskListChange,
2385 onUseArtifactTemplate,
2386 openChangeRequestLink,
2387 openExternalLinkInPreview,
2388 openMarkdownMedia,
2389 resolveThreadPullRequest,
2390 resolvedTheme,
2391 skills,
2392 text,
2393 threadRef,
2394 updateThreadPullRequestLink,
2395 ],
2396 );
2397 return {
2398 componentState,
2399 handleCopy,
2400 markdownUrlTransform,
2401 localMediaPreview,
2402 setLocalMediaPreview,
2403 };
2404}
2405
2406const ChatMarkdownRendererContext = React.createContext<
2407 ReturnType<typeof useChatMarkdownState>["componentState"]
2408>(null!);
2409
2410// Keep component types stable when streaming changes the message state.
2411const CHAT_MARKDOWN_COMPONENTS = {
2412 div: function MarkdownDiv({ node, children, ...props }) {
2413 const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext);
2414 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
2415 if (artifactTemplate) {
2416 return (
2417 <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} />
2418 );
2419 }
2420 return <div {...props}>{children}</div>;
2421 },
2422 p: function MarkdownParagraph({ node: _node, children, ...props }) {
2423 const { skills } = use(ChatMarkdownRendererContext);
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,
24182586 );
2419 if (!Number.isSafeInteger(markerOffset)) return;
2420 onTaskListChange({ markerOffset, checked: event.currentTarget.checked });
2421 }}
2422 />
2423 );
2424 },
2425 a({ node, href, children, title: _title, ...props }) {
2426 const citation = href ? parseAssistantCitationHref(href) : null;
2427 if (citation) return <AssistantCitationChip citation={citation} />;
2428 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
2429 const fileLinkMeta = normalizedHref
2430 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??
2431 resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd))
2432 : null;
2433 if (!fileLinkMeta) {
2434 const faviconHost = resolveExternalWebLinkHost(href);
2435 const pullRequestAutolink = String(
2436 (props as Record<string, unknown>)["data-pull-request-autolink"] ?? "",
2437 );
2438 const pullRequestCopy =
2439 pullRequestAutolink === "commit"
2440 ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1]
2441 : pullRequestAutolink === "reference"
2442 ? plainHastText(node)
2443 : undefined;
2444 const isPullRequestAutolink = pullRequestCopy !== undefined;
2445 const isSameDocumentLink = href?.startsWith("#") ?? false;
2446 const onClick = props.onClick;
2447 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
2448 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
2449 const link = (
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;
2587 void readLocalApi()?.shell.openExternal(href);
2588 });
2589 }}
2590 onContextMenu={(event) => {
2591 if (!href || !faviconHost) return;
2592 event.preventDefault();
2593 event.stopPropagation();
2594 const api = readLocalApi();
2595 if (!api) return;
2596 const pullRequest = resolveThreadPullRequest(href);
2597 const currentPullRequest =
2598 threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;
2599 const threadLinkAction =
2600 currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href)
2601 ? "unlink-from-thread"
2602 : pullRequest === null
2603 ? undefined
2604 : "link-to-thread";
2605 void showExternalLinkContextMenu({
2606 href,
2607 canOpenInPreview,
2608 threadLinkAction,
2609 position: { x: event.clientX, y: event.clientY },
2610 showContextMenu: (items, position) => api.contextMenu.show(items, position),
2611 openInPreview: async (target) => {
2612 const result = await openExternalLinkInPreview(target);
2613 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2614 reportMarkdownActionFailure(
2615 { operation: "open-link-in-preview", target },
2616 result.cause,
2617 );
24772618 }
2478 // A link to a change request in a workspace project opens beside the
2479 // conversation instead of in a browser: it is the thing being talked about, and
2480 // the panel it opens offers the browser as one of its actions.
2481 if (!href || openChangeRequestLink(event, href)) return;
2482 // Anything else follows the "Open links in" setting. The system browser
2483 // keeps the `_blank` the shell already handles; the in-app browser needs
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.
2619 },
2620 openExternal: (target) => api.shell.openExternal(target),
2621 copyLink: (target) => writeTextToClipboard(target, "link"),
2622 updateThreadLink: updateThreadPullRequestLink,
2623 reportFailure: (operation, cause) => {
2624 reportMarkdownActionFailure({ operation, target: href }, cause);
24862625 if (
2487 event.defaultPrevented ||
2488 resolveLinkTarget({
2489 url: href,
2490 event,
2491 preference: linkTargetPreference,
2492 canOpenInApp: canOpenInPreview,
2493 }) !== "app"
2626 operation === "link-pull-request-to-thread" ||
2627 operation === "unlink-pull-request-from-thread"
24942628 ) {
2495 return;
2496 }
2497 event.preventDefault();
2498 event.stopPropagation();
2499 // The click was taken from the shell, so an in-app open that fails
2500 // hands the link to the system browser instead of dropping it.
2501 void openExternalLinkInPreview(href).then((result) => {
2502 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
2503 reportMarkdownActionFailure(
2504 { operation: "open-link-in-preview", target: href },
2505 result.cause,
2629 toastManager.add(
2630 stackedThreadToast({
2631 type: "error",
2632 title:
2633 operation === "link-pull-request-to-thread"
2634 ? "Unable to link pull request"
2635 : "Unable to unlink pull request",
2636 description: cause instanceof Error ? cause.message : "The request failed.",
2637 }),
25062638 );
2507 void readLocalApi()?.shell.openExternal(href);
2508 });
2509 }}
2510 onContextMenu={(event) => {
2511 if (!href || !faviconHost) return;
2512 event.preventDefault();
2513 event.stopPropagation();
2514 const api = readLocalApi();
2515 if (!api) return;
2516 const pullRequest = resolveThreadPullRequest(href);
2517 const currentPullRequest =
2518 threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;
2519 const threadLinkAction =
2520 currentPullRequest != null &&
2521 matchesLinkedPullRequestUrl(currentPullRequest, href)
2522 ? "unlink-from-thread"
2523 : pullRequest === null
2524 ? undefined
2525 : "link-to-thread";
2526 void showExternalLinkContextMenu({
2527 href,
2528 canOpenInPreview,
2529 threadLinkAction,
2530 position: { x: event.clientX, y: event.clientY },
2531 showContextMenu: (items, position) => api.contextMenu.show(items, position),
2532 openInPreview: async (target) => {
2533 const result = await openExternalLinkInPreview(target);
2534 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2535 reportMarkdownActionFailure(
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 }
2639 }
2640 },
2641 });
2642 }}
2643 >
2644 {faviconHost && hastHasText(node) && !isPullRequestAutolink ? (
2645 <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>
2646 {linkChildren}
2647 </MarkdownExternalLinkContent>
2648 ) : (
2649 linkChildren
2650 )}
2651 </a>
2652 );
2653 if (!faviconHost || !href) {
2654 return link;
2655 }
2656 return (
2657 <Tooltip>
2658 <TooltipTrigger render={link} />
2659 <TooltipPopup
2660 side="top"
2661 className="max-w-[min(36rem,calc(100vw-2rem))] whitespace-normal leading-tight wrap-anywhere"
2662 >
2663 {href}
2664 </TooltipPopup>
2665 </Tooltip>
2666 );
2667 }
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) {
25912686 return fileLinkChip(
25922687 fileLinkMeta,
2593 `[${fileLinkMeta.basename}](${normalizedHref})`,
2594 props.className,
2595 normalizedHref,
2688 `\`${codeText}\``,
2689 undefined,
2690 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),
25962691 );
2597 },
2598 code({ node, children, className, ...props }) {
2599 if (node?.properties?.dataInlineCode != null) {
2600 const codeText = nodeToPlainText(children);
2601 const fileLinkMeta =
2602 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
2603 resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd);
2604 if (fileLinkMeta) {
2605 return fileLinkChip(
2606 fileLinkMeta,
2607 `\`${codeText}\``,
2608 undefined,
2609 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),
2610 );
2611 }
2612 }
2692 }
2693 }
2694 return (
2695 <code {...props} className={className}>
2696 {children}
2697 </code>
2698 );
2699 },
2700 img: function MarkdownImage({ node, title, src, alt, ...props }) {
2701 const { expandMedia, cwd, imageBaseDir, threadRef } = use(ChatMarkdownRendererContext);
2702 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
2703 const localSrc = node?.properties?.dataLocalSrc;
2704 const markdownTitle = node?.properties?.dataMarkdownTitle;
2705 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
2706 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
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") {
26132728 return (
2614 <code {...props} className={className}>
2615 {children}
2616 </code>
2729 <ChatMarkdownVideo
2730 src={mediaSrc}
2731 alt={altText}
2732 copyMarkdown={copyMarkdown}
2733 originalUrl={originalUrl}
2734 style={authoredSizeStyle}
2735 actionsSource={actionsSource}
2736 />
26172737 );
2618 },
2619 img: function MarkdownImage({ node, title, src, alt, ...props }) {
2620 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
2621 const localSrc = node?.properties?.dataLocalSrc;
2622 const markdownTitle = node?.properties?.dataMarkdownTitle;
2623 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
2624 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
2625 const srcString =
2626 typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : "";
2627 const classifiedSrc =
2628 typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString;
2629 const altText = alt ?? "";
2630 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);
2631 const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height);
2632 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
2633 const kind = mediaKindFromPath(classifiedSrc) ?? "image";
2634 if (imageSource._tag === "Direct") {
2635 const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri);
2636 const originalUrl =
2637 resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined;
2638 const reference = mediaUrlReference(imageSource.uri);
2639 const actionsSource: MediaActionSource = {
2640 kind,
2641 name: altText || kind,
2642 src: mediaSrc,
2643 ...(reference ? { reference } : {}),
2644 };
2645 if (kind === "video") {
2646 return (
2647 <ChatMarkdownVideo
2648 src={mediaSrc}
2649 alt={altText}
2650 copyMarkdown={copyMarkdown}
2651 originalUrl={originalUrl}
2652 style={authoredSizeStyle}
2653 actionsSource={actionsSource}
2654 />
2655 );
2656 }
2657 return (
2658 <MediaActions source={actionsSource}>
2659 <img
2660 {...props}
2661 src={mediaSrc}
2662 alt={altText}
2663 loading="lazy"
2664 className={cn(
2665 props.className,
2666 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
2667 imageExpand && "cursor-zoom-in",
2668 )}
2669 style={authoredSizeStyle}
2670 {...expandableMarkdownImageProps(
2671 imageExpand,
2672 mediaSrc,
2673 altText,
2674 originalUrl,
2675 actionsSource,
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 }
2738 }
2739 return (
2740 <MediaActions source={actionsSource}>
2741 <img
2742 {...props}
2743 src={mediaSrc}
2744 alt={altText}
2745 loading="lazy"
2746 className={cn(
2747 props.className,
2748 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
2749 imageExpand && "cursor-zoom-in",
2750 )}
2751 style={authoredSizeStyle}
2752 {...expandableMarkdownImageProps(
2753 imageExpand,
2754 mediaSrc,
2755 altText,
2756 originalUrl,
2757 actionsSource,
2758 )}
2759 />
2760 </MediaActions>
2761 );
2762 }
2763 if (imageSource._tag === "WorkspaceFile" && threadRef) {
2764 return (
2765 <ChatMarkdownAssetImage
2766 environmentId={threadRef.environmentId}
2767 resource={{
2768 _tag: "media-file",
2769 threadId: threadRef.threadId,
2770 path: imageSource.path,
2771 }}
2772 alt={altText}
2773 kind={kind}
2774 copyMarkdown={copyMarkdown}
2775 srcFragment={markdownImageSourceFragment(classifiedSrc)}
2776 style={authoredSizeStyle}
2777 workspaceRoot={cwd}
2778 onImageExpand={imageExpand}
2779 />
2780 );
2781 }
2782 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} kind={kind} />;
2783 },
2784 table: function MarkdownTableRenderer({ node: _node, ...props }) {
2785 return <MarkdownTable {...props} />;
2786 },
2787 details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) {
2788 return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
2789 },
2790 pre: function MarkdownPre({ node, children, ...props }) {
2791 const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext);
2792 const codeBlock = extractCodeBlock(children);
2793 if (!codeBlock) {
2794 return <pre {...props}>{children}</pre>;
2795 }
27132796
2714 const language = extractFenceLanguage(codeBlock.className);
2715 const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
2716 return (
2717 <MarkdownCodeBlock
2718 code={codeBlock.code}
2719 language={language}
2720 fenceTitle={fenceTitle}
2721 theme={resolvedTheme}
2722 >
2723 <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
2724 <Suspense fallback={<pre {...props}>{children}</pre>}>
2725 <SuspenseShikiCodeBlock
2726 className={codeBlock.className}
2727 code={codeBlock.code}
2728 themeName={diffThemeName}
2729 isStreaming={isStreaming}
2730 />
2731 </Suspense>
2732 </RenderErrorBoundary>
2733 </MarkdownCodeBlock>
2734 );
2735 },
2736 };
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 */
2797 const language = extractFenceLanguage(codeBlock.className);
2798 const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
2799 return (
2800 <MarkdownCodeBlock
2801 code={codeBlock.code}
2802 language={language}
2803 fenceTitle={fenceTitle}
2804 theme={resolvedTheme}
2805 >
2806 <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
2807 <Suspense fallback={<pre {...props}>{children}</pre>}>
2808 <SuspenseShikiCodeBlock
2809 className={codeBlock.className}
2810 code={codeBlock.code}
2811 themeName={diffThemeName}
2812 isStreaming={isStreaming}
2813 />
2814 </Suspense>
2815 </RenderErrorBoundary>
2816 </MarkdownCodeBlock>
2817 );
2818 },
2819} satisfies 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 });
27692836 const remarkPlugins = useMemo(
27702837 () => [
27712838 ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
......@@ -2785,15 +2852,17 @@ function ChatMarkdown({
27852852 )}
27862853 onCopy={handleCopy}
27872854 >
2788 <ReactMarkdown
2789 remarkPlugins={remarkPlugins}
2790 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
2791 skipHtml={false}
2792 components={markdownComponents}
2793 urlTransform={markdownUrlTransform}
2794 >
2795 {text}
2796 </ReactMarkdown>
2855 <ChatMarkdownRendererContext value={componentState}>
2856 <ReactMarkdown
2857 remarkPlugins={remarkPlugins}
2858 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
2859 skipHtml={false}
2860 components={CHAT_MARKDOWN_COMPONENTS}
2861 urlTransform={markdownUrlTransform}
2862 >
2863 {text}
2864 </ReactMarkdown>
2865 </ChatMarkdownRendererContext>
27972866 {localMediaPreview ? (
27982867 <ExpandedImageDialog
27992868 preview={localMediaPreview}
apps/web/src/components/chat/MessagesTimeline.tsx+1-4
......@@ -639,10 +639,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
639639 listRef,
640640 timestampFormat,
641641 routeThreadKey,
642 // Must be referentially stable: ChatMarkdown keys its react-markdown
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).
642 // Keep Markdown callbacks memoized during unrelated activity updates.
646643 threadRef: citationThreadRef,
647644 markdownCwd,
648645 resolvedTheme,
pnpm-lock.yaml+17
......@@ -690,6 +690,9 @@ importers:
690690 '@types/react-dom':
691691 specifier: ~19.2.3
692692 version: 19.2.3(@types/react@19.2.16)
693 '@types/react-test-renderer':
694 specifier: 19.1.0
695 version: 19.1.0
693696 '@vercel/config':
694697 specifier: ^0.3.0
695698 version: 0.3.0
......@@ -702,6 +705,9 @@ importers:
702705 compression:
703706 specifier: ^1.8.1
704707 version: 1.8.1
708 react-test-renderer:
709 specifier: 19.2.6
710 version: 19.2.6(react@19.2.6)
705711 tailwindcss:
706712 specifier: 4.3.3
707713 version: 4.3.3
......@@ -9092,6 +9098,11 @@ packages:
90929098 '@types/react':
90939099 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
90959106 react@19.2.3:
90969107 resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
90979108 engines: {node: '>=0.10.0'}
......@@ -19795,6 +19806,12 @@ snapshots:
1979519806 optionalDependencies:
1979619807 '@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
1979819815 react@19.2.3: {}
1979919816
1980019817 react@19.2.6: {}