| 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,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; |
| 73 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; | 73 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; |
| 74 | import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; | 74 | import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; |
| 75 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; | 75 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| 76 | import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; | ||
| 76 | 77 | ||
| 77 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | 78 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 78 | 79 | ||
| ... | @@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string { | ... | @@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string { |
| 355 | // each enqueue, shell change, or reconnect. | 356 | // each enqueue, shell change, or reconnect. |
| 356 | function ThreadOutboxDrainWorker() { | 357 | function ThreadOutboxDrainWorker() { |
| 357 | useThreadOutboxDrain(); | 358 | useThreadOutboxDrain(); |
| 359 | useComposerAttachmentUploadWorker(); | ||
| 358 | return null; | 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,8 +10,14 @@ import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 10 | import { PresentationSource } from "./NativePresentation"; | 10 | import { PresentationSource } from "./NativePresentation"; |
| 11 | import type { FilePreviewSource } from "./FilePreviewModal"; | 11 | import type { FilePreviewSource } from "./FilePreviewModal"; |
| 12 | import { isPdfFile } from "../lib/filePreview"; | 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 | export interface ComposerAttachmentStripProps { | 19 | export interface ComposerAttachmentStripProps { |
| 20 | readonly environmentId?: EnvironmentId; | ||
| 15 | /** Attachments to display. */ | 21 | /** Attachments to display. */ |
| 16 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; | 22 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 17 | /** Called when the user removes an attachment. */ | 23 | /** Called when the user removes an attachment. */ |
| ... | @@ -30,7 +36,8 @@ export interface ComposerAttachmentStripProps { | ... | @@ -30,7 +36,8 @@ export interface ComposerAttachmentStripProps { |
| 30 | readonly removeButtonPlacement?: "overlay" | "gutter"; | 36 | readonly removeButtonPlacement?: "overlay" | "gutter"; |
| 31 | } | 37 | } |
| 32 | 38 | ||
| 33 | export function ComposerAttachmentThumbnail(props: { | 39 | type ComposerAttachmentThumbnailProps = { |
| 40 | readonly environmentId?: EnvironmentId; | ||
| 34 | readonly attachment: DraftComposerAttachment; | 41 | readonly attachment: DraftComposerAttachment; |
| 35 | readonly size: number; | 42 | readonly size: number; |
| 36 | readonly borderRadius: number; | 43 | readonly borderRadius: number; |
| ... | @@ -40,7 +47,47 @@ export function ComposerAttachmentThumbnail(props: { | ... | @@ -40,7 +47,47 @@ export function ComposerAttachmentThumbnail(props: { |
| 40 | attachment: DraftComposerFileAttachment, | 47 | attachment: DraftComposerFileAttachment, |
| 41 | sourceIdentifier: string, | 48 | sourceIdentifier: string, |
| 42 | ) => void; | 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 | const { attachment } = props; | 91 | const { attachment } = props; |
| 45 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; | 92 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 46 | if (attachment.type === "image") { | 93 | if (attachment.type === "image") { |
| ... | @@ -213,6 +260,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { | ... | @@ -213,6 +260,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 213 | }} | 260 | }} |
| 214 | > | 261 | > |
| 215 | <ComposerAttachmentThumbnail | 262 | <ComposerAttachmentThumbnail |
| 263 | environmentId={props.environmentId} | ||
| 216 | attachment={attachment} | 264 | attachment={attachment} |
| 217 | size={size} | 265 | size={size} |
| 218 | borderRadius={radius} | 266 | borderRadius={radius} |
apps/mobile/src/features/cloud/CloudAuthProvider.test.ts+6| ... | @@ -26,6 +26,12 @@ vi.mock("../../connection/catalog", () => ({ | ... | @@ -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 | vi.mock("./publicConfig", () => ({ | 35 | vi.mock("./publicConfig", () => ({ |
| 30 | resolveCloudPublicConfig: vi.fn(() => ({ | 36 | resolveCloudPublicConfig: vi.fn(() => ({ |
| 31 | clerk: { publishableKey: null }, | 37 | clerk: { publishableKey: null }, |
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+52-30| ... | @@ -5,14 +5,18 @@ import { | ... | @@ -5,14 +5,18 @@ import { |
| 5 | reportAtomCommandResult, | 5 | reportAtomCommandResult, |
| 6 | settleAsyncResult, | 6 | settleAsyncResult, |
| 7 | settlePromise, | 7 | settlePromise, |
| 8 | squashAtomCommandFailure, | ||
| 8 | } from "@t3tools/client-runtime/state/runtime"; | 9 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | import { type ReactNode, useEffect, useRef } from "react"; | 11 | import { type ReactNode, useEffect, useRef } from "react"; |
| 11 | 12 | ||
| 12 | import { environmentCatalog } from "../../connection/catalog"; | ||
| 13 | import { runtime } from "../../lib/runtime"; | 13 | import { runtime } from "../../lib/runtime"; |
| 14 | import { appAtomRegistry } from "../../state/atom-registry"; | 14 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | 15 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 16 | import { | ||
| 17 | getComposerCloudAccountId, | ||
| 18 | restoreCloudComposerDrafts, | ||
| 19 | } from "../../state/use-composer-drafts"; | ||
| 16 | import { | 20 | import { |
| 17 | releaseAgentAwarenessRelayTokenProvider, | 21 | releaseAgentAwarenessRelayTokenProvider, |
| 18 | setAgentAwarenessRelayTokenProvider, | 22 | setAgentAwarenessRelayTokenProvider, |
| ... | @@ -20,6 +24,7 @@ import { | ... | @@ -20,6 +24,7 @@ import { |
| 20 | } from "../agent-awareness/remoteRegistration"; | 24 | } from "../agent-awareness/remoteRegistration"; |
| 21 | import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; | 25 | import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; |
| 22 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; | 26 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 27 | import { removeCloudEnvironments } from "./cloud-drafts"; | ||
| 23 | 28 | ||
| 24 | function resetManagedRelayTokenCache() { | 29 | function resetManagedRelayTokenCache() { |
| 25 | return settleAsyncResult(() => | 30 | return settleAsyncResult(() => |
| ... | @@ -47,7 +52,7 @@ export function activateCloudRelayAccount( | ... | @@ -47,7 +52,7 @@ export function activateCloudRelayAccount( |
| 47 | 52 | ||
| 48 | function CloudAuthBridge(props: { readonly children: ReactNode }) { | 53 | function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 49 | const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); | 54 | const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); |
| 50 | const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { | 55 | const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, { |
| 51 | reportFailure: false, | 56 | reportFailure: false, |
| 52 | reportDefect: false, | 57 | reportDefect: false, |
| 53 | }); | 58 | }); |
| ... | @@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | ... | @@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 81 | clearConnectOnboardingRequest(); | 86 | clearConnectOnboardingRequest(); |
| 82 | } | 87 | } |
| 83 | 88 | ||
| 84 | const queueAccountCleanup = ( | 89 | const cleanUpAccount = async ( |
| 85 | previous: { | 90 | previous: { |
| 86 | readonly userId: string; | 91 | readonly userId: string; |
| 87 | readonly provider: () => Promise<string | null>; | 92 | readonly provider: () => Promise<string | null>; |
| 88 | } | null, | 93 | } | null, |
| 94 | accountId: string | null, | ||
| 89 | ) => { | 95 | ) => { |
| 90 | const previousTransition = accountTransitionRef.current ?? Promise.resolve(); | 96 | const removal = await removeRelayEnvironments(accountId); |
| 91 | accountTransitionRef.current = previousTransition.then(async () => { | 97 | if (removal._tag !== "Success") throw squashAtomCommandFailure(removal); |
| 92 | const cleanup = [ | 98 | const cleanup = [ |
| 93 | resetManagedRelayTokenCache(), | 99 | resetManagedRelayTokenCache(), |
| 94 | removeRelayEnvironments(), | 100 | ...(previous |
| 95 | ...(previous | 101 | ? [ |
| 96 | ? [ | 102 | settleAsyncResult(() => |
| 97 | settleAsyncResult(() => | 103 | runtime.runPromiseExit( |
| 98 | runtime.runPromiseExit( | 104 | unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), |
| 99 | unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), | ||
| 100 | ), | ||
| 101 | ), | 105 | ), |
| 102 | ] | 106 | ), |
| 103 | : []), | 107 | ] |
| 104 | ]; | 108 | : []), |
| 105 | const results = await Promise.all(cleanup); | 109 | ]; |
| 106 | for (const result of results) { | 110 | const results = await Promise.all(cleanup); |
| 107 | reportAtomCommandResult(result, { label: "cloud account cleanup" }); | 111 | for (const result of results) { |
| 108 | } | 112 | reportAtomCommandResult(result, { label: "cloud account cleanup" }); |
| 109 | }); | 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 | return accountTransitionRef.current; | 120 | return accountTransitionRef.current; |
| 111 | }; | 121 | }; |
| 112 | 122 | ||
| ... | @@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | ... | @@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 115 | previousTokenProviderRef.current = null; | 125 | previousTokenProviderRef.current = null; |
| 116 | deactivateCloudRelayAccount(); | 126 | deactivateCloudRelayAccount(); |
| 117 | if (previousObservedAccount !== null) { | 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 | return; | 132 | return; |
| 121 | } | 133 | } |
| ... | @@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | ... | @@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 133 | } | 145 | } |
| 134 | }; | 146 | }; |
| 135 | const activateAfterTransition = (transition: Promise<void>) => { | 147 | const activateAfterTransition = (transition: Promise<void>) => { |
| 136 | void (async () => { | 148 | const activation = (async () => { |
| 137 | const result = await settlePromise(async () => { | 149 | await transition; |
| 138 | await transition; | 150 | if (cancelled) return; |
| 139 | activateSession(); | 151 | const storedAccount = await getComposerCloudAccountId(); |
| 140 | }); | 152 | if (storedAccount !== null && storedAccount !== userId) { |
| 141 | reportAtomCommandResult(result, { label: "cloud account activation" }); | 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 | if ( | 164 | if ( |
| 145 | previousObservedAccount !== undefined && | 165 | previousObservedAccount !== undefined && |
| ... | @@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | ... | @@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 150 | deactivateCloudRelayAccount(); | 170 | deactivateCloudRelayAccount(); |
| 151 | activateAfterTransition(queueAccountCleanup(previous)); | 171 | activateAfterTransition(queueAccountCleanup(previous)); |
| 152 | } else { | 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 | return () => { | 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,3 +1,4 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { | 3 | import { |
| 3 | CommonActions, | 4 | CommonActions, |
| ... | @@ -35,6 +36,10 @@ import { | ... | @@ -35,6 +36,10 @@ import { |
| 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 36 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 36 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; | 37 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 37 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 38 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 39 | import { | ||
| 40 | composerAttachmentUploadBlockReason, | ||
| 41 | composerAttachmentUploadsAtom, | ||
| 42 | } from "../../state/composer-attachment-uploads"; | ||
| 38 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 43 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 39 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | 44 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; |
| 40 | import { ProviderIcon } from "../../components/ProviderIcon"; | 45 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| ... | @@ -164,6 +169,16 @@ export function NewTaskDraftScreen(props: { | ... | @@ -164,6 +169,16 @@ export function NewTaskDraftScreen(props: { |
| 164 | connectedEnvironments.find( | 169 | connectedEnvironments.find( |
| 165 | (environment) => environment.environmentId === selectedProject.environmentId, | 170 | (environment) => environment.environmentId === selectedProject.environmentId, |
| 166 | )?.connectionState === "connected"; | 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 | const promptInputRef = useRef<ComposerEditorHandle>(null); | 182 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 168 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); | 183 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 169 | const [isComposerFocused, setIsComposerFocused] = useState(false); | 184 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| ... | @@ -863,6 +878,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -863,6 +878,7 @@ export function NewTaskDraftScreen(props: { |
| 863 | const initialMessageText = draft.text.trim(); | 878 | const initialMessageText = draft.text.trim(); |
| 864 | 879 | ||
| 865 | if ( | 880 | if ( |
| 881 | attachmentBlockReason !== null || | ||
| 866 | !modelSelection || | 882 | !modelSelection || |
| 867 | initialMessageText.length === 0 || | 883 | initialMessageText.length === 0 || |
| 868 | flow.submitting || | 884 | flow.submitting || |
| ... | @@ -1017,6 +1033,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1017,6 +1033,7 @@ export function NewTaskDraftScreen(props: { |
| 1017 | 1033 | ||
| 1018 | const isAndroid = Platform.OS === "android"; | 1034 | const isAndroid = Platform.OS === "android"; |
| 1019 | const canStart = | 1035 | const canStart = |
| 1036 | attachmentBlockReason === null && | ||
| 1020 | Boolean(flow.selectedProject) && | 1037 | Boolean(flow.selectedProject) && |
| 1021 | Boolean(flow.selectedModel) && | 1038 | Boolean(flow.selectedModel) && |
| 1022 | flow.prompt.trim().length > 0 && | 1039 | flow.prompt.trim().length > 0 && |
| ... | @@ -1216,6 +1233,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1216,6 +1233,7 @@ export function NewTaskDraftScreen(props: { |
| 1216 | {flow.attachments.length > 0 ? ( | 1233 | {flow.attachments.length > 0 ? ( |
| 1217 | <View className="px-[14px] pb-2.5"> | 1234 | <View className="px-[14px] pb-2.5"> |
| 1218 | <ComposerAttachmentStrip | 1235 | <ComposerAttachmentStrip |
| 1236 | environmentId={selectedProject.environmentId} | ||
| 1219 | attachments={flow.attachments} | 1237 | attachments={flow.attachments} |
| 1220 | imageBorderRadius={16} | 1238 | imageBorderRadius={16} |
| 1221 | imageSize={72} | 1239 | imageSize={72} |
| ... | @@ -1317,11 +1335,12 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1317,11 +1335,12 @@ export function NewTaskDraftScreen(props: { |
| 1317 | {voicePresentation.showsSend ? ( | 1335 | {voicePresentation.showsSend ? ( |
| 1318 | <ComposerActionButton | 1336 | <ComposerActionButton |
| 1319 | accessibilityLabel={ | 1337 | accessibilityLabel={ |
| 1320 | flow.submitting | 1338 | attachmentBlockReason ?? |
| 1339 | (flow.submitting | ||
| 1321 | ? "Starting task" | 1340 | ? "Starting task" |
| 1322 | : environmentConnected | 1341 | : environmentConnected |
| 1323 | ? "Start task" | 1342 | ? "Start task" |
| 1324 | : "Queue task" | 1343 | : "Queue task") |
| 1325 | } | 1344 | } |
| 1326 | disabled={!canStart} | 1345 | disabled={!canStart} |
| 1327 | icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} | 1346 | icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} |
apps/mobile/src/features/threads/ThreadComposer.tsx+19-4| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import type { | 2 | import type { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 3 | MessageId, | 4 | MessageId, |
| ... | @@ -21,6 +22,10 @@ import { | ... | @@ -21,6 +22,10 @@ import { |
| 21 | } from "react"; | 22 | } from "react"; |
| 22 | import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; | 23 | import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; |
| 23 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 24 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 25 | import { | ||
| 26 | composerAttachmentUploadBlockReason, | ||
| 27 | composerAttachmentUploadsAtom, | ||
| 28 | } from "../../state/composer-attachment-uploads"; | ||
| 24 | import Animated, { | 29 | import Animated, { |
| 25 | FadeIn, | 30 | FadeIn, |
| 26 | FadeInDown, | 31 | FadeInDown, |
| ... | @@ -365,7 +370,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -365,7 +370,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 365 | const isExpanded = isFocused || settingsSheetPresentation.isActive; | 370 | const isExpanded = isFocused || settingsSheetPresentation.isActive; |
| 366 | const showsCompactDictation = isVoiceInputPresented && !isExpanded; | 371 | const showsCompactDictation = isVoiceInputPresented && !isExpanded; |
| 367 | const isToolbarVisible = isExpanded || isVoiceInputPresented; | 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 | // Keep the feed inset aligned with the card or compact dictation strip. | 383 | // Keep the feed inset aligned with the card or compact dictation strip. |
| 371 | useEffect(() => { | 384 | useEffect(() => { |
| ... | @@ -617,6 +630,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -617,6 +630,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 617 | exiting={FadeOut.duration(120)} | 630 | exiting={FadeOut.duration(120)} |
| 618 | > | 631 | > |
| 619 | <ComposerAttachmentStrip | 632 | <ComposerAttachmentStrip |
| 633 | environmentId={props.environmentId} | ||
| 620 | attachments={props.draftAttachments} | 634 | attachments={props.draftAttachments} |
| 621 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} | 635 | onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} |
| 622 | onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} | 636 | onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} |
| ... | @@ -668,6 +682,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -668,6 +682,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 668 | <View className="flex-row gap-1 pl-1"> | 682 | <View className="flex-row gap-1 pl-1"> |
| 669 | {props.draftAttachments.slice(0, 3).map((attachment) => ( | 683 | {props.draftAttachments.slice(0, 3).map((attachment) => ( |
| 670 | <ComposerAttachmentThumbnail | 684 | <ComposerAttachmentThumbnail |
| 685 | environmentId={props.environmentId} | ||
| 671 | key={attachment.id} | 686 | key={attachment.id} |
| 672 | attachment={attachment} | 687 | attachment={attachment} |
| 673 | size={30} | 688 | size={30} |
| ... | @@ -703,10 +718,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -703,10 +718,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 703 | /> | 718 | /> |
| 704 | ) : ( | 719 | ) : ( |
| 705 | <ComposerActionButton | 720 | <ComposerActionButton |
| 706 | accessibilityLabel={sendLabel} | 721 | accessibilityLabel={attachmentBlockReason ?? sendLabel} |
| 707 | icon="arrow.up" | 722 | icon="arrow.up" |
| 708 | variant="primary" | 723 | variant="primary" |
| 709 | disabled={!hasContent} | 724 | disabled={!canSend} |
| 710 | onPress={handleSend} | 725 | onPress={handleSend} |
| 711 | /> | 726 | /> |
| 712 | )} | 727 | )} |
| ... | @@ -794,7 +809,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -794,7 +809,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 794 | /> | 809 | /> |
| 795 | ) : voicePresentation.showsSend ? ( | 810 | ) : voicePresentation.showsSend ? ( |
| 796 | <ComposerActionButton | 811 | <ComposerActionButton |
| 797 | accessibilityLabel={sendLabel} | 812 | accessibilityLabel={attachmentBlockReason ?? sendLabel} |
| 798 | icon="arrow.up" | 813 | icon="arrow.up" |
| 799 | variant="primary" | 814 | variant="primary" |
| 800 | disabled={!canSend} | 815 | disabled={!canSend} |
apps/mobile/src/features/threads/use-project-actions.ts+3| ... | @@ -85,6 +85,9 @@ export function useCreateProjectThread() { | ... | @@ -85,6 +85,9 @@ export function useCreateProjectThread() { |
| 85 | prepared = await prepareTurnAttachments({ | 85 | prepared = await prepareTurnAttachments({ |
| 86 | environmentId: input.project.environmentId, | 86 | environmentId: input.project.environmentId, |
| 87 | attachments: input.initialAttachments, | 87 | attachments: input.initialAttachments, |
| 88 | supportsImageUploads: | ||
| 89 | appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId)) | ||
| 90 | ?.environment.capabilities.attachmentUploads === true, | ||
| 88 | persistUploadedReferences: async (draftAttachments) => { | 91 | persistUploadedReferences: async (draftAttachments) => { |
| 89 | await input.onAttachmentsUploaded(draftAttachments); | 92 | await input.onAttachmentsUploaded(draftAttachments); |
| 90 | return "persisted"; | 93 | return "persisted"; |
apps/mobile/src/lib/attachmentUpload.test.ts+136-3| ... | @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ |
| 12 | runAtomCommand: vi.fn(), | 12 | runAtomCommand: vi.fn(), |
| 13 | readAtom: vi.fn(), | 13 | readAtom: vi.fn(), |
| 14 | upload: vi.fn(), | 14 | upload: vi.fn(), |
| 15 | writeFile: vi.fn(), | ||
| 16 | deleteFile: vi.fn(), | ||
| 15 | })); | 17 | })); |
| 16 | 18 | ||
| 17 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ | 19 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ |
| ... | @@ -53,13 +55,25 @@ vi.mock("./uuid", () => ({ | ... | @@ -53,13 +55,25 @@ vi.mock("./uuid", () => ({ |
| 53 | 55 | ||
| 54 | vi.mock("expo-file-system", () => ({ | 56 | vi.mock("expo-file-system", () => ({ |
| 55 | File: class { | 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 | upload(url: string, options: unknown) { | 71 | upload(url: string, options: unknown) { |
| 59 | return mocks.upload(this.uri, url, options); | 72 | return mocks.upload(this.uri, url, options); |
| 60 | } | 73 | } |
| 61 | }, | 74 | }, |
| 62 | Paths: { | 75 | Paths: { |
| 76 | cache: "file:///cache", | ||
| 63 | get document() { | 77 | get document() { |
| 64 | return { uri: mocks.documentUri }; | 78 | return { uri: mocks.documentUri }; |
| 65 | }, | 79 | }, |
| ... | @@ -158,6 +172,8 @@ describe("prepareTurnAttachments", () => { | ... | @@ -158,6 +172,8 @@ describe("prepareTurnAttachments", () => { |
| 158 | mocks.runAtomCommand.mockReset(); | 172 | mocks.runAtomCommand.mockReset(); |
| 159 | mocks.readAtom.mockReset(); | 173 | mocks.readAtom.mockReset(); |
| 160 | mocks.upload.mockReset(); | 174 | mocks.upload.mockReset(); |
| 175 | mocks.writeFile.mockReset(); | ||
| 176 | mocks.deleteFile.mockReset(); | ||
| 161 | mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); | 177 | mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); |
| 162 | mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => | 178 | mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => |
| 163 | command === mocks.createUploadUrl | 179 | command === mocks.createUploadUrl |
| ... | @@ -198,11 +214,11 @@ describe("prepareTurnAttachments", () => { | ... | @@ -198,11 +214,11 @@ describe("prepareTurnAttachments", () => { |
| 198 | expect(mocks.upload).toHaveBeenCalledWith( | 214 | expect(mocks.upload).toHaveBeenCalledWith( |
| 199 | "file:///documents/report.pdf", | 215 | "file:///documents/report.pdf", |
| 200 | "https://environment.example/api/attachments/upload/signed", | 216 | "https://environment.example/api/attachments/upload/signed", |
| 201 | { | 217 | expect.objectContaining({ |
| 202 | httpMethod: "POST", | 218 | httpMethod: "POST", |
| 203 | uploadType: 0, | 219 | uploadType: 0, |
| 204 | headers: { "Content-Type": "application/pdf" }, | 220 | headers: { "Content-Type": "application/pdf" }, |
| 205 | }, | 221 | }), |
| 206 | ); | 222 | ); |
| 207 | expect(prepared.status).toBe("ready"); | 223 | expect(prepared.status).toBe("ready"); |
| 208 | if (prepared.status !== "ready") return; | 224 | if (prepared.status !== "ready") return; |
| ... | @@ -354,6 +370,123 @@ describe("prepareTurnAttachments", () => { | ... | @@ -354,6 +370,123 @@ describe("prepareTurnAttachments", () => { |
| 354 | expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); | 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 | it("removes pending uploads when the native HTTP request fails", async () => { | 490 | it("removes pending uploads when the native HTTP request fails", async () => { |
| 358 | mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} }); | 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 +9,11 @@ import { |
| 9 | import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 9 | import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 10 | import type { | 10 | import type { |
| 11 | ChatFileAttachment, | 11 | ChatFileAttachment, |
| 12 | ChatImageAttachment, | ||
| 12 | EnvironmentId, | 13 | EnvironmentId, |
| 13 | UploadChatImageAttachment, | 14 | UploadChatImageAttachment, |
| 14 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts"; | ||
| 15 | import * as Option from "effect/Option"; | 17 | import * as Option from "effect/Option"; |
| 16 | 18 | ||
| 17 | import { appAtomRegistry } from "../state/atom-registry"; | 19 | import { appAtomRegistry } from "../state/atom-registry"; |
| ... | @@ -20,6 +22,7 @@ import { attachmentEnvironment } from "../state/attachments"; | ... | @@ -20,6 +22,7 @@ import { attachmentEnvironment } from "../state/attachments"; |
| 20 | import { environmentSession } from "../state/session"; | 22 | import { environmentSession } from "../state/session"; |
| 21 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; | 23 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; |
| 22 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; | 24 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; |
| 25 | import { uuidv4 } from "./uuid"; | ||
| 23 | 26 | ||
| 24 | /** | 27 | /** |
| 25 | * This module owns the server side of a composer attachment's lifecycle. | 28 | * This module owns the server side of a composer attachment's lifecycle. |
| ... | @@ -31,7 +34,10 @@ import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./co | ... | @@ -31,7 +34,10 @@ import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./co |
| 31 | * owned by `removeThreadOutboxMessage` / the composer draft mutators, which | 34 | * owned by `removeThreadOutboxMessage` / the composer draft mutators, which |
| 32 | * release files through `releaseUnusedComposerAttachmentFiles`. | 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 | export function validateDraftFileAttachments(input: { | 42 | export function validateDraftFileAttachments(input: { |
| 37 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; | 43 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| ... | @@ -56,7 +62,7 @@ export function validateDraftFileAttachments(input: { | ... | @@ -56,7 +62,7 @@ export function validateDraftFileAttachments(input: { |
| 56 | return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null; | 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 | export function withUploadedMobileAttachmentReferences(input: { | 66 | export function withUploadedMobileAttachmentReferences(input: { |
| 61 | readonly environmentId: EnvironmentId; | 67 | readonly environmentId: EnvironmentId; |
| 62 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; | 68 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| ... | @@ -65,8 +71,9 @@ export function withUploadedMobileAttachmentReferences(input: { | ... | @@ -65,8 +71,9 @@ export function withUploadedMobileAttachmentReferences(input: { |
| 65 | return input.attachments.map((attachment, index) => { | 71 | return input.attachments.map((attachment, index) => { |
| 66 | const uploaded = input.uploadedAttachments[index]; | 72 | const uploaded = input.uploadedAttachments[index]; |
| 67 | if ( | 73 | if ( |
| 68 | attachment.type !== "file" || | 74 | !uploaded || |
| 69 | uploaded?.type !== "file" || | 75 | !("id" in uploaded) || |
| 76 | attachment.type !== uploaded.type || | ||
| 70 | (attachment.uploadedAttachmentId === uploaded.id && | 77 | (attachment.uploadedAttachmentId === uploaded.id && |
| 71 | attachment.uploadEnvironmentId === input.environmentId) | 78 | attachment.uploadEnvironmentId === input.environmentId) |
| 72 | ) { | 79 | ) { |
| ... | @@ -145,21 +152,73 @@ export type PrepareTurnAttachmentsResult = | ... | @@ -145,21 +152,73 @@ export type PrepareTurnAttachmentsResult = |
| 145 | | PreparedTurnAttachments | 152 | | PreparedTurnAttachments |
| 146 | | { readonly status: "abandoned" }; | 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 | async function uploadFileBytes( | 182 | async function uploadFileBytes( |
| 149 | attachment: Extract<DraftComposerAttachment, { readonly type: "file" }>, | 183 | attachment: DraftComposerAttachment, |
| 150 | url: string, | 184 | url: string, |
| 185 | signal: AbortSignal, | ||
| 186 | onProgress?: (progress: number) => void, | ||
| 151 | ): Promise<void> { | 187 | ): Promise<void> { |
| 152 | const { File, Paths, UploadType } = await import("expo-file-system"); | 188 | const { File, Paths, UploadType } = await import("expo-file-system"); |
| 153 | const fileUri = | 189 | if (signal.aborted) throw new Error("Upload cancelled."); |
| 154 | resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? | 190 | const file = |
| 155 | attachment.fileUri; | 191 | attachment.type === "image" |
| 156 | const result = await new File(fileUri).upload(url, { | 192 | ? new File(Paths.cache, `t3-upload-${uuidv4()}`) |
| 157 | httpMethod: "POST", | 193 | : new File( |
| 158 | uploadType: UploadType.BINARY_CONTENT, | 194 | resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? |
| 159 | headers: { "Content-Type": attachment.mimeType }, | 195 | attachment.fileUri, |
| 160 | }); | 196 | ); |
| 161 | if (result.status < 200 || result.status >= 300) { | 197 | try { |
| 162 | throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); | 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,11 +235,16 @@ async function uploadFileBytes( |
| 176 | export async function prepareTurnAttachments(input: { | 235 | export async function prepareTurnAttachments(input: { |
| 177 | readonly environmentId: EnvironmentId; | 236 | readonly environmentId: EnvironmentId; |
| 178 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; | 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 | readonly persistUploadedReferences?: ( | 242 | readonly persistUploadedReferences?: ( |
| 180 | draftAttachments: ReadonlyArray<DraftComposerAttachment>, | 243 | draftAttachments: ReadonlyArray<DraftComposerAttachment>, |
| 181 | ) => Promise<"persisted" | "abandon">; | 244 | ) => Promise<"persisted" | "abandon">; |
| 182 | }): Promise<PrepareTurnAttachmentsResult> { | 245 | }): Promise<PrepareTurnAttachmentsResult> { |
| 183 | const { environmentId } = input; | 246 | const { environmentId } = input; |
| 247 | if (input.signal?.aborted) return { status: "abandoned" }; | ||
| 184 | const files = input.attachments.filter((attachment) => attachment.type === "file"); | 248 | const files = input.attachments.filter((attachment) => attachment.type === "file"); |
| 185 | const ready = ( | 249 | const ready = ( |
| 186 | attachments: ReadonlyArray<UploadedMobileAttachment>, | 250 | attachments: ReadonlyArray<UploadedMobileAttachment>, |
| ... | @@ -194,7 +258,7 @@ export async function prepareTurnAttachments(input: { | ... | @@ -194,7 +258,7 @@ export async function prepareTurnAttachments(input: { |
| 194 | releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), | 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 | return ready( | 262 | return ready( |
| 199 | toUploadChatImageAttachments( | 263 | toUploadChatImageAttachments( |
| 200 | input.attachments.filter((attachment) => attachment.type === "image"), | 264 | input.attachments.filter((attachment) => attachment.type === "image"), |
| ... | @@ -214,9 +278,13 @@ export async function prepareTurnAttachments(input: { | ... | @@ -214,9 +278,13 @@ export async function prepareTurnAttachments(input: { |
| 214 | const uploadedAttachments: UploadedMobileAttachment[] = []; | 278 | const uploadedAttachments: UploadedMobileAttachment[] = []; |
| 215 | const pendingAttachmentIds: string[] = []; | 279 | const pendingAttachmentIds: string[] = []; |
| 216 | const createdAttachmentIds: string[] = []; | 280 | const createdAttachmentIds: string[] = []; |
| 281 | const controller = new AbortController(); | ||
| 282 | const abort = () => controller.abort(); | ||
| 283 | input.signal?.addEventListener("abort", abort, { once: true }); | ||
| 217 | try { | 284 | try { |
| 218 | for (const attachment of input.attachments) { | 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 | uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); | 288 | uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); |
| 221 | continue; | 289 | continue; |
| 222 | } | 290 | } |
| ... | @@ -238,13 +306,7 @@ export async function prepareTurnAttachments(input: { | ... | @@ -238,13 +306,7 @@ export async function prepareTurnAttachments(input: { |
| 238 | } | 306 | } |
| 239 | if (verification.status === "verified") { | 307 | if (verification.status === "verified") { |
| 240 | pendingAttachmentIds.push(attachment.uploadedAttachmentId); | 308 | pendingAttachmentIds.push(attachment.uploadedAttachmentId); |
| 241 | uploadedAttachments.push({ | 309 | uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId)); |
| 242 | type: "file", | ||
| 243 | id: attachment.uploadedAttachmentId, | ||
| 244 | name: attachment.name, | ||
| 245 | mimeType: attachment.mimeType, | ||
| 246 | sizeBytes: attachment.sizeBytes, | ||
| 247 | }); | ||
| 248 | continue; | 310 | continue; |
| 249 | } | 311 | } |
| 250 | // "missing": the pending upload expired, upload the bytes again. | 312 | // "missing": the pending upload expired, upload the bytes again. |
| ... | @@ -255,12 +317,7 @@ export async function prepareTurnAttachments(input: { | ... | @@ -255,12 +317,7 @@ export async function prepareTurnAttachments(input: { |
| 255 | createUploadUrl: attachmentEnvironment.createUploadUrl, | 317 | createUploadUrl: attachmentEnvironment.createUploadUrl, |
| 256 | remove: attachmentEnvironment.remove, | 318 | remove: attachmentEnvironment.remove, |
| 257 | environmentId, | 319 | environmentId, |
| 258 | upload: { | 320 | upload: attachmentUploadInput(attachment), |
| 259 | type: "file", | ||
| 260 | name: attachment.name, | ||
| 261 | mimeType: attachment.mimeType, | ||
| 262 | sizeBytes: attachment.sizeBytes, | ||
| 263 | }, | ||
| 264 | // Read the connection at transfer time: the environment may have | 321 | // Read the connection at transfer time: the environment may have |
| 265 | // reconnected on a new base URL since this cycle started. | 322 | // reconnected on a new base URL since this cycle started. |
| 266 | resolveUploadUrl: (relativeUrl) => { | 323 | resolveUploadUrl: (relativeUrl) => { |
| ... | @@ -272,11 +329,18 @@ export async function prepareTurnAttachments(input: { | ... | @@ -272,11 +329,18 @@ export async function prepareTurnAttachments(input: { |
| 272 | : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl); | 329 | : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl); |
| 273 | }, | 330 | }, |
| 274 | transport: (url) => ({ | 331 | transport: (url) => ({ |
| 275 | done: uploadFileBytes(attachment, url), | 332 | done: uploadFileBytes( |
| 276 | // expo-file-system uploads cannot abort mid-flight. | 333 | attachment, |
| 277 | abort: () => {}, | 334 | url, |
| 335 | controller.signal, | ||
| 336 | input.onUploadProgress | ||
| 337 | ? (progress) => input.onUploadProgress?.(attachment.id, progress) | ||
| 338 | : undefined, | ||
| 339 | ), | ||
| 340 | abort, | ||
| 278 | }), | 341 | }), |
| 279 | onMinted: (attachmentId) => { | 342 | onMinted: (attachmentId) => { |
| 343 | if (controller.signal.aborted) return "cancel"; | ||
| 280 | pendingAttachmentIds.push(attachmentId); | 344 | pendingAttachmentIds.push(attachmentId); |
| 281 | createdAttachmentIds.push(attachmentId); | 345 | createdAttachmentIds.push(attachmentId); |
| 282 | return "continue"; | 346 | return "continue"; |
| ... | @@ -287,15 +351,11 @@ export async function prepareTurnAttachments(input: { | ... | @@ -287,15 +351,11 @@ export async function prepareTurnAttachments(input: { |
| 287 | ? result.error | 351 | ? result.error |
| 288 | : new Error(`Upload failed for '${attachment.name}'.`); | 352 | : new Error(`Upload failed for '${attachment.name}'.`); |
| 289 | } | 353 | } |
| 290 | uploadedAttachments.push({ | 354 | uploadedAttachments.push(uploadedReference(attachment, result.attachmentId)); |
| 291 | type: "file", | ||
| 292 | id: result.attachmentId, | ||
| 293 | name: attachment.name, | ||
| 294 | mimeType: attachment.mimeType, | ||
| 295 | sizeBytes: attachment.sizeBytes, | ||
| 296 | }); | ||
| 297 | } | 355 | } |
| 298 | 356 | ||
| 357 | if (controller.signal.aborted) throw new Error("Upload cancelled."); | ||
| 358 | |||
| 299 | const draftAttachments = withUploadedMobileAttachmentReferences({ | 359 | const draftAttachments = withUploadedMobileAttachmentReferences({ |
| 300 | environmentId, | 360 | environmentId, |
| 301 | attachments: input.attachments, | 361 | attachments: input.attachments, |
| ... | @@ -313,6 +373,9 @@ export async function prepareTurnAttachments(input: { | ... | @@ -313,6 +373,9 @@ export async function prepareTurnAttachments(input: { |
| 313 | return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments); | 373 | return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments); |
| 314 | } catch (error) { | 374 | } catch (error) { |
| 315 | await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); | 375 | await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); |
| 376 | if (controller.signal.aborted) return { status: "abandoned" }; | ||
| 316 | throw error; | 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,6 +9,8 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ |
| 9 | mimeType: Schema.String, | 9 | mimeType: Schema.String, |
| 10 | sizeBytes: Schema.Number, | 10 | sizeBytes: Schema.Number, |
| 11 | dataUrl: Schema.String, | 11 | dataUrl: Schema.String, |
| 12 | uploadedAttachmentId: Schema.optional(Schema.String), | ||
| 13 | uploadEnvironmentId: Schema.optional(EnvironmentId), | ||
| 12 | }); | 14 | }); |
| 13 | 15 | ||
| 14 | export const DraftComposerFileAttachmentSchema = Schema.Struct({ | 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,6 +23,8 @@ import { uuidv4 } from "./uuid"; |
| 23 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { | 23 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { |
| 24 | readonly id: string; | 24 | readonly id: string; |
| 25 | readonly previewUri: string; | 25 | readonly previewUri: string; |
| 26 | readonly uploadedAttachmentId?: string; | ||
| 27 | readonly uploadEnvironmentId?: EnvironmentId; | ||
| 26 | } | 28 | } |
| 27 | 29 | ||
| 28 | export interface DraftComposerFileAttachment { | 30 | export interface DraftComposerFileAttachment { |
apps/mobile/src/lib/projectThreadStartTurn.ts+2-3| ... | @@ -2,15 +2,14 @@ import { | ... | @@ -2,15 +2,14 @@ import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | MessageId, | 3 | MessageId, |
| 4 | ThreadId, | 4 | ThreadId, |
| 5 | type ChatFileAttachment, | ||
| 6 | type ModelSelection, | 5 | type ModelSelection, |
| 7 | type ProjectId, | 6 | type ProjectId, |
| 8 | type ProviderInteractionMode, | 7 | type ProviderInteractionMode, |
| 9 | type RuntimeMode, | 8 | type RuntimeMode, |
| 10 | type UploadChatImageAttachment, | ||
| 11 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 12 | 10 | ||
| 13 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; | 11 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; |
| 12 | import type { UploadedMobileAttachment } from "./attachmentUpload"; | ||
| 14 | 13 | ||
| 15 | export function deriveThreadTitleFromPrompt(value: string): string { | 14 | export function deriveThreadTitleFromPrompt(value: string): string { |
| 16 | const trimmed = value.trim(); | 15 | const trimmed = value.trim(); |
| ... | @@ -31,7 +30,7 @@ export interface ProjectThreadStartTurnSpec { | ... | @@ -31,7 +30,7 @@ export interface ProjectThreadStartTurnSpec { |
| 31 | readonly createdAt: string; | 30 | readonly createdAt: string; |
| 32 | readonly text: string; | 31 | readonly text: string; |
| 33 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; | 32 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 34 | readonly uploadedAttachments?: ReadonlyArray<UploadChatImageAttachment | ChatFileAttachment>; | 33 | readonly uploadedAttachments?: ReadonlyArray<UploadedMobileAttachment>; |
| 35 | readonly modelSelection: ModelSelection; | 34 | readonly modelSelection: ModelSelection; |
| 36 | readonly runtimeMode: RuntimeMode; | 35 | readonly runtimeMode: RuntimeMode; |
| 37 | readonly interactionMode: ProviderInteractionMode; | 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,10 +118,12 @@ import { appAtomRegistry } from "./atom-registry"; |
| 118 | import { threadOutboxManager } from "./thread-outbox"; | 118 | import { threadOutboxManager } from "./thread-outbox"; |
| 119 | import { | 119 | import { |
| 120 | appendComposerDraftAttachments, | 120 | appendComposerDraftAttachments, |
| 121 | archiveCloudComposerDrafts, | ||
| 121 | clearComposerDraftContentState, | 122 | clearComposerDraftContentState, |
| 122 | clearComposerDraftsEnvironment, | 123 | clearComposerDraftsEnvironment, |
| 123 | ComposerDraftPersistenceError, | 124 | ComposerDraftPersistenceError, |
| 124 | composerDraftsAtom, | 125 | composerDraftsAtom, |
| 126 | composerCloudDraftsAtom, | ||
| 125 | copyComposerDraftContentIfEmpty, | 127 | copyComposerDraftContentIfEmpty, |
| 126 | copyComposerDraftContentState, | 128 | copyComposerDraftContentState, |
| 127 | decodePersistedComposerState, | 129 | decodePersistedComposerState, |
| ... | @@ -136,7 +138,10 @@ import { | ... | @@ -136,7 +138,10 @@ import { |
| 136 | resetComposerDraftsLoadState, | 138 | resetComposerDraftsLoadState, |
| 137 | retainComposerAttachmentFileForPreview, | 139 | retainComposerAttachmentFileForPreview, |
| 138 | restoreComposerDraftSnapshotState, | 140 | restoreComposerDraftSnapshotState, |
| 141 | restoreCloudComposerDrafts, | ||
| 139 | setComposerDraftText, | 142 | setComposerDraftText, |
| 143 | setComposerDraftAttachmentUpload, | ||
| 144 | waitForComposerDraftsLoaded, | ||
| 140 | setStickyComposerModelSelection, | 145 | setStickyComposerModelSelection, |
| 141 | stickyComposerModelSelectionAtom, | 146 | stickyComposerModelSelectionAtom, |
| 142 | undoComposerDraftMerge, | 147 | undoComposerDraftMerge, |
| ... | @@ -157,6 +162,7 @@ afterEach(() => { | ... | @@ -157,6 +162,7 @@ afterEach(() => { |
| 157 | composerDraftFileMocks.setOnWrite(null); | 162 | composerDraftFileMocks.setOnWrite(null); |
| 158 | composerDraftFileMocks.resetWrites(); | 163 | composerDraftFileMocks.resetWrites(); |
| 159 | appAtomRegistry.set(composerDraftsAtom, {}); | 164 | appAtomRegistry.set(composerDraftsAtom, {}); |
| 165 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); | ||
| 160 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); | 166 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); |
| 161 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); | 167 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); |
| 162 | composerAttachmentCleanupMocks.remove.mockClear(); | 168 | composerAttachmentCleanupMocks.remove.mockClear(); |
| ... | @@ -320,6 +326,150 @@ describe("mobile composer drafts", () => { | ... | @@ -320,6 +326,150 @@ describe("mobile composer drafts", () => { |
| 320 | expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); | 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 | it("keeps a removed file until both playback and a share copy finish", async () => { | 473 | it("keeps a removed file until both playback and a share copy finish", async () => { |
| 324 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 474 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 325 | onTestFinished(() => outboxLoad.mockRestore()); | 475 | onTestFinished(() => outboxLoad.mockRestore()); |
apps/mobile/src/state/use-composer-drafts.ts+311-13| ... | @@ -23,7 +23,14 @@ import { | ... | @@ -23,7 +23,14 @@ import { |
| 23 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | 23 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; |
| 24 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; | 24 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 25 | import { appAtomRegistry } from "./atom-registry"; | 25 | import { appAtomRegistry } from "./atom-registry"; |
| 26 | import { | ||
| 27 | decodeQueuedThreadMessage, | ||
| 28 | encodeQueuedThreadMessage, | ||
| 29 | QueuedThreadMessageSchema, | ||
| 30 | type QueuedThreadMessage, | ||
| 31 | } from "./thread-outbox-model"; | ||
| 26 | import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; | 32 | import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; |
| 33 | import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; | ||
| 27 | 34 | ||
| 28 | const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; | 35 | const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; |
| 29 | const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; | 36 | const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; |
| ... | @@ -93,6 +100,16 @@ const PersistedComposerDraftsSchema = Schema.Struct({ | ... | @@ -93,6 +100,16 @@ const PersistedComposerDraftsSchema = Schema.Struct({ |
| 93 | schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), | 100 | schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), |
| 94 | drafts: Schema.Record(Schema.String, ComposerDraftSchema), | 101 | drafts: Schema.Record(Schema.String, ComposerDraftSchema), |
| 95 | stickyModelSelection: Schema.optional(ModelSelectionSchema), | 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 | const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( | 115 | const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( |
| ... | @@ -114,6 +131,21 @@ export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null> | ... | @@ -114,6 +131,21 @@ export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null> |
| 114 | Atom.withLabel("mobile:sticky-composer-model-selection"), | 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 | let loadPromise: Promise<void> | null = null; | 149 | let loadPromise: Promise<void> | null = null; |
| 118 | let persistTimer: ReturnType<typeof setTimeout> | null = null; | 150 | let persistTimer: ReturnType<typeof setTimeout> | null = null; |
| 119 | const persistenceQueue = new SerializedAsyncQueue(); | 151 | const persistenceQueue = new SerializedAsyncQueue(); |
| ... | @@ -156,6 +188,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { | ... | @@ -156,6 +188,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { |
| 156 | export function decodePersistedComposerState(value: unknown): { | 188 | export function decodePersistedComposerState(value: unknown): { |
| 157 | readonly drafts: Record<string, ComposerDraft>; | 189 | readonly drafts: Record<string, ComposerDraft>; |
| 158 | readonly stickyModelSelection: ModelSelection | null; | 190 | readonly stickyModelSelection: ModelSelection | null; |
| 191 | readonly cloudDrafts: ComposerCloudDraftState; | ||
| 159 | } { | 192 | } { |
| 160 | const parsed = decodePersistedComposerDraftsDocument(value); | 193 | const parsed = decodePersistedComposerDraftsDocument(value); |
| 161 | return { | 194 | return { |
| ... | @@ -188,6 +221,18 @@ export function decodePersistedComposerState(value: unknown): { | ... | @@ -188,6 +221,18 @@ export function decodePersistedComposerState(value: unknown): { |
| 188 | .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), | 221 | .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), |
| 189 | ), | 222 | ), |
| 190 | stickyModelSelection: parsed.stickyModelSelection ?? null, | 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,15 +247,18 @@ async function getComposerDraftsFile() { |
| 202 | return new File(directory, COMPOSER_DRAFTS_FILE); | 247 | return new File(directory, COMPOSER_DRAFTS_FILE); |
| 203 | } | 248 | } |
| 204 | 249 | ||
| 205 | async function loadPersistedComposerState(): Promise<{ | 250 | async function loadPersistedComposerState(): Promise< |
| 206 | readonly drafts: Record<string, ComposerDraft>; | 251 | ReturnType<typeof decodePersistedComposerState> |
| 207 | readonly stickyModelSelection: ModelSelection | null; | 252 | > { |
| 208 | }> { | ||
| 209 | let operation: ComposerDraftPersistenceError["operation"] = "open"; | 253 | let operation: ComposerDraftPersistenceError["operation"] = "open"; |
| 210 | try { | 254 | try { |
| 211 | const file = await getComposerDraftsFile(); | 255 | const file = await getComposerDraftsFile(); |
| 212 | if (!file.exists) { | 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 | operation = "read"; | 263 | operation = "read"; |
| 216 | const raw = await file.text(); | 264 | const raw = await file.text(); |
| ... | @@ -226,13 +274,18 @@ async function loadPersistedComposerState(): Promise<{ | ... | @@ -226,13 +274,18 @@ async function loadPersistedComposerState(): Promise<{ |
| 226 | cause, | 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 | async function writePersistedComposerState( | 285 | async function writePersistedComposerState( |
| 234 | drafts: Record<string, ComposerDraft>, | 286 | drafts: Record<string, ComposerDraft>, |
| 235 | stickyModelSelection: ModelSelection | null, | 287 | stickyModelSelection: ModelSelection | null, |
| 288 | cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), | ||
| 236 | ): Promise<void> { | 289 | ): Promise<void> { |
| 237 | let operation: ComposerDraftPersistenceError["operation"] = "open"; | 290 | let operation: ComposerDraftPersistenceError["operation"] = "open"; |
| 238 | try { | 291 | try { |
| ... | @@ -245,6 +298,20 @@ async function writePersistedComposerState( | ... | @@ -245,6 +298,20 @@ async function writePersistedComposerState( |
| 245 | schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, | 298 | schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, |
| 246 | drafts: nonEmptyDrafts, | 299 | drafts: nonEmptyDrafts, |
| 247 | ...(stickyModelSelection ? { stickyModelSelection } : {}), | 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 | } as const; | 315 | } as const; |
| 249 | const encoded = JSON.stringify(document); | 316 | const encoded = JSON.stringify(document); |
| 250 | operation = "write"; | 317 | operation = "write"; |
| ... | @@ -290,6 +357,13 @@ export async function flushComposerDrafts(): Promise<void> { | ... | @@ -290,6 +357,13 @@ export async function flushComposerDrafts(): Promise<void> { |
| 290 | } while (persistTimer !== null); | 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 | function isComposerAttachmentFileReferenced(fileUri: string): boolean { | 367 | function isComposerAttachmentFileReferenced(fileUri: string): boolean { |
| 294 | if (isComposerAttachmentFileRetained(fileUri)) { | 368 | if (isComposerAttachmentFileRetained(fileUri)) { |
| 295 | return true; | 369 | return true; |
| ... | @@ -299,7 +373,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean { | ... | @@ -299,7 +373,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean { |
| 299 | const queuedMessages = Object.values( | 373 | const queuedMessages = Object.values( |
| 300 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), | 374 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), |
| 301 | ).flat(); | 375 | ).flat(); |
| 302 | return [...drafts, ...queuedMessages].some((owner) => | 376 | return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => |
| 303 | owner.attachments.some( | 377 | owner.attachments.some( |
| 304 | (attachment) => | 378 | (attachment) => |
| 305 | attachment.type === "file" && | 379 | attachment.type === "file" && |
| ... | @@ -316,10 +390,9 @@ function isComposerAttachmentUploadReferenced( | ... | @@ -316,10 +390,9 @@ function isComposerAttachmentUploadReferenced( |
| 316 | const queuedMessages = Object.values( | 390 | const queuedMessages = Object.values( |
| 317 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), | 391 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), |
| 318 | ).flat(); | 392 | ).flat(); |
| 319 | return [...drafts, ...queuedMessages].some((owner) => | 393 | return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => |
| 320 | owner.attachments.some( | 394 | owner.attachments.some( |
| 321 | (attachment) => | 395 | (attachment) => |
| 322 | attachment.type === "file" && | ||
| 323 | attachment.uploadEnvironmentId === environmentId && | 396 | attachment.uploadEnvironmentId === environmentId && |
| 324 | attachment.uploadedAttachmentId === attachmentId, | 397 | attachment.uploadedAttachmentId === attachmentId, |
| 325 | ), | 398 | ), |
| ... | @@ -337,7 +410,6 @@ export async function releaseUnusedComposerAttachmentFiles( | ... | @@ -337,7 +410,6 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 337 | const uploadCandidates = new Map<EnvironmentId, Set<string>>(); | 410 | const uploadCandidates = new Map<EnvironmentId, Set<string>>(); |
| 338 | for (const attachment of attachments) { | 411 | for (const attachment of attachments) { |
| 339 | if ( | 412 | if ( |
| 340 | attachment.type !== "file" || | ||
| 341 | attachment.uploadEnvironmentId === undefined || | 413 | attachment.uploadEnvironmentId === undefined || |
| 342 | attachment.uploadedAttachmentId === undefined | 414 | attachment.uploadedAttachmentId === undefined |
| 343 | ) { | 415 | ) { |
| ... | @@ -347,7 +419,7 @@ export async function releaseUnusedComposerAttachmentFiles( | ... | @@ -347,7 +419,7 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 347 | ids.add(attachment.uploadedAttachmentId); | 419 | ids.add(attachment.uploadedAttachmentId); |
| 348 | uploadCandidates.set(attachment.uploadEnvironmentId, ids); | 420 | uploadCandidates.set(attachment.uploadEnvironmentId, ids); |
| 349 | } | 421 | } |
| 350 | if (candidates.size === 0) { | 422 | if (candidates.size === 0 && uploadCandidates.size === 0) { |
| 351 | return; | 423 | return; |
| 352 | } | 424 | } |
| 353 | 425 | ||
| ... | @@ -435,7 +507,11 @@ export async function releaseUnusedComposerAttachmentFiles( | ... | @@ -435,7 +507,11 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 435 | export function scheduleUnusedComposerAttachmentCleanup( | 507 | export function scheduleUnusedComposerAttachmentCleanup( |
| 436 | attachments: ReadonlyArray<DraftComposerAttachment>, | 508 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 437 | ): void { | 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 | return; | 515 | return; |
| 440 | } | 516 | } |
| 441 | void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => { | 517 | void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => { |
| ... | @@ -443,7 +519,7 @@ export function scheduleUnusedComposerAttachmentCleanup( | ... | @@ -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 | export function retainComposerAttachmentFileForPreview( | 523 | export function retainComposerAttachmentFileForPreview( |
| 448 | attachment: DraftComposerFileAttachment, | 524 | attachment: DraftComposerFileAttachment, |
| 449 | ): () => void { | 525 | ): () => void { |
| ... | @@ -484,6 +560,7 @@ export function ensureComposerDraftsLoaded(): void { | ... | @@ -484,6 +560,7 @@ export function ensureComposerDraftsLoaded(): void { |
| 484 | } | 560 | } |
| 485 | loadPromise = loadPersistedComposerState() | 561 | loadPromise = loadPersistedComposerState() |
| 486 | .then((persisted) => { | 562 | .then((persisted) => { |
| 563 | appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); | ||
| 487 | if (Object.keys(persisted.drafts).length > 0) { | 564 | if (Object.keys(persisted.drafts).length > 0) { |
| 488 | const current = appAtomRegistry.get(composerDraftsAtom); | 565 | const current = appAtomRegistry.get(composerDraftsAtom); |
| 489 | appAtomRegistry.set(composerDraftsAtom, { | 566 | appAtomRegistry.set(composerDraftsAtom, { |
| ... | @@ -520,6 +597,192 @@ export async function waitForComposerDraftsLoaded(): Promise<void> { | ... | @@ -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 | function updateComposerDrafts( | 786 | function updateComposerDrafts( |
| 524 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, | 787 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, |
| 525 | ): void { | 788 | ): void { |
| ... | @@ -655,6 +918,41 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) | ... | @@ -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 | export function updateComposerDraftSettings( | 956 | export function updateComposerDraftSettings( |
| 659 | draftKey: string, | 957 | draftKey: string, |
| 660 | settings: Partial<ComposerDraftSettingsUpdate>, | 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,6 +56,10 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox"; |
| 56 | import { useThreadOutboxMessages } from "./use-thread-outbox"; | 56 | import { useThreadOutboxMessages } from "./use-thread-outbox"; |
| 57 | import { threadEnvironment } from "./threads"; | 57 | import { threadEnvironment } from "./threads"; |
| 58 | import { useAtomCommand } from "./use-atom-command"; | 58 | import { useAtomCommand } from "./use-atom-command"; |
| 59 | import { | ||
| 60 | composerAttachmentUploadBlockReason, | ||
| 61 | composerAttachmentUploadsAtom, | ||
| 62 | } from "./composer-attachment-uploads"; | ||
| 59 | 63 | ||
| 60 | export function appendReviewCommentToDraft(input: { | 64 | export function appendReviewCommentToDraft(input: { |
| 61 | readonly environmentId: EnvironmentId; | 65 | readonly environmentId: EnvironmentId; |
| ... | @@ -178,6 +182,16 @@ export function useThreadComposerState() { | ... | @@ -178,6 +182,16 @@ export function useThreadComposerState() { |
| 178 | const thread = selectedThreadDetail ?? selectedThreadShell; | 182 | const thread = selectedThreadDetail ?? selectedThreadShell; |
| 179 | const text = draft.text.trim(); | 183 | const text = draft.text.trim(); |
| 180 | const attachments = draft.attachments; | 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 | if (text.length === 0 && attachments.length === 0) { | 195 | if (text.length === 0 && attachments.length === 0) { |
| 182 | return null; | 196 | return null; |
| 183 | } | 197 | } |
| ... | @@ -298,7 +312,8 @@ export function useThreadComposerState() { | ... | @@ -298,7 +312,8 @@ export function useThreadComposerState() { |
| 298 | ); | 312 | ); |
| 299 | return messageId; | 313 | return messageId; |
| 300 | }, [ | 314 | }, [ |
| 301 | selectedEnvironmentRuntime?.serverConfig?.providers, | 315 | selectedEnvironmentRuntime?.connectionState, |
| 316 | selectedEnvironmentRuntime?.serverConfig, | ||
| 302 | selectedThreadDetail, | 317 | selectedThreadDetail, |
| 303 | selectedThreadShell, | 318 | selectedThreadShell, |
| 304 | uploadThreadFeedback, | 319 | uploadThreadFeedback, |
apps/mobile/src/state/use-thread-outbox-drain.test.ts+67| ... | @@ -193,6 +193,7 @@ beforeEach(() => { | ... | @@ -193,6 +193,7 @@ beforeEach(() => { |
| 193 | afterEach(() => { | 193 | afterEach(() => { |
| 194 | appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); | 194 | appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); |
| 195 | appAtomRegistry.set(composerDrafts.composerDraftsAtom, {}); | 195 | appAtomRegistry.set(composerDrafts.composerDraftsAtom, {}); |
| 196 | appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); | ||
| 196 | appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); | 197 | appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); |
| 197 | harness.draftFile.setWriteError(null); | 198 | harness.draftFile.setWriteError(null); |
| 198 | harness.removePersistedFile.mockClear(); | 199 | harness.removePersistedFile.mockClear(); |
| ... | @@ -321,6 +322,72 @@ describe("thread outbox attachment preparation", () => { | ... | @@ -321,6 +322,72 @@ describe("thread outbox attachment preparation", () => { |
| 321 | }); | 322 | }); |
| 322 | 323 | ||
| 323 | describe("thread outbox drain delivery cleanup", () => { | 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 | it("retries only cleanup after an acknowledged send removal fails", async () => { | 391 | it("retries only cleanup after an acknowledged send removal fails", async () => { |
| 325 | const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" }); | 392 | const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" }); |
| 326 | const acknowledged = new Set([message.messageId]); | 393 | const acknowledged = new Set([message.messageId]); |
apps/mobile/src/state/use-thread-outbox-drain.ts+37-16| ... | @@ -52,6 +52,7 @@ import { | ... | @@ -52,6 +52,7 @@ import { |
| 52 | getComposerDraftSnapshot, | 52 | getComposerDraftSnapshot, |
| 53 | mergeComposerDraftContent, | 53 | mergeComposerDraftContent, |
| 54 | replaceComposerDraftAttachments, | 54 | replaceComposerDraftAttachments, |
| 55 | removeDeliveredCloudQueuedMessage, | ||
| 55 | undoComposerDraftMerge, | 56 | undoComposerDraftMerge, |
| 56 | updateComposerDraftSettings, | 57 | updateComposerDraftSettings, |
| 57 | waitForComposerDraftsLoaded, | 58 | waitForComposerDraftsLoaded, |
| ... | @@ -114,7 +115,10 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma | ... | @@ -114,7 +115,10 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma |
| 114 | * `deliveryRevision` is the revision of the payload this attempt will send, | 115 | * `deliveryRevision` is the revision of the payload this attempt will send, |
| 115 | * used for the delivery removal's compare-and-set. | 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 | readonly status: "ready"; | 123 | readonly status: "ready"; |
| 120 | readonly prepared: PreparedTurnAttachments; | 124 | readonly prepared: PreparedTurnAttachments; |
| ... | @@ -135,6 +139,7 @@ export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThrea | ... | @@ -135,6 +139,7 @@ export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThrea |
| 135 | const result = await prepareTurnAttachments({ | 139 | const result = await prepareTurnAttachments({ |
| 136 | environmentId: queuedMessage.environmentId, | 140 | environmentId: queuedMessage.environmentId, |
| 137 | attachments: queuedMessage.attachments, | 141 | attachments: queuedMessage.attachments, |
| 142 | supportsImageUploads, | ||
| 138 | persistUploadedReferences: async (draftAttachments) => { | 143 | persistUploadedReferences: async (draftAttachments) => { |
| 139 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { | 144 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { |
| 140 | return "abandon"; | 145 | return "abandon"; |
| ... | @@ -179,13 +184,19 @@ export async function completeQueuedMessageDelivery( | ... | @@ -179,13 +184,19 @@ export async function completeQueuedMessageDelivery( |
| 179 | queuedMessage: QueuedThreadMessage, | 184 | queuedMessage: QueuedThreadMessage, |
| 180 | deliveryRevision: number, | 185 | deliveryRevision: number, |
| 181 | ): Promise<"removed" | "edited" | "failed"> { | 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 | try { | 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 | // Removal also releases the message's local attachment files. | 200 | // Removal also releases the message's local attachment files. |
| 190 | const removed = await removeThreadOutboxMessage( | 201 | const removed = await removeThreadOutboxMessage( |
| 191 | queuedMessage, | 202 | queuedMessage, |
| ... | @@ -221,6 +232,12 @@ export async function removeAcknowledgedExistingThreadMessage( | ... | @@ -221,6 +232,12 @@ export async function removeAcknowledgedExistingThreadMessage( |
| 221 | acknowledgedMessageIds: Set<MessageId>, | 232 | acknowledgedMessageIds: Set<MessageId>, |
| 222 | ): Promise<boolean> { | 233 | ): Promise<boolean> { |
| 223 | try { | 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 | const removed = await removeThreadOutboxMessage(queuedMessage); | 241 | const removed = await removeThreadOutboxMessage(queuedMessage); |
| 225 | if (removed) { | 242 | if (removed) { |
| 226 | acknowledgedMessageIds.delete(queuedMessage.messageId); | 243 | acknowledgedMessageIds.delete(queuedMessage.messageId); |
| ... | @@ -453,15 +470,10 @@ async function preserveUploadedAttachmentsForEditor( | ... | @@ -453,15 +470,10 @@ async function preserveUploadedAttachmentsForEditor( |
| 453 | const draftKey = `pending-task:${originalMessage.messageId}`; | 470 | const draftKey = `pending-task:${originalMessage.messageId}`; |
| 454 | const draft = getComposerDraftSnapshot(draftKey); | 471 | const draft = getComposerDraftSnapshot(draftKey); |
| 455 | const uploadedById = new Map( | 472 | const uploadedById = new Map( |
| 456 | uploadedMessage.attachments | 473 | uploadedMessage.attachments.map((attachment) => [attachment.id, attachment] as const), |
| 457 | .filter((attachment) => attachment.type === "file") | ||
| 458 | .map((attachment) => [attachment.id, attachment] as const), | ||
| 459 | ); | 474 | ); |
| 460 | let changed = false; | 475 | let changed = false; |
| 461 | const nextAttachments = draft.attachments.map((attachment) => { | 476 | const nextAttachments = draft.attachments.map((attachment) => { |
| 462 | if (attachment.type !== "file") { | ||
| 463 | return attachment; | ||
| 464 | } | ||
| 465 | const uploaded = uploadedById.get(attachment.id); | 477 | const uploaded = uploadedById.get(attachment.id); |
| 466 | if ( | 478 | if ( |
| 467 | !uploaded?.uploadedAttachmentId || | 479 | !uploaded?.uploadedAttachmentId || |
| ... | @@ -672,7 +684,11 @@ export function useThreadOutboxDrain(): void { | ... | @@ -672,7 +684,11 @@ export function useThreadOutboxDrain(): void { |
| 672 | let persistedMessage: QueuedThreadMessage; | 684 | let persistedMessage: QueuedThreadMessage; |
| 673 | let deliveryRevision: number; | 685 | let deliveryRevision: number; |
| 674 | try { | 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 | if (preparedResult.status === "abandoned") { | 692 | if (preparedResult.status === "abandoned") { |
| 677 | return true; | 693 | return true; |
| 678 | } | 694 | } |
| ... | @@ -744,6 +760,7 @@ export function useThreadOutboxDrain(): void { | ... | @@ -744,6 +760,7 @@ export function useThreadOutboxDrain(): void { |
| 744 | startTurn, | 760 | startTurn, |
| 745 | updateThreadMetadata, | 761 | updateThreadMetadata, |
| 746 | restoreQueuedMessage, | 762 | restoreQueuedMessage, |
| 763 | serverConfigs, | ||
| 747 | ], | 764 | ], |
| 748 | ); | 765 | ); |
| 749 | 766 | ||
| ... | @@ -761,7 +778,11 @@ export function useThreadOutboxDrain(): void { | ... | @@ -761,7 +778,11 @@ export function useThreadOutboxDrain(): void { |
| 761 | let persistedMessage: QueuedThreadMessage; | 778 | let persistedMessage: QueuedThreadMessage; |
| 762 | let deliveryRevision: number; | 779 | let deliveryRevision: number; |
| 763 | try { | 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 | if (preparedResult.status === "abandoned") { | 786 | if (preparedResult.status === "abandoned") { |
| 766 | return true; | 787 | return true; |
| 767 | } | 788 | } |
| ... | @@ -838,7 +859,7 @@ export function useThreadOutboxDrain(): void { | ... | @@ -838,7 +859,7 @@ export function useThreadOutboxDrain(): void { |
| 838 | } | 859 | } |
| 839 | return false; | 860 | return false; |
| 840 | }, | 861 | }, |
| 841 | [makeDeliveryHelpers, restoreQueuedMessage, startTurn], | 862 | [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn], |
| 842 | ); | 863 | ); |
| 843 | 864 | ||
| 844 | useEffect(() => { | 865 | useEffect(() => { |
docs/internals/connection-runtime.md+13| ... | @@ -138,6 +138,19 @@ connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on | ... | @@ -138,6 +138,19 @@ connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on |
| 138 | removal the registry clears its cache and calls the platform implementation, so | 138 | removal the registry clears its cache and calls the platform implementation, so |
| 139 | web clears composer drafts and mobile clears drafts plus the thread outbox. | 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 | ## Source Boundaries | 154 | ## Source Boundaries |
| 142 | 155 | ||
| 143 | Applications must import explicit package subpaths; the package intentionally | 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 +13,16 @@ attach videos, text files, PDFs, ZIP archives, and other files. Each file can be |
| 13 | by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files | 13 | by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files |
| 14 | upload directly to the environment, where your agent can read, copy, or edit them by their file path. | 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 | 16 | Attachments upload as soon as you add them while connected to a server that supports uploads. |
| 17 | after every upload finishes. Failed uploads can be retried or removed. On mobile, tap **+** to open | 17 | The send button becomes available after every upload finishes. Failed uploads can be retried or |
| 18 | removed. On mobile, tap **+** to open | ||
| 18 | the photo library from either the compact or expanded composer. When the connected server supports | 19 | the photo library from either the compact or expanded composer. When the connected server supports |
| 19 | file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**. | 20 | file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**. |
| 20 | Videos use the server's file upload limit. You can also share photos, videos, and files into | 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 | T3 Code from other apps through the system share sheet. Mobile keeps a local copy of each draft |
| 22 | sends, so queued messages keep their files until they deliver. Select a received file on mobile | 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 | to preview it or open the system share options. | 26 | to preview it or open the system share options. |
| 24 | 27 | ||
| 25 | Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail | 28 | Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail |