authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-09-14 15:26:20+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 15:26:20+05:30
log0dec07d91488e08206ad32f9758a95acd746c424
treeb1119aedddb866eb29c97346680c0dad6d7287f0
parent01e05c15268dedb76da95f442fbf5201cd8e7a44
signature Signed by PGP key B5690EEEBB952194

fix(web): keep large image previews from stalling composer typing (#11324)


4 files changed, 151 insertions(+), 3 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+21-3
...@@ -242,6 +242,7 @@ import { ProviderModelPicker } from "./ProviderModelPicker";...@@ -242,6 +242,7 @@ import { ProviderModelPicker } from "./ProviderModelPicker";
242import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";242import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
243import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";243import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
244import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";244import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
245import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
245import { ComposerPrimaryActions } from "./ComposerPrimaryActions";246import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
246import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";247import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
247import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";248import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
...@@ -4673,7 +4674,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4673,7 +4674,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4673 }}4674 }}
4674 >4675 >
4675 {image.previewUrl ? (4676 {image.previewUrl ? (
4676 <img src={image.previewUrl} alt="" className="size-full object-cover" />4677 <ComposerImageThumbnail
4678 file={image.file}
4679 alt=""
4680 className="size-full object-cover"
4681 fallback={
4682 <PierreEntryIcon
4683 pathValue={image.name}
4684 kind="file"
4685 theme={resolvedTheme}
4686 className="m-auto size-3.5"
4687 />
4688 }
4689 />
4677 ) : (4690 ) : (
4678 <PierreEntryIcon4691 <PierreEntryIcon
4679 pathValue={image.name}4692 pathValue={image.name}
...@@ -6287,10 +6300,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6287,10 +6300,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6287 onExpandImage(preview);6300 onExpandImage(preview);
6288 }}6301 }}
6289 >6302 >
6290 <img6303 <ComposerImageThumbnail
6291 src={image.previewUrl}6304 file={image.file}
6292 alt={image.name}6305 alt={image.name}
6293 className="h-full w-full object-cover"6306 className="h-full w-full object-cover"
6307 fallback={
6308 <span className="flex h-full items-center justify-center px-1 text-[10px] text-secondary-label">
6309 {image.name}
6310 </span>
6311 }
6294 />6312 />
6295 </button>6313 </button>
6296 ) : (6314 ) : (
apps/web/src/components/chat/ComposerImageThumbnail.tsx created+29
...@@ -0,0 +1,29 @@
1import { memo, useEffect, useState, type ReactNode } from "react";
2
3import { createComposerImageThumbnail } from "../../lib/imageCompression";
4
5/** Keep full-resolution image decoding out of composer rerenders. */
6export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({
7 file,
8 alt,
9 className,
10 fallback,
11}: {
12 file: File;
13 alt: string;
14 className: string;
15 fallback: ReactNode;
16}) {
17 const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null);
18 useEffect(() => {
19 let active = true;
20 void createComposerImageThumbnail(file).then((src) => {
21 if (active) setPreview({ file, src });
22 });
23 return () => {
24 active = false;
25 };
26 }, [file]);
27 const src = preview?.file === file ? preview.src : null;
28 return src ? <img src={src} alt={alt} className={className} /> : fallback;
29});
apps/web/src/lib/imageCompression.test.ts+60
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22
3import {3import {
4 createComposerImageThumbnail,
4 compressImageForStash,5 compressImageForStash,
5 compressImageToByteLimit,6 compressImageToByteLimit,
6 dataUrlToFile,7 dataUrlToFile,
...@@ -123,6 +124,65 @@ afterEach(() => {...@@ -123,6 +124,65 @@ afterEach(() => {
123 globalThis.OffscreenCanvas = originalOffscreenCanvas;124 globalThis.OffscreenCanvas = originalOffscreenCanvas;
124});125});
125126
127describe("composer image thumbnails", () => {
128 it("decodes a tall original once and caches a bounded center crop", async () => {
129 const close = vi.fn();
130 const bitmap = { width: 2304, height: 32766, close };
131 const decode = vi.fn(async () => bitmap);
132 const drawImage = vi.fn();
133 const dimensions: number[][] = [];
134 vi.stubGlobal("createImageBitmap", decode);
135 vi.stubGlobal(
136 "OffscreenCanvas",
137 class {
138 constructor(width: number, height: number) {
139 dimensions.push([width, height]);
140 }
141 getContext() {
142 return { drawImage };
143 }
144 async convertToBlob() {
145 return new Blob(["thumbnail"], { type: "image/png" });
146 }
147 },
148 );
149 const original = new File(["original bytes"], "tall.png", { type: "image/png" });
150 const [first, second] = await Promise.all([
151 createComposerImageThumbnail(original),
152 createComposerImageThumbnail(original),
153 ]);
154 expect(first).toBe("data:image/png;base64,dGh1bWJuYWls");
155 expect(second).toBe(first);
156 expect(await createComposerImageThumbnail(original)).toBe(first);
157 expect(decode).toHaveBeenCalledExactlyOnceWith(original);
158 expect(dimensions).toEqual([[256, 256]]);
159 expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 15231, 2304, 2304, 0, 0, 256, 256);
160 expect(close).toHaveBeenCalledOnce();
161 expect(await original.text()).toBe("original bytes");
162 });
163
164 it("releases the decoded image when thumbnail encoding fails", async () => {
165 const close = vi.fn();
166 vi.stubGlobal(
167 "createImageBitmap",
168 vi.fn(async () => ({ width: 500, height: 500, close })),
169 );
170 vi.stubGlobal(
171 "OffscreenCanvas",
172 class {
173 getContext() {
174 return { drawImage: vi.fn() };
175 }
176 async convertToBlob() {
177 throw new Error("encoder unavailable");
178 }
179 },
180 );
181 expect(await createComposerImageThumbnail(makeFile(5))).toBeNull();
182 expect(close).toHaveBeenCalledOnce();
183 });
184});
185
126describe("dataUrlToFile", () => {186describe("dataUrlToFile", () => {
127 it("decodes a captured image without a fetch request", async () => {187 it("decodes a captured image without a fetch request", async () => {
128 const file = dataUrlToFile("data:image/png;base64,AAEC/w==", "window.png", "image/png");188 const file = dataUrlToFile("data:image/png;base64,AAEC/w==", "window.png", "image/png");
apps/web/src/lib/imageCompression.ts+41
...@@ -247,6 +247,47 @@ async function encodeCanvas(...@@ -247,6 +247,47 @@ async function encodeCanvas(
247 return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };247 return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
248}248}
249249
250const composerThumbnails = new WeakMap<File, Promise<string | null>>();
251
252/** Cache a centered square crop for the composer's object-cover image tiles. */
253export function createComposerImageThumbnail(file: File): Promise<string | null> {
254 const cached = composerThumbnails.get(file);
255 if (cached) return cached;
256 const thumbnail = (async () => {
257 if (!canRecompress()) return null;
258 let bitmap: ImageBitmap | undefined;
259 try {
260 bitmap = await createImageBitmap(file);
261 const side = Math.min(bitmap.width, bitmap.height);
262 if (side <= 0) return null;
263 const dimension = Math.min(256, side);
264 const surface = createCanvas(dimension, dimension);
265 if (!surface) return null;
266 surface.context.drawImage(
267 bitmap,
268 (bitmap.width - side) / 2,
269 (bitmap.height - side) / 2,
270 side,
271 side,
272 0,
273 0,
274 dimension,
275 dimension,
276 );
277 return (
278 (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ??
279 null
280 );
281 } catch {
282 return null;
283 } finally {
284 bitmap?.close();
285 }
286 })();
287 composerThumbnails.set(file, thumbnail);
288 return thumbnail;
289}
290
250/**291/**
251 * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping292 * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping
252 * quality down until the data URL fits `budgetChars`.293 * quality down until the data URL fits `budgetChars`.