Skip to content

Commit 625bc2a

Browse files
t3dotggclaude
authored andcommitted
feat(web): pasting a huge screenshot now compresses it instead of erroring (pingdotgg#4967)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit cbe8052)
1 parent e269ec3 commit 625bc2a

3 files changed

Lines changed: 282 additions & 82 deletions

File tree

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import {
6464
} from "../../promptStashStore";
6565
import { ComposerStashBadge } from "./ComposerStashBadge";
6666
import { ComposerStashMenu } from "./ComposerStashMenu";
67-
import { compressImageForStash } from "../../lib/stashImageCompression";
67+
import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";
6868
import { isCommandPaletteOpen } from "../../commandPaletteBus";
6969
import { getTerminalFocusOwner } from "../../lib/terminalFocus";
7070
import { resolveShortcutCommand } from "../../keybindings";
@@ -196,8 +196,6 @@ import { searchProviderSkills } from "../../providerSkillSearch";
196196
import { useMediaQuery } from "../../hooks/useMediaQuery";
197197
import type { ReviewCommentContext } from "../../reviewCommentContext";
198198

199-
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
200-
201199
const COMPOSER_FLOATING_LAYER_SELECTOR = [
202200
'[data-slot="popover-popup"]',
203201
'[data-slot="menu-popup"]',
@@ -972,6 +970,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
972970
* thread) can still be stashed while an earlier encode is running.
973971
*/
974972
const stashInFlightRef = useRef<Set<string>>(new Set());
973+
/**
974+
* Count of pasted images still being compressed, per thread. Reserved
975+
* against the attachment limit so concurrent pastes can't overshoot it,
976+
* and checked by `submitComposer` so a send can't race an image into the
977+
* next draft.
978+
*/
979+
const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());
975980

976981
// ------------------------------------------------------------------
977982
// Derived: composer send state
@@ -1787,12 +1792,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17871792
event?.preventDefault();
17881793
return;
17891794
}
1795+
// A send while a pasted image is still compressing would strand that
1796+
// image: the turn snapshot wouldn't include it, and it would surface
1797+
// in the *next* draft instead. Only oversized images hit this — small
1798+
// files clear the pending counter within a microtask.
1799+
if (activeThreadId && (pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {
1800+
event?.preventDefault();
1801+
toastManager.add({
1802+
type: "info",
1803+
title: "Still compressing a pasted image.",
1804+
description: "Send again once its thumbnail appears.",
1805+
});
1806+
return;
1807+
}
17901808
onSend(event);
17911809
if (shouldBlurMobileComposerOnSubmit()) {
17921810
blurMobileComposerAfterSend();
17931811
}
17941812
},
17951813
[
1814+
activeThreadId,
17961815
blurMobileComposerAfterSend,
17971816
isSendDisabled,
17981817
noProviderAvailable,
@@ -2239,7 +2258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22392258
// ------------------------------------------------------------------
22402259
// Callbacks: images
22412260
// ------------------------------------------------------------------
2242-
const addComposerImages = (files: File[]) => {
2261+
const addComposerImages = async (files: File[]) => {
22432262
if (!activeThreadId || files.length === 0) return;
22442263
if (pendingUserInputs.length > 0) {
22452264
toastManager.add({
@@ -2248,40 +2267,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22482267
});
22492268
return;
22502269
}
2251-
const nextImages: ComposerImageAttachment[] = [];
2252-
let nextImageCount = composerImagesRef.current.length;
2270+
// Captured before the awaits below: the user may switch threads while a
2271+
// large image is being compressed, and the attachments and errors belong
2272+
// to the thread the paste happened in.
2273+
const threadId = activeThreadId;
2274+
2275+
// Validation happens synchronously so concurrent pastes see each other:
2276+
// accepted files reserve their attachment slots (via the pending counter)
2277+
// before the first await, keeping the total under the limit.
2278+
const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0;
2279+
let reservedCount = composerImagesRef.current.length + pendingCount;
2280+
const acceptedFiles: File[] = [];
22532281
let error: string | null = null;
22542282
for (const file of files) {
22552283
if (!file.type.startsWith("image/")) {
22562284
error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
22572285
continue;
22582286
}
2259-
if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
2260-
error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
2261-
continue;
2262-
}
2263-
if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
2287+
if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
22642288
error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
22652289
break;
22662290
}
2267-
const previewUrl = URL.createObjectURL(file);
2268-
nextImages.push({
2269-
type: "image",
2270-
id: randomUUID(),
2271-
name: file.name || "image",
2272-
mimeType: file.type,
2273-
sizeBytes: file.size,
2274-
previewUrl,
2275-
file,
2276-
});
2277-
nextImageCount += 1;
2291+
acceptedFiles.push(file);
2292+
reservedCount += 1;
22782293
}
2279-
if (nextImages.length === 1 && nextImages[0]) {
2280-
addComposerImage(nextImages[0]);
2281-
} else if (nextImages.length > 1) {
2282-
addComposerImagesToDraft(nextImages);
2294+
setThreadError(threadId, error);
2295+
if (acceptedFiles.length === 0) return;
2296+
2297+
pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length);
2298+
try {
2299+
const nextImages: ComposerImageAttachment[] = [];
2300+
let compressionError: string | null = null;
2301+
for (const file of acceptedFiles) {
2302+
// Images over the wire cap are downscaled to fit rather than
2303+
// refused; files already within it pass through byte-for-byte.
2304+
const compressed = await compressImageToByteLimit(file, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES);
2305+
if (!compressed.ok) {
2306+
compressionError =
2307+
compressed.reason === "unreadable"
2308+
? `'${file.name}' could not be read as an image.`
2309+
: `'${file.name}' is too large to attach, even after compression.`;
2310+
continue;
2311+
}
2312+
const attachmentFile = compressed.file;
2313+
const previewUrl = URL.createObjectURL(attachmentFile);
2314+
nextImages.push({
2315+
type: "image",
2316+
id: randomUUID(),
2317+
name: attachmentFile.name || "image",
2318+
mimeType: attachmentFile.type,
2319+
sizeBytes: attachmentFile.size,
2320+
previewUrl,
2321+
file: attachmentFile,
2322+
});
2323+
}
2324+
if (nextImages.length === 1 && nextImages[0]) {
2325+
addComposerImage(nextImages[0]);
2326+
} else if (nextImages.length > 1) {
2327+
addComposerImagesToDraft(nextImages);
2328+
}
2329+
// Only failures are reported here. Success must not pass `null`: by
2330+
// now other work (a failed send, an overlapping paste) may have set a
2331+
// thread error this call knows nothing about, and clearing it would
2332+
// swallow that message.
2333+
if (compressionError !== null) {
2334+
setThreadError(threadId, compressionError);
2335+
}
2336+
} finally {
2337+
const remaining =
2338+
(pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length;
2339+
if (remaining > 0) {
2340+
pendingImageCompressionsRef.current.set(threadId, remaining);
2341+
} else {
2342+
pendingImageCompressionsRef.current.delete(threadId);
2343+
}
22832344
}
2284-
setThreadError(activeThreadId, error);
22852345
};
22862346

22872347
const removeComposerImage = (imageId: string) => {
@@ -2297,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22972357
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
22982358
if (imageFiles.length === 0) return;
22992359
event.preventDefault();
2300-
addComposerImages(imageFiles);
2360+
void addComposerImages(imageFiles);
23012361
};
23022362

23032363
const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
@@ -2331,7 +2391,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23312391
dragDepthRef.current = 0;
23322392
setIsDragOverComposer(false);
23332393
const files = Array.from(event.dataTransfer.files);
2334-
addComposerImages(files);
2394+
void addComposerImages(files);
23352395
focusComposer();
23362396
};
23372397

apps/web/src/lib/stashImageCompression.test.ts renamed to apps/web/src/lib/imageCompression.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22

3-
import { compressImageForStash, MAX_STASH_IMAGE_DATA_URL_CHARS } from "./stashImageCompression";
3+
import {
4+
compressImageForStash,
5+
compressImageToByteLimit,
6+
MAX_COMPRESSIBLE_SOURCE_BYTES,
7+
MAX_STASH_IMAGE_DATA_URL_CHARS,
8+
} from "./imageCompression";
49

510
/**
611
* jsdom has no real canvas/codec, so the re-encode path is exercised with
@@ -159,6 +164,56 @@ describe("compressImageForStash", () => {
159164
});
160165
});
161166

167+
it("compressImageToByteLimit passes small files through byte-for-byte", async () => {
168+
const bitmapSpy = vi.fn();
169+
vi.stubGlobal("createImageBitmap", bitmapSpy);
170+
171+
const original = makeFile(1024);
172+
const result = await compressImageToByteLimit(original, 10 * 1024 * 1024);
173+
174+
expect(result.ok).toBe(true);
175+
expect(result.ok && result.recompressed).toBe(false);
176+
// Pass-through must be the same File object, not a copy.
177+
expect(result.ok && result.file).toBe(original);
178+
expect(bitmapSpy).not.toHaveBeenCalled();
179+
});
180+
181+
it("compressImageToByteLimit re-encodes an oversized file under the byte cap", async () => {
182+
stubCanvasPipeline(() => 200_000);
183+
184+
const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);
185+
186+
expect(result.ok).toBe(true);
187+
expect(result.ok && result.recompressed).toBe(true);
188+
expect(result.ok && result.file.type).toBe("image/webp");
189+
// The re-encoded name must match the new container format.
190+
expect(result.ok && result.file.name).toBe("shot.webp");
191+
expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000);
192+
});
193+
194+
it("compressImageToByteLimit refuses sources above the decode-safety ceiling", async () => {
195+
const bitmapSpy = vi.fn();
196+
vi.stubGlobal("createImageBitmap", bitmapSpy);
197+
198+
const result = await compressImageToByteLimit(
199+
makeFile(MAX_COMPRESSIBLE_SOURCE_BYTES + 1),
200+
10 * 1024 * 1024,
201+
);
202+
203+
expect(result).toEqual({ ok: false, reason: "too-large" });
204+
// The whole point of the ceiling is to never decode such a file.
205+
expect(bitmapSpy).not.toHaveBeenCalled();
206+
});
207+
208+
it("compressImageToByteLimit reports too-large when no encoding fits", async () => {
209+
const { close } = stubCanvasPipeline(() => 3_000_000);
210+
211+
const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);
212+
213+
expect(result).toEqual({ ok: false, reason: "too-large" });
214+
expect(close).toHaveBeenCalled();
215+
});
216+
162217
it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => {
163218
// A small-but-heavy source (e.g. a dense PNG): only a real downscale can
164219
// get it under budget, since quality alone is stubbed to never suffice.

0 commit comments

Comments
 (0)