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
115 changes: 100 additions & 15 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ import { shouldSubmitComposerOnEnter } from "./ChatComposer.logic";
import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
import {
COMPOSER_ATTACHMENT_FORMAT_LABEL,
COMPOSER_ATTACHMENT_INPUT_ACCEPT,
inferComposerImageMimeType,
normalizeComposerImageFile,
} from "./composerAttachments";
import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
import { searchSlashCommandItems } from "./composerSlashCommandSearch";
import {
Expand All @@ -97,6 +103,7 @@ import {
ArrowUpIcon,
CircleAlertIcon,
ListTodoIcon,
PaperclipIcon,
PencilRulerIcon,
type LucideIcon,
LockIcon,
Expand Down Expand Up @@ -900,6 +907,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// Refs
// ------------------------------------------------------------------
const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
const composerFileInputRef = useRef<HTMLInputElement>(null);
const composerFormRef = useRef<HTMLFormElement>(null);
const composerSurfaceRef = useRef<HTMLDivElement>(null);
const composerSelectLockRef = useRef(false);
Expand Down Expand Up @@ -1151,6 +1159,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const collapsedComposerPrimaryActionLabel = "Send message";
const showMobilePendingAnswerActions =
isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
const canAttachComposerImages =
activeThreadId !== null &&
!isComposerApprovalState &&
!showPlanFollowUpPrompt &&
phase !== "running" &&
pendingUserInputs.length === 0;

// ------------------------------------------------------------------
// Prompt helpers
Expand Down Expand Up @@ -1710,7 +1724,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
},
[blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],
);
const expandMobileComposer = useCallback(() => {
const expandMobileComposer = useCallback((options?: { focusEditor?: boolean }) => {
if (composerBlurFrameRef.current !== null) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
composerBlurFrameRef.current = null;
Expand All @@ -1725,7 +1739,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setIsComposerFocused(true);
mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {
mobileComposerExpandFrameRef.current = null;
composerEditorRef.current?.focusAtEnd();
if (options?.focusEditor !== false) {
composerEditorRef.current?.focusAtEnd();
}
mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
mobileComposerExpandReleaseFrameRef.current = null;
mobileComposerExpandInFlightRef.current = false;
Expand Down Expand Up @@ -1775,21 +1791,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// ------------------------------------------------------------------
// Callbacks: images
// ------------------------------------------------------------------
const addComposerImages = (files: File[]) => {
if (!activeThreadId || files.length === 0) return;
const addComposerImages = (files: File[]): number => {
if (!activeThreadId || files.length === 0) return 0;
if (phase === "running") {
toastManager.add({
type: "error",
title: "Attach images after the current turn finishes.",
});
return 0;
}
if (pendingUserInputs.length > 0) {
toastManager.add({
type: "error",
title: "Attach images after answering plan questions.",
});
return;
return 0;
}
if (showPlanFollowUpPrompt) {
toastManager.add({
type: "error",
title: "Attach images after submitting the plan follow-up.",
});
return 0;
}
const nextImages: ComposerImageAttachment[] = [];
let nextImageCount = composerImagesRef.current.length;
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.`;
const mimeType = inferComposerImageMimeType(file);
if (!mimeType) {
error = `Unsupported file type for '${file.name}'. Please attach ${COMPOSER_ATTACHMENT_FORMAT_LABEL}.`;
continue;
}
if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
Expand All @@ -1800,15 +1831,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
break;
}
const previewUrl = URL.createObjectURL(file);
const imageFile = normalizeComposerImageFile(file, mimeType);
const previewUrl = URL.createObjectURL(imageFile);
nextImages.push({
type: "image",
id: randomUUID(),
name: file.name || "image",
mimeType: file.type,
sizeBytes: file.size,
name: imageFile.name || "image",
mimeType,
sizeBytes: imageFile.size,
previewUrl,
file,
file: imageFile,
});
nextImageCount += 1;
}
Expand All @@ -1818,12 +1850,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
addComposerImagesToDraft(nextImages);
}
setThreadError(activeThreadId, error);
return nextImages.length;
};

const removeComposerImage = (imageId: string) => {
removeComposerImageFromDraft(imageId);
};

const openComposerFilePicker = useCallback(() => {
if (!canAttachComposerImages) return;
composerFileInputRef.current?.click();
}, [canAttachComposerImages]);

const onComposerFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = "";
const addedCount = addComposerImages(files);
if (addedCount > 0 && isMobileViewport) {
expandMobileComposer({ focusEditor: false });
}
};

// ------------------------------------------------------------------
// Callbacks: paste / drag
// ------------------------------------------------------------------
Expand Down Expand Up @@ -2054,13 +2101,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)

// Render
// ------------------------------------------------------------------
const renderAttachmentPickerButton = (className?: string) => (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn("rounded-full text-muted-foreground/80 hover:text-foreground", className)}
disabled={!canAttachComposerImages}
aria-label="Attach images"
onPointerDown={isMobileViewport ? (event) => event.preventDefault() : undefined}
onClick={openComposerFilePicker}
/>
}
>
<PaperclipIcon />
</TooltipTrigger>
<TooltipPopup side="top">Attach images</TooltipPopup>
</Tooltip>
);

return (
<form
ref={composerFormRef}
onSubmit={submitComposer}
className="mx-auto w-full min-w-0 max-w-3xl"
data-chat-composer-form="true"
>
<input
ref={composerFileInputRef}
className="hidden"
type="file"
accept={COMPOSER_ATTACHMENT_INPUT_ACCEPT}
multiple
tabIndex={-1}
aria-hidden="true"
onChange={onComposerFileInputChange}
/>
<div
className={cn(
"group rounded-[22px] p-px transition-colors duration-200",
Expand Down Expand Up @@ -2175,7 +2254,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
!activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
)}
onPointerDown={(event) => event.preventDefault()}
onClick={expandMobileComposer}
onClick={() => expandMobileComposer()}
aria-label="Write custom answer"
>
{activePendingProgress?.customAnswer || "Write custom answer"}
Expand Down Expand Up @@ -2207,6 +2286,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)

{showCollapsedMobilePromptRow ? (
<div className="flex items-center justify-between gap-2 px-3 py-2">
{renderAttachmentPickerButton("size-8")}
<button
type="button"
className={cn(
Expand All @@ -2216,13 +2296,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: "text-muted-foreground/35",
)}
onPointerDown={(event) => event.preventDefault()}
onClick={expandMobileComposer}
onClick={() => expandMobileComposer()}
aria-label="Expand composer"
>
{activePendingProgress
? activePendingProgress.customAnswer ||
"Type your own answer, or leave this blank to use the selected option"
: prompt.trim() || "Ask anything..."}
: prompt.trim() ||
(composerImages.length > 0
? `${composerImages.length} image${composerImages.length === 1 ? "" : "s"} attached`
: "Ask anything...")}
</button>
<button
type="button"
Expand Down Expand Up @@ -2473,6 +2556,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)}
>
<div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{renderAttachmentPickerButton()}

<ProviderModelPicker
compact={isComposerFooterCompact}
activeInstanceId={selectedInstanceId}
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/components/chat/composerAttachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vite-plus/test";

import {
COMPOSER_ATTACHMENT_FORMAT_LABEL,
COMPOSER_ATTACHMENT_INPUT_ACCEPT,
inferComposerImageMimeType,
normalizeComposerImageFile,
} from "./composerAttachments";

describe("composerAttachments", () => {
it("advertises image files to the platform picker", () => {
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).toContain("image/png");
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).toContain("image/jpeg");
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).toContain(".png");
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).toContain(".jpg");
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).not.toContain("image/*");
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).not.toContain(".heic");
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).not.toContain(".svg");
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).not.toContain(".pdf");
expect(COMPOSER_ATTACHMENT_INPUT_ACCEPT).not.toContain(".md");
});

it("describes the attachment formats accepted by providers", () => {
expect(COMPOSER_ATTACHMENT_FORMAT_LABEL).toBe("PNG, JPEG, GIF, or WebP images");
});

it("uses the browser-reported image MIME type when present", () => {
const file = new File(["x"], "screenshot.bin", { type: "image/png" });

expect(inferComposerImageMimeType(file)).toBe("image/png");
});

it("normalizes browser-reported image/jpg MIME types", () => {
const file = new File(["x"], "screenshot.bin", { type: "image/jpg" });

expect(inferComposerImageMimeType(file)).toBe("image/jpeg");
});

it("falls back to image file extensions for mobile pickers that omit MIME types", () => {
const file = new File(["x"], "screenshot.JPG", { type: "" });

expect(inferComposerImageMimeType(file)).toBe("image/jpeg");
});

it("returns null for non-image files", () => {
const file = new File(["x"], "notes.pdf", { type: "application/pdf" });

expect(inferComposerImageMimeType(file)).toBeNull();
});

it("does not override explicit non-image MIME types with image extensions", () => {
const file = new File(["x"], "notes.jpg", { type: "application/pdf" });

expect(inferComposerImageMimeType(file)).toBeNull();
});

it("returns null for provider-unsupported image MIME types", () => {
const file = new File(["x"], "photo.heic", { type: "image/heic" });

expect(inferComposerImageMimeType(file)).toBeNull();
});

it("rewrites empty browser MIME types so FileReader emits an image data URL", async () => {
const file = new File(["image-bytes"], "screenshot.jpg", { type: "" });

const normalized = normalizeComposerImageFile(file, "image/jpeg");

expect(normalized).not.toBe(file);
expect(normalized.name).toBe("screenshot.jpg");
expect(normalized.type).toBe("image/jpeg");
await expect(normalized.text()).resolves.toBe("image-bytes");
});
});
46 changes: 46 additions & 0 deletions apps/web/src/components/chat/composerAttachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const COMPOSER_IMAGE_MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
gif: "image/gif",
jpeg: "image/jpeg",
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp",
};

const COMPOSER_SUPPORTED_IMAGE_MIME_TYPES = new Set([
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
]);

export const COMPOSER_ATTACHMENT_INPUT_ACCEPT = [
...COMPOSER_SUPPORTED_IMAGE_MIME_TYPES,
...Object.keys(COMPOSER_IMAGE_MIME_BY_EXTENSION).map((extension) => `.${extension}`),
].join(",");
export const COMPOSER_ATTACHMENT_FORMAT_LABEL = "PNG, JPEG, GIF, or WebP images";

function extensionFromFileName(name: string): string {
const dotIndex = name.lastIndexOf(".");
return dotIndex >= 0 ? name.slice(dotIndex + 1).toLowerCase() : "";
}

export function inferComposerImageMimeType(file: File): string | null {
const explicitType = file.type.trim().toLowerCase();
if (explicitType.length > 0) {
const normalizedType = explicitType === "image/jpg" ? "image/jpeg" : explicitType;
return COMPOSER_SUPPORTED_IMAGE_MIME_TYPES.has(normalizedType) ? normalizedType : null;
}

return COMPOSER_IMAGE_MIME_BY_EXTENSION[extensionFromFileName(file.name)] ?? null;
}

export function normalizeComposerImageFile(file: File, mimeType: string): File {
if (file.type.trim().toLowerCase() === mimeType) {
return file;
}

return new File([file], file.name || "image", {
type: mimeType,
lastModified: file.lastModified,
});
}
Loading