| author | |
| committer | |
| log | 66a24d6c1a008430eebd855cd5640607d5aef38c |
| tree | bc6bd53468f49cf8f92657644b87dcd5ddefaf4e |
| parent | 3d00cfd5a710a58bf108de7a66fbe00a1420cc78 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 42 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 43 | 43 | import { |
| 44 | 44 | composerAttachmentUploadBlockReason, |
| 45 | composerAttachmentsStillUploading, | |
| 45 | 46 | composerAttachmentUploadsAtom, |
| 46 | 47 | } from "../../state/composer-attachment-uploads"; |
| 47 | 48 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| ... | ... | @@ -192,6 +193,18 @@ export function NewTaskDraftScreen(props: { |
| 192 | 193 | states: uploadStates, |
| 193 | 194 | }) |
| 194 | 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 | 208 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 196 | 209 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 197 | 210 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| ... | ... | @@ -987,10 +1000,11 @@ export function NewTaskDraftScreen(props: { |
| 987 | 1000 | |
| 988 | 1001 | const editingPendingTask = flow.editingPendingTask; |
| 989 | 1002 | |
| 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. | |
| 994 | 1008 | const metadata = editingPendingTask |
| 995 | 1009 | ? { |
| 996 | 1010 | threadId: editingPendingTask.threadId, |
| ... | ... | @@ -1255,7 +1269,7 @@ export function NewTaskDraftScreen(props: { |
| 1255 | 1269 | |
| 1256 | 1270 | const workspaceControls = ( |
| 1257 | 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 | 1273 | <View |
| 1260 | 1274 | accessible |
| 1261 | 1275 | accessibilityLabel="Setting up worktree…" |
| ... | ... | @@ -1444,12 +1458,14 @@ export function NewTaskDraftScreen(props: { |
| 1444 | 1458 | attachmentBlockReason ?? |
| 1445 | 1459 | (flow.submitting |
| 1446 | 1460 | ? "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") | |
| 1450 | 1466 | } |
| 1451 | 1467 | disabled={!canStart} |
| 1452 | icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} | |
| 1468 | icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} | |
| 1453 | 1469 | onPress={() => void handleStart()} |
| 1454 | 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 | 30 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 31 | 31 | import { |
| 32 | 32 | composerAttachmentUploadBlockReason, |
| 33 | composerAttachmentsStillUploading, | |
| 33 | 34 | composerAttachmentUploadsAtom, |
| 34 | 35 | } from "../../state/composer-attachment-uploads"; |
| 35 | 36 | import Animated, { |
| ... | ... | @@ -323,8 +324,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 323 | 324 | (props.selectedThread.session?.status === "running" || |
| 324 | 325 | props.selectedThread.session?.status === "starting"); |
| 325 | 326 | |
| 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 | 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 | 342 | const currentModelSelection = props.selectedThread.modelSelection; |
| 329 | 343 | const currentRuntimeMode = props.selectedThread.runtimeMode; |
| 330 | 344 | const modelUnavailable = |
| ... | ... | @@ -403,7 +417,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 403 | 417 | const isExpanded = isFocused || settingsSheetPresentation.isActive; |
| 404 | 418 | const showsCompactDictation = isVoiceInputPresented && !isExpanded; |
| 405 | 419 | const isToolbarVisible = isExpanded || isVoiceInputPresented; |
| 406 | const uploadStates = useAtomValue(composerAttachmentUploadsAtom); | |
| 407 | 420 | const attachmentBlockReason = composerAttachmentUploadBlockReason({ |
| 408 | 421 | environmentId: props.environmentId, |
| 409 | 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 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | composerAttachmentUploadBlockReason, |
| 6 | composerAttachmentsStillUploading, | |
| 6 | 7 | composerAttachmentUploadKey, |
| 7 | 8 | composerDraftEnvironmentId, |
| 8 | 9 | createComposerAttachmentUploadQueue, |
| ... | ... | @@ -248,7 +249,7 @@ describe("draft upload scope and offline submission", () => { |
| 248 | 249 | ); |
| 249 | 250 | }); |
| 250 | 251 | |
| 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 | 253 | const key = composerAttachmentUploadKey(environmentId, "file"); |
| 253 | 254 | const input = { |
| 254 | 255 | environmentId, |
| ... | ... | @@ -261,7 +262,16 @@ describe("draft upload scope and offline submission", () => { |
| 261 | 262 | }, |
| 262 | 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 | 275 | expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); |
| 266 | 276 | expect( |
| 267 | 277 | composerAttachmentUploadBlockReason({ |
| ... | ... | @@ -269,8 +279,19 @@ describe("draft upload scope and offline submission", () => { |
| 269 | 279 | states: { [key]: { status: "failed", reason: "Offline" } }, |
| 270 | 280 | }), |
| 271 | 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 | 288 | expect( |
| 273 | 289 | composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), |
| 274 | 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 | 75 | ); |
| 76 | 76 | } |
| 77 | 77 | |
| 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 | */ | |
| 78 | 84 | export function composerAttachmentUploadBlockReason(input: { |
| 79 | 85 | readonly environmentId: EnvironmentId; |
| 80 | 86 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| ... | ... | @@ -87,11 +93,24 @@ export function composerAttachmentUploadBlockReason(input: { |
| 87 | 93 | if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue; |
| 88 | 94 | const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; |
| 89 | 95 | if (state?.status === "failed") return "Retry or remove the failed attachment"; |
| 90 | if (state?.status !== "ready") return "Attachment still uploading"; | |
| 91 | 96 | } |
| 92 | 97 | return null; |
| 93 | 98 | } |
| 94 | 99 | |
| 100 | /** Whether any attachment the environment accepts is still being uploaded. */ | |
| 101 | export 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 | 114 | /** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */ |
| 96 | 115 | export function createComposerAttachmentUploadQueue(options: { |
| 97 | 116 | readonly upload: ( |
apps/mobile/src/state/composer-attachment-uploads.ts+4-1| ... | ... | @@ -25,7 +25,10 @@ import { |
| 25 | 25 | } from "./use-composer-drafts"; |
| 26 | 26 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; |
| 27 | 27 | |
| 28 | export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue"; | |
| 28 | export { | |
| 29 | composerAttachmentUploadBlockReason, | |
| 30 | composerAttachmentsStillUploading, | |
| 31 | } from "../lib/composerAttachmentUploadQueue"; | |
| 29 | 32 | |
| 30 | 33 | export const composerAttachmentUploadsAtom = Atom.make< |
| 31 | 34 | Readonly<Record<string, ComposerAttachmentUploadState>> |