authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 16:40:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 16:40:05-07:00
log66a24d6c1a008430eebd855cd5640607d5aef38c
treebc6bd53468f49cf8f92657644b87dcd5ddefaf4e
parent3d00cfd5a710a58bf108de7a66fbe00a1420cc78
signature Signed by PGP key B5690EEEBB952194

feat(mobile): queue a message while its attachment is still uploading (#10404)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 87 insertions(+), 15 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+25-9
......@@ -42,6 +42,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri
4242import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
4343import {
4444 composerAttachmentUploadBlockReason,
45 composerAttachmentsStillUploading,
4546 composerAttachmentUploadsAtom,
4647} from "../../state/composer-attachment-uploads";
4748import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
......@@ -192,6 +193,18 @@ export function NewTaskDraftScreen(props: {
192193 states: uploadStates,
193194 })
194195 : null;
196 // A connected composer with uploads still in flight queues the task rather
197 // than making the user wait: the outbox drain finishes the upload and sends.
198 const attachmentsUploading =
199 environmentConnected &&
200 selectedProject !== null &&
201 composerAttachmentsStillUploading({
202 environmentId: selectedProject.environmentId,
203 attachments: flow.attachments,
204 serverConfig: selectedEnvironmentServerConfig,
205 states: uploadStates,
206 });
207 const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading;
195208 const promptInputRef = useRef<ComposerEditorHandle>(null);
196209 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
197210 const [isComposerFocused, setIsComposerFocused] = useState(false);
......@@ -987,10 +1000,11 @@ export function NewTaskDraftScreen(props: {
9871000
9881001 const editingPendingTask = flow.editingPendingTask;
9891002
990 if (!environmentConnected) {
991 // Offline: park the task in the outbox; the drain sends it when the
992 // environment reconnects. Editing an existing pending task re-queues it
993 // under its original identifiers.
1003 if (queuesInsteadOfStarting) {
1004 // Offline, or an attachment is still uploading: park the task in the
1005 // outbox and let the drain send it once the environment is reachable
1006 // and the bytes are on the server. Editing an existing pending task
1007 // re-queues it under its original identifiers.
9941008 const metadata = editingPendingTask
9951009 ? {
9961010 threadId: editingPendingTask.threadId,
......@@ -1255,7 +1269,7 @@ export function NewTaskDraftScreen(props: {
12551269
12561270 const workspaceControls = (
12571271 <View className="flex-row items-center gap-1 px-2">
1258 {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? (
1272 {flow.submitting && !queuesInsteadOfStarting && flow.workspaceMode === "worktree" ? (
12591273 <View
12601274 accessible
12611275 accessibilityLabel="Setting up worktree…"
......@@ -1444,12 +1458,14 @@ export function NewTaskDraftScreen(props: {
14441458 attachmentBlockReason ??
14451459 (flow.submitting
14461460 ? "Starting task"
1447 : environmentConnected
1448 ? "Start task"
1449 : "Queue task")
1461 : attachmentsUploading
1462 ? "Queue task, sends when uploads finish"
1463 : environmentConnected
1464 ? "Start task"
1465 : "Queue task")
14501466 }
14511467 disabled={!canStart}
1452 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
1468 icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}
14531469 onPress={() => void handleStart()}
14541470 variant="primary"
14551471 />
apps/mobile/src/features/threads/ThreadComposer.tsx+15-2
......@@ -30,6 +30,7 @@ import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } f
3030import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
3131import {
3232 composerAttachmentUploadBlockReason,
33 composerAttachmentsStillUploading,
3334 composerAttachmentUploadsAtom,
3435} from "../../state/composer-attachment-uploads";
3536import Animated, {
......@@ -323,8 +324,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
323324 (props.selectedThread.session?.status === "running" ||
324325 props.selectedThread.session?.status === "starting");
325326
327 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
328 const attachmentsUploading =
329 props.connectionState === "connected" &&
330 composerAttachmentsStillUploading({
331 environmentId: props.environmentId,
332 attachments: props.draftAttachments,
333 serverConfig: props.serverConfig,
334 states: uploadStates,
335 });
336 // Every send goes through the outbox; the label says whether it leaves now
337 // or waits (for the connection, an earlier queued message, or an upload).
326338 const sendLabel =
327 props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";
339 props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading
340 ? "Queue"
341 : "Send";
328342 const currentModelSelection = props.selectedThread.modelSelection;
329343 const currentRuntimeMode = props.selectedThread.runtimeMode;
330344 const modelUnavailable =
......@@ -403,7 +417,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
403417 const isExpanded = isFocused || settingsSheetPresentation.isActive;
404418 const showsCompactDictation = isVoiceInputPresented && !isExpanded;
405419 const isToolbarVisible = isExpanded || isVoiceInputPresented;
406 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
407420 const attachmentBlockReason = composerAttachmentUploadBlockReason({
408421 environmentId: props.environmentId,
409422 attachments: props.draftAttachments,
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+23-2
......@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test";
33
44import {
55 composerAttachmentUploadBlockReason,
6 composerAttachmentsStillUploading,
67 composerAttachmentUploadKey,
78 composerDraftEnvironmentId,
89 createComposerAttachmentUploadQueue,
......@@ -248,7 +249,7 @@ describe("draft upload scope and offline submission", () => {
248249 );
249250 });
250251
251 it("allows offline queuing while a connected composer waits for upload or retry", () => {
252 it("only a failed upload blocks sending; an in-flight one queues instead", () => {
252253 const key = composerAttachmentUploadKey(environmentId, "file");
253254 const input = {
254255 environmentId,
......@@ -261,7 +262,16 @@ describe("draft upload scope and offline submission", () => {
261262 },
262263 states: {},
263264 };
264 expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading");
265 // Not started yet and mid-transfer both let the send through as a queued
266 // message; the outbox drain finishes (or redoes) the upload.
267 expect(composerAttachmentUploadBlockReason(input)).toBeNull();
268 expect(composerAttachmentsStillUploading(input)).toBe(true);
269 expect(
270 composerAttachmentsStillUploading({
271 ...input,
272 states: { [key]: { status: "uploading", progress: 0.6 } },
273 }),
274 ).toBe(true);
265275 expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull();
266276 expect(
267277 composerAttachmentUploadBlockReason({
......@@ -269,8 +279,19 @@ describe("draft upload scope and offline submission", () => {
269279 states: { [key]: { status: "failed", reason: "Offline" } },
270280 }),
271281 ).toBe("Retry or remove the failed attachment");
282 expect(
283 composerAttachmentsStillUploading({
284 ...input,
285 states: { [key]: { status: "failed", reason: "Offline" } },
286 }),
287 ).toBe(false);
272288 expect(
273289 composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }),
274290 ).toBeNull();
291 expect(
292 composerAttachmentsStillUploading({ ...input, states: { [key]: { status: "ready" } } }),
293 ).toBe(false);
294 // Attachments the environment cannot accept never count as uploading.
295 expect(composerAttachmentsStillUploading({ ...input, serverConfig: null })).toBe(false);
275296 });
276297});
apps/mobile/src/lib/composerAttachmentUploadQueue.ts+20-1
......@@ -75,6 +75,12 @@ export function canUploadComposerAttachment(
7575 );
7676}
7777
78/**
79 * Only a failed upload blocks sending: the outbox drain would hit the same
80 * failure, so the user has to retry or remove the file first. An upload still
81 * in flight does not block; the message queues and the drain reuses the
82 * finished upload (or re-sends the local bytes) when it delivers.
83 */
7884export function composerAttachmentUploadBlockReason(input: {
7985 readonly environmentId: EnvironmentId;
8086 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
......@@ -87,11 +93,24 @@ export function composerAttachmentUploadBlockReason(input: {
8793 if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue;
8894 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
8995 if (state?.status === "failed") return "Retry or remove the failed attachment";
90 if (state?.status !== "ready") return "Attachment still uploading";
9196 }
9297 return null;
9398}
9499
100/** Whether any attachment the environment accepts is still being uploaded. */
101export function composerAttachmentsStillUploading(input: {
102 readonly environmentId: EnvironmentId;
103 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
104 readonly serverConfig: UploadServerConfig | null;
105 readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
106}): boolean {
107 return input.attachments.some((attachment) => {
108 if (!canUploadComposerAttachment(attachment, input.serverConfig)) return false;
109 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
110 return state?.status !== "ready" && state?.status !== "failed";
111 });
112}
113
95114/** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */
96115export function createComposerAttachmentUploadQueue(options: {
97116 readonly upload: (
apps/mobile/src/state/composer-attachment-uploads.ts+4-1
......@@ -25,7 +25,10 @@ import {
2525} from "./use-composer-drafts";
2626import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
2727
28export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue";
28export {
29 composerAttachmentUploadBlockReason,
30 composerAttachmentsStillUploading,
31} from "../lib/composerAttachmentUploadQueue";
2932
3033export const composerAttachmentUploadsAtom = Atom.make<
3134 Readonly<Record<string, ComposerAttachmentUploadState>>