diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index e3ae139110360fa257a13e5a5a6b92975aeb89fd..fafdee59e04292cf093a30de5eadcfc33bf5ce96 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -242,6 +242,7 @@ import { ProviderModelPicker } from "./ProviderModelPicker";
import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
+import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
@@ -4673,7 +4674,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}}
>
{image.previewUrl ? (
-
+
+ }
+ />
) : (
-
+ {image.name}
+
+ }
/>
) : (
diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c803d5693009f180231cd44d2358ce30fadfb4c9
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx
@@ -0,0 +1,29 @@
+import { memo, useEffect, useState, type ReactNode } from "react";
+
+import { createComposerImageThumbnail } from "../../lib/imageCompression";
+
+/** Keep full-resolution image decoding out of composer rerenders. */
+export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({
+ file,
+ alt,
+ className,
+ fallback,
+}: {
+ file: File;
+ alt: string;
+ className: string;
+ fallback: ReactNode;
+}) {
+ const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null);
+ useEffect(() => {
+ let active = true;
+ void createComposerImageThumbnail(file).then((src) => {
+ if (active) setPreview({ file, src });
+ });
+ return () => {
+ active = false;
+ };
+ }, [file]);
+ const src = preview?.file === file ? preview.src : null;
+ return src ?
: fallback;
+});
diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts
index 5c8952144f5ab7caeba26073b0b8e0471ae64f89..d83bc1ef87c184bfad26be11f96a9d157029c6df 100644
--- a/apps/web/src/lib/imageCompression.test.ts
+++ b/apps/web/src/lib/imageCompression.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import {
+ createComposerImageThumbnail,
compressImageForStash,
compressImageToByteLimit,
dataUrlToFile,
@@ -123,6 +124,65 @@ afterEach(() => {
globalThis.OffscreenCanvas = originalOffscreenCanvas;
});
+describe("composer image thumbnails", () => {
+ it("decodes a tall original once and caches a bounded center crop", async () => {
+ const close = vi.fn();
+ const bitmap = { width: 2304, height: 32766, close };
+ const decode = vi.fn(async () => bitmap);
+ const drawImage = vi.fn();
+ const dimensions: number[][] = [];
+ vi.stubGlobal("createImageBitmap", decode);
+ vi.stubGlobal(
+ "OffscreenCanvas",
+ class {
+ constructor(width: number, height: number) {
+ dimensions.push([width, height]);
+ }
+ getContext() {
+ return { drawImage };
+ }
+ async convertToBlob() {
+ return new Blob(["thumbnail"], { type: "image/png" });
+ }
+ },
+ );
+ const original = new File(["original bytes"], "tall.png", { type: "image/png" });
+ const [first, second] = await Promise.all([
+ createComposerImageThumbnail(original),
+ createComposerImageThumbnail(original),
+ ]);
+ expect(first).toBe("data:image/png;base64,dGh1bWJuYWls");
+ expect(second).toBe(first);
+ expect(await createComposerImageThumbnail(original)).toBe(first);
+ expect(decode).toHaveBeenCalledExactlyOnceWith(original);
+ expect(dimensions).toEqual([[256, 256]]);
+ expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 15231, 2304, 2304, 0, 0, 256, 256);
+ expect(close).toHaveBeenCalledOnce();
+ expect(await original.text()).toBe("original bytes");
+ });
+
+ it("releases the decoded image when thumbnail encoding fails", async () => {
+ const close = vi.fn();
+ vi.stubGlobal(
+ "createImageBitmap",
+ vi.fn(async () => ({ width: 500, height: 500, close })),
+ );
+ vi.stubGlobal(
+ "OffscreenCanvas",
+ class {
+ getContext() {
+ return { drawImage: vi.fn() };
+ }
+ async convertToBlob() {
+ throw new Error("encoder unavailable");
+ }
+ },
+ );
+ expect(await createComposerImageThumbnail(makeFile(5))).toBeNull();
+ expect(close).toHaveBeenCalledOnce();
+ });
+});
+
describe("dataUrlToFile", () => {
it("decodes a captured image without a fetch request", async () => {
const file = dataUrlToFile("data:image/png;base64,AAEC/w==", "window.png", "image/png");
diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts
index af51001f7102edc867e993f29c048a7461b539a0..8414f190d5d9d64a913c51202919b3064b623963 100644
--- a/apps/web/src/lib/imageCompression.ts
+++ b/apps/web/src/lib/imageCompression.ts
@@ -247,6 +247,47 @@ async function encodeCanvas(
return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
}
+const composerThumbnails = new WeakMap>();
+
+/** Cache a centered square crop for the composer's object-cover image tiles. */
+export function createComposerImageThumbnail(file: File): Promise {
+ const cached = composerThumbnails.get(file);
+ if (cached) return cached;
+ const thumbnail = (async () => {
+ if (!canRecompress()) return null;
+ let bitmap: ImageBitmap | undefined;
+ try {
+ bitmap = await createImageBitmap(file);
+ const side = Math.min(bitmap.width, bitmap.height);
+ if (side <= 0) return null;
+ const dimension = Math.min(256, side);
+ const surface = createCanvas(dimension, dimension);
+ if (!surface) return null;
+ surface.context.drawImage(
+ bitmap,
+ (bitmap.width - side) / 2,
+ (bitmap.height - side) / 2,
+ side,
+ side,
+ 0,
+ 0,
+ dimension,
+ dimension,
+ );
+ return (
+ (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ??
+ null
+ );
+ } catch {
+ return null;
+ } finally {
+ bitmap?.close();
+ }
+ })();
+ composerThumbnails.set(file, thumbnail);
+ return thumbnail;
+}
+
/**
* Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping
* quality down until the data URL fits `budgetChars`.