1import { MarkdownFindContext, useFindRevealRef } from "./chat/markdownFindContext";
2import {
3 buildFileLinkParentSuffixByPath,
4 fileLinkLabel,
5 resolvePathLinkTarget,
6} from "@t3tools/shared/fileLinks";
7import {
8 isWindowsDrivePathHref,
9 normalizeMarkdownLinkDestination,
10 extractInlineCodeSpans,
11 extractMarkdownLinkHrefs,
12 inlineCodeFilePathCandidate,
13} from "@t3tools/shared/markdownLinks";
14import { isAbsolutePath } from "@t3tools/shared/path";
15import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
16import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";
17import {
18 CHAT_MARKDOWN_REMARK_PLUGINS,
19 CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
20 CHAT_MARKDOWN_REHYPE_PLUGINS,
21} from "@t3tools/shared/markdownPipeline";
22import { useAtomValue } from "@effect/atom-react";
23import {
24 COMPOSER_CONTEXT_CLIPBOARD_MIME,
25 encodeComposerContextClipboardHtml,
26} from "@t3tools/shared/composerContextClipboard";
27import {
28 ChevronRightIcon,
29 CodeIcon,
30 FileSpreadsheetIcon,
31 FileTextIcon,
32 GlobeIcon,
33 ImageIcon,
34 InfoIcon,
35 LightbulbIcon,
36 MailIcon,
37 MessageSquareIcon,
38 MessageSquareWarningIcon,
39 OctagonAlertIcon,
40 PlayIcon,
41 PresentationIcon,
42 SparklesIcon,
43 TriangleAlertIcon,
44 WorkflowIcon,
45 WrapTextIcon,
46 type LucideIcon,
47} from "lucide-react";
48import { Check, Copy, Maximize2, Minimize2 } from "lucide";
49import {
50 AuthPreviewOperateScope,
51 type AssetResource,
52 type EnvironmentId,
53 type ScopedThreadRef,
54 type ServerProviderSkill,
55 type ThreadPullRequestKey,
56} from "@t3tools/contracts";
57import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
58import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
59import {
60 isAtomCommandInterrupted,
61 squashAtomCommandFailure,
62 type AtomCommandResult,
63} from "@t3tools/client-runtime/state/runtime";
64import {
65 codexArtifactTemplatePresentationLabel,
66 type CodexArtifactTemplate,
67 type CodexArtifactTemplateKind,
68} from "@t3tools/shared/codexArtifactTemplates";
69import {
70 classifyMarkdownImageSource,
71 markdownImageSourceFragment,
72} from "@t3tools/client-runtime/markdown-images";
73import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
74import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
75import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
76import * as Cause from "effect/Cause";
77import { AsyncResult } from "effect/reactivity";
78import React, {
79 Children,
80 Suspense,
81 type CSSProperties,
82 type ComponentProps,
83 type ClipboardEvent as ReactClipboardEvent,
84 type KeyboardEvent as ReactKeyboardEvent,
85 type MouseEvent as ReactMouseEvent,
86 isValidElement,
87 use,
88 useCallback,
89 memo,
90 useEffect,
91 useMemo,
92 useRef,
93 useState,
94 type ReactNode,
95} from "react";
96import type {
97 Components,
98 ExtraProps as ReactMarkdownExtraProps,
99 Options as ReactMarkdownOptions,
100} from "react-markdown";
101import ReactMarkdown from "react-markdown";
102import { toHtml } from "hast-util-to-html";
103import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
104import { defaultUrlTransform } from "react-markdown";
105import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
106import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
107import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
108import { AssistantCitationChip } from "./chat/AssistantCitationChip";
109import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
110import {
111 artifactTemplateFromHastProperties,
112 renderCodexInlineDirectivesAsMarkdown,
113} from "@t3tools/shared/codexMarkdownDirectives";
114import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
115import {
116 resolveMarkdownMediaPreview,
117 type ExpandedImagePreview,
118} from "./chat/ExpandedImagePreview";
119import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
120import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery";
121import { MediaVideoPlayer } from "./media/MediaVideoPlayer";
122import { MediaActions, type MediaActionSource } from "./media/MediaActions";
123import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";
124import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon";
125import { FileTagChipContent } from "./chat/FileTagChip";
126import { PierreEntryIcon } from "./chat/PierreEntryIcon";
127import {
128 revealInFileExplorerLabelForKind,
129 revealInFileExplorerLabelForOs,
130} from "./preview/fileExplorerLabel";
131import {
132 resolveExternalWebLinkHost,
133 showExternalLinkContextMenu,
134} from "./chat/externalLinkContextMenu";
135import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";
136import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
137import { Button } from "./ui/button";
138import { MorphIcon } from "~/components/MorphIcon";
139import { ContextChip } from "./ContextChip";
140import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";
141import { ScrollArea } from "./ui/scroll-area";
142import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
143import { stackedThreadToast, toastManager } from "./ui/toast";
144import { recordVisitForThread } from "../browserHistoryStore";
145import {
146 PreferredEditorEnvironmentRequiredError,
147 useOpenInPreferredEditor,
148 usePreferredEditor,
149} from "../editorPreferences";
150import { openInEditorMenuLabel } from "../editorLabels";
151import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
152import { fnv1a32 } from "../lib/diffRendering";
153import { LRUCache } from "../lib/lruCache";
154import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
155import { GitHubIcon } from "./Icons";
156import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting";
157import { HighlightedCodeLines } from "./chat/HighlightedCodeLines";
158import { RenderErrorBoundary } from "./RenderErrorBoundary";
159import { MermaidDiagram } from "./chat/MermaidDiagram";
160import { useTheme } from "../hooks/useTheme";
161import { getClientSettings, useClientSettings } from "../hooks/useSettings";
162import {
163 chatMarkdownClipboardPayload,
164 serializeTableElementToCsv,
165 serializeTableElementToMarkdown,
166} from "../markdown-clipboard";
167import {
168 resolveInlineCodeFileLinkMeta,
169 resolveMarkdownFileLinkMeta,
170 rewriteMarkdownFileUriHref,
171 shouldOpenMarkdownFileLinkInBrowserByDefault,
172 shouldOpenMarkdownFileLinkInEditor,
173 type MarkdownFileLinkMeta,
174} from "../markdown-links";
175import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
176import { isElectron } from "../env";
177import { readLocalApi } from "../localApi";
178import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";
179import { cn } from "../lib/utils";
180import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";
181import { useRightPanelStore } from "../rightPanelStore";
182import { readThreadShell, useProjects } from "../state/entities";
183import { serverEnvironment } from "../state/server";
184import { shellEnvironment } from "../state/shell";
185import { assetEnvironment } from "../state/assets";
186import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session";
187import { previewEnvironment } from "../state/preview";
188import { useAtomCommand } from "../state/use-atom-command";
189import { useAtomQueryRunner } from "../state/use-atom-query-runner";
190import { projectEnvironment } from "../state/projects";
191import {
192 claimWorkspaceBasenameLookup,
193 needsWorkspaceBasenameLookup,
194 pickWorkspaceBasenameMatch,
195 WORKSPACE_BASENAME_LOOKUP_LIMIT,
196} from "../workspaceBasenameLookup";
197import {
198 parseChangeRequestUrl,
199 pullRequestCandidateUrlFromReferenceAutolink,
200 resolvePullRequestPreviewTarget,
201 useOpenChangeRequestLink,
202} from "~/lib/openPullRequestLink";
203import { useOpenLink } from "../browser/useOpenLink";
204import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
205import { isPreviewAvailableFor } from "../browser/previewRuntime";
206import {
207 isBrowserPreviewFile,
208 openFileInPreview,
209 openUrlInPreview,
210 BrowserPreviewUnavailableError,
211 BrowserSettingsReadError,
212} from "../browser/openFileInPreview";
213import { resolveLinkTarget } from "../browser/browserLinkTarget";
214import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
215
216interface ChatMarkdownProps {
217 text: string;
218 cwd: string | undefined;
219 threadRef?: ScopedThreadRef | undefined;
220 /** Panel that receives pull request links, including the standalone PR view. */
221 pullRequestPanelRef?: ScopedThreadRef | undefined;
222 /** Environment that owns non-thread markdown, such as a pull request panel. */
223 environmentId?: EnvironmentId | undefined;
224 onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;
225 isStreaming?: boolean;
226 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
227 className?: string;
228 /** Treat single newlines as hard breaks — chat-style user input. */
229 lineBreaks?: boolean;
230 /** Parse sanitized raw HTML instead of displaying its source text. */
231 parseRawHtml?: boolean;
232 /** Append a prompt that invokes a newly created artifact-template skill. */
233 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
234 onRunShellCommand?: ((command: string) => void) | undefined;
235 /** Directory that anchors relative links and images; defaults to `cwd`. Set
236 to the file's own directory when rendering a markdown file. */
237 imageBaseDir?: string | undefined;
238 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
239 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
240 /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */
241 renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;
242 /** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's
243 uploads need; without it those images and videos load unauthenticated and 404. */
244 githubMedia?: boolean | undefined;
245 /** Levels added to each markdown heading in the accessibility tree so the
246 text nests under the heading that introduces it, such as a chat message's
247 author. Rendered tags and their styling are unchanged. */
248 headingLevelOffset?: number | undefined;
249}
250
251export interface ChatMarkdownContextReference {
252 kind: string;
253 contextId: string;
254 label: string;
255}
256
257export function canUseMarkdownFileShellActions(
258 environmentId: EnvironmentId | null,
259 remoteOpenMode: RemoteOpenMode,
260 isRemoteOpenResolved: boolean,
261): boolean {
262 return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec";
263}
264
265export function hasMarkdownFilePrimaryAction(input: {
266 canOpenInEditor: boolean;
267 canOpenInBrowser: boolean;
268 canOpenInPanel: boolean;
269 canOpenMedia?: boolean;
270}): boolean {
271 return (
272 input.canOpenInEditor ||
273 input.canOpenInBrowser ||
274 input.canOpenInPanel ||
275 input.canOpenMedia === true
276 );
277}
278
279export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
280 iconPath: string;
281 canOpenInEditor: boolean;
282 canOpenInBrowser: boolean;
283 canOpenInPanel: boolean;
284}): boolean {
285 return (
286 input.canOpenInBrowser &&
287 (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) ||
288 (!input.canOpenInEditor && !input.canOpenInPanel))
289 );
290}
291
292const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
293const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];
294
295const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
296 document: FileTextIcon,
297 presentation: PresentationIcon,
298 spreadsheet: FileSpreadsheetIcon,
299 site: GlobeIcon,
300 "google-docs": FileTextIcon,
301 "google-slides": PresentationIcon,
302 "google-sheets": FileSpreadsheetIcon,
303 image: ImageIcon,
304 email: MailIcon,
305 slack: MessageSquareIcon,
306} satisfies Record<CodexArtifactTemplateKind, LucideIcon>;
307
308function CodexArtifactTemplateCard(props: {
309 readonly template: CodexArtifactTemplate;
310 readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined;
311}) {
312 const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind];
313 const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind);
314
315 return (
316 <div
317 role="group"
318 aria-label={`${props.template.displayName} template`}
319 data-chat-markdown-artifact-template
320 className="my-[0.65rem] flex w-full min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card/60 px-3 py-2.5 text-foreground shadow-xs"
321 data-artifact-kind={props.template.artifactKind}
322 data-markdown-copy={`${props.template.displayName} (${presentationLabel})\n\n`}
323 data-skill-name={props.template.skillName}
324 >
325 <div className="flex min-w-0 flex-1 items-center gap-3">
326 <span className="relative flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-background text-muted-foreground shadow-xs">
327 <Icon aria-hidden className="size-5" />
328 <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground shadow-xs">
329 <SparklesIcon aria-hidden className="size-2.5" />
330 </span>
331 </span>
332 {/* Block elements keep the name and label separate thread-find segments. */}
333 <div className="min-w-0">
334 <div className="truncate text-sm font-medium text-foreground">
335 {props.template.displayName}
336 </div>
337 <div className="text-xs text-muted-foreground">{presentationLabel}</div>
338 </div>
339 </div>
340 {props.onUse ? (
341 <Button
342 data-thread-find-ignore
343 type="button"
344 size="sm"
345 variant="outline"
346 className="shrink-0"
347 onClick={() => props.onUse?.(props.template)}
348 >
349 Use template
350 </Button>
351 ) : null}
352 </div>
353 );
354}
355
356const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
357const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
358const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
359const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
360
361interface MarkdownActionFailureContext {
362 readonly operation: string;
363 readonly target?: string;
364 readonly format?: "markdown" | "csv";
365 readonly language?: string;
366 readonly fenceTitle?: string;
367 readonly copyTarget?: string;
368}
369
370function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void {
371 console.error("[chat-markdown] action failed", context, cause);
372}
373
374const highlightedCodeCache = new LRUCache<string>(
375 MAX_HIGHLIGHT_CACHE_ENTRIES,
376 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
377);
378
379function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null {
380 const firstLineEnd = markdown.indexOf("\n", listItemStart);
381 const firstLine = markdown.slice(
382 listItemStart,
383 firstLineEnd === -1 ? markdown.length : firstLineEnd,
384 );
385 const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/);
386 if (!match?.[1]) return null;
387 return listItemStart + firstLine.indexOf(match[1]);
388}
389
390/**
391 * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits one-character
392 * markers. Wider markers can extend past it and get clipped by a collapsed
393 * message's overflow. Widen the gutter to fit the widest marker, including a
394 * negative marker's minus sign, the period, and the trailing space.
395 */
396function orderedListGutterStyle(
397 itemCount: number,
398 start: unknown,
399): { "--list-gutter": string } | undefined {
400 const parsedStart = Number.parseInt(String(start ?? 1), 10);
401 const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart;
402 const lastNumber = firstNumber + Math.max(itemCount - 1, 0);
403 const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length);
404 if (markerWidth <= 1) return undefined;
405 return { "--list-gutter": `${markerWidth + 2}ch` };
406}
407
408const GITHUB_ALERT_PRESENTATIONS: Record<
409 string,
410 { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string }
411> = {
412 note: {
413 label: "Note",
414 Icon: InfoIcon,
415 borderClassName: "border-blue-500/70",
416 titleClassName: "text-blue-600 dark:text-blue-400",
417 },
418 tip: {
419 label: "Tip",
420 Icon: LightbulbIcon,
421 borderClassName: "border-emerald-500/70",
422 titleClassName: "text-emerald-600 dark:text-emerald-400",
423 },
424 important: {
425 label: "Important",
426 Icon: MessageSquareWarningIcon,
427 borderClassName: "border-purple-500/70",
428 titleClassName: "text-purple-600 dark:text-purple-400",
429 },
430 warning: {
431 label: "Warning",
432 Icon: TriangleAlertIcon,
433 borderClassName: "border-amber-500/70",
434 titleClassName: "text-amber-600 dark:text-amber-500",
435 },
436 caution: {
437 label: "Caution",
438 Icon: OctagonAlertIcon,
439 borderClassName: "border-red-500/70",
440 titleClassName: "text-red-600 dark:text-red-400",
441 },
442};
443
444function extractFenceLanguage(className: string | undefined): string {
445 const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
446 const raw = match?.[1] ?? "text";
447 // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685)
448 return raw === "gitignore" ? "ini" : raw;
449}
450
451const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i;
452const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/;
453
454/** Pulls a filename out of fence meta: ```ts title="x.ts" / ```ts src/main.ts */
455function extractFenceTitle(meta: string | undefined): string | null {
456 if (!meta) return null;
457 const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta);
458 const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3];
459 if (attrTitle) return attrTitle;
460 return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null;
461}
462
463function extractPreCodeMeta(node: unknown): string | undefined {
464 const children = (
465 node as
466 | {
467 children?: Array<{
468 type?: string;
469 tagName?: string;
470 data?: { meta?: unknown };
471 properties?: { dataCodeMeta?: unknown };
472 }>;
473 }
474 | undefined
475 )?.children;
476 const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code");
477 const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta;
478 return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined;
479}
480
481function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): boolean {
482 const start = node?.position?.start.offset;
483 const end = node?.position?.end.offset;
484 if (start === undefined || end === undefined) return false;
485 const source = text.slice(start, end);
486 const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0];
487 // One class for the blockquote prefix: nested quantifiers here backtrack
488 // exponentially on code lines that start with many `> ` markers.
489 const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1];
490 return (
491 opening !== undefined &&
492 closing !== undefined &&
493 opening[0] === closing[0] &&
494 closing.length >= opening.length
495 );
496}
497
498function nodeToPlainText(node: ReactNode): string {
499 if (typeof node === "string" || typeof node === "number") {
500 return String(node);
501 }
502 if (Array.isArray(node)) {
503 return node.map((child) => nodeToPlainText(child)).join("");
504 }
505 if (isValidElement<{ children?: ReactNode }>(node)) {
506 return nodeToPlainText(node.props.children);
507 }
508 return "";
509}
510
511function extractCodeBlock(
512 children: ReactNode,
513): { className: string | undefined; code: string } | null {
514 const childNodes = Children.toArray(children);
515 if (childNodes.length !== 1) {
516 return null;
517 }
518
519 const onlyChild = childNodes[0];
520 if (
521 !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>(
522 onlyChild,
523 )
524 ) {
525 return null;
526 }
527 // With a custom `code` component the child's type is that component, not
528 // the "code" tag — the hast node react-markdown attaches still names it.
529 if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") {
530 return null;
531 }
532
533 return {
534 className: onlyChild.props.className,
535 code: nodeToPlainText(onlyChild.props.children),
536 };
537}
538
539function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string {
540 return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`;
541}
542
543function estimateHighlightedSize(html: string, code: string): number {
544 return Math.max(html.length * 2, code.length * 3);
545}
546
547function readInitialWordWrapSetting(): boolean {
548 return getClientSettings().wordWrap;
549}
550
551function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
552 const containerRef = useRef<HTMLDivElement | null>(null);
553 const tableRef = useRef<HTMLTableElement | null>(null);
554 const [expanded, setExpanded] = useState(readInitialWordWrapSetting);
555 const [copied, setCopied] = useState(false);
556 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
557 const expandLabel = expanded ? "Collapse table cells" : "Expand table cells";
558 const copyLabel = copied ? "Copied" : "Copy table";
559
560 function toggleExpanded() {
561 const table = tableRef.current;
562 if (!table) return;
563
564 if (!expanded) {
565 const rows = [...table.rows];
566 const columnWidths = rows.reduce<number[]>((widths, row) => {
567 [...row.cells].forEach((cell, columnIndex) => {
568 widths[columnIndex] = Math.max(
569 widths[columnIndex] ?? 0,
570 cell.getBoundingClientRect().width,
571 );
572 });
573 return widths;
574 }, []);
575
576 [...(table.tHead?.rows[0]?.cells ?? [])].forEach((cell, columnIndex) => {
577 cell.style.minWidth = `${columnWidths[columnIndex] ?? cell.getBoundingClientRect().width}px`;
578 });
579 }
580
581 setExpanded((value) => !value);
582 }
583
584 const handleCopy = useCallback((format: "markdown" | "csv") => {
585 const table = containerRef.current?.querySelector("table");
586 if (!table || typeof navigator === "undefined" || navigator.clipboard == null) {
587 return;
588 }
589 const text =
590 format === "markdown"
591 ? serializeTableElementToMarkdown(table)
592 : serializeTableElementToCsv(table);
593 void navigator.clipboard
594 .writeText(text)
595 .then(() => {
596 if (copiedTimerRef.current != null) {
597 clearTimeout(copiedTimerRef.current);
598 }
599 setCopied(true);
600 copiedTimerRef.current = setTimeout(() => {
601 setCopied(false);
602 copiedTimerRef.current = null;
603 }, 1200);
604 })
605 .catch((cause) => {
606 reportMarkdownActionFailure({ operation: "copy-table", format }, cause);
607 });
608 }, []);
609
610 useEffect(
611 () => () => {
612 if (copiedTimerRef.current != null) {
613 clearTimeout(copiedTimerRef.current);
614 copiedTimerRef.current = null;
615 }
616 },
617 [],
618 );
619
620 return (
621 <div
622 ref={containerRef}
623 className="chat-markdown-table-container"
624 data-expanded={expanded ? "true" : "false"}
625 >
626 <ScrollArea radius="none" chainVerticalScroll scrollFade className="w-full max-w-full">
627 <table ref={tableRef} {...props}>
628 {children}
629 </table>
630 </ScrollArea>
631 <div className="mt-0.5 flex items-center justify-between select-none">
632 <Tooltip>
633 <TooltipTrigger
634 render={
635 <Button
636 type="button"
637 variant={expanded ? "secondary" : "ghost-muted"}
638 size="icon-xs"
639 aria-pressed={expanded}
640 onClick={toggleExpanded}
641 aria-label={expandLabel}
642 />
643 }
644 >
645 <MorphIcon className="size-3" icon={expanded ? Minimize2 : Maximize2} />
646 </TooltipTrigger>
647 <TooltipPopup side="top">{expandLabel}</TooltipPopup>
648 </Tooltip>
649 <Menu>
650 <Tooltip>
651 <TooltipTrigger
652 render={
653 <MenuTrigger
654 render={
655 <Button
656 type="button"
657 variant="ghost-muted"
658 size="icon-xs"
659 aria-label={copyLabel}
660 />
661 }
662 />
663 }
664 >
665 <MorphIcon className="size-3" icon={copied ? Check : Copy} />
666 </TooltipTrigger>
667 <TooltipPopup side="top">{copyLabel}</TooltipPopup>
668 </Tooltip>
669 <MenuPopup align="end">
670 <MenuItem onClick={() => handleCopy("markdown")}>Copy as Markdown</MenuItem>
671 <MenuItem onClick={() => handleCopy("csv")}>Copy as CSV</MenuItem>
672 </MenuPopup>
673 </Menu>
674 </div>
675 </div>
676 );
677}
678
679function MarkdownDetails({
680 children,
681 open = false,
682}: Pick<React.ComponentProps<"details">, "children" | "open">) {
683 const [isOpen, setIsOpen] = useState(open);
684 const searching = use(MarkdownFindContext);
685 const expanded = isOpen;
686 const revealForFind = useCallback(() => setIsOpen(true), []);
687 // Base UI only listens for `beforematch` on a panel mounted at its first
688 // render; a closed panel mounts later, when find starts, so listen here.
689 const findRevealRef = useFindRevealRef(revealForFind);
690 const childNodes = Children.toArray(children);
691 const summaryIndex = childNodes.findIndex(
692 (child) => isValidElement(child) && child.type === "summary",
693 );
694 const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null;
695 const summary =
696 isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children
697 ? summaryNode.props.children
698 : "Details";
699 const content = childNodes.filter((_, index) => index !== summaryIndex);
700
701 return (
702 <div className="my-2 border-y border-border/60">
703 <Collapsible
704 open={expanded}
705 onOpenChange={setIsOpen}
706 data-markdown-details=""
707 data-markdown-details-open={expanded ? "true" : "false"}
708 >
709 <CollapsibleTrigger
710 className="flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90"
711 data-markdown-details-summary=""
712 >
713 <ChevronRightIcon
714 className="size-4 shrink-0 text-muted-foreground transition-transform"
715 aria-hidden
716 />
717 <span>{summary}</span>
718 </CollapsibleTrigger>
719 <CollapsiblePanel ref={findRevealRef} hiddenUntilFound={searching}>
720 <div
721 className="pb-3 ps-6 text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)]"
722 data-markdown-details-content=""
723 >
724 {content}
725 </div>
726 </CollapsiblePanel>
727 </Collapsible>
728 </div>
729 );
730}
731
732/**
733 * The code block frame, shared with the composer so an editable fence and a
734 * rendered one cannot drift apart: the wrapper, its header row with the
735 * language title, an optional actions slot, and the body as children. The
736 * composer passes Tiptap's node view wrapper as the outer element.
737 */
738const THREAD_FIND_IGNORE_PROPS = {
739 "data-thread-find-ignore": "",
740} as React.HTMLAttributes<HTMLDivElement>;
741
742export function MarkdownCodeBlockFrame({
743 as: Wrapper = "div",
744 language,
745 fenceTitle,
746 theme,
747 wrapped = true,
748 title,
749 actions,
750 headerProps,
751 children,
752}: {
753 as?: React.ElementType;
754 language: string;
755 fenceTitle: string | null;
756 theme: "light" | "dark";
757 wrapped?: boolean;
758 /** Replaces the language title, as the composer does with its language picker. */
759 title?: React.ReactNode;
760 actions?: React.ReactNode;
761 headerProps?: React.HTMLAttributes<HTMLDivElement>;
762 children: React.ReactNode;
763}) {
764 return (
765 <Wrapper
766 className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"
767 data-language={language}
768 data-wrap={wrapped ? "true" : "false"}
769 >
770 <div
771 {...headerProps}
772 className={cn(
773 "chat-markdown-codeblock-header flex items-center justify-between gap-2 pt-1.5 pr-1.5 pb-0 pl-3 select-none",
774 headerProps?.className,
775 )}
776 >
777 {title ?? (
778 <span className="inline-flex min-w-0 items-center gap-1.5 font-mono text-2xs">
779 <MarkdownCodeBlockTitleContent
780 fenceTitle={fenceTitle}
781 language={language}
782 theme={theme}
783 />
784 </span>
785 )}
786 {actions}
787 </div>
788 {children}
789 </Wrapper>
790 );
791}
792
793/**
794 * Filename titles render icon + text; language-only titles render just the
795 * icon (redundant next to its own name) and fall back to the language text
796 * when no specific icon exists or it fails to load.
797 */
798function MarkdownCodeBlockTitleContent({
799 fenceTitle,
800 language,
801 theme,
802}: {
803 fenceTitle: string | null;
804 language: string;
805 theme: "light" | "dark";
806}) {
807 if (fenceTitle) {
808 return (
809 <>
810 <PierreEntryIcon pathValue={fenceTitle} kind="file" theme={theme} className="size-3.5" />
811 <span className="truncate">{fenceTitle}</span>
812 </>
813 );
814 }
815
816 const fileName = syntheticFileNameForLanguageId(language);
817 if (!hasSpecificPierreIconForFileName(fileName)) {
818 return <span className="truncate">{language}</span>;
819 }
820 return (
821 <Tooltip>
822 <TooltipTrigger
823 render={
824 <span className="inline-flex shrink-0 rounded-sm" aria-label={`Language: ${language}`} />
825 }
826 >
827 <PierreEntryIcon pathValue={fileName} kind="file" theme={theme} className="size-3.5" />
828 </TooltipTrigger>
829 <TooltipPopup side="top">{language}</TooltipPopup>
830 </Tooltip>
831 );
832}
833
834function MarkdownCodeBlock({
835 code,
836 language,
837 fenceTitle,
838 theme,
839 onRunShellCommand,
840 isStreaming,
841 viewToggle,
842 canWrap = true,
843 diagram = false,
844 children,
845}: {
846 code: string;
847 language: string;
848 fenceTitle: string | null;
849 theme: "light" | "dark";
850 onRunShellCommand?: ((command: string) => void) | undefined;
851 isStreaming: boolean;
852 /** Switches between source and rendered content; sits left of copy in both views. */
853 viewToggle?: ReactNode;
854 canWrap?: boolean;
855 /** Renders content frameless, with the code header's actions above it. */
856 diagram?: boolean;
857 children: ReactNode;
858}) {
859 const [copied, setCopied] = useState(false);
860 const [wrapped, setWrapped] = useState(readInitialWordWrapSetting);
861 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
862 const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
863 const copyLabel = copied ? "Copied" : "Copy code";
864 const command = code.trim();
865 const canRun =
866 onRunShellCommand !== undefined &&
867 !isStreaming &&
868 /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) &&
869 code.endsWith("\n") &&
870 command.length > 0 &&
871 !command.endsWith("\\") &&
872 // Control and invisible format characters (bidi overrides, zero-width) can
873 // make the rendered command differ from what the terminal would receive.
874 !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1));
875
876 const handleCopy = useCallback(() => {
877 if (typeof navigator === "undefined" || navigator.clipboard == null) {
878 return;
879 }
880 void navigator.clipboard
881 .writeText(code)
882 .then(() => {
883 if (copiedTimerRef.current != null) {
884 clearTimeout(copiedTimerRef.current);
885 }
886 setCopied(true);
887 copiedTimerRef.current = setTimeout(() => {
888 setCopied(false);
889 copiedTimerRef.current = null;
890 }, 1200);
891 })
892 .catch((cause) => {
893 reportMarkdownActionFailure(
894 {
895 operation: "copy-code-block",
896 language,
897 ...(fenceTitle ? { fenceTitle } : {}),
898 },
899 cause,
900 );
901 });
902 }, [code, fenceTitle, language]);
903
904 useEffect(
905 () => () => {
906 if (copiedTimerRef.current != null) {
907 clearTimeout(copiedTimerRef.current);
908 copiedTimerRef.current = null;
909 }
910 },
911 [],
912 );
913
914 const copyButton = (
915 <Tooltip>
916 <TooltipTrigger
917 render={
918 <Button
919 type="button"
920 variant="ghost-muted"
921 size="icon-xs"
922 onClick={handleCopy}
923 aria-label={copyLabel}
924 />
925 }
926 >
927 <MorphIcon className="size-3" icon={copied ? Check : Copy} />
928 </TooltipTrigger>
929 <TooltipPopup side="top">{copyLabel}</TooltipPopup>
930 </Tooltip>
931 );
932
933 if (diagram) {
934 return (
935 // The transparent border and header padding match the code frame, so the
936 // actions stay in place when switching views.
937 <div className="my-[0.65rem] border border-transparent" data-language={language}>
938 <div className="flex items-center justify-end gap-0.5 pt-1.5 pr-1.5 select-none">
939 {viewToggle}
940 {copyButton}
941 </div>
942 {children}
943 </div>
944 );
945 }
946
947 return (
948 <MarkdownCodeBlockFrame
949 language={language}
950 fenceTitle={fenceTitle}
951 theme={theme}
952 wrapped={wrapped}
953 // Find does not count the header, so it must not highlight it either.
954 headerProps={THREAD_FIND_IGNORE_PROPS}
955 actions={
956 <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">
957 {canWrap ? (
958 <Tooltip>
959 <TooltipTrigger
960 render={
961 <Button
962 type="button"
963 variant={wrapped ? "secondary" : "ghost-muted"}
964 size="icon-xs"
965 aria-pressed={wrapped}
966 onClick={() => setWrapped((value) => !value)}
967 aria-label={wrapLabel}
968 />
969 }
970 >
971 <WrapTextIcon className="size-3" />
972 </TooltipTrigger>
973 <TooltipPopup side="top">{wrapLabel}</TooltipPopup>
974 </Tooltip>
975 ) : null}
976 {canRun ? (
977 <Tooltip>
978 <TooltipTrigger
979 render={
980 <Button
981 type="button"
982 variant="ghost-muted"
983 size="icon-xs"
984 onClick={() => onRunShellCommand(command)}
985 aria-label="Run in terminal"
986 />
987 }
988 >
989 <PlayIcon className="size-3" />
990 </TooltipTrigger>
991 <TooltipPopup side="top">Run in terminal</TooltipPopup>
992 </Tooltip>
993 ) : null}
994 {viewToggle}
995 {copyButton}
996 </span>
997 }
998 >
999 {children}
1000 </MarkdownCodeBlockFrame>
1001 );
1002}
1003
1004/**
1005 * Mermaid fences render as a diagram once the response settles; streaming and
1006 * the code toggle keep the highlighted source.
1007 */
1008function MarkdownMermaidCodeBlock({
1009 code,
1010 fenceTitle,
1011 theme,
1012 isStreaming,
1013 onExpand,
1014 children,
1015}: {
1016 code: string;
1017 fenceTitle: string | null;
1018 theme: "light" | "dark";
1019 isStreaming: boolean;
1020 onExpand: (imageUrl: string) => void;
1021 children: ReactNode;
1022}) {
1023 const [showCode, setShowCode] = useState(false);
1024 const searching = use(MarkdownFindContext);
1025 const revealSource = useCallback(() => setShowCode(true), []);
1026 const sourceRevealRef = useFindRevealRef(revealSource);
1027 const showDiagram = !showCode && !isStreaming && code.trim().length > 0;
1028 const toggleLabel = showCode ? "Show diagram" : "Show code";
1029 return (
1030 <MarkdownCodeBlock
1031 code={code}
1032 language="mermaid"
1033 fenceTitle={fenceTitle}
1034 theme={theme}
1035 isStreaming={isStreaming}
1036 canWrap={!showDiagram}
1037 diagram={showDiagram}
1038 viewToggle={
1039 isStreaming ? null : (
1040 <Tooltip>
1041 <TooltipTrigger
1042 render={
1043 <Button
1044 type="button"
1045 variant="ghost-muted"
1046 size="icon-xs"
1047 onClick={() => setShowCode((value) => !value)}
1048 aria-label={toggleLabel}
1049 />
1050 }
1051 >
1052 {showCode ? <WorkflowIcon className="size-3" /> : <CodeIcon className="size-3" />}
1053 </TooltipTrigger>
1054 <TooltipPopup side="top">{toggleLabel}</TooltipPopup>
1055 </Tooltip>
1056 )
1057 }
1058 >
1059 {showDiagram ? (
1060 <RenderErrorBoundary resetKeys={[code, theme]} fallback={children}>
1061 <Suspense
1062 fallback={
1063 <div className="flex min-h-36 items-center justify-center text-xs text-muted-foreground">
1064 Rendering diagram
1065 </div>
1066 }
1067 >
1068 <MermaidDiagram source={code} theme={theme} onExpand={onExpand} />
1069 </Suspense>
1070 {/* Find counts the source; a selected match here switches to it. */}
1071 {searching ? (
1072 <pre ref={sourceRevealRef} hidden>
1073 {code}
1074 </pre>
1075 ) : null}
1076 </RenderErrorBoundary>
1077 ) : (
1078 children
1079 )}
1080 </MarkdownCodeBlock>
1081 );
1082}
1083
1084interface SuspenseShikiCodeBlockProps {
1085 className: string | undefined;
1086 code: string;
1087 themeName: DiffThemeName;
1088 isStreaming: boolean;
1089}
1090
1091function SuspenseShikiCodeBlock({
1092 className,
1093 code,
1094 themeName,
1095 isStreaming,
1096}: SuspenseShikiCodeBlockProps) {
1097 const [hasStreamed, setHasStreamed] = useState(isStreaming);
1098 if (isStreaming && !hasStreamed) setHasStreamed(true);
1099 const language = extractFenceLanguage(className);
1100 const cacheKey = createHighlightCacheKey(code, language, themeName);
1101 // Once lines are mounted individually, keep that renderer when streaming
1102 // finishes so switching to cached HTML cannot clear an existing selection.
1103 const cachedHighlightedHtml =
1104 !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null;
1105
1106 if (cachedHighlightedHtml != null) {
1107 return (
1108 <div
1109 className="chat-markdown-shiki"
1110 dangerouslySetInnerHTML={{ __html: cachedHighlightedHtml }}
1111 />
1112 );
1113 }
1114
1115 return (
1116 <UncachedShikiCodeBlock
1117 code={code}
1118 language={language}
1119 themeName={themeName}
1120 cacheKey={cacheKey}
1121 isStreaming={isStreaming}
1122 preserveLines={isStreaming || hasStreamed}
1123 />
1124 );
1125}
1126
1127interface UncachedShikiCodeBlockProps {
1128 code: string;
1129 language: string;
1130 themeName: DiffThemeName;
1131 cacheKey: string;
1132 isStreaming: boolean;
1133 preserveLines: boolean;
1134}
1135
1136function UncachedShikiCodeBlock({
1137 code,
1138 language,
1139 themeName,
1140 cacheKey,
1141 isStreaming,
1142 preserveLines,
1143}: UncachedShikiCodeBlockProps) {
1144 const highlighter = use(getSyntaxHighlighterPromise(language));
1145 const incrementalHighlight = useMemo(
1146 () =>
1147 preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null,
1148 [highlighter, preserveLines, language, themeName],
1149 );
1150 const highlighted = useMemo(() => {
1151 try {
1152 if (incrementalHighlight) return incrementalHighlight(code);
1153 return preserveLines
1154 ? highlighter.codeToHast(code, { lang: language, theme: themeName })
1155 : highlighter.codeToHtml(code, { lang: language, theme: themeName });
1156 } catch (error) {
1157 // Log highlighting failures for debugging while falling back to plain text
1158 console.warn(
1159 `Code highlighting failed for language "${language}", falling back to plain text.`,
1160 error instanceof Error ? error.message : error,
1161 );
1162 // If highlighting fails for this language, render as plain text
1163 return preserveLines
1164 ? highlighter.codeToHast(code, { lang: "text", theme: themeName })
1165 : highlighter.codeToHtml(code, { lang: "text", theme: themeName });
1166 }
1167 }, [code, highlighter, incrementalHighlight, language, preserveLines, themeName]);
1168
1169 useEffect(() => {
1170 if (!isStreaming) {
1171 const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted);
1172 highlightedCodeCache.set(
1173 cacheKey,
1174 highlightedHtml,
1175 estimateHighlightedSize(highlightedHtml, code),
1176 );
1177 }
1178 }, [cacheKey, code, highlighted, isStreaming]);
1179
1180 return typeof highlighted === "string" ? (
1181 <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlighted }} />
1182 ) : (
1183 <div className="chat-markdown-shiki">
1184 <HighlightedCodeLines root={highlighted} />
1185 </div>
1186 );
1187}
1188
1189interface MarkdownFileLinkProps {
1190 environmentId: EnvironmentId | null;
1191 href: string;
1192 targetPath: string;
1193 iconPath: string;
1194 displayPath: string;
1195 /** What the files panel opens: workspace-relative inside the workspace, the
1196 absolute host path outside it, null when the panel cannot show the file. */
1197 panelPath: string | null;
1198 line?: number | undefined;
1199 label: ReactNode;
1200 copyMarkdown: string;
1201 theme: "light" | "dark";
1202 threadRef?: ScopedThreadRef | undefined;
1203 onOpen?: ((targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1204 onOpenInPanel: (panelPath: string, line: number | undefined) => void;
1205 openInEditorMenuLabel: string;
1206 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1207 onOpenMedia?: (() => void) | undefined;
1208 onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
1209 /** Platform-specific menu label ("Reveal in Finder", ...); required for the
1210 reveal item to show. */
1211 revealLabel?: string | undefined;
1212}
1213
1214const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link";
1215
1216function normalizeMarkdownLinkHrefKey(href: string): string {
1217 const normalizedHref = normalizeMarkdownLinkDestination(href);
1218 const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
1219 return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref)
1220 ? rewrittenHref.replaceAll("\\", "/")
1221 : rewrittenHref;
1222}
1223
1224const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";
1225
1226/** Hosts whose favicon request already failed this session — skip straight to the globe. */
1227const failedFaviconHosts = new Set<string>();
1228
1229/** Sites whose brand mark (drawn in `currentColor`) replaces the fetched favicon so it follows the theme. */
1230function brandLinkIcon(host: string): typeof GitHubIcon | null {
1231 const hostname = host.toLowerCase();
1232 if (hostname === "github.com" || hostname.endsWith(".github.com")) return GitHubIcon;
1233 return null;
1234}
1235
1236const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
1237 const [failedHost, setFailedHost] = useState<string | null>(null);
1238 const BrandIcon = brandLinkIcon(host);
1239 const faviconUrl = BrandIcon ? null : faviconUrlForOrigin(`https://${host}`);
1240 return (
1241 <span
1242 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
1243 aria-hidden
1244 >
1245 {BrandIcon ? (
1246 <BrandIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
1247 ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? (
1248 <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
1249 ) : (
1250 <img
1251 src={faviconUrl}
1252 alt=""
1253 loading="lazy"
1254 draggable={false}
1255 className={cn(MARKDOWN_LINK_FAVICON_CLASS_NAME, "rounded-sm")}
1256 onError={() => {
1257 failedFaviconHosts.add(host);
1258 setFailedHost(host);
1259 }}
1260 />
1261 )}
1262 </span>
1263 );
1264});
1265
1266const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]";
1267const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn(
1268 "max-h-[30rem]",
1269 CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME,
1270);
1271const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!";
1272const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40";
1273const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn(
1274 "h-auto w-auto object-contain",
1275 CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
1276);
1277
1278function markdownImageCopy(alt: string, src: string, title: string | undefined): string {
1279 const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
1280 const titleSuffix =
1281 title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
1282 return `![${escapedAlt}](${src}${titleSuffix})`;
1283}
1284
1285/**
1286 * `maxHeightRem` folds a height cap into the width bound: `max-height` alone
1287 * would not feed back through `aspect-ratio` once `width` is definite, so a
1288 * tall image would keep a box wider than the picture it draws.
1289 */
1290function authoredImageSizeStyle(
1291 width: string | number | undefined,
1292 height: string | number | undefined,
1293 maxHeightRem = 30,
1294): CSSProperties | undefined {
1295 const parsedWidth = Number(width);
1296 const parsedHeight = Number(height);
1297 const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0;
1298 const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0;
1299 if (hasWidth && hasHeight) {
1300 return {
1301 width: parsedWidth,
1302 height: "auto",
1303 aspectRatio: `${parsedWidth} / ${parsedHeight}`,
1304 maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`,
1305 };
1306 }
1307 if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` };
1308 if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` };
1309 return undefined;
1310}
1311
1312const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
1313 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1314 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
1315);
1316const MarkdownLinkContext = React.createContext(false);
1317
1318function expandableMarkdownImageProps(
1319 onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined,
1320 alt: string,
1321) {
1322 if (!onImageExpand) return {};
1323 const previewName = alt.trim() || "image";
1324 const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => {
1325 if (event.currentTarget.closest("a")) return;
1326 event.preventDefault();
1327 event.stopPropagation();
1328 const item = markdownImageItems.get(event.currentTarget);
1329 if (item) onImageExpand(markdownImageGallery(event.currentTarget, item));
1330 };
1331 return {
1332 role: "button" as const,
1333 tabIndex: 0,
1334 "aria-label": `Preview ${previewName}`,
1335 onClick: expand,
1336 onKeyDown: (event: ReactKeyboardEvent) => {
1337 if (event.key === "Enter" || event.key === " ") expand(event);
1338 },
1339 };
1340}
1341
1342function ChatMarkdownMediaUnavailableLabel(props: {
1343 readonly alt: string;
1344 readonly kind?: "image" | "video" | undefined;
1345 /** Why the media is unavailable, when the cause is known. */
1346 readonly detail?: string | undefined;
1347}) {
1348 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
1349 return (
1350 // Find indexes no image text, so this fallback must not highlight either.
1351 <span data-thread-find-ignore className="inline-flex items-center gap-1.5">
1352 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
1353 {[label, props.alt, props.detail].filter(Boolean).join(" · ")}
1354 </span>
1355 );
1356}
1357
1358/** Inline chip for an image that sits in a line of text or can never load. */
1359function ChatMarkdownImageFallback(props: {
1360 readonly alt: string;
1361 readonly copyMarkdown?: string | undefined;
1362 readonly kind?: "image" | "video";
1363 readonly actionsSource?: MediaActionSource | undefined;
1364 readonly detail?: string | undefined;
1365}) {
1366 const content = (
1367 <span
1368 data-markdown-copy={props.copyMarkdown}
1369 className={cn(
1370 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1371 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",
1372 )}
1373 >
1374 <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} detail={props.detail} />
1375 </span>
1376 );
1377 return props.actionsSource ? (
1378 <MediaActions source={props.actionsSource}>{content}</MediaActions>
1379 ) : (
1380 content
1381 );
1382}
1383
1384const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn(
1385 "aspect-video w-full overflow-hidden bg-muted/60",
1386 CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME,
1387 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
1388);
1389
1390/**
1391 * A standalone image holds a 16:9 slot (or its authored size) until it has
1392 * decoded, and keeps that slot if it fails, so a timeline row moves at most
1393 * once: when the natural size arrives. A bare `<img>` is zero height until
1394 * then. Once decoded the image renders bare again so its box, hit area, and
1395 * alignment are exactly the image's own. Inline images (badges, icons in a
1396 * sentence) skip the slot: a placeholder taller than the image would move the
1397 * page more than the image does.
1398 *
1399 * Callers key this on the file's identity, not its URL: a re-signed URL for
1400 * the same file keeps the decoded image on screen while the new bytes arrive,
1401 * and a different file starts from the slot again.
1402 */
1403function ChatMarkdownImage(props: {
1404 /** Null while the URL is being resolved; the last decoded image stays up. */
1405 readonly src: string | null;
1406 readonly sourceFailed?: boolean | undefined;
1407 /** Why the source failed, when the cause is known. */
1408 readonly unavailableDetail?: string | undefined;
1409 readonly alt: string;
1410 readonly copyMarkdown: string | undefined;
1411 readonly standalone: boolean;
1412 readonly className?: string | undefined;
1413 readonly style?: CSSProperties | undefined;
1414 /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */
1415 readonly imageProps?:
1416 | Omit<ComponentProps<"img">, "src" | "alt" | "className" | "style">
1417 | undefined;
1418 readonly actionsSource: MediaActionSource;
1419 readonly originalUrl?: string | undefined;
1420 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1421}) {
1422 const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
1423 const [failedSrc, setFailedSrc] = useState<string | null>(null);
1424 const src = props.src ?? loadedSrc;
1425 const failed = props.sourceFailed === true || (src !== null && failedSrc === src);
1426 // A failure forgets the decoded image so the next URL loads behind the slot.
1427 const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null);
1428 // Cached images are complete before `onLoad` can fire.
1429 const markLoadedIfComplete = useCallback(
1430 (image: HTMLImageElement | null) => {
1431 if (!image) return;
1432 if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src);
1433 markdownImageItems.set(image, {
1434 src,
1435 name: props.alt.trim() || "image",
1436 actionsSource: props.actionsSource,
1437 ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}),
1438 });
1439 },
1440 [props.actionsSource, props.alt, props.originalUrl, src],
1441 );
1442 const imageEvents = (loadingSrc: string) => ({
1443 onLoad: () => {
1444 setLoadedSrc(loadingSrc);
1445 setFailedSrc(null);
1446 },
1447 onError: () => {
1448 setFailedSrc(loadingSrc);
1449 setLoadedSrc(null);
1450 },
1451 });
1452
1453 if (settled) {
1454 return (
1455 <MediaActions source={props.actionsSource}>
1456 <img
1457 {...props.imageProps}
1458 ref={markLoadedIfComplete}
1459 src={src}
1460 alt={props.alt}
1461 data-markdown-copy={props.copyMarkdown}
1462 decoding="async"
1463 draggable={false}
1464 className={cn(
1465 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
1466 props.className,
1467 props.onImageExpand && "cursor-zoom-in",
1468 )}
1469 style={props.style}
1470 {...expandableMarkdownImageProps(props.onImageExpand, props.alt)}
1471 {...imageEvents(src)}
1472 />
1473 </MediaActions>
1474 );
1475 }
1476 if (!props.standalone) {
1477 return failed ? (
1478 <ChatMarkdownImageFallback
1479 alt={props.alt}
1480 copyMarkdown={props.copyMarkdown}
1481 actionsSource={props.actionsSource}
1482 detail={props.unavailableDetail}
1483 />
1484 ) : (
1485 <span
1486 id={props.imageProps?.id}
1487 data-markdown-copy={props.copyMarkdown}
1488 role="status"
1489 aria-label="Loading image"
1490 className={CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME}
1491 />
1492 );
1493 }
1494 return (
1495 <MediaActions source={props.actionsSource}>
1496 <span
1497 id={props.imageProps?.id}
1498 data-markdown-copy={props.copyMarkdown}
1499 className={cn(
1500 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1501 CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME,
1502 "relative",
1503 )}
1504 style={props.style}
1505 {...(failed
1506 ? { role: "alert" as const }
1507 : { role: "status" as const, "aria-label": "Loading image" })}
1508 >
1509 {failed ? (
1510 <span className="flex size-full items-center justify-center p-2 text-center text-xs text-muted-foreground">
1511 <ChatMarkdownMediaUnavailableLabel alt={props.alt} detail={props.unavailableDetail} />
1512 </span>
1513 ) : src !== null ? (
1514 <img
1515 ref={markLoadedIfComplete}
1516 src={src}
1517 alt={props.alt}
1518 decoding="async"
1519 draggable={false}
1520 className="invisible absolute inset-0 size-full"
1521 {...imageEvents(src)}
1522 />
1523 ) : null}
1524 </span>
1525 </MediaActions>
1526 );
1527}
1528
1529function ChatMarkdownVideo(props: {
1530 readonly src: string | null;
1531 readonly alt: string;
1532 readonly copyMarkdown: string | undefined;
1533 readonly originalUrl?: string | undefined;
1534 readonly sourceFailed?: boolean | undefined;
1535 readonly style?: CSSProperties | undefined;
1536 readonly mediaIdentity?: string | undefined;
1537 readonly actionsSource?: MediaActionSource | undefined;
1538 readonly onRetry?: (() => Promise<unknown>) | undefined;
1539}) {
1540 return (
1541 <MediaVideoPlayer
1542 key={props.mediaIdentity ?? props.copyMarkdown ?? props.src}
1543 src={props.src}
1544 sourceFailed={props.sourceFailed}
1545 label={props.alt}
1546 originalUrl={props.originalUrl}
1547 style={props.style}
1548 copyMarkdown={props.copyMarkdown}
1549 className={cn(
1550 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1551 CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME,
1552 "w-full max-w-[min(100%,30rem,calc(30rem*var(--media-video-ratio)))]",
1553 )}
1554 videoClassName={cn(
1555 CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
1556 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
1557 )}
1558 onRetry={props.onRetry}
1559 actionsSource={props.actionsSource}
1560 />
1561 );
1562}
1563
1564/** Environment-hosted media loads through an exact-file signed asset URL. */
1565export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: {
1566 readonly environmentId: EnvironmentId;
1567 readonly resource: Extract<
1568 AssetResource,
1569 {
1570 readonly _tag:
1571 | "attachment"
1572 | "workspace-file"
1573 | "media-file"
1574 | "github-media"
1575 | "tool-output-image";
1576 }
1577 >;
1578 readonly kind?: "image" | "video";
1579 readonly alt: string;
1580 readonly copyMarkdown?: string;
1581 readonly srcFragment?: string;
1582 /** Reserve a slot while loading; off for images that share a line with text. */
1583 readonly standalone?: boolean | undefined;
1584 /** Caps the box height in rem while keeping the image's ratio; 30 by default. */
1585 readonly maxHeightRem?: number | undefined;
1586 readonly style?: CSSProperties | undefined;
1587 readonly className?: string | undefined;
1588 /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */
1589 readonly imageProps?:
1590 | Omit<ComponentProps<"img">, "src" | "alt" | "className" | "style">
1591 | undefined;
1592 /** Where the media also lives on the web, for the failure state's escape hatch. */
1593 readonly originalUrl?: string | undefined;
1594 /** The workspace media frame, on by default; off for media that keeps the author's own box. */
1595 readonly framed?: boolean | undefined;
1596 /** Loaded instead of the failure state when no URL can be signed, such as against a server
1597 too old to know this resource. Only safe when the client can reach it directly. */
1598 readonly fallbackSrc?: string | undefined;
1599 readonly workspaceRoot?: string | undefined;
1600 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1601}) {
1602 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
1603 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);
1604 const resource = props.resource;
1605 const path =
1606 resource._tag === "media-file"
1607 ? resource.path
1608 : resource._tag === "workspace-file" && props.workspaceRoot
1609 ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}`
1610 : undefined;
1611 const reference = path
1612 ? mediaFileReference(path, props.workspaceRoot)
1613 : props.originalUrl
1614 ? mediaUrlReference(props.originalUrl)
1615 : undefined;
1616 const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
1617 const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined;
1618 const src =
1619 assetUrl._tag === "Success"
1620 ? assetUrl.url + (props.srcFragment ?? "")
1621 : fallbackSrc === undefined
1622 ? null
1623 : fallbackSrc + (props.srcFragment ?? "");
1624 // The server reads the pixel size from the file header, so the slot can be
1625 // the image's final box instead of a 16:9 guess. An authored size wins; a
1626 // caller's height cap shrinks the box while keeping the ratio.
1627 const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined;
1628 const maxHeightRem = props.maxHeightRem ?? 30;
1629 const style =
1630 props.style ??
1631 (knownSize
1632 ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem)
1633 : maxHeightRem !== 30
1634 ? { maxHeight: `${maxHeightRem}rem` }
1635 : undefined);
1636 const actionsSource: MediaActionSource = {
1637 kind: props.kind ?? "image",
1638 name: props.alt || (props.kind ?? "image"),
1639 src,
1640 ...(fallbackSrc === undefined
1641 ? { asset: { environmentId: props.environmentId, resource } }
1642 : {}),
1643 ...(reference ? { reference } : {}),
1644 ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file")
1645 ? {
1646 onOpenFile: () =>
1647 useRightPanelStore
1648 .getState()
1649 .openFile(
1650 { environmentId: props.environmentId, threadId: resource.threadId },
1651 relativePath,
1652 ),
1653 }
1654 : {}),
1655 };
1656
1657 if (props.kind === "video") {
1658 return (
1659 <ChatMarkdownVideo
1660 src={src}
1661 sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined}
1662 alt={props.alt}
1663 copyMarkdown={props.copyMarkdown}
1664 originalUrl={props.originalUrl}
1665 style={props.style}
1666 mediaIdentity={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
1667 onRetry={refreshAssetUrl}
1668 actionsSource={actionsSource}
1669 />
1670 );
1671 }
1672
1673 return (
1674 <ChatMarkdownImage
1675 key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
1676 src={src}
1677 sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined}
1678 unavailableDetail={
1679 assetUrl._tag === "Failure" &&
1680 assetUrl.reason === "missing-file-access" &&
1681 fallbackSrc === undefined
1682 ? MISSING_FILE_ACCESS_MESSAGE
1683 : undefined
1684 }
1685 alt={props.alt}
1686 copyMarkdown={props.copyMarkdown}
1687 standalone={props.standalone ?? true}
1688 className={cn(
1689 props.framed === false ? undefined : CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,
1690 props.className,
1691 )}
1692 style={style}
1693 imageProps={props.imageProps}
1694 actionsSource={actionsSource}
1695 originalUrl={props.originalUrl}
1696 onImageExpand={props.onImageExpand}
1697 />
1698 );
1699});
1700
1701function leadingExternalLinkTextLength(text: string): number {
1702 const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0];
1703 if (protocol) return protocol.length;
1704 return Math.min(text.length, 1);
1705}
1706
1707function breakableExternalLinkText(text: string): ReactNode[] {
1708 return Array.from(text, (character, index) => (
1709 <React.Fragment key={`${index}:${character}`}>
1710 {index > 0 && <wbr />}
1711 {character}
1712 </React.Fragment>
1713 ));
1714}
1715
1716function plainHastText(node: unknown): string | null {
1717 if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) {
1718 return null;
1719 }
1720 const parts = node.children.map((child) => {
1721 if (
1722 child &&
1723 typeof child === "object" &&
1724 "type" in child &&
1725 child.type === "text" &&
1726 "value" in child &&
1727 typeof child.value === "string"
1728 ) {
1729 return child.value;
1730 }
1731 return null;
1732 });
1733 return parts.every((part) => part !== null) ? parts.join("") : null;
1734}
1735
1736/**
1737 * The anchor's words, gathered through any nesting. A context label that picked up emphasis or a
1738 * code span still has to read as its label; `plainHastText` gives up on the first non-text child,
1739 * which would leave the raw context id showing in its place.
1740 */
1741function hastPlainTextDeep(node: unknown): string {
1742 if (!node || typeof node !== "object") return "";
1743 if ("type" in node && node.type === "text" && "value" in node && typeof node.value === "string") {
1744 return node.value;
1745 }
1746 if (!("children" in node) || !Array.isArray(node.children)) return "";
1747 return node.children.map(hastPlainTextDeep).join("");
1748}
1749
1750/**
1751 * Whether the link carries any words of its own. An anchor that is only an image — a badge, a
1752 * "Fix in Cursor" button — already shows its identity, and a favicon bolted on in front of it
1753 * is a stray logo rather than a hint.
1754 */
1755function hastHasText(node: unknown): boolean {
1756 if (!node || typeof node !== "object") return false;
1757 if (
1758 "type" in node &&
1759 node.type === "text" &&
1760 "value" in node &&
1761 typeof node.value === "string" &&
1762 node.value.trim().length > 0
1763 ) {
1764 return true;
1765 }
1766 return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText);
1767}
1768
1769const SANITIZED_FRAGMENT_PREFIX = "user-content-";
1770
1771function decodeMarkdownFragmentId(href: string): string {
1772 const encodedId = href.slice(1);
1773 try {
1774 return decodeURIComponent(encodedId);
1775 } catch {
1776 return encodedId;
1777 }
1778}
1779
1780function normalizeSanitizedFragmentId(id: string): string {
1781 let normalizedId = id;
1782 while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) {
1783 normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length);
1784 }
1785 return normalizedId;
1786}
1787
1788function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null {
1789 const decodedId = decodeMarkdownFragmentId(href);
1790 const normalizedId = normalizeSanitizedFragmentId(decodedId);
1791 const matchesFragment = (element: HTMLElement) =>
1792 element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId;
1793 const markdownRoot = anchor.closest<HTMLElement>(".chat-markdown");
1794 if (markdownRoot) {
1795 const localTargets = Array.from(markdownRoot.querySelectorAll<HTMLElement>("[id]"));
1796 const localTarget = localTargets.find(matchesFragment);
1797 if (localTarget) return localTarget;
1798 }
1799
1800 return (
1801 document.getElementById(decodedId) ??
1802 Array.from(document.querySelectorAll<HTMLElement>("[id]")).find(matchesFragment) ??
1803 null
1804 );
1805}
1806
1807function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>, href: string) {
1808 if (
1809 event.defaultPrevented ||
1810 event.button !== 0 ||
1811 event.metaKey ||
1812 event.ctrlKey ||
1813 event.shiftKey ||
1814 event.altKey
1815 ) {
1816 return;
1817 }
1818
1819 // Never let the browser follow the fragment or write it to the URL: desktop keeps
1820 // its route in the hash, so replacing the hash navigates away from the thread.
1821 event.preventDefault();
1822 findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" });
1823}
1824
1825type MarkdownHastNode = {
1826 type?: string;
1827 value?: string;
1828 tagName?: string;
1829 properties?: Record<string, unknown>;
1830 children?: MarkdownHastNode[];
1831};
1832
1833function rehypeVideoRows() {
1834 return (tree: MarkdownHastNode) => {
1835 const isVideoImage = (node: MarkdownHastNode) => {
1836 const src = node.properties?.dataLocalSrc ?? node.properties?.src;
1837 return (
1838 node.type === "element" &&
1839 node.tagName === "img" &&
1840 typeof src === "string" &&
1841 mediaKindFromPath(src) === "video"
1842 );
1843 };
1844 const videosInBlock = (node: MarkdownHastNode) => {
1845 if (isVideoImage(node)) return [node];
1846 if (
1847 node.type !== "element" ||
1848 node.tagName !== "p" ||
1849 Object.keys(node.properties ?? {}).length > 0
1850 ) {
1851 return null;
1852 }
1853 const videos = (node.children ?? []).filter(
1854 (child) =>
1855 !(child.type === "text" && !child.value?.trim()) &&
1856 !(child.type === "element" && child.tagName === "br"),
1857 );
1858 return videos.length > 0 && videos.every(isVideoImage) ? videos : null;
1859 };
1860 const visit = (node: MarkdownHastNode) => {
1861 if (!node.children) return;
1862 node.children.forEach(visit);
1863 if (
1864 node.type !== "root" &&
1865 !["div", "blockquote", "li", "td", "th", "figure", "details"].includes(node.tagName ?? "")
1866 ) {
1867 return;
1868 }
1869
1870 for (let index = 0; index < node.children.length; index++) {
1871 const first = node.children[index]!;
1872 const videos = videosInBlock(first);
1873 if (!videos) continue;
1874 let end = index + 1;
1875 while (end < node.children.length) {
1876 const next = node.children[end]!;
1877 if (next.type === "text" && !next.value?.trim()) {
1878 end++;
1879 continue;
1880 }
1881 const moreVideos = videosInBlock(next);
1882 if (!moreVideos) break;
1883 videos.push(...moreVideos);
1884 end++;
1885 }
1886 node.children.splice(index, end - index, {
1887 type: "element",
1888 tagName: "div",
1889 properties: {
1890 className: [
1891 "chat-markdown-video-row",
1892 ...(first.tagName === "img" ? ["chat-markdown-video-bare"] : []),
1893 ],
1894 dataVideoCount: videos.length,
1895 },
1896 children: videos.map((video) => ({
1897 type: "element",
1898 tagName: "div",
1899 properties: {},
1900 children: [video],
1901 })),
1902 });
1903 }
1904 };
1905 visit(tree);
1906 };
1907}
1908
1909/** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */
1910function githubHeadingSlug(text: string): string {
1911 return text
1912 .trim()
1913 .toLowerCase()
1914 .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "")
1915 .replace(/ /g, "-");
1916}
1917
1918/**
1919 * Gives headings without an authored id GitHub's slug id, deduplicated per document. Like the
1920 * sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app element ids;
1921 * fragment lookup strips it.
1922 */
1923function rehypeHeadingIds() {
1924 return (tree: MarkdownHastNode) => {
1925 // Every id already in the document, authored or assigned, so a suffix never
1926 // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids.
1927 const taken = new Set<string>();
1928 const collect = (node: MarkdownHastNode) => {
1929 const id = node.properties?.id;
1930 if (typeof id === "string") taken.add(id);
1931 node.children?.forEach(collect);
1932 };
1933 collect(tree);
1934 const nextSuffix = new Map<string, number>();
1935 const visit = (node: MarkdownHastNode) => {
1936 if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) {
1937 const slug = githubHeadingSlug(hastPlainTextDeep(node));
1938 if (node.properties?.id === undefined && slug) {
1939 let count = nextSuffix.get(slug) ?? 0;
1940 let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`;
1941 while (taken.has(id)) {
1942 count += 1;
1943 id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`;
1944 }
1945 nextSuffix.set(slug, count);
1946 taken.add(id);
1947 node.properties = { ...node.properties, id };
1948 }
1949 return;
1950 }
1951 node.children?.forEach(visit);
1952 };
1953 visit(tree);
1954 };
1955}
1956
1957// Heading ids are added after sanitizing, which would prefix them a second time.
1958const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [
1959 ...CHAT_MARKDOWN_REHYPE_PLUGINS,
1960 rehypeHeadingIds,
1961 rehypeVideoRows,
1962] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
1963
1964const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [
1965 rehypeHeadingIds,
1966 rehypeVideoRows,
1967] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
1968
1969function MarkdownExternalLinkContent({
1970 host,
1971 plainText,
1972 children,
1973}: {
1974 host: string;
1975 plainText: string | null;
1976 children: ReactNode;
1977}) {
1978 if (plainText) {
1979 const leadingLength = leadingExternalLinkTextLength(plainText);
1980 return (
1981 <>
1982 <span className="whitespace-nowrap">
1983 <MarkdownLinkFavicon host={host} />
1984 {plainText.slice(0, leadingLength)}
1985 </span>
1986 {breakableExternalLinkText(plainText.slice(leadingLength))}
1987 </>
1988 );
1989 }
1990
1991 const childNodes = Children.toArray(children);
1992 const firstChild = childNodes[0];
1993
1994 if (typeof firstChild === "string" && firstChild.length > 0) {
1995 const leadingLength = leadingExternalLinkTextLength(firstChild);
1996 return (
1997 <>
1998 <span className="whitespace-nowrap">
1999 <MarkdownLinkFavicon host={host} />
2000 {firstChild.slice(0, leadingLength)}
2001 </span>
2002 {breakableExternalLinkText(firstChild.slice(leadingLength))}
2003 {childNodes.slice(1)}
2004 </>
2005 );
2006 }
2007
2008 return (
2009 <>
2010 <span className="whitespace-nowrap">
2011 <MarkdownLinkFavicon host={host} />
2012 {firstChild}
2013 </span>
2014 {childNodes.slice(1)}
2015 </>
2016 );
2017}
2018
2019const MarkdownFileLink = memo(function MarkdownFileLink({
2020 environmentId,
2021 href,
2022 targetPath,
2023 iconPath,
2024 displayPath,
2025 panelPath,
2026 line,
2027 label,
2028 copyMarkdown,
2029 theme,
2030 threadRef,
2031 onOpen,
2032 onOpenInPanel,
2033 openInEditorMenuLabel,
2034 onOpenInBrowser,
2035 onOpenMedia,
2036 onReveal,
2037 revealLabel,
2038}: MarkdownFileLinkProps) {
2039 const handleOpenInEditor = useCallback(() => {
2040 if (!onOpen) {
2041 return;
2042 }
2043 void (async () => {
2044 try {
2045 const result = await onOpen(targetPath);
2046 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
2047 return;
2048 }
2049 reportMarkdownActionFailure(
2050 { operation: "open-file-in-editor", target: targetPath },
2051 result.cause,
2052 );
2053 const error = squashAtomCommandFailure(result);
2054 toastManager.add(
2055 stackedThreadToast({
2056 type: "error",
2057 title: "Unable to open file",
2058 description: error instanceof Error ? error.message : "An error occurred.",
2059 }),
2060 );
2061 } catch (cause) {
2062 reportMarkdownActionFailure(
2063 { operation: "open-file-in-editor", target: targetPath },
2064 cause,
2065 );
2066 toastManager.add(
2067 stackedThreadToast({
2068 type: "error",
2069 title: "Unable to open file",
2070 description: cause instanceof Error ? cause.message : "An error occurred.",
2071 }),
2072 );
2073 }
2074 })();
2075 }, [onOpen, targetPath]);
2076
2077 const handleOpenInFilePreview = useCallback(() => {
2078 if (threadRef && panelPath) {
2079 onOpenInPanel(panelPath, line);
2080 return;
2081 }
2082 if (onOpenMedia) {
2083 onOpenMedia();
2084 return;
2085 }
2086 handleOpenInEditor();
2087 }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]);
2088
2089 const handleOpenInBrowser = useCallback(() => {
2090 if (!onOpenInBrowser) {
2091 return;
2092 }
2093 void (async () => {
2094 try {
2095 const result = await onOpenInBrowser();
2096 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
2097 return;
2098 }
2099 reportMarkdownActionFailure(
2100 { operation: "open-file-in-browser", target: targetPath },
2101 result.cause,
2102 );
2103 const error = squashAtomCommandFailure(result);
2104 toastManager.add(
2105 stackedThreadToast({
2106 type: "error",
2107 title: "Unable to open file in browser",
2108 description: error instanceof Error ? error.message : "An error occurred.",
2109 }),
2110 );
2111 } catch (cause) {
2112 reportMarkdownActionFailure(
2113 { operation: "open-file-in-browser", target: targetPath },
2114 cause,
2115 );
2116 toastManager.add(
2117 stackedThreadToast({
2118 type: "error",
2119 title: "Unable to open file in browser",
2120 description: cause instanceof Error ? cause.message : "An error occurred.",
2121 }),
2122 );
2123 }
2124 })();
2125 }, [onOpenInBrowser, targetPath]);
2126
2127 const handleRevealInFileManager = useCallback(() => {
2128 if (!onReveal) {
2129 return;
2130 }
2131 void (async () => {
2132 try {
2133 const result = await onReveal();
2134 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
2135 return;
2136 }
2137 reportMarkdownActionFailure(
2138 { operation: "reveal-file-in-file-manager", target: targetPath },
2139 result.cause,
2140 );
2141 const error = squashAtomCommandFailure(result);
2142 toastManager.add(
2143 stackedThreadToast({
2144 type: "error",
2145 title: "Unable to reveal file",
2146 description: error instanceof Error ? error.message : "An error occurred.",
2147 }),
2148 );
2149 } catch (cause) {
2150 reportMarkdownActionFailure(
2151 { operation: "reveal-file-in-file-manager", target: targetPath },
2152 cause,
2153 );
2154 toastManager.add(
2155 stackedThreadToast({
2156 type: "error",
2157 title: "Unable to reveal file",
2158 description: cause instanceof Error ? cause.message : "An error occurred.",
2159 }),
2160 );
2161 }
2162 })();
2163 }, [onReveal, targetPath]);
2164
2165 const handleCopy = useCallback(
2166 (value: string, title: string) => {
2167 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
2168 toastManager.add(
2169 stackedThreadToast({
2170 type: "error",
2171 title: `Failed to copy ${title.toLowerCase()}`,
2172 description: "Clipboard API unavailable.",
2173 }),
2174 );
2175 return;
2176 }
2177
2178 void navigator.clipboard.writeText(value).then(
2179 () => {
2180 toastManager.add({
2181 type: "success",
2182 title: `${title} copied`,
2183 description: value,
2184 });
2185 },
2186 (error) => {
2187 reportMarkdownActionFailure(
2188 { operation: "copy-file-path", target: targetPath, copyTarget: title },
2189 error,
2190 );
2191 toastManager.add(
2192 stackedThreadToast({
2193 type: "error",
2194 title: `Failed to copy ${title.toLowerCase()}`,
2195 description: error instanceof Error ? error.message : "An error occurred.",
2196 }),
2197 );
2198 },
2199 );
2200 },
2201 [targetPath],
2202 );
2203
2204 const showFileContextMenu = useCallback(
2205 async (position: { x: number; y: number }) => {
2206 const api = readLocalApi();
2207 if (!api) return;
2208
2209 try {
2210 const clicked = await api.contextMenu.show(
2211 [
2212 ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []),
2213 ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []),
2214 ...(onOpenInBrowser
2215 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
2216 : []),
2217 ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []),
2218 { id: "copy-relative", label: "Copy relative path" },
2219 { id: "copy-full", label: "Copy full path" },
2220 ] as const,
2221 position,
2222 );
2223
2224 if (clicked === "preview-media") {
2225 onOpenMedia?.();
2226 return;
2227 }
2228 if (clicked === "open") {
2229 handleOpenInEditor();
2230 return;
2231 }
2232 if (clicked === "open-in-browser") {
2233 handleOpenInBrowser();
2234 return;
2235 }
2236 if (clicked === "reveal") {
2237 handleRevealInFileManager();
2238 return;
2239 }
2240 if (clicked === "copy-relative") {
2241 handleCopy(displayPath, "Relative path");
2242 return;
2243 }
2244 if (clicked === "copy-full") {
2245 handleCopy(targetPath, "Full path");
2246 }
2247 } catch (cause) {
2248 reportMarkdownActionFailure(
2249 { operation: "show-file-context-menu", target: targetPath },
2250 cause,
2251 );
2252 }
2253 },
2254 [
2255 displayPath,
2256 handleCopy,
2257 handleOpenInBrowser,
2258 handleOpenInEditor,
2259 handleRevealInFileManager,
2260 onOpenInBrowser,
2261 onOpenMedia,
2262 onOpen,
2263 onReveal,
2264 openInEditorMenuLabel,
2265 revealLabel,
2266 targetPath,
2267 ],
2268 );
2269
2270 const handleContextMenu = useCallback(
2271 (event: ReactMouseEvent<HTMLElement>) => {
2272 event.preventDefault();
2273 event.stopPropagation();
2274 const position =
2275 event.clientX === 0 && event.clientY === 0
2276 ? (() => {
2277 const bounds = event.currentTarget.getBoundingClientRect();
2278 return { x: bounds.left, y: bounds.bottom };
2279 })()
2280 : { x: event.clientX, y: event.clientY };
2281 void showFileContextMenu(position);
2282 },
2283 [showFileContextMenu],
2284 );
2285
2286 const canOpenInEditor = onOpen !== undefined;
2287 const canOpenInBrowser = onOpenInBrowser !== undefined;
2288 const canOpenInPanel = threadRef !== undefined && Boolean(panelPath);
2289 const hasPrimaryAction = hasMarkdownFilePrimaryAction({
2290 canOpenInEditor,
2291 canOpenInBrowser,
2292 canOpenInPanel,
2293 canOpenMedia: onOpenMedia !== undefined,
2294 });
2295 const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({
2296 iconPath,
2297 canOpenInEditor,
2298 canOpenInBrowser,
2299 canOpenInPanel,
2300 });
2301
2302 return (
2303 <Tooltip>
2304 <TooltipTrigger
2305 render={
2306 hasPrimaryAction ? (
2307 <ContextChip
2308 kind="mention"
2309 render={<a href={href} />}
2310 className={MARKDOWN_FILE_LINK_CLASS_NAME}
2311 data-markdown-copy={copyMarkdown}
2312 aria-description={targetPath}
2313 onClick={(event) => {
2314 event.preventDefault();
2315 event.stopPropagation();
2316 if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) {
2317 handleOpenInEditor();
2318 return;
2319 }
2320 if (useBrowserPrimaryAction) {
2321 handleOpenInBrowser();
2322 return;
2323 }
2324 handleOpenInFilePreview();
2325 }}
2326 onContextMenu={handleContextMenu}
2327 >
2328 <FileTagChipContent
2329 path={iconPath}
2330 environmentId={environmentId}
2331 label={label}
2332 theme={theme}
2333 />
2334 </ContextChip>
2335 ) : (
2336 <ContextChip
2337 kind="mention"
2338 render={<button type="button" />}
2339 aria-label={`File options for ${nodeToPlainText(label)}`}
2340 aria-description={targetPath}
2341 aria-haspopup="menu"
2342 className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")}
2343 data-markdown-copy={copyMarkdown}
2344 onClick={handleContextMenu}
2345 onContextMenu={handleContextMenu}
2346 >
2347 <FileTagChipContent
2348 path={iconPath}
2349 environmentId={environmentId}
2350 label={label}
2351 theme={theme}
2352 />
2353 </ContextChip>
2354 )
2355 }
2356 />
2357 <TooltipPopup side="top" variant="code">
2358 {/* The full path: the chip already shows the shortened form, and a link
2359 to the workspace root collapses to a bare label that repeats it. */}
2360 <div className="overflow-x-auto whitespace-nowrap scrollbar-thumb-border/78 scrollbar-track-transparent [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/78 [&::-webkit-scrollbar-track]:bg-transparent">
2361 <WorkspaceEntryTooltip path={iconPath} label={targetPath} environmentId={environmentId} />
2362 </div>
2363 </TooltipPopup>
2364 </Tooltip>
2365 );
2366}, areMarkdownFileLinkPropsEqual);
2367
2368function areMarkdownFileLinkPropsEqual(
2369 previous: Readonly<MarkdownFileLinkProps>,
2370 next: Readonly<MarkdownFileLinkProps>,
2371): boolean {
2372 return (
2373 previous.href === next.href &&
2374 previous.targetPath === next.targetPath &&
2375 previous.iconPath === next.iconPath &&
2376 previous.environmentId === next.environmentId &&
2377 previous.displayPath === next.displayPath &&
2378 previous.panelPath === next.panelPath &&
2379 previous.line === next.line &&
2380 previous.label === next.label &&
2381 previous.copyMarkdown === next.copyMarkdown &&
2382 previous.theme === next.theme &&
2383 previous.threadRef === next.threadRef &&
2384 previous.onOpen === next.onOpen &&
2385 previous.onOpenInPanel === next.onOpenInPanel &&
2386 previous.openInEditorMenuLabel === next.openInEditorMenuLabel &&
2387 previous.onOpenInBrowser === next.onOpenInBrowser &&
2388 previous.onOpenMedia === next.onOpenMedia &&
2389 previous.onReveal === next.onReveal &&
2390 previous.revealLabel === next.revealLabel
2391 );
2392}
2393
2394function useChatMarkdownState({
2395 text,
2396 cwd,
2397 threadRef,
2398 pullRequestPanelRef,
2399 environmentId: explicitEnvironmentId,
2400 onTaskListChange,
2401 isStreaming = false,
2402 skills = EMPTY_MARKDOWN_SKILLS,
2403 onUseArtifactTemplate,
2404 onRunShellCommand,
2405 imageBaseDir,
2406 onImageExpand,
2407 renderContextReference,
2408 headingLevelOffset = 0,
2409 githubMedia = false,
2410}: ChatMarkdownProps) {
2411 const { resolvedTheme } = useTheme();
2412 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
2413 const markdownRef = useRef<HTMLDivElement>(null);
2414 const expandMedia = onImageExpand ?? setLocalMediaPreview;
2415 const mediaRequestId = useRef(0);
2416 useEffect(() => {
2417 setLocalMediaPreview(null);
2418 return () => {
2419 mediaRequestId.current += 1;
2420 };
2421 }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]);
2422 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
2423 reportFailure: false,
2424 refresh: true,
2425 });
2426 const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
2427 reportFailure: false,
2428 });
2429 const openPreview = useAtomCommand(previewEnvironment.open, {
2430 reportFailure: false,
2431 });
2432 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);
2433 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
2434 const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
2435 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
2436 const remoteOpen = useRemoteOpenResolution(environmentId);
2437 const canUseShellActions =
2438 canOperateHost &&
2439 canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved);
2440 const preparedConnection = usePreparedConnection(environmentId);
2441 const openMarkdownMedia = useCallback(
2442 (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => {
2443 const requestId = ++mediaRequestId.current;
2444 void resolveMarkdownMediaPreview({
2445 source,
2446 resolvedFilePath,
2447 cwd,
2448 threadRef,
2449 httpBaseUrl:
2450 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : undefined,
2451 createAssetUrl,
2452 onOpenFile: threadRef
2453 ? (path) => useRightPanelStore.getState().openFile(threadRef, path)
2454 : undefined,
2455 }).then(
2456 (preview) => {
2457 if (preview && mediaRequestId.current === requestId) {
2458 const selected = preview.images[preview.index];
2459 expandMedia(
2460 selected && selected.type !== "video" && markdownRef.current
2461 ? markdownImageGallery(clickedImage ?? markdownRef.current, selected)
2462 : preview,
2463 );
2464 }
2465 },
2466 (error: unknown) => {
2467 if (mediaRequestId.current !== requestId) return;
2468 toastManager.add(
2469 stackedThreadToast({
2470 type: "error",
2471 title: "Media unavailable",
2472 description:
2473 error instanceof Error
2474 ? error.message
2475 : "The file could not be loaded. It may have been moved or deleted.",
2476 }),
2477 );
2478 },
2479 );
2480 },
2481 [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef],
2482 );
2483 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
2484 const projects = useProjects();
2485 const availableEditors = serverConfig?.availableEditors ?? [];
2486 const [preferredEditor] = usePreferredEditor(availableEditors);
2487 const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor);
2488 const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors);
2489 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
2490 reportFailure: false,
2491 });
2492 const revealInFileManagerLabel =
2493 environmentId !== null &&
2494 serverConfig?.shellRevealInFileManager === true &&
2495 serverConfig.availableEditors.includes("file-manager")
2496 ? serverConfig.shellRevealInFileManagerKind === undefined
2497 ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os)
2498 : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind)
2499 : undefined;
2500 const revealFileInFileManager = useCallback(
2501 (filePath: string) => {
2502 if (environmentId === null) {
2503 return Promise.resolve(
2504 AsyncResult.failure<void, PreferredEditorEnvironmentRequiredError>(
2505 Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })),
2506 ),
2507 );
2508 }
2509 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
2510 return Promise.resolve(
2511 AsyncResult.failure<void, Error>(
2512 Cause.fail(new Error("This connection cannot reveal files on this environment.")),
2513 ),
2514 );
2515 }
2516 return openInEditor({
2517 environmentId,
2518 input: { cwd: filePath, editor: "file-manager", reveal: true },
2519 });
2520 },
2521 [environmentId, openInEditor],
2522 );
2523 const diffThemeName = resolveDiffThemeName(resolvedTheme);
2524 const markdownFileLinkMetaByHref = useMemo(() => {
2525 const metaByHref = new Map<
2526 string,
2527 NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
2528 >();
2529 for (const href of extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text))) {
2530 if (parseComposerContextHref(href)) continue;
2531 const normalizedHref = normalizeMarkdownLinkHrefKey(href);
2532 if (metaByHref.has(normalizedHref)) continue;
2533 const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd);
2534 if (meta) {
2535 metaByHref.set(normalizedHref, meta);
2536 }
2537 }
2538 return metaByHref;
2539 }, [cwd, imageBaseDir, text]);
2540 const inlineCodeFileLinkMetaByText = useMemo(() => {
2541 const metaByText = new Map<string, MarkdownFileLinkMeta>();
2542 for (const span of extractInlineCodeSpans(text)) {
2543 if (metaByText.has(span)) continue;
2544 const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd);
2545 if (meta) {
2546 metaByText.set(span, meta);
2547 }
2548 }
2549 return metaByText;
2550 }, [cwd, imageBaseDir, text]);
2551 const fileLinkParentSuffixByPath = useMemo(() => {
2552 const filePaths = [
2553 ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath),
2554 ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath),
2555 ];
2556 return buildFileLinkParentSuffixByPath(filePaths);
2557 }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]);
2558 const markdownUrlTransform = useCallback((href: string) => {
2559 if (parseAssistantCitationHref(href)) return href;
2560 if (parseComposerContextHref(href)) return href;
2561 if (parseThreadLinkHref(href)) return href;
2562 if (isWindowsDrivePathHref(href)) return href;
2563 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
2564 }, []);
2565 // Re-emit highlighted content as markdown so copying out of the rendered
2566 // view keeps links, emphasis, lists, and code fences intact.
2567 const handleCopy = useCallback((event: ReactClipboardEvent<HTMLDivElement>) => {
2568 const selection = window.getSelection();
2569 if (!selection || selection.isCollapsed || !event.clipboardData) return;
2570 const payload = chatMarkdownClipboardPayload(selection);
2571 if (!payload) return;
2572 event.preventDefault();
2573 event.clipboardData.setData("text/plain", payload.text);
2574 const fragment = event.clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME);
2575 event.clipboardData.setData(
2576 "text/html",
2577 fragment
2578 ? encodeComposerContextClipboardHtml(payload.text, fragment, payload.html)
2579 : payload.html,
2580 );
2581 }, []);
2582 const openChangeRequestLink = useOpenChangeRequestLink(threadRef, pullRequestPanelRef);
2583 const openDeferredMarkdownLink = useOpenLink(threadRef);
2584 // Subscribed rather than read at click time: the anchor has to decide
2585 // synchronously whether to intercept its `_blank`, and a subscription is what
2586 // makes a persisted "app" apply once settings hydrate after launch.
2587 const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget);
2588 const resolveThreadPullRequest = useCallback(
2589 (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => {
2590 if (
2591 threadRef === undefined ||
2592 readThreadShell(threadRef) === null ||
2593 !pullRequestLinking.canLink(href)
2594 )
2595 return null;
2596 const parsed = parseChangeRequestUrl(href);
2597 return parsed === null ? null : { ...parsed, url: href };
2598 },
2599 [pullRequestLinking, threadRef],
2600 );
2601 const linkedThreadPullRequestFor = useCallback(
2602 (href: string) => {
2603 if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href))
2604 return null;
2605 const parsed = parseChangeRequestUrl(href);
2606 return parsed === null ? null : { ...parsed, url: href };
2607 },
2608 [pullRequestLinking, threadRef],
2609 );
2610 const updateThreadPullRequestLink = useCallback(
2611 async (href: string, linked: boolean) => {
2612 if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return;
2613 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
2614 await pullRequestLinking.changeLink(threadRef, href, linked);
2615 },
2616 [linkedThreadPullRequestFor, pullRequestLinking, threadRef],
2617 );
2618 const openExternalLinkInPreview = useCallback(
2619 (url: string) => {
2620 if (!threadRef || !canOperatePreview) {
2621 return Promise.resolve(
2622 AsyncResult.failure<void, BrowserPreviewUnavailableError>(
2623 Cause.fail(
2624 new BrowserPreviewUnavailableError({
2625 message: "Preview access is unavailable for this client.",
2626 }),
2627 ),
2628 ),
2629 );
2630 }
2631 return openUrlInPreview({ threadRef, url, openPreview }).then((result) => {
2632 if (result._tag === "Success") recordVisitForThread(threadRef, url);
2633 else if (!isAtomCommandInterrupted(result)) {
2634 const error = squashAtomCommandFailure(result);
2635 if (error instanceof BrowserSettingsReadError) {
2636 toastManager.add(
2637 stackedThreadToast({
2638 type: "error",
2639 title: "Unable to open link in browser",
2640 description: error.message,
2641 }),
2642 );
2643 }
2644 }
2645 return result;
2646 });
2647 },
2648 [canOperatePreview, openPreview, threadRef],
2649 );
2650 const openMarkdownFileInPreview = useCallback(
2651 (path: string) => {
2652 if (!threadRef || !canOperatePreview || preparedConnection._tag === "None") {
2653 return Promise.resolve(
2654 AsyncResult.failure<void, BrowserPreviewUnavailableError>(
2655 Cause.fail(
2656 new BrowserPreviewUnavailableError({
2657 message: "Environment is not connected.",
2658 }),
2659 ),
2660 ),
2661 );
2662 }
2663 return openFileInPreview({
2664 threadRef,
2665 filePath: path,
2666 workspaceRoot: cwd,
2667 httpBaseUrl: preparedConnection.value.httpBaseUrl,
2668 createAssetUrl,
2669 openPreview,
2670 });
2671 },
2672 [canOperatePreview, createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
2673 );
2674 const findWorkspaceBasenameMatch = useCallback(
2675 async (workspaceRelativePath: string) => {
2676 if (
2677 !cwd ||
2678 environmentId === null ||
2679 !readEnvironmentScope(environmentId, AuthFilesystemReadScope) ||
2680 !needsWorkspaceBasenameLookup(workspaceRelativePath)
2681 ) {
2682 return null;
2683 }
2684 const result = await searchProjectEntries({
2685 environmentId,
2686 input: {
2687 cwd,
2688 query: workspaceRelativePath,
2689 limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
2690 kind: "file",
2691 },
2692 });
2693 return result._tag === "Success"
2694 ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries)
2695 : null;
2696 },
2697 [cwd, environmentId, searchProjectEntries],
2698 );
2699 // A bare filename resolves to the workspace root, which is rarely where the
2700 // file is, so ask the index before opening. Absolute host paths open as-is.
2701 const openFileInPanel = useCallback(
2702 (panelPath: string, line: number | undefined) => {
2703 if (!threadRef) return;
2704 // Claimed on every open so a synchronous one supersedes a lookup already
2705 // in flight.
2706 const isLatestLookup = claimWorkspaceBasenameLookup();
2707 const openAt = (path: string) =>
2708 useRightPanelStore.getState().openFile(threadRef, path, line);
2709 if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) {
2710 openAt(panelPath);
2711 return;
2712 }
2713 void (async () => {
2714 const match = await findWorkspaceBasenameMatch(panelPath);
2715 if (!isLatestLookup()) return;
2716 openAt(match ?? panelPath);
2717 })();
2718 },
2719 [cwd, findWorkspaceBasenameMatch, threadRef],
2720 );
2721 const revealMarkdownFileInFileManager = useCallback(
2722 async (fileLinkMeta: MarkdownFileLinkMeta) => {
2723 const workspaceRelativePath = fileLinkMeta.workspaceRelativePath;
2724 const match = workspaceRelativePath
2725 ? await findWorkspaceBasenameMatch(workspaceRelativePath)
2726 : null;
2727 const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath;
2728 return revealFileInFileManager(filePath);
2729 },
2730 [cwd, findWorkspaceBasenameMatch, revealFileInFileManager],
2731 );
2732 const fileLinkChip = useCallback(
2733 (
2734 fileLinkMeta: MarkdownFileLinkMeta,
2735 copyMarkdown: string,
2736 mediaSource?: string,
2737 label?: ReactNode,
2738 ) => {
2739 const mediaPath = mediaSource ?? fileLinkMeta.filePath;
2740 const canPreviewMedia =
2741 mediaMimeTypeFromExtension(
2742 fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")),
2743 ) !== null;
2744 // Media outside the workspace keeps the expanded preview; other host
2745 // files (a report in a temp dir) open read-only in the files panel.
2746 const panelPath =
2747 fileLinkMeta.workspaceRelativePath ??
2748 (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null);
2749
2750 return (
2751 <MarkdownFileLink
2752 environmentId={environmentId}
2753 href={fileLinkMeta.targetPath}
2754 targetPath={fileLinkMeta.targetPath}
2755 iconPath={fileLinkMeta.filePath}
2756 displayPath={fileLinkMeta.displayPath}
2757 panelPath={panelPath}
2758 line={fileLinkMeta.line}
2759 label={
2760 label ??
2761 fileLinkLabel(
2762 {
2763 path: fileLinkMeta.filePath,
2764 ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}),
2765 ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}),
2766 },
2767 fileLinkParentSuffixByPath,
2768 )
2769 }
2770 copyMarkdown={copyMarkdown}
2771 theme={resolvedTheme}
2772 threadRef={threadRef}
2773 {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})}
2774 onOpenInPanel={openFileInPanel}
2775 onOpenMedia={
2776 threadRef && canPreviewMedia
2777 ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath)
2778 : undefined
2779 }
2780 openInEditorMenuLabel={preferredEditorMenuLabel}
2781 onReveal={
2782 canUseShellActions && revealInFileManagerLabel !== undefined
2783 ? () => revealMarkdownFileInFileManager(fileLinkMeta)
2784 : undefined
2785 }
2786 revealLabel={revealInFileManagerLabel}
2787 onOpenInBrowser={
2788 threadRef &&
2789 canOperatePreview &&
2790 isPreviewAvailableFor(threadRef.environmentId) &&
2791 isBrowserPreviewFile(fileLinkMeta.filePath)
2792 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
2793 : undefined
2794 }
2795 />
2796 );
2797 },
2798 [
2799 canUseShellActions,
2800 canOperatePreview,
2801 environmentId,
2802 fileLinkParentSuffixByPath,
2803 openFileInPanel,
2804 openInPreferredEditor,
2805 openMarkdownFileInPreview,
2806 openMarkdownMedia,
2807 preferredEditorMenuLabel,
2808 resolvedTheme,
2809 revealInFileManagerLabel,
2810 revealMarkdownFileInFileManager,
2811 threadRef,
2812 ],
2813 );
2814
2815 const componentState = useMemo(
2816 () => ({
2817 canOperateHost,
2818 canOperatePreview,
2819 cwd,
2820 diffThemeName,
2821 environmentId,
2822 expandMedia,
2823 fileLinkChip,
2824 githubMedia,
2825 renderContextReference,
2826 headingLevelOffset,
2827 imageBaseDir,
2828 inlineCodeFileLinkMetaByText,
2829 isStreaming,
2830 linkTargetPreference,
2831 markdownFileLinkMetaByHref,
2832 onTaskListChange,
2833 onUseArtifactTemplate,
2834 onRunShellCommand,
2835 openChangeRequestLink,
2836 openDeferredMarkdownLink,
2837 openExternalLinkInPreview,
2838 openMarkdownMedia,
2839 projects,
2840 linkedThreadPullRequestFor,
2841 resolveThreadPullRequest,
2842 resolvedTheme,
2843 serverConfig,
2844 skills,
2845 text,
2846 threadRef,
2847 updateThreadPullRequestLink,
2848 }),
2849 [
2850 canOperateHost,
2851 canOperatePreview,
2852 cwd,
2853 diffThemeName,
2854 environmentId,
2855 expandMedia,
2856 fileLinkChip,
2857 githubMedia,
2858 renderContextReference,
2859 headingLevelOffset,
2860 imageBaseDir,
2861 inlineCodeFileLinkMetaByText,
2862 isStreaming,
2863 linkTargetPreference,
2864 markdownFileLinkMetaByHref,
2865 onTaskListChange,
2866 onUseArtifactTemplate,
2867 onRunShellCommand,
2868 openChangeRequestLink,
2869 openDeferredMarkdownLink,
2870 openExternalLinkInPreview,
2871 openMarkdownMedia,
2872 projects,
2873 linkedThreadPullRequestFor,
2874 resolveThreadPullRequest,
2875 resolvedTheme,
2876 serverConfig,
2877 skills,
2878 text,
2879 threadRef,
2880 updateThreadPullRequestLink,
2881 ],
2882 );
2883 return {
2884 componentState,
2885 handleCopy,
2886 markdownRef,
2887 markdownUrlTransform,
2888 localMediaPreview,
2889 setLocalMediaPreview,
2890 };
2891}
2892
2893const ChatMarkdownRendererContext = React.createContext<
2894 ReturnType<typeof useChatMarkdownState>["componentState"]
2895>(null!);
2896
2897// Screen readers take a heading's level from its tag, which would let a `#` in a
2898// message outrank the heading placed above it. Override only the exposed level:
2899// the tag keeps driving the stylesheet and copy-as-markdown.
2900function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) {
2901 const Tag = `h${level}` as const;
2902 return function MarkdownHeading({
2903 node: _node,
2904 ...props
2905 }: ComponentProps<typeof Tag> & ReactMarkdownExtraProps) {
2906 const { headingLevelOffset } = use(ChatMarkdownRendererContext);
2907 return (
2908 <Tag
2909 {...props}
2910 aria-level={headingLevelOffset > 0 ? Math.min(level + headingLevelOffset, 6) : undefined}
2911 />
2912 );
2913 };
2914}
2915
2916// Keep component types stable when streaming changes the message state.
2917const CHAT_MARKDOWN_COMPONENTS = {
2918 h1: markdownHeadingRenderer(1),
2919 h2: markdownHeadingRenderer(2),
2920 h3: markdownHeadingRenderer(3),
2921 h4: markdownHeadingRenderer(4),
2922 h5: markdownHeadingRenderer(5),
2923 h6: markdownHeadingRenderer(6),
2924 div: function MarkdownDiv({ node, children, ...props }) {
2925 const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext);
2926 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
2927 if (artifactTemplate) {
2928 return (
2929 <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} />
2930 );
2931 }
2932 const videoCount = node?.properties.dataVideoCount;
2933 if (typeof videoCount === "number") {
2934 return (
2935 <div {...props} style={{ "--chat-video-count": videoCount } as CSSProperties}>
2936 {children}
2937 </div>
2938 );
2939 }
2940 return <div {...props}>{children}</div>;
2941 },
2942 p: function MarkdownParagraph({ node: _node, children, ...props }) {
2943 const { skills } = use(ChatMarkdownRendererContext);
2944 return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
2945 },
2946 blockquote: function MarkdownBlockquote({ node: _node, children, ...props }) {
2947 const alert =
2948 GITHUB_ALERT_PRESENTATIONS[String((props as Record<string, unknown>)["data-alert"] ?? "")];
2949 if (!alert) {
2950 return <blockquote {...props}>{children}</blockquote>;
2951 }
2952 // Not a <blockquote>: the stylesheet mutes those, and an alert's body is ordinary
2953 // text under a colored title — which is how the host renders it.
2954 return (
2955 <div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>
2956 <p
2957 data-thread-find-ignore="true"
2958 className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}
2959 >
2960 <alert.Icon aria-hidden className="size-3.5 shrink-0" />
2961 {alert.label}
2962 </p>
2963 {children}
2964 </div>
2965 );
2966 },
2967 ol: function MarkdownOrderedList({ node, start, style, ...props }) {
2968 const itemCount =
2969 node?.children?.filter((child) => child.type === "element" && child.tagName === "li")
2970 .length ?? 0;
2971 const gutterStyle = orderedListGutterStyle(itemCount, start);
2972 return (
2973 <ol {...props} start={start} style={gutterStyle ? { ...style, ...gutterStyle } : style} />
2974 );
2975 },
2976 li: function MarkdownListItem({ node, children, ...props }) {
2977 const { text, skills } = use(ChatMarkdownRendererContext);
2978 const listItemStart = node?.position?.start.offset;
2979 const markerOffset =
2980 typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null;
2981 return (
2982 <li {...props} data-task-marker-offset={markerOffset ?? undefined}>
2983 {renderSkillInlineMarkdownChildren(children, skills)}
2984 </li>
2985 );
2986 },
2987 input: function MarkdownInput({ node: _node, type, checked, disabled: _disabled, ...props }) {
2988 const { onTaskListChange } = use(ChatMarkdownRendererContext);
2989 if (type !== "checkbox" || !onTaskListChange) {
2990 return (
2991 <input
2992 {...props}
2993 type={type}
2994 checked={checked}
2995 disabled={_disabled}
2996 readOnly={type === "checkbox"}
2997 />
2998 );
2999 }
3000 return (
3001 <input
3002 {...props}
3003 type="checkbox"
3004 name="markdown-task"
3005 aria-label="Toggle task"
3006 checked={checked}
3007 onChange={(event) => {
3008 const markerOffset = Number(event.currentTarget.closest("li")?.dataset.taskMarkerOffset);
3009 if (!Number.isSafeInteger(markerOffset)) return;
3010 onTaskListChange({ markerOffset, checked: event.currentTarget.checked });
3011 }}
3012 />
3013 );
3014 },
3015 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
3016 const {
3017 canOperateHost,
3018 canOperatePreview,
3019 cwd,
3020 environmentId,
3021 imageBaseDir,
3022 markdownFileLinkMetaByHref,
3023 threadRef,
3024 openMarkdownMedia,
3025 openChangeRequestLink,
3026 openDeferredMarkdownLink,
3027 linkTargetPreference,
3028 openExternalLinkInPreview,
3029 projects,
3030 linkedThreadPullRequestFor,
3031 resolveThreadPullRequest,
3032 serverConfig,
3033 updateThreadPullRequestLink,
3034 fileLinkChip,
3035 renderContextReference,
3036 text,
3037 } = use(ChatMarkdownRendererContext);
3038 const citation = href ? parseAssistantCitationHref(href) : null;
3039 if (citation) return <AssistantCitationChip citation={citation} />;
3040 // A thread link names a thread in this message's environment and opens it in the app.
3041 const linkedThreadId = href ? parseThreadLinkHref(href) : null;
3042 if (linkedThreadId) {
3043 const label = hastPlainTextDeep(node) || linkedThreadId;
3044 return environmentId ? (
3045 <MarkdownThreadLink environmentId={environmentId} threadId={linkedThreadId} label={label} />
3046 ) : (
3047 <span>{label}</span>
3048 );
3049 }
3050 const contextReference = href ? parseComposerContextHref(href) : null;
3051 if (contextReference) {
3052 const label = hastPlainTextDeep(node) || contextReference.contextId;
3053 return renderContextReference ? (
3054 renderContextReference({ ...contextReference, label })
3055 ) : (
3056 <span>{label}</span>
3057 );
3058 }
3059 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
3060 const fileLinkMeta = normalizedHref
3061 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??
3062 resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd))
3063 : null;
3064 if (!fileLinkMeta) {
3065 const faviconHost = resolveExternalWebLinkHost(href);
3066 const hasText = hastHasText(node);
3067 const pullRequestAutolink = String(
3068 (props as Record<string, unknown>)["data-pull-request-autolink"] ?? "",
3069 );
3070 const pullRequestCopy =
3071 pullRequestAutolink === "commit"
3072 ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1]
3073 : pullRequestAutolink === "reference"
3074 ? plainHastText(node)
3075 : undefined;
3076 const isPullRequestAutolink = pullRequestCopy !== undefined;
3077 const confirmBeforeOpen = pullRequestAutolink === "reference";
3078 const pullRequestCandidateUrl =
3079 confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href;
3080 const pullRequestPreviewTarget = pullRequestCandidateUrl
3081 ? resolvePullRequestPreviewTarget({
3082 environmentId,
3083 projects,
3084 pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true,
3085 url: pullRequestCandidateUrl,
3086 })
3087 : null;
3088 const isSameDocumentLink = href?.startsWith("#") ?? false;
3089 const onClick = props.onClick;
3090 const canOpenInPreview =
3091 canOperatePreview && Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId));
3092 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
3093 const link = (
3094 <a
3095 {...props}
3096 className={cn(props.className, pullRequestAutolink === "commit" && "font-mono")}
3097 data-markdown-copy={pullRequestCopy}
3098 data-markdown-image-link={!hasText || undefined}
3099 href={href}
3100 target={isSameDocumentLink ? undefined : "_blank"}
3101 rel={isSameDocumentLink ? undefined : "noopener noreferrer"}
3102 onClick={(event) => {
3103 onClick?.(event);
3104 if (isSameDocumentLink && href) {
3105 handleMarkdownFragmentClick(event, href);
3106 return;
3107 }
3108 if (
3109 href &&
3110 faviconHost !== null &&
3111 mediaKindFromPath(href) !== null &&
3112 !event.defaultPrevented &&
3113 !event.metaKey &&
3114 !event.ctrlKey &&
3115 !event.shiftKey &&
3116 !event.altKey
3117 ) {
3118 event.preventDefault();
3119 event.stopPropagation();
3120 openMarkdownMedia(
3121 href,
3122 undefined,
3123 event.target instanceof HTMLImageElement
3124 ? event.target
3125 : event.currentTarget.querySelector("img"),
3126 );
3127 return;
3128 }
3129 // A link to a change request in a workspace project opens beside the
3130 // conversation instead of in a browser: it is the thing being talked about, and
3131 // the panel it opens offers the browser as one of its actions.
3132 if (
3133 !href ||
3134 openChangeRequestLink(event, href, undefined, environmentId ?? undefined)
3135 ) {
3136 return;
3137 }
3138 // Anything else follows the "Open links in" setting. The system browser
3139 // keeps the `_blank` the shell already handles; the in-app browser needs
3140 // the click intercepted here. A modifier click is the way out of the
3141 // in-app default, so it is left to the shell too.
3142 if (event.defaultPrevented) return;
3143 if (
3144 resolveLinkTarget({
3145 url: href,
3146 event,
3147 preference: linkTargetPreference,
3148 canOpenInApp: canOpenInPreview,
3149 }) !== "app"
3150 ) {
3151 // Chromium turns an Option/Alt-click into a download that never reaches the
3152 // shell. On desktop, open the link here while the key is still down, so link
3153 // routers such as Finicky can still read it.
3154 if (isElectron && event.altKey) {
3155 event.preventDefault();
3156 event.stopPropagation();
3157 void readLocalApi()
3158 ?.shell.openExternal(href)
3159 .catch((cause) =>
3160 reportMarkdownActionFailure(
3161 { operation: "open-link-external", target: href },
3162 cause,
3163 ),
3164 );
3165 }
3166 return;
3167 }
3168 event.preventDefault();
3169 event.stopPropagation();
3170 // Keep the link here if saved settings could not be read.
3171 void openExternalLinkInPreview(href).then((result) => {
3172 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
3173 reportMarkdownActionFailure(
3174 { operation: "open-link-in-preview", target: href },
3175 result.cause,
3176 );
3177 if (squashAtomCommandFailure(result) instanceof BrowserSettingsReadError) return;
3178 void readLocalApi()?.shell.openExternal(href);
3179 });
3180 }}
3181 onContextMenu={(event) => {
3182 if (!href || !faviconHost) return;
3183 event.preventDefault();
3184 event.stopPropagation();
3185 const api = readLocalApi();
3186 if (!api) return;
3187 const threadLinkAction = !canOperateHost
3188 ? undefined
3189 : linkedThreadPullRequestFor(href) !== null
3190 ? "unlink-from-thread"
3191 : resolveThreadPullRequest(href) === null
3192 ? undefined
3193 : "link-to-thread";
3194 void showExternalLinkContextMenu({
3195 href,
3196 canOpenInPreview,
3197 threadLinkAction,
3198 position: { x: event.clientX, y: event.clientY },
3199 showContextMenu: (items, position) => api.contextMenu.show(items, position),
3200 openInPreview: async (target) => {
3201 const result = await openExternalLinkInPreview(target);
3202 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3203 reportMarkdownActionFailure(
3204 { operation: "open-link-in-preview", target },
3205 result.cause,
3206 );
3207 }
3208 },
3209 openExternal: (target) => api.shell.openExternal(target),
3210 copyLink: (target) => writeTextToClipboard(target, "link"),
3211 updateThreadLink: updateThreadPullRequestLink,
3212 reportFailure: (operation, cause) => {
3213 reportMarkdownActionFailure({ operation, target: href }, cause);
3214 if (
3215 operation === "link-pull-request-to-thread" ||
3216 operation === "unlink-pull-request-from-thread"
3217 ) {
3218 toastManager.add(
3219 stackedThreadToast({
3220 type: "error",
3221 title:
3222 operation === "link-pull-request-to-thread"
3223 ? "Unable to link pull request"
3224 : "Unable to unlink pull request",
3225 description: cause instanceof Error ? cause.message : "The request failed.",
3226 }),
3227 );
3228 }
3229 },
3230 });
3231 }}
3232 >
3233 {faviconHost && hasText && !isPullRequestAutolink ? (
3234 <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>
3235 {linkChildren}
3236 </MarkdownExternalLinkContent>
3237 ) : (
3238 linkChildren
3239 )}
3240 </a>
3241 );
3242 if (!faviconHost || !href) {
3243 return link;
3244 }
3245 if (pullRequestPreviewTarget !== null) {
3246 return (
3247 <PullRequestLinkPreview
3248 link={link}
3249 originalUrl={href}
3250 target={pullRequestPreviewTarget}
3251 confirmBeforeOpen={confirmBeforeOpen}
3252 onOpenPullRequest={(targetUrl) =>
3253 openChangeRequestLink(
3254 {
3255 metaKey: false,
3256 ctrlKey: false,
3257 preventDefault: () => undefined,
3258 stopPropagation: () => undefined,
3259 },
3260 targetUrl,
3261 undefined,
3262 environmentId ?? undefined,
3263 )
3264 }
3265 onOpenFallback={openDeferredMarkdownLink}
3266 />
3267 );
3268 }
3269 return (
3270 <Tooltip>
3271 <TooltipTrigger render={link} />
3272 <TooltipPopup side="top">{href}</TooltipPopup>
3273 </Tooltip>
3274 );
3275 }
3276
3277 const label = nodeToPlainText(children);
3278 const start = node?.position?.start.offset;
3279 const end = node?.position?.end.offset;
3280 const source = start !== undefined && end !== undefined ? text.slice(start, end) : "";
3281 const copyMarkdown =
3282 source.startsWith("[") && source.includes("](")
3283 ? source
3284 : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`;
3285 return fileLinkChip(
3286 fileLinkMeta,
3287 copyMarkdown,
3288 normalizedHref,
3289 isMarkdownFileLinkLabel(label, normalizedHref) ? undefined : children,
3290 );
3291 },
3292 code: function MarkdownCode({ node, children, className, ...props }) {
3293 const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip } = use(
3294 ChatMarkdownRendererContext,
3295 );
3296 if (node?.properties?.dataInlineCode != null) {
3297 const codeText = nodeToPlainText(children);
3298 const fileLinkMeta =
3299 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
3300 resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd);
3301 if (fileLinkMeta) {
3302 return fileLinkChip(
3303 fileLinkMeta,
3304 `\`${codeText}\``,
3305 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),
3306 );
3307 }
3308 }
3309 return (
3310 <code {...props} className={className}>
3311 {children}
3312 </code>
3313 );
3314 },
3315 img: function MarkdownImage({ node, title, src, alt, ...props }) {
3316 const {
3317 expandMedia,
3318 cwd,
3319 environmentId,
3320 githubMedia,
3321 imageBaseDir,
3322 threadRef,
3323 renderContextReference,
3324 } = use(ChatMarkdownRendererContext);
3325 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
3326 const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null;
3327 if (contextReference) {
3328 const label = alt || contextReference.contextId;
3329 return renderContextReference ? (
3330 renderContextReference({ ...contextReference, label })
3331 ) : (
3332 <span>{label}</span>
3333 );
3334 }
3335 const localSrc = node?.properties?.dataLocalSrc;
3336 const markdownTitle = node?.properties?.dataMarkdownTitle;
3337 const standalone = node?.properties?.dataStandalone === true;
3338 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
3339 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
3340 const srcString =
3341 typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : "";
3342 const classifiedSrc =
3343 typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString;
3344 const altText = alt ?? "";
3345 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);
3346 const { className, style: _style, width, height, ...imageProps } = props;
3347 const authoredSizeStyle = authoredImageSizeStyle(width, height);
3348 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
3349 const kind = mediaKindFromPath(classifiedSrc) ?? "image";
3350 const directUri = imageSource._tag === "Direct" ? imageSource.uri : null;
3351 const githubMediaUrl =
3352 directUri === null ? null : githubMediaFetchUrl(resolveProtocolRelativeMediaUrl(directUri));
3353 if (
3354 githubMedia &&
3355 cwd !== undefined &&
3356 environmentId !== null &&
3357 directUri !== null &&
3358 githubMediaUrl !== null
3359 ) {
3360 return (
3361 <ChatMarkdownAssetImage
3362 environmentId={environmentId}
3363 resource={{ _tag: "github-media", cwd, url: githubMediaUrl }}
3364 alt={altText}
3365 kind={kind}
3366 copyMarkdown={copyMarkdown}
3367 standalone={standalone}
3368 className={className}
3369 style={authoredSizeStyle}
3370 imageProps={imageProps}
3371 srcFragment={markdownImageSourceFragment(classifiedSrc)}
3372 originalUrl={resolveProtocolRelativeMediaUrl(directUri)}
3373 // A pull request body draws its own boxes; keep the author's, not the workspace frame.
3374 framed={false}
3375 // A server too old to sign this resource, or one with no route to GitHub, still leaves
3376 // the public half of these working exactly as it did before. The canonical URL, not the
3377 // authored one: a `blob` link addresses the page, and only the raw host has the bytes.
3378 fallbackSrc={githubMediaUrl}
3379 onImageExpand={imageExpand}
3380 />
3381 );
3382 }
3383 if (imageSource._tag === "Direct") {
3384 const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri);
3385 const originalUrl =
3386 resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined;
3387 const reference = mediaUrlReference(imageSource.uri);
3388 const actionsSource: MediaActionSource = {
3389 kind,
3390 name: altText || kind,
3391 src: mediaSrc,
3392 ...(reference ? { reference } : {}),
3393 };
3394 if (kind === "video") {
3395 return (
3396 <ChatMarkdownVideo
3397 src={mediaSrc}
3398 alt={altText}
3399 copyMarkdown={copyMarkdown}
3400 originalUrl={originalUrl}
3401 style={authoredSizeStyle}
3402 actionsSource={actionsSource}
3403 />
3404 );
3405 }
3406 return (
3407 <ChatMarkdownImage
3408 key={mediaSrc}
3409 src={mediaSrc}
3410 alt={altText}
3411 copyMarkdown={copyMarkdown}
3412 standalone={standalone}
3413 className={className}
3414 style={authoredSizeStyle}
3415 imageProps={imageProps}
3416 actionsSource={actionsSource}
3417 originalUrl={originalUrl}
3418 onImageExpand={imageExpand}
3419 />
3420 );
3421 }
3422 if (imageSource._tag === "WorkspaceFile" && threadRef) {
3423 return (
3424 <ChatMarkdownAssetImage
3425 environmentId={threadRef.environmentId}
3426 resource={{
3427 _tag: "media-file",
3428 threadId: threadRef.threadId,
3429 path: imageSource.path,
3430 }}
3431 alt={altText}
3432 kind={kind}
3433 copyMarkdown={copyMarkdown}
3434 srcFragment={markdownImageSourceFragment(classifiedSrc)}
3435 standalone={standalone}
3436 style={authoredSizeStyle}
3437 workspaceRoot={cwd}
3438 onImageExpand={imageExpand}
3439 />
3440 );
3441 }
3442 return <ChatMarkdownImageFallback alt={altText} copyMarkdown={copyMarkdown} kind={kind} />;
3443 },
3444 table: function MarkdownTableRenderer({ node: _node, ...props }) {
3445 return <MarkdownTable {...props} />;
3446 },
3447 details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) {
3448 return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
3449 },
3450 pre: function MarkdownPre({ node, children, ...props }) {
3451 const { resolvedTheme, diffThemeName, expandMedia, isStreaming, onRunShellCommand, text } = use(
3452 ChatMarkdownRendererContext,
3453 );
3454 const codeBlock = extractCodeBlock(children);
3455 if (!codeBlock) {
3456 return <pre {...props}>{children}</pre>;
3457 }
3458
3459 const language = extractFenceLanguage(codeBlock.className);
3460 const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
3461 const highlightedCode = (
3462 <RenderErrorBoundary
3463 resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
3464 fallback={<pre {...props}>{children}</pre>}
3465 >
3466 {/* Reserve the block's height but stay hidden until Shiki has colored
3467 it, so plain text never flashes before the highlighted version. */}
3468 <Suspense
3469 fallback={
3470 <pre {...props} className="invisible" aria-hidden>
3471 {children}
3472 </pre>
3473 }
3474 >
3475 <SuspenseShikiCodeBlock
3476 className={codeBlock.className}
3477 code={codeBlock.code}
3478 themeName={diffThemeName}
3479 isStreaming={isStreaming}
3480 />
3481 </Suspense>
3482 </RenderErrorBoundary>
3483 );
3484 if (language === "mermaid") {
3485 return (
3486 <MarkdownMermaidCodeBlock
3487 code={codeBlock.code}
3488 fenceTitle={fenceTitle}
3489 theme={resolvedTheme}
3490 isStreaming={isStreaming}
3491 onExpand={(src) => expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })}
3492 >
3493 {highlightedCode}
3494 </MarkdownMermaidCodeBlock>
3495 );
3496 }
3497 return (
3498 <MarkdownCodeBlock
3499 code={codeBlock.code}
3500 language={language}
3501 fenceTitle={fenceTitle}
3502 theme={resolvedTheme}
3503 onRunShellCommand={
3504 onRunShellCommand && !isStreaming && isClosedCodeFence(node, text)
3505 ? onRunShellCommand
3506 : undefined
3507 }
3508 isStreaming={isStreaming}
3509 >
3510 {highlightedCode}
3511 </MarkdownCodeBlock>
3512 );
3513 },
3514} satisfies Components;
3515
3516function ChatMarkdown({
3517 text,
3518 className,
3519 lineBreaks = false,
3520 parseRawHtml = true,
3521 extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
3522 ...props
3523}: ChatMarkdownProps) {
3524 const {
3525 componentState,
3526 handleCopy,
3527 markdownRef,
3528 markdownUrlTransform,
3529 localMediaPreview,
3530 setLocalMediaPreview,
3531 } = useChatMarkdownState({ text, ...props });
3532 const incrementalParsing =
3533 props.isStreaming === true &&
3534 extraRemarkPlugins.length === 0 &&
3535 /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text);
3536 const remarkPlugins = useMemo(
3537 () => [
3538 ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
3539 ...extraRemarkPlugins,
3540 ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []),
3541 ],
3542 [extraRemarkPlugins, incrementalParsing, lineBreaks],
3543 );
3544
3545 // react-markdown converts unparsed HTML nodes to text when skipHtml is false.
3546 // Keep that behavior explicit because literal mode depends on escaping the
3547 // complete source token instead of dropping it from the rendered message.
3548 return (
3549 <div
3550 ref={markdownRef}
3551 className={cn(
3552 "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)] [overflow-wrap:anywhere] [word-break:break-word]",
3553 className,
3554 )}
3555 // Gates the fade-in for blocks that arrive while the response streams.
3556 data-streaming={componentState.isStreaming ? "" : undefined}
3557 onCopy={handleCopy}
3558 >
3559 <ChatMarkdownRendererContext value={componentState}>
3560 <ReactMarkdown
3561 remarkPlugins={remarkPlugins}
3562 rehypePlugins={
3563 parseRawHtml
3564 ? CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS
3565 : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS
3566 }
3567 skipHtml={false}
3568 components={CHAT_MARKDOWN_COMPONENTS}
3569 urlTransform={markdownUrlTransform}
3570 >
3571 {text}
3572 </ReactMarkdown>
3573 </ChatMarkdownRendererContext>
3574 {localMediaPreview ? (
3575 <ExpandedImageDialog
3576 preview={localMediaPreview}
3577 onClose={() => setLocalMediaPreview(null)}
3578 />
3579 ) : null}
3580 </div>
3581 );
3582}
3583
3584export default memo(ChatMarkdown);