authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 00:55:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 00:55:35-07:00
logeced382b451abc35137e7047b010c6ae5f6e9353
tree4ac319bafd030bc3bd8de3127821f36296e4df22
parentcd92a7e7ae68a9b0c01938e1f69c2044b6c455ac
signature Signed by PGP key B5690EEEBB952194

fix(web): keep chat media at a stable size while it loads (#9938)

Co-authored-by: Claude Code <noreply@anthropic.com>

3 files changed, 307 insertions(+), 97 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+242-80
...@@ -52,6 +52,7 @@ import React, {...@@ -52,6 +52,7 @@ import React, {
52 Children,52 Children,
53 Suspense,53 Suspense,
54 type CSSProperties,54 type CSSProperties,
55 type ComponentProps,
55 type ClipboardEvent as ReactClipboardEvent,56 type ClipboardEvent as ReactClipboardEvent,
56 type KeyboardEvent as ReactKeyboardEvent,57 type KeyboardEvent as ReactKeyboardEvent,
57 type MouseEvent as ReactMouseEvent,58 type MouseEvent as ReactMouseEvent,
...@@ -357,6 +358,55 @@ type MarkdownImageHastNode = {...@@ -357,6 +358,55 @@ type MarkdownImageHastNode = {
357 children?: MarkdownImageHastNode[];358 children?: MarkdownImageHastNode[];
358};359};
359360
361function 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. */
375const STANDALONE_IMAGE_BLOCKS = new Set([
376 "p",
377 "div",
378 "li",
379 "td",
380 "th",
381 "figure",
382 "center",
383 "blockquote",
384]);
385
386function 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
399function 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/** Carries authored image source metadata through the sanitizer to the image renderer. */410/** Carries authored image source metadata through the sanitizer to the image renderer. */
361function rehypePreserveImageSourceMeta() {411function rehypePreserveImageSourceMeta() {
362 return (tree: MarkdownImageHastNode) => {412 return (tree: MarkdownImageHastNode) => {
...@@ -374,6 +424,7 @@ function rehypePreserveImageSourceMeta() {...@@ -374,6 +424,7 @@ function rehypePreserveImageSourceMeta() {
374 };424 };
375425
376 visit(tree);426 visit(tree);
427 markStandaloneImages(tree);
377 };428 };
378}429}
379430
...@@ -386,7 +437,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {...@@ -386,7 +437,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
386 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],437 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
387 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],438 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
388 a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],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 protocols: {447 protocols: {
392 ...defaultSchema.protocols,448 ...defaultSchema.protocols,
...@@ -1200,7 +1256,6 @@ function authoredImageSizeStyle(...@@ -1200,7 +1256,6 @@ function authoredImageSizeStyle(
1200}1256}
12011257
1202const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(1258const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
1203 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
1204 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,1259 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1205 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,1260 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
1206);1261);
...@@ -1242,13 +1297,26 @@ function expandableMarkdownImageProps(...@@ -1242,13 +1297,26 @@ function expandableMarkdownImageProps(
1242 };1297 };
1243}1298}
12441299
1300function 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. */
1245function ChatMarkdownImageFallback(props: {1314function ChatMarkdownImageFallback(props: {
1246 readonly alt: string;1315 readonly alt: string;
1247 readonly copyMarkdown?: string | undefined;1316 readonly copyMarkdown?: string | undefined;
1248 readonly kind?: "image" | "video";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 const content = (1320 const content = (
1253 <span1321 <span
1254 data-markdown-copy={props.copyMarkdown}1322 data-markdown-copy={props.copyMarkdown}
...@@ -1257,10 +1325,7 @@ function ChatMarkdownImageFallback(props: {...@@ -1257,10 +1325,7 @@ function ChatMarkdownImageFallback(props: {
1257 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",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">1328 <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} />
1261 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
1262 {props.alt.length > 0 ? `${label} · ${props.alt}` : label}
1263 </span>
1264 </span>1329 </span>
1265 );1330 );
1266 return props.actionsSource ? (1331 return props.actionsSource ? (
...@@ -1270,6 +1335,144 @@ function ChatMarkdownImageFallback(props: {...@@ -1270,6 +1335,144 @@ function ChatMarkdownImageFallback(props: {
1270 );1335 );
1271}1336}
12721337
1338const 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 */
1357function 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
1273function ChatMarkdownVideo(props: {1476function ChatMarkdownVideo(props: {
1274 readonly src: string | null;1477 readonly src: string | null;
1275 readonly alt: string;1478 readonly alt: string;
...@@ -1316,13 +1519,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1316,13 +1519,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1316 readonly alt: string;1519 readonly alt: string;
1317 readonly copyMarkdown?: string;1520 readonly copyMarkdown?: string;
1318 readonly srcFragment?: string;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 readonly style?: CSSProperties | undefined;1524 readonly style?: CSSProperties | undefined;
1320 readonly workspaceRoot?: string | undefined;1525 readonly workspaceRoot?: string | undefined;
1321 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;1526 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1322}) {1527}) {
1323 const assetUrl = useAssetUrlState(props.environmentId, props.resource);1528 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
1324 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);1529 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);
1325 const [failedUrl, setFailedUrl] = useState<string | null>(null);
1326 const resource = props.resource;1530 const resource = props.resource;
1327 const path =1531 const path =
1328 resource._tag === "media-file"1532 resource._tag === "media-file"
...@@ -1367,56 +1571,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1367,56 +1571,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1367 );1571 );
1368 }1572 }
13691573
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 return (1574 return (
1398 <MediaActions source={actionsSource}>1575 <ChatMarkdownImage
1399 <img1576 key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
1400 src={src ?? undefined}1577 src={src}
1401 alt={props.alt}1578 sourceFailed={assetUrl._tag === "Failure"}
1402 data-markdown-copy={props.copyMarkdown}1579 alt={props.alt}
1403 loading="lazy"1580 copyMarkdown={props.copyMarkdown}
1404 draggable={false}1581 standalone={props.standalone ?? true}
1405 className={cn(1582 className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}
1406 CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,1583 style={props.style}
1407 props.onImageExpand && "cursor-zoom-in",1584 actionsSource={actionsSource}
1408 )}1585 onImageExpand={props.onImageExpand}
1409 style={props.style}1586 />
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>
1420 );1587 );
1421});1588});
14221589
...@@ -2772,6 +2939,7 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2772,6 +2939,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
2772 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;2939 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
2773 const localSrc = node?.properties?.dataLocalSrc;2940 const localSrc = node?.properties?.dataLocalSrc;
2774 const markdownTitle = node?.properties?.dataMarkdownTitle;2941 const markdownTitle = node?.properties?.dataMarkdownTitle;
2942 const standalone = node?.properties?.dataStandalone === true;
2775 const authoredSrc = typeof localSrc === "string" ? localSrc : src;2943 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
2776 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;2944 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
2777 const srcString =2945 const srcString =
...@@ -2780,7 +2948,8 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2780,7 +2948,8 @@ const CHAT_MARKDOWN_COMPONENTS = {
2780 typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString;2948 typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString;
2781 const altText = alt ?? "";2949 const altText = alt ?? "";
2782 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);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 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);2953 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
2785 const kind = mediaKindFromPath(classifiedSrc) ?? "image";2954 const kind = mediaKindFromPath(classifiedSrc) ?? "image";
2786 if (imageSource._tag === "Direct") {2955 if (imageSource._tag === "Direct") {
...@@ -2807,27 +2976,19 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2807,27 +2976,19 @@ const CHAT_MARKDOWN_COMPONENTS = {
2807 );2976 );
2808 }2977 }
2809 return (2978 return (
2810 <MediaActions source={actionsSource}>2979 <ChatMarkdownImage
2811 <img2980 key={mediaSrc}
2812 {...props}2981 src={mediaSrc}
2813 src={mediaSrc}2982 alt={altText}
2814 alt={altText}2983 copyMarkdown={copyMarkdown}
2815 loading="lazy"2984 standalone={standalone}
2816 className={cn(2985 className={className}
2817 props.className,2986 style={authoredSizeStyle}
2818 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,2987 imageProps={imageProps}
2819 imageExpand && "cursor-zoom-in",2988 actionsSource={actionsSource}
2820 )}2989 originalUrl={originalUrl}
2821 style={authoredSizeStyle}2990 onImageExpand={imageExpand}
2822 {...expandableMarkdownImageProps(2991 />
2823 imageExpand,
2824 mediaSrc,
2825 altText,
2826 originalUrl,
2827 actionsSource,
2828 )}
2829 />
2830 </MediaActions>
2831 );2992 );
2832 }2993 }
2833 if (imageSource._tag === "WorkspaceFile" && threadRef) {2994 if (imageSource._tag === "WorkspaceFile" && threadRef) {
...@@ -2843,6 +3004,7 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2843,6 +3004,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
2843 kind={kind}3004 kind={kind}
2844 copyMarkdown={copyMarkdown}3005 copyMarkdown={copyMarkdown}
2845 srcFragment={markdownImageSourceFragment(classifiedSrc)}3006 srcFragment={markdownImageSourceFragment(classifiedSrc)}
3007 standalone={standalone}
2846 style={authoredSizeStyle}3008 style={authoredSizeStyle}
2847 workspaceRoot={cwd}3009 workspaceRoot={cwd}
2848 onImageExpand={imageExpand}3010 onImageExpand={imageExpand}
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+62-16
...@@ -139,9 +139,10 @@ describe("ChatMarkdown workspace images", () => {...@@ -139,9 +139,10 @@ describe("ChatMarkdown workspace images", () => {
139 path: "\\\\server\\share\\workspace-image.svg",139 path: "\\\\server\\share\\workspace-image.svg",
140 },140 },
141 ]);141 ]);
142 expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4);142 expect(html.match(/<img[^>]*src="https:\/\/signed\.test\/workspace-image\.svg"/g)).toHaveLength(
143 4,
144 );
143 expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4);145 expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4);
144 expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4);
145 expect(html).not.toContain("Image unavailable");146 expect(html).not.toContain("Image unavailable");
146 });147 });
147148
...@@ -203,23 +204,49 @@ describe("ChatMarkdown workspace images", () => {...@@ -203,23 +204,49 @@ describe("ChatMarkdown workspace images", () => {
203 expect(loadedStyle).toHaveProperty(constraint, expectedValue);204 expect(loadedStyle).toHaveProperty(constraint, expectedValue);
204 });205 });
205206
206 it("keeps all images baseline-aligned and workspace images inline", () => {207 it("keeps images that share a line inline and lets a standalone one reserve a slot", () => {
207 const html = render(208 const html = render(
208 "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)",209 "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)",
209 );210 );
210 const classNames = Array.from(html.matchAll(/<img[^>]*class="([^"]*)"/g), (match) =>
211 match[1]?.split(" "),
212 );
213211
214 expect(classNames).toHaveLength(2);212 // Two images in one paragraph are badges: neither reserves a slot.
215 expect(classNames[1]).toContain("inline-block!");213 expect(html).not.toContain("aspect-video");
214 expect(html).toContain('src="https://example.com/badge.svg"');
215 expect(html).toContain('src="https://signed.test/workspace-image.svg"');
216 expect(html.match(/<img[^>]*class="[^"]*inline-block![^"]*"/g)).toHaveLength(1);
217 expect(html).not.toContain("invisible");
216218
217 const centeredHtml = render(219 const centeredHtml = render(
218 '<p align="center"><img src=".t3/workspace-image.svg" alt="logo"></p>',220 '<p align="center"><img src=".t3/workspace-image.svg" alt="logo"></p>',
219 );221 );
220 const centeredClassName = /<img[^>]*class="([^"]*)"/.exec(centeredHtml)?.[1];222 const frame = /<span[^>]*role="status"[^>]*>/.exec(centeredHtml)?.[0];
223
224 expect(frame).toContain("inline-block!");
225 expect(frame).toContain("aspect-video");
226 });
227
228 it("reserves a slot for an image that is the only content of its link", () => {
229 const html = render("[![shot](.t3/workspace-image.svg)](https://example.com)");
230
231 expect(html).toContain("aspect-video");
232 });
233
234 it.each([
235 ["a link", "Figure: [![shot](.t3/workspace-image.svg)](https://example.com)"],
236 ["emphasis", "**![shot](.t3/workspace-image.svg)** caption"],
237 ])("keeps an image wrapped in %s inline when text shares its block", (_wrapper, markdown) => {
238 expect(render(markdown)).not.toContain("aspect-video");
239 });
240
241 it("keeps an authored id on a remote image so fragment links resolve", () => {
242 const html = render('<img id="diagram" src="https://example.com/diagram.png" alt="diagram">');
243
244 // The sanitizer prefixes authored ids; the loading slot carries it too.
245 expect(html).toContain('<span id="user-content-diagram"');
246 });
221247
222 expect(centeredClassName?.split(" ")).toContain("inline-block!");248 it("reserves a slot for an image that is alone in a list item", () => {
249 expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video");
223 });250 });
224251
225 it("retains an authored SVG fragment on the signed URL", () => {252 it("retains an authored SVG fragment on the signed URL", () => {
...@@ -278,15 +305,35 @@ describe("ChatMarkdown workspace images", () => {...@@ -278,15 +305,35 @@ describe("ChatMarkdown workspace images", () => {
278 );305 );
279 });306 });
280307
281 it("uses a static bounded-width placeholder while a signed asset URL loads", () => {308 it("reserves the same 16:9 frame while the URL, the bytes, and a failure resolve", () => {
309 const frameClassName = (html: string) => {
310 const frame = /<span[^>]*role="(?:status|alert)"[^>]*>/.exec(html)?.[0] ?? "";
311 return /class="([^"]*)"/.exec(frame)?.[1]?.split(" ") ?? [];
312 };
313 const markdown = "![shot](.t3/workspace-image.svg)";
314
282 testState.assetState = "loading";315 testState.assetState = "loading";
316 const loadingUrl = frameClassName(render(markdown));
317 testState.assetState = "success";
318 const loadingBytes = render(markdown);
319 testState.assetState = "failure";
320 const failure = render(markdown);
321
322 expect(loadingUrl).toEqual(expect.arrayContaining(["aspect-video", "w-full"]));
323 expect(loadingUrl).not.toContain("animate-pulse");
324 expect(frameClassName(loadingBytes)).toEqual(loadingUrl);
325 expect(frameClassName(failure)).toEqual(loadingUrl);
326 expect(failure).toContain("Image unavailable");
327 // The bytes are requested inside the frame but never paint at an unknown size.
328 expect(loadingBytes).toMatch(/<img[^>]*src="https:\/\/signed[^>]*class="invisible/);
329 expect(loadingBytes).not.toContain('loading="lazy"');
330 });
283331
284 const html = render("![loading](.t3/workspace-image.svg)");332 it("gives a standalone remote image the same frame instead of a bare tag", () => {
285 const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1];333 const html = render("![remote](https://example.com/shot.png)");
286334
287 expect(html).toContain('aria-label="Loading image"');335 expect(html).toContain('aria-label="Loading image"');
288 expect(html).not.toContain("animate-pulse");336 expect(html).toContain("aspect-video");
289 expect(className?.split(" ")).toContain("w-64");
290 });337 });
291338
292 it("never passes a workspace source to a raw image when thread context is unavailable", () => {339 it("never passes a workspace source to a raw image when thread context is unavailable", () => {
...@@ -313,7 +360,6 @@ describe("ChatMarkdown workspace images", () => {...@@ -313,7 +360,6 @@ describe("ChatMarkdown workspace images", () => {
313 expect(testState.resources).toEqual([]);360 expect(testState.resources).toEqual([]);
314 expect(html).toContain('src="https://example.com/image.png"');361 expect(html).toContain('src="https://example.com/image.png"');
315 expect(html).toContain("max-w-[min(100%,30rem)]");362 expect(html).toContain("max-w-[min(100%,30rem)]");
316 expect(html).toContain("max-h-[30rem]");
317 expect(html).not.toContain("Image unavailable");363 expect(html).not.toContain("Image unavailable");
318 });364 });
319});365});
apps/web/src/components/media/MediaVideoPlayer.tsx+3-1
...@@ -127,7 +127,9 @@ export function MediaVideoPlayer({...@@ -127,7 +127,9 @@ export function MediaVideoPlayer({
127 <span127 <span
128 role="alert"128 role="alert"
129 className={cn(129 className={cn(
130 "flex min-h-28 flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground",130 // Same 16:9 slot as the loading and playing states, so a failed or
131 // retried video does not move the rows below it.
132 "flex aspect-video max-h-full min-h-28 w-full flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground",
131 stateClassName,133 stateClassName,
132 )}134 )}
133 >135 >