| author | |
| committer | |
| log | 9bc7a56848eb7c7546605e54ff02c6abea176f96 |
| tree | 2eb6c3e22ef88f33631d8c549f85d736699cb358 |
| parent | 85b656ff300f71060ad6305c7e1e29a72b442ce9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>23 files changed, 1622 insertions(+), 118 deletions(-)
apps/mobile/src/Stack.tsx+2| ... | ... | @@ -73,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; |
| 73 | 73 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; |
| 74 | 74 | import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; |
| 75 | 75 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| 76 | import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; | |
| 76 | 77 | |
| 77 | 78 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 78 | 79 | |
| ... | ... | @@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string { |
| 355 | 356 | // each enqueue, shell change, or reconnect. |
| 356 | 357 | function ThreadOutboxDrainWorker() { |
| 357 | 358 | useThreadOutboxDrain(); |
| 359 | useComposerAttachmentUploadWorker(); | |
| 358 | 360 | return null; |
| 359 | 361 | } |
| 360 | 362 |
apps/mobile/src/components/ComposerAttachmentStrip.tsx+50-2| ... | ... | @@ -10,8 +10,14 @@ import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 10 | 10 | import { PresentationSource } from "./NativePresentation"; |
| 11 | 11 | import type { FilePreviewSource } from "./FilePreviewModal"; |
| 12 | 12 | import { isPdfFile } from "../lib/filePreview"; |
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 14 | import { | |
| 15 | retryComposerAttachmentUpload, | |
| 16 | useComposerAttachmentUploadState, | |
| 17 | } from "../state/composer-attachment-uploads"; | |
| 13 | 18 | |
| 14 | 19 | export interface ComposerAttachmentStripProps { |
| 20 | readonly environmentId?: EnvironmentId; | |
| 15 | 21 | /** Attachments to display. */ |
| 16 | 22 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 17 | 23 | /** Called when the user removes an attachment. */ |
| ... | ... | @@ -30,7 +36,8 @@ export interface ComposerAttachmentStripProps { |
| 30 | 36 | readonly removeButtonPlacement?: "overlay" | "gutter"; |
| 31 | 37 | } |
| 32 | 38 | |
| 33 | export function ComposerAttachmentThumbnail(props: { | |
| 39 | type ComposerAttachmentThumbnailProps = { | |
| 40 | readonly environmentId?: EnvironmentId; | |
| 34 | 41 | readonly attachment: DraftComposerAttachment; |
| 35 | 42 | readonly size: number; |
| 36 | 43 | readonly borderRadius: number; |
| ... | ... | @@ -40,7 +47,47 @@ export function ComposerAttachmentThumbnail(props: { |
| 40 | 47 | attachment: DraftComposerFileAttachment, |
| 41 | 48 | sourceIdentifier: string, |
| 42 | 49 | ) => void; |
| 43 | }) { | |
| 50 | }; | |
| 51 | ||
| 52 | export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) { | |
| 53 | const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id); | |
| 54 | return ( | |
| 55 | <View style={{ width: props.size, height: props.size }}> | |
| 56 | <ComposerAttachmentContent {...props} /> | |
| 57 | {upload && upload.status !== "ready" ? ( | |
| 58 | <Pressable | |
| 59 | accessibilityRole={upload.status === "failed" ? "button" : "text"} | |
| 60 | accessibilityLabel={ | |
| 61 | upload.status === "failed" | |
| 62 | ? `Retry uploading ${props.attachment.name}` | |
| 63 | : `Uploading ${props.attachment.name}, ${Math.floor(upload.progress * 100)}%` | |
| 64 | } | |
| 65 | accessibilityHint={upload.status === "failed" ? upload.reason : undefined} | |
| 66 | disabled={upload.status !== "failed"} | |
| 67 | onPress={() => | |
| 68 | props.environmentId && | |
| 69 | retryComposerAttachmentUpload(props.environmentId, props.attachment.id) | |
| 70 | } | |
| 71 | className="absolute bottom-0.5 left-0.5 flex-row items-center gap-0.5 rounded-full bg-black/70 px-1 py-0.5" | |
| 72 | > | |
| 73 | <SymbolView | |
| 74 | name={upload.status === "failed" ? "arrow.clockwise" : "arrow.up"} | |
| 75 | size={props.compact ? 8 : 10} | |
| 76 | tintColor="#ffffff" | |
| 77 | type="monochrome" | |
| 78 | /> | |
| 79 | {!props.compact ? ( | |
| 80 | <Text className="text-2xs text-white"> | |
| 81 | {upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`} | |
| 82 | </Text> | |
| 83 | ) : null} | |
| 84 | </Pressable> | |
| 85 | ) : null} | |
| 86 | </View> | |
| 87 | ); | |
| 88 | } | |
| 89 | ||
| 90 | function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { | |
| 44 | 91 | const { attachment } = props; |
| 45 | 92 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 46 | 93 | if (attachment.type === "image") { |
| ... | ... | @@ -213,6 +260,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 213 | 260 | }} |
| 214 | 261 | > |
| 215 | 262 | <ComposerAttachmentThumbnail |
| 263 | environmentId={props.environmentId} | |
| 216 | 264 | attachment={attachment} |
| 217 | 265 | size={size} |
| 218 | 266 | borderRadius={radius} |
apps/mobile/src/features/cloud/CloudAuthProvider.test.ts+6| ... | ... | @@ -26,6 +26,12 @@ vi.mock("../../connection/catalog", () => ({ |
| 26 | 26 | }, |
| 27 | 27 | })); |
| 28 | 28 | |
| 29 | vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} })); | |
| 30 | vi.mock("../../state/use-composer-drafts", () => ({ | |
| 31 | getComposerCloudAccountId: vi.fn(async () => null), | |
| 32 | restoreCloudComposerDrafts: vi.fn(async () => undefined), | |
| 33 | })); | |
| 34 | ||
| 29 | 35 | vi.mock("./publicConfig", () => ({ |
| 30 | 36 | resolveCloudPublicConfig: vi.fn(() => ({ |
| 31 | 37 | clerk: { publishableKey: null }, |
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+52-30| ... | ... | @@ -5,14 +5,18 @@ import { |
| 5 | 5 | reportAtomCommandResult, |
| 6 | 6 | settleAsyncResult, |
| 7 | 7 | settlePromise, |
| 8 | squashAtomCommandFailure, | |
| 8 | 9 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 10 | 11 | import { type ReactNode, useEffect, useRef } from "react"; |
| 11 | 12 | |
| 12 | import { environmentCatalog } from "../../connection/catalog"; | |
| 13 | 13 | import { runtime } from "../../lib/runtime"; |
| 14 | 14 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 15 | 15 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 16 | import { | |
| 17 | getComposerCloudAccountId, | |
| 18 | restoreCloudComposerDrafts, | |
| 19 | } from "../../state/use-composer-drafts"; | |
| 16 | 20 | import { |
| 17 | 21 | releaseAgentAwarenessRelayTokenProvider, |
| 18 | 22 | setAgentAwarenessRelayTokenProvider, |
| ... | ... | @@ -20,6 +24,7 @@ import { |
| 20 | 24 | } from "../agent-awareness/remoteRegistration"; |
| 21 | 25 | import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; |
| 22 | 26 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 27 | import { removeCloudEnvironments } from "./cloud-drafts"; | |
| 23 | 28 | |
| 24 | 29 | function resetManagedRelayTokenCache() { |
| 25 | 30 | return settleAsyncResult(() => |
| ... | ... | @@ -47,7 +52,7 @@ export function activateCloudRelayAccount( |
| 47 | 52 | |
| 48 | 53 | function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 49 | 54 | const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); |
| 50 | const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { | |
| 55 | const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, { | |
| 51 | 56 | reportFailure: false, |
| 52 | 57 | reportDefect: false, |
| 53 | 58 | }); |
| ... | ... | @@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 81 | 86 | clearConnectOnboardingRequest(); |
| 82 | 87 | } |
| 83 | 88 | |
| 84 | const queueAccountCleanup = ( | |
| 89 | const cleanUpAccount = async ( | |
| 85 | 90 | previous: { |
| 86 | 91 | readonly userId: string; |
| 87 | 92 | readonly provider: () => Promise<string | null>; |
| 88 | 93 | } | null, |
| 94 | accountId: string | null, | |
| 89 | 95 | ) => { |
| 90 | const previousTransition = accountTransitionRef.current ?? Promise.resolve(); | |
| 91 | accountTransitionRef.current = previousTransition.then(async () => { | |
| 92 | const cleanup = [ | |
| 93 | resetManagedRelayTokenCache(), | |
| 94 | removeRelayEnvironments(), | |
| 95 | ...(previous | |
| 96 | ? [ | |
| 97 | settleAsyncResult(() => | |
| 98 | runtime.runPromiseExit( | |
| 99 | unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), | |
| 100 | ), | |
| 96 | const removal = await removeRelayEnvironments(accountId); | |
| 97 | if (removal._tag !== "Success") throw squashAtomCommandFailure(removal); | |
| 98 | const cleanup = [ | |
| 99 | resetManagedRelayTokenCache(), | |
| 100 | ...(previous | |
| 101 | ? [ | |
| 102 | settleAsyncResult(() => | |
| 103 | runtime.runPromiseExit( | |
| 104 | unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), | |
| 101 | 105 | ), |
| 102 | ] | |
| 103 | : []), | |
| 104 | ]; | |
| 105 | const results = await Promise.all(cleanup); | |
| 106 | for (const result of results) { | |
| 107 | reportAtomCommandResult(result, { label: "cloud account cleanup" }); | |
| 108 | } | |
| 109 | }); | |
| 106 | ), | |
| 107 | ] | |
| 108 | : []), | |
| 109 | ]; | |
| 110 | const results = await Promise.all(cleanup); | |
| 111 | for (const result of results) { | |
| 112 | reportAtomCommandResult(result, { label: "cloud account cleanup" }); | |
| 113 | } | |
| 114 | }; | |
| 115 | const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => { | |
| 116 | const previousTransition = accountTransitionRef.current ?? Promise.resolve(); | |
| 117 | accountTransitionRef.current = previousTransition | |
| 118 | .catch(() => {}) | |
| 119 | .then(() => cleanUpAccount(previous, previousObservedAccount ?? null)); | |
| 110 | 120 | return accountTransitionRef.current; |
| 111 | 121 | }; |
| 112 | 122 | |
| ... | ... | @@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 115 | 125 | previousTokenProviderRef.current = null; |
| 116 | 126 | deactivateCloudRelayAccount(); |
| 117 | 127 | if (previousObservedAccount !== null) { |
| 118 | void queueAccountCleanup(previous); | |
| 128 | void settlePromise(() => queueAccountCleanup(previous)).then((result) => { | |
| 129 | reportAtomCommandResult(result, { label: "cloud account cleanup" }); | |
| 130 | }); | |
| 119 | 131 | } |
| 120 | 132 | return; |
| 121 | 133 | } |
| ... | ... | @@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 133 | 145 | } |
| 134 | 146 | }; |
| 135 | 147 | const activateAfterTransition = (transition: Promise<void>) => { |
| 136 | void (async () => { | |
| 137 | const result = await settlePromise(async () => { | |
| 138 | await transition; | |
| 139 | activateSession(); | |
| 140 | }); | |
| 141 | reportAtomCommandResult(result, { label: "cloud account activation" }); | |
| 148 | const activation = (async () => { | |
| 149 | await transition; | |
| 150 | if (cancelled) return; | |
| 151 | const storedAccount = await getComposerCloudAccountId(); | |
| 152 | if (storedAccount !== null && storedAccount !== userId) { | |
| 153 | await cleanUpAccount(null, storedAccount); | |
| 154 | } | |
| 155 | if (cancelled) return; | |
| 156 | await restoreCloudComposerDrafts(userId); | |
| 157 | activateSession(); | |
| 142 | 158 | })(); |
| 159 | accountTransitionRef.current = activation; | |
| 160 | void settlePromise(() => activation).then((result) => { | |
| 161 | reportAtomCommandResult(result, { label: "cloud account activation" }); | |
| 162 | }); | |
| 143 | 163 | }; |
| 144 | 164 | if ( |
| 145 | 165 | previousObservedAccount !== undefined && |
| ... | ... | @@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 150 | 170 | deactivateCloudRelayAccount(); |
| 151 | 171 | activateAfterTransition(queueAccountCleanup(previous)); |
| 152 | 172 | } else { |
| 153 | activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); | |
| 173 | // A failed disk write can be retried. The persisted account check above | |
| 174 | // still requires cleanup before activating a different account. | |
| 175 | activateAfterTransition((accountTransitionRef.current ?? Promise.resolve()).catch(() => {})); | |
| 154 | 176 | } |
| 155 | 177 | |
| 156 | 178 | return () => { |
apps/mobile/src/features/cloud/cloud-drafts.ts created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; | |
| 2 | import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 6 | ||
| 7 | import { connectionAtomRuntime } from "../../connection/runtime"; | |
| 8 | import { archiveCloudComposerDrafts } from "../../state/use-composer-drafts"; | |
| 9 | ||
| 10 | export class CloudDraftArchiveError extends Schema.TaggedErrorClass<CloudDraftArchiveError>()( | |
| 11 | "CloudDraftArchiveError", | |
| 12 | { | |
| 13 | environmentCount: Schema.Number, | |
| 14 | hasAccountId: Schema.Boolean, | |
| 15 | cause: Schema.Defect(), | |
| 16 | }, | |
| 17 | ) { | |
| 18 | override get message(): string { | |
| 19 | return `Could not preserve local drafts for ${this.environmentCount} cloud environments before sign-out.`; | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntime, { | |
| 24 | label: "cloud:preserve-drafts-and-remove-environments", | |
| 25 | execute: Effect.fn("removeCloudEnvironments")(function* (accountId: string | null) { | |
| 26 | const registry = yield* EnvironmentRegistry; | |
| 27 | const entries = yield* SubscriptionRef.get(registry.entries); | |
| 28 | const environmentIds = new Set( | |
| 29 | [...entries.values()] | |
| 30 | .filter((entry) => entry.target._tag === "RelayConnectionTarget") | |
| 31 | .map((entry) => entry.target.environmentId), | |
| 32 | ); | |
| 33 | // Credentials are already revoked. A failed backup must leave the local | |
| 34 | // owners intact so a later sign-in can retry without losing their files. | |
| 35 | yield* Effect.tryPromise({ | |
| 36 | try: () => archiveCloudComposerDrafts(accountId, environmentIds), | |
| 37 | catch: (cause) => | |
| 38 | new CloudDraftArchiveError({ | |
| 39 | environmentCount: environmentIds.size, | |
| 40 | hasAccountId: accountId !== null, | |
| 41 | cause, | |
| 42 | }), | |
| 43 | }); | |
| 44 | yield* registry.removeRelayEnvironments(); | |
| 45 | }), | |
| 46 | }); |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+21-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | 3 | import { |
| 3 | 4 | CommonActions, |
| ... | ... | @@ -35,6 +36,10 @@ import { |
| 35 | 36 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 36 | 37 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 37 | 38 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 39 | import { | |
| 40 | composerAttachmentUploadBlockReason, | |
| 41 | composerAttachmentUploadsAtom, | |
| 42 | } from "../../state/composer-attachment-uploads"; | |
| 38 | 43 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 39 | 44 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 40 | 45 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| ... | ... | @@ -164,6 +169,16 @@ export function NewTaskDraftScreen(props: { |
| 164 | 169 | connectedEnvironments.find( |
| 165 | 170 | (environment) => environment.environmentId === selectedProject.environmentId, |
| 166 | 171 | )?.connectionState === "connected"; |
| 172 | const uploadStates = useAtomValue(composerAttachmentUploadsAtom); | |
| 173 | const attachmentBlockReason = selectedProject | |
| 174 | ? composerAttachmentUploadBlockReason({ | |
| 175 | environmentId: selectedProject.environmentId, | |
| 176 | attachments: flow.attachments, | |
| 177 | connected: environmentConnected, | |
| 178 | serverConfig: selectedEnvironmentServerConfig, | |
| 179 | states: uploadStates, | |
| 180 | }) | |
| 181 | : null; | |
| 167 | 182 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 168 | 183 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 169 | 184 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| ... | ... | @@ -863,6 +878,7 @@ export function NewTaskDraftScreen(props: { |
| 863 | 878 | const initialMessageText = draft.text.trim(); |
| 864 | 879 | |
| 865 | 880 | if ( |
| 881 | attachmentBlockReason !== null || | |
| 866 | 882 | !modelSelection || |
| 867 | 883 | initialMessageText.length === 0 || |
| 868 | 884 | flow.submitting || |
| ... | ... | @@ -1017,6 +1033,7 @@ export function NewTaskDraftScreen(props: { |
| 1017 | 1033 | |
| 1018 | 1034 | const isAndroid = Platform.OS === "android"; |
| 1019 | 1035 | const canStart = |
| 1036 | attachmentBlockReason === null && | |
| 1020 | 1037 | Boolean(flow.selectedProject) && |
| 1021 | 1038 | Boolean(flow.selectedModel) && |
| 1022 | 1039 | flow.prompt.trim().length > 0 && |
| ... | ... | @@ -1216,6 +1233,7 @@ export function NewTaskDraftScreen(props: { |
| 1216 | 1233 | {flow.attachments.length > 0 ? ( |
| 1217 | 1234 | <View className="px-[14px] pb-2.5"> |
| 1218 | 1235 | <ComposerAttachmentStrip |
| 1236 | environmentId={selectedProject.environmentId} | |
| 1219 | 1237 | attachments={flow.attachments} |
| 1220 | 1238 | imageBorderRadius={16} |
| 1221 | 1239 | imageSize={72} |
| ... | ... | @@ -1317,11 +1335,12 @@ export function NewTaskDraftScreen(props: { |
| 1317 | 1335 | {voicePresentation.showsSend ? ( |
| 1318 | 1336 | <ComposerActionButton |
| 1319 | 1337 | accessibilityLabel={ |
| 1320 | flow.submitting | |
| 1338 | attachmentBlockReason ?? | |
| 1339 | (flow.submitting | |
| 1321 | 1340 | ? "Starting task" |
| 1322 | 1341 | : environmentConnected |
| 1323 | 1342 | ? "Start task" |
| 1324 | : "Queue task" | |
| 1343 | : "Queue task") | |
| 1325 | 1344 | } |
| 1326 | 1345 | disabled={!canStart} |
| 1327 | 1346 | icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} |
apps/mobile/src/features/threads/ThreadComposer.tsx+19-4| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import type { |
| 2 | 3 | EnvironmentId, |
| 3 | 4 | MessageId, |
| ... | ... | @@ -21,6 +22,10 @@ import { |
| 21 | 22 | } from "react"; |
| 22 | 23 | import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; |
| 23 | 24 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 25 | import { | |
| 26 | composerAttachmentUploadBlockReason, | |
| 27 | composerAttachmentUploadsAtom, | |
| 28 | } from "../../state/composer-attachment-uploads"; | |
| 24 | 29 | import Animated, { |
| 25 | 30 | FadeIn, |
| 26 | 31 | FadeInDown, |
| ... | ... | @@ -365,7 +370,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 365 | 370 | const isExpanded = isFocused || settingsSheetPresentation.isActive; |
| 366 | 371 | const showsCompactDictation = isVoiceInputPresented && !isExpanded; |
| 367 | 372 | const isToolbarVisible = isExpanded || isVoiceInputPresented; |
| 368 | const canSend = hasContent && !voiceInput.blocksSubmission; | |
| 373 | const uploadStates = useAtomValue(composerAttachmentUploadsAtom); | |
| 374 | const attachmentBlockReason = composerAttachmentUploadBlockReason({ | |
| 375 | environmentId: props.environmentId, | |
| 376 | attachments: props.draftAttachments, | |
| 377 | connected: props.connectionState === "connected", | |
| 378 | serverConfig: props.serverConfig, | |
| 379 | states: uploadStates, | |
| 380 | }); | |
| 381 | const canSend = hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null; | |
| 369 | 382 | |
| 370 | 383 | // Keep the feed inset aligned with the card or compact dictation strip. |
| 371 | 384 | useEffect(() => { |
| ... | ... | @@ -617,6 +630,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 617 | 630 | exiting={FadeOut.duration(120)} |
| 618 | 631 | > |
| 619 | 632 | <ComposerAttachmentStrip |
| 633 | environmentId={props.environmentId} | |
| 620 | 634 | attachments={props.draftAttachments} |
| 621 | 635 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} |
| 622 | 636 | onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} |
| ... | ... | @@ -668,6 +682,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 668 | 682 | <View className="flex-row gap-1 pl-1"> |
| 669 | 683 | {props.draftAttachments.slice(0, 3).map((attachment) => ( |
| 670 | 684 | <ComposerAttachmentThumbnail |
| 685 | environmentId={props.environmentId} | |
| 671 | 686 | key={attachment.id} |
| 672 | 687 | attachment={attachment} |
| 673 | 688 | size={30} |
| ... | ... | @@ -703,10 +718,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 703 | 718 | /> |
| 704 | 719 | ) : ( |
| 705 | 720 | <ComposerActionButton |
| 706 | accessibilityLabel={sendLabel} | |
| 721 | accessibilityLabel={attachmentBlockReason ?? sendLabel} | |
| 707 | 722 | icon="arrow.up" |
| 708 | 723 | variant="primary" |
| 709 | disabled={!hasContent} | |
| 724 | disabled={!canSend} | |
| 710 | 725 | onPress={handleSend} |
| 711 | 726 | /> |
| 712 | 727 | )} |
| ... | ... | @@ -794,7 +809,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 794 | 809 | /> |
| 795 | 810 | ) : voicePresentation.showsSend ? ( |
| 796 | 811 | <ComposerActionButton |
| 797 | accessibilityLabel={sendLabel} | |
| 812 | accessibilityLabel={attachmentBlockReason ?? sendLabel} | |
| 798 | 813 | icon="arrow.up" |
| 799 | 814 | variant="primary" |
| 800 | 815 | disabled={!canSend} |
apps/mobile/src/features/threads/use-project-actions.ts+3| ... | ... | @@ -85,6 +85,9 @@ export function useCreateProjectThread() { |
| 85 | 85 | prepared = await prepareTurnAttachments({ |
| 86 | 86 | environmentId: input.project.environmentId, |
| 87 | 87 | attachments: input.initialAttachments, |
| 88 | supportsImageUploads: | |
| 89 | appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId)) | |
| 90 | ?.environment.capabilities.attachmentUploads === true, | |
| 88 | 91 | persistUploadedReferences: async (draftAttachments) => { |
| 89 | 92 | await input.onAttachmentsUploaded(draftAttachments); |
| 90 | 93 | return "persisted"; |
apps/mobile/src/lib/attachmentUpload.test.ts+136-3| ... | ... | @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ |
| 12 | 12 | runAtomCommand: vi.fn(), |
| 13 | 13 | readAtom: vi.fn(), |
| 14 | 14 | upload: vi.fn(), |
| 15 | writeFile: vi.fn(), | |
| 16 | deleteFile: vi.fn(), | |
| 15 | 17 | })); |
| 16 | 18 | |
| 17 | 19 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ |
| ... | ... | @@ -53,13 +55,25 @@ vi.mock("./uuid", () => ({ |
| 53 | 55 | |
| 54 | 56 | vi.mock("expo-file-system", () => ({ |
| 55 | 57 | File: class { |
| 56 | constructor(readonly uri: string) {} | |
| 58 | readonly uri: string; | |
| 59 | exists = true; | |
| 60 | constructor(uri: string, name?: string) { | |
| 61 | this.uri = name ? `${uri}/${name}` : uri; | |
| 62 | } | |
| 63 | create() {} | |
| 64 | write(bytes: string, options: unknown) { | |
| 65 | mocks.writeFile(this.uri, bytes, options); | |
| 66 | } | |
| 67 | delete() { | |
| 68 | mocks.deleteFile(this.uri); | |
| 69 | } | |
| 57 | 70 | |
| 58 | 71 | upload(url: string, options: unknown) { |
| 59 | 72 | return mocks.upload(this.uri, url, options); |
| 60 | 73 | } |
| 61 | 74 | }, |
| 62 | 75 | Paths: { |
| 76 | cache: "file:///cache", | |
| 63 | 77 | get document() { |
| 64 | 78 | return { uri: mocks.documentUri }; |
| 65 | 79 | }, |
| ... | ... | @@ -158,6 +172,8 @@ describe("prepareTurnAttachments", () => { |
| 158 | 172 | mocks.runAtomCommand.mockReset(); |
| 159 | 173 | mocks.readAtom.mockReset(); |
| 160 | 174 | mocks.upload.mockReset(); |
| 175 | mocks.writeFile.mockReset(); | |
| 176 | mocks.deleteFile.mockReset(); | |
| 161 | 177 | mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); |
| 162 | 178 | mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => |
| 163 | 179 | command === mocks.createUploadUrl |
| ... | ... | @@ -198,11 +214,11 @@ describe("prepareTurnAttachments", () => { |
| 198 | 214 | expect(mocks.upload).toHaveBeenCalledWith( |
| 199 | 215 | "file:///documents/report.pdf", |
| 200 | 216 | "https://environment.example/api/attachments/upload/signed", |
| 201 | { | |
| 217 | expect.objectContaining({ | |
| 202 | 218 | httpMethod: "POST", |
| 203 | 219 | uploadType: 0, |
| 204 | 220 | headers: { "Content-Type": "application/pdf" }, |
| 205 | }, | |
| 221 | }), | |
| 206 | 222 | ); |
| 207 | 223 | expect(prepared.status).toBe("ready"); |
| 208 | 224 | if (prepared.status !== "ready") return; |
| ... | ... | @@ -354,6 +370,123 @@ describe("prepareTurnAttachments", () => { |
| 354 | 370 | expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); |
| 355 | 371 | }); |
| 356 | 372 | |
| 373 | it("uploads image bytes over HTTP while retaining the durable offline image", async () => { | |
| 374 | const persisted = vi.fn(async () => "persisted" as const); | |
| 375 | const prepared = await prepareTurnAttachments({ | |
| 376 | environmentId, | |
| 377 | attachments: [image], | |
| 378 | supportsImageUploads: true, | |
| 379 | persistUploadedReferences: persisted, | |
| 380 | }); | |
| 381 | expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", { | |
| 382 | encoding: "base64", | |
| 383 | }); | |
| 384 | expect(mocks.upload).toHaveBeenCalledWith( | |
| 385 | "file:///cache/t3-upload-uuid", | |
| 386 | "https://environment.example/api/attachments/upload/signed", | |
| 387 | expect.objectContaining({ headers: { "Content-Type": "image/png" } }), | |
| 388 | ); | |
| 389 | expect(mocks.deleteFile).toHaveBeenCalledExactlyOnceWith("file:///cache/t3-upload-uuid"); | |
| 390 | expect(prepared.status).toBe("ready"); | |
| 391 | if (prepared.status !== "ready") return; | |
| 392 | expect(prepared.attachments).toEqual([ | |
| 393 | { | |
| 394 | type: "image", | |
| 395 | id: MINTED_ID, | |
| 396 | name: image.name, | |
| 397 | mimeType: image.mimeType, | |
| 398 | sizeBytes: image.sizeBytes, | |
| 399 | }, | |
| 400 | ]); | |
| 401 | expect(prepared.draftAttachments).toEqual([ | |
| 402 | { ...image, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId }, | |
| 403 | ]); | |
| 404 | expect(persisted).toHaveBeenCalledWith(prepared.draftAttachments); | |
| 405 | }); | |
| 406 | ||
| 407 | it("reuses an uploaded image and reuploads its local bytes after server expiry", async () => { | |
| 408 | const saved = { | |
| 409 | ...image, | |
| 410 | uploadedAttachmentId: "saved-image", | |
| 411 | uploadEnvironmentId: environmentId, | |
| 412 | }; | |
| 413 | const reused = await prepareTurnAttachments({ | |
| 414 | environmentId, | |
| 415 | attachments: [saved], | |
| 416 | supportsImageUploads: true, | |
| 417 | }); | |
| 418 | expect(reused.status === "ready" && reused.attachments[0]).toEqual({ | |
| 419 | type: "image", | |
| 420 | id: "saved-image", | |
| 421 | name: image.name, | |
| 422 | mimeType: image.mimeType, | |
| 423 | sizeBytes: image.sizeBytes, | |
| 424 | }); | |
| 425 | expect(mocks.upload).not.toHaveBeenCalled(); | |
| 426 | mocks.executeAtomQuery.mockResolvedValueOnce({ | |
| 427 | _tag: "Failure", | |
| 428 | error: { _tag: "AssetAttachmentNotFoundError" }, | |
| 429 | }); | |
| 430 | const restored = await prepareTurnAttachments({ | |
| 431 | environmentId, | |
| 432 | attachments: [saved], | |
| 433 | supportsImageUploads: true, | |
| 434 | }); | |
| 435 | expect(restored.status === "ready" && restored.draftAttachments[0]).toEqual({ | |
| 436 | ...saved, | |
| 437 | uploadedAttachmentId: MINTED_ID, | |
| 438 | }); | |
| 439 | expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", { | |
| 440 | encoding: "base64", | |
| 441 | }); | |
| 442 | }); | |
| 443 | ||
| 444 | it("does not reuse an image upload from another environment", async () => { | |
| 445 | const prepared = await prepareTurnAttachments({ | |
| 446 | environmentId, | |
| 447 | attachments: [ | |
| 448 | { | |
| 449 | ...image, | |
| 450 | uploadedAttachmentId: "other-image", | |
| 451 | uploadEnvironmentId: EnvironmentId.make("other"), | |
| 452 | }, | |
| 453 | ], | |
| 454 | supportsImageUploads: true, | |
| 455 | }); | |
| 456 | expect(mocks.executeAtomQuery).not.toHaveBeenCalled(); | |
| 457 | expect(mocks.upload).toHaveBeenCalledOnce(); | |
| 458 | expect(prepared.status === "ready" && prepared.draftAttachments[0]?.uploadEnvironmentId).toBe( | |
| 459 | environmentId, | |
| 460 | ); | |
| 461 | }); | |
| 462 | ||
| 463 | it("aborts an active transfer without dropping local bytes or stamping a partial upload", async () => { | |
| 464 | const started = Promise.withResolvers<void>(); | |
| 465 | const controller = new AbortController(); | |
| 466 | const persist = vi.fn(async () => "persisted" as const); | |
| 467 | mocks.upload.mockImplementation( | |
| 468 | (_uri: string, _url: string, options: { signal: AbortSignal }) => | |
| 469 | new Promise((_, reject) => { | |
| 470 | options.signal.addEventListener("abort", () => reject(new Error("cancelled")), { | |
| 471 | once: true, | |
| 472 | }); | |
| 473 | started.resolve(); | |
| 474 | }), | |
| 475 | ); | |
| 476 | const preparing = prepareTurnAttachments({ | |
| 477 | environmentId, | |
| 478 | attachments: [file], | |
| 479 | signal: controller.signal, | |
| 480 | persistUploadedReferences: persist, | |
| 481 | }); | |
| 482 | await started.promise; | |
| 483 | controller.abort(); | |
| 484 | expect(await preparing).toEqual({ status: "abandoned" }); | |
| 485 | expect(persist).not.toHaveBeenCalled(); | |
| 486 | expect(mocks.deleteFile).not.toHaveBeenCalled(); | |
| 487 | expect(removeCallsFor(MINTED_ID)).toBe(1); | |
| 488 | }); | |
| 489 | ||
| 357 | 490 | it("removes pending uploads when the native HTTP request fails", async () => { |
| 358 | 491 | mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} }); |
| 359 | 492 |
apps/mobile/src/lib/attachmentUpload.ts+103-40| ... | ... | @@ -9,9 +9,11 @@ import { |
| 9 | 9 | import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 10 | 10 | import type { |
| 11 | 11 | ChatFileAttachment, |
| 12 | ChatImageAttachment, | |
| 12 | 13 | EnvironmentId, |
| 13 | 14 | UploadChatImageAttachment, |
| 14 | 15 | } from "@t3tools/contracts"; |
| 16 | import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts"; | |
| 15 | 17 | import * as Option from "effect/Option"; |
| 16 | 18 | |
| 17 | 19 | import { appAtomRegistry } from "../state/atom-registry"; |
| ... | ... | @@ -20,6 +22,7 @@ import { attachmentEnvironment } from "../state/attachments"; |
| 20 | 22 | import { environmentSession } from "../state/session"; |
| 21 | 23 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; |
| 22 | 24 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; |
| 25 | import { uuidv4 } from "./uuid"; | |
| 23 | 26 | |
| 24 | 27 | /** |
| 25 | 28 | * This module owns the server side of a composer attachment's lifecycle. |
| ... | ... | @@ -31,7 +34,10 @@ import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./co |
| 31 | 34 | * owned by `removeThreadOutboxMessage` / the composer draft mutators, which |
| 32 | 35 | * release files through `releaseUnusedComposerAttachmentFiles`. |
| 33 | 36 | */ |
| 34 | export type UploadedMobileAttachment = UploadChatImageAttachment | ChatFileAttachment; | |
| 37 | export type UploadedMobileAttachment = | |
| 38 | | UploadChatImageAttachment | |
| 39 | | ChatImageAttachment | |
| 40 | | ChatFileAttachment; | |
| 35 | 41 | |
| 36 | 42 | export function validateDraftFileAttachments(input: { |
| 37 | 43 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| ... | ... | @@ -56,7 +62,7 @@ export function validateDraftFileAttachments(input: { |
| 56 | 62 | return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null; |
| 57 | 63 | } |
| 58 | 64 | |
| 59 | /** Keep uploaded file ids on durable drafts so a later send can reuse their bytes. */ | |
| 65 | /** Keep uploaded ids alongside the local bytes so a later send can reuse them. */ | |
| 60 | 66 | export function withUploadedMobileAttachmentReferences(input: { |
| 61 | 67 | readonly environmentId: EnvironmentId; |
| 62 | 68 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| ... | ... | @@ -65,8 +71,9 @@ export function withUploadedMobileAttachmentReferences(input: { |
| 65 | 71 | return input.attachments.map((attachment, index) => { |
| 66 | 72 | const uploaded = input.uploadedAttachments[index]; |
| 67 | 73 | if ( |
| 68 | attachment.type !== "file" || | |
| 69 | uploaded?.type !== "file" || | |
| 74 | !uploaded || | |
| 75 | !("id" in uploaded) || | |
| 76 | attachment.type !== uploaded.type || | |
| 70 | 77 | (attachment.uploadedAttachmentId === uploaded.id && |
| 71 | 78 | attachment.uploadEnvironmentId === input.environmentId) |
| 72 | 79 | ) { |
| ... | ... | @@ -145,21 +152,73 @@ export type PrepareTurnAttachmentsResult = |
| 145 | 152 | | PreparedTurnAttachments |
| 146 | 153 | | { readonly status: "abandoned" }; |
| 147 | 154 | |
| 155 | function uploadedReference( | |
| 156 | attachment: DraftComposerAttachment, | |
| 157 | id: string, | |
| 158 | ): ChatImageAttachment | ChatFileAttachment { | |
| 159 | const fields = { | |
| 160 | id, | |
| 161 | name: attachment.name, | |
| 162 | mimeType: attachment.mimeType, | |
| 163 | sizeBytes: attachment.sizeBytes, | |
| 164 | }; | |
| 165 | return attachment.type === "image" ? { type: "image", ...fields } : { type: "file", ...fields }; | |
| 166 | } | |
| 167 | ||
| 168 | function attachmentUploadInput(attachment: DraftComposerAttachment) { | |
| 169 | const fields = { | |
| 170 | name: attachment.name, | |
| 171 | mimeType: attachment.mimeType, | |
| 172 | sizeBytes: attachment.sizeBytes, | |
| 173 | }; | |
| 174 | if (attachment.type === "file") return { type: "file" as const, ...fields }; | |
| 175 | const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( | |
| 176 | (type) => type === attachment.mimeType.toLowerCase(), | |
| 177 | ); | |
| 178 | if (!mimeType) throw new Error(`Unsupported image type for '${attachment.name}'.`); | |
| 179 | return { ...fields, mimeType }; | |
| 180 | } | |
| 181 | ||
| 148 | 182 | async function uploadFileBytes( |
| 149 | attachment: Extract<DraftComposerAttachment, { readonly type: "file" }>, | |
| 183 | attachment: DraftComposerAttachment, | |
| 150 | 184 | url: string, |
| 185 | signal: AbortSignal, | |
| 186 | onProgress?: (progress: number) => void, | |
| 151 | 187 | ): Promise<void> { |
| 152 | 188 | const { File, Paths, UploadType } = await import("expo-file-system"); |
| 153 | const fileUri = | |
| 154 | resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? | |
| 155 | attachment.fileUri; | |
| 156 | const result = await new File(fileUri).upload(url, { | |
| 157 | httpMethod: "POST", | |
| 158 | uploadType: UploadType.BINARY_CONTENT, | |
| 159 | headers: { "Content-Type": attachment.mimeType }, | |
| 160 | }); | |
| 161 | if (result.status < 200 || result.status >= 300) { | |
| 162 | throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); | |
| 189 | if (signal.aborted) throw new Error("Upload cancelled."); | |
| 190 | const file = | |
| 191 | attachment.type === "image" | |
| 192 | ? new File(Paths.cache, `t3-upload-${uuidv4()}`) | |
| 193 | : new File( | |
| 194 | resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? | |
| 195 | attachment.fileUri, | |
| 196 | ); | |
| 197 | try { | |
| 198 | if (attachment.type === "image") { | |
| 199 | file.create(); | |
| 200 | file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), { | |
| 201 | encoding: "base64", | |
| 202 | }); | |
| 203 | } | |
| 204 | const result = await file.upload(url, { | |
| 205 | httpMethod: "POST", | |
| 206 | uploadType: UploadType.BINARY_CONTENT, | |
| 207 | headers: { "Content-Type": attachment.mimeType }, | |
| 208 | signal, | |
| 209 | ...(onProgress | |
| 210 | ? { | |
| 211 | onProgress: ({ bytesSent, totalBytes }) => { | |
| 212 | if (totalBytes > 0) onProgress(bytesSent / totalBytes); | |
| 213 | }, | |
| 214 | } | |
| 215 | : {}), | |
| 216 | }); | |
| 217 | if (result.status < 200 || result.status >= 300) { | |
| 218 | throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); | |
| 219 | } | |
| 220 | } finally { | |
| 221 | if (attachment.type === "image" && file.exists) file.delete(); | |
| 163 | 222 | } |
| 164 | 223 | } |
| 165 | 224 | |
| ... | ... | @@ -176,11 +235,16 @@ async function uploadFileBytes( |
| 176 | 235 | export async function prepareTurnAttachments(input: { |
| 177 | 236 | readonly environmentId: EnvironmentId; |
| 178 | 237 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 238 | /** Older environments continue to receive inline images. */ | |
| 239 | readonly supportsImageUploads?: boolean; | |
| 240 | readonly signal?: AbortSignal; | |
| 241 | readonly onUploadProgress?: (attachmentId: string, progress: number) => void; | |
| 179 | 242 | readonly persistUploadedReferences?: ( |
| 180 | 243 | draftAttachments: ReadonlyArray<DraftComposerAttachment>, |
| 181 | 244 | ) => Promise<"persisted" | "abandon">; |
| 182 | 245 | }): Promise<PrepareTurnAttachmentsResult> { |
| 183 | 246 | const { environmentId } = input; |
| 247 | if (input.signal?.aborted) return { status: "abandoned" }; | |
| 184 | 248 | const files = input.attachments.filter((attachment) => attachment.type === "file"); |
| 185 | 249 | const ready = ( |
| 186 | 250 | attachments: ReadonlyArray<UploadedMobileAttachment>, |
| ... | ... | @@ -194,7 +258,7 @@ export async function prepareTurnAttachments(input: { |
| 194 | 258 | releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), |
| 195 | 259 | }); |
| 196 | 260 | |
| 197 | if (files.length === 0) { | |
| 261 | if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { | |
| 198 | 262 | return ready( |
| 199 | 263 | toUploadChatImageAttachments( |
| 200 | 264 | input.attachments.filter((attachment) => attachment.type === "image"), |
| ... | ... | @@ -214,9 +278,13 @@ export async function prepareTurnAttachments(input: { |
| 214 | 278 | const uploadedAttachments: UploadedMobileAttachment[] = []; |
| 215 | 279 | const pendingAttachmentIds: string[] = []; |
| 216 | 280 | const createdAttachmentIds: string[] = []; |
| 281 | const controller = new AbortController(); | |
| 282 | const abort = () => controller.abort(); | |
| 283 | input.signal?.addEventListener("abort", abort, { once: true }); | |
| 217 | 284 | try { |
| 218 | 285 | for (const attachment of input.attachments) { |
| 219 | if (attachment.type === "image") { | |
| 286 | if (controller.signal.aborted) throw new Error("Upload cancelled."); | |
| 287 | if (attachment.type === "image" && !input.supportsImageUploads) { | |
| 220 | 288 | uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); |
| 221 | 289 | continue; |
| 222 | 290 | } |
| ... | ... | @@ -238,13 +306,7 @@ export async function prepareTurnAttachments(input: { |
| 238 | 306 | } |
| 239 | 307 | if (verification.status === "verified") { |
| 240 | 308 | pendingAttachmentIds.push(attachment.uploadedAttachmentId); |
| 241 | uploadedAttachments.push({ | |
| 242 | type: "file", | |
| 243 | id: attachment.uploadedAttachmentId, | |
| 244 | name: attachment.name, | |
| 245 | mimeType: attachment.mimeType, | |
| 246 | sizeBytes: attachment.sizeBytes, | |
| 247 | }); | |
| 309 | uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId)); | |
| 248 | 310 | continue; |
| 249 | 311 | } |
| 250 | 312 | // "missing": the pending upload expired, upload the bytes again. |
| ... | ... | @@ -255,12 +317,7 @@ export async function prepareTurnAttachments(input: { |
| 255 | 317 | createUploadUrl: attachmentEnvironment.createUploadUrl, |
| 256 | 318 | remove: attachmentEnvironment.remove, |
| 257 | 319 | environmentId, |
| 258 | upload: { | |
| 259 | type: "file", | |
| 260 | name: attachment.name, | |
| 261 | mimeType: attachment.mimeType, | |
| 262 | sizeBytes: attachment.sizeBytes, | |
| 263 | }, | |
| 320 | upload: attachmentUploadInput(attachment), | |
| 264 | 321 | // Read the connection at transfer time: the environment may have |
| 265 | 322 | // reconnected on a new base URL since this cycle started. |
| 266 | 323 | resolveUploadUrl: (relativeUrl) => { |
| ... | ... | @@ -272,11 +329,18 @@ export async function prepareTurnAttachments(input: { |
| 272 | 329 | : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl); |
| 273 | 330 | }, |
| 274 | 331 | transport: (url) => ({ |
| 275 | done: uploadFileBytes(attachment, url), | |
| 276 | // expo-file-system uploads cannot abort mid-flight. | |
| 277 | abort: () => {}, | |
| 332 | done: uploadFileBytes( | |
| 333 | attachment, | |
| 334 | url, | |
| 335 | controller.signal, | |
| 336 | input.onUploadProgress | |
| 337 | ? (progress) => input.onUploadProgress?.(attachment.id, progress) | |
| 338 | : undefined, | |
| 339 | ), | |
| 340 | abort, | |
| 278 | 341 | }), |
| 279 | 342 | onMinted: (attachmentId) => { |
| 343 | if (controller.signal.aborted) return "cancel"; | |
| 280 | 344 | pendingAttachmentIds.push(attachmentId); |
| 281 | 345 | createdAttachmentIds.push(attachmentId); |
| 282 | 346 | return "continue"; |
| ... | ... | @@ -287,15 +351,11 @@ export async function prepareTurnAttachments(input: { |
| 287 | 351 | ? result.error |
| 288 | 352 | : new Error(`Upload failed for '${attachment.name}'.`); |
| 289 | 353 | } |
| 290 | uploadedAttachments.push({ | |
| 291 | type: "file", | |
| 292 | id: result.attachmentId, | |
| 293 | name: attachment.name, | |
| 294 | mimeType: attachment.mimeType, | |
| 295 | sizeBytes: attachment.sizeBytes, | |
| 296 | }); | |
| 354 | uploadedAttachments.push(uploadedReference(attachment, result.attachmentId)); | |
| 297 | 355 | } |
| 298 | 356 | |
| 357 | if (controller.signal.aborted) throw new Error("Upload cancelled."); | |
| 358 | ||
| 299 | 359 | const draftAttachments = withUploadedMobileAttachmentReferences({ |
| 300 | 360 | environmentId, |
| 301 | 361 | attachments: input.attachments, |
| ... | ... | @@ -313,6 +373,9 @@ export async function prepareTurnAttachments(input: { |
| 313 | 373 | return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments); |
| 314 | 374 | } catch (error) { |
| 315 | 375 | await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); |
| 376 | if (controller.signal.aborted) return { status: "abandoned" }; | |
| 316 | 377 | throw error; |
| 378 | } finally { | |
| 379 | input.signal?.removeEventListener("abort", abort); | |
| 317 | 380 | } |
| 318 | 381 | } |
apps/mobile/src/lib/composer-image-schema.ts+2| ... | ... | @@ -9,6 +9,8 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ |
| 9 | 9 | mimeType: Schema.String, |
| 10 | 10 | sizeBytes: Schema.Number, |
| 11 | 11 | dataUrl: Schema.String, |
| 12 | uploadedAttachmentId: Schema.optional(Schema.String), | |
| 13 | uploadEnvironmentId: Schema.optional(EnvironmentId), | |
| 12 | 14 | }); |
| 13 | 15 | |
| 14 | 16 | export const DraftComposerFileAttachmentSchema = Schema.Struct({ |
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts created+258| ... | ... | @@ -0,0 +1,258 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | composerAttachmentUploadBlockReason, | |
| 6 | composerAttachmentUploadKey, | |
| 7 | composerDraftEnvironmentId, | |
| 8 | createComposerAttachmentUploadQueue, | |
| 9 | type ComposerAttachmentUploadRequest, | |
| 10 | type ComposerAttachmentUploadState, | |
| 11 | } from "./composerAttachmentUploadQueue"; | |
| 12 | ||
| 13 | const environmentId = EnvironmentId.make("environment-1"); | |
| 14 | function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest { | |
| 15 | return { | |
| 16 | environmentId: environment, | |
| 17 | attachment: { | |
| 18 | id, | |
| 19 | type: "file", | |
| 20 | name: `${id}.pdf`, | |
| 21 | mimeType: "application/pdf", | |
| 22 | sizeBytes: 42, | |
| 23 | fileUri: `file:///documents/${id}.pdf`, | |
| 24 | }, | |
| 25 | }; | |
| 26 | } | |
| 27 | ||
| 28 | describe("composer attachment upload queue", () => { | |
| 29 | it("bounds concurrency, deduplicates updates, and drains all attachments", async () => { | |
| 30 | const gates = new Map<string, ReturnType<typeof Promise.withResolvers<boolean>>>(); | |
| 31 | const fourthStarted = Promise.withResolvers<void>(); | |
| 32 | const firstThreeStarted = Promise.withResolvers<void>(); | |
| 33 | let active = 0; | |
| 34 | let maximum = 0; | |
| 35 | const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => { | |
| 36 | active += 1; | |
| 37 | maximum = Math.max(maximum, active); | |
| 38 | const gate = Promise.withResolvers<boolean>(); | |
| 39 | gates.set(input.attachment.id, gate); | |
| 40 | if (gates.size === 3) firstThreeStarted.resolve(); | |
| 41 | if (gates.size === 4) fourthStarted.resolve(); | |
| 42 | try { | |
| 43 | return await gate.promise; | |
| 44 | } finally { | |
| 45 | active -= 1; | |
| 46 | } | |
| 47 | }); | |
| 48 | const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); | |
| 49 | const requests = [request("one"), request("two"), request("three"), request("four")]; | |
| 50 | queue.sync(requests); | |
| 51 | queue.sync(requests); | |
| 52 | await firstThreeStarted.promise; | |
| 53 | expect(upload).toHaveBeenCalledTimes(3); | |
| 54 | gates.get("one")!.resolve(true); | |
| 55 | await fourthStarted.promise; | |
| 56 | for (const gate of gates.values()) gate.resolve(true); | |
| 57 | await queue.settled(); | |
| 58 | queue.sync(requests); | |
| 59 | await queue.settled(); | |
| 60 | expect(maximum).toBe(3); | |
| 61 | expect(upload).toHaveBeenCalledTimes(4); | |
| 62 | queue.dispose(); | |
| 63 | }); | |
| 64 | ||
| 65 | it("cancels on disconnect and resumes from the same local draft on reconnect", async () => { | |
| 66 | const started = Promise.withResolvers<void>(); | |
| 67 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; | |
| 68 | let signal: AbortSignal | undefined; | |
| 69 | const upload = vi.fn( | |
| 70 | async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => { | |
| 71 | signal = currentSignal; | |
| 72 | started.resolve(); | |
| 73 | return new Promise<boolean>((resolve) => | |
| 74 | currentSignal.addEventListener("abort", () => resolve(false), { once: true }), | |
| 75 | ); | |
| 76 | }, | |
| 77 | ); | |
| 78 | const queue = createComposerAttachmentUploadQueue({ | |
| 79 | upload, | |
| 80 | onChange: (next) => { | |
| 81 | states = next; | |
| 82 | }, | |
| 83 | }); | |
| 84 | const local = request("offline-draft"); | |
| 85 | queue.sync([local]); | |
| 86 | await started.promise; | |
| 87 | queue.sync([]); | |
| 88 | await queue.settled(); | |
| 89 | expect(signal?.aborted).toBe(true); | |
| 90 | expect(states).toEqual({}); | |
| 91 | upload.mockResolvedValueOnce(true); | |
| 92 | queue.sync([local]); | |
| 93 | await queue.settled(); | |
| 94 | expect(upload.mock.calls[1]?.[0]).toBe(local); | |
| 95 | expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ | |
| 96 | status: "ready", | |
| 97 | }); | |
| 98 | expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" }); | |
| 99 | queue.dispose(); | |
| 100 | }); | |
| 101 | ||
| 102 | it("ignores a late completion after removal or environment switch", async () => { | |
| 103 | const gate = Promise.withResolvers<boolean>(); | |
| 104 | const started = Promise.withResolvers<void>(); | |
| 105 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; | |
| 106 | const upload = vi.fn(async () => { | |
| 107 | started.resolve(); | |
| 108 | return gate.promise; | |
| 109 | }); | |
| 110 | const queue = createComposerAttachmentUploadQueue({ | |
| 111 | upload, | |
| 112 | onChange: (next) => { | |
| 113 | states = next; | |
| 114 | }, | |
| 115 | }); | |
| 116 | queue.sync([request("photo")]); | |
| 117 | await started.promise; | |
| 118 | upload.mockResolvedValueOnce(true); | |
| 119 | const other = EnvironmentId.make("environment-2"); | |
| 120 | queue.sync([request("photo", other)]); | |
| 121 | gate.resolve(true); | |
| 122 | await queue.settled(); | |
| 123 | expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } }); | |
| 124 | queue.sync([]); | |
| 125 | expect(states).toEqual({}); | |
| 126 | queue.dispose(); | |
| 127 | }); | |
| 128 | ||
| 129 | it("restarts a re-added attachment after its aborted transfer finishes settling", async () => { | |
| 130 | const firstStarted = Promise.withResolvers<void>(); | |
| 131 | const firstSettled = Promise.withResolvers<boolean>(); | |
| 132 | const secondStarted = Promise.withResolvers<void>(); | |
| 133 | const secondSettled = Promise.withResolvers<boolean>(); | |
| 134 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; | |
| 135 | let firstSignal: AbortSignal | undefined; | |
| 136 | const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => { | |
| 137 | if (!firstSignal) { | |
| 138 | firstSignal = signal; | |
| 139 | firstStarted.resolve(); | |
| 140 | return firstSettled.promise; | |
| 141 | } | |
| 142 | secondStarted.resolve(); | |
| 143 | return secondSettled.promise; | |
| 144 | }); | |
| 145 | const queue = createComposerAttachmentUploadQueue({ | |
| 146 | upload, | |
| 147 | onChange: (next) => { | |
| 148 | states = next; | |
| 149 | }, | |
| 150 | }); | |
| 151 | const local = request("re-added"); | |
| 152 | queue.sync([local]); | |
| 153 | await firstStarted.promise; | |
| 154 | queue.sync([]); | |
| 155 | queue.sync([local]); | |
| 156 | expect(firstSignal?.aborted).toBe(true); | |
| 157 | expect(upload).toHaveBeenCalledOnce(); | |
| 158 | firstSettled.resolve(false); | |
| 159 | await secondStarted.promise; | |
| 160 | expect(upload).toHaveBeenCalledTimes(2); | |
| 161 | secondSettled.resolve(true); | |
| 162 | await queue.settled(); | |
| 163 | expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ | |
| 164 | status: "ready", | |
| 165 | }); | |
| 166 | queue.dispose(); | |
| 167 | }); | |
| 168 | ||
| 169 | it("keeps failures stable until retry and reports bounded progress", async () => { | |
| 170 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; | |
| 171 | const progress: number[] = []; | |
| 172 | const upload = vi.fn( | |
| 173 | async ( | |
| 174 | _request: ComposerAttachmentUploadRequest, | |
| 175 | _signal: AbortSignal, | |
| 176 | report: (value: number) => void, | |
| 177 | ): Promise<boolean> => { | |
| 178 | report(0.12); | |
| 179 | report(0.13); | |
| 180 | report(1.1); | |
| 181 | throw new Error("Server unavailable"); | |
| 182 | }, | |
| 183 | ); | |
| 184 | const queue = createComposerAttachmentUploadQueue({ | |
| 185 | upload, | |
| 186 | onChange: (next) => { | |
| 187 | states = next; | |
| 188 | const state = next[composerAttachmentUploadKey(environmentId, "file")]; | |
| 189 | if (state?.status === "uploading") progress.push(state.progress); | |
| 190 | }, | |
| 191 | }); | |
| 192 | queue.sync([request("file")]); | |
| 193 | await queue.settled(); | |
| 194 | queue.sync([request("file")]); | |
| 195 | expect(upload).toHaveBeenCalledOnce(); | |
| 196 | expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ | |
| 197 | status: "failed", | |
| 198 | reason: "Server unavailable", | |
| 199 | }); | |
| 200 | expect(progress).toEqual([0, 0.1, 1]); | |
| 201 | upload.mockImplementationOnce(async () => true); | |
| 202 | queue.retry(environmentId, "file"); | |
| 203 | await queue.settled(); | |
| 204 | expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" }); | |
| 205 | queue.dispose(); | |
| 206 | }); | |
| 207 | ||
| 208 | it("does not spin when an upload's draft was abandoned before persistence", async () => { | |
| 209 | const upload = vi.fn(async () => false); | |
| 210 | const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); | |
| 211 | queue.sync([request("discarded")]); | |
| 212 | await queue.settled(); | |
| 213 | expect(upload).toHaveBeenCalledOnce(); | |
| 214 | queue.dispose(); | |
| 215 | }); | |
| 216 | }); | |
| 217 | ||
| 218 | describe("draft upload scope and offline submission", () => { | |
| 219 | it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { | |
| 220 | expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); | |
| 221 | expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); | |
| 222 | expect( | |
| 223 | composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]), | |
| 224 | ).toBe(environmentId); | |
| 225 | expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull(); | |
| 226 | const colonEnvironment = EnvironmentId.make("a:vcs-status:b"); | |
| 227 | expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment); | |
| 228 | expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe( | |
| 229 | colonEnvironment, | |
| 230 | ); | |
| 231 | }); | |
| 232 | ||
| 233 | it("allows offline queuing while a connected composer waits for upload or retry", () => { | |
| 234 | const key = composerAttachmentUploadKey(environmentId, "file"); | |
| 235 | const input = { | |
| 236 | environmentId, | |
| 237 | attachments: [request("file").attachment], | |
| 238 | connected: true, | |
| 239 | serverConfig: { | |
| 240 | environment: { | |
| 241 | capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } }, | |
| 242 | }, | |
| 243 | }, | |
| 244 | states: {}, | |
| 245 | }; | |
| 246 | expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading"); | |
| 247 | expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); | |
| 248 | expect( | |
| 249 | composerAttachmentUploadBlockReason({ | |
| 250 | ...input, | |
| 251 | states: { [key]: { status: "failed", reason: "Offline" } }, | |
| 252 | }), | |
| 253 | ).toBe("Retry or remove the failed attachment"); | |
| 254 | expect( | |
| 255 | composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), | |
| 256 | ).toBeNull(); | |
| 257 | }); | |
| 258 | }); |
apps/mobile/src/lib/composerAttachmentUploadQueue.ts created+193| ... | ... | @@ -0,0 +1,193 @@ |
| 1 | import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; | |
| 2 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; | |
| 3 | ||
| 4 | import type { DraftComposerAttachment } from "./composerImages"; | |
| 5 | ||
| 6 | export interface ComposerAttachmentUploadRequest { | |
| 7 | readonly environmentId: EnvironmentId; | |
| 8 | readonly attachment: DraftComposerAttachment; | |
| 9 | } | |
| 10 | ||
| 11 | export type ComposerAttachmentUploadState = | |
| 12 | | { readonly status: "uploading"; readonly progress: number } | |
| 13 | | { readonly status: "ready" } | |
| 14 | | { readonly status: "failed"; readonly reason: string }; | |
| 15 | ||
| 16 | export function composerAttachmentUploadKey( | |
| 17 | environmentId: EnvironmentId, | |
| 18 | attachmentId: string, | |
| 19 | ): string { | |
| 20 | return `${environmentId}:${attachmentId}`; | |
| 21 | } | |
| 22 | ||
| 23 | export function composerDraftEnvironmentId( | |
| 24 | draftKey: string, | |
| 25 | queuedMessages: ReadonlyArray<{ | |
| 26 | readonly messageId: string; | |
| 27 | readonly environmentId: EnvironmentId; | |
| 28 | }>, | |
| 29 | ): EnvironmentId | null { | |
| 30 | if (draftKey.startsWith("pending-task:")) { | |
| 31 | return ( | |
| 32 | queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey) | |
| 33 | ?.environmentId ?? null | |
| 34 | ); | |
| 35 | } | |
| 36 | const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey; | |
| 37 | const separator = scope.lastIndexOf(":"); | |
| 38 | return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null; | |
| 39 | } | |
| 40 | ||
| 41 | type UploadServerConfig = { | |
| 42 | readonly environment: { | |
| 43 | readonly capabilities: Pick< | |
| 44 | ServerConfig["environment"]["capabilities"], | |
| 45 | "attachmentUploads" | "fileAttachments" | |
| 46 | >; | |
| 47 | }; | |
| 48 | }; | |
| 49 | ||
| 50 | export function canUploadComposerAttachment( | |
| 51 | attachment: DraftComposerAttachment, | |
| 52 | config: UploadServerConfig | null | undefined, | |
| 53 | ): boolean { | |
| 54 | const capabilities = config?.environment.capabilities; | |
| 55 | return ( | |
| 56 | capabilities?.attachmentUploads === true && | |
| 57 | (attachment.type === "image" || | |
| 58 | (capabilities.fileAttachments !== undefined && | |
| 59 | attachment.sizeBytes <= | |
| 60 | clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes))) | |
| 61 | ); | |
| 62 | } | |
| 63 | ||
| 64 | export function composerAttachmentUploadBlockReason(input: { | |
| 65 | readonly environmentId: EnvironmentId; | |
| 66 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; | |
| 67 | readonly connected: boolean; | |
| 68 | readonly serverConfig: UploadServerConfig | null; | |
| 69 | readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>; | |
| 70 | }): string | null { | |
| 71 | if (!input.connected) return null; | |
| 72 | for (const attachment of input.attachments) { | |
| 73 | if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue; | |
| 74 | const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; | |
| 75 | if (state?.status === "failed") return "Retry or remove the failed attachment"; | |
| 76 | if (state?.status !== "ready") return "Attachment still uploading"; | |
| 77 | } | |
| 78 | return null; | |
| 79 | } | |
| 80 | ||
| 81 | /** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */ | |
| 82 | export function createComposerAttachmentUploadQueue(options: { | |
| 83 | readonly upload: ( | |
| 84 | request: ComposerAttachmentUploadRequest, | |
| 85 | signal: AbortSignal, | |
| 86 | onProgress: (progress: number) => void, | |
| 87 | ) => Promise<boolean>; | |
| 88 | readonly onChange: (states: Readonly<Record<string, ComposerAttachmentUploadState>>) => void; | |
| 89 | }) { | |
| 90 | const jobs = new Map< | |
| 91 | string, | |
| 92 | { readonly controller: AbortController; readonly done: Promise<void> } | |
| 93 | >(); | |
| 94 | let desired = new Map<string, ComposerAttachmentUploadRequest>(); | |
| 95 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; | |
| 96 | let disposed = false; | |
| 97 | ||
| 98 | function setState(key: string, state: ComposerAttachmentUploadState | undefined) { | |
| 99 | const previous = states[key]; | |
| 100 | if ( | |
| 101 | previous === state || | |
| 102 | (previous?.status === "uploading" && | |
| 103 | state?.status === "uploading" && | |
| 104 | previous.progress === state.progress) | |
| 105 | ) | |
| 106 | return; | |
| 107 | const next = { ...states }; | |
| 108 | if (state) next[key] = state; | |
| 109 | else delete next[key]; | |
| 110 | states = next; | |
| 111 | options.onChange(states); | |
| 112 | } | |
| 113 | ||
| 114 | function pump() { | |
| 115 | if (disposed) return; | |
| 116 | for (const [key, request] of desired) { | |
| 117 | if (jobs.size >= 3) break; | |
| 118 | if (jobs.has(key) || states[key]?.status === "ready" || states[key]?.status === "failed") | |
| 119 | continue; | |
| 120 | const controller = new AbortController(); | |
| 121 | setState(key, { status: "uploading", progress: 0 }); | |
| 122 | // Publish the job before starting async work, including synchronous test transports. | |
| 123 | const done = Promise.resolve() | |
| 124 | .then(() => | |
| 125 | options.upload(request, controller.signal, (progress) => { | |
| 126 | if (controller.signal.aborted) return; | |
| 127 | setState(key, { | |
| 128 | status: "uploading", | |
| 129 | progress: Math.floor(Math.max(0, Math.min(1, progress)) * 20) / 20, | |
| 130 | }); | |
| 131 | }), | |
| 132 | ) | |
| 133 | .then((persisted) => { | |
| 134 | if (!controller.signal.aborted && desired.has(key)) { | |
| 135 | if (!persisted) desired.delete(key); | |
| 136 | setState(key, persisted ? { status: "ready" } : undefined); | |
| 137 | } | |
| 138 | }) | |
| 139 | .catch((error: unknown) => { | |
| 140 | if (!controller.signal.aborted && desired.has(key)) { | |
| 141 | setState(key, { | |
| 142 | status: "failed", | |
| 143 | reason: error instanceof Error ? error.message : "Upload failed. Tap to retry.", | |
| 144 | }); | |
| 145 | } | |
| 146 | }) | |
| 147 | .finally(() => { | |
| 148 | jobs.delete(key); | |
| 149 | pump(); | |
| 150 | }); | |
| 151 | jobs.set(key, { controller, done }); | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | return { | |
| 156 | sync(requests: ReadonlyArray<ComposerAttachmentUploadRequest>) { | |
| 157 | if (disposed) return; | |
| 158 | desired = new Map( | |
| 159 | requests.map((request) => [ | |
| 160 | composerAttachmentUploadKey(request.environmentId, request.attachment.id), | |
| 161 | request, | |
| 162 | ]), | |
| 163 | ); | |
| 164 | for (const [key, job] of jobs) { | |
| 165 | if (!desired.has(key)) job.controller.abort(); | |
| 166 | } | |
| 167 | for (const key of Object.keys(states)) { | |
| 168 | if (!desired.has(key)) setState(key, undefined); | |
| 169 | } | |
| 170 | for (const key of desired.keys()) { | |
| 171 | if (!states[key]) setState(key, { status: "uploading", progress: 0 }); | |
| 172 | } | |
| 173 | pump(); | |
| 174 | }, | |
| 175 | retry(environmentId: EnvironmentId, attachmentId: string) { | |
| 176 | const key = composerAttachmentUploadKey(environmentId, attachmentId); | |
| 177 | if (states[key]?.status !== "failed") return; | |
| 178 | setState(key, undefined); | |
| 179 | pump(); | |
| 180 | }, | |
| 181 | /** Waits for the current transfers, useful for shutdown and focused verification. */ | |
| 182 | async settled() { | |
| 183 | while (jobs.size > 0) await Promise.all([...jobs.values()].map((job) => job.done)); | |
| 184 | }, | |
| 185 | dispose() { | |
| 186 | disposed = true; | |
| 187 | desired.clear(); | |
| 188 | for (const job of jobs.values()) job.controller.abort(); | |
| 189 | states = {}; | |
| 190 | options.onChange(states); | |
| 191 | }, | |
| 192 | }; | |
| 193 | } |
apps/mobile/src/lib/composerImages.ts+2| ... | ... | @@ -23,6 +23,8 @@ import { uuidv4 } from "./uuid"; |
| 23 | 23 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { |
| 24 | 24 | readonly id: string; |
| 25 | 25 | readonly previewUri: string; |
| 26 | readonly uploadedAttachmentId?: string; | |
| 27 | readonly uploadEnvironmentId?: EnvironmentId; | |
| 26 | 28 | } |
| 27 | 29 | |
| 28 | 30 | export interface DraftComposerFileAttachment { |
apps/mobile/src/lib/projectThreadStartTurn.ts+2-3| ... | ... | @@ -2,15 +2,14 @@ import { |
| 2 | 2 | CommandId, |
| 3 | 3 | MessageId, |
| 4 | 4 | ThreadId, |
| 5 | type ChatFileAttachment, | |
| 6 | 5 | type ModelSelection, |
| 7 | 6 | type ProjectId, |
| 8 | 7 | type ProviderInteractionMode, |
| 9 | 8 | type RuntimeMode, |
| 10 | type UploadChatImageAttachment, | |
| 11 | 9 | } from "@t3tools/contracts"; |
| 12 | 10 | |
| 13 | 11 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; |
| 12 | import type { UploadedMobileAttachment } from "./attachmentUpload"; | |
| 14 | 13 | |
| 15 | 14 | export function deriveThreadTitleFromPrompt(value: string): string { |
| 16 | 15 | const trimmed = value.trim(); |
| ... | ... | @@ -31,7 +30,7 @@ export interface ProjectThreadStartTurnSpec { |
| 31 | 30 | readonly createdAt: string; |
| 32 | 31 | readonly text: string; |
| 33 | 32 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 34 | readonly uploadedAttachments?: ReadonlyArray<UploadChatImageAttachment | ChatFileAttachment>; | |
| 33 | readonly uploadedAttachments?: ReadonlyArray<UploadedMobileAttachment>; | |
| 35 | 34 | readonly modelSelection: ModelSelection; |
| 36 | 35 | readonly runtimeMode: RuntimeMode; |
| 37 | 36 | readonly interactionMode: ProviderInteractionMode; |
apps/mobile/src/state/composer-attachment-uploads.ts created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { Atom } from "effect/unstable/reactivity"; | |
| 4 | import { useEffect, useRef } from "react"; | |
| 5 | ||
| 6 | import { prepareTurnAttachments } from "../lib/attachmentUpload"; | |
| 7 | import { | |
| 8 | composerAttachmentUploadKey, | |
| 9 | composerDraftEnvironmentId, | |
| 10 | canUploadComposerAttachment, | |
| 11 | createComposerAttachmentUploadQueue, | |
| 12 | type ComposerAttachmentUploadState, | |
| 13 | } from "../lib/composerAttachmentUploadQueue"; | |
| 14 | import { appAtomRegistry } from "./atom-registry"; | |
| 15 | import { useServerConfigs } from "./entities"; | |
| 16 | import { flattenQueuedThreadMessages, threadOutboxManager } from "./thread-outbox"; | |
| 17 | import { useThreadOutboxMessages } from "./use-thread-outbox"; | |
| 18 | import { | |
| 19 | composerDraftsAtom, | |
| 20 | ensureComposerDraftsLoaded, | |
| 21 | flushComposerDrafts, | |
| 22 | retainComposerAttachmentFileForPreview, | |
| 23 | setComposerDraftAttachmentUpload, | |
| 24 | } from "./use-composer-drafts"; | |
| 25 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; | |
| 26 | ||
| 27 | export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue"; | |
| 28 | ||
| 29 | export const composerAttachmentUploadsAtom = Atom.make< | |
| 30 | Readonly<Record<string, ComposerAttachmentUploadState>> | |
| 31 | >({}).pipe(Atom.keepAlive); | |
| 32 | const uploadStateAtom = Atom.family((key: string) => | |
| 33 | Atom.map(composerAttachmentUploadsAtom, (states) => states[key]), | |
| 34 | ); | |
| 35 | let uploadQueue: ReturnType<typeof createComposerAttachmentUploadQueue> | null = null; | |
| 36 | ||
| 37 | export function useComposerAttachmentUploadState( | |
| 38 | environmentId: EnvironmentId | undefined, | |
| 39 | attachmentId: string, | |
| 40 | ) { | |
| 41 | return useAtomValue( | |
| 42 | uploadStateAtom(environmentId ? composerAttachmentUploadKey(environmentId, attachmentId) : ""), | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 46 | export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) { | |
| 47 | uploadQueue?.retry(environmentId, attachmentId); | |
| 48 | } | |
| 49 | ||
| 50 | /** Runs outside mounted composers so a transfer can finish after navigation. */ | |
| 51 | export function useComposerAttachmentUploadWorker() { | |
| 52 | const drafts = useAtomValue(composerDraftsAtom); | |
| 53 | const queuedMessages = useThreadOutboxMessages(); | |
| 54 | const serverConfigs = useServerConfigs(); | |
| 55 | const { connectedEnvironments } = useRemoteConnectionStatus(); | |
| 56 | const queueRef = useRef<ReturnType<typeof createComposerAttachmentUploadQueue> | null>(null); | |
| 57 | ||
| 58 | useEffect(() => { | |
| 59 | ensureComposerDraftsLoaded(); | |
| 60 | const queue = createComposerAttachmentUploadQueue({ | |
| 61 | onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), | |
| 62 | upload: async ({ environmentId, attachment }, signal, onProgress) => { | |
| 63 | const release = | |
| 64 | attachment.type === "file" | |
| 65 | ? retainComposerAttachmentFileForPreview(attachment) | |
| 66 | : undefined; | |
| 67 | try { | |
| 68 | const result = await prepareTurnAttachments({ | |
| 69 | environmentId, | |
| 70 | attachments: [attachment], | |
| 71 | supportsImageUploads: true, | |
| 72 | signal, | |
| 73 | onUploadProgress: (_, progress) => onProgress(progress), | |
| 74 | persistUploadedReferences: async ([uploaded]) => { | |
| 75 | if (signal.aborted || !uploaded) return "abandon"; | |
| 76 | const queued = flattenQueuedThreadMessages( | |
| 77 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), | |
| 78 | ); | |
| 79 | let retained = false; | |
| 80 | for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { | |
| 81 | if ( | |
| 82 | composerDraftEnvironmentId(key, queued) === environmentId && | |
| 83 | draft.attachments.some((candidate) => candidate.id === attachment.id) | |
| 84 | ) { | |
| 85 | retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; | |
| 86 | } | |
| 87 | } | |
| 88 | if (!retained) return "abandon"; | |
| 89 | await flushComposerDrafts(); | |
| 90 | return "persisted"; | |
| 91 | }, | |
| 92 | }); | |
| 93 | return result.status === "ready"; | |
| 94 | } finally { | |
| 95 | release?.(); | |
| 96 | } | |
| 97 | }, | |
| 98 | }); | |
| 99 | queueRef.current = queue; | |
| 100 | uploadQueue = queue; | |
| 101 | return () => { | |
| 102 | queue.dispose(); | |
| 103 | if (uploadQueue === queue) uploadQueue = null; | |
| 104 | queueRef.current = null; | |
| 105 | }; | |
| 106 | }, []); | |
| 107 | ||
| 108 | useEffect(() => { | |
| 109 | const queued = flattenQueuedThreadMessages(queuedMessages); | |
| 110 | const connected = new Set( | |
| 111 | connectedEnvironments | |
| 112 | .filter((environment) => environment.connectionState === "connected") | |
| 113 | .map((environment) => environment.environmentId), | |
| 114 | ); | |
| 115 | const requests = Object.entries(drafts).flatMap(([key, draft]) => { | |
| 116 | const environmentId = composerDraftEnvironmentId(key, queued); | |
| 117 | if (environmentId === null || !connected.has(environmentId)) return []; | |
| 118 | return draft.attachments | |
| 119 | .filter((attachment) => | |
| 120 | canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)), | |
| 121 | ) | |
| 122 | .map((attachment) => ({ environmentId, attachment })); | |
| 123 | }); | |
| 124 | queueRef.current?.sync(requests); | |
| 125 | }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]); | |
| 126 | } |
apps/mobile/src/state/use-composer-drafts.test.ts+150| ... | ... | @@ -118,10 +118,12 @@ import { appAtomRegistry } from "./atom-registry"; |
| 118 | 118 | import { threadOutboxManager } from "./thread-outbox"; |
| 119 | 119 | import { |
| 120 | 120 | appendComposerDraftAttachments, |
| 121 | archiveCloudComposerDrafts, | |
| 121 | 122 | clearComposerDraftContentState, |
| 122 | 123 | clearComposerDraftsEnvironment, |
| 123 | 124 | ComposerDraftPersistenceError, |
| 124 | 125 | composerDraftsAtom, |
| 126 | composerCloudDraftsAtom, | |
| 125 | 127 | copyComposerDraftContentIfEmpty, |
| 126 | 128 | copyComposerDraftContentState, |
| 127 | 129 | decodePersistedComposerState, |
| ... | ... | @@ -136,7 +138,10 @@ import { |
| 136 | 138 | resetComposerDraftsLoadState, |
| 137 | 139 | retainComposerAttachmentFileForPreview, |
| 138 | 140 | restoreComposerDraftSnapshotState, |
| 141 | restoreCloudComposerDrafts, | |
| 139 | 142 | setComposerDraftText, |
| 143 | setComposerDraftAttachmentUpload, | |
| 144 | waitForComposerDraftsLoaded, | |
| 140 | 145 | setStickyComposerModelSelection, |
| 141 | 146 | stickyComposerModelSelectionAtom, |
| 142 | 147 | undoComposerDraftMerge, |
| ... | ... | @@ -157,6 +162,7 @@ afterEach(() => { |
| 157 | 162 | composerDraftFileMocks.setOnWrite(null); |
| 158 | 163 | composerDraftFileMocks.resetWrites(); |
| 159 | 164 | appAtomRegistry.set(composerDraftsAtom, {}); |
| 165 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); | |
| 160 | 166 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); |
| 161 | 167 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); |
| 162 | 168 | composerAttachmentCleanupMocks.remove.mockClear(); |
| ... | ... | @@ -320,6 +326,150 @@ describe("mobile composer drafts", () => { |
| 320 | 326 | expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); |
| 321 | 327 | }); |
| 322 | 328 | |
| 329 | it("retains offline image bytes and newer edits when an early upload finishes", async () => { | |
| 330 | const key = "environment-1:thread-1"; | |
| 331 | const image = { | |
| 332 | id: "photo", | |
| 333 | type: "image" as const, | |
| 334 | name: "photo.png", | |
| 335 | mimeType: "image/png", | |
| 336 | sizeBytes: 3, | |
| 337 | dataUrl: "data:image/png;base64,YWJj", | |
| 338 | previewUri: "file:///photo.png", | |
| 339 | }; | |
| 340 | const second = { ...image, id: "second", name: "second.png" }; | |
| 341 | const uploaded = { | |
| 342 | ...image, | |
| 343 | uploadedAttachmentId: "pending-photo", | |
| 344 | uploadEnvironmentId: EnvironmentId.make("environment-1"), | |
| 345 | }; | |
| 346 | composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); | |
| 347 | appendComposerDraftAttachments(key, [image]); | |
| 348 | setComposerDraftText(key, "Edited while uploading"); | |
| 349 | appendComposerDraftAttachments(key, [second]); | |
| 350 | expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true); | |
| 351 | await flushComposerDrafts(); | |
| 352 | ||
| 353 | appAtomRegistry.set(composerDraftsAtom, {}); | |
| 354 | resetComposerDraftsLoadState(); | |
| 355 | await waitForComposerDraftsLoaded(); | |
| 356 | expect(getComposerDraftSnapshot(key)).toMatchObject({ | |
| 357 | text: "Edited while uploading", | |
| 358 | attachments: [uploaded, second], | |
| 359 | }); | |
| 360 | expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false); | |
| 361 | expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2); | |
| 362 | }); | |
| 363 | ||
| 364 | it("cleans up an unreferenced image upload even when there is no local file URI", async () => { | |
| 365 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 366 | onTestFinished(() => outboxLoad.mockRestore()); | |
| 367 | const environmentId = EnvironmentId.make("environment-1"); | |
| 368 | await releaseUnusedComposerAttachmentFiles([ | |
| 369 | { | |
| 370 | id: "photo", | |
| 371 | type: "image", | |
| 372 | name: "photo.png", | |
| 373 | mimeType: "image/png", | |
| 374 | sizeBytes: 3, | |
| 375 | dataUrl: "data:image/png;base64,YWJj", | |
| 376 | previewUri: "file:///photo.png", | |
| 377 | uploadedAttachmentId: "pending-photo", | |
| 378 | uploadEnvironmentId: environmentId, | |
| 379 | }, | |
| 380 | ]); | |
| 381 | expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ | |
| 382 | "pending-photo", | |
| 383 | ]); | |
| 384 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 385 | }); | |
| 386 | ||
| 387 | it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => { | |
| 388 | const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 389 | onTestFinished(() => load.mockRestore()); | |
| 390 | await waitForComposerDraftsLoaded(); | |
| 391 | const environmentId = EnvironmentId.make("cloud-environment"); | |
| 392 | const key = `${environmentId}:thread-1`; | |
| 393 | const file = { | |
| 394 | id: "local-pdf", | |
| 395 | type: "file" as const, | |
| 396 | name: "notes.pdf", | |
| 397 | mimeType: "application/pdf", | |
| 398 | sizeBytes: 42, | |
| 399 | fileUri: "file:///documents/t3-composer-attachments/notes.pdf", | |
| 400 | uploadEnvironmentId: environmentId, | |
| 401 | uploadedAttachmentId: "pending-pdf", | |
| 402 | }; | |
| 403 | const queued = { | |
| 404 | environmentId, | |
| 405 | threadId: ThreadId.make("thread-2"), | |
| 406 | messageId: MessageId.make("queued-1"), | |
| 407 | commandId: CommandId.make("command-1"), | |
| 408 | text: "Send later", | |
| 409 | attachments: [file], | |
| 410 | createdAt: "2026-08-31T12:00:00.000Z", | |
| 411 | }; | |
| 412 | appAtomRegistry.set(composerDraftsAtom, { | |
| 413 | [key]: { text: "Unsent notes", attachments: [file] }, | |
| 414 | "direct-environment:thread-1": DRAFT, | |
| 415 | "pending-task:queued-1": { text: "Edited queued task", attachments: [file] }, | |
| 416 | }); | |
| 417 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] }); | |
| 418 | await archiveCloudComposerDrafts("account-a", new Set([environmentId])); | |
| 419 | expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ | |
| 420 | "direct-environment:thread-1": DRAFT, | |
| 421 | }); | |
| 422 | // The registry can remove the active outbox and drafts after the backup lands. | |
| 423 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); | |
| 424 | await clearComposerDraftsEnvironment(environmentId); | |
| 425 | await releaseUnusedComposerAttachmentFiles([file]); | |
| 426 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 427 | expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); | |
| 428 | ||
| 429 | appAtomRegistry.set(composerDraftsAtom, {}); | |
| 430 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); | |
| 431 | resetComposerDraftsLoadState(); | |
| 432 | await waitForComposerDraftsLoaded(); | |
| 433 | await restoreCloudComposerDrafts("account-b"); | |
| 434 | expect(getComposerDraftSnapshot(key).attachments).toEqual([]); | |
| 435 | expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({}); | |
| 436 | const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); | |
| 437 | onTestFinished(() => enqueue.mockRestore()); | |
| 438 | await restoreCloudComposerDrafts("account-a"); | |
| 439 | expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); | |
| 440 | expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); | |
| 441 | expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); | |
| 442 | expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); | |
| 443 | const persisted = decodePersistedComposerState( | |
| 444 | JSON.parse(composerDraftFileMocks.getDocument()), | |
| 445 | ); | |
| 446 | expect(persisted.drafts[key]?.attachments).toEqual([file]); | |
| 447 | expect(persisted.cloudDrafts.accountId).toBe("account-a"); | |
| 448 | }); | |
| 449 | ||
| 450 | it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => { | |
| 451 | const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 452 | onTestFinished(() => load.mockRestore()); | |
| 453 | await waitForComposerDraftsLoaded(); | |
| 454 | appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": DRAFT }); | |
| 455 | composerDraftFileMocks.setWriteError(new Error("Storage is full")); | |
| 456 | await expect( | |
| 457 | archiveCloudComposerDrafts("account-a", new Set([EnvironmentId.make("environment-1")])), | |
| 458 | ).rejects.toThrow(); | |
| 459 | expect( | |
| 460 | appAtomRegistry.get(composerCloudDraftsAtom).signedOut["account-a"]?.drafts[ | |
| 461 | "environment-1:thread-1" | |
| 462 | ], | |
| 463 | ).toEqual(DRAFT); | |
| 464 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 465 | composerDraftFileMocks.setWriteError(null); | |
| 466 | await archiveCloudComposerDrafts(null, new Set([EnvironmentId.make("environment-1")])); | |
| 467 | expect( | |
| 468 | decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).cloudDrafts | |
| 469 | .signedOut["account-a"]?.drafts["environment-1:thread-1"], | |
| 470 | ).toEqual(DRAFT); | |
| 471 | }); | |
| 472 | ||
| 323 | 473 | it("keeps a removed file until both playback and a share copy finish", async () => { |
| 324 | 474 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 325 | 475 | onTestFinished(() => outboxLoad.mockRestore()); |
apps/mobile/src/state/use-composer-drafts.ts+311-13| ... | ... | @@ -23,7 +23,14 @@ import { |
| 23 | 23 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; |
| 24 | 24 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 25 | 25 | import { appAtomRegistry } from "./atom-registry"; |
| 26 | import { | |
| 27 | decodeQueuedThreadMessage, | |
| 28 | encodeQueuedThreadMessage, | |
| 29 | QueuedThreadMessageSchema, | |
| 30 | type QueuedThreadMessage, | |
| 31 | } from "./thread-outbox-model"; | |
| 26 | 32 | import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; |
| 33 | import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; | |
| 27 | 34 | |
| 28 | 35 | const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; |
| 29 | 36 | const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; |
| ... | ... | @@ -93,6 +100,16 @@ const PersistedComposerDraftsSchema = Schema.Struct({ |
| 93 | 100 | schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), |
| 94 | 101 | drafts: Schema.Record(Schema.String, ComposerDraftSchema), |
| 95 | 102 | stickyModelSelection: Schema.optional(ModelSelectionSchema), |
| 103 | cloudAccountId: Schema.optional(Schema.String), | |
| 104 | signedOutDrafts: Schema.optional( | |
| 105 | Schema.Record( | |
| 106 | Schema.String, | |
| 107 | Schema.Struct({ | |
| 108 | drafts: Schema.Record(Schema.String, ComposerDraftSchema), | |
| 109 | queuedMessages: Schema.Array(QueuedThreadMessageSchema), | |
| 110 | }), | |
| 111 | ), | |
| 112 | ), | |
| 96 | 113 | }); |
| 97 | 114 | |
| 98 | 115 | const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( |
| ... | ... | @@ -114,6 +131,21 @@ export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null> |
| 114 | 131 | Atom.withLabel("mobile:sticky-composer-model-selection"), |
| 115 | 132 | ); |
| 116 | 133 | |
| 134 | interface SignedOutDrafts { | |
| 135 | readonly drafts: Record<string, ComposerDraft>; | |
| 136 | readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; | |
| 137 | } | |
| 138 | ||
| 139 | interface ComposerCloudDraftState { | |
| 140 | readonly accountId: string | null; | |
| 141 | readonly signedOut: Record<string, SignedOutDrafts>; | |
| 142 | } | |
| 143 | ||
| 144 | export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({ | |
| 145 | accountId: null, | |
| 146 | signedOut: {}, | |
| 147 | }).pipe(Atom.keepAlive); | |
| 148 | ||
| 117 | 149 | let loadPromise: Promise<void> | null = null; |
| 118 | 150 | let persistTimer: ReturnType<typeof setTimeout> | null = null; |
| 119 | 151 | const persistenceQueue = new SerializedAsyncQueue(); |
| ... | ... | @@ -156,6 +188,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { |
| 156 | 188 | export function decodePersistedComposerState(value: unknown): { |
| 157 | 189 | readonly drafts: Record<string, ComposerDraft>; |
| 158 | 190 | readonly stickyModelSelection: ModelSelection | null; |
| 191 | readonly cloudDrafts: ComposerCloudDraftState; | |
| 159 | 192 | } { |
| 160 | 193 | const parsed = decodePersistedComposerDraftsDocument(value); |
| 161 | 194 | return { |
| ... | ... | @@ -188,6 +221,18 @@ export function decodePersistedComposerState(value: unknown): { |
| 188 | 221 | .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), |
| 189 | 222 | ), |
| 190 | 223 | stickyModelSelection: parsed.stickyModelSelection ?? null, |
| 224 | cloudDrafts: { | |
| 225 | accountId: parsed.cloudAccountId ?? null, | |
| 226 | signedOut: Object.fromEntries( | |
| 227 | Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ | |
| 228 | id, | |
| 229 | { | |
| 230 | drafts: saved.drafts, | |
| 231 | queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), | |
| 232 | }, | |
| 233 | ]), | |
| 234 | ), | |
| 235 | }, | |
| 191 | 236 | }; |
| 192 | 237 | } |
| 193 | 238 | |
| ... | ... | @@ -202,15 +247,18 @@ async function getComposerDraftsFile() { |
| 202 | 247 | return new File(directory, COMPOSER_DRAFTS_FILE); |
| 203 | 248 | } |
| 204 | 249 | |
| 205 | async function loadPersistedComposerState(): Promise<{ | |
| 206 | readonly drafts: Record<string, ComposerDraft>; | |
| 207 | readonly stickyModelSelection: ModelSelection | null; | |
| 208 | }> { | |
| 250 | async function loadPersistedComposerState(): Promise< | |
| 251 | ReturnType<typeof decodePersistedComposerState> | |
| 252 | > { | |
| 209 | 253 | let operation: ComposerDraftPersistenceError["operation"] = "open"; |
| 210 | 254 | try { |
| 211 | 255 | const file = await getComposerDraftsFile(); |
| 212 | 256 | if (!file.exists) { |
| 213 | return { drafts: {}, stickyModelSelection: null }; | |
| 257 | return { | |
| 258 | drafts: {}, | |
| 259 | stickyModelSelection: null, | |
| 260 | cloudDrafts: { accountId: null, signedOut: {} }, | |
| 261 | }; | |
| 214 | 262 | } |
| 215 | 263 | operation = "read"; |
| 216 | 264 | const raw = await file.text(); |
| ... | ... | @@ -226,13 +274,18 @@ async function loadPersistedComposerState(): Promise<{ |
| 226 | 274 | cause, |
| 227 | 275 | }), |
| 228 | 276 | ); |
| 229 | return { drafts: {}, stickyModelSelection: null }; | |
| 277 | return { | |
| 278 | drafts: {}, | |
| 279 | stickyModelSelection: null, | |
| 280 | cloudDrafts: { accountId: null, signedOut: {} }, | |
| 281 | }; | |
| 230 | 282 | } |
| 231 | 283 | } |
| 232 | 284 | |
| 233 | 285 | async function writePersistedComposerState( |
| 234 | 286 | drafts: Record<string, ComposerDraft>, |
| 235 | 287 | stickyModelSelection: ModelSelection | null, |
| 288 | cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), | |
| 236 | 289 | ): Promise<void> { |
| 237 | 290 | let operation: ComposerDraftPersistenceError["operation"] = "open"; |
| 238 | 291 | try { |
| ... | ... | @@ -245,6 +298,20 @@ async function writePersistedComposerState( |
| 245 | 298 | schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, |
| 246 | 299 | drafts: nonEmptyDrafts, |
| 247 | 300 | ...(stickyModelSelection ? { stickyModelSelection } : {}), |
| 301 | ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), | |
| 302 | ...(Object.keys(cloudDrafts.signedOut).length > 0 | |
| 303 | ? { | |
| 304 | signedOutDrafts: Object.fromEntries( | |
| 305 | Object.entries(cloudDrafts.signedOut).map(([id, saved]) => [ | |
| 306 | id, | |
| 307 | { | |
| 308 | drafts: saved.drafts, | |
| 309 | queuedMessages: saved.queuedMessages.map(encodeQueuedThreadMessage), | |
| 310 | }, | |
| 311 | ]), | |
| 312 | ), | |
| 313 | } | |
| 314 | : {}), | |
| 248 | 315 | } as const; |
| 249 | 316 | const encoded = JSON.stringify(document); |
| 250 | 317 | operation = "write"; |
| ... | ... | @@ -290,6 +357,13 @@ export async function flushComposerDrafts(): Promise<void> { |
| 290 | 357 | } while (persistTimer !== null); |
| 291 | 358 | } |
| 292 | 359 | |
| 360 | function signedOutAttachmentOwners() { | |
| 361 | return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [ | |
| 362 | ...Object.values(saved.drafts), | |
| 363 | ...saved.queuedMessages, | |
| 364 | ]); | |
| 365 | } | |
| 366 | ||
| 293 | 367 | function isComposerAttachmentFileReferenced(fileUri: string): boolean { |
| 294 | 368 | if (isComposerAttachmentFileRetained(fileUri)) { |
| 295 | 369 | return true; |
| ... | ... | @@ -299,7 +373,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean { |
| 299 | 373 | const queuedMessages = Object.values( |
| 300 | 374 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), |
| 301 | 375 | ).flat(); |
| 302 | return [...drafts, ...queuedMessages].some((owner) => | |
| 376 | return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => | |
| 303 | 377 | owner.attachments.some( |
| 304 | 378 | (attachment) => |
| 305 | 379 | attachment.type === "file" && |
| ... | ... | @@ -316,10 +390,9 @@ function isComposerAttachmentUploadReferenced( |
| 316 | 390 | const queuedMessages = Object.values( |
| 317 | 391 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), |
| 318 | 392 | ).flat(); |
| 319 | return [...drafts, ...queuedMessages].some((owner) => | |
| 393 | return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => | |
| 320 | 394 | owner.attachments.some( |
| 321 | 395 | (attachment) => |
| 322 | attachment.type === "file" && | |
| 323 | 396 | attachment.uploadEnvironmentId === environmentId && |
| 324 | 397 | attachment.uploadedAttachmentId === attachmentId, |
| 325 | 398 | ), |
| ... | ... | @@ -337,7 +410,6 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 337 | 410 | const uploadCandidates = new Map<EnvironmentId, Set<string>>(); |
| 338 | 411 | for (const attachment of attachments) { |
| 339 | 412 | if ( |
| 340 | attachment.type !== "file" || | |
| 341 | 413 | attachment.uploadEnvironmentId === undefined || |
| 342 | 414 | attachment.uploadedAttachmentId === undefined |
| 343 | 415 | ) { |
| ... | ... | @@ -347,7 +419,7 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 347 | 419 | ids.add(attachment.uploadedAttachmentId); |
| 348 | 420 | uploadCandidates.set(attachment.uploadEnvironmentId, ids); |
| 349 | 421 | } |
| 350 | if (candidates.size === 0) { | |
| 422 | if (candidates.size === 0 && uploadCandidates.size === 0) { | |
| 351 | 423 | return; |
| 352 | 424 | } |
| 353 | 425 | |
| ... | ... | @@ -435,7 +507,11 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 435 | 507 | export function scheduleUnusedComposerAttachmentCleanup( |
| 436 | 508 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 437 | 509 | ): void { |
| 438 | if (!attachments.some((attachment) => attachment.type === "file")) { | |
| 510 | if ( | |
| 511 | !attachments.some( | |
| 512 | (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined, | |
| 513 | ) | |
| 514 | ) { | |
| 439 | 515 | return; |
| 440 | 516 | } |
| 441 | 517 | void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => { |
| ... | ... | @@ -443,7 +519,7 @@ export function scheduleUnusedComposerAttachmentCleanup( |
| 443 | 519 | }); |
| 444 | 520 | } |
| 445 | 521 | |
| 446 | /** Keeps previews usable after send/removal, then retries the normal ownership cleanup. */ | |
| 522 | /** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ | |
| 447 | 523 | export function retainComposerAttachmentFileForPreview( |
| 448 | 524 | attachment: DraftComposerFileAttachment, |
| 449 | 525 | ): () => void { |
| ... | ... | @@ -484,6 +560,7 @@ export function ensureComposerDraftsLoaded(): void { |
| 484 | 560 | } |
| 485 | 561 | loadPromise = loadPersistedComposerState() |
| 486 | 562 | .then((persisted) => { |
| 563 | appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); | |
| 487 | 564 | if (Object.keys(persisted.drafts).length > 0) { |
| 488 | 565 | const current = appAtomRegistry.get(composerDraftsAtom); |
| 489 | 566 | appAtomRegistry.set(composerDraftsAtom, { |
| ... | ... | @@ -520,6 +597,192 @@ export async function waitForComposerDraftsLoaded(): Promise<void> { |
| 520 | 597 | } |
| 521 | 598 | } |
| 522 | 599 | |
| 600 | export async function getComposerCloudAccountId(): Promise<string | null> { | |
| 601 | await waitForComposerDraftsLoaded(); | |
| 602 | return appAtomRegistry.get(composerCloudDraftsAtom).accountId; | |
| 603 | } | |
| 604 | ||
| 605 | /** Save an account's local work before its relay environments are removed. */ | |
| 606 | export async function archiveCloudComposerDrafts( | |
| 607 | accountId: string | null, | |
| 608 | environmentIds: ReadonlySet<EnvironmentId>, | |
| 609 | ): Promise<void> { | |
| 610 | await waitForComposerDraftsLoaded(); | |
| 611 | if (!(await threadOutboxManager.load())) throw new Error("Could not preserve queued messages."); | |
| 612 | await flushThreadOutbox(); | |
| 613 | const cloud = appAtomRegistry.get(composerCloudDraftsAtom); | |
| 614 | const owner = accountId ?? cloud.accountId; | |
| 615 | if (owner === null) return; | |
| 616 | const queued = Object.values( | |
| 617 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), | |
| 618 | ).flat(); | |
| 619 | const current = appAtomRegistry.get(composerDraftsAtom); | |
| 620 | const remaining = { ...current }; | |
| 621 | const savedDrafts = { ...cloud.signedOut[owner]?.drafts }; | |
| 622 | for (const [key, draft] of Object.entries(current)) { | |
| 623 | const environmentId = composerDraftEnvironmentId(key, queued); | |
| 624 | if (environmentId !== null && environmentIds.has(environmentId)) { | |
| 625 | savedDrafts[key] = draft; | |
| 626 | delete remaining[key]; | |
| 627 | } | |
| 628 | } | |
| 629 | const savedMessages = new Map( | |
| 630 | (cloud.signedOut[owner]?.queuedMessages ?? []).map((message) => [message.messageId, message]), | |
| 631 | ); | |
| 632 | for (const message of queued) { | |
| 633 | if (environmentIds.has(message.environmentId)) savedMessages.set(message.messageId, message); | |
| 634 | } | |
| 635 | appAtomRegistry.set(composerDraftsAtom, remaining); | |
| 636 | appAtomRegistry.set(composerCloudDraftsAtom, { | |
| 637 | // Keep the owner through removal. A crash or failed cleanup can retry it | |
| 638 | // on cold start before a different account activates. | |
| 639 | accountId: owner, | |
| 640 | signedOut: { | |
| 641 | ...cloud.signedOut, | |
| 642 | [owner]: { drafts: savedDrafts, queuedMessages: [...savedMessages.values()] }, | |
| 643 | }, | |
| 644 | }); | |
| 645 | schedulePersistComposerState(); | |
| 646 | await flushComposerDrafts(); | |
| 647 | } | |
| 648 | ||
| 649 | function sameDraftAttachmentIds( | |
| 650 | left: ReadonlyArray<DraftComposerAttachment>, | |
| 651 | right: ReadonlyArray<DraftComposerAttachment>, | |
| 652 | ): boolean { | |
| 653 | return ( | |
| 654 | left.length === right.length && | |
| 655 | left.every((attachment, index) => attachment.id === right[index]?.id) | |
| 656 | ); | |
| 657 | } | |
| 658 | ||
| 659 | /** An in-flight delivery can finish after sign-out took its snapshot. */ | |
| 660 | export async function removeDeliveredCloudQueuedMessage( | |
| 661 | message: QueuedThreadMessage, | |
| 662 | ): Promise<void> { | |
| 663 | await waitForComposerDraftsLoaded(); | |
| 664 | const cloud = appAtomRegistry.get(composerCloudDraftsAtom); | |
| 665 | const signedOut = { ...cloud.signedOut }; | |
| 666 | let changed = false; | |
| 667 | for (const [accountId, saved] of Object.entries(signedOut)) { | |
| 668 | const archived = saved.queuedMessages.find( | |
| 669 | (candidate) => | |
| 670 | candidate.environmentId === message.environmentId && | |
| 671 | candidate.messageId === message.messageId, | |
| 672 | ); | |
| 673 | if ( | |
| 674 | !archived || | |
| 675 | archived.commandId !== message.commandId || | |
| 676 | archived.threadId !== message.threadId || | |
| 677 | archived.text !== message.text || | |
| 678 | !sameDraftAttachmentIds(archived.attachments, message.attachments) | |
| 679 | ) | |
| 680 | continue; | |
| 681 | // Upload ids may change during preparation; user edits must remain recoverable. | |
| 682 | if ( | |
| 683 | JSON.stringify([ | |
| 684 | archived.modelSelection, | |
| 685 | archived.runtimeMode, | |
| 686 | archived.interactionMode, | |
| 687 | archived.creation, | |
| 688 | ]) !== | |
| 689 | JSON.stringify([ | |
| 690 | message.modelSelection, | |
| 691 | message.runtimeMode, | |
| 692 | message.interactionMode, | |
| 693 | message.creation, | |
| 694 | ]) | |
| 695 | ) | |
| 696 | continue; | |
| 697 | const editorKey = `pending-task:${message.messageId}`; | |
| 698 | const editor = saved.drafts[editorKey]; | |
| 699 | if ( | |
| 700 | editor && | |
| 701 | (editor.text !== message.text || | |
| 702 | !sameDraftAttachmentIds(editor.attachments, message.attachments) || | |
| 703 | (editor.modelSelection !== undefined && | |
| 704 | JSON.stringify(editor.modelSelection) !== JSON.stringify(message.modelSelection)) || | |
| 705 | (editor.runtimeMode !== undefined && editor.runtimeMode !== message.runtimeMode) || | |
| 706 | (editor.interactionMode !== undefined && | |
| 707 | editor.interactionMode !== message.interactionMode) || | |
| 708 | (editor.workspaceSelection !== undefined && | |
| 709 | (editor.workspaceSelection.mode !== message.creation?.workspaceMode || | |
| 710 | editor.workspaceSelection.branch !== message.creation?.branch || | |
| 711 | editor.workspaceSelection.worktreePath !== message.creation?.worktreePath || | |
| 712 | (editor.workspaceSelection.startFromOrigin ?? false) !== | |
| 713 | (message.creation?.startFromOrigin ?? false)))) | |
| 714 | ) | |
| 715 | continue; | |
| 716 | const drafts = { ...saved.drafts }; | |
| 717 | delete drafts[editorKey]; | |
| 718 | signedOut[accountId] = { | |
| 719 | drafts, | |
| 720 | queuedMessages: saved.queuedMessages.filter((candidate) => candidate !== archived), | |
| 721 | }; | |
| 722 | changed = true; | |
| 723 | } | |
| 724 | if (!changed) return; | |
| 725 | appAtomRegistry.set(composerCloudDraftsAtom, { ...cloud, signedOut }); | |
| 726 | schedulePersistComposerState(); | |
| 727 | try { | |
| 728 | await flushComposerDrafts(); | |
| 729 | } catch (error) { | |
| 730 | // The live outbox can still remove this acknowledged message. Keep the | |
| 731 | // archive update pending so a later successful flush lands it too. | |
| 732 | schedulePersistComposerState(); | |
| 733 | throw error; | |
| 734 | } | |
| 735 | } | |
| 736 | ||
| 737 | /** Restores only this account, before its connections can deliver queued turns. */ | |
| 738 | export async function restoreCloudComposerDrafts(accountId: string): Promise<void> { | |
| 739 | await waitForComposerDraftsLoaded(); | |
| 740 | const cloud = appAtomRegistry.get(composerCloudDraftsAtom); | |
| 741 | const saved = cloud.signedOut[accountId]; | |
| 742 | if (saved) { | |
| 743 | if (!(await threadOutboxManager.load())) throw new Error("Could not restore queued messages."); | |
| 744 | for (const message of saved.queuedMessages) { | |
| 745 | const alreadyQueued = Object.values( | |
| 746 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), | |
| 747 | ) | |
| 748 | .flat() | |
| 749 | .some((current) => current.messageId === message.messageId); | |
| 750 | if (!alreadyQueued) await threadOutboxManager.enqueue(message); | |
| 751 | } | |
| 752 | updateComposerDrafts((current) => { | |
| 753 | const restored = { ...current }; | |
| 754 | for (const [key, draft] of Object.entries(saved.drafts)) { | |
| 755 | const existing = current[key]; | |
| 756 | const attachmentIds = new Set(existing?.attachments.map((attachment) => attachment.id)); | |
| 757 | restored[key] = existing | |
| 758 | ? { | |
| 759 | ...draft, | |
| 760 | ...existing, | |
| 761 | text: mergeComposerDraftText(existing.text, draft.text), | |
| 762 | // A concurrent import must not lose files, even above the send limit. | |
| 763 | attachments: [ | |
| 764 | ...existing.attachments, | |
| 765 | ...draft.attachments.filter((attachment) => !attachmentIds.has(attachment.id)), | |
| 766 | ], | |
| 767 | importedShareIds: [ | |
| 768 | ...new Set([ | |
| 769 | ...(existing.importedShareIds ?? []), | |
| 770 | ...(draft.importedShareIds ?? []), | |
| 771 | ]), | |
| 772 | ], | |
| 773 | } | |
| 774 | : draft; | |
| 775 | } | |
| 776 | return restored; | |
| 777 | }); | |
| 778 | } | |
| 779 | const signedOut = { ...cloud.signedOut }; | |
| 780 | delete signedOut[accountId]; | |
| 781 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId, signedOut }); | |
| 782 | schedulePersistComposerState(); | |
| 783 | await flushComposerDrafts(); | |
| 784 | } | |
| 785 | ||
| 523 | 786 | function updateComposerDrafts( |
| 524 | 787 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, |
| 525 | 788 | ): void { |
| ... | ... | @@ -655,6 +918,41 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) |
| 655 | 918 | ); |
| 656 | 919 | } |
| 657 | 920 | |
| 921 | /** Stamps a finished upload without overwriting text, removals, or newer attachments. */ | |
| 922 | export function setComposerDraftAttachmentUpload( | |
| 923 | draftKey: string, | |
| 924 | attachment: DraftComposerAttachment, | |
| 925 | ): boolean { | |
| 926 | let previous: DraftComposerAttachment | undefined; | |
| 927 | updateComposerDrafts((current) => { | |
| 928 | const draft = current[draftKey]; | |
| 929 | previous = draft?.attachments.find((candidate) => candidate.id === attachment.id); | |
| 930 | if (!draft || !previous) return current; | |
| 931 | if ( | |
| 932 | previous.uploadedAttachmentId === attachment.uploadedAttachmentId && | |
| 933 | previous.uploadEnvironmentId === attachment.uploadEnvironmentId | |
| 934 | ) | |
| 935 | return current; | |
| 936 | return { | |
| 937 | ...current, | |
| 938 | [draftKey]: { | |
| 939 | ...draft, | |
| 940 | attachments: draft.attachments.map((candidate) => | |
| 941 | candidate.id === attachment.id | |
| 942 | ? { | |
| 943 | ...candidate, | |
| 944 | uploadedAttachmentId: attachment.uploadedAttachmentId, | |
| 945 | uploadEnvironmentId: attachment.uploadEnvironmentId, | |
| 946 | } | |
| 947 | : candidate, | |
| 948 | ), | |
| 949 | }, | |
| 950 | }; | |
| 951 | }); | |
| 952 | if (previous) scheduleUnusedComposerAttachmentCleanup([previous]); | |
| 953 | return previous !== undefined; | |
| 954 | } | |
| 955 | ||
| 658 | 956 | export function updateComposerDraftSettings( |
| 659 | 957 | draftKey: string, |
| 660 | 958 | settings: Partial<ComposerDraftSettingsUpdate>, |
apps/mobile/src/state/use-thread-composer-state.ts+16-1| ... | ... | @@ -56,6 +56,10 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox"; |
| 56 | 56 | import { useThreadOutboxMessages } from "./use-thread-outbox"; |
| 57 | 57 | import { threadEnvironment } from "./threads"; |
| 58 | 58 | import { useAtomCommand } from "./use-atom-command"; |
| 59 | import { | |
| 60 | composerAttachmentUploadBlockReason, | |
| 61 | composerAttachmentUploadsAtom, | |
| 62 | } from "./composer-attachment-uploads"; | |
| 59 | 63 | |
| 60 | 64 | export function appendReviewCommentToDraft(input: { |
| 61 | 65 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -178,6 +182,16 @@ export function useThreadComposerState() { |
| 178 | 182 | const thread = selectedThreadDetail ?? selectedThreadShell; |
| 179 | 183 | const text = draft.text.trim(); |
| 180 | 184 | const attachments = draft.attachments; |
| 185 | if ( | |
| 186 | composerAttachmentUploadBlockReason({ | |
| 187 | environmentId: selectedThreadShell.environmentId, | |
| 188 | attachments, | |
| 189 | connected: selectedEnvironmentRuntime?.connectionState === "connected", | |
| 190 | serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, | |
| 191 | states: appAtomRegistry.get(composerAttachmentUploadsAtom), | |
| 192 | }) !== null | |
| 193 | ) | |
| 194 | return null; | |
| 181 | 195 | if (text.length === 0 && attachments.length === 0) { |
| 182 | 196 | return null; |
| 183 | 197 | } |
| ... | ... | @@ -298,7 +312,8 @@ export function useThreadComposerState() { |
| 298 | 312 | ); |
| 299 | 313 | return messageId; |
| 300 | 314 | }, [ |
| 301 | selectedEnvironmentRuntime?.serverConfig?.providers, | |
| 315 | selectedEnvironmentRuntime?.connectionState, | |
| 316 | selectedEnvironmentRuntime?.serverConfig, | |
| 302 | 317 | selectedThreadDetail, |
| 303 | 318 | selectedThreadShell, |
| 304 | 319 | uploadThreadFeedback, |
apps/mobile/src/state/use-thread-outbox-drain.test.ts+67| ... | ... | @@ -193,6 +193,7 @@ beforeEach(() => { |
| 193 | 193 | afterEach(() => { |
| 194 | 194 | appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); |
| 195 | 195 | appAtomRegistry.set(composerDrafts.composerDraftsAtom, {}); |
| 196 | appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); | |
| 196 | 197 | appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); |
| 197 | 198 | harness.draftFile.setWriteError(null); |
| 198 | 199 | harness.removePersistedFile.mockClear(); |
| ... | ... | @@ -321,6 +322,72 @@ describe("thread outbox attachment preparation", () => { |
| 321 | 322 | }); |
| 322 | 323 | |
| 323 | 324 | describe("thread outbox drain delivery cleanup", () => { |
| 325 | it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { | |
| 326 | const message = queuedMessage({ messageId: "archive-write-failure", text: "Delivered" }); | |
| 327 | await harness.manager.enqueue(message); | |
| 328 | await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); | |
| 329 | harness.draftFile.setWriteError(new Error("Draft storage unavailable")); | |
| 330 | ||
| 331 | await expect( | |
| 332 | completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId)), | |
| 333 | ).resolves.toBe("removed"); | |
| 334 | expect(remainingMessages()).toEqual([]); | |
| 335 | ||
| 336 | harness.draftFile.setWriteError(null); | |
| 337 | await composerDrafts.flushComposerDrafts(); | |
| 338 | appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); | |
| 339 | composerDrafts.resetComposerDraftsLoadState(); | |
| 340 | await composerDrafts.restoreCloudComposerDrafts("account-a"); | |
| 341 | expect(remainingMessages()).toEqual([]); | |
| 342 | }); | |
| 343 | ||
| 344 | it.each([false, true])( | |
| 345 | "does not restore a message delivered after the sign-out snapshot (outbox already cleared: %s)", | |
| 346 | async (cleared) => { | |
| 347 | const message = queuedMessage({ | |
| 348 | messageId: "delivered-during-sign-out", | |
| 349 | text: "Already delivered", | |
| 350 | }); | |
| 351 | await harness.manager.enqueue(message); | |
| 352 | const deliveryRevision = harness.manager.revisionOf(message.messageId); | |
| 353 | await composerDrafts.archiveCloudComposerDrafts( | |
| 354 | "account-a", | |
| 355 | new Set([message.environmentId]), | |
| 356 | ); | |
| 357 | expect( | |
| 358 | appAtomRegistry.get(composerDrafts.composerCloudDraftsAtom).signedOut["account-a"] | |
| 359 | ?.queuedMessages, | |
| 360 | ).toEqual([message]); | |
| 361 | ||
| 362 | if (cleared) await harness.manager.clearEnvironment(message.environmentId); | |
| 363 | await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe( | |
| 364 | cleared ? "edited" : "removed", | |
| 365 | ); | |
| 366 | ||
| 367 | // Restart before signing back in: the archived copy must be removed on disk too. | |
| 368 | appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { | |
| 369 | accountId: null, | |
| 370 | signedOut: {}, | |
| 371 | }); | |
| 372 | composerDrafts.resetComposerDraftsLoadState(); | |
| 373 | await composerDrafts.restoreCloudComposerDrafts("account-a"); | |
| 374 | expect(remainingMessages()).toEqual([]); | |
| 375 | }, | |
| 376 | ); | |
| 377 | ||
| 378 | it("preserves an archived edit when an older payload finishes delivery", async () => { | |
| 379 | const message = queuedMessage({ messageId: "edited-during-sign-out", text: "Original" }); | |
| 380 | await harness.manager.enqueue(message); | |
| 381 | const deliveryRevision = harness.manager.revisionOf(message.messageId); | |
| 382 | const edited = { ...message, text: "Keep this edit" }; | |
| 383 | await harness.manager.update(edited); | |
| 384 | await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); | |
| 385 | await harness.manager.clearEnvironment(message.environmentId); | |
| 386 | await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); | |
| 387 | await composerDrafts.restoreCloudComposerDrafts("account-a"); | |
| 388 | expect(remainingMessages()).toEqual([edited]); | |
| 389 | }); | |
| 390 | ||
| 324 | 391 | it("retries only cleanup after an acknowledged send removal fails", async () => { |
| 325 | 392 | const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" }); |
| 326 | 393 | const acknowledged = new Set([message.messageId]); |
apps/mobile/src/state/use-thread-outbox-drain.ts+37-16| ... | ... | @@ -52,6 +52,7 @@ import { |
| 52 | 52 | getComposerDraftSnapshot, |
| 53 | 53 | mergeComposerDraftContent, |
| 54 | 54 | replaceComposerDraftAttachments, |
| 55 | removeDeliveredCloudQueuedMessage, | |
| 55 | 56 | undoComposerDraftMerge, |
| 56 | 57 | updateComposerDraftSettings, |
| 57 | 58 | waitForComposerDraftsLoaded, |
| ... | ... | @@ -114,7 +115,10 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma |
| 114 | 115 | * `deliveryRevision` is the revision of the payload this attempt will send, |
| 115 | 116 | * used for the delivery removal's compare-and-set. |
| 116 | 117 | */ |
| 117 | export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThreadMessage): Promise< | |
| 118 | export async function prepareQueuedMessageAttachments( | |
| 119 | queuedMessage: QueuedThreadMessage, | |
| 120 | supportsImageUploads = false, | |
| 121 | ): Promise< | |
| 118 | 122 | | { |
| 119 | 123 | readonly status: "ready"; |
| 120 | 124 | readonly prepared: PreparedTurnAttachments; |
| ... | ... | @@ -135,6 +139,7 @@ export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThrea |
| 135 | 139 | const result = await prepareTurnAttachments({ |
| 136 | 140 | environmentId: queuedMessage.environmentId, |
| 137 | 141 | attachments: queuedMessage.attachments, |
| 142 | supportsImageUploads, | |
| 138 | 143 | persistUploadedReferences: async (draftAttachments) => { |
| 139 | 144 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { |
| 140 | 145 | return "abandon"; |
| ... | ... | @@ -179,13 +184,19 @@ export async function completeQueuedMessageDelivery( |
| 179 | 184 | queuedMessage: QueuedThreadMessage, |
| 180 | 185 | deliveryRevision: number, |
| 181 | 186 | ): Promise<"removed" | "edited" | "failed"> { |
| 182 | // The editor may have taken the entry while startTurn was in flight; its | |
| 183 | // unsaved edits have not bumped the revision yet, so the CAS alone would | |
| 184 | // let removal win and the editor would lose them once it saves. | |
| 185 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { | |
| 186 | return "edited"; | |
| 187 | } | |
| 188 | 187 | try { |
| 188 | await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { | |
| 189 | console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { | |
| 190 | messageId: queuedMessage.messageId, | |
| 191 | error, | |
| 192 | }); | |
| 193 | }); | |
| 194 | // The editor may have taken the entry while startTurn was in flight; its | |
| 195 | // unsaved edits have not bumped the revision yet, so the CAS alone would | |
| 196 | // let removal win and the editor would lose them once it saves. | |
| 197 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { | |
| 198 | return "edited"; | |
| 199 | } | |
| 189 | 200 | // Removal also releases the message's local attachment files. |
| 190 | 201 | const removed = await removeThreadOutboxMessage( |
| 191 | 202 | queuedMessage, |
| ... | ... | @@ -221,6 +232,12 @@ export async function removeAcknowledgedExistingThreadMessage( |
| 221 | 232 | acknowledgedMessageIds: Set<MessageId>, |
| 222 | 233 | ): Promise<boolean> { |
| 223 | 234 | try { |
| 235 | await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { | |
| 236 | console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { | |
| 237 | messageId: queuedMessage.messageId, | |
| 238 | error, | |
| 239 | }); | |
| 240 | }); | |
| 224 | 241 | const removed = await removeThreadOutboxMessage(queuedMessage); |
| 225 | 242 | if (removed) { |
| 226 | 243 | acknowledgedMessageIds.delete(queuedMessage.messageId); |
| ... | ... | @@ -453,15 +470,10 @@ async function preserveUploadedAttachmentsForEditor( |
| 453 | 470 | const draftKey = `pending-task:${originalMessage.messageId}`; |
| 454 | 471 | const draft = getComposerDraftSnapshot(draftKey); |
| 455 | 472 | const uploadedById = new Map( |
| 456 | uploadedMessage.attachments | |
| 457 | .filter((attachment) => attachment.type === "file") | |
| 458 | .map((attachment) => [attachment.id, attachment] as const), | |
| 473 | uploadedMessage.attachments.map((attachment) => [attachment.id, attachment] as const), | |
| 459 | 474 | ); |
| 460 | 475 | let changed = false; |
| 461 | 476 | const nextAttachments = draft.attachments.map((attachment) => { |
| 462 | if (attachment.type !== "file") { | |
| 463 | return attachment; | |
| 464 | } | |
| 465 | 477 | const uploaded = uploadedById.get(attachment.id); |
| 466 | 478 | if ( |
| 467 | 479 | !uploaded?.uploadedAttachmentId || |
| ... | ... | @@ -672,7 +684,11 @@ export function useThreadOutboxDrain(): void { |
| 672 | 684 | let persistedMessage: QueuedThreadMessage; |
| 673 | 685 | let deliveryRevision: number; |
| 674 | 686 | try { |
| 675 | const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); | |
| 687 | const preparedResult = await prepareQueuedMessageAttachments( | |
| 688 | queuedMessage, | |
| 689 | serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities | |
| 690 | .attachmentUploads === true, | |
| 691 | ); | |
| 676 | 692 | if (preparedResult.status === "abandoned") { |
| 677 | 693 | return true; |
| 678 | 694 | } |
| ... | ... | @@ -744,6 +760,7 @@ export function useThreadOutboxDrain(): void { |
| 744 | 760 | startTurn, |
| 745 | 761 | updateThreadMetadata, |
| 746 | 762 | restoreQueuedMessage, |
| 763 | serverConfigs, | |
| 747 | 764 | ], |
| 748 | 765 | ); |
| 749 | 766 | |
| ... | ... | @@ -761,7 +778,11 @@ export function useThreadOutboxDrain(): void { |
| 761 | 778 | let persistedMessage: QueuedThreadMessage; |
| 762 | 779 | let deliveryRevision: number; |
| 763 | 780 | try { |
| 764 | const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); | |
| 781 | const preparedResult = await prepareQueuedMessageAttachments( | |
| 782 | queuedMessage, | |
| 783 | serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities | |
| 784 | .attachmentUploads === true, | |
| 785 | ); | |
| 765 | 786 | if (preparedResult.status === "abandoned") { |
| 766 | 787 | return true; |
| 767 | 788 | } |
| ... | ... | @@ -838,7 +859,7 @@ export function useThreadOutboxDrain(): void { |
| 838 | 859 | } |
| 839 | 860 | return false; |
| 840 | 861 | }, |
| 841 | [makeDeliveryHelpers, restoreQueuedMessage, startTurn], | |
| 862 | [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn], | |
| 842 | 863 | ); |
| 843 | 864 | |
| 844 | 865 | useEffect(() => { |
docs/internals/connection-runtime.md+13| ... | ... | @@ -138,6 +138,19 @@ connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on |
| 138 | 138 | removal the registry clears its cache and calls the platform implementation, so |
| 139 | 139 | web clears composer drafts and mobile clears drafts plus the thread outbox. |
| 140 | 140 | |
| 141 | Mobile cloud sign-out first saves relay drafts and queued messages in the local | |
| 142 | composer store under the owning account. These saved copies retain attachment | |
| 143 | files during cleanup and remain outside the active composer and upload queue. | |
| 144 | Signing back into that account restores them before relay credentials activate. | |
| 145 | Directly paired environments keep their drafts and outbox when cloud sign-out runs. | |
| 146 | ||
| 147 | Mobile composer attachments upload over HTTP while their environment is connected, | |
| 148 | with at most three concurrent transfers. Drafts retain local image data or an owned | |
| 149 | file URI alongside the pending upload ID. Sending verifies and reuses that ID, or | |
| 150 | uploads the local bytes again if it expired. Disconnecting cancels active transfers | |
| 151 | without discarding drafts; reconnecting resumes preparation. Older servers without | |
| 152 | attachment-upload support continue to receive inline images. | |
| 153 | ||
| 141 | 154 | ## Source Boundaries |
| 142 | 155 | |
| 143 | 156 | Applications must import explicit package subpaths; the package intentionally |
docs/user/composer.md+7-4| ... | ... | @@ -13,13 +13,16 @@ attach videos, text files, PDFs, ZIP archives, and other files. Each file can be |
| 13 | 13 | by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files |
| 14 | 14 | upload directly to the environment, where your agent can read, copy, or edit them by their file path. |
| 15 | 15 | |
| 16 | On web and desktop, attachments upload as soon as you add them. The send button becomes available | |
| 17 | after every upload finishes. Failed uploads can be retried or removed. On mobile, tap **+** to open | |
| 16 | Attachments upload as soon as you add them while connected to a server that supports uploads. | |
| 17 | The send button becomes available after every upload finishes. Failed uploads can be retried or | |
| 18 | removed. On mobile, tap **+** to open | |
| 18 | 19 | the photo library from either the compact or expanded composer. When the connected server supports |
| 19 | 20 | file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**. |
| 20 | 21 | Videos use the server's file upload limit. You can also share photos, videos, and files into |
| 21 | T3 Code from other apps through the system share sheet. Mobile uploads happen when the message | |
| 22 | sends, so queued messages keep their files until they deliver. Select a received file on mobile | |
| 22 | T3 Code from other apps through the system share sheet. Mobile keeps a local copy of each draft | |
| 23 | attachment, so you can still preview it and queue messages while offline. Uploads resume when | |
| 24 | you reconnect. Drafts and queued messages survive app restarts; signing out of T3 Connect keeps | |
| 25 | them on your device until you sign back into the same account. Select a received file on mobile | |
| 23 | 26 | to preview it or open the system share options. |
| 24 | 27 | |
| 25 | 28 | Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail |