From fe104b49b7ad18c2e703c4c0f16a517e61fbe7ed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:21:48 -0700 Subject: [PATCH 1/6] fix(web): keep chat media at a stable size while it loads Assistant markdown images collapsed to zero height between the signed URL resolving and the bytes arriving, then jumped to their natural size, and failures swapped to a one-line chip. Remote images had no loading or error state at all. Every image in a thread moved the rows below it two or three times, right as the user scrolled to it because lazy loading deferred the fetch until the row was in view. Both image paths now share one component that reserves a 16:9 slot (or the authored size) through URL resolution and byte loading, hosts the image invisibly inside it until it decodes, and shows the failure state in the same slot. Images decode async instead of lazy so buffered rows settle before they scroll in. The video failure panel takes the same 16:9 slot as its loading and playing states. Co-Authored-By: Claude Code --- apps/web/src/components/ChatMarkdown.tsx | 209 ++++++++++-------- .../ChatMarkdown.workspace-images.test.tsx | 44 +++- .../src/components/media/MediaVideoPlayer.tsx | 4 +- 3 files changed, 157 insertions(+), 100 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 614c404d978e..a93f2fe32d62 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1199,11 +1199,6 @@ function authoredImageSizeStyle( return undefined; } -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, -); const MarkdownLinkContext = React.createContext(false); function expandableMarkdownImageProps( @@ -1242,14 +1237,26 @@ function expandableMarkdownImageProps( }; } +function ChatMarkdownMediaUnavailableLabel(props: { + readonly alt: string; + readonly kind?: "image" | "video" | undefined; +}) { + const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; + return ( + + + {props.alt.length > 0 ? `${label} · ${props.alt}` : label} + + ); +} + +/** Inline chip for sources that can never load, so nothing is waiting on a response. */ function ChatMarkdownImageFallback(props: { readonly alt: string; readonly copyMarkdown?: string | undefined; readonly kind?: "image" | "video"; - readonly actionsSource?: MediaActionSource; }) { - const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; - const content = ( + return ( - - - {props.alt.length > 0 ? `${label} · ${props.alt}` : label} - + ); - return props.actionsSource ? ( - {content} - ) : ( - content +} + +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, +); + +/** + * Holds a 16:9 slot (or the authored size) until the image has decoded, and + * keeps that slot for the failure state, so a timeline row moves at most once: + * when the natural size arrives. A bare `` is zero height until then. + */ +function ChatMarkdownImage(props: { + readonly src: string | null; + readonly sourceFailed?: boolean | undefined; + readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly className?: string | undefined; + readonly style?: CSSProperties | undefined; + readonly actionsSource: MediaActionSource; + readonly originalUrl?: string | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + const [loaded, setLoaded] = useState(false); + const [failedSrc, setFailedSrc] = useState(null); + const src = props.src; + const failed = props.sourceFailed === true || (src !== null && failedSrc === src); + // Once a size is known it stays; a re-signed URL for the same file must not + // drop the row back to the placeholder. + const settled = loaded && !failed; + // Cached images are complete before `onLoad` can fire. + const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => { + if (image?.complete && image.naturalWidth > 0) setLoaded(true); + }, []); + + return ( + + + {failed ? ( + + + + ) : src !== null ? ( + {props.alt} setLoaded(true)} + onError={() => setFailedSrc(src)} + /> + ) : null} + + ); } @@ -1322,7 +1409,6 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); - const [failedUrl, setFailedUrl] = useState(null); const resource = props.resource; const path = resource._tag === "media-file" @@ -1367,56 +1453,16 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props ); } - if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ( - - ); - } - if (assetUrl._tag !== "Success") { - return ( - - - - ); - } return ( - - {props.alt} setFailedUrl(assetUrl.url)} - /> - + ); }); @@ -2807,27 +2853,16 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } return ( - - {altText} - + ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 39be0eedafe2..244a5dfa48e7 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -139,9 +139,10 @@ describe("ChatMarkdown workspace images", () => { path: "\\\\server\\share\\workspace-image.svg", }, ]); - expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); + expect(html.match(/]*src="https:\/\/signed\.test\/workspace-image\.svg"/g)).toHaveLength( + 4, + ); expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); - expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); expect(html).not.toContain("Image unavailable"); }); @@ -203,21 +204,23 @@ describe("ChatMarkdown workspace images", () => { expect(loadedStyle).toHaveProperty(constraint, expectedValue); }); - it("keeps all images baseline-aligned and workspace images inline", () => { + it("keeps all images baseline-aligned and inline", () => { const html = render( "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)", ); - const classNames = Array.from(html.matchAll(/]*class="([^"]*)"/g), (match) => - match[1]?.split(" "), + const classNames = Array.from( + html.matchAll(/]*class="([^"]*)"[^>]*role="status"/g), + (match) => match[1]?.split(" "), ); expect(classNames).toHaveLength(2); + expect(classNames[0]).toContain("inline-block!"); expect(classNames[1]).toContain("inline-block!"); const centeredHtml = render( '

logo

', ); - const centeredClassName = /]*class="([^"]*)"/.exec(centeredHtml)?.[1]; + const centeredClassName = /]*class="([^"]*)"[^>]*role="status"/.exec(centeredHtml)?.[1]; expect(centeredClassName?.split(" ")).toContain("inline-block!"); }); @@ -278,15 +281,33 @@ describe("ChatMarkdown workspace images", () => { ); }); - it("uses a static bounded-width placeholder while a signed asset URL loads", () => { + it("reserves the same 16:9 frame while the URL, the bytes, and a failure resolve", () => { + const frameClassName = (html: string) => + /]*class="([^"]*)"[^>]*role="(?:status|alert)"/.exec(html)?.[1]?.split(" ") ?? []; + const markdown = "![shot](.t3/workspace-image.svg)"; + testState.assetState = "loading"; + const loadingUrl = frameClassName(render(markdown)); + testState.assetState = "success"; + const loadingBytes = render(markdown); + testState.assetState = "failure"; + const failure = render(markdown); + + expect(loadingUrl).toEqual(expect.arrayContaining(["aspect-video", "w-full"])); + expect(loadingUrl).not.toContain("animate-pulse"); + expect(frameClassName(loadingBytes)).toEqual(loadingUrl); + expect(frameClassName(failure)).toEqual(loadingUrl); + expect(failure).toContain("Image unavailable"); + // The bytes are requested inside the frame but never paint at an unknown size. + expect(loadingBytes).toMatch(/]*src="https:\/\/signed[^>]*class="invisible/); + expect(loadingBytes).not.toContain('loading="lazy"'); + }); - const html = render("![loading](.t3/workspace-image.svg)"); - const className = /]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; + it("gives remote images the same frame instead of a bare tag", () => { + const html = render("![remote](https://example.com/shot.png)"); expect(html).toContain('aria-label="Loading image"'); - expect(html).not.toContain("animate-pulse"); - expect(className?.split(" ")).toContain("w-64"); + expect(html).toContain("aspect-video"); }); it("never passes a workspace source to a raw image when thread context is unavailable", () => { @@ -313,7 +334,6 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([]); expect(html).toContain('src="https://example.com/image.png"'); expect(html).toContain("max-w-[min(100%,30rem)]"); - expect(html).toContain("max-h-[30rem]"); expect(html).not.toContain("Image unavailable"); }); }); diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index f436beeb3855..bf3fd85a5081 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -127,7 +127,9 @@ export function MediaVideoPlayer({ From 2d67ca7f9103e837a748309416c42bd5b832b1ec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:06:46 -0700 Subject: [PATCH 2/6] fix(web): reserve the media slot only for standalone images Review found three problems with the first pass. The wrapper span stayed around the decoded image, so its hit area and centering no longer matched the picture. Every image reserved a full-width slot, so a badge row stacked one per line and then collapsed, moving the page more than the badges do. And a failure never cleared the loaded flag, so the next URL rendered bare before it decoded. Images that are the only content of their block (optionally inside a link) are now marked standalone in the rehype pass and get the slot; images that share a line stay inline at their natural size. Once decoded the image renders bare, and a failure resets the loaded flag so recovery loads behind the slot again. The video failure panel is capped at its container height so it cannot overflow the file preview pane. Co-Authored-By: Claude Code --- apps/web/src/components/ChatMarkdown.tsx | 160 ++++++++++++++---- .../ChatMarkdown.workspace-images.test.tsx | 34 ++-- .../src/components/media/MediaVideoPlayer.tsx | 2 +- 3 files changed, 148 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a93f2fe32d62..962b52404153 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -357,6 +357,36 @@ 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); + if (children.length === 1) { + 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) => { + if (child.type === "element") markStandaloneImages(child); + }); +} + /** Carries authored image source metadata through the sanitizer to the image renderer. */ function rehypePreserveImageSourceMeta() { return (tree: MarkdownImageHastNode) => { @@ -374,6 +404,7 @@ function rehypePreserveImageSourceMeta() { }; visit(tree); + markStandaloneImages(tree); }; } @@ -386,7 +417,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, @@ -1199,6 +1235,10 @@ function authoredImageSizeStyle( return undefined; } +const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( + CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, +); const MarkdownLinkContext = React.createContext(false); function expandableMarkdownImageProps( @@ -1250,13 +1290,14 @@ function ChatMarkdownMediaUnavailableLabel(props: { ); } -/** Inline chip for sources that can never load, so nothing is waiting on a response. */ +/** 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 | undefined; }) { - return ( + const content = ( ); + return props.actionsSource ? ( + {content} + ) : ( + content + ); } const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( @@ -1276,15 +1322,20 @@ const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( ); /** - * Holds a 16:9 slot (or the authored size) until the image has decoded, and - * keeps that slot for the failure state, so a timeline row moves at most once: - * when the natural size arrives. A bare `` is zero height until then. + * 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 `` 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. */ function ChatMarkdownImage(props: { 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; @@ -1295,37 +1346,77 @@ function ChatMarkdownImage(props: { const [failedSrc, setFailedSrc] = useState(null); const src = props.src; const failed = props.sourceFailed === true || (src !== null && failedSrc === src); - // Once a size is known it stays; a re-signed URL for the same file must not - // drop the row back to the placeholder. - const settled = loaded && !failed; + // A decoded image keeps its box across a re-signed URL for the same file, so + // a periodic refresh never drops the row back to the placeholder; the browser + // keeps painting the old bitmap until the new one decodes. A failure clears + // that, so the next URL loads behind the slot again. + const settled = !failed && (!props.standalone || (loaded && failedSrc === null)); // Cached images are complete before `onLoad` can fire. const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => { if (image?.complete && image.naturalWidth > 0) setLoaded(true); }, []); + if (src !== null && settled) { + return ( + + {props.alt} { + setLoaded(true); + setFailedSrc(null); + }} + onError={() => setFailedSrc(src)} + /> + + ); + } + if (!props.standalone) { + return failed ? ( + + ) : ( + + ); + } return ( {failed ? ( @@ -1338,17 +1429,11 @@ function ChatMarkdownImage(props: { alt={props.alt} decoding="async" draggable={false} - className={ - settled - ? cn( - "block!", - CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, - ) - : "invisible absolute inset-0 size-full" - } - style={settled ? props.style : undefined} - onLoad={() => setLoaded(true)} + className="invisible absolute inset-0 size-full" + onLoad={() => { + setLoaded(true); + setFailedSrc(null); + }} onError={() => setFailedSrc(src)} /> ) : null} @@ -1403,6 +1488,8 @@ 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; @@ -1459,6 +1546,8 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props 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} @@ -2818,6 +2907,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 = @@ -2857,6 +2947,7 @@ const CHAT_MARKDOWN_COMPONENTS = { src={mediaSrc} alt={altText} copyMarkdown={copyMarkdown} + standalone={standalone} className={props.className} style={authoredSizeStyle} actionsSource={actionsSource} @@ -2878,6 +2969,7 @@ const CHAT_MARKDOWN_COMPONENTS = { kind={kind} copyMarkdown={copyMarkdown} srcFragment={markdownImageSourceFragment(classifiedSrc)} + standalone={standalone} style={authoredSizeStyle} workspaceRoot={cwd} onImageExpand={imageExpand} diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 244a5dfa48e7..d78e5e33ff05 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -204,25 +204,31 @@ describe("ChatMarkdown workspace images", () => { expect(loadedStyle).toHaveProperty(constraint, expectedValue); }); - it("keeps all images baseline-aligned and inline", () => { + it("keeps images that share a line inline and lets a standalone one reserve a slot", () => { const html = render( "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)", ); - const classNames = Array.from( - html.matchAll(/]*class="([^"]*)"[^>]*role="status"/g), - (match) => match[1]?.split(" "), - ); - expect(classNames).toHaveLength(2); - expect(classNames[0]).toContain("inline-block!"); - expect(classNames[1]).toContain("inline-block!"); + // Two images in one paragraph are badges: neither reserves a slot. + expect(html).not.toContain("aspect-video"); + expect(html).toContain('src="https://example.com/badge.svg"'); + expect(html).toContain('src="https://signed.test/workspace-image.svg"'); + expect(html.match(/]*class="[^"]*inline-block![^"]*"/g)).toHaveLength(1); + expect(html).not.toContain("invisible"); const centeredHtml = render( '

logo

', ); - const centeredClassName = /]*class="([^"]*)"[^>]*role="status"/.exec(centeredHtml)?.[1]; + const frame = /]*role="status"[^>]*>/.exec(centeredHtml)?.[0]; + + expect(frame).toContain("inline-block!"); + expect(frame).toContain("aspect-video"); + }); - expect(centeredClassName?.split(" ")).toContain("inline-block!"); + it("reserves a slot for an image that is the only content of its link", () => { + const html = render("[![shot](.t3/workspace-image.svg)](https://example.com)"); + + expect(html).toContain("aspect-video"); }); it("retains an authored SVG fragment on the signed URL", () => { @@ -282,8 +288,10 @@ describe("ChatMarkdown workspace images", () => { }); it("reserves the same 16:9 frame while the URL, the bytes, and a failure resolve", () => { - const frameClassName = (html: string) => - /]*class="([^"]*)"[^>]*role="(?:status|alert)"/.exec(html)?.[1]?.split(" ") ?? []; + const frameClassName = (html: string) => { + const frame = /]*role="(?:status|alert)"[^>]*>/.exec(html)?.[0] ?? ""; + return /class="([^"]*)"/.exec(frame)?.[1]?.split(" ") ?? []; + }; const markdown = "![shot](.t3/workspace-image.svg)"; testState.assetState = "loading"; @@ -303,7 +311,7 @@ describe("ChatMarkdown workspace images", () => { expect(loadingBytes).not.toContain('loading="lazy"'); }); - it("gives remote images the same frame instead of a bare tag", () => { + it("gives a standalone remote image the same frame instead of a bare tag", () => { const html = render("![remote](https://example.com/shot.png)"); expect(html).toContain('aria-label="Loading image"'); diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index bf3fd85a5081..90c18984e17b 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -129,7 +129,7 @@ export function MediaVideoPlayer({ className={cn( // Same 16:9 slot as the loading and playing states, so a failed or // retried video does not move the rows below it. - "flex aspect-video 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", + "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", stateClassName, )} > From 6491096d1b839c637efaf9cbaa4cc6d98704a42d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:21:11 -0700 Subject: [PATCH 3/6] fix(web): a linked image beside text stays inline A link is inline content, so an image that is the only child of a link is standalone only when the link is the only child of its block. Co-Authored-By: Claude Code --- apps/web/src/components/ChatMarkdown.tsx | 4 +++- .../src/components/ChatMarkdown.workspace-images.test.tsx | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 962b52404153..3e44c03587b8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -372,7 +372,9 @@ function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastN */ function markStandaloneImages(node: MarkdownImageHastNode) { const children = meaningfulHastChildren(node); - if (children.length === 1) { + // 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); diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index d78e5e33ff05..594f6fd875be 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -231,6 +231,12 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain("aspect-video"); }); + it("keeps a linked image inline when text shares its line", () => { + const html = render("Figure: [![shot](.t3/workspace-image.svg)](https://example.com)"); + + expect(html).not.toContain("aspect-video"); + }); + it("retains an authored SVG fragment on the signed URL", () => { const html = render("![logo](icons.svg#logo)"); From 1b8cdd08e83de7341ba360942a5cec456c1e5b17 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:27:00 -0700 Subject: [PATCH 4/6] fix(web): keep the decoded image up across a re-signed URL Track which URL decoded instead of a bare flag. A null src while the asset URL re-resolves keeps showing the last decoded image, a new URL for the same file replaces it in place, and a failure forgets it so the next attempt loads behind the slot. Image components are keyed on the file identity so a different file starts from the slot. Co-Authored-By: Claude Code --- apps/web/src/components/ChatMarkdown.tsx | 44 ++++++++++++++---------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3e44c03587b8..7fc3ae22339a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1331,8 +1331,13 @@ const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( * 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; @@ -1344,21 +1349,28 @@ function ChatMarkdownImage(props: { readonly originalUrl?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { - const [loaded, setLoaded] = useState(false); + const [loadedSrc, setLoadedSrc] = useState(null); const [failedSrc, setFailedSrc] = useState(null); - const src = props.src; + const src = props.src ?? loadedSrc; const failed = props.sourceFailed === true || (src !== null && failedSrc === src); - // A decoded image keeps its box across a re-signed URL for the same file, so - // a periodic refresh never drops the row back to the placeholder; the browser - // keeps painting the old bitmap until the new one decodes. A failure clears - // that, so the next URL loads behind the slot again. - const settled = !failed && (!props.standalone || (loaded && failedSrc === null)); + // 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) setLoaded(true); + 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 (src !== null && settled) { + if (settled) { return ( { - setLoaded(true); - setFailedSrc(null); - }} - onError={() => setFailedSrc(src)} + {...imageEvents(src)} /> ); @@ -1432,11 +1440,7 @@ function ChatMarkdownImage(props: { decoding="async" draggable={false} className="invisible absolute inset-0 size-full" - onLoad={() => { - setLoaded(true); - setFailedSrc(null); - }} - onError={() => setFailedSrc(src)} + {...imageEvents(src)} /> ) : null}
@@ -1544,6 +1548,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props return ( Date: Sat, 5 Sep 2026 00:28:03 -0700 Subject: [PATCH 5/6] fix(web): classify standalone images by their block container Walk down from a block (paragraph, list item, cell, root) through inline wrappers to find a sole image, instead of treating any element with one child as a block. Emphasis around an image beside text no longer earns a slot. Co-Authored-By: Claude Code --- apps/web/src/components/ChatMarkdown.tsx | 41 +++++++++++++------ .../ChatMarkdown.workspace-images.test.tsx | 11 +++-- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7fc3ae22339a..e6712f278ed2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -370,19 +370,36 @@ function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastN * 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) { +/** 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); - // 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 }; - } + 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 `` 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); diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 594f6fd875be..a873e1c137cc 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -231,10 +231,15 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain("aspect-video"); }); - it("keeps a linked image inline when text shares its line", () => { - const html = render("Figure: [![shot](.t3/workspace-image.svg)](https://example.com)"); + it.each([ + ["a link", "Figure: [![shot](.t3/workspace-image.svg)](https://example.com)"], + ["emphasis", "**![shot](.t3/workspace-image.svg)** caption"], + ])("keeps an image wrapped in %s inline when text shares its block", (_wrapper, markdown) => { + expect(render(markdown)).not.toContain("aspect-video"); + }); - expect(html).not.toContain("aspect-video"); + it("reserves a slot for an image that is alone in a list item", () => { + expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video"); }); it("retains an authored SVG fragment on the signed URL", () => { From 6d6472c1c24411524483a8b3550c246d4bacaf6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:42:26 -0700 Subject: [PATCH 6/6] fix(web): forward sanitized image attributes to the rendered image The direct-image path used to spread the sanitized props onto its img; keep doing that so an authored id or align survives, and put the id on the loading slot too so a fragment link resolves before the image does. Co-Authored-By: Claude Code --- apps/web/src/components/ChatMarkdown.tsx | 14 ++++++++++++-- .../ChatMarkdown.workspace-images.test.tsx | 7 +++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e6712f278ed2..af41324be6a7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -52,6 +52,7 @@ import React, { Children, Suspense, type CSSProperties, + type ComponentProps, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, @@ -1362,6 +1363,10 @@ function ChatMarkdownImage(props: { readonly standalone: boolean; readonly className?: string | undefined; readonly style?: CSSProperties | undefined; + /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ + readonly imageProps?: + | Omit, "src" | "alt" | "className" | "style"> + | undefined; readonly actionsSource: MediaActionSource; readonly originalUrl?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; @@ -1391,6 +1396,7 @@ function ChatMarkdownImage(props: { return ( {props.alt} ) : ( { expect(render(markdown)).not.toContain("aspect-video"); }); + it("keeps an authored id on a remote image so fragment links resolve", () => { + const html = render('diagram'); + + // The sanitizer prefixes authored ids; the loading slot carries it too. + expect(html).toContain(' { expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video"); });