| 1 | import { MarkdownFindContext, useFindRevealRef } from "./chat/markdownFindContext"; |
| 2 | import { |
| 3 | buildFileLinkParentSuffixByPath, |
| 4 | fileLinkLabel, |
| 5 | resolvePathLinkTarget, |
| 6 | } from "@t3tools/shared/fileLinks"; |
| 7 | import { |
| 8 | isWindowsDrivePathHref, |
| 9 | normalizeMarkdownLinkDestination, |
| 10 | extractInlineCodeSpans, |
| 11 | extractMarkdownLinkHrefs, |
| 12 | inlineCodeFilePathCandidate, |
| 13 | } from "@t3tools/shared/markdownLinks"; |
| 14 | import { isAbsolutePath } from "@t3tools/shared/path"; |
| 15 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; |
| 16 | import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; |
| 17 | import { |
| 18 | CHAT_MARKDOWN_REMARK_PLUGINS, |
| 19 | CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, |
| 20 | CHAT_MARKDOWN_REHYPE_PLUGINS, |
| 21 | } from "@t3tools/shared/markdownPipeline"; |
| 22 | import { useAtomValue } from "@effect/atom-react"; |
| 23 | import { |
| 24 | COMPOSER_CONTEXT_CLIPBOARD_MIME, |
| 25 | encodeComposerContextClipboardHtml, |
| 26 | } from "@t3tools/shared/composerContextClipboard"; |
| 27 | import { |
| 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"; |
| 48 | import { Check, Copy, Maximize2, Minimize2 } from "lucide"; |
| 49 | import { |
| 50 | AuthPreviewOperateScope, |
| 51 | type AssetResource, |
| 52 | type EnvironmentId, |
| 53 | type ScopedThreadRef, |
| 54 | type ServerProviderSkill, |
| 55 | type ThreadPullRequestKey, |
| 56 | } from "@t3tools/contracts"; |
| 57 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; |
| 58 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; |
| 59 | import { |
| 60 | isAtomCommandInterrupted, |
| 61 | squashAtomCommandFailure, |
| 62 | type AtomCommandResult, |
| 63 | } from "@t3tools/client-runtime/state/runtime"; |
| 64 | import { |
| 65 | codexArtifactTemplatePresentationLabel, |
| 66 | type CodexArtifactTemplate, |
| 67 | type CodexArtifactTemplateKind, |
| 68 | } from "@t3tools/shared/codexArtifactTemplates"; |
| 69 | import { |
| 70 | classifyMarkdownImageSource, |
| 71 | markdownImageSourceFragment, |
| 72 | } from "@t3tools/client-runtime/markdown-images"; |
| 73 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; |
| 74 | import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; |
| 75 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| 76 | import * as Cause from "effect/Cause"; |
| 77 | import { AsyncResult } from "effect/reactivity"; |
| 78 | import 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"; |
| 96 | import type { |
| 97 | Components, |
| 98 | ExtraProps as ReactMarkdownExtraProps, |
| 99 | Options as ReactMarkdownOptions, |
| 100 | } from "react-markdown"; |
| 101 | import ReactMarkdown from "react-markdown"; |
| 102 | import { toHtml } from "hast-util-to-html"; |
| 103 | import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; |
| 104 | import { defaultUrlTransform } from "react-markdown"; |
| 105 | import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; |
| 106 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; |
| 107 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; |
| 108 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| 109 | import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; |
| 110 | import { |
| 111 | artifactTemplateFromHastProperties, |
| 112 | renderCodexInlineDirectivesAsMarkdown, |
| 113 | } from "@t3tools/shared/codexMarkdownDirectives"; |
| 114 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 115 | import { |
| 116 | resolveMarkdownMediaPreview, |
| 117 | type ExpandedImagePreview, |
| 118 | } from "./chat/ExpandedImagePreview"; |
| 119 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 120 | import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; |
| 121 | import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; |
| 122 | import { MediaActions, type MediaActionSource } from "./media/MediaActions"; |
| 123 | import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; |
| 124 | import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; |
| 125 | import { FileTagChipContent } from "./chat/FileTagChip"; |
| 126 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 127 | import { |
| 128 | revealInFileExplorerLabelForKind, |
| 129 | revealInFileExplorerLabelForOs, |
| 130 | } from "./preview/fileExplorerLabel"; |
| 131 | import { |
| 132 | resolveExternalWebLinkHost, |
| 133 | showExternalLinkContextMenu, |
| 134 | } from "./chat/externalLinkContextMenu"; |
| 135 | import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; |
| 136 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 137 | import { Button } from "./ui/button"; |
| 138 | import { MorphIcon } from "~/components/MorphIcon"; |
| 139 | import { ContextChip } from "./ContextChip"; |
| 140 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; |
| 141 | import { ScrollArea } from "./ui/scroll-area"; |
| 142 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 143 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 144 | import { recordVisitForThread } from "../browserHistoryStore"; |
| 145 | import { |
| 146 | PreferredEditorEnvironmentRequiredError, |
| 147 | useOpenInPreferredEditor, |
| 148 | usePreferredEditor, |
| 149 | } from "../editorPreferences"; |
| 150 | import { openInEditorMenuLabel } from "../editorLabels"; |
| 151 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 152 | import { fnv1a32 } from "../lib/diffRendering"; |
| 153 | import { LRUCache } from "../lib/lruCache"; |
| 154 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; |
| 155 | import { GitHubIcon } from "./Icons"; |
| 156 | import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; |
| 157 | import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; |
| 158 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; |
| 159 | import { MermaidDiagram } from "./chat/MermaidDiagram"; |
| 160 | import { useTheme } from "../hooks/useTheme"; |
| 161 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; |
| 162 | import { |
| 163 | chatMarkdownClipboardPayload, |
| 164 | serializeTableElementToCsv, |
| 165 | serializeTableElementToMarkdown, |
| 166 | } from "../markdown-clipboard"; |
| 167 | import { |
| 168 | resolveInlineCodeFileLinkMeta, |
| 169 | resolveMarkdownFileLinkMeta, |
| 170 | rewriteMarkdownFileUriHref, |
| 171 | shouldOpenMarkdownFileLinkInBrowserByDefault, |
| 172 | shouldOpenMarkdownFileLinkInEditor, |
| 173 | type MarkdownFileLinkMeta, |
| 174 | } from "../markdown-links"; |
| 175 | import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; |
| 176 | import { isElectron } from "../env"; |
| 177 | import { readLocalApi } from "../localApi"; |
| 178 | import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; |
| 179 | import { cn } from "../lib/utils"; |
| 180 | import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; |
| 181 | import { useRightPanelStore } from "../rightPanelStore"; |
| 182 | import { readThreadShell, useProjects } from "../state/entities"; |
| 183 | import { serverEnvironment } from "../state/server"; |
| 184 | import { shellEnvironment } from "../state/shell"; |
| 185 | import { assetEnvironment } from "../state/assets"; |
| 186 | import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session"; |
| 187 | import { previewEnvironment } from "../state/preview"; |
| 188 | import { useAtomCommand } from "../state/use-atom-command"; |
| 189 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 190 | import { projectEnvironment } from "../state/projects"; |
| 191 | import { |
| 192 | claimWorkspaceBasenameLookup, |
| 193 | needsWorkspaceBasenameLookup, |
| 194 | pickWorkspaceBasenameMatch, |
| 195 | WORKSPACE_BASENAME_LOOKUP_LIMIT, |
| 196 | } from "../workspaceBasenameLookup"; |
| 197 | import { |
| 198 | parseChangeRequestUrl, |
| 199 | pullRequestCandidateUrlFromReferenceAutolink, |
| 200 | resolvePullRequestPreviewTarget, |
| 201 | useOpenChangeRequestLink, |
| 202 | } from "~/lib/openPullRequestLink"; |
| 203 | import { useOpenLink } from "../browser/useOpenLink"; |
| 204 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; |
| 205 | import { isPreviewAvailableFor } from "../browser/previewRuntime"; |
| 206 | import { |
| 207 | isBrowserPreviewFile, |
| 208 | openFileInPreview, |
| 209 | openUrlInPreview, |
| 210 | BrowserPreviewUnavailableError, |
| 211 | BrowserSettingsReadError, |
| 212 | } from "../browser/openFileInPreview"; |
| 213 | import { resolveLinkTarget } from "../browser/browserLinkTarget"; |
| 214 | import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; |
| 215 | |
| 216 | interface 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 | |
| 251 | export interface ChatMarkdownContextReference { |
| 252 | kind: string; |
| 253 | contextId: string; |
| 254 | label: string; |
| 255 | } |
| 256 | |
| 257 | export function canUseMarkdownFileShellActions( |
| 258 | environmentId: EnvironmentId | null, |
| 259 | remoteOpenMode: RemoteOpenMode, |
| 260 | isRemoteOpenResolved: boolean, |
| 261 | ): boolean { |
| 262 | return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; |
| 263 | } |
| 264 | |
| 265 | export 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 | |
| 279 | export 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 | |
| 292 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 293 | const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = []; |
| 294 | |
| 295 | const 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 | |
| 308 | function 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 | |
| 356 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; |
| 357 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; |
| 358 | const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; |
| 359 | const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; |
| 360 | |
| 361 | interface 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 | |
| 370 | function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { |
| 371 | console.error("[chat-markdown] action failed", context, cause); |
| 372 | } |
| 373 | |
| 374 | const highlightedCodeCache = new LRUCache<string>( |
| 375 | MAX_HIGHLIGHT_CACHE_ENTRIES, |
| 376 | MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, |
| 377 | ); |
| 378 | |
| 379 | function 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 | */ |
| 396 | function 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 | |
| 408 | const 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 | |
| 444 | function 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 | |
| 451 | const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; |
| 452 | const 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 */ |
| 455 | function 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 | |
| 463 | function 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 | |
| 481 | function 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 | |
| 498 | function 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 | |
| 511 | function 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 | |
| 539 | function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { |
| 540 | return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; |
| 541 | } |
| 542 | |
| 543 | function estimateHighlightedSize(html: string, code: string): number { |
| 544 | return Math.max(html.length * 2, code.length * 3); |
| 545 | } |
| 546 | |
| 547 | function readInitialWordWrapSetting(): boolean { |
| 548 | return getClientSettings().wordWrap; |
| 549 | } |
| 550 | |
| 551 | function 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 | |
| 679 | function 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 | */ |
| 738 | const THREAD_FIND_IGNORE_PROPS = { |
| 739 | "data-thread-find-ignore": "", |
| 740 | } as React.HTMLAttributes<HTMLDivElement>; |
| 741 | |
| 742 | export 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 | */ |
| 798 | function 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 | |
| 834 | function 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 | */ |
| 1008 | function 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 | |
| 1084 | interface SuspenseShikiCodeBlockProps { |
| 1085 | className: string | undefined; |
| 1086 | code: string; |
| 1087 | themeName: DiffThemeName; |
| 1088 | isStreaming: boolean; |
| 1089 | } |
| 1090 | |
| 1091 | function 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 | |
| 1127 | interface UncachedShikiCodeBlockProps { |
| 1128 | code: string; |
| 1129 | language: string; |
| 1130 | themeName: DiffThemeName; |
| 1131 | cacheKey: string; |
| 1132 | isStreaming: boolean; |
| 1133 | preserveLines: boolean; |
| 1134 | } |
| 1135 | |
| 1136 | function 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 | |
| 1189 | interface 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 | |
| 1214 | const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; |
| 1215 | |
| 1216 | function 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 | |
| 1224 | const 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. */ |
| 1227 | const failedFaviconHosts = new Set<string>(); |
| 1228 | |
| 1229 | /** Sites whose brand mark (drawn in `currentColor`) replaces the fetched favicon so it follows the theme. */ |
| 1230 | function 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 | |
| 1236 | const 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 | |
| 1266 | const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; |
| 1267 | const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( |
| 1268 | "max-h-[30rem]", |
| 1269 | CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, |
| 1270 | ); |
| 1271 | const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; |
| 1272 | const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; |
| 1273 | const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( |
| 1274 | "h-auto w-auto object-contain", |
| 1275 | CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, |
| 1276 | ); |
| 1277 | |
| 1278 | function 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 ``; |
| 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 | */ |
| 1290 | function 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 | |
| 1312 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( |
| 1313 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, |
| 1314 | CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, |
| 1315 | ); |
| 1316 | const MarkdownLinkContext = React.createContext(false); |
| 1317 | |
| 1318 | function 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 | |
| 1342 | function 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. */ |
| 1359 | function 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 | |
| 1384 | const 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 | */ |
| 1403 | function 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 | |
| 1529 | function 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. */ |
| 1565 | export 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 | |
| 1701 | function 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 | |
| 1707 | function 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 | |
| 1716 | function 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 | */ |
| 1741 | function 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 | */ |
| 1755 | function 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 | |
| 1769 | const SANITIZED_FRAGMENT_PREFIX = "user-content-"; |
| 1770 | |
| 1771 | function decodeMarkdownFragmentId(href: string): string { |
| 1772 | const encodedId = href.slice(1); |
| 1773 | try { |
| 1774 | return decodeURIComponent(encodedId); |
| 1775 | } catch { |
| 1776 | return encodedId; |
| 1777 | } |
| 1778 | } |
| 1779 | |
| 1780 | function 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 | |
| 1788 | function 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 | |
| 1807 | function 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 | |
| 1825 | type MarkdownHastNode = { |
| 1826 | type?: string; |
| 1827 | value?: string; |
| 1828 | tagName?: string; |
| 1829 | properties?: Record<string, unknown>; |
| 1830 | children?: MarkdownHastNode[]; |
| 1831 | }; |
| 1832 | |
| 1833 | function 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. */ |
| 1910 | function 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 | */ |
| 1923 | function 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. |
| 1958 | const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ |
| 1959 | ...CHAT_MARKDOWN_REHYPE_PLUGINS, |
| 1960 | rehypeHeadingIds, |
| 1961 | rehypeVideoRows, |
| 1962 | ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; |
| 1963 | |
| 1964 | const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ |
| 1965 | rehypeHeadingIds, |
| 1966 | rehypeVideoRows, |
| 1967 | ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; |
| 1968 | |
| 1969 | function 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 | |
| 2019 | const 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 | |
| 2368 | function 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 | |
| 2394 | function 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 | |
| 2893 | const 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. |
| 2900 | function 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. |
| 2917 | const 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 | |
| 3516 | function 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 | |
| 3584 | export default memo(ChatMarkdown); |