# Files changed · PR \#10404 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10404/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(mobile\): queue a message while its attachment is still uploading](https://git.cafe/pingdotgg/t3code/pull/10404.md)

Version: 1

Head commit: 071c390a9a28712879f1f8e0b944cb32adbe27da

Base commit: 7ac93e300ee17a4ee5e92192264fcfd438805b6f

Comparison base: ea646c0834a3394ecb0be4a30c5d367e5a9002bd

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/threads/NewTaskDraftScreen.tsx

Status: modified; +25; −9

```diff
@@ -42,6 +42,7 @@
 import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
 import {
   composerAttachmentUploadBlockReason,
+  composerAttachmentsStillUploading,
   composerAttachmentUploadsAtom,
 } from "../../state/composer-attachment-uploads";
 import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
@@ -192,6 +193,18 @@
         states: uploadStates,
       })
     : null;
+  // A connected composer with uploads still in flight queues the task rather
+  // than making the user wait: the outbox drain finishes the upload and sends.
+  const attachmentsUploading =
+    environmentConnected &&
+    selectedProject !== null &&
+    composerAttachmentsStillUploading({
+      environmentId: selectedProject.environmentId,
+      attachments: flow.attachments,
+      serverConfig: selectedEnvironmentServerConfig,
+      states: uploadStates,
+    });
+  const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading;
   const promptInputRef = useRef<ComposerEditorHandle>(null);
   const loadedBranchesProjectKeyRef = useRef<string | null>(null);
   const [isComposerFocused, setIsComposerFocused] = useState(false);
@@ -987,10 +1000,11 @@
 
     const editingPendingTask = flow.editingPendingTask;
 
-    if (!environmentConnected) {
-      // Offline: park the task in the outbox; the drain sends it when the
-      // environment reconnects. Editing an existing pending task re-queues it
-      // under its original identifiers.
+    if (queuesInsteadOfStarting) {
+      // Offline, or an attachment is still uploading: park the task in the
+      // outbox and let the drain send it once the environment is reachable
+      // and the bytes are on the server. Editing an existing pending task
+      // re-queues it under its original identifiers.
       const metadata = editingPendingTask
         ? {
             threadId: editingPendingTask.threadId,
@@ -1255,7 +1269,7 @@
 
   const workspaceControls = (
     <View className="flex-row items-center gap-1 px-2">
-      {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? (
+      {flow.submitting && !queuesInsteadOfStarting && flow.workspaceMode === "worktree" ? (
         <View
           accessible
           accessibilityLabel="Setting up worktree…"
@@ -1444,12 +1458,14 @@
                     attachmentBlockReason ??
                     (flow.submitting
                       ? "Starting task"
+                      : attachmentsUploading
+                        ? "Queue task, sends when uploads finish"
-                      : environmentConnected
-                        ? "Start task"
-                        : "Queue task")
+                        : environmentConnected
+                          ? "Start task"
+                          : "Queue task")
                   }
                   disabled={!canStart}
-                  icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
+                  icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}
                   onPress={() => void handleStart()}
                   variant="primary"
                 />
```

## apps/mobile/src/features/threads/ThreadComposer.tsx

Status: modified; +15; −2

```diff
@@ -30,6 +30,7 @@
 import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
 import {
   composerAttachmentUploadBlockReason,
+  composerAttachmentsStillUploading,
   composerAttachmentUploadsAtom,
 } from "../../state/composer-attachment-uploads";
 import Animated, {
@@ -323,8 +324,21 @@
     (props.selectedThread.session?.status === "running" ||
       props.selectedThread.session?.status === "starting");
 
+  const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
+  const attachmentsUploading =
+    props.connectionState === "connected" &&
+    composerAttachmentsStillUploading({
+      environmentId: props.environmentId,
+      attachments: props.draftAttachments,
+      serverConfig: props.serverConfig,
+      states: uploadStates,
+    });
+  // Every send goes through the outbox; the label says whether it leaves now
+  // or waits (for the connection, an earlier queued message, or an upload).
   const sendLabel =
-    props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";
+    props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading
+      ? "Queue"
+      : "Send";
   const currentModelSelection = props.selectedThread.modelSelection;
   const currentRuntimeMode = props.selectedThread.runtimeMode;
   const modelUnavailable =
@@ -403,7 +417,6 @@
   const isExpanded = isFocused || settingsSheetPresentation.isActive;
   const showsCompactDictation = isVoiceInputPresented && !isExpanded;
   const isToolbarVisible = isExpanded || isVoiceInputPresented;
-  const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
   const attachmentBlockReason = composerAttachmentUploadBlockReason({
     environmentId: props.environmentId,
     attachments: props.draftAttachments,
```

## apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts

Status: modified; +23; −2

```diff
@@ -3,6 +3,7 @@
 
 import {
   composerAttachmentUploadBlockReason,
+  composerAttachmentsStillUploading,
   composerAttachmentUploadKey,
   composerDraftEnvironmentId,
   createComposerAttachmentUploadQueue,
@@ -248,7 +249,7 @@
     );
   });
 
-  it("allows offline queuing while a connected composer waits for upload or retry", () => {
+  it("only a failed upload blocks sending; an in-flight one queues instead", () => {
     const key = composerAttachmentUploadKey(environmentId, "file");
     const input = {
       environmentId,
@@ -261,7 +262,16 @@
       },
       states: {},
     };
+    // Not started yet and mid-transfer both let the send through as a queued
+    // message; the outbox drain finishes (or redoes) the upload.
+    expect(composerAttachmentUploadBlockReason(input)).toBeNull();
+    expect(composerAttachmentsStillUploading(input)).toBe(true);
+    expect(
+      composerAttachmentsStillUploading({
+        ...input,
+        states: { [key]: { status: "uploading", progress: 0.6 } },
+      }),
+    ).toBe(true);
-    expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading");
     expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull();
     expect(
       composerAttachmentUploadBlockReason({
@@ -270,7 +280,18 @@
       }),
     ).toBe("Retry or remove the failed attachment");
     expect(
+      composerAttachmentsStillUploading({
+        ...input,
+        states: { [key]: { status: "failed", reason: "Offline" } },
+      }),
+    ).toBe(false);
+    expect(
       composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }),
     ).toBeNull();
+    expect(
+      composerAttachmentsStillUploading({ ...input, states: { [key]: { status: "ready" } } }),
+    ).toBe(false);
+    // Attachments the environment cannot accept never count as uploading.
+    expect(composerAttachmentsStillUploading({ ...input, serverConfig: null })).toBe(false);
   });
 });
```

## apps/mobile/src/lib/composerAttachmentUploadQueue.ts

Status: modified; +20; −1

```diff
@@ -75,6 +75,12 @@
   );
 }
 
+/**
+ * Only a failed upload blocks sending: the outbox drain would hit the same
+ * failure, so the user has to retry or remove the file first. An upload still
+ * in flight does not block; the message queues and the drain reuses the
+ * finished upload (or re-sends the local bytes) when it delivers.
+ */
 export function composerAttachmentUploadBlockReason(input: {
   readonly environmentId: EnvironmentId;
   readonly attachments: ReadonlyArray<DraftComposerAttachment>;
@@ -87,11 +93,24 @@
     if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue;
     const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
     if (state?.status === "failed") return "Retry or remove the failed attachment";
-    if (state?.status !== "ready") return "Attachment still uploading";
   }
   return null;
 }
 
+/** Whether any attachment the environment accepts is still being uploaded. */
+export function composerAttachmentsStillUploading(input: {
+  readonly environmentId: EnvironmentId;
+  readonly attachments: ReadonlyArray<DraftComposerAttachment>;
+  readonly serverConfig: UploadServerConfig | null;
+  readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
+}): boolean {
+  return input.attachments.some((attachment) => {
+    if (!canUploadComposerAttachment(attachment, input.serverConfig)) return false;
+    const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
+    return state?.status !== "ready" && state?.status !== "failed";
+  });
+}
+
 /** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */
 export function createComposerAttachmentUploadQueue(options: {
   readonly upload: (
```

## apps/mobile/src/state/composer-attachment-uploads.ts

Status: modified; +4; −1

```diff
@@ -25,7 +25,10 @@
 } from "./use-composer-drafts";
 import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
 
+export {
+  composerAttachmentUploadBlockReason,
+  composerAttachmentsStillUploading,
-export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue";
+} from "../lib/composerAttachmentUploadQueue";
 
 export const composerAttachmentUploadsAtom = Atom.make<
   Readonly<Record<string, ComposerAttachmentUploadState>>
```
