Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
293 changes: 214 additions & 79 deletions apps/web/src/components/ChatMarkdown.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,38 @@ type MarkdownImageHastNode = {
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.
*/
function markStandaloneImages(node: MarkdownImageHastNode) {
const children = meaningfulHastChildren(node);
// Only a block can make its image standalone; a link that wraps an image
// is still inline content of whatever block holds the link.
if (children.length === 1 && node.tagName !== "a") {
let only = children[0];
if (only?.type === "element" && only.tagName === "a") {
const linkChildren = meaningfulHastChildren(only);
only = linkChildren.length === 1 ? linkChildren[0] : undefined;
}
if (only?.type === "element" && only.tagName === "img") {
only.properties = { ...only.properties, dataStandalone: true };
}
}
node.children?.forEach((child) => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (child.type === "element") markStandaloneImages(child);
});
}

/** Carries authored image source metadata through the sanitizer to the image renderer. */
function rehypePreserveImageSourceMeta() {
return (tree: MarkdownImageHastNode) => {
Expand All @@ -374,6 +406,7 @@ function rehypePreserveImageSourceMeta() {
};

visit(tree);
markStandaloneImages(tree);
};
}

Expand All @@ -386,7 +419,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
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"],
img: [
...(defaultSchema.attributes?.img ?? []),
"dataLocalSrc",
"dataMarkdownTitle",
"dataStandalone",
],
},
protocols: {
...defaultSchema.protocols,
Expand Down Expand Up @@ -1200,7 +1238,6 @@ function authoredImageSizeStyle(
}

const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
);
Expand Down Expand Up @@ -1242,13 +1279,26 @@ function expandableMarkdownImageProps(
};
}

function ChatMarkdownMediaUnavailableLabel(props: {
readonly alt: string;
readonly kind?: "image" | "video" | undefined;
}) {
const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
return (
<span className="inline-flex items-center gap-1.5">
<TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
{props.alt.length > 0 ? `${label} · ${props.alt}` : label}
</span>
);
}

/** Inline chip for an image that sits in a line of text or can never load. */
function ChatMarkdownImageFallback(props: {
readonly alt: string;
readonly copyMarkdown?: string | undefined;
readonly kind?: "image" | "video";
readonly actionsSource?: MediaActionSource;
readonly actionsSource?: MediaActionSource | undefined;
}) {
const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
const content = (
<span
data-markdown-copy={props.copyMarkdown}
Expand All @@ -1257,10 +1307,7 @@ function ChatMarkdownImageFallback(props: {
"rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",
)}
>
<span className="inline-flex items-center gap-1.5">
<TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
{props.alt.length > 0 ? `${label} · ${props.alt}` : label}
</span>
<ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} />
</span>
);
return props.actionsSource ? (
Expand All @@ -1270,6 +1317,137 @@ function ChatMarkdownImageFallback(props: {
);
}

const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn(
"aspect-video w-full overflow-hidden bg-muted/60",
CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME,
CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
);

/**
* A standalone image holds a 16:9 slot (or its authored size) until it has
* decoded, and keeps that slot if it fails, so a timeline row moves at most
* once: when the natural size arrives. A bare `<img>` is zero height until
* then. Once decoded the image renders bare again so its box, hit area, and
* alignment are exactly the image's own. Inline images (badges, icons in a
* sentence) skip the slot: a placeholder taller than the image would move the
* page more than the image does.
*
* Callers key this on the file's identity, not its URL: a re-signed URL for
* the same file keeps the decoded image on screen while the new bytes arrive,
* and a different file starts from the slot again.
*/
function ChatMarkdownImage(props: {
/** Null while the URL is being resolved; the last decoded image stays up. */
readonly src: string | null;
readonly sourceFailed?: boolean | undefined;
readonly alt: string;
readonly copyMarkdown: string | undefined;
readonly standalone: boolean;
readonly className?: string | undefined;
readonly style?: CSSProperties | undefined;
readonly actionsSource: MediaActionSource;
readonly originalUrl?: string | undefined;
readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
}) {
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const src = props.src ?? loadedSrc;
const failed = props.sourceFailed === true || (src !== null && failedSrc === src);
// A failure forgets the decoded image so the next URL loads behind the slot.
const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null);
// Cached images are complete before `onLoad` can fire.
const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => {
if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src);
}, []);
const imageEvents = (loadingSrc: string) => ({
onLoad: () => {
setLoadedSrc(loadingSrc);
setFailedSrc(null);
},
onError: () => {
setFailedSrc(loadingSrc);
setLoadedSrc(null);
},
});

if (settled) {
return (
<MediaActions source={props.actionsSource}>
<img
ref={markLoadedIfComplete}
src={src}
alt={props.alt}
data-markdown-copy={props.copyMarkdown}
decoding="async"
draggable={false}
className={cn(
CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
props.className,
props.onImageExpand && "cursor-zoom-in",
)}
style={props.style}
{...expandableMarkdownImageProps(
props.onImageExpand,
src,
props.alt,
props.originalUrl,
props.actionsSource,
)}
{...imageEvents(src)}
/>
</MediaActions>
);
}
if (!props.standalone) {
return failed ? (
<ChatMarkdownImageFallback
alt={props.alt}
copyMarkdown={props.copyMarkdown}
actionsSource={props.actionsSource}
/>
) : (
<span
data-markdown-copy={props.copyMarkdown}
role="status"
aria-label="Loading image"
className={CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME}
/>
);
}
return (
<MediaActions source={props.actionsSource}>
<span
data-markdown-copy={props.copyMarkdown}
className={cn(
CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME,
"relative",
)}
style={props.style}
{...(failed
? { role: "alert" as const }
: { role: "status" as const, "aria-label": "Loading image" })}
>
{failed ? (
<span className="flex size-full items-center justify-center p-2 text-center text-xs text-muted-foreground">
<ChatMarkdownMediaUnavailableLabel alt={props.alt} />
</span>
) : src !== null ? (
<img
ref={markLoadedIfComplete}
src={src}
alt={props.alt}
decoding="async"
draggable={false}
className="invisible absolute inset-0 size-full"
{...imageEvents(src)}
/>
) : null}
Comment thread
cursor[bot] marked this conversation as resolved.
</span>
</MediaActions>
);
}

function ChatMarkdownVideo(props: {
readonly src: string | null;
readonly alt: string;
Expand Down Expand Up @@ -1316,13 +1494,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
readonly alt: string;
readonly copyMarkdown?: string;
readonly srcFragment?: string;
/** Reserve a slot while loading; off for images that share a line with text. */
readonly standalone?: boolean | undefined;
readonly style?: CSSProperties | undefined;
readonly workspaceRoot?: string | undefined;
readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
}) {
const assetUrl = useAssetUrlState(props.environmentId, props.resource);
const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const resource = props.resource;
const path =
resource._tag === "media-file"
Expand Down Expand Up @@ -1367,56 +1546,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
);
}

if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
return (
<ChatMarkdownImageFallback
alt={props.alt}
copyMarkdown={props.copyMarkdown}
kind={props.kind ?? "image"}
actionsSource={actionsSource}
/>
);
}
if (assetUrl._tag !== "Success") {
return (
<MediaActions source={actionsSource}>
<span
data-markdown-copy={props.copyMarkdown}
role="status"
aria-label="Loading image"
className={cn(
CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
"aspect-video w-64 max-w-full rounded-lg bg-muted/60",
CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
)}
style={props.style}
/>
</MediaActions>
);
}
return (
<MediaActions source={actionsSource}>
<img
src={src ?? undefined}
alt={props.alt}
data-markdown-copy={props.copyMarkdown}
loading="lazy"
draggable={false}
className={cn(
CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,
props.onImageExpand && "cursor-zoom-in",
)}
style={props.style}
{...expandableMarkdownImageProps(
props.onImageExpand,
src ?? assetUrl.url,
props.alt,
undefined,
actionsSource,
)}
onError={() => setFailedUrl(assetUrl.url)}
/>
</MediaActions>
<ChatMarkdownImage
key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
src={src}
sourceFailed={assetUrl._tag === "Failure"}
alt={props.alt}
copyMarkdown={props.copyMarkdown}
standalone={props.standalone ?? true}
className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}
style={props.style}
actionsSource={actionsSource}
onImageExpand={props.onImageExpand}
/>
);
});

Expand Down Expand Up @@ -2772,6 +2914,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
const localSrc = node?.properties?.dataLocalSrc;
const markdownTitle = node?.properties?.dataMarkdownTitle;
const standalone = node?.properties?.dataStandalone === true;
const authoredSrc = typeof localSrc === "string" ? localSrc : src;
const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
const srcString =
Expand Down Expand Up @@ -2807,27 +2950,18 @@ const CHAT_MARKDOWN_COMPONENTS = {
);
}
return (
<MediaActions source={actionsSource}>
<img
{...props}
src={mediaSrc}
alt={altText}
loading="lazy"
className={cn(
props.className,
CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
imageExpand && "cursor-zoom-in",
)}
style={authoredSizeStyle}
{...expandableMarkdownImageProps(
imageExpand,
mediaSrc,
altText,
originalUrl,
actionsSource,
)}
/>
</MediaActions>
<ChatMarkdownImage
key={mediaSrc}
src={mediaSrc}
alt={altText}
copyMarkdown={copyMarkdown}
standalone={standalone}
className={props.className}
style={authoredSizeStyle}
actionsSource={actionsSource}
originalUrl={originalUrl}
onImageExpand={imageExpand}
/>
);
}
if (imageSource._tag === "WorkspaceFile" && threadRef) {
Expand All @@ -2843,6 +2977,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
kind={kind}
copyMarkdown={copyMarkdown}
srcFragment={markdownImageSourceFragment(classifiedSrc)}
standalone={standalone}
style={authoredSizeStyle}
workspaceRoot={cwd}
onImageExpand={imageExpand}
Expand Down
Loading
Loading