From 4d4f14feb8d240e83176f75a05012e029a7ef453 Mon Sep 17 00:00:00 2001 From: brainage04 Date: Tue, 21 Jul 2026 17:32:30 +1000 Subject: [PATCH 1/3] fix(web): materialize Android picker images on selection --- apps/web/src/features/composer/Composer.tsx | 86 +++++++++++++------ apps/web/src/features/composer/attachments.ts | 77 +++++++++++++++++ .../features/session-runtime/image-upload.ts | 2 +- apps/web/test/composer-runtime.test.ts | 61 +++++++++++++ 4 files changed, 199 insertions(+), 27 deletions(-) diff --git a/apps/web/src/features/composer/Composer.tsx b/apps/web/src/features/composer/Composer.tsx index f8f44f4..1744730 100644 --- a/apps/web/src/features/composer/Composer.tsx +++ b/apps/web/src/features/composer/Composer.tsx @@ -31,6 +31,7 @@ import { } from "../context-packet/context-packet.ts"; import { admitAttachments, + materializeAttachmentCandidates, toPromptAttachment, type AttachmentCandidate, type StagedAttachment, @@ -71,6 +72,10 @@ const IMAGE_REVISION_REASON = "Images cannot be added to a plan revision. Remove them or finish the revision first."; const IMAGE_ACTIVE_TURN_REASON = "Images can be sent with the next prompt after the running turn finishes."; +const IMAGE_PREPARATION_PENDING_REASON = + "Your selected images are still being prepared. Wait a moment and send again."; +const IMAGE_PREPARATION_FAILED_REASON = + "The selected images could not be prepared. Try choosing them again or selecting them through Files."; function filesToCandidates(files: ArrayLike): AttachmentCandidate[] { return Array.from(files, (file) => ({ file })); @@ -153,6 +158,8 @@ export function Composer({ const contextNotice = useComposer((state) => state.contextNoticeBySessionId[sessionId] ?? null); const textareaRef = useRef(null); const fileInputRef = useRef(null); + const attachmentIntakeTailRef = useRef>(Promise.resolve()); + const [preparingAttachmentCount, setPreparingAttachmentCount] = useState(0); const [caret, setCaret] = useState(0); const [menuIndex, setMenuIndex] = useState(0); const [menuDismissed, setMenuDismissed] = useState(false); @@ -330,6 +337,10 @@ export function Composer({ ); const submit = useCallback(() => { + if (preparingAttachmentCount > 0) { + setRejections([IMAGE_PREPARATION_PENDING_REASON]); + return; + } const text = draft.trim(); if (text === "" && attachments.length === 0) return; if (attachments.length > 0 && !controls.attachmentsSupported) { @@ -373,6 +384,7 @@ export function Composer({ }, ); }, [ + preparingAttachmentCount, draft, attachments, controls.attachmentsSupported, @@ -386,6 +398,10 @@ export function Composer({ ]); const queueFollowUp = useCallback(() => { + if (preparingAttachmentCount > 0) { + setRejections([IMAGE_PREPARATION_PENDING_REASON]); + return; + } const text = draft.trim(); if (text === "") return; if (attachments.length > 0) { @@ -393,7 +409,7 @@ export function Composer({ return; } runSubmission({ kind: "followUp", text }, { text: draft, attachmentIds: [] }); - }, [draft, attachments.length, runSubmission]); + }, [draft, attachments.length, preparingAttachmentCount, runSubmission]); // While observed/reconciling the gate carries its own reason; the generic // host copy only applies when the host truly lacks image prompts. @@ -404,26 +420,39 @@ export function Composer({ }, [attachmentsUnavailableReason, setRejections]); const intake = useCallback( - (candidates: readonly AttachmentCandidate[]) => { + (candidates: readonly AttachmentCandidate[]): Promise => { if (!controls.attachmentsSupported) { reportUnsupportedImages(); - return; - } - const stagedBySession = composerStore.getState().attachmentsBySessionId; - const existing = stagedBySession[sessionId] ?? []; - let stagedBytes = 0; - let stagedCount = 0; - for (const staged of Object.values(stagedBySession)) { - stagedCount += staged.length; - for (const attachment of staged) stagedBytes += attachment.sizeBytes; + return Promise.resolve(); } - const result = admitAttachments(existing, candidates, { stagedBytes, stagedCount }); - if (result.accepted.length > 0) { - composerStore.getState().addAttachments(sessionId, result.accepted); - } - setRejections(result.rejections); + + setPreparingAttachmentCount((count) => count + 1); + // Start every picker read now, before this batch waits behind an earlier + // intake and before the file input is cleared. + const materialized = materializeAttachmentCandidates(candidates); + const task = attachmentIntakeTailRef.current.then(async () => { + const prepared = await materialized; + const stagedBySession = composerStore.getState().attachmentsBySessionId; + const existing = stagedBySession[sessionId] ?? []; + let stagedBytes = 0; + let stagedCount = 0; + for (const staged of Object.values(stagedBySession)) { + stagedCount += staged.length; + for (const attachment of staged) stagedBytes += attachment.sizeBytes; + } + const result = admitAttachments(existing, prepared.accepted, { stagedBytes, stagedCount }); + if (result.accepted.length > 0) { + composerStore.getState().addAttachments(sessionId, result.accepted); + } + setRejections([...prepared.rejections, ...result.rejections]); + }); + const settled = task + .catch(() => setRejections([IMAGE_PREPARATION_FAILED_REASON])) + .finally(() => setPreparingAttachmentCount((count) => Math.max(0, count - 1))); + attachmentIntakeTailRef.current = settled; + return settled; }, - [sessionId, controls.attachmentsSupported, reportUnsupportedImages], + [sessionId, controls.attachmentsSupported, reportUnsupportedImages, setRejections], ); const requestAttachmentPicker = useCallback(() => { @@ -494,7 +523,9 @@ export function Composer({ }; const primaryLabel = revisingPlanId !== null ? "Send revision" : turnActive ? "Steer" : "Send"; - const canSubmit = !disabled && (draft.trim() !== "" || attachments.length > 0); + const preparingAttachments = preparingAttachmentCount > 0; + const canSubmit = + !disabled && !preparingAttachments && (draft.trim() !== "" || attachments.length > 0); const runOptionsSummary = `${controls.modelLabel ?? "Host model"} ยท ${thinkingLabel(controls.thinking)}`; return ( @@ -601,7 +632,7 @@ export function Composer({ if (event.dataTransfer.files.length === 0) return; event.preventDefault(); if (disabled) return; - intake(filesToCandidates(event.dataTransfer.files)); + void intake(filesToCandidates(event.dataTransfer.files)); }} > {revisingPlanId !== null && ( @@ -695,7 +726,7 @@ export function Composer({ if (event.clipboardData.files.length === 0) return; event.preventDefault(); if (disabled) return; - intake(filesToCandidates(event.clipboardData.files)); + void intake(filesToCandidates(event.clipboardData.files)); }} onSelect={(event) => setCaret(event.currentTarget.selectionStart)} placeholder={ @@ -717,8 +748,11 @@ export function Composer({ className="hidden" multiple onChange={(event) => { - if (event.target.files !== null) intake(filesToCandidates(event.target.files)); - event.target.value = ""; + const input = event.currentTarget; + if (input.files === null) return; + void intake(filesToCandidates(input.files)).finally(() => { + input.value = ""; + }); }} ref={fileInputRef} type="file" @@ -781,7 +815,7 @@ export function Composer({