From 0dec07d91488e08206ad32f9758a95acd746c424 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:26:20 +0530 Subject: [PATCH] fix(web): keep large image previews from stalling composer typing (#11324) --- apps/web/src/components/chat/ChatComposer.tsx | 24 +++++++- .../chat/ComposerImageThumbnail.tsx | 29 +++++++++ apps/web/src/lib/imageCompression.test.ts | 60 +++++++++++++++++++ apps/web/src/lib/imageCompression.ts | 41 +++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerImageThumbnail.tsx 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} + {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 ? {alt} : 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`. -- 2.54.0