| ... | ... | @@ -52,6 +52,7 @@ import React, { |
| 52 | 52 | Children, |
| 53 | 53 | Suspense, |
| 54 | 54 | type CSSProperties, |
| 55 | type ComponentProps, |
| 55 | 56 | type ClipboardEvent as ReactClipboardEvent, |
| 56 | 57 | type KeyboardEvent as ReactKeyboardEvent, |
| 57 | 58 | type MouseEvent as ReactMouseEvent, |
| ... | ... | @@ -357,6 +358,55 @@ type MarkdownImageHastNode = { |
| 357 | 358 | children?: MarkdownImageHastNode[]; |
| 358 | 359 | }; |
| 359 | 360 | |
| 361 | function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { |
| 362 | return (node.children ?? []).filter( |
| 363 | (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), |
| 364 | ); |
| 365 | } |
| 366 | |
| 367 | /** |
| 368 | * An image that is the only content of its block (optionally wrapped in a |
| 369 | * link) is almost always a screenshot or figure, so it gets a reserved slot |
| 370 | * while it loads. Images mixed with text or other images — badge rows, icons |
| 371 | * in a sentence — stay inline at their natural size, since a placeholder taller |
| 372 | * than the image would move the page more than the image itself does. |
| 373 | */ |
| 374 | /** Containers whose sole child image reads as a figure rather than part of a sentence. */ |
| 375 | const STANDALONE_IMAGE_BLOCKS = new Set([ |
| 376 | "p", |
| 377 | "div", |
| 378 | "li", |
| 379 | "td", |
| 380 | "th", |
| 381 | "figure", |
| 382 | "center", |
| 383 | "blockquote", |
| 384 | ]); |
| 385 | |
| 386 | function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { |
| 387 | const children = meaningfulHastChildren(node); |
| 388 | if (children.length !== 1) return undefined; |
| 389 | const only = children[0]; |
| 390 | if (only?.type !== "element") return undefined; |
| 391 | if (only.tagName === "img") return only; |
| 392 | // A link, emphasis, or similar inline wrapper around the image still counts |
| 393 | // as long as nothing else shares the block. |
| 394 | return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" |
| 395 | ? soleImageDescendant(only) |
| 396 | : undefined; |
| 397 | } |
| 398 | |
| 399 | function markStandaloneImages(node: MarkdownImageHastNode) { |
| 400 | // A raw `<img>` on its own line reaches the root without a paragraph. |
| 401 | if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { |
| 402 | const image = soleImageDescendant(node); |
| 403 | if (image) image.properties = { ...image.properties, dataStandalone: true }; |
| 404 | } |
| 405 | node.children?.forEach((child) => { |
| 406 | if (child.type === "element") markStandaloneImages(child); |
| 407 | }); |
| 408 | } |
| 409 | |
| 360 | 410 | /** Carries authored image source metadata through the sanitizer to the image renderer. */ |
| 361 | 411 | function rehypePreserveImageSourceMeta() { |
| 362 | 412 | return (tree: MarkdownImageHastNode) => { |
| ... | ... | @@ -374,6 +424,7 @@ function rehypePreserveImageSourceMeta() { |
| 374 | 424 | }; |
| 375 | 425 | |
| 376 | 426 | visit(tree); |
| 427 | markStandaloneImages(tree); |
| 377 | 428 | }; |
| 378 | 429 | } |
| 379 | 430 | |
| ... | ... | @@ -386,7 +437,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 386 | 437 | blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], |
| 387 | 438 | div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], |
| 388 | 439 | a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], |
| 389 | | img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], |
| 440 | img: [ |
| 441 | ...(defaultSchema.attributes?.img ?? []), |
| 442 | "dataLocalSrc", |
| 443 | "dataMarkdownTitle", |
| 444 | "dataStandalone", |
| 445 | ], |
| 390 | 446 | }, |
| 391 | 447 | protocols: { |
| 392 | 448 | ...defaultSchema.protocols, |
| ... | ... | @@ -1200,7 +1256,6 @@ function authoredImageSizeStyle( |
| 1200 | 1256 | } |
| 1201 | 1257 | |
| 1202 | 1258 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( |
| 1203 | | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, |
| 1204 | 1259 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, |
| 1205 | 1260 | CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, |
| 1206 | 1261 | ); |
| ... | ... | @@ -1242,13 +1297,26 @@ function expandableMarkdownImageProps( |
| 1242 | 1297 | }; |
| 1243 | 1298 | } |
| 1244 | 1299 | |
| 1300 | function ChatMarkdownMediaUnavailableLabel(props: { |
| 1301 | readonly alt: string; |
| 1302 | readonly kind?: "image" | "video" | undefined; |
| 1303 | }) { |
| 1304 | const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; |
| 1305 | return ( |
| 1306 | <span className="inline-flex items-center gap-1.5"> |
| 1307 | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> |
| 1308 | {props.alt.length > 0 ? `${label} · ${props.alt}` : label} |
| 1309 | </span> |
| 1310 | ); |
| 1311 | } |
| 1312 | |
| 1313 | /** Inline chip for an image that sits in a line of text or can never load. */ |
| 1245 | 1314 | function ChatMarkdownImageFallback(props: { |
| 1246 | 1315 | readonly alt: string; |
| 1247 | 1316 | readonly copyMarkdown?: string | undefined; |
| 1248 | 1317 | readonly kind?: "image" | "video"; |
| 1249 | | readonly actionsSource?: MediaActionSource; |
| 1318 | readonly actionsSource?: MediaActionSource | undefined; |
| 1250 | 1319 | }) { |
| 1251 | | const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; |
| 1252 | 1320 | const content = ( |
| 1253 | 1321 | <span |
| 1254 | 1322 | data-markdown-copy={props.copyMarkdown} |
| ... | ... | @@ -1257,10 +1325,7 @@ function ChatMarkdownImageFallback(props: { |
| 1257 | 1325 | "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground", |
| 1258 | 1326 | )} |
| 1259 | 1327 | > |
| 1260 | | <span className="inline-flex items-center gap-1.5"> |
| 1261 | | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> |
| 1262 | | {props.alt.length > 0 ? `${label} · ${props.alt}` : label} |
| 1263 | | </span> |
| 1328 | <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} /> |
| 1264 | 1329 | </span> |
| 1265 | 1330 | ); |
| 1266 | 1331 | return props.actionsSource ? ( |
| ... | ... | @@ -1270,6 +1335,144 @@ function ChatMarkdownImageFallback(props: { |
| 1270 | 1335 | ); |
| 1271 | 1336 | } |
| 1272 | 1337 | |
| 1338 | const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( |
| 1339 | "aspect-video w-full overflow-hidden bg-muted/60", |
| 1340 | CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, |
| 1341 | CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, |
| 1342 | ); |
| 1343 | |
| 1344 | /** |
| 1345 | * A standalone image holds a 16:9 slot (or its authored size) until it has |
| 1346 | * decoded, and keeps that slot if it fails, so a timeline row moves at most |
| 1347 | * once: when the natural size arrives. A bare `<img>` is zero height until |
| 1348 | * then. Once decoded the image renders bare again so its box, hit area, and |
| 1349 | * alignment are exactly the image's own. Inline images (badges, icons in a |
| 1350 | * sentence) skip the slot: a placeholder taller than the image would move the |
| 1351 | * page more than the image does. |
| 1352 | * |
| 1353 | * Callers key this on the file's identity, not its URL: a re-signed URL for |
| 1354 | * the same file keeps the decoded image on screen while the new bytes arrive, |
| 1355 | * and a different file starts from the slot again. |
| 1356 | */ |
| 1357 | function ChatMarkdownImage(props: { |
| 1358 | /** Null while the URL is being resolved; the last decoded image stays up. */ |
| 1359 | readonly src: string | null; |
| 1360 | readonly sourceFailed?: boolean | undefined; |
| 1361 | readonly alt: string; |
| 1362 | readonly copyMarkdown: string | undefined; |
| 1363 | readonly standalone: boolean; |
| 1364 | readonly className?: string | undefined; |
| 1365 | readonly style?: CSSProperties | undefined; |
| 1366 | /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ |
| 1367 | readonly imageProps?: |
| 1368 | | Omit<ComponentProps<"img">, "src" | "alt" | "className" | "style"> |
| 1369 | | undefined; |
| 1370 | readonly actionsSource: MediaActionSource; |
| 1371 | readonly originalUrl?: string | undefined; |
| 1372 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 1373 | }) { |
| 1374 | const [loadedSrc, setLoadedSrc] = useState<string | null>(null); |
| 1375 | const [failedSrc, setFailedSrc] = useState<string | null>(null); |
| 1376 | const src = props.src ?? loadedSrc; |
| 1377 | const failed = props.sourceFailed === true || (src !== null && failedSrc === src); |
| 1378 | // A failure forgets the decoded image so the next URL loads behind the slot. |
| 1379 | const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); |
| 1380 | // Cached images are complete before `onLoad` can fire. |
| 1381 | const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => { |
| 1382 | if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); |
| 1383 | }, []); |
| 1384 | const imageEvents = (loadingSrc: string) => ({ |
| 1385 | onLoad: () => { |
| 1386 | setLoadedSrc(loadingSrc); |
| 1387 | setFailedSrc(null); |
| 1388 | }, |
| 1389 | onError: () => { |
| 1390 | setFailedSrc(loadingSrc); |
| 1391 | setLoadedSrc(null); |
| 1392 | }, |
| 1393 | }); |
| 1394 | |
| 1395 | if (settled) { |
| 1396 | return ( |
| 1397 | <MediaActions source={props.actionsSource}> |
| 1398 | <img |
| 1399 | {...props.imageProps} |
| 1400 | ref={markLoadedIfComplete} |
| 1401 | src={src} |
| 1402 | alt={props.alt} |
| 1403 | data-markdown-copy={props.copyMarkdown} |
| 1404 | decoding="async" |
| 1405 | draggable={false} |
| 1406 | className={cn( |
| 1407 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, |
| 1408 | props.className, |
| 1409 | props.onImageExpand && "cursor-zoom-in", |
| 1410 | )} |
| 1411 | style={props.style} |
| 1412 | {...expandableMarkdownImageProps( |
| 1413 | props.onImageExpand, |
| 1414 | src, |
| 1415 | props.alt, |
| 1416 | props.originalUrl, |
| 1417 | props.actionsSource, |
| 1418 | )} |
| 1419 | {...imageEvents(src)} |
| 1420 | /> |
| 1421 | </MediaActions> |
| 1422 | ); |
| 1423 | } |
| 1424 | if (!props.standalone) { |
| 1425 | return failed ? ( |
| 1426 | <ChatMarkdownImageFallback |
| 1427 | alt={props.alt} |
| 1428 | copyMarkdown={props.copyMarkdown} |
| 1429 | actionsSource={props.actionsSource} |
| 1430 | /> |
| 1431 | ) : ( |
| 1432 | <span |
| 1433 | id={props.imageProps?.id} |
| 1434 | data-markdown-copy={props.copyMarkdown} |
| 1435 | role="status" |
| 1436 | aria-label="Loading image" |
| 1437 | className={CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME} |
| 1438 | /> |
| 1439 | ); |
| 1440 | } |
| 1441 | return ( |
| 1442 | <MediaActions source={props.actionsSource}> |
| 1443 | <span |
| 1444 | id={props.imageProps?.id} |
| 1445 | data-markdown-copy={props.copyMarkdown} |
| 1446 | className={cn( |
| 1447 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, |
| 1448 | CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME, |
| 1449 | "relative", |
| 1450 | )} |
| 1451 | style={props.style} |
| 1452 | {...(failed |
| 1453 | ? { role: "alert" as const } |
| 1454 | : { role: "status" as const, "aria-label": "Loading image" })} |
| 1455 | > |
| 1456 | {failed ? ( |
| 1457 | <span className="flex size-full items-center justify-center p-2 text-center text-xs text-muted-foreground"> |
| 1458 | <ChatMarkdownMediaUnavailableLabel alt={props.alt} /> |
| 1459 | </span> |
| 1460 | ) : src !== null ? ( |
| 1461 | <img |
| 1462 | ref={markLoadedIfComplete} |
| 1463 | src={src} |
| 1464 | alt={props.alt} |
| 1465 | decoding="async" |
| 1466 | draggable={false} |
| 1467 | className="invisible absolute inset-0 size-full" |
| 1468 | {...imageEvents(src)} |
| 1469 | /> |
| 1470 | ) : null} |
| 1471 | </span> |
| 1472 | </MediaActions> |
| 1473 | ); |
| 1474 | } |
| 1475 | |
| 1273 | 1476 | function ChatMarkdownVideo(props: { |
| 1274 | 1477 | readonly src: string | null; |
| 1275 | 1478 | readonly alt: string; |
| ... | ... | @@ -1316,13 +1519,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1316 | 1519 | readonly alt: string; |
| 1317 | 1520 | readonly copyMarkdown?: string; |
| 1318 | 1521 | readonly srcFragment?: string; |
| 1522 | /** Reserve a slot while loading; off for images that share a line with text. */ |
| 1523 | readonly standalone?: boolean | undefined; |
| 1319 | 1524 | readonly style?: CSSProperties | undefined; |
| 1320 | 1525 | readonly workspaceRoot?: string | undefined; |
| 1321 | 1526 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 1322 | 1527 | }) { |
| 1323 | 1528 | const assetUrl = useAssetUrlState(props.environmentId, props.resource); |
| 1324 | 1529 | const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); |
| 1325 | | const [failedUrl, setFailedUrl] = useState<string | null>(null); |
| 1326 | 1530 | const resource = props.resource; |
| 1327 | 1531 | const path = |
| 1328 | 1532 | resource._tag === "media-file" |
| ... | ... | @@ -1367,56 +1571,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1367 | 1571 | ); |
| 1368 | 1572 | } |
| 1369 | 1573 | |
| 1370 | | if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { |
| 1371 | | return ( |
| 1372 | | <ChatMarkdownImageFallback |
| 1373 | | alt={props.alt} |
| 1374 | | copyMarkdown={props.copyMarkdown} |
| 1375 | | kind={props.kind ?? "image"} |
| 1376 | | actionsSource={actionsSource} |
| 1377 | | /> |
| 1378 | | ); |
| 1379 | | } |
| 1380 | | if (assetUrl._tag !== "Success") { |
| 1381 | | return ( |
| 1382 | | <MediaActions source={actionsSource}> |
| 1383 | | <span |
| 1384 | | data-markdown-copy={props.copyMarkdown} |
| 1385 | | role="status" |
| 1386 | | aria-label="Loading image" |
| 1387 | | className={cn( |
| 1388 | | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, |
| 1389 | | "aspect-video w-64 max-w-full rounded-lg bg-muted/60", |
| 1390 | | CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, |
| 1391 | | )} |
| 1392 | | style={props.style} |
| 1393 | | /> |
| 1394 | | </MediaActions> |
| 1395 | | ); |
| 1396 | | } |
| 1397 | 1574 | return ( |
| 1398 | | <MediaActions source={actionsSource}> |
| 1399 | | <img |
| 1400 | | src={src ?? undefined} |
| 1401 | | alt={props.alt} |
| 1402 | | data-markdown-copy={props.copyMarkdown} |
| 1403 | | loading="lazy" |
| 1404 | | draggable={false} |
| 1405 | | className={cn( |
| 1406 | | CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME, |
| 1407 | | props.onImageExpand && "cursor-zoom-in", |
| 1408 | | )} |
| 1409 | | style={props.style} |
| 1410 | | {...expandableMarkdownImageProps( |
| 1411 | | props.onImageExpand, |
| 1412 | | src ?? assetUrl.url, |
| 1413 | | props.alt, |
| 1414 | | undefined, |
| 1415 | | actionsSource, |
| 1416 | | )} |
| 1417 | | onError={() => setFailedUrl(assetUrl.url)} |
| 1418 | | /> |
| 1419 | | </MediaActions> |
| 1575 | <ChatMarkdownImage |
| 1576 | key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} |
| 1577 | src={src} |
| 1578 | sourceFailed={assetUrl._tag === "Failure"} |
| 1579 | alt={props.alt} |
| 1580 | copyMarkdown={props.copyMarkdown} |
| 1581 | standalone={props.standalone ?? true} |
| 1582 | className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} |
| 1583 | style={props.style} |
| 1584 | actionsSource={actionsSource} |
| 1585 | onImageExpand={props.onImageExpand} |
| 1586 | /> |
| 1420 | 1587 | ); |
| 1421 | 1588 | }); |
| 1422 | 1589 | |
| ... | ... | @@ -2772,6 +2939,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2772 | 2939 | const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; |
| 2773 | 2940 | const localSrc = node?.properties?.dataLocalSrc; |
| 2774 | 2941 | const markdownTitle = node?.properties?.dataMarkdownTitle; |
| 2942 | const standalone = node?.properties?.dataStandalone === true; |
| 2775 | 2943 | const authoredSrc = typeof localSrc === "string" ? localSrc : src; |
| 2776 | 2944 | const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; |
| 2777 | 2945 | const srcString = |
| ... | ... | @@ -2780,7 +2948,8 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2780 | 2948 | typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; |
| 2781 | 2949 | const altText = alt ?? ""; |
| 2782 | 2950 | const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); |
| 2783 | | const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); |
| 2951 | const { className, style: _style, width, height, ...imageProps } = props; |
| 2952 | const authoredSizeStyle = authoredImageSizeStyle(width, height); |
| 2784 | 2953 | const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); |
| 2785 | 2954 | const kind = mediaKindFromPath(classifiedSrc) ?? "image"; |
| 2786 | 2955 | if (imageSource._tag === "Direct") { |
| ... | ... | @@ -2807,27 +2976,19 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2807 | 2976 | ); |
| 2808 | 2977 | } |
| 2809 | 2978 | return ( |
| 2810 | | <MediaActions source={actionsSource}> |
| 2811 | | <img |
| 2812 | | {...props} |
| 2813 | | src={mediaSrc} |
| 2814 | | alt={altText} |
| 2815 | | loading="lazy" |
| 2816 | | className={cn( |
| 2817 | | props.className, |
| 2818 | | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, |
| 2819 | | imageExpand && "cursor-zoom-in", |
| 2820 | | )} |
| 2821 | | style={authoredSizeStyle} |
| 2822 | | {...expandableMarkdownImageProps( |
| 2823 | | imageExpand, |
| 2824 | | mediaSrc, |
| 2825 | | altText, |
| 2826 | | originalUrl, |
| 2827 | | actionsSource, |
| 2828 | | )} |
| 2829 | | /> |
| 2830 | | </MediaActions> |
| 2979 | <ChatMarkdownImage |
| 2980 | key={mediaSrc} |
| 2981 | src={mediaSrc} |
| 2982 | alt={altText} |
| 2983 | copyMarkdown={copyMarkdown} |
| 2984 | standalone={standalone} |
| 2985 | className={className} |
| 2986 | style={authoredSizeStyle} |
| 2987 | imageProps={imageProps} |
| 2988 | actionsSource={actionsSource} |
| 2989 | originalUrl={originalUrl} |
| 2990 | onImageExpand={imageExpand} |
| 2991 | /> |
| 2831 | 2992 | ); |
| 2832 | 2993 | } |
| 2833 | 2994 | if (imageSource._tag === "WorkspaceFile" && threadRef) { |
| ... | ... | @@ -2843,6 +3004,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2843 | 3004 | kind={kind} |
| 2844 | 3005 | copyMarkdown={copyMarkdown} |
| 2845 | 3006 | srcFragment={markdownImageSourceFragment(classifiedSrc)} |
| 3007 | standalone={standalone} |
| 2846 | 3008 | style={authoredSizeStyle} |
| 2847 | 3009 | workspaceRoot={cwd} |
| 2848 | 3010 | onImageExpand={imageExpand} |