| 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,6 +42,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri |
| 42 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 42 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 43 | import { | 43 | import { |
| 44 | composerAttachmentUploadBlockReason, | 44 | composerAttachmentUploadBlockReason, |
| 45 | composerAttachmentsStillUploading, | ||
| 45 | composerAttachmentUploadsAtom, | 46 | composerAttachmentUploadsAtom, |
| 46 | } from "../../state/composer-attachment-uploads"; | 47 | } from "../../state/composer-attachment-uploads"; |
| 47 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 48 | import { 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: { |
| 987 | 1000 | ||
| 988 | const editingPendingTask = flow.editingPendingTask; | 1001 | const editingPendingTask = flow.editingPendingTask; |
| 989 | 1002 | ||
| 990 | if (!environmentConnected) { | 1003 | if (queuesInsteadOfStarting) { |
| 991 | // Offline: park the task in the outbox; the drain sends it when the | 1004 | // Offline, or an attachment is still uploading: park the task in the |
| 992 | // environment reconnects. Editing an existing pending task re-queues it | 1005 | // 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 = editingPendingTask | 1008 | 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: { |
| 1255 | 1269 | ||
| 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 | <View | 1273 | <View |
| 1260 | accessible | 1274 | 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.submitting | 1459 | (flow.submitting |
| 1446 | ? "Starting task" | 1460 | ? "Starting task" |
| 1447 | : environmentConnected | 1461 | : 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 |
| 30 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 30 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 31 | import { | 31 | import { |
| 32 | composerAttachmentUploadBlockReason, | 32 | composerAttachmentUploadBlockReason, |
| 33 | composerAttachmentsStillUploading, | ||
| 33 | composerAttachmentUploadsAtom, | 34 | composerAttachmentUploadsAtom, |
| 34 | } from "../../state/composer-attachment-uploads"; | 35 | } from "../../state/composer-attachment-uploads"; |
| 35 | import Animated, { | 36 | import 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"); |
| 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 | 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"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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 | }); |
| 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 | 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 | } |
| 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 | export function composerAttachmentUploadBlockReason(input: { | 84 | export 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 | } |
| 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 | /** 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. */ |
| 96 | export function createComposerAttachmentUploadQueue(options: { | 115 | export 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"; |
| 26 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; | 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 | export const composerAttachmentUploadsAtom = Atom.make< | 33 | export const composerAttachmentUploadsAtom = Atom.make< |
| 31 | Readonly<Record<string, ComposerAttachmentUploadState>> | 34 | Readonly<Record<string, ComposerAttachmentUploadState>> |