From ef22d26f70a1f55071028223d87921b7c73f419a Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:55:41 +0100 Subject: [PATCH] fix(web): add mobile image attachment picker (ADA-57) --- apps/web/src/components/chat/ChatComposer.tsx | 115 +++++++++++++++--- .../chat/composerAttachments.test.ts | 73 +++++++++++ .../components/chat/composerAttachments.ts | 46 +++++++ 3 files changed, 219 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/chat/composerAttachments.test.ts create mode 100644 apps/web/src/components/chat/composerAttachments.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7942566d56c..9db6d05a6f2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -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 { @@ -97,6 +103,7 @@ import { ArrowUpIcon, CircleAlertIcon, ListTodoIcon, + PaperclipIcon, PencilRulerIcon, type LucideIcon, LockIcon, @@ -900,6 +907,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Refs // ------------------------------------------------------------------ const composerEditorRef = useRef(null); + const composerFileInputRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); const composerSelectLockRef = useRef(false); @@ -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 @@ -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; @@ -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; @@ -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) { @@ -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; } @@ -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) => { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ""; + const addedCount = addComposerImages(files); + if (addedCount > 0 && isMobileViewport) { + expandMobileComposer({ focusEditor: false }); + } + }; + // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ @@ -2054,6 +2101,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Render // ------------------------------------------------------------------ + const renderAttachmentPickerButton = (className?: string) => ( + + event.preventDefault() : undefined} + onClick={openComposerFilePicker} + /> + } + > + + + Attach images + + ); + return (
+
event.preventDefault()} - onClick={expandMobileComposer} + onClick={() => expandMobileComposer()} aria-label="Write custom answer" > {activePendingProgress?.customAnswer || "Write custom answer"} @@ -2207,6 +2286,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {showCollapsedMobilePromptRow ? (
+ {renderAttachmentPickerButton("size-8")}