From 5f3dad1cc9e58e10c598aad38a5930fa4b620f6b Mon Sep 17 00:00:00 2001 From: Melbourne Baldove Date: Fri, 30 Oct 2026 18:27:39 +0800 Subject: [PATCH 1/2] fix(web): expanded SVG diagrams fill the image viewer Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 7 +++- .../chat/ExpandedImageDialog.browser.test.tsx | 36 +++++++++++++++++++ .../components/chat/ExpandedImageDialog.tsx | 12 ++++++- .../web/src/components/chat/ZoomableImage.tsx | 3 +- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 99c2ebbc8f40f..a97cc1357bb33 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1608,7 +1608,12 @@ const CHAT_MARKDOWN_COMPONENTS = { fenceTitle={fenceTitle} theme={resolvedTheme} isStreaming={isStreaming && !isClosedCodeFence(node, text, codeBlock.code)} - onExpand={(src) => expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} + onExpand={(src) => + expandMedia({ + images: [{ src, name: "Mermaid diagram", mimeType: "image/svg+xml" }], + index: 0, + }) + } > {highlightedCode} diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx index 1ea8d4c558f35..c0c94a1845533 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -16,7 +16,43 @@ const snapShotSource: SnapShotSource = { windowTitle: "Downloads", }; +const D2_SVG = + ''; + describe("ExpandedImageDialog", () => { + it("fits a markdown SVG without intrinsic size to the viewer", async () => { + const src = URL.createObjectURL(new Blob([D2_SVG], { type: "image/svg+xml" })); + using _src = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + await using screen = await render( + , + // The dialog renders no links and reads no route. + { router: false }, + ); + + const viewer = screen.getByRole("region", { name: "Pipeline, zoomable image" }); + const availableWidth = window.innerWidth * 0.92 - (window.innerWidth >= 640 ? 96 : 0); + await vi.waitFor(() => + expect(viewer.element().getBoundingClientRect().width).toBeCloseTo(availableWidth, 0), + ); + }); + it("Escape closes a popup opened inside the dialog before the dialog", async () => { const onClose = vi.fn(); await using screen = await render( diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index aadbcab79b129..81493cfd45390 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -25,6 +25,8 @@ import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { expandedImageKey } from "./ExpandedImagePreview"; import { registerThreadOverlay } from "./threadOverlayRetention"; +import { mediaReferenceFileName } from "@t3tools/client-runtime/media-reference"; +import { mediaMimeType } from "@t3tools/shared/mediaPreview"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -42,6 +44,14 @@ function ExpandedMediaFailure({ children }: { children: ReactNode }) { ); } +function itemMimeType(item: ExpandedImageItem): string | undefined { + if (item.mimeType) return item.mimeType; + const reference = item.actionsSource?.reference; + return ( + mediaMimeType((reference && mediaReferenceFileName(reference)) || item.src || "") ?? undefined + ); +} + function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { const asset = item.actionsSource?.asset; const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); @@ -222,7 +232,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ key={`${index}:${item.src}`} src={item.src} name={item.name} - mimeType={item.mimeType} + mimeType={itemMimeType(item)} onError={() => setFailedImageSrc(item.src)} /> )} diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx index a5ce763d65419..0e8451300dc27 100644 --- a/apps/web/src/components/chat/ZoomableImage.tsx +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -71,8 +71,9 @@ export function ZoomableImage({ const suppressClickRef = useRef(false); const [dragging, setDragging] = useState(false); const maxHeight = Math.max(1, Math.min(viewerSize.height * 0.86, viewerSize.height - 160)); + // A vector scales without loss, and an SVG without width and height reports a tiny default size. const fit = Math.min( - 1, + mimeType === "image/svg+xml" ? Infinity : 1, (viewerSize.width * 0.92 - (viewerSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1), maxHeight / (naturalSize.height || 1), ); From 2cd0b95c53394baa70adedfe0bd512bf55c86982 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 21:10:50 +0000 Subject: [PATCH 2/2] fix(web): fit svg previews with literal filename characters --- .../chat/ExpandedImageDialog.browser.test.tsx | 61 ++++++++++--------- .../components/chat/ExpandedImageDialog.tsx | 10 +-- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx index c0c94a1845533..0dc5b367b4e34 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -20,38 +20,41 @@ const D2_SVG = ''; describe("ExpandedImageDialog", () => { - it("fits a markdown SVG without intrinsic size to the viewer", async () => { - const src = URL.createObjectURL(new Blob([D2_SVG], { type: "image/svg+xml" })); - using _src = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; - await using screen = await render( - { + const src = URL.createObjectURL(new Blob([D2_SVG], { type: "image/svg+xml" })); + using _src = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + await using screen = await render( + , - // The dialog renders no links and reads no route. - { router: false }, - ); + ], + index: 0, + }} + onClose={vi.fn()} + />, + // The dialog renders no links and reads no route. + { router: false }, + ); - const viewer = screen.getByRole("region", { name: "Pipeline, zoomable image" }); - const availableWidth = window.innerWidth * 0.92 - (window.innerWidth >= 640 ? 96 : 0); - await vi.waitFor(() => - expect(viewer.element().getBoundingClientRect().width).toBeCloseTo(availableWidth, 0), - ); - }); + const viewer = screen.getByRole("region", { name: "Pipeline, zoomable image" }); + const availableWidth = window.innerWidth * 0.92 - (window.innerWidth >= 640 ? 96 : 0); + await vi.waitFor(() => + expect(viewer.element().getBoundingClientRect().width).toBeCloseTo(availableWidth, 0), + ); + }, + ); it("Escape closes a popup opened inside the dialog before the dialog", async () => { const onClose = vi.fn(); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 81493cfd45390..72fb4db069216 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -26,7 +26,7 @@ import { appAtomRegistry } from "~/rpc/atomRegistry"; import { expandedImageKey } from "./ExpandedImagePreview"; import { registerThreadOverlay } from "./threadOverlayRetention"; import { mediaReferenceFileName } from "@t3tools/client-runtime/media-reference"; -import { mediaMimeType } from "@t3tools/shared/mediaPreview"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -47,9 +47,11 @@ function ExpandedMediaFailure({ children }: { children: ReactNode }) { function itemMimeType(item: ExpandedImageItem): string | undefined { if (item.mimeType) return item.mimeType; const reference = item.actionsSource?.reference; - return ( - mediaMimeType((reference && mediaReferenceFileName(reference)) || item.src || "") ?? undefined - ); + const fileName = reference && mediaReferenceFileName(reference); + if (fileName) { + return mediaMimeTypeFromExtension(fileName.slice(fileName.lastIndexOf("."))) ?? undefined; + } + return mediaMimeType(item.src ?? "") ?? undefined; } function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {