Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 89 additions & 29 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import {
} from "../../promptStashStore";
import { ComposerStashBadge } from "./ComposerStashBadge";
import { ComposerStashMenu } from "./ComposerStashMenu";
import { compressImageForStash } from "../../lib/stashImageCompression";
import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";
import { isCommandPaletteOpen } from "../../commandPaletteBus";
import { getTerminalFocusOwner } from "../../lib/terminalFocus";
import { resolveShortcutCommand } from "../../keybindings";
Expand Down Expand Up @@ -206,8 +206,6 @@ import { searchProviderSkills } from "../../providerSkillSearch";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import type { ReviewCommentContext } from "../../reviewCommentContext";

const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;

const runtimeModeConfig: Record<
RuntimeMode,
{ label: string; description: string; icon: LucideIcon }
Expand Down Expand Up @@ -1006,6 +1004,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
* thread) can still be stashed while an earlier encode is running.
*/
const stashInFlightRef = useRef<Set<string>>(new Set());
/**
* Count of pasted images still being compressed, per thread. Reserved
* against the attachment limit so concurrent pastes can't overshoot it,
* and checked by `submitComposer` so a send can't race an image into the
* next draft.
*/
const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());

// ------------------------------------------------------------------
// Derived: composer send state
Expand Down Expand Up @@ -1821,12 +1826,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
event?.preventDefault();
return;
}
// A send while a pasted image is still compressing would strand that
// image: the turn snapshot wouldn't include it, and it would surface
// in the *next* draft instead. Only oversized images hit this — small
// files clear the pending counter within a microtask.
if (activeThreadId && (pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {
event?.preventDefault();
toastManager.add({
type: "info",
title: "Still compressing a pasted image.",
description: "Send again once its thumbnail appears.",
});
return;
}
onSend(event);
if (shouldBlurMobileComposerOnSubmit()) {
blurMobileComposerAfterSend();
}
},
[
activeThreadId,
blurMobileComposerAfterSend,
isSendDisabled,
noProviderAvailable,
Expand Down Expand Up @@ -2273,7 +2292,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// ------------------------------------------------------------------
// Callbacks: images
// ------------------------------------------------------------------
const addComposerImages = (files: File[]) => {
const addComposerImages = async (files: File[]) => {
if (!activeThreadId || files.length === 0) return;
if (pendingUserInputs.length > 0) {
toastManager.add({
Expand All @@ -2282,40 +2301,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
});
return;
}
const nextImages: ComposerImageAttachment[] = [];
let nextImageCount = composerImagesRef.current.length;
// Captured before the awaits below: the user may switch threads while a
// large image is being compressed, and the attachments and errors belong
// to the thread the paste happened in.
const threadId = activeThreadId;

// Validation happens synchronously so concurrent pastes see each other:
// accepted files reserve their attachment slots (via the pending counter)
// before the first await, keeping the total under the limit.
const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0;
let reservedCount = composerImagesRef.current.length + pendingCount;
const acceptedFiles: File[] = [];
let error: string | null = null;
for (const file of files) {
if (!file.type.startsWith("image/")) {
error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
continue;
}
if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
continue;
}
if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
break;
}
const previewUrl = URL.createObjectURL(file);
nextImages.push({
type: "image",
id: randomUUID(),
name: file.name || "image",
mimeType: file.type,
sizeBytes: file.size,
previewUrl,
file,
});
nextImageCount += 1;
acceptedFiles.push(file);
reservedCount += 1;
Comment thread
t3dotgg marked this conversation as resolved.
}
if (nextImages.length === 1 && nextImages[0]) {
addComposerImage(nextImages[0]);
} else if (nextImages.length > 1) {
addComposerImagesToDraft(nextImages);
setThreadError(threadId, error);
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
if (acceptedFiles.length === 0) return;

pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length);
try {
const nextImages: ComposerImageAttachment[] = [];
let compressionError: string | null = null;
for (const file of acceptedFiles) {
// Images over the wire cap are downscaled to fit rather than
// refused; files already within it pass through byte-for-byte.
const compressed = await compressImageToByteLimit(file, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES);
if (!compressed.ok) {
compressionError =
compressed.reason === "unreadable"
? `'${file.name}' could not be read as an image.`
: `'${file.name}' is too large to attach, even after compression.`;
continue;
Comment thread
t3dotgg marked this conversation as resolved.
}
const attachmentFile = compressed.file;
const previewUrl = URL.createObjectURL(attachmentFile);
nextImages.push({
type: "image",
id: randomUUID(),
name: attachmentFile.name || "image",
mimeType: attachmentFile.type,
sizeBytes: attachmentFile.size,
previewUrl,
file: attachmentFile,
});
}
if (nextImages.length === 1 && nextImages[0]) {
addComposerImage(nextImages[0]);
} else if (nextImages.length > 1) {
addComposerImagesToDraft(nextImages);
}
// Only failures are reported here. Success must not pass `null`: by
// now other work (a failed send, an overlapping paste) may have set a
// thread error this call knows nothing about, and clearing it would
// swallow that message.
if (compressionError !== null) {
setThreadError(threadId, compressionError);
}
} finally {
const remaining =
(pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length;
if (remaining > 0) {
pendingImageCompressionsRef.current.set(threadId, remaining);
} else {
pendingImageCompressionsRef.current.delete(threadId);
}
}
setThreadError(activeThreadId, error);
};

const removeComposerImage = (imageId: string) => {
Expand All @@ -2331,7 +2391,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
if (imageFiles.length === 0) return;
event.preventDefault();
addComposerImages(imageFiles);
void addComposerImages(imageFiles);
};

const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -2365,7 +2425,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
dragDepthRef.current = 0;
setIsDragOverComposer(false);
const files = Array.from(event.dataTransfer.files);
addComposerImages(files);
void addComposerImages(files);
focusComposer();
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { compressImageForStash, MAX_STASH_IMAGE_DATA_URL_CHARS } from "./stashImageCompression";
import {
compressImageForStash,
compressImageToByteLimit,
MAX_COMPRESSIBLE_SOURCE_BYTES,
MAX_STASH_IMAGE_DATA_URL_CHARS,
} from "./imageCompression";

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

it("compressImageToByteLimit passes small files through byte-for-byte", async () => {
const bitmapSpy = vi.fn();
vi.stubGlobal("createImageBitmap", bitmapSpy);

const original = makeFile(1024);
const result = await compressImageToByteLimit(original, 10 * 1024 * 1024);

expect(result.ok).toBe(true);
expect(result.ok && result.recompressed).toBe(false);
// Pass-through must be the same File object, not a copy.
expect(result.ok && result.file).toBe(original);
expect(bitmapSpy).not.toHaveBeenCalled();
});

it("compressImageToByteLimit re-encodes an oversized file under the byte cap", async () => {
stubCanvasPipeline(() => 200_000);

const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);

expect(result.ok).toBe(true);
expect(result.ok && result.recompressed).toBe(true);
expect(result.ok && result.file.type).toBe("image/webp");
// The re-encoded name must match the new container format.
expect(result.ok && result.file.name).toBe("shot.webp");
expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000);
});

it("compressImageToByteLimit refuses sources above the decode-safety ceiling", async () => {
const bitmapSpy = vi.fn();
vi.stubGlobal("createImageBitmap", bitmapSpy);

const result = await compressImageToByteLimit(
makeFile(MAX_COMPRESSIBLE_SOURCE_BYTES + 1),
10 * 1024 * 1024,
);

expect(result).toEqual({ ok: false, reason: "too-large" });
// The whole point of the ceiling is to never decode such a file.
expect(bitmapSpy).not.toHaveBeenCalled();
});

it("compressImageToByteLimit reports too-large when no encoding fits", async () => {
const { close } = stubCanvasPipeline(() => 3_000_000);

const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);

expect(result).toEqual({ ok: false, reason: "too-large" });
expect(close).toHaveBeenCalled();
});

it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => {
// A small-but-heavy source (e.g. a dense PNG): only a real downscale can
// get it under budget, since quality alone is stubbed to never suffice.
Expand Down
Loading
Loading