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..0dc5b367b4e34 100644
--- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx
+++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx
@@ -16,7 +16,46 @@ const snapShotSource: SnapShotSource = {
windowTitle: "Downloads",
};
+const D2_SVG =
+ '';
+
describe("ExpandedImageDialog", () => {
+ it.each(["/work/pipeline.svg", "/work/diagram#v2.svg", "/work/diagram?v2.svg"])(
+ "fits a markdown SVG without intrinsic size to the viewer: %s",
+ async (path) => {
+ 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..72fb4db069216 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, mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview";
interface ExpandedImageDialogProps {
preview: ExpandedImagePreview;
@@ -42,6 +44,16 @@ function ExpandedMediaFailure({ children }: { children: ReactNode }) {
);
}
+function itemMimeType(item: ExpandedImageItem): string | undefined {
+ if (item.mimeType) return item.mimeType;
+ const reference = item.actionsSource?.reference;
+ 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 }) {
const asset = item.actionsSource?.asset;
const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null);
@@ -222,7 +234,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),
);