authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-31 19:31:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 19:31:47-07:00
log9bc7a56848eb7c7546605e54ff02c6abea176f96
tree2eb6c3e22ef88f33631d8c549f85d736699cb358
parent85b656ff300f71060ad6305c7e1e29a72b442ce9
signature Signed by PGP key B5690EEEBB952194

feat(mobile): upload attachments while composing (#8978)

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";
7373import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
7474import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
7575import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
76import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads";
7677
7778const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
7879
......@@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string {
355356// each enqueue, shell change, or reconnect.
356357function ThreadOutboxDrainWorker() {
357358 useThreadOutboxDrain();
359 useComposerAttachmentUploadWorker();
358360 return null;
359361}
360362
apps/mobile/src/components/ComposerAttachmentStrip.tsx+50-2
......@@ -10,8 +10,14 @@ import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
1010import { PresentationSource } from "./NativePresentation";
1111import type { FilePreviewSource } from "./FilePreviewModal";
1212import { isPdfFile } from "../lib/filePreview";
13import type { EnvironmentId } from "@t3tools/contracts";
14import {
15 retryComposerAttachmentUpload,
16 useComposerAttachmentUploadState,
17} from "../state/composer-attachment-uploads";
1318
1419export interface ComposerAttachmentStripProps {
20 readonly environmentId?: EnvironmentId;
1521 /** Attachments to display. */
1622 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
1723 /** Called when the user removes an attachment. */
......@@ -30,7 +36,8 @@ export interface ComposerAttachmentStripProps {
3036 readonly removeButtonPlacement?: "overlay" | "gutter";
3137}
3238
33export function ComposerAttachmentThumbnail(props: {
39type ComposerAttachmentThumbnailProps = {
40 readonly environmentId?: EnvironmentId;
3441 readonly attachment: DraftComposerAttachment;
3542 readonly size: number;
3643 readonly borderRadius: number;
......@@ -40,7 +47,47 @@ export function ComposerAttachmentThumbnail(props: {
4047 attachment: DraftComposerFileAttachment,
4148 sourceIdentifier: string,
4249 ) => void;
43}) {
50};
51
52export 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
90function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {
4491 const { attachment } = props;
4592 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
4693 if (attachment.type === "image") {
......@@ -213,6 +260,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
213260 }}
214261 >
215262 <ComposerAttachmentThumbnail
263 environmentId={props.environmentId}
216264 attachment={attachment}
217265 size={size}
218266 borderRadius={radius}
apps/mobile/src/features/cloud/CloudAuthProvider.test.ts+6
......@@ -26,6 +26,12 @@ vi.mock("../../connection/catalog", () => ({
2626 },
2727}));
2828
29vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} }));
30vi.mock("../../state/use-composer-drafts", () => ({
31 getComposerCloudAccountId: vi.fn(async () => null),
32 restoreCloudComposerDrafts: vi.fn(async () => undefined),
33}));
34
2935vi.mock("./publicConfig", () => ({
3036 resolveCloudPublicConfig: vi.fn(() => ({
3137 clerk: { publishableKey: null },
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+52-30
......@@ -5,14 +5,18 @@ import {
55 reportAtomCommandResult,
66 settleAsyncResult,
77 settlePromise,
8 squashAtomCommandFailure,
89} from "@t3tools/client-runtime/state/runtime";
910import * as Effect from "effect/Effect";
1011import { type ReactNode, useEffect, useRef } from "react";
1112
12import { environmentCatalog } from "../../connection/catalog";
1313import { runtime } from "../../lib/runtime";
1414import { appAtomRegistry } from "../../state/atom-registry";
1515import { useAtomCommand } from "../../state/use-atom-command";
16import {
17 getComposerCloudAccountId,
18 restoreCloudComposerDrafts,
19} from "../../state/use-composer-drafts";
1620import {
1721 releaseAgentAwarenessRelayTokenProvider,
1822 setAgentAwarenessRelayTokenProvider,
......@@ -20,6 +24,7 @@ import {
2024} from "../agent-awareness/remoteRegistration";
2125import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
2226import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
27import { removeCloudEnvironments } from "./cloud-drafts";
2328
2429function resetManagedRelayTokenCache() {
2530 return settleAsyncResult(() =>
......@@ -47,7 +52,7 @@ export function activateCloudRelayAccount(
4752
4853function CloudAuthBridge(props: { readonly children: ReactNode }) {
4954 const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
50 const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, {
55 const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, {
5156 reportFailure: false,
5257 reportDefect: false,
5358 });
......@@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
8186 clearConnectOnboardingRequest();
8287 }
8388
84 const queueAccountCleanup = (
89 const cleanUpAccount = async (
8590 previous: {
8691 readonly userId: string;
8792 readonly provider: () => Promise<string | null>;
8893 } | null,
94 accountId: string | null,
8995 ) => {
90 const previousTransition = accountTransitionRef.current ?? Promise.resolve();
91 accountTransitionRef.current = previousTransition.then(async () => {
92 const cleanup = [
93 resetManagedRelayTokenCache(),
94 removeRelayEnvironments(),
95 ...(previous
96 ? [
97 settleAsyncResult(() =>
98 runtime.runPromiseExit(
99 unregisterAgentAwarenessDeviceForCurrentUser(previous.provider),
100 ),
96 const removal = await removeRelayEnvironments(accountId);
97 if (removal._tag !== "Success") throw squashAtomCommandFailure(removal);
98 const cleanup = [
99 resetManagedRelayTokenCache(),
100 ...(previous
101 ? [
102 settleAsyncResult(() =>
103 runtime.runPromiseExit(
104 unregisterAgentAwarenessDeviceForCurrentUser(previous.provider),
101105 ),
102 ]
103 : []),
104 ];
105 const results = await Promise.all(cleanup);
106 for (const result of results) {
107 reportAtomCommandResult(result, { label: "cloud account cleanup" });
108 }
109 });
106 ),
107 ]
108 : []),
109 ];
110 const results = await Promise.all(cleanup);
111 for (const result of results) {
112 reportAtomCommandResult(result, { label: "cloud account cleanup" });
113 }
114 };
115 const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => {
116 const previousTransition = accountTransitionRef.current ?? Promise.resolve();
117 accountTransitionRef.current = previousTransition
118 .catch(() => {})
119 .then(() => cleanUpAccount(previous, previousObservedAccount ?? null));
110120 return accountTransitionRef.current;
111121 };
112122
......@@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
115125 previousTokenProviderRef.current = null;
116126 deactivateCloudRelayAccount();
117127 if (previousObservedAccount !== null) {
118 void queueAccountCleanup(previous);
128 void settlePromise(() => queueAccountCleanup(previous)).then((result) => {
129 reportAtomCommandResult(result, { label: "cloud account cleanup" });
130 });
119131 }
120132 return;
121133 }
......@@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
133145 }
134146 };
135147 const activateAfterTransition = (transition: Promise<void>) => {
136 void (async () => {
137 const result = await settlePromise(async () => {
138 await transition;
139 activateSession();
140 });
141 reportAtomCommandResult(result, { label: "cloud account activation" });
148 const activation = (async () => {
149 await transition;
150 if (cancelled) return;
151 const storedAccount = await getComposerCloudAccountId();
152 if (storedAccount !== null && storedAccount !== userId) {
153 await cleanUpAccount(null, storedAccount);
154 }
155 if (cancelled) return;
156 await restoreCloudComposerDrafts(userId);
157 activateSession();
142158 })();
159 accountTransitionRef.current = activation;
160 void settlePromise(() => activation).then((result) => {
161 reportAtomCommandResult(result, { label: "cloud account activation" });
162 });
143163 };
144164 if (
145165 previousObservedAccount !== undefined &&
......@@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
150170 deactivateCloudRelayAccount();
151171 activateAfterTransition(queueAccountCleanup(previous));
152172 } 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(() => {}));
154176 }
155177
156178 return () => {
apps/mobile/src/features/cloud/cloud-drafts.ts created+46
......@@ -0,0 +1,46 @@
1import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import * as SubscriptionRef from "effect/SubscriptionRef";
6
7import { connectionAtomRuntime } from "../../connection/runtime";
8import { archiveCloudComposerDrafts } from "../../state/use-composer-drafts";
9
10export 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
23export 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 @@
1import { useAtomValue } from "@effect/atom-react";
12import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
23import {
34 CommonActions,
......@@ -35,6 +36,10 @@ import {
3536import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
3637import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
3738import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
39import {
40 composerAttachmentUploadBlockReason,
41 composerAttachmentUploadsAtom,
42} from "../../state/composer-attachment-uploads";
3843import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
3944import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
4045import { ProviderIcon } from "../../components/ProviderIcon";
......@@ -164,6 +169,16 @@ export function NewTaskDraftScreen(props: {
164169 connectedEnvironments.find(
165170 (environment) => environment.environmentId === selectedProject.environmentId,
166171 )?.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;
167182 const promptInputRef = useRef<ComposerEditorHandle>(null);
168183 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
169184 const [isComposerFocused, setIsComposerFocused] = useState(false);
......@@ -863,6 +878,7 @@ export function NewTaskDraftScreen(props: {
863878 const initialMessageText = draft.text.trim();
864879
865880 if (
881 attachmentBlockReason !== null ||
866882 !modelSelection ||
867883 initialMessageText.length === 0 ||
868884 flow.submitting ||
......@@ -1017,6 +1033,7 @@ export function NewTaskDraftScreen(props: {
10171033
10181034 const isAndroid = Platform.OS === "android";
10191035 const canStart =
1036 attachmentBlockReason === null &&
10201037 Boolean(flow.selectedProject) &&
10211038 Boolean(flow.selectedModel) &&
10221039 flow.prompt.trim().length > 0 &&
......@@ -1216,6 +1233,7 @@ export function NewTaskDraftScreen(props: {
12161233 {flow.attachments.length > 0 ? (
12171234 <View className="px-[14px] pb-2.5">
12181235 <ComposerAttachmentStrip
1236 environmentId={selectedProject.environmentId}
12191237 attachments={flow.attachments}
12201238 imageBorderRadius={16}
12211239 imageSize={72}
......@@ -1317,11 +1335,12 @@ export function NewTaskDraftScreen(props: {
13171335 {voicePresentation.showsSend ? (
13181336 <ComposerActionButton
13191337 accessibilityLabel={
1320 flow.submitting
1338 attachmentBlockReason ??
1339 (flow.submitting
13211340 ? "Starting task"
13221341 : environmentConnected
13231342 ? "Start task"
1324 : "Queue task"
1343 : "Queue task")
13251344 }
13261345 disabled={!canStart}
13271346 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
apps/mobile/src/features/threads/ThreadComposer.tsx+19-4
......@@ -1,3 +1,4 @@
1import { useAtomValue } from "@effect/atom-react";
12import type {
23 EnvironmentId,
34 MessageId,
......@@ -21,6 +22,10 @@ import {
2122} from "react";
2223import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
2324import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
25import {
26 composerAttachmentUploadBlockReason,
27 composerAttachmentUploadsAtom,
28} from "../../state/composer-attachment-uploads";
2429import Animated, {
2530 FadeIn,
2631 FadeInDown,
......@@ -365,7 +370,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
365370 const isExpanded = isFocused || settingsSheetPresentation.isActive;
366371 const showsCompactDictation = isVoiceInputPresented && !isExpanded;
367372 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;
369382
370383 // Keep the feed inset aligned with the card or compact dictation strip.
371384 useEffect(() => {
......@@ -617,6 +630,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
617630 exiting={FadeOut.duration(120)}
618631 >
619632 <ComposerAttachmentStrip
633 environmentId={props.environmentId}
620634 attachments={props.draftAttachments}
621635 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}
622636 onPressPreview={voiceInput.isBusy ? undefined : onPressPreview}
......@@ -668,6 +682,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
668682 <View className="flex-row gap-1 pl-1">
669683 {props.draftAttachments.slice(0, 3).map((attachment) => (
670684 <ComposerAttachmentThumbnail
685 environmentId={props.environmentId}
671686 key={attachment.id}
672687 attachment={attachment}
673688 size={30}
......@@ -703,10 +718,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
703718 />
704719 ) : (
705720 <ComposerActionButton
706 accessibilityLabel={sendLabel}
721 accessibilityLabel={attachmentBlockReason ?? sendLabel}
707722 icon="arrow.up"
708723 variant="primary"
709 disabled={!hasContent}
724 disabled={!canSend}
710725 onPress={handleSend}
711726 />
712727 )}
......@@ -794,7 +809,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
794809 />
795810 ) : voicePresentation.showsSend ? (
796811 <ComposerActionButton
797 accessibilityLabel={sendLabel}
812 accessibilityLabel={attachmentBlockReason ?? sendLabel}
798813 icon="arrow.up"
799814 variant="primary"
800815 disabled={!canSend}
apps/mobile/src/features/threads/use-project-actions.ts+3
......@@ -85,6 +85,9 @@ export function useCreateProjectThread() {
8585 prepared = await prepareTurnAttachments({
8686 environmentId: input.project.environmentId,
8787 attachments: input.initialAttachments,
88 supportsImageUploads:
89 appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId))
90 ?.environment.capabilities.attachmentUploads === true,
8891 persistUploadedReferences: async (draftAttachments) => {
8992 await input.onAttachmentsUploaded(draftAttachments);
9093 return "persisted";
apps/mobile/src/lib/attachmentUpload.test.ts+136-3
......@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
1212 runAtomCommand: vi.fn(),
1313 readAtom: vi.fn(),
1414 upload: vi.fn(),
15 writeFile: vi.fn(),
16 deleteFile: vi.fn(),
1517}));
1618
1719vi.mock("@t3tools/client-runtime/state/runtime", () => ({
......@@ -53,13 +55,25 @@ vi.mock("./uuid", () => ({
5355
5456vi.mock("expo-file-system", () => ({
5557 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 }
5770
5871 upload(url: string, options: unknown) {
5972 return mocks.upload(this.uri, url, options);
6073 }
6174 },
6275 Paths: {
76 cache: "file:///cache",
6377 get document() {
6478 return { uri: mocks.documentUri };
6579 },
......@@ -158,6 +172,8 @@ describe("prepareTurnAttachments", () => {
158172 mocks.runAtomCommand.mockReset();
159173 mocks.readAtom.mockReset();
160174 mocks.upload.mockReset();
175 mocks.writeFile.mockReset();
176 mocks.deleteFile.mockReset();
161177 mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));
162178 mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>
163179 command === mocks.createUploadUrl
......@@ -198,11 +214,11 @@ describe("prepareTurnAttachments", () => {
198214 expect(mocks.upload).toHaveBeenCalledWith(
199215 "file:///documents/report.pdf",
200216 "https://environment.example/api/attachments/upload/signed",
201 {
217 expect.objectContaining({
202218 httpMethod: "POST",
203219 uploadType: 0,
204220 headers: { "Content-Type": "application/pdf" },
205 },
221 }),
206222 );
207223 expect(prepared.status).toBe("ready");
208224 if (prepared.status !== "ready") return;
......@@ -354,6 +370,123 @@ describe("prepareTurnAttachments", () => {
354370 expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]);
355371 });
356372
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
357490 it("removes pending uploads when the native HTTP request fails", async () => {
358491 mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} });
359492
apps/mobile/src/lib/attachmentUpload.ts+103-40
......@@ -9,9 +9,11 @@ import {
99import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
1010import type {
1111 ChatFileAttachment,
12 ChatImageAttachment,
1213 EnvironmentId,
1314 UploadChatImageAttachment,
1415} from "@t3tools/contracts";
16import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts";
1517import * as Option from "effect/Option";
1618
1719import { appAtomRegistry } from "../state/atom-registry";
......@@ -20,6 +22,7 @@ import { attachmentEnvironment } from "../state/attachments";
2022import { environmentSession } from "../state/session";
2123import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
2224import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
25import { uuidv4 } from "./uuid";
2326
2427/**
2528 * This module owns the server side of a composer attachment's lifecycle.
......@@ -31,7 +34,10 @@ import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./co
3134 * owned by `removeThreadOutboxMessage` / the composer draft mutators, which
3235 * release files through `releaseUnusedComposerAttachmentFiles`.
3336 */
34export type UploadedMobileAttachment = UploadChatImageAttachment | ChatFileAttachment;
37export type UploadedMobileAttachment =
38 | UploadChatImageAttachment
39 | ChatImageAttachment
40 | ChatFileAttachment;
3541
3642export function validateDraftFileAttachments(input: {
3743 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
......@@ -56,7 +62,7 @@ export function validateDraftFileAttachments(input: {
5662 return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null;
5763}
5864
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. */
6066export function withUploadedMobileAttachmentReferences(input: {
6167 readonly environmentId: EnvironmentId;
6268 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
......@@ -65,8 +71,9 @@ export function withUploadedMobileAttachmentReferences(input: {
6571 return input.attachments.map((attachment, index) => {
6672 const uploaded = input.uploadedAttachments[index];
6773 if (
68 attachment.type !== "file" ||
69 uploaded?.type !== "file" ||
74 !uploaded ||
75 !("id" in uploaded) ||
76 attachment.type !== uploaded.type ||
7077 (attachment.uploadedAttachmentId === uploaded.id &&
7178 attachment.uploadEnvironmentId === input.environmentId)
7279 ) {
......@@ -145,21 +152,73 @@ export type PrepareTurnAttachmentsResult =
145152 | PreparedTurnAttachments
146153 | { readonly status: "abandoned" };
147154
155function 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
168function 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
148182async function uploadFileBytes(
149 attachment: Extract<DraftComposerAttachment, { readonly type: "file" }>,
183 attachment: DraftComposerAttachment,
150184 url: string,
185 signal: AbortSignal,
186 onProgress?: (progress: number) => void,
151187): Promise<void> {
152188 const { File, Paths, UploadType } = await import("expo-file-system");
153 const fileUri =
154 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
155 attachment.fileUri;
156 const result = await new File(fileUri).upload(url, {
157 httpMethod: "POST",
158 uploadType: UploadType.BINARY_CONTENT,
159 headers: { "Content-Type": attachment.mimeType },
160 });
161 if (result.status < 200 || result.status >= 300) {
162 throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);
189 if (signal.aborted) throw new Error("Upload cancelled.");
190 const file =
191 attachment.type === "image"
192 ? new File(Paths.cache, `t3-upload-${uuidv4()}`)
193 : new File(
194 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
195 attachment.fileUri,
196 );
197 try {
198 if (attachment.type === "image") {
199 file.create();
200 file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), {
201 encoding: "base64",
202 });
203 }
204 const result = await file.upload(url, {
205 httpMethod: "POST",
206 uploadType: UploadType.BINARY_CONTENT,
207 headers: { "Content-Type": attachment.mimeType },
208 signal,
209 ...(onProgress
210 ? {
211 onProgress: ({ bytesSent, totalBytes }) => {
212 if (totalBytes > 0) onProgress(bytesSent / totalBytes);
213 },
214 }
215 : {}),
216 });
217 if (result.status < 200 || result.status >= 300) {
218 throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);
219 }
220 } finally {
221 if (attachment.type === "image" && file.exists) file.delete();
163222 }
164223}
165224
......@@ -176,11 +235,16 @@ async function uploadFileBytes(
176235export async function prepareTurnAttachments(input: {
177236 readonly environmentId: EnvironmentId;
178237 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;
179242 readonly persistUploadedReferences?: (
180243 draftAttachments: ReadonlyArray<DraftComposerAttachment>,
181244 ) => Promise<"persisted" | "abandon">;
182245}): Promise<PrepareTurnAttachmentsResult> {
183246 const { environmentId } = input;
247 if (input.signal?.aborted) return { status: "abandoned" };
184248 const files = input.attachments.filter((attachment) => attachment.type === "file");
185249 const ready = (
186250 attachments: ReadonlyArray<UploadedMobileAttachment>,
......@@ -194,7 +258,7 @@ export async function prepareTurnAttachments(input: {
194258 releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds),
195259 });
196260
197 if (files.length === 0) {
261 if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) {
198262 return ready(
199263 toUploadChatImageAttachments(
200264 input.attachments.filter((attachment) => attachment.type === "image"),
......@@ -214,9 +278,13 @@ export async function prepareTurnAttachments(input: {
214278 const uploadedAttachments: UploadedMobileAttachment[] = [];
215279 const pendingAttachmentIds: string[] = [];
216280 const createdAttachmentIds: string[] = [];
281 const controller = new AbortController();
282 const abort = () => controller.abort();
283 input.signal?.addEventListener("abort", abort, { once: true });
217284 try {
218285 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) {
220288 uploadedAttachments.push(...toUploadChatImageAttachments([attachment]));
221289 continue;
222290 }
......@@ -238,13 +306,7 @@ export async function prepareTurnAttachments(input: {
238306 }
239307 if (verification.status === "verified") {
240308 pendingAttachmentIds.push(attachment.uploadedAttachmentId);
241 uploadedAttachments.push({
242 type: "file",
243 id: attachment.uploadedAttachmentId,
244 name: attachment.name,
245 mimeType: attachment.mimeType,
246 sizeBytes: attachment.sizeBytes,
247 });
309 uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId));
248310 continue;
249311 }
250312 // "missing": the pending upload expired, upload the bytes again.
......@@ -255,12 +317,7 @@ export async function prepareTurnAttachments(input: {
255317 createUploadUrl: attachmentEnvironment.createUploadUrl,
256318 remove: attachmentEnvironment.remove,
257319 environmentId,
258 upload: {
259 type: "file",
260 name: attachment.name,
261 mimeType: attachment.mimeType,
262 sizeBytes: attachment.sizeBytes,
263 },
320 upload: attachmentUploadInput(attachment),
264321 // Read the connection at transfer time: the environment may have
265322 // reconnected on a new base URL since this cycle started.
266323 resolveUploadUrl: (relativeUrl) => {
......@@ -272,11 +329,18 @@ export async function prepareTurnAttachments(input: {
272329 : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl);
273330 },
274331 transport: (url) => ({
275 done: uploadFileBytes(attachment, url),
276 // expo-file-system uploads cannot abort mid-flight.
277 abort: () => {},
332 done: uploadFileBytes(
333 attachment,
334 url,
335 controller.signal,
336 input.onUploadProgress
337 ? (progress) => input.onUploadProgress?.(attachment.id, progress)
338 : undefined,
339 ),
340 abort,
278341 }),
279342 onMinted: (attachmentId) => {
343 if (controller.signal.aborted) return "cancel";
280344 pendingAttachmentIds.push(attachmentId);
281345 createdAttachmentIds.push(attachmentId);
282346 return "continue";
......@@ -287,15 +351,11 @@ export async function prepareTurnAttachments(input: {
287351 ? result.error
288352 : new Error(`Upload failed for '${attachment.name}'.`);
289353 }
290 uploadedAttachments.push({
291 type: "file",
292 id: result.attachmentId,
293 name: attachment.name,
294 mimeType: attachment.mimeType,
295 sizeBytes: attachment.sizeBytes,
296 });
354 uploadedAttachments.push(uploadedReference(attachment, result.attachmentId));
297355 }
298356
357 if (controller.signal.aborted) throw new Error("Upload cancelled.");
358
299359 const draftAttachments = withUploadedMobileAttachmentReferences({
300360 environmentId,
301361 attachments: input.attachments,
......@@ -313,6 +373,9 @@ export async function prepareTurnAttachments(input: {
313373 return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments);
314374 } catch (error) {
315375 await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);
376 if (controller.signal.aborted) return { status: "abandoned" };
316377 throw error;
378 } finally {
379 input.signal?.removeEventListener("abort", abort);
317380 }
318381}
apps/mobile/src/lib/composer-image-schema.ts+2
......@@ -9,6 +9,8 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({
99 mimeType: Schema.String,
1010 sizeBytes: Schema.Number,
1111 dataUrl: Schema.String,
12 uploadedAttachmentId: Schema.optional(Schema.String),
13 uploadEnvironmentId: Schema.optional(EnvironmentId),
1214});
1315
1416export const DraftComposerFileAttachmentSchema = Schema.Struct({
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts created+258
......@@ -0,0 +1,258 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 composerAttachmentUploadBlockReason,
6 composerAttachmentUploadKey,
7 composerDraftEnvironmentId,
8 createComposerAttachmentUploadQueue,
9 type ComposerAttachmentUploadRequest,
10 type ComposerAttachmentUploadState,
11} from "./composerAttachmentUploadQueue";
12
13const environmentId = EnvironmentId.make("environment-1");
14function 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
28describe("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
218describe("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 @@
1import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
2import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
3
4import type { DraftComposerAttachment } from "./composerImages";
5
6export interface ComposerAttachmentUploadRequest {
7 readonly environmentId: EnvironmentId;
8 readonly attachment: DraftComposerAttachment;
9}
10
11export type ComposerAttachmentUploadState =
12 | { readonly status: "uploading"; readonly progress: number }
13 | { readonly status: "ready" }
14 | { readonly status: "failed"; readonly reason: string };
15
16export function composerAttachmentUploadKey(
17 environmentId: EnvironmentId,
18 attachmentId: string,
19): string {
20 return `${environmentId}:${attachmentId}`;
21}
22
23export 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
41type UploadServerConfig = {
42 readonly environment: {
43 readonly capabilities: Pick<
44 ServerConfig["environment"]["capabilities"],
45 "attachmentUploads" | "fileAttachments"
46 >;
47 };
48};
49
50export 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
64export 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. */
82export 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";
2323export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
2424 readonly id: string;
2525 readonly previewUri: string;
26 readonly uploadedAttachmentId?: string;
27 readonly uploadEnvironmentId?: EnvironmentId;
2628}
2729
2830export interface DraftComposerFileAttachment {
apps/mobile/src/lib/projectThreadStartTurn.ts+2-3
......@@ -2,15 +2,14 @@ import {
22 CommandId,
33 MessageId,
44 ThreadId,
5 type ChatFileAttachment,
65 type ModelSelection,
76 type ProjectId,
87 type ProviderInteractionMode,
98 type RuntimeMode,
10 type UploadChatImageAttachment,
119} from "@t3tools/contracts";
1210
1311import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
12import type { UploadedMobileAttachment } from "./attachmentUpload";
1413
1514export function deriveThreadTitleFromPrompt(value: string): string {
1615 const trimmed = value.trim();
......@@ -31,7 +30,7 @@ export interface ProjectThreadStartTurnSpec {
3130 readonly createdAt: string;
3231 readonly text: string;
3332 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
34 readonly uploadedAttachments?: ReadonlyArray<UploadChatImageAttachment | ChatFileAttachment>;
33 readonly uploadedAttachments?: ReadonlyArray<UploadedMobileAttachment>;
3534 readonly modelSelection: ModelSelection;
3635 readonly runtimeMode: RuntimeMode;
3736 readonly interactionMode: ProviderInteractionMode;
apps/mobile/src/state/composer-attachment-uploads.ts created+126
......@@ -0,0 +1,126 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { Atom } from "effect/unstable/reactivity";
4import { useEffect, useRef } from "react";
5
6import { prepareTurnAttachments } from "../lib/attachmentUpload";
7import {
8 composerAttachmentUploadKey,
9 composerDraftEnvironmentId,
10 canUploadComposerAttachment,
11 createComposerAttachmentUploadQueue,
12 type ComposerAttachmentUploadState,
13} from "../lib/composerAttachmentUploadQueue";
14import { appAtomRegistry } from "./atom-registry";
15import { useServerConfigs } from "./entities";
16import { flattenQueuedThreadMessages, threadOutboxManager } from "./thread-outbox";
17import { useThreadOutboxMessages } from "./use-thread-outbox";
18import {
19 composerDraftsAtom,
20 ensureComposerDraftsLoaded,
21 flushComposerDrafts,
22 retainComposerAttachmentFileForPreview,
23 setComposerDraftAttachmentUpload,
24} from "./use-composer-drafts";
25import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
26
27export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue";
28
29export const composerAttachmentUploadsAtom = Atom.make<
30 Readonly<Record<string, ComposerAttachmentUploadState>>
31>({}).pipe(Atom.keepAlive);
32const uploadStateAtom = Atom.family((key: string) =>
33 Atom.map(composerAttachmentUploadsAtom, (states) => states[key]),
34);
35let uploadQueue: ReturnType<typeof createComposerAttachmentUploadQueue> | null = null;
36
37export function useComposerAttachmentUploadState(
38 environmentId: EnvironmentId | undefined,
39 attachmentId: string,
40) {
41 return useAtomValue(
42 uploadStateAtom(environmentId ? composerAttachmentUploadKey(environmentId, attachmentId) : ""),
43 );
44}
45
46export 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. */
51export 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";
118118import { threadOutboxManager } from "./thread-outbox";
119119import {
120120 appendComposerDraftAttachments,
121 archiveCloudComposerDrafts,
121122 clearComposerDraftContentState,
122123 clearComposerDraftsEnvironment,
123124 ComposerDraftPersistenceError,
124125 composerDraftsAtom,
126 composerCloudDraftsAtom,
125127 copyComposerDraftContentIfEmpty,
126128 copyComposerDraftContentState,
127129 decodePersistedComposerState,
......@@ -136,7 +138,10 @@ import {
136138 resetComposerDraftsLoadState,
137139 retainComposerAttachmentFileForPreview,
138140 restoreComposerDraftSnapshotState,
141 restoreCloudComposerDrafts,
139142 setComposerDraftText,
143 setComposerDraftAttachmentUpload,
144 waitForComposerDraftsLoaded,
140145 setStickyComposerModelSelection,
141146 stickyComposerModelSelectionAtom,
142147 undoComposerDraftMerge,
......@@ -157,6 +162,7 @@ afterEach(() => {
157162 composerDraftFileMocks.setOnWrite(null);
158163 composerDraftFileMocks.resetWrites();
159164 appAtomRegistry.set(composerDraftsAtom, {});
165 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
160166 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
161167 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
162168 composerAttachmentCleanupMocks.remove.mockClear();
......@@ -320,6 +326,150 @@ describe("mobile composer drafts", () => {
320326 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
321327 });
322328
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
323473 it("keeps a removed file until both playback and a share copy finish", async () => {
324474 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
325475 onTestFinished(() => outboxLoad.mockRestore());
apps/mobile/src/state/use-composer-drafts.ts+311-13
......@@ -23,7 +23,14 @@ import {
2323import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
2424import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
2525import { appAtomRegistry } from "./atom-registry";
26import {
27 decodeQueuedThreadMessage,
28 encodeQueuedThreadMessage,
29 QueuedThreadMessageSchema,
30 type QueuedThreadMessage,
31} from "./thread-outbox-model";
2632import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox";
33import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue";
2734
2835const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;
2936const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";
......@@ -93,6 +100,16 @@ const PersistedComposerDraftsSchema = Schema.Struct({
93100 schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),
94101 drafts: Schema.Record(Schema.String, ComposerDraftSchema),
95102 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 ),
96113});
97114
98115const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(
......@@ -114,6 +131,21 @@ export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>
114131 Atom.withLabel("mobile:sticky-composer-model-selection"),
115132);
116133
134interface SignedOutDrafts {
135 readonly drafts: Record<string, ComposerDraft>;
136 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
137}
138
139interface ComposerCloudDraftState {
140 readonly accountId: string | null;
141 readonly signedOut: Record<string, SignedOutDrafts>;
142}
143
144export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({
145 accountId: null,
146 signedOut: {},
147}).pipe(Atom.keepAlive);
148
117149let loadPromise: Promise<void> | null = null;
118150let persistTimer: ReturnType<typeof setTimeout> | null = null;
119151const persistenceQueue = new SerializedAsyncQueue();
......@@ -156,6 +188,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
156188export function decodePersistedComposerState(value: unknown): {
157189 readonly drafts: Record<string, ComposerDraft>;
158190 readonly stickyModelSelection: ModelSelection | null;
191 readonly cloudDrafts: ComposerCloudDraftState;
159192} {
160193 const parsed = decodePersistedComposerDraftsDocument(value);
161194 return {
......@@ -188,6 +221,18 @@ export function decodePersistedComposerState(value: unknown): {
188221 .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0),
189222 ),
190223 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 },
191236 };
192237}
193238
......@@ -202,15 +247,18 @@ async function getComposerDraftsFile() {
202247 return new File(directory, COMPOSER_DRAFTS_FILE);
203248}
204249
205async function loadPersistedComposerState(): Promise<{
206 readonly drafts: Record<string, ComposerDraft>;
207 readonly stickyModelSelection: ModelSelection | null;
208}> {
250async function loadPersistedComposerState(): Promise<
251 ReturnType<typeof decodePersistedComposerState>
252> {
209253 let operation: ComposerDraftPersistenceError["operation"] = "open";
210254 try {
211255 const file = await getComposerDraftsFile();
212256 if (!file.exists) {
213 return { drafts: {}, stickyModelSelection: null };
257 return {
258 drafts: {},
259 stickyModelSelection: null,
260 cloudDrafts: { accountId: null, signedOut: {} },
261 };
214262 }
215263 operation = "read";
216264 const raw = await file.text();
......@@ -226,13 +274,18 @@ async function loadPersistedComposerState(): Promise<{
226274 cause,
227275 }),
228276 );
229 return { drafts: {}, stickyModelSelection: null };
277 return {
278 drafts: {},
279 stickyModelSelection: null,
280 cloudDrafts: { accountId: null, signedOut: {} },
281 };
230282 }
231283}
232284
233285async function writePersistedComposerState(
234286 drafts: Record<string, ComposerDraft>,
235287 stickyModelSelection: ModelSelection | null,
288 cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom),
236289): Promise<void> {
237290 let operation: ComposerDraftPersistenceError["operation"] = "open";
238291 try {
......@@ -245,6 +298,20 @@ async function writePersistedComposerState(
245298 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
246299 drafts: nonEmptyDrafts,
247300 ...(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 : {}),
248315 } as const;
249316 const encoded = JSON.stringify(document);
250317 operation = "write";
......@@ -290,6 +357,13 @@ export async function flushComposerDrafts(): Promise<void> {
290357 } while (persistTimer !== null);
291358}
292359
360function signedOutAttachmentOwners() {
361 return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [
362 ...Object.values(saved.drafts),
363 ...saved.queuedMessages,
364 ]);
365}
366
293367function isComposerAttachmentFileReferenced(fileUri: string): boolean {
294368 if (isComposerAttachmentFileRetained(fileUri)) {
295369 return true;
......@@ -299,7 +373,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean {
299373 const queuedMessages = Object.values(
300374 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
301375 ).flat();
302 return [...drafts, ...queuedMessages].some((owner) =>
376 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>
303377 owner.attachments.some(
304378 (attachment) =>
305379 attachment.type === "file" &&
......@@ -316,10 +390,9 @@ function isComposerAttachmentUploadReferenced(
316390 const queuedMessages = Object.values(
317391 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
318392 ).flat();
319 return [...drafts, ...queuedMessages].some((owner) =>
393 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>
320394 owner.attachments.some(
321395 (attachment) =>
322 attachment.type === "file" &&
323396 attachment.uploadEnvironmentId === environmentId &&
324397 attachment.uploadedAttachmentId === attachmentId,
325398 ),
......@@ -337,7 +410,6 @@ export async function releaseUnusedComposerAttachmentFiles(
337410 const uploadCandidates = new Map<EnvironmentId, Set<string>>();
338411 for (const attachment of attachments) {
339412 if (
340 attachment.type !== "file" ||
341413 attachment.uploadEnvironmentId === undefined ||
342414 attachment.uploadedAttachmentId === undefined
343415 ) {
......@@ -347,7 +419,7 @@ export async function releaseUnusedComposerAttachmentFiles(
347419 ids.add(attachment.uploadedAttachmentId);
348420 uploadCandidates.set(attachment.uploadEnvironmentId, ids);
349421 }
350 if (candidates.size === 0) {
422 if (candidates.size === 0 && uploadCandidates.size === 0) {
351423 return;
352424 }
353425
......@@ -435,7 +507,11 @@ export async function releaseUnusedComposerAttachmentFiles(
435507export function scheduleUnusedComposerAttachmentCleanup(
436508 attachments: ReadonlyArray<DraftComposerAttachment>,
437509): void {
438 if (!attachments.some((attachment) => attachment.type === "file")) {
510 if (
511 !attachments.some(
512 (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined,
513 )
514 ) {
439515 return;
440516 }
441517 void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => {
......@@ -443,7 +519,7 @@ export function scheduleUnusedComposerAttachmentCleanup(
443519 });
444520}
445521
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. */
447523export function retainComposerAttachmentFileForPreview(
448524 attachment: DraftComposerFileAttachment,
449525): () => void {
......@@ -484,6 +560,7 @@ export function ensureComposerDraftsLoaded(): void {
484560 }
485561 loadPromise = loadPersistedComposerState()
486562 .then((persisted) => {
563 appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts);
487564 if (Object.keys(persisted.drafts).length > 0) {
488565 const current = appAtomRegistry.get(composerDraftsAtom);
489566 appAtomRegistry.set(composerDraftsAtom, {
......@@ -520,6 +597,192 @@ export async function waitForComposerDraftsLoaded(): Promise<void> {
520597 }
521598}
522599
600export 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. */
606export 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
649function 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. */
660export 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. */
738export 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
523786function updateComposerDrafts(
524787 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,
525788): void {
......@@ -655,6 +918,41 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
655918 );
656919}
657920
921/** Stamps a finished upload without overwriting text, removals, or newer attachments. */
922export 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
658956export function updateComposerDraftSettings(
659957 draftKey: string,
660958 settings: Partial<ComposerDraftSettingsUpdate>,
apps/mobile/src/state/use-thread-composer-state.ts+16-1
......@@ -56,6 +56,10 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox";
5656import { useThreadOutboxMessages } from "./use-thread-outbox";
5757import { threadEnvironment } from "./threads";
5858import { useAtomCommand } from "./use-atom-command";
59import {
60 composerAttachmentUploadBlockReason,
61 composerAttachmentUploadsAtom,
62} from "./composer-attachment-uploads";
5963
6064export function appendReviewCommentToDraft(input: {
6165 readonly environmentId: EnvironmentId;
......@@ -178,6 +182,16 @@ export function useThreadComposerState() {
178182 const thread = selectedThreadDetail ?? selectedThreadShell;
179183 const text = draft.text.trim();
180184 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;
181195 if (text.length === 0 && attachments.length === 0) {
182196 return null;
183197 }
......@@ -298,7 +312,8 @@ export function useThreadComposerState() {
298312 );
299313 return messageId;
300314 }, [
301 selectedEnvironmentRuntime?.serverConfig?.providers,
315 selectedEnvironmentRuntime?.connectionState,
316 selectedEnvironmentRuntime?.serverConfig,
302317 selectedThreadDetail,
303318 selectedThreadShell,
304319 uploadThreadFeedback,
apps/mobile/src/state/use-thread-outbox-drain.test.ts+67
......@@ -193,6 +193,7 @@ beforeEach(() => {
193193afterEach(() => {
194194 appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {});
195195 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {});
196 appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} });
196197 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});
197198 harness.draftFile.setWriteError(null);
198199 harness.removePersistedFile.mockClear();
......@@ -321,6 +322,72 @@ describe("thread outbox attachment preparation", () => {
321322});
322323
323324describe("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
324391 it("retries only cleanup after an acknowledged send removal fails", async () => {
325392 const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" });
326393 const acknowledged = new Set([message.messageId]);
apps/mobile/src/state/use-thread-outbox-drain.ts+37-16
......@@ -52,6 +52,7 @@ import {
5252 getComposerDraftSnapshot,
5353 mergeComposerDraftContent,
5454 replaceComposerDraftAttachments,
55 removeDeliveredCloudQueuedMessage,
5556 undoComposerDraftMerge,
5657 updateComposerDraftSettings,
5758 waitForComposerDraftsLoaded,
......@@ -114,7 +115,10 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma
114115 * `deliveryRevision` is the revision of the payload this attempt will send,
115116 * used for the delivery removal's compare-and-set.
116117 */
117export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThreadMessage): Promise<
118export async function prepareQueuedMessageAttachments(
119 queuedMessage: QueuedThreadMessage,
120 supportsImageUploads = false,
121): Promise<
118122 | {
119123 readonly status: "ready";
120124 readonly prepared: PreparedTurnAttachments;
......@@ -135,6 +139,7 @@ export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThrea
135139 const result = await prepareTurnAttachments({
136140 environmentId: queuedMessage.environmentId,
137141 attachments: queuedMessage.attachments,
142 supportsImageUploads,
138143 persistUploadedReferences: async (draftAttachments) => {
139144 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
140145 return "abandon";
......@@ -179,13 +184,19 @@ export async function completeQueuedMessageDelivery(
179184 queuedMessage: QueuedThreadMessage,
180185 deliveryRevision: number,
181186): 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 }
188187 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 }
189200 // Removal also releases the message's local attachment files.
190201 const removed = await removeThreadOutboxMessage(
191202 queuedMessage,
......@@ -221,6 +232,12 @@ export async function removeAcknowledgedExistingThreadMessage(
221232 acknowledgedMessageIds: Set<MessageId>,
222233): Promise<boolean> {
223234 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 });
224241 const removed = await removeThreadOutboxMessage(queuedMessage);
225242 if (removed) {
226243 acknowledgedMessageIds.delete(queuedMessage.messageId);
......@@ -453,15 +470,10 @@ async function preserveUploadedAttachmentsForEditor(
453470 const draftKey = `pending-task:${originalMessage.messageId}`;
454471 const draft = getComposerDraftSnapshot(draftKey);
455472 const uploadedById = new Map(
456 uploadedMessage.attachments
457 .filter((attachment) => attachment.type === "file")
458 .map((attachment) => [attachment.id, attachment] as const),
473 uploadedMessage.attachments.map((attachment) => [attachment.id, attachment] as const),
459474 );
460475 let changed = false;
461476 const nextAttachments = draft.attachments.map((attachment) => {
462 if (attachment.type !== "file") {
463 return attachment;
464 }
465477 const uploaded = uploadedById.get(attachment.id);
466478 if (
467479 !uploaded?.uploadedAttachmentId ||
......@@ -672,7 +684,11 @@ export function useThreadOutboxDrain(): void {
672684 let persistedMessage: QueuedThreadMessage;
673685 let deliveryRevision: number;
674686 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 );
676692 if (preparedResult.status === "abandoned") {
677693 return true;
678694 }
......@@ -744,6 +760,7 @@ export function useThreadOutboxDrain(): void {
744760 startTurn,
745761 updateThreadMetadata,
746762 restoreQueuedMessage,
763 serverConfigs,
747764 ],
748765 );
749766
......@@ -761,7 +778,11 @@ export function useThreadOutboxDrain(): void {
761778 let persistedMessage: QueuedThreadMessage;
762779 let deliveryRevision: number;
763780 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 );
765786 if (preparedResult.status === "abandoned") {
766787 return true;
767788 }
......@@ -838,7 +859,7 @@ export function useThreadOutboxDrain(): void {
838859 }
839860 return false;
840861 },
841 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],
862 [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn],
842863 );
843864
844865 useEffect(() => {
docs/internals/connection-runtime.md+13
......@@ -138,6 +138,19 @@ connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on
138138removal the registry clears its cache and calls the platform implementation, so
139139web clears composer drafts and mobile clears drafts plus the thread outbox.
140140
141Mobile cloud sign-out first saves relay drafts and queued messages in the local
142composer store under the owning account. These saved copies retain attachment
143files during cleanup and remain outside the active composer and upload queue.
144Signing back into that account restores them before relay credentials activate.
145Directly paired environments keep their drafts and outbox when cloud sign-out runs.
146
147Mobile composer attachments upload over HTTP while their environment is connected,
148with at most three concurrent transfers. Drafts retain local image data or an owned
149file URI alongside the pending upload ID. Sending verifies and reuses that ID, or
150uploads the local bytes again if it expired. Disconnecting cancels active transfers
151without discarding drafts; reconnecting resumes preparation. Older servers without
152attachment-upload support continue to receive inline images.
153
141154## Source Boundaries
142155
143156Applications 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
1313by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files
1414upload directly to the environment, where your agent can read, copy, or edit them by their file path.
1515
16On web and desktop, attachments upload as soon as you add them. The send button becomes available
17after every upload finishes. Failed uploads can be retried or removed. On mobile, tap **+** to open
16Attachments upload as soon as you add them while connected to a server that supports uploads.
17The send button becomes available after every upload finishes. Failed uploads can be retried or
18removed. On mobile, tap **+** to open
1819the photo library from either the compact or expanded composer. When the connected server supports
1920file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**.
2021Videos use the server's file upload limit. You can also share photos, videos, and files into
21T3 Code from other apps through the system share sheet. Mobile uploads happen when the message
22sends, so queued messages keep their files until they deliver. Select a received file on mobile
22T3 Code from other apps through the system share sheet. Mobile keeps a local copy of each draft
23attachment, so you can still preview it and queue messages while offline. Uploads resume when
24you reconnect. Drafts and queued messages survive app restarts; signing out of T3 Connect keeps
25them on your device until you sign back into the same account. Select a received file on mobile
2326to preview it or open the system share options.
2427
2528Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail