import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";import { useAtomValue } from "@effect/atom-react";import { CheckIcon, ChevronRightIcon, CopyIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, ImageIcon, InfoIcon, LightbulbIcon, MailIcon, Maximize2Icon, MessageSquareIcon, MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, WrapTextIcon, type LucideIcon,} from "lucide-react";import type { AssetResource, EnvironmentId, ScopedThreadRef, ServerProviderSkill, ThreadPullRequestKey,} from "@t3tools/contracts";import { faviconUrlForOrigin } from "@t3tools/shared/favicon";import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, type CodexArtifactTemplateKind,} from "@t3tools/client-runtime/codex-artifact-templates";import { classifyMarkdownImageSource, markdownImageSourceFragment,} from "@t3tools/client-runtime/markdown-images";import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";import * as Cause from "effect/Cause";import { AsyncResult } from "effect/unstable/reactivity";import React, { Children, Suspense, type CSSProperties, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, memo, useEffect, useMemo, useRef, useState, type ReactNode,} from "react";import type { Components, Options as ReactMarkdownOptions } from "react-markdown";import ReactMarkdown from "react-markdown";import { defaultUrlTransform } from "react-markdown";import rehypeRaw from "rehype-raw";import rehypeSanitize, { defaultSchema } from "rehype-sanitize";import remarkBreaks from "remark-breaks";import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";import { AssistantCitationChip } from "./chat/AssistantCitationChip";import remarkGfm from "remark-gfm";import { remarkGithubAlerts } from "../markdown-github-alerts";import { artifactTemplateFromHastProperties, CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, remarkCodexDirectives, renderCodexFileCitationsAsMarkdown,} from "@t3tools/client-runtime/codex-markdown-directives";import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";import { resolveMarkdownMediaPreview, type ExpandedImagePreview,} from "./chat/ExpandedImagePreview";import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery";import { MediaVideoPlayer } from "./media/MediaVideoPlayer";import { MediaActions, type MediaActionSource } from "./media/MediaActions";import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";import { PierreEntryIcon } from "./chat/PierreEntryIcon";import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs,} from "./preview/fileExplorerLabel";import { resolveExternalWebLinkHost, showExternalLinkContextMenu,} from "./chat/externalLinkContextMenu";import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { Button } from "./ui/button";import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";import { ScrollArea } from "./ui/scroll-area";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";import { stackedThreadToast, toastManager } from "./ui/toast";import { recordVisitForThread } from "../browserHistoryStore";import { PreferredEditorEnvironmentRequiredError, useOpenInPreferredEditor, usePreferredEditor,} from "../editorPreferences";import { openInEditorMenuLabel } from "../editorLabels";import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";import { fnv1a32 } from "../lib/diffRendering";import { LRUCache } from "../lib/lruCache";import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";import { GitHubIcon } from "./Icons";import { RenderErrorBoundary } from "./RenderErrorBoundary";import { useTheme } from "../hooks/useTheme";import { getClientSettings, useClientSettings } from "../hooks/useSettings";import { chatMarkdownClipboardPayload, serializeTableElementToCsv, serializeTableElementToMarkdown,} from "../markdown-clipboard";import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";import { extractMarkdownLinkHrefs, isWindowsDrivePathHref, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta,} from "../markdown-links";import { readLocalApi } from "../localApi";import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";import { cn } from "../lib/utils";import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";import { useRightPanelStore } from "../rightPanelStore";import { readThreadShell, useProjects } from "../state/entities";import { serverEnvironment } from "../state/server";import { shellEnvironment } from "../state/shell";import { assetEnvironment } from "../state/assets";import { usePreparedConnection } from "../state/session";import { previewEnvironment } from "../state/preview";import { useAtomCommand } from "../state/use-atom-command";import { useAtomQueryRunner } from "../state/use-atom-query-runner";import { projectEnvironment } from "../state/projects";import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT,} from "../workspaceBasenameLookup";import { findProjectForChangeRequest, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, useOpenChangeRequestLink,} from "~/lib/openPullRequestLink";import { useOpenLink } from "../browser/useOpenLink";import { writeTextToClipboard } from "../hooks/useCopyToClipboard";import { isPreviewSupportedInRuntime } from "../previewStateStore";import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";import { isBrowserPreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, BrowserSettingsReadError,} from "../browser/openFileInPreview";import { resolveLinkTarget } from "../browser/browserLinkTarget";import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; /** Panel that receives pull request links, including the standalone PR view. */ pullRequestPanelRef?: ScopedThreadRef | undefined; /** Environment that owns non-thread markdown, such as a pull request panel. */ environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; /** Parse sanitized raw HTML instead of displaying its source text. */ parseRawHtml?: boolean; /** Append a prompt that invokes a newly created artifact-template skill. */ onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; /** Directory that anchors relative links and images; defaults to `cwd`. Set to the file's own directory when rendering a markdown file. */ imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;} export function canUseMarkdownFileShellActions( environmentId: EnvironmentId | null, remoteOpenMode: RemoteOpenMode, isRemoteOpenResolved: boolean,): boolean { return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec";} export function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; canOpenMedia?: boolean;}): boolean { return ( input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel || input.canOpenMedia === true );} export function shouldUseMarkdownFileBrowserPrimaryAction(input: { iconPath: string; canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean;}): boolean { return ( input.canOpenInBrowser && (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || (!input.canOpenInEditor && !input.canOpenInPanel)) );} const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, presentation: PresentationIcon, spreadsheet: FileSpreadsheetIcon, site: GlobeIcon, "google-docs": FileTextIcon, "google-slides": PresentationIcon, "google-sheets": FileSpreadsheetIcon, image: ImageIcon, email: MailIcon, slack: MessageSquareIcon,} satisfies Record<CodexArtifactTemplateKind, LucideIcon>; function CodexArtifactTemplateCard(props: { readonly template: CodexArtifactTemplate; readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined;}) { const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); return ( <div role="group" aria-label={`${props.template.displayName} template`} className="chat-markdown-artifact-template 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" data-artifact-kind={props.template.artifactKind} data-markdown-copy={`${props.template.displayName} (${presentationLabel})\n\n`} data-skill-name={props.template.skillName} > <div className="flex min-w-0 flex-1 items-center gap-3"> <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"> <Icon aria-hidden className="size-5" /> <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full border border-background bg-fuchsia-500 text-white shadow-xs"> <SparklesIcon aria-hidden className="size-2.5" /> </span> </span> <span className="min-w-0"> <span className="block truncate text-sm font-medium text-foreground"> {props.template.displayName} </span> <span className="block text-xs text-muted-foreground">{presentationLabel}</span> </span> </div> {props.onUse ? ( <Button type="button" size="sm" variant="outline" className="shrink-0" onClick={() => props.onUse?.(props.template)} > Use template </Button> ) : null} </div> );} const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; interface MarkdownActionFailureContext { readonly operation: string; readonly target?: string; readonly format?: "markdown" | "csv"; readonly language?: string; readonly fenceTitle?: string; readonly copyTarget?: string;} function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { console.error("[chat-markdown] action failed", context, cause);} const highlightedCodeCache = new LRUCache<string>( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); const firstLine = markdown.slice( listItemStart, firstLineEnd === -1 ? markdown.length : firstLineEnd, ); const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]);} /** * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits one-character * markers. Wider markers can extend past it and get clipped by a collapsed * message's overflow. Widen the gutter to fit the widest marker, including a * negative marker's minus sign. */function orderedListGutterStyle( itemCount: number, start: unknown,): { "--list-gutter": string } | undefined { const parsedStart = Number.parseInt(String(start ?? 1), 10); const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); if (markerWidth <= 1) return undefined; return { "--list-gutter": `${markerWidth + 1}ch` };} type MarkdownImageHastNode = { type?: string; tagName?: string; properties?: Record<string, unknown>; children?: MarkdownImageHastNode[];}; function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { return (node.children ?? []).filter( (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), );} /** * An image that is the only content of its block (optionally wrapped in a * link) is almost always a screenshot or figure, so it gets a reserved slot * while it loads. Images mixed with text or other images — badge rows, icons * in a sentence — stay inline at their natural size, since a placeholder taller * than the image would move the page more than the image itself does. *//** Containers whose sole child image reads as a figure rather than part of a sentence. */const STANDALONE_IMAGE_BLOCKS = new Set([ "p", "div", "li", "td", "th", "figure", "center", "blockquote",]); function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { const children = meaningfulHastChildren(node); if (children.length !== 1) return undefined; const only = children[0]; if (only?.type !== "element") return undefined; if (only.tagName === "img") return only; // A link, emphasis, or similar inline wrapper around the image still counts // as long as nothing else shares the block. return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" ? soleImageDescendant(only) : undefined;} function markStandaloneImages(node: MarkdownImageHastNode) { // A raw `<img>` on its own line reaches the root without a paragraph. if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { const image = soleImageDescendant(node); if (image) image.properties = { ...image.properties, dataStandalone: true }; } node.children?.forEach((child) => { if (child.type === "element") markStandaloneImages(child); });} /** Carries authored image source metadata through the sanitizer to the image renderer. */function rehypePreserveImageSourceMeta() { return (tree: MarkdownImageHastNode) => { const visit = (node: MarkdownImageHastNode) => { const src = node.properties?.src; const title = node.properties?.title; if (node.type === "element" && node.tagName === "img") { node.properties = { ...node.properties, ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}), ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}), }; } node.children?.forEach(visit); }; visit(tree); markStandaloneImages(tree); };} const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], img: [ ...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle", "dataStandalone", ], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation"], src: [...(defaultSchema.protocols?.src ?? []), "file"], },} satisfies Parameters<typeof rehypeSanitize>[0]; const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode,] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, remarkBreaks, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode,] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */const GITHUB_ALERT_PRESENTATIONS: Record< string, { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string }> = { note: { label: "Note", Icon: InfoIcon, borderClassName: "border-blue-500/70", titleClassName: "text-blue-600 dark:text-blue-400", }, tip: { label: "Tip", Icon: LightbulbIcon, borderClassName: "border-emerald-500/70", titleClassName: "text-emerald-600 dark:text-emerald-400", }, important: {Lines 1–500 of 3148. Load more
import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, ChevronRightIcon, CopyIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, ImageIcon, InfoIcon, LightbulbIcon, MailIcon, Maximize2Icon, MessageSquareIcon, MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, WrapTextIcon, type LucideIcon, } from "lucide-react"; import type { AssetResource, EnvironmentId, ScopedThreadRef, ServerProviderSkill, ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, type CodexArtifactTemplateKind, } from "@t3tools/client-runtime/codex-artifact-templates"; import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, Suspense, type CSSProperties, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, memo, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; import { artifactTemplateFromHastProperties, CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, remarkCodexDirectives, renderCodexFileCitationsAsMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { resolveMarkdownMediaPreview, type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, } from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; import { PreferredEditorEnvironmentRequiredError, useOpenInPreferredEditor, usePreferredEditor, } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { extractMarkdownLinkHrefs, isWindowsDrivePathHref, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { findProjectForChangeRequest, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, BrowserSettingsReadError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; /** Panel that receives pull request links, including the standalone PR view. */ pullRequestPanelRef?: ScopedThreadRef | undefined; /** Environment that owns non-thread markdown, such as a pull request panel. */ environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; /** Parse sanitized raw HTML instead of displaying its source text. */ parseRawHtml?: boolean; /** Append a prompt that invokes a newly created artifact-template skill. */ onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; /** Directory that anchors relative links and images; defaults to `cwd`. Set to the file's own directory when rendering a markdown file. */ imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>; } export function canUseMarkdownFileShellActions( environmentId: EnvironmentId | null, remoteOpenMode: RemoteOpenMode, isRemoteOpenResolved: boolean, ): boolean { return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; } export function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; canOpenMedia?: boolean; }): boolean { return ( input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel || input.canOpenMedia === true ); } export function shouldUseMarkdownFileBrowserPrimaryAction(input: { iconPath: string; canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; }): boolean { return ( input.canOpenInBrowser && (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || (!input.canOpenInEditor && !input.canOpenInPanel)) ); } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, presentation: PresentationIcon, spreadsheet: FileSpreadsheetIcon, site: GlobeIcon, "google-docs": FileTextIcon, "google-slides": PresentationIcon, "google-sheets": FileSpreadsheetIcon, image: ImageIcon, email: MailIcon, slack: MessageSquareIcon, } satisfies Record<CodexArtifactTemplateKind, LucideIcon>; function CodexArtifactTemplateCard(props: { readonly template: CodexArtifactTemplate; readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; }) { const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); return ( <div role="group" aria-label={`${props.template.displayName} template`} className="chat-markdown-artifact-template 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" data-artifact-kind={props.template.artifactKind} data-markdown-copy={`${props.template.displayName} (${presentationLabel})\n\n`} data-skill-name={props.template.skillName} > <div className="flex min-w-0 flex-1 items-center gap-3"> <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"> <Icon aria-hidden className="size-5" /> <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full border border-background bg-fuchsia-500 text-white shadow-xs"> <SparklesIcon aria-hidden className="size-2.5" /> </span> </span> <span className="min-w-0"> <span className="block truncate text-sm font-medium text-foreground"> {props.template.displayName} </span> <span className="block text-xs text-muted-foreground">{presentationLabel}</span> </span> </div> {props.onUse ? ( <Button type="button" size="sm" variant="outline" className="shrink-0" onClick={() => props.onUse?.(props.template)} > Use template </Button> ) : null} </div> ); } const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; interface MarkdownActionFailureContext { readonly operation: string; readonly target?: string; readonly format?: "markdown" | "csv"; readonly language?: string; readonly fenceTitle?: string; readonly copyTarget?: string; } function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { console.error("[chat-markdown] action failed", context, cause); } const highlightedCodeCache = new LRUCache<string>( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); const firstLine = markdown.slice( listItemStart, firstLineEnd === -1 ? markdown.length : firstLineEnd, ); const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } /** * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits one-character * markers. Wider markers can extend past it and get clipped by a collapsed * message's overflow. Widen the gutter to fit the widest marker, including a * negative marker's minus sign. */ function orderedListGutterStyle( itemCount: number, start: unknown, ): { "--list-gutter": string } | undefined { const parsedStart = Number.parseInt(String(start ?? 1), 10); const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); if (markerWidth <= 1) return undefined; return { "--list-gutter": `${markerWidth + 1}ch` }; } type MarkdownImageHastNode = { type?: string; tagName?: string; properties?: Record<string, unknown>; children?: MarkdownImageHastNode[]; }; function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { return (node.children ?? []).filter( (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), ); } /** * An image that is the only content of its block (optionally wrapped in a * link) is almost always a screenshot or figure, so it gets a reserved slot * while it loads. Images mixed with text or other images — badge rows, icons * in a sentence — stay inline at their natural size, since a placeholder taller * than the image would move the page more than the image itself does. */ /** Containers whose sole child image reads as a figure rather than part of a sentence. */ const STANDALONE_IMAGE_BLOCKS = new Set([ "p", "div", "li", "td", "th", "figure", "center", "blockquote", ]); function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { const children = meaningfulHastChildren(node); if (children.length !== 1) return undefined; const only = children[0]; if (only?.type !== "element") return undefined; if (only.tagName === "img") return only; // A link, emphasis, or similar inline wrapper around the image still counts // as long as nothing else shares the block. return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" ? soleImageDescendant(only) : undefined; } function markStandaloneImages(node: MarkdownImageHastNode) { // A raw `<img>` on its own line reaches the root without a paragraph. if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { const image = soleImageDescendant(node); if (image) image.properties = { ...image.properties, dataStandalone: true }; } node.children?.forEach((child) => { if (child.type === "element") markStandaloneImages(child); }); } /** Carries authored image source metadata through the sanitizer to the image renderer. */ function rehypePreserveImageSourceMeta() { return (tree: MarkdownImageHastNode) => { const visit = (node: MarkdownImageHastNode) => { const src = node.properties?.src; const title = node.properties?.title; if (node.type === "element" && node.tagName === "img") { node.properties = { ...node.properties, ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}), ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}), }; } node.children?.forEach(visit); }; visit(tree); markStandaloneImages(tree); }; } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], img: [ ...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle", "dataStandalone", ], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation"], src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters<typeof rehypeSanitize>[0]; const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, remarkBreaks, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ const GITHUB_ALERT_PRESENTATIONS: Record< string, { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } > = { note: { label: "Note", Icon: InfoIcon, borderClassName: "border-blue-500/70", titleClassName: "text-blue-600 dark:text-blue-400", }, tip: { label: "Tip", Icon: LightbulbIcon, borderClassName: "border-emerald-500/70", titleClassName: "text-emerald-600 dark:text-emerald-400", }, important: {