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...@@ -42,6 +42,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri
42import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";42import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
43import {43import {
44 composerAttachmentUploadBlockReason,44 composerAttachmentUploadBlockReason,
45 composerAttachmentsStillUploading,
45 composerAttachmentUploadsAtom,46 composerAttachmentUploadsAtom,
46} from "../../state/composer-attachment-uploads";47} from "../../state/composer-attachment-uploads";
47import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";48import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
...@@ -192,6 +193,18 @@ export function NewTaskDraftScreen(props: {...@@ -192,6 +193,18 @@ export function NewTaskDraftScreen(props: {
192 states: uploadStates,193 states: uploadStates,
193 })194 })
194 : null;195 : 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;
195 const promptInputRef = useRef<ComposerEditorHandle>(null);208 const promptInputRef = useRef<ComposerEditorHandle>(null);
196 const loadedBranchesProjectKeyRef = useRef<string | null>(null);209 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
197 const [isComposerFocused, setIsComposerFocused] = useState(false);210 const [isComposerFocused, setIsComposerFocused] = useState(false);
...@@ -987,10 +1000,11 @@ export function NewTaskDraftScreen(props: {...@@ -987,10 +1000,11 @@ export function NewTaskDraftScreen(props: {
9871000
988 const editingPendingTask = flow.editingPendingTask;1001 const editingPendingTask = flow.editingPendingTask;
9891002
990 if (!environmentConnected) {1003 if (queuesInsteadOfStarting) {
991 // Offline: park the task in the outbox; the drain sends it when the1004 // Offline, or an attachment is still uploading: park the task in the
992 // environment reconnects. Editing an existing pending task re-queues it1005 // outbox and let the drain send it once the environment is reachable
993 // under its original identifiers.1006 // and the bytes are on the server. Editing an existing pending task
1007 // re-queues it under its original identifiers.
994 const metadata = editingPendingTask1008 const metadata = editingPendingTask
995 ? {1009 ? {
996 threadId: editingPendingTask.threadId,1010 threadId: editingPendingTask.threadId,
...@@ -1255,7 +1269,7 @@ export function NewTaskDraftScreen(props: {...@@ -1255,7 +1269,7 @@ export function NewTaskDraftScreen(props: {
12551269
1256 const workspaceControls = (1270 const workspaceControls = (
1257 <View className="flex-row items-center gap-1 px-2">1271 <View className="flex-row items-center gap-1 px-2">
1258 {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? (1272 {flow.submitting && !queuesInsteadOfStarting && flow.workspaceMode === "worktree" ? (
1259 <View1273 <View
1260 accessible1274 accessible
1261 accessibilityLabel="Setting up worktree…"1275 accessibilityLabel="Setting up worktree…"
...@@ -1444,12 +1458,14 @@ export function NewTaskDraftScreen(props: {...@@ -1444,12 +1458,14 @@ export function NewTaskDraftScreen(props: {
1444 attachmentBlockReason ??1458 attachmentBlockReason ??
1445 (flow.submitting1459 (flow.submitting
1446 ? "Starting task"1460 ? "Starting task"
1447 : environmentConnected1461 : attachmentsUploading
1448 ? "Start task"1462 ? "Queue task, sends when uploads finish"
1449 : "Queue task")1463 : environmentConnected
1464 ? "Start task"
1465 : "Queue task")
1450 }1466 }
1451 disabled={!canStart}1467 disabled={!canStart}
1452 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}1468 icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}
1453 onPress={() => void handleStart()}1469 onPress={() => void handleStart()}
1454 variant="primary"1470 variant="primary"
1455 />1471 />
apps/mobile/src/features/threads/ThreadComposer.tsx+15-2
...@@ -30,6 +30,7 @@ import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } f...@@ -30,6 +30,7 @@ import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } f
30import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";30import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
31import {31import {
32 composerAttachmentUploadBlockReason,32 composerAttachmentUploadBlockReason,
33 composerAttachmentsStillUploading,
33 composerAttachmentUploadsAtom,34 composerAttachmentUploadsAtom,
34} from "../../state/composer-attachment-uploads";35} from "../../state/composer-attachment-uploads";
35import Animated, {36import Animated, {
...@@ -323,8 +324,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -323,8 +324,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
323 (props.selectedThread.session?.status === "running" ||324 (props.selectedThread.session?.status === "running" ||
324 props.selectedThread.session?.status === "starting");325 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).
326 const sendLabel =338 const sendLabel =
327 props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";339 props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading
340 ? "Queue"
341 : "Send";
328 const currentModelSelection = props.selectedThread.modelSelection;342 const currentModelSelection = props.selectedThread.modelSelection;
329 const currentRuntimeMode = props.selectedThread.runtimeMode;343 const currentRuntimeMode = props.selectedThread.runtimeMode;
330 const modelUnavailable =344 const modelUnavailable =
...@@ -403,7 +417,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -403,7 +417,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
403 const isExpanded = isFocused || settingsSheetPresentation.isActive;417 const isExpanded = isFocused || settingsSheetPresentation.isActive;
404 const showsCompactDictation = isVoiceInputPresented && !isExpanded;418 const showsCompactDictation = isVoiceInputPresented && !isExpanded;
405 const isToolbarVisible = isExpanded || isVoiceInputPresented;419 const isToolbarVisible = isExpanded || isVoiceInputPresented;
406 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
407 const attachmentBlockReason = composerAttachmentUploadBlockReason({420 const attachmentBlockReason = composerAttachmentUploadBlockReason({
408 environmentId: props.environmentId,421 environmentId: props.environmentId,
409 attachments: props.draftAttachments,422 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";...@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test";
33
4import {4import {
5 composerAttachmentUploadBlockReason,5 composerAttachmentUploadBlockReason,
6 composerAttachmentsStillUploading,
6 composerAttachmentUploadKey,7 composerAttachmentUploadKey,
7 composerDraftEnvironmentId,8 composerDraftEnvironmentId,
8 createComposerAttachmentUploadQueue,9 createComposerAttachmentUploadQueue,
...@@ -248,7 +249,7 @@ describe("draft upload scope and offline submission", () => {...@@ -248,7 +249,7 @@ describe("draft upload scope and offline submission", () => {
248 );249 );
249 });250 });
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", () => {
252 const key = composerAttachmentUploadKey(environmentId, "file");253 const key = composerAttachmentUploadKey(environmentId, "file");
253 const input = {254 const input = {
254 environmentId,255 environmentId,
...@@ -261,7 +262,16 @@ describe("draft upload scope and offline submission", () => {...@@ -261,7 +262,16 @@ describe("draft upload scope and offline submission", () => {
261 },262 },
262 states: {},263 states: {},
263 };264 };
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);
265 expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull();275 expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull();
266 expect(276 expect(
267 composerAttachmentUploadBlockReason({277 composerAttachmentUploadBlockReason({
...@@ -269,8 +279,19 @@ describe("draft upload scope and offline submission", () => {...@@ -269,8 +279,19 @@ describe("draft upload scope and offline submission", () => {
269 states: { [key]: { status: "failed", reason: "Offline" } },279 states: { [key]: { status: "failed", reason: "Offline" } },
270 }),280 }),
271 ).toBe("Retry or remove the failed attachment");281 ).toBe("Retry or remove the failed attachment");
282 expect(
283 composerAttachmentsStillUploading({
284 ...input,
285 states: { [key]: { status: "failed", reason: "Offline" } },
286 }),
287 ).toBe(false);
272 expect(288 expect(
273 composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }),289 composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }),
274 ).toBeNull();290 ).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);
275 });296 });
276});297});
apps/mobile/src/lib/composerAttachmentUploadQueue.ts+20-1
...@@ -75,6 +75,12 @@ export function canUploadComposerAttachment(...@@ -75,6 +75,12 @@ export function canUploadComposerAttachment(
75 );75 );
76}76}
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 */
78export function composerAttachmentUploadBlockReason(input: {84export function composerAttachmentUploadBlockReason(input: {
79 readonly environmentId: EnvironmentId;85 readonly environmentId: EnvironmentId;
80 readonly attachments: ReadonlyArray<DraftComposerAttachment>;86 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
...@@ -87,11 +93,24 @@ export function composerAttachmentUploadBlockReason(input: {...@@ -87,11 +93,24 @@ export function composerAttachmentUploadBlockReason(input: {
87 if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue;93 if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue;
88 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];94 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
89 if (state?.status === "failed") return "Retry or remove the failed attachment";95 if (state?.status === "failed") return "Retry or remove the failed attachment";
90 if (state?.status !== "ready") return "Attachment still uploading";
91 }96 }
92 return null;97 return null;
93}98}
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
95/** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */114/** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */
96export function createComposerAttachmentUploadQueue(options: {115export function createComposerAttachmentUploadQueue(options: {
97 readonly upload: (116 readonly upload: (
apps/mobile/src/state/composer-attachment-uploads.ts+4-1
...@@ -25,7 +25,10 @@ import {...@@ -25,7 +25,10 @@ import {
25} from "./use-composer-drafts";25} from "./use-composer-drafts";
26import { useRemoteConnectionStatus } from "./use-remote-environment-registry";26import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
2727
28export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue";28export {
29 composerAttachmentUploadBlockReason,
30 composerAttachmentsStillUploading,
31} from "../lib/composerAttachmentUploadQueue";
2932
30export const composerAttachmentUploadsAtom = Atom.make<33export const composerAttachmentUploadsAtom = Atom.make<
31 Readonly<Record<string, ComposerAttachmentUploadState>>34 Readonly<Record<string, ComposerAttachmentUploadState>>