| ... | ... | @@ -1952,7 +1952,7 @@ function areMarkdownFileLinkPropsEqual( |
| 1952 | 1952 | ); |
| 1953 | 1953 | } |
| 1954 | 1954 | |
| 1955 | | function ChatMarkdown({ |
| 1955 | function useChatMarkdownState({ |
| 1956 | 1956 | text, |
| 1957 | 1957 | cwd, |
| 1958 | 1958 | threadRef, |
| ... | ... | @@ -1960,13 +1960,9 @@ function ChatMarkdown({ |
| 1960 | 1960 | onTaskListChange, |
| 1961 | 1961 | isStreaming = false, |
| 1962 | 1962 | skills = EMPTY_MARKDOWN_SKILLS, |
| 1963 | | className, |
| 1964 | | lineBreaks = false, |
| 1965 | | parseRawHtml = true, |
| 1966 | 1963 | onUseArtifactTemplate, |
| 1967 | 1964 | imageBaseDir, |
| 1968 | 1965 | onImageExpand, |
| 1969 | | extraRemarkPlugins = EMPTY_REMARK_PLUGINS, |
| 1970 | 1966 | }: ChatMarkdownProps) { |
| 1971 | 1967 | const { resolvedTheme } = useTheme(); |
| 1972 | 1968 | const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null); |
| ... | ... | @@ -2270,11 +2266,8 @@ function ChatMarkdown({ |
| 2270 | 2266 | }, |
| 2271 | 2267 | [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], |
| 2272 | 2268 | ); |
| 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 | ( |
| 2278 | 2271 | fileLinkMeta: MarkdownFileLinkMeta, |
| 2279 | 2272 | copyMarkdown: string, |
| 2280 | 2273 | className?: string, |
| ... | ... | @@ -2339,433 +2332,507 @@ function ChatMarkdown({ |
| 2339 | 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 | ); |
| 2343 | 2350 | |
| 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 | |
| 2406 | const ChatMarkdownRendererContext = React.createContext< |
| 2407 | ReturnType<typeof useChatMarkdownState>["componentState"] |
| 2408 | >(null!); |
| 2409 | |
| 2410 | // Keep component types stable when streaming changes the message state. |
| 2411 | const 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, |
| 2418 | 2586 | ); |
| 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 | ); |
| 2477 | 2618 | } |
| 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); |
| 2486 | 2625 | 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" |
| 2494 | 2628 | ) { |
| 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 | }), |
| 2506 | 2638 | ); |
| 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 | } |
| 2590 | 2668 | |
| 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 | 2686 | return fileLinkChip( |
| 2592 | 2687 | fileLinkMeta, |
| 2593 | | `[${fileLinkMeta.basename}](${normalizedHref})`, |
| 2594 | | props.className, |
| 2595 | | normalizedHref, |
| 2688 | `\`${codeText}\``, |
| 2689 | undefined, |
| 2690 | inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), |
| 2596 | 2691 | ); |
| 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") { |
| 2613 | 2728 | 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 | /> |
| 2617 | 2737 | ); |
| 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 | } |
| 2713 | 2796 | |
| 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; |
| 2768 | 2820 | |
| 2821 | function 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 | 2836 | const remarkPlugins = useMemo( |
| 2770 | 2837 | () => [ |
| 2771 | 2838 | ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), |
| ... | ... | @@ -2785,15 +2852,17 @@ function ChatMarkdown({ |
| 2785 | 2852 | )} |
| 2786 | 2853 | onCopy={handleCopy} |
| 2787 | 2854 | > |
| 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> |
| 2797 | 2866 | {localMediaPreview ? ( |
| 2798 | 2867 | <ExpandedImageDialog |
| 2799 | 2868 | preview={localMediaPreview} |