diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 61e498f9e..2259d8f4c 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -51,6 +51,7 @@ import {
classifyChildren,
hasBlockMedia,
isImageOnlyParagraph,
+ reservedImageSize,
shallowArrayEqual,
} from "./markdownUtils";
import { resolveFileCard } from "./markdownFileCard";
@@ -246,10 +247,12 @@ type MarkdownVariant = "default" | "compact" | "tight";
*/
function ImageBlock({
alt,
+ dim,
resolvedSrc,
src,
}: {
alt: string | undefined;
+ dim?: string;
resolvedSrc: string | undefined;
src: string | undefined;
}) {
@@ -299,13 +302,22 @@ function ImageBlock({
});
};
+ const reserved = reservedImageSize(dim);
+
return (
<>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: image opens lightbox on click; keyboard equivalent handled by lightbox close button */}
setLightboxOpen(true)}
onContextMenuCapture={handleContextMenu}
/>
@@ -911,7 +923,12 @@ function createMarkdownComponents(
}
return (
-
+
);
},
diff --git a/desktop/src/shared/ui/markdownUtils.ts b/desktop/src/shared/ui/markdownUtils.ts
index dab951285..886813102 100644
--- a/desktop/src/shared/ui/markdownUtils.ts
+++ b/desktop/src/shared/ui/markdownUtils.ts
@@ -65,3 +65,46 @@ export function shallowArrayEqual(a?: string[], b?: string[]): boolean {
}
return true;
}
+
+// Display caps for inline message images — must mirror the `max-h-64 max-w-sm`
+// (256×384px) Tailwind classes on the rendered
.
+export const INLINE_IMAGE_MAX_WIDTH = 384;
+export const INLINE_IMAGE_MAX_HEIGHT = 256;
+
+/**
+ * Compute the exact rendered box for an inline image from its imeta `dim`
+ * ("WIDTHxHEIGHT"), scaled to fit the display caps (same fit as `object-contain`
+ * within `max-w-sm`/`max-h-64`). Used to reserve the row's height via the
+ * width/height attributes BEFORE the bytes load — without it, image rows paint
+ * at height 0 then grow when the image arrives, thrashing the virtualized list
+ * (every late image re-measures and shifts its neighbors).
+ *
+ * Returns undefined when `dim` is missing/malformed/non-positive — we can't
+ * reserve what we don't know, so the natural load is the correct fallback.
+ */
+export function reservedImageSize(
+ dim: string | undefined,
+): { width: number; height: number } | undefined {
+ if (!dim) return undefined;
+ const match = dim.match(/^(\d+)x(\d+)$/i);
+ if (!match) return undefined;
+ const width = Number(match[1]);
+ const height = Number(match[2]);
+ if (
+ !Number.isFinite(width) ||
+ !Number.isFinite(height) ||
+ width <= 0 ||
+ height <= 0
+ ) {
+ return undefined;
+ }
+ const scale = Math.min(
+ 1,
+ INLINE_IMAGE_MAX_WIDTH / width,
+ INLINE_IMAGE_MAX_HEIGHT / height,
+ );
+ return {
+ width: Math.round(width * scale),
+ height: Math.round(height * scale),
+ };
+}
diff --git a/desktop/src/shared/ui/reservedImageSize.dom.test.tsx b/desktop/src/shared/ui/reservedImageSize.dom.test.tsx
new file mode 100644
index 000000000..2a021fb55
--- /dev/null
+++ b/desktop/src/shared/ui/reservedImageSize.dom.test.tsx
@@ -0,0 +1,45 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+// Imported through the DOM lane (esbuild) so we can pull from markdownUtils.ts
+// directly even though it imports React — no stale inlined copy. Pure-math
+// helper, but it lives in a React module, hence the .dom lane.
+import {
+ INLINE_IMAGE_MAX_HEIGHT,
+ INLINE_IMAGE_MAX_WIDTH,
+ reservedImageSize,
+} from "./markdownUtils";
+
+test("returns undefined when dim is missing or malformed", () => {
+ assert.equal(reservedImageSize(undefined), undefined);
+ assert.equal(reservedImageSize(""), undefined);
+ assert.equal(reservedImageSize("800"), undefined);
+ assert.equal(reservedImageSize("800x"), undefined);
+ assert.equal(reservedImageSize("axb"), undefined);
+ assert.equal(reservedImageSize("0x600"), undefined);
+ assert.equal(reservedImageSize("800x0"), undefined);
+});
+
+test("small image within caps is reserved at its natural size", () => {
+ // 200x100 fits inside 384x256 → no scaling.
+ assert.deepEqual(reservedImageSize("200x100"), { width: 200, height: 100 });
+});
+
+test("wide image scales down to the width cap, preserving aspect ratio", () => {
+ // 800x400 (2:1). Width cap 384 → scale 0.48 → 384x192.
+ assert.deepEqual(reservedImageSize("800x400"), { width: 384, height: 192 });
+});
+
+test("tall image scales down to the height cap, preserving aspect ratio", () => {
+ // 400x800 (1:2). Height cap 256 → scale 0.32 → 128x256.
+ assert.deepEqual(reservedImageSize("400x800"), { width: 128, height: 256 });
+});
+
+test("reserved box never exceeds the display caps", () => {
+ const r = reservedImageSize("4000x3000");
+ assert.ok(r);
+ assert.ok(r.width <= INLINE_IMAGE_MAX_WIDTH);
+ assert.ok(r.height <= INLINE_IMAGE_MAX_HEIGHT);
+ // 4:3 → constrained by height (256) → 341x256.
+ assert.deepEqual(r, { width: 341, height: 256 });
+});