authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-30 13:35:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 13:35:20-07:00
log86c9a9288b5704e79bbe68fb0834d56036456f3e
treea16c36d03439f2b1373d9ee6782c674731a206ce
parent7880a6e583f8a04db2eaeef2366ce9c81c3f7bec
signature Signed by PGP key B5690EEEBB952194

feat(mobile): pick, share, and receive files in threads (#8237)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

47 files changed, 7459 insertions(+), 297 deletions(-)

apps/mobile/app.config.ts+3-2
......@@ -148,12 +148,13 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
148148 supportsText: true,
149149 supportsWebUrlWithMaxCount: 1,
150150 supportsImageWithMaxCount: 8,
151 supportsFileWithMaxCount: 8,
151152 },
152153 },
153154 android: {
154155 enabled: true,
155 singleShareMimeTypes: ["text/plain", "image/*"],
156 multipleShareMimeTypes: ["image/*"],
156 singleShareMimeTypes: ["*/*"],
157 multipleShareMimeTypes: ["*/*"],
157158 },
158159 },
159160];
apps/mobile/src/components/ComposerAttachmentStrip.tsx+36-18
......@@ -1,12 +1,13 @@
11import { SymbolView } from "../components/AppSymbol";
22import { Image, Pressable, ScrollView, View } from "react-native";
33
4import type { DraftComposerImageAttachment } from "../lib/composerImages";
4import { AppText as Text } from "./AppText";
5import type { DraftComposerAttachment } from "../lib/composerImages";
56
67export interface ComposerAttachmentStripProps {
7 /** Attachment images to display. */
8 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
9 /** Called when the user taps the remove button on an image. */
8 /** Attachments to display. */
9 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
10 /** Called when the user removes an attachment. */
1011 readonly onRemove: (imageId: string) => void;
1112 /** Called when the user taps on an image thumbnail to preview it. */
1213 readonly onPressImage?: (previewUri: string) => void;
......@@ -19,8 +20,7 @@ export interface ComposerAttachmentStripProps {
1920}
2021
2122/**
22 * A horizontally-scrollable strip of image attachment thumbnails with remove
23 * buttons. Used by both the thread composer and the new-task draft screen.
23 * Attachment thumbnails used by the thread composer and the new-task draft screen.
2424 */
2525export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
2626 const size = props.imageSize ?? 72;
......@@ -40,29 +40,47 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
4040 className="grow-0"
4141 >
4242 <View className="flex-row gap-2.5">
43 {props.attachments.map((image) => (
43 {props.attachments.map((attachment) => (
4444 <View
45 key={image.id}
45 key={attachment.id}
4646 className="relative"
4747 style={{
4848 paddingTop: removeButtonGutter,
4949 paddingRight: removeButtonGutter,
5050 }}
5151 >
52 <Pressable
53 onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined}
54 >
55 <Image
56 source={{ uri: image.previewUri }}
57 className="bg-subtle"
52 {attachment.type === "image" ? (
53 <Pressable
54 onPress={
55 props.onPressImage ? () => props.onPressImage!(attachment.previewUri) : undefined
56 }
57 >
58 <Image
59 source={{ uri: attachment.previewUri }}
60 style={{
61 width: size,
62 height: size,
63 borderRadius: radius,
64 }}
65 className="bg-subtle"
66 resizeMode="cover"
67 />
68 </Pressable>
69 ) : (
70 <View
71 className="items-center justify-center gap-1 bg-subtle px-2"
5872 style={{
5973 width: size,
6074 height: size,
6175 borderRadius: radius,
6276 }}
63 resizeMode="cover"
64 />
65 </Pressable>
77 >
78 <SymbolView name="doc.text" size={22} tintColor="#a3a3a3" type="monochrome" />
79 <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
80 {attachment.name}
81 </Text>
82 </View>
83 )}
6684 <Pressable
6785 className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55"
6886 style={{
......@@ -70,7 +88,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
7088 right: removeButtonPlacement === "gutter" ? 0 : 4,
7189 }}
7290 hitSlop={6}
73 onPress={() => props.onRemove(image.id)}
91 onPress={() => props.onRemove(attachment.id)}
7492 >
7593 <SymbolView
7694 name="xmark"
apps/mobile/src/connection/platform.ts+1-1
......@@ -29,7 +29,7 @@ import { authClientMetadata } from "../lib/authClientMetadata";
2929import * as Runtime from "../lib/runtime";
3030import * as MobileStorage from "../persistence/mobile-storage";
3131import { appAtomRegistry } from "../state/atom-registry";
32import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
32import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal";
3333import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
3434import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
3535import { connectionStorageLayer } from "./storage";
apps/mobile/src/features/home/usePendingTaskListActions.ts+1-1
......@@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native";
22import { useCallback } from "react";
33import { Alert } from "react-native";
44
5import { removeThreadOutboxMessage } from "../../state/thread-outbox";
5import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
66import type { PendingNewTask } from "../../state/use-pending-new-tasks";
77import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
88
apps/mobile/src/features/sharing/IncomingShareProvider.tsx+52-12
......@@ -1,5 +1,6 @@
11import Constants from "expo-constants";
22import * as Crypto from "expo-crypto";
3import { PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts";
34import {
45 clearSharedPayloads,
56 getResolvedSharedPayloadsAsync,
......@@ -12,11 +13,13 @@ import { Alert, AppState, Platform } from "react-native";
1213
1314import {
1415 buildIncomingShareDraft,
16 isShareFileUriUnderOwnedRoots,
1517 type IncomingShareDestination,
1618 type IncomingShareDraft,
1719} from "./incoming-share-model";
1820import { createIncomingSharePayloadReader } from "./incoming-share-native";
1921import { IncomingShareInbox } from "./incoming-share-inbox";
22import { persistComposerAttachmentFile } from "../../lib/composerImages";
2023import {
2124 loadIncomingShareDrafts,
2225 removeIncomingShareDraft,
......@@ -54,7 +57,7 @@ const getIncomingSharePayloads = createIncomingSharePayloadReader({
5457 readPayloads: getSharedPayloads,
5558});
5659
57async function resolvedPayloadsForImages(): Promise<ReadonlyArray<ResolvedSharePayload>> {
60async function resolvedPayloadsForFiles(): Promise<ReadonlyArray<ResolvedSharePayload>> {
5861 try {
5962 return await getResolvedSharedPayloadsAsync();
6063 } catch (error) {
......@@ -84,12 +87,29 @@ async function readBase64(uri: string): Promise<string> {
8487 return new File(uri).base64();
8588}
8689
90async function readFileSize(uri: string): Promise<number | null> {
91 const { File } = await import("expo-file-system");
92 return new File(uri).size ?? null;
93}
94
8795async function removeOwnedFile(uri: string): Promise<void> {
8896 if (!uri.startsWith("file:")) {
8997 return;
9098 }
9199 try {
92 const { File } = await import("expo-file-system");
100 const { File, Paths } = await import("expo-file-system");
101 // Only delete files in directories this app owns: its documents and cache
102 // sandbox and its share-extension App Group container. An iOS
103 // open-in-place share points at the sender's own storage; deleting that
104 // URI would destroy the user's document.
105 const ownedRootUris = [
106 Paths.document.uri,
107 Paths.cache.uri,
108 ...Object.values(Paths.appleSharedContainers ?? {}).map((directory) => directory.uri),
109 ];
110 if (!isShareFileUriUnderOwnedRoots(uri, ownedRootUris)) {
111 return;
112 }
93113 const file = new File(uri);
94114 if (file.exists) {
95115 file.delete();
......@@ -99,21 +119,23 @@ async function removeOwnedFile(uri: string): Promise<void> {
99119 }
100120}
101121
102async function removeReplayedImagePayloadFiles(
103 payloads: ReadonlyArray<SharePayload>,
104): Promise<void> {
122async function removeReplayedPayloadFiles(payloads: ReadonlyArray<SharePayload>): Promise<void> {
105123 const uris = new Set<string>();
106124 for (const payload of payloads) {
107 if (payload.shareType === "image") {
125 if (["image", "file", "audio", "video"].includes(payload.shareType)) {
108126 uris.add(payload.value);
109127 }
110128 }
111129 if (uris.size === 0) {
112130 return;
113131 }
114 const resolvedPayloads = await resolvedPayloadsForImages();
132 const resolvedPayloads = payloads.some((payload) =>
133 ["file", "audio", "video"].includes(payload.shareType),
134 )
135 ? []
136 : await resolvedPayloadsForFiles();
115137 for (const payload of resolvedPayloads) {
116 if (payload.shareType === "image" && payload.contentUri) {
138 if (["image", "file", "audio", "video"].includes(payload.shareType) && payload.contentUri) {
117139 uris.add(payload.contentUri);
118140 }
119141 }
......@@ -131,14 +153,29 @@ const incomingShareInbox = new IncomingShareInbox({
131153 clearPayloads: clearSharedPayloads,
132154 buildDraft: async ({ payloads, id, createdAt }) => {
133155 const cleanupUris = new Set<string>();
134 const resolvedPayloads = payloads.some((payload) => payload.shareType === "image")
135 ? await resolvedPayloadsForImages()
136 : [];
156 const persistedUris = new Set<string>();
157 const hasGenericFilePayload = payloads.some((payload) =>
158 ["file", "audio", "video"].includes(payload.shareType),
159 );
160 const resolvedPayloads =
161 !hasGenericFilePayload && payloads.some((payload) => payload.shareType === "image")
162 ? await resolvedPayloadsForFiles()
163 : [];
137164 const draft = await buildIncomingShareDraft({
138165 payloads,
139166 resolvedPayloads,
140167 fileReader: {
141168 readBase64,
169 persistFile: async (uri, name) => {
170 const persistedUri = await persistComposerAttachmentFile(
171 uri,
172 name,
173 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
174 );
175 persistedUris.add(persistedUri);
176 return persistedUri;
177 },
178 readSize: readFileSize,
142179 removeOwnedFile: (uri) => {
143180 cleanupUris.add(uri);
144181 },
......@@ -151,9 +188,12 @@ const incomingShareInbox = new IncomingShareInbox({
151188 cleanup: async () => {
152189 await Promise.all([...cleanupUris].map(removeOwnedFile));
153190 },
191 rollback: async () => {
192 await Promise.all([...persistedUris].map(removeOwnedFile));
193 },
154194 };
155195 },
156 cleanupReplayedPayloads: removeReplayedImagePayloadFiles,
196 cleanupReplayedPayloads: removeReplayedPayloadFiles,
157197 idForPayloads: incomingShareIdForPayloads,
158198 now: () => new Date().toISOString(),
159199 onClearError: (error) => {
apps/mobile/src/features/sharing/incoming-share-inbox.test.ts+3
......@@ -136,11 +136,13 @@ describe("IncomingShareInbox", () => {
136136 it("does not acknowledge a supported payload when its durable write fails", async () => {
137137 const clearPayloads = vi.fn();
138138 const cleanup = vi.fn(async () => undefined);
139 const rollback = vi.fn(async () => undefined);
139140 const { inbox } = createHarness({
140141 clearPayloads,
141142 buildDraft: async ({ id, createdAt }) => ({
142143 draft: draft(id, createdAt),
143144 cleanup,
145 rollback,
144146 }),
145147 writeDraft: async () => {
146148 throw new Error("disk full");
......@@ -150,6 +152,7 @@ describe("IncomingShareInbox", () => {
150152 await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full");
151153 expect(clearPayloads).not.toHaveBeenCalled();
152154 expect(cleanup).not.toHaveBeenCalled();
155 expect(rollback).toHaveBeenCalledOnce();
153156 });
154157
155158 it("durably reserves a share for one project before draft import", async () => {
apps/mobile/src/features/sharing/incoming-share-inbox.ts+9-1
......@@ -20,6 +20,7 @@ export interface IncomingShareInboxDependencies {
2020 }) => Promise<{
2121 readonly draft: IncomingShareDraft;
2222 readonly cleanup: () => Promise<void>;
23 readonly rollback?: () => Promise<void>;
2324 }>;
2425 readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>;
2526 readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>;
......@@ -116,7 +117,14 @@ export class IncomingShareInbox {
116117 // The durable inbox write is the transaction boundary. Never clear the
117118 // native handoff first: a process termination must leave one recoverable
118119 // copy on one side of the boundary.
119 await this.dependencies.writeDraft(draft);
120 try {
121 await this.dependencies.writeDraft(draft);
122 } catch (error) {
123 if (built.rollback) {
124 await this.cleanup(built.rollback);
125 }
126 throw error;
127 }
120128 await this.cleanup(built.cleanup);
121129 this.clearNativePayloads();
122130 return sortAndDedupeIncomingShares([draft, ...persisted]);
apps/mobile/src/features/sharing/incoming-share-model.test.ts+518-1
......@@ -1,11 +1,18 @@
11import { describe, expect, it, vi } from "@effect/vitest";
22import {
33 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
45 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
56} from "@t3tools/contracts";
67import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
78
8import { buildIncomingShareDraft, hasIncomingShareContent } from "./incoming-share-model";
9import {
10 buildIncomingShareDraft,
11 hasIncomingShareContent,
12 isShareFileUriUnderOwnedRoots,
13 selectIncomingShareAttachments,
14 selectIncomingShareAttachmentsForServer,
15} from "./incoming-share-model";
916
1017describe("incoming native shares", () => {
1118 it("converts shared text, URLs, and images into a durable composer draft", async () => {
......@@ -96,6 +103,411 @@ describe("incoming native shares", () => {
96103 expect(hasIncomingShareContent(result)).toBe(false);
97104 });
98105
106 it("keeps a shared PDF on disk without converting its contents to base64", async () => {
107 const file: SharePayload = {
108 shareType: "file",
109 value: "file:///shared/report.pdf",
110 mimeType: "application/pdf",
111 };
112 const readBase64 = vi.fn(async () => "unused");
113 const persistFile = vi.fn(async () => "file:///documents/report.pdf");
114 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
115
116 const result = await buildIncomingShareDraft({
117 id: "share-report",
118 createdAt: "2026-07-15T10:00:00.000Z",
119 payloads: [file],
120 resolvedPayloads: [
121 {
122 ...file,
123 contentUri: file.value,
124 contentType: "file",
125 contentMimeType: "application/pdf",
126 contentSize: 42,
127 originalName: "report.pdf",
128 },
129 ],
130 fileReader: { readBase64, persistFile, removeOwnedFile },
131 });
132
133 expect(result.attachments).toEqual([
134 {
135 id: "share-report:file:0",
136 type: "file",
137 name: "report.pdf",
138 mimeType: "application/pdf",
139 sizeBytes: 42,
140 fileUri: "file:///documents/report.pdf",
141 },
142 ]);
143 expect(readBase64).not.toHaveBeenCalled();
144 expect(persistFile).toHaveBeenCalledWith(file.value, "report.pdf");
145 expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
146 });
147
148 it("rejects shared files that exceed the generic attachment limit", async () => {
149 const file: SharePayload = {
150 shareType: "file",
151 value: "file:///shared/huge.zip",
152 mimeType: "application/zip",
153 };
154 const persistFile = vi.fn(async () => "file:///documents/huge.zip");
155 const removeOwnedFile = vi.fn(async () => undefined);
156
157 const result = await buildIncomingShareDraft({
158 id: "share-huge",
159 createdAt: "2026-07-15T10:00:00.000Z",
160 payloads: [file],
161 resolvedPayloads: [
162 {
163 ...file,
164 contentUri: file.value,
165 contentType: "file",
166 contentMimeType: "application/zip",
167 contentSize: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1,
168 originalName: "huge.zip",
169 },
170 ],
171 fileReader: {
172 readBase64: async () => "unused",
173 persistFile,
174 removeOwnedFile,
175 },
176 });
177
178 expect(result.attachments).toEqual([]);
179 expect(result.warnings).toEqual(["'huge.zip' exceeds the 50 MB attachment limit."]);
180 expect(persistFile).not.toHaveBeenCalled();
181 expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
182 });
183
184 it("reports an unreadable shared file without calling it oversized", async () => {
185 const file: SharePayload = {
186 shareType: "file",
187 value: "file:///shared/empty.txt",
188 mimeType: "text/plain",
189 };
190
191 const result = await buildIncomingShareDraft({
192 id: "share-empty",
193 createdAt: "2026-07-15T10:00:00.000Z",
194 payloads: [file],
195 resolvedPayloads: [],
196 fileReader: {
197 readBase64: async () => "unused",
198 readSize: async () => 0,
199 removeOwnedFile: async () => undefined,
200 },
201 });
202
203 expect(result.attachments).toEqual([]);
204 expect(result.warnings).toEqual(["'empty.txt' is empty or could not be read."]);
205 });
206
207 it("reads an Android content URI's size after copying it into app-owned storage", async () => {
208 const file: SharePayload = {
209 shareType: "file",
210 value: "content://shared/report",
211 mimeType: "application/pdf",
212 };
213 const persistFile = vi.fn(async () => "file:///documents/report.pdf");
214 const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? null : 42));
215
216 const result = await buildIncomingShareDraft({
217 id: "share-android-report",
218 createdAt: "2026-07-15T10:00:00.000Z",
219 payloads: [file],
220 resolvedPayloads: [],
221 fileReader: {
222 readBase64: async () => "unused",
223 persistFile,
224 readSize,
225 removeOwnedFile: async () => undefined,
226 },
227 });
228
229 expect(result.attachments).toEqual([
230 {
231 id: "share-android-report:file:0",
232 type: "file",
233 name: "report",
234 mimeType: "application/pdf",
235 sizeBytes: 42,
236 fileUri: "file:///documents/report.pdf",
237 },
238 ]);
239 expect(readSize.mock.calls).toEqual([
240 ["content://shared/report"],
241 ["file:///documents/report.pdf"],
242 ]);
243 });
244
245 it("records the stored copy's measured size when a content URI under-reports", async () => {
246 const file: SharePayload = {
247 shareType: "file",
248 value: "content://shared/report",
249 mimeType: "application/pdf",
250 };
251 // The source claims 42 bytes but the stored copy measures 4200.
252 const persistFile = vi.fn(async () => "file:///documents/report.pdf");
253 const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? 42 : 4200));
254
255 const result = await buildIncomingShareDraft({
256 id: "share-android-report",
257 createdAt: "2026-07-15T10:00:00.000Z",
258 payloads: [file],
259 resolvedPayloads: [],
260 fileReader: {
261 readBase64: async () => "unused",
262 persistFile,
263 readSize,
264 removeOwnedFile: async () => undefined,
265 },
266 });
267
268 expect(result.attachments).toHaveLength(1);
269 expect(result.attachments[0]?.sizeBytes).toBe(4200);
270 });
271
272 it("treats a zero-length Android content URI as unknown until its copy is measured", async () => {
273 const file: SharePayload = {
274 shareType: "file",
275 value: "content://shared/report",
276 mimeType: "application/pdf",
277 };
278
279 const result = await buildIncomingShareDraft({
280 id: "share-zero-metadata",
281 createdAt: "2026-07-15T10:00:00.000Z",
282 payloads: [file],
283 resolvedPayloads: [],
284 fileReader: {
285 readBase64: async () => "unused",
286 persistFile: async () => "file:///documents/report.pdf",
287 readSize: async (uri) => (uri.startsWith("content:") ? 0 : 42),
288 removeOwnedFile: async () => undefined,
289 },
290 });
291
292 expect(result.attachments[0]?.sizeBytes).toBe(42);
293 expect(result.warnings).toEqual([]);
294 });
295
296 it("rejects a shared file whose persisted copy measures empty and releases the copy", async () => {
297 const file: SharePayload = {
298 shareType: "file",
299 value: "content://shared/report",
300 mimeType: "application/pdf",
301 };
302 const persistedUri = "file:///documents/report.pdf";
303 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
304
305 const result = await buildIncomingShareDraft({
306 id: "share-empty-copy",
307 createdAt: "2026-07-15T10:00:00.000Z",
308 payloads: [file],
309 resolvedPayloads: [],
310 fileReader: {
311 readBase64: async () => "unused",
312 persistFile: async () => persistedUri,
313 // The source claims 42 bytes but the stored copy measures zero: the
314 // copy is what uploads, so its measured size wins and the empty file
315 // is rejected instead of shipped with a made-up size.
316 readSize: async (uri) => (uri.startsWith("content:") ? 42 : 0),
317 removeOwnedFile,
318 },
319 });
320
321 expect(result.attachments).toEqual([]);
322 expect(result.warnings).toEqual(["'report' is empty or could not be read."]);
323 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri);
324 });
325
326 it("keeps the Android display name without copying the file into the Expo cache", async () => {
327 const file = {
328 shareType: "file" as const,
329 value: "content://shared/12345",
330 mimeType: "application/pdf",
331 originalName: "quarterly-report.pdf",
332 };
333
334 const result = await buildIncomingShareDraft({
335 id: "share-named-report",
336 createdAt: "2026-07-15T10:00:00.000Z",
337 payloads: [file],
338 resolvedPayloads: [],
339 fileReader: {
340 readBase64: async () => "unused",
341 readSize: async () => 42,
342 persistFile: async (_uri, name) => `file:///documents/${name}`,
343 removeOwnedFile: async () => undefined,
344 },
345 });
346
347 expect(result.attachments).toEqual([
348 {
349 id: "share-named-report:file:0",
350 type: "file",
351 name: "quarterly-report.pdf",
352 mimeType: "application/pdf",
353 sizeBytes: 42,
354 fileUri: "file:///documents/quarterly-report.pdf",
355 },
356 ]);
357 });
358
359 it("keeps a no-copy file source that the returned attachment still owns", async () => {
360 const sourceUri = "file:///documents/report.pdf";
361 const removeOwnedFile = vi.fn(async () => undefined);
362
363 const result = await buildIncomingShareDraft({
364 id: "share-no-copy",
365 createdAt: "2026-07-15T10:00:00.000Z",
366 payloads: [{ shareType: "file", value: sourceUri, mimeType: "application/pdf" }],
367 resolvedPayloads: [],
368 fileReader: {
369 readBase64: async () => "unused",
370 readSize: async () => 42,
371 removeOwnedFile,
372 },
373 });
374
375 expect(result.attachments[0]).toMatchObject({ type: "file", fileUri: sourceUri });
376 expect(removeOwnedFile).not.toHaveBeenCalled();
377 });
378
379 it("keeps a persisted copy and releases distinct temporary source URIs", async () => {
380 const payloadUri = "content://shared/report";
381 const resolvedUri = "file:///cache/report.pdf";
382 const persistedUri = "file:///documents/report.pdf";
383 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
384
385 const result = await buildIncomingShareDraft({
386 id: "share-copy",
387 createdAt: "2026-07-15T10:00:00.000Z",
388 payloads: [{ shareType: "file", value: payloadUri, mimeType: "application/pdf" }],
389 resolvedPayloads: [
390 {
391 shareType: "file",
392 value: payloadUri,
393 mimeType: "application/pdf",
394 contentUri: resolvedUri,
395 contentType: "file",
396 contentMimeType: "application/pdf",
397 contentSize: 42,
398 originalName: "report.pdf",
399 },
400 ],
401 fileReader: {
402 readBase64: async () => "unused",
403 readSize: async () => 42,
404 persistFile: async () => persistedUri,
405 removeOwnedFile,
406 },
407 });
408
409 expect(result.attachments[0]).toMatchObject({ type: "file", fileUri: persistedUri });
410 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toEqual([resolvedUri, payloadUri]);
411 });
412
413 it("keeps images and rejects shared files on servers without file support", () => {
414 const image = {
415 id: "image-1",
416 type: "image" as const,
417 name: "image.png",
418 mimeType: "image/png",
419 sizeBytes: 3,
420 dataUrl: "data:image/png;base64,YWJj",
421 previewUri: "data:image/png;base64,YWJj",
422 };
423 const file = {
424 id: "file-1",
425 type: "file" as const,
426 name: "report.pdf",
427 mimeType: "application/pdf",
428 sizeBytes: 42,
429 fileUri: "file:///documents/report.pdf",
430 };
431
432 expect(
433 selectIncomingShareAttachments({
434 attachments: [image, file],
435 maxFileAttachmentBytes: null,
436 }),
437 ).toEqual({
438 attachments: [image],
439 warnings: ["'report.pdf' was skipped because this server does not support files."],
440 });
441 });
442
443 it("uses the destination server's attachment limit in share warnings", () => {
444 const file = {
445 id: "file-1",
446 type: "file" as const,
447 name: "report.pdf",
448 mimeType: "application/pdf",
449 sizeBytes: 6 * 1024 * 1024,
450 fileUri: "file:///documents/report.pdf",
451 };
452
453 expect(
454 selectIncomingShareAttachments({
455 attachments: [file],
456 maxFileAttachmentBytes: 5 * 1024 * 1024,
457 }),
458 ).toEqual({
459 attachments: [],
460 warnings: ["'report.pdf' exceeds the 5 MB attachment limit."],
461 });
462 });
463
464 it("uses current server support and limits when selecting a reserved share", () => {
465 const file = {
466 id: "file-1",
467 type: "file" as const,
468 name: "report.pdf",
469 mimeType: "application/pdf",
470 sizeBytes: 6 * 1024 * 1024,
471 fileUri: "file:///documents/report.pdf",
472 };
473
474 expect(
475 selectIncomingShareAttachmentsForServer({ attachments: [file], serverConfig: null }),
476 ).toEqual({ status: "pending" });
477 expect(
478 selectIncomingShareAttachmentsForServer({
479 attachments: [file],
480 serverConfig: { environment: { capabilities: { attachmentUploads: true } } },
481 }),
482 ).toMatchObject({ status: "ready", attachments: [] });
483 expect(
484 selectIncomingShareAttachmentsForServer({
485 attachments: [file],
486 serverConfig: {
487 environment: {
488 capabilities: {
489 attachmentUploads: true,
490 fileAttachments: { maxUploadBytes: 5 * 1024 * 1024 },
491 },
492 },
493 },
494 }),
495 ).toMatchObject({ status: "ready", attachments: [] });
496 expect(
497 selectIncomingShareAttachmentsForServer({
498 attachments: [file],
499 serverConfig: {
500 environment: {
501 capabilities: {
502 attachmentUploads: true,
503 fileAttachments: { maxUploadBytes: 10 * 1024 * 1024 },
504 },
505 },
506 },
507 }),
508 ).toMatchObject({ status: "ready", attachments: [file] });
509 });
510
99511 it("releases every temporary file when a share exceeds the attachment limit", async () => {
100512 const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({
101513 shareType: "image" as const,
......@@ -193,4 +605,109 @@ describe("incoming native shares", () => {
193605 expect(result.attachments).toHaveLength(1);
194606 expect(result.warnings).toEqual([]);
195607 });
608
609 it("releases a persisted copy when a later step fails to read it", async () => {
610 const file: SharePayload = {
611 shareType: "file",
612 value: "content://shared/report",
613 mimeType: "application/pdf",
614 };
615 const persistedUri = "file:///documents/t3-composer-attachments/report.pdf";
616 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
617
618 const result = await buildIncomingShareDraft({
619 id: "share-persist-leak",
620 createdAt: "2026-07-16T08:00:00.000Z",
621 payloads: [file],
622 resolvedPayloads: [],
623 fileReader: {
624 readBase64: async () => "unused",
625 persistFile: async () => persistedUri,
626 readSize: async (uri) => {
627 if (uri === persistedUri) {
628 throw new Error("read failed");
629 }
630 return null;
631 },
632 removeOwnedFile,
633 },
634 });
635
636 expect(result.attachments).toEqual([]);
637 expect(result.warnings).toEqual(["read failed"]);
638 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri);
639 });
640});
641
642describe("share cleanup ownership", () => {
643 const ownedRoots = [
644 "file:///var/mobile/Containers/Data/Application/APP/Documents/",
645 "file:///var/mobile/Containers/Shared/AppGroup/GROUP",
646 ];
647
648 it("allows deleting files inside the app's own directories", () => {
649 expect(
650 isShareFileUriUnderOwnedRoots(
651 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/shared.pdf",
652 ownedRoots,
653 ),
654 ).toBe(true);
655 });
656
657 it("treats /private/var and /var as the same iOS location", () => {
658 expect(
659 isShareFileUriUnderOwnedRoots(
660 "file:///private/var/mobile/Containers/Shared/AppGroup/GROUP/shared.pdf",
661 ownedRoots,
662 ),
663 ).toBe(true);
664 expect(
665 isShareFileUriUnderOwnedRoots(
666 "file:///var/mobile/Containers/Data/Application/APP/Documents/t3-composer-attachments/a.pdf",
667 ["file:///private/var/mobile/Containers/Data/Application/APP/Documents/"],
668 ),
669 ).toBe(true);
670 });
671
672 it("refuses to delete a sender-owned open-in-place document", () => {
673 expect(
674 isShareFileUriUnderOwnedRoots(
675 "file:///private/var/mobile/Containers/Shared/FileProvider/OTHER/File%20Provider%20Storage/taxes.pdf",
676 ownedRoots,
677 ),
678 ).toBe(false);
679 });
680
681 it("refuses traversal segments that escape an owned root", () => {
682 // An encoded separator survives URL normalization: "..%2F.." decodes to
683 // "../..", so the lexical check must reject it before containment.
684 expect(
685 isShareFileUriUnderOwnedRoots(
686 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/..%2F..%2FsenderDoc.pdf",
687 ownedRoots,
688 ),
689 ).toBe(false);
690 expect(
691 isShareFileUriUnderOwnedRoots(
692 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/../senderDoc.pdf",
693 ownedRoots,
694 ),
695 ).toBe(false);
696 expect(
697 isShareFileUriUnderOwnedRoots(
698 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/%2e%2e/senderDoc.pdf",
699 ownedRoots,
700 ),
701 ).toBe(false);
702 });
703
704 it("refuses non-file URIs and the owned root itself", () => {
705 expect(isShareFileUriUnderOwnedRoots("content://shared/report", ownedRoots)).toBe(false);
706 expect(
707 isShareFileUriUnderOwnedRoots(
708 "file:///var/mobile/Containers/Shared/AppGroup/GROUP",
709 ownedRoots,
710 ),
711 ).toBe(false);
712 });
196713});
apps/mobile/src/features/sharing/incoming-share-model.ts+233-10
......@@ -1,13 +1,18 @@
1import {
2 clampFileAttachmentUploadBytes,
3 fileAttachmentTooLargeMessage,
4} from "@t3tools/client-runtime/state/attachments";
15import {
26 isProviderSendTurnSupportedImageMimeType,
37 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
49 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
510} from "@t3tools/contracts";
611import * as Schema from "effect/Schema";
712import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
813
9import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema";
10import type { DraftComposerImageAttachment } from "../../lib/composerImages";
14import { DraftComposerAttachmentSchema } from "../../lib/composer-image-schema";
15import type { DraftComposerAttachment } from "../../lib/composerImages";
1116import { estimateBase64ByteSize } from "../../lib/base64";
1217
1318export interface IncomingShareDraft {
......@@ -16,7 +21,7 @@ export interface IncomingShareDraft {
1621 readonly createdAt: string;
1722 readonly destination?: IncomingShareDestination;
1823 readonly text: string;
19 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
24 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
2025 readonly warnings: ReadonlyArray<string>;
2126}
2227
......@@ -36,7 +41,7 @@ export const IncomingShareDraftSchema = Schema.Struct({
3641 createdAt: Schema.String,
3742 destination: Schema.optional(IncomingShareDestinationSchema),
3843 text: Schema.String,
39 attachments: Schema.Array(DraftComposerImageAttachmentSchema),
44 attachments: Schema.Array(DraftComposerAttachmentSchema),
4045 warnings: Schema.Array(Schema.String),
4146});
4247
......@@ -46,9 +51,126 @@ export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft {
4651 return decodeIncomingShareDraftSync(value);
4752}
4853
54/**
55 * `file:` path with the iOS `/private` prefix stripped, so URIs that reach the
56 * same file through the `/var` symlink and through `/private/var` compare
57 * equal. Null for anything that is not a `file:` URI.
58 */
59function normalizedFileUriPath(uri: string): string | null {
60 try {
61 const url = new URL(uri);
62 if (url.protocol !== "file:") {
63 return null;
64 }
65 const path = decodeURIComponent(url.pathname);
66 // URL parsing collapses literal ".." segments, but an encoded separator
67 // survives it: "..%2F.." decodes to "../..", which the filesystem would
68 // resolve outside the root the lexical containment check accepted.
69 if (path.split("/").includes("..")) {
70 return null;
71 }
72 return path.startsWith("/private/var/") ? path.slice("/private".length) : path;
73 } catch {
74 return null;
75 }
76}
77
78/**
79 * Whether a shared `file:` URI points strictly inside one of the directories
80 * this app owns (its sandbox and its share-extension App Group container).
81 * Share cleanup must never delete anything else: an iOS open-in-place share
82 * hands over the sender's own file URL, and deleting it destroys the user's
83 * document.
84 */
85export function isShareFileUriUnderOwnedRoots(
86 uri: string,
87 ownedRootUris: ReadonlyArray<string>,
88): boolean {
89 const path = normalizedFileUriPath(uri);
90 if (path === null) {
91 return false;
92 }
93 return ownedRootUris.some((rootUri) => {
94 const rootPath = normalizedFileUriPath(rootUri);
95 if (rootPath === null) {
96 return false;
97 }
98 const root = rootPath.endsWith("/") ? rootPath : `${rootPath}/`;
99 return path.startsWith(root) && path.length > root.length;
100 });
101}
102
49103export interface IncomingShareFileReader {
50104 readonly readBase64: (uri: string) => Promise<string>;
51105 readonly removeOwnedFile: (uri: string) => Promise<void> | void;
106 readonly persistFile?: (uri: string, name: string) => Promise<string>;
107 readonly readSize?: (uri: string) => Promise<number | null>;
108}
109
110/** Apply the destination server's file support after the user chooses a project. */
111export function selectIncomingShareAttachments(input: {
112 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
113 readonly maxFileAttachmentBytes: number | null;
114}): {
115 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
116 readonly warnings: ReadonlyArray<string>;
117} {
118 const attachments: DraftComposerAttachment[] = [];
119 const warnings: string[] = [];
120
121 for (const attachment of input.attachments) {
122 if (attachment.type === "image") {
123 attachments.push(attachment);
124 continue;
125 }
126 if (input.maxFileAttachmentBytes === null) {
127 warnings.push(`'${attachment.name}' was skipped because this server does not support files.`);
128 continue;
129 }
130 const maxFileAttachmentBytes = clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes);
131 if (attachment.sizeBytes > maxFileAttachmentBytes) {
132 warnings.push(fileAttachmentTooLargeMessage(attachment.name, maxFileAttachmentBytes));
133 continue;
134 }
135 attachments.push(attachment);
136 }
137
138 return { attachments, warnings };
139}
140
141export function selectIncomingShareAttachmentsForServer(input: {
142 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
143 readonly serverConfig: {
144 readonly environment: {
145 readonly capabilities: {
146 readonly attachmentUploads?: boolean;
147 readonly fileAttachments?: { readonly maxUploadBytes: number };
148 };
149 };
150 } | null;
151}):
152 | { readonly status: "pending" }
153 | {
154 readonly status: "ready";
155 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
156 readonly warnings: ReadonlyArray<string>;
157 } {
158 const hasFiles = input.attachments.some((attachment) => attachment.type === "file");
159 if (hasFiles && input.serverConfig === null) {
160 return { status: "pending" };
161 }
162 const capabilities = input.serverConfig?.environment.capabilities;
163 const maxFileAttachmentBytes =
164 capabilities?.attachmentUploads === true
165 ? (capabilities.fileAttachments?.maxUploadBytes ?? null)
166 : null;
167 return {
168 status: "ready",
169 ...selectIncomingShareAttachments({
170 attachments: input.attachments,
171 maxFileAttachmentBytes,
172 }),
173 };
52174}
53175
54176function sharedText(payloads: ReadonlyArray<SharePayload>): string {
......@@ -119,8 +241,11 @@ function fallbackName(uri: string, index: number, mimeType: string): string {
119241 } catch {
120242 // Fall through to a deterministic attachment name.
121243 }
122 const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png";
123 return `shared-image-${index + 1}.${extension}`;
244 const family = mimeType.split("/")[0]?.toLowerCase();
245 const kind = family === "image" || family === "audio" || family === "video" ? family : "file";
246 const extension =
247 mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || (kind === "image" ? "png" : "bin");
248 return `shared-${kind}-${index + 1}.${extension}`;
124249}
125250
126251export async function buildIncomingShareDraft(input: {
......@@ -130,13 +255,18 @@ export async function buildIncomingShareDraft(input: {
130255 readonly id: string;
131256 readonly createdAt: string;
132257}): Promise<IncomingShareDraft> {
133 const attachments: DraftComposerImageAttachment[] = [];
258 const attachments: DraftComposerAttachment[] = [];
134259 const warnings: string[] = [];
135260 const consumedResolvedPayloadIndexes = new Set<number>();
136261 let warnedAttachmentLimit = false;
137262
138263 for (const [index, payload] of input.payloads.entries()) {
139 if (payload.shareType !== "image") {
264 if (
265 payload.shareType !== "image" &&
266 payload.shareType !== "file" &&
267 payload.shareType !== "audio" &&
268 payload.shareType !== "video"
269 ) {
140270 continue;
141271 }
142272 const resolved = resolvedImageFor(
......@@ -149,7 +279,7 @@ export async function buildIncomingShareDraft(input: {
149279 if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
150280 if (!warnedAttachmentLimit) {
151281 warnings.push(
152 `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`,
282 `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared ${payload.shareType === "image" ? "images" : "files"} were attached.`,
153283 );
154284 warnedAttachmentLimit = true;
155285 }
......@@ -157,7 +287,100 @@ export async function buildIncomingShareDraft(input: {
157287 continue;
158288 }
159289
160 const mimeType = (resolved?.contentMimeType ?? payload.mimeType ?? "image/png").toLowerCase();
290 const mimeType = (
291 resolved?.contentMimeType ??
292 payload.mimeType ??
293 (payload.shareType === "image" ? "image/png" : "application/octet-stream")
294 ).toLowerCase();
295 if (payload.shareType !== "image") {
296 // The patched native module never emits a blank display name, but keep
297 // the guard: an empty name would fail the attachment name contract.
298 const sharedFileName =
299 typeof payload.originalName === "string" && payload.originalName.trim().length > 0
300 ? payload.originalName
301 : undefined;
302 const name = resolved?.originalName ?? sharedFileName ?? fallbackName(uri, index, mimeType);
303 if (!uri) {
304 warnings.push("One shared file could not be read.");
305 continue;
306 }
307 let persistedFileUri: string | undefined;
308 let retainedFileUri: string | undefined;
309 try {
310 let sizeBytes = resolved?.contentSize ?? (await input.fileReader.readSize?.(uri)) ?? null;
311 if (
312 (sizeBytes === null || (sizeBytes === 0 && uri.startsWith("content:"))) &&
313 input.fileReader.persistFile
314 ) {
315 persistedFileUri = await input.fileReader.persistFile(uri, name);
316 sizeBytes = (await input.fileReader.readSize?.(persistedFileUri)) ?? null;
317 }
318 if (sizeBytes === null) {
319 warnings.push(`The size of '${name}' could not be determined.`);
320 if (persistedFileUri) {
321 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
322 }
323 continue;
324 }
325 if (sizeBytes <= 0) {
326 warnings.push(`'${name}' is empty or could not be read.`);
327 if (persistedFileUri) {
328 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
329 }
330 continue;
331 }
332 if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
333 warnings.push(fileAttachmentTooLargeMessage(name, PROVIDER_SEND_TURN_MAX_FILE_BYTES));
334 if (persistedFileUri) {
335 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
336 }
337 continue;
338 }
339 if (persistedFileUri === undefined && input.fileReader.persistFile) {
340 persistedFileUri = await input.fileReader.persistFile(uri, name);
341 // An Android content: source can misreport its size while the
342 // stored copy is what uploads, so the copy's measured size is what
343 // the attachment must record. A measured zero means the copy holds
344 // no bytes: reject it, whatever the source claimed.
345 const storedSize = (await input.fileReader.readSize?.(persistedFileUri)) ?? null;
346 if (storedSize !== null) {
347 sizeBytes = storedSize;
348 }
349 if (sizeBytes <= 0) {
350 warnings.push(`'${name}' is empty or could not be read.`);
351 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
352 continue;
353 }
354 if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
355 warnings.push(fileAttachmentTooLargeMessage(name, PROVIDER_SEND_TURN_MAX_FILE_BYTES));
356 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
357 continue;
358 }
359 }
360 attachments.push({
361 id: `${input.id}:file:${index}`,
362 type: "file",
363 name,
364 mimeType,
365 sizeBytes,
366 fileUri: persistedFileUri ?? uri,
367 });
368 retainedFileUri = persistedFileUri ?? uri;
369 } catch (error) {
370 warnings.push(error instanceof Error ? error.message : `Could not read '${name}'.`);
371 // A copy persisted before the failure has no attachment referencing
372 // it; release it or it leaks in the app's attachment directory.
373 if (persistedFileUri !== undefined) {
374 await releaseOwnedFiles(input.fileReader, [persistedFileUri]);
375 }
376 } finally {
377 await releaseOwnedFiles(
378 input.fileReader,
379 [uri, payload.value].filter((candidate) => candidate !== retainedFileUri),
380 );
381 }
382 continue;
383 }
161384 if (!uri || !mimeType.startsWith("image/")) {
162385 warnings.push("One shared item was not a supported image.");
163386 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);
apps/mobile/src/features/sharing/incoming-share-storage.test.ts created+78
......@@ -0,0 +1,78 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { vi } from "vite-plus/test";
3
4const fileSystemMocks = vi.hoisted(() => {
5 let entries: File[] = [];
6
7 class File {
8 readonly exists = true;
9
10 constructor(
11 readonly name: string,
12 private readonly contents: string,
13 ) {}
14
15 async text(): Promise<string> {
16 return this.contents;
17 }
18 }
19
20 class Directory {
21 create(): void {}
22
23 list(): ReadonlyArray<File> {
24 return entries;
25 }
26 }
27
28 return {
29 Directory,
30 File,
31 setEntries(next: File[]) {
32 entries = next;
33 },
34 };
35});
36
37vi.mock("expo-file-system", () => ({
38 Directory: fileSystemMocks.Directory,
39 File: fileSystemMocks.File,
40 Paths: { document: "/documents" },
41}));
42
43import { IncomingShareStorageError, loadIncomingShareDrafts } from "./incoming-share-storage";
44
45const VALID_DRAFT = {
46 schemaVersion: 1,
47 id: "share-valid",
48 createdAt: "2026-08-28T12:00:00.000Z",
49 text: "Review this file",
50 attachments: [],
51 warnings: [],
52} as const;
53
54afterEach(() => {
55 fileSystemMocks.setEntries([]);
56 vi.restoreAllMocks();
57});
58
59describe("incoming share storage", () => {
60 it("skips an invalid persisted share by default", async () => {
61 fileSystemMocks.setEntries([
62 new fileSystemMocks.File("valid.json", JSON.stringify(VALID_DRAFT)),
63 new fileSystemMocks.File("invalid.json", "{"),
64 ]);
65 const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
66
67 await expect(loadIncomingShareDrafts()).resolves.toEqual([VALID_DRAFT]);
68 expect(warning).toHaveBeenCalledOnce();
69 });
70
71 it("rejects an invalid persisted share in strict mode", async () => {
72 fileSystemMocks.setEntries([new fileSystemMocks.File("invalid.json", "{")]);
73
74 await expect(loadIncomingShareDrafts({ strict: true })).rejects.toBeInstanceOf(
75 IncomingShareStorageError,
76 );
77 });
78});
apps/mobile/src/features/sharing/incoming-share-storage.ts+11-5
......@@ -33,7 +33,9 @@ async function getFile(shareId: string) {
3333 return new File(await getDirectory(), fileName(shareId));
3434}
3535
36export async function loadIncomingShareDrafts(): Promise<ReadonlyArray<IncomingShareDraft>> {
36export async function loadIncomingShareDrafts(options?: {
37 readonly strict?: boolean;
38}): Promise<ReadonlyArray<IncomingShareDraft>> {
3739 try {
3840 const { File } = await import("expo-file-system");
3941 const drafts: IncomingShareDraft[] = [];
......@@ -44,14 +46,18 @@ export async function loadIncomingShareDrafts(): Promise<ReadonlyArray<IncomingS
4446 try {
4547 drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown));
4648 } catch (cause) {
47 console.warn(
48 "[incoming-share] ignored invalid persisted share",
49 new IncomingShareStorageError({ operation: "load", shareId: null, cause }),
50 );
49 const error = new IncomingShareStorageError({ operation: "load", shareId: null, cause });
50 if (options?.strict) {
51 throw error;
52 }
53 console.warn("[incoming-share] ignored invalid persisted share", error);
5154 }
5255 }
5356 return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
5457 } catch (cause) {
58 if (cause instanceof IncomingShareStorageError) {
59 throw cause;
60 }
5561 throw new IncomingShareStorageError({ operation: "load", shareId: null, cause });
5662 }
5763}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+142-23
......@@ -20,6 +20,7 @@ import {
2020 isAtomCommandInterrupted,
2121 squashAtomCommandFailure,
2222} from "@t3tools/client-runtime/state/runtime";
23import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
2324
2425import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
2526import {
......@@ -42,20 +43,27 @@ import {
4243} from "./use-thread-settings-sheet-presentation";
4344
4445import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
45import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
46import {
47 convertPastedImagesToAttachments,
48 pickComposerFiles,
49 pickComposerImages,
50} from "../../lib/composerImages";
4651import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
4752import {
4853 clearComposerDraftContent,
54 flushComposerDrafts,
4955 getComposerDraftSnapshot,
5056 mergeComposerDraftContent,
5157 restoreComposerDraftSnapshot,
58 scheduleUnusedComposerAttachmentCleanup,
5259 type ComposerDraft,
5360} from "../../state/use-composer-drafts";
5461import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
5562import { resolveSelectableModelSelection } from "../../lib/modelOptions";
5663import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
5764import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
58import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
65import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
66import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
5967import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
6068import { useNewTaskFlow } from "./new-task-flow-provider";
6169import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
......@@ -66,6 +74,9 @@ import {
6674 resolveNewTaskWorkspaceLabel,
6775} from "./new-task-context-presentation";
6876import { useIncomingShare } from "../sharing/IncomingShareProvider";
77import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model";
78import { appAtomRegistry } from "../../state/atom-registry";
79import { serverEnvironment } from "../../state/server";
6980
7081function NewTaskWorkspaceIcon(props: {
7182 readonly workspaceMode: "local" | "worktree";
......@@ -215,9 +226,18 @@ export function NewTaskDraftScreen(props: {
215226 );
216227 const isProjectPickerReturnActive =
217228 isReturningToProjectPicker && !requestedInitialProjectAvailable;
229 const isIncomingShareAwaitingServerConfig = Boolean(
230 incomingShare?.attachments.some((attachment) => attachment.type === "file") &&
231 selectedEnvironmentServerConfig === null,
232 );
218233 const isIncomingShareTransferPending = Boolean(
219 incomingShare && cancelledIncomingShareId !== props.incomingShareId,
234 incomingShare &&
235 cancelledIncomingShareId !== props.incomingShareId &&
236 !isIncomingShareAwaitingServerConfig,
220237 );
238 const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting;
239 // Also guard while a submit is in flight: an Android back press or iOS
240 // Cancel would otherwise abandon the screen while the task still starts.
221241 const composerMenu = useComposerCommandMenu({
222242 draftMessage: flow.prompt,
223243 environmentId: selectedProject?.environmentId ?? null,
......@@ -227,12 +247,14 @@ export function NewTaskDraftScreen(props: {
227247 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null,
228248 selectedProviderStatus: flow.selectedProviderStatus,
229249 hasThread: false,
230 enabled: isComposerFocused && !isIncomingShareTransferPending,
250 enabled: isComposerFocused && !isComposerInteractionLocked,
231251 onChangeDraftMessage: flow.setPrompt,
232252 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
233253 });
234254 usePreventRemove(
235 (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport,
255 (isIncomingShareTransferPending && !isProjectPickerReturnActive) ||
256 isCancellingShareImport ||
257 flow.submitting,
236258 () => undefined,
237259 );
238260 const hasImportedIncomingShare = Boolean(
......@@ -446,6 +468,13 @@ export function NewTaskDraftScreen(props: {
446468 return;
447469 }
448470
471 if (
472 incomingShare.attachments.some((attachment) => attachment.type === "file") &&
473 selectedEnvironmentServerConfig === null
474 ) {
475 return;
476 }
477
449478 if (alertedUnavailableIncomingShareIdRef.current === shareId) {
450479 alertedUnavailableIncomingShareIdRef.current = null;
451480 }
......@@ -455,6 +484,7 @@ export function NewTaskDraftScreen(props: {
455484 shareImportDraftBackupRef.current.set(importKey, draftBackup);
456485 const importToken = Symbol(importKey);
457486 let didReserveShare = false;
487 let didConsumeShare = false;
458488 let needsDraftRestore = false;
459489 activeShareImportTokenRef.current = importToken;
460490 setImportingShareKey(importKey);
......@@ -472,10 +502,19 @@ export function NewTaskDraftScreen(props: {
472502 ) {
473503 return;
474504 }
505 const selectedAttachments = selectIncomingShareAttachmentsForServer({
506 attachments: incomingShare.attachments,
507 serverConfig: appAtomRegistry.get(
508 serverEnvironment.configValueAtom(destinationProject.environmentId),
509 ),
510 });
511 if (selectedAttachments.status === "pending") {
512 throw new Error("Server attachment support is still loading.");
513 }
475514 needsDraftRestore = true;
476515 const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, {
477516 text: incomingShare.text,
478 attachments: incomingShare.attachments,
517 attachments: selectedAttachments.attachments,
479518 sourceShareId: shareId,
480519 });
481520 if (
......@@ -489,13 +528,25 @@ export function NewTaskDraftScreen(props: {
489528 return;
490529 }
491530 await consumeShare(shareId);
531 didConsumeShare = true;
532 // The consumed inbox draft was the last owner of files that never made
533 // it into the composer draft (unsupported server, oversize, limit
534 // skips). Release them before any early return: an unmount or a
535 // superseding import must not leak them, and the sweep re-checks
536 // ownership so it cannot delete a file another draft picked up.
537 const retainedAttachmentIds = new Set(
538 getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id),
539 );
540 scheduleUnusedComposerAttachmentCleanup(
541 incomingShare.attachments.filter((attachment) => !retainedAttachmentIds.has(attachment.id)),
542 );
492543 if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {
493544 return;
494545 }
495 const warnings = [...incomingShare.warnings];
546 const warnings = [...incomingShare.warnings, ...selectedAttachments.warnings];
496547 if (skippedAttachmentCount > 0) {
497548 warnings.push(
498 `${skippedAttachmentCount} shared image${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`,
549 `${skippedAttachmentCount} shared file${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`,
499550 );
500551 }
501552 if (warnings.length > 0) {
......@@ -526,8 +577,16 @@ export function NewTaskDraftScreen(props: {
526577 setIsCancellingShareImport(true);
527578 try {
528579 if (needsDraftRestore) {
580 // The restore drops the share's merged-in attachments
581 // from the draft. Sweep them only when the inbox entry
582 // was consumed: before that, the inbox still references
583 // these files and must keep them for a later import.
584 const mergedAttachments = getComposerDraftSnapshot(draftKey).attachments;
529585 await restoreComposerDraftSnapshot(draftKey, draftBackup);
530586 needsDraftRestore = false;
587 if (didConsumeShare) {
588 scheduleUnusedComposerAttachmentCleanup(mergedAttachments);
589 }
531590 }
532591 if (didReserveShare) {
533592 await releaseShareReservation(shareId, {
......@@ -602,6 +661,7 @@ export function NewTaskDraftScreen(props: {
602661 props.initialProjectRef?.projectId,
603662 releaseShareReservation,
604663 reserveShare,
664 selectedEnvironmentServerConfig,
605665 selectedProject,
606666 shareImportAttempt,
607667 ]);
......@@ -633,12 +693,47 @@ export function NewTaskDraftScreen(props: {
633693 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
634694
635695 async function handlePickImages(): Promise<void> {
636 if (isIncomingShareTransferPending) {
696 if (isComposerInteractionLocked) {
637697 return;
638698 }
639699 const result = await pickComposerImages({ existingCount: flow.attachments.length });
640 if (result.images.length > 0) {
641 flow.appendAttachments(result.images);
700 const rejectedCount = result.images.length > 0 ? flow.appendAttachments(result.images) : 0;
701 const problems = [
702 ...(result.error ? [result.error] : []),
703 ...(rejectedCount > 0
704 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]
705 : []),
706 ];
707 if (problems.length > 0) {
708 Alert.alert("Could not attach photo", problems.join("\n\n"));
709 }
710 }
711
712 async function handlePickFiles(): Promise<void> {
713 if (isComposerInteractionLocked) {
714 return;
715 }
716 const maxBytes =
717 selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments?.maxUploadBytes;
718 if (maxBytes === undefined) {
719 Alert.alert("File attachments are not available on this server.");
720 return;
721 }
722 const result = await pickComposerFiles({
723 existingCount: flow.attachments.length,
724 maxBytes,
725 });
726 const rejectedCount = result.files.length > 0 ? flow.appendAttachments(result.files) : 0;
727 // The picker error and the live-cap rejection can both happen in one
728 // pick; report both in a single alert.
729 const problems = [
730 ...(result.error ? [result.error] : []),
731 ...(rejectedCount > 0
732 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`]
733 : []),
734 ];
735 if (problems.length > 0) {
736 Alert.alert("Could not attach file", problems.join("\n\n"));
642737 }
643738 }
644739
......@@ -693,6 +788,16 @@ export function NewTaskDraftScreen(props: {
693788 ) {
694789 return;
695790 }
791 // A failed-send restore can leave the draft over the cap on purpose (it
792 // never drops the user's files); starting anyway would upload everything
793 // and have the server reject the turn.
794 if (draft.attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
795 Alert.alert(
796 "Too many attachments",
797 `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`,
798 );
799 return;
800 }
696801
697802 const editingPendingTask = flow.editingPendingTask;
698803
......@@ -764,6 +869,10 @@ export function NewTaskDraftScreen(props: {
764869 interactionMode,
765870 initialMessageText,
766871 initialAttachments: draft.attachments,
872 onAttachmentsUploaded: async (attachments) => {
873 flow.replaceAttachments(attachments);
874 await flushComposerDrafts();
875 },
767876 ...(editingPendingTask
768877 ? {
769878 turnMetadata: {
......@@ -839,7 +948,7 @@ export function NewTaskDraftScreen(props: {
839948 // The context-first screen intentionally opens with the keyboard closed.
840949 // Focusing is a user action, so presenting the form sheet has one motion.
841950 autoFocus={false}
842 editable={!isIncomingShareTransferPending}
951 editable={!isComposerInteractionLocked}
843952 multiline
844953 scrollEnabled
845954 value={flow.prompt}
......@@ -873,7 +982,7 @@ export function NewTaskDraftScreen(props: {
873982 navigation.goBack();
874983 };
875984 const chooseProject = () => {
876 if (isIncomingShareTransferPending) {
985 if (isComposerInteractionLocked) {
877986 return;
878987 }
879988 promptInputRef.current?.blur();
......@@ -881,7 +990,7 @@ export function NewTaskDraftScreen(props: {
881990 navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId }));
882991 };
883992 const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {
884 if (isIncomingShareTransferPending) {
993 if (isComposerInteractionLocked) {
885994 return;
886995 }
887996 promptInputRef.current?.blur();
......@@ -901,7 +1010,7 @@ export function NewTaskDraftScreen(props: {
9011010 accessibilityHint="Opens the project picker"
9021011 accessibilityLabel={`Change project from ${selectedProject.title}`}
9031012 accessibilityRole="button"
904 disabled={isIncomingShareTransferPending}
1013 disabled={isComposerInteractionLocked}
9051014 onPress={chooseProject}
9061015 className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65"
9071016 >
......@@ -919,7 +1028,7 @@ export function NewTaskDraftScreen(props: {
9191028 <ComposerInlineControl
9201029 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
9211030 chevronDirection="right"
922 disabled={isIncomingShareTransferPending}
1031 disabled={isComposerInteractionLocked}
9231032 icon="desktopcomputer"
9241033 label={`on ${selectedEnvironmentLabel}`}
9251034 maxWidth={260}
......@@ -954,7 +1063,7 @@ export function NewTaskDraftScreen(props: {
9541063 <ComposerInlineControl
9551064 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
9561065 accessibilityLabel={workspaceLabel}
957 disabled={isIncomingShareTransferPending}
1066 disabled={isComposerInteractionLocked}
9581067 iconNode={
9591068 <NewTaskWorkspaceIcon
9601069 workspaceMode={flow.workspaceMode}
......@@ -970,7 +1079,7 @@ export function NewTaskDraftScreen(props: {
9701079 <ComposerInlineControl
9711080 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
9721081 chevronDirection="right"
973 disabled={isIncomingShareTransferPending}
1082 disabled={isComposerInteractionLocked}
9741083 icon="arrow.triangle.branch"
9751084 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
9761085 maxWidth={190}
......@@ -1010,7 +1119,7 @@ export function NewTaskDraftScreen(props: {
10101119 attachments={flow.attachments}
10111120 imageBorderRadius={16}
10121121 imageSize={72}
1013 onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment}
1122 onRemove={isComposerInteractionLocked ? () => undefined : flow.removeAttachment}
10141123 />
10151124 </View>
10161125 ) : null}
......@@ -1021,14 +1130,24 @@ export function NewTaskDraftScreen(props: {
10211130 <ComposerToolbarScroller contentPaddingRight={8} fadeSurface="sheet">
10221131 <ComposerToolbarButton
10231132 accessibilityLabel="Add attachment"
1024 disabled={isIncomingShareTransferPending}
1133 disabled={isComposerInteractionLocked}
10251134 icon="plus"
1026 onPress={() => void handlePickImages()}
1135 onPress={() => {
1136 if (selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments) {
1137 Alert.alert("Add attachment", undefined, [
1138 { text: "Photos", onPress: () => void handlePickImages() },
1139 { text: "Files", onPress: () => void handlePickFiles() },
1140 { text: "Cancel", style: "cancel" },
1141 ]);
1142 return;
1143 }
1144 void handlePickImages();
1145 }}
10271146 showChevron={false}
10281147 />
10291148 <ComposerInlineControl
10301149 accessibilityLabel="Model and reasoning settings"
1031 disabled={isIncomingShareTransferPending}
1150 disabled={isComposerInteractionLocked}
10321151 emphasized
10331152 iconNode={
10341153 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
......@@ -1041,7 +1160,7 @@ export function NewTaskDraftScreen(props: {
10411160 <ComposerInlineControl
10421161 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}
10431162 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}
1044 disabled={isIncomingShareTransferPending}
1163 disabled={isComposerInteractionLocked}
10451164 emphasized
10461165 icon={
10471166 flow.interactionMode === "plan"
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+2-2
......@@ -99,8 +99,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
9999 ? incomingShare.attachments.length === 0
100100 ? "Choose a project for what you shared"
101101 : incomingShare.attachments.length === 1
102 ? "Choose a project for the image you shared"
103 : `Choose a project for the ${incomingShare.attachments.length} images you shared`
102 ? `Choose a project for the ${incomingShare.attachments[0]?.type === "image" ? "image" : "file"} you shared`
103 : `Choose a project for the ${incomingShare.attachments.length} ${incomingShare.attachments.every((attachment) => attachment.type === "image") ? "images" : "files"} you shared`
104104 : null;
105105 const screenTitle = incomingShare ? "Start a task" : "Choose project";
106106 const projectEmptyState = deriveProjectEmptyState(catalogState);
apps/mobile/src/features/threads/ThreadComposer.tsx+45-13
......@@ -19,7 +19,15 @@ import {
1919 useState,
2020 type RefObject,
2121} from "react";
22import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native";
22import {
23 ActivityIndicator,
24 Alert,
25 Image,
26 Platform,
27 Pressable,
28 View,
29 type ViewStyle,
30} from "react-native";
2331import ImageViewing from "react-native-image-viewing";
2432import Animated, {
2533 FadeIn,
......@@ -36,6 +44,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re
3644import { scopedThreadKey } from "../../lib/scopedEntities";
3745
3846import { AppText as Text } from "../../components/AppText";
47import { SymbolView } from "../../components/AppSymbol";
3948import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
4049import { GlassSurface } from "../../components/GlassSurface";
4150import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
......@@ -46,7 +55,7 @@ import {
4655} from "../../components/ComposerToolbar";
4756import { ControlPill } from "../../components/ControlPill";
4857import { ProviderIcon } from "../../components/ProviderIcon";
49import type { DraftComposerImageAttachment } from "../../lib/composerImages";
58import type { DraftComposerAttachment } from "../../lib/composerImages";
5059import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
5160import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
5261import type { RemoteClientConnectionState } from "../../lib/connection";
......@@ -76,7 +85,7 @@ export const COMPOSER_EXPANDED_CHROME = 156;
7685
7786export interface ThreadComposerProps {
7887 readonly draftMessage: string;
79 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
88 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
8089 readonly placeholder: string;
8190 readonly contentMaxWidth?: number;
8291 readonly bottomInset?: number;
......@@ -97,6 +106,7 @@ export interface ThreadComposerProps {
97106 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
98107 readonly onChangeDraftMessage: (value: string) => void;
99108 readonly onPickDraftImages: () => Promise<void>;
109 readonly onPickDraftFiles: () => Promise<void>;
100110 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
101111 readonly onRemoveDraftImage: (imageId: string) => void;
102112 readonly onStopThread: () => void;
......@@ -597,15 +607,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
597607 </View>
598608 {!isExpanded && props.draftAttachments.length > 0 ? (
599609 <View className="flex-row gap-1 pl-1">
600 {props.draftAttachments.slice(0, 3).map((image) => (
601 <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}>
602 <Image
603 source={{ uri: image.previewUri }}
604 className="size-[30px] rounded-lg bg-subtle"
605 resizeMode="cover"
606 />
607 </Pressable>
608 ))}
610 {props.draftAttachments.slice(0, 3).map((attachment) =>
611 attachment.type === "image" ? (
612 <Pressable
613 key={attachment.id}
614 onPress={() => onPressImage(attachment.previewUri)}
615 >
616 <Image
617 source={{ uri: attachment.previewUri }}
618 className="size-[30px] rounded-lg bg-subtle"
619 resizeMode="cover"
620 />
621 </Pressable>
622 ) : (
623 <View
624 key={attachment.id}
625 className="size-[30px] items-center justify-center rounded-lg bg-subtle"
626 >
627 <SymbolView name="doc.text" size={15} tintColor="#a3a3a3" type="monochrome" />
628 </View>
629 ),
630 )}
609631 {props.draftAttachments.length > 3 ? (
610632 <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong">
611633 <Text className="text-foreground-muted text-2xs font-t3-bold">
......@@ -650,7 +672,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
650672 <ComposerToolbarButton
651673 accessibilityLabel="Add attachment"
652674 icon="plus"
653 onPress={() => void props.onPickDraftImages()}
675 onPress={() => {
676 if (props.serverConfig?.environment.capabilities.fileAttachments) {
677 Alert.alert("Add attachment", undefined, [
678 { text: "Photos", onPress: () => void props.onPickDraftImages() },
679 { text: "Files", onPress: () => void props.onPickDraftFiles() },
680 { text: "Cancel", style: "cancel" },
681 ]);
682 return;
683 }
684 void props.onPickDraftImages();
685 }}
654686 showChevron={false}
655687 />
656688 <View className="min-w-0 flex-1" style={{ maxWidth: 152 }}>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4-2
......@@ -54,7 +54,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
5454import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
5555import type { ComposerEditorHandle } from "../../components/ComposerEditor";
5656import type { StatusTone } from "../../components/StatusPill";
57import type { DraftComposerImageAttachment } from "../../lib/composerImages";
57import type { DraftComposerAttachment } from "../../lib/composerImages";
5858import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
5959import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
6060import { scopedThreadKey } from "../../lib/scopedEntities";
......@@ -101,7 +101,7 @@ export interface ThreadDetailScreenProps {
101101 readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;
102102 readonly respondingUserInputId: ApprovalRequestId | null;
103103 readonly draftMessage: string;
104 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
104 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
105105 readonly connectionStateLabel: EnvironmentConnectionPhase;
106106 /** Message sync status for the selected thread (drives the composer status pill). */
107107 readonly threadSyncStatus?: EnvironmentThreadStatus;
......@@ -118,6 +118,7 @@ export interface ThreadDetailScreenProps {
118118 readonly onOpenConnectionEditor: () => void;
119119 readonly onChangeDraftMessage: (value: string) => void;
120120 readonly onPickDraftImages: () => Promise<void>;
121 readonly onPickDraftFiles: () => Promise<void>;
121122 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
122123 readonly onRemoveDraftImage: (imageId: string) => void;
123124 readonly onStopThread: () => void;
......@@ -776,6 +777,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
776777 bottomInset={composerBottomInset}
777778 onChangeDraftMessage={props.onChangeDraftMessage}
778779 onPickDraftImages={props.onPickDraftImages}
780 onPickDraftFiles={props.onPickDraftFiles}
779781 onNativePasteImages={props.onNativePasteImages}
780782 onRemoveDraftImage={props.onRemoveDraftImage}
781783 onStopThread={props.onStopThread}
apps/mobile/src/features/threads/ThreadFeed.tsx+154-8
......@@ -1,12 +1,23 @@
11import * as Haptics from "expo-haptics";
22import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
33import { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
4import type {
5 ChatAttachment,
6 ChatFileAttachment,
7 ChatImageAttachment,
8 EnvironmentId,
9 MessageId,
10 ThreadId,
11 TurnId,
12} from "@t3tools/contracts";
13import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
14import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
15import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
516import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
617import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
718import { SymbolView } from "../../components/AppSymbol";
819import { HeaderHeightContext } from "@react-navigation/elements";
9import { useNavigation } from "@react-navigation/native";
20import { useFocusEffect, useNavigation } from "@react-navigation/native";
1021import {
1122 memo,
1223 useCallback,
......@@ -27,6 +38,7 @@ import {
2738} from "react-native-nitro-markdown";
2839import {
2940 ActivityIndicator,
41 Alert,
3042 Image,
3143 Platform,
3244 type LayoutChangeEvent,
......@@ -57,6 +69,7 @@ import { useFontFamily } from "../../lib/useFontFamily";
5769import { scopedThreadKey } from "../../lib/scopedEntities";
5870import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
5971import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
72import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
6073import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
6174import {
6275 hasNativeSelectableMarkdownText,
......@@ -112,7 +125,10 @@ import {
112125 WORK_GROUP_TOGGLE_HEIGHT,
113126} from "./thread-work-log";
114127import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
115import { useAssetUrl, useAssetUrlState } from "../../state/assets";
128import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets";
129import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
130import { usePreparedConnection } from "../../state/session";
131import * as Option from "effect/Option";
116132import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
117133import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
118134
......@@ -207,6 +223,122 @@ function MessageAttachmentImage(props: {
207223 );
208224}
209225
226// The attachment union has an open member (`type: string` for attachment
227// types from newer servers), so literal comparisons do not narrow it. Split
228// with guards and render unknown types as inert rows, never crash.
229function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment {
230 return attachment.type === "image";
231}
232
233function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment {
234 return attachment.type === "file";
235}
236
237function MessageAttachmentFile(props: {
238 readonly environmentId: EnvironmentId;
239 readonly attachment: ChatFileAttachment;
240}) {
241 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
242 reportFailure: false,
243 });
244 const preparedConnection = usePreparedConnection(props.environmentId);
245 const { attachment } = props;
246 const httpBaseUrl = Option.isSome(preparedConnection)
247 ? preparedConnection.value.httpBaseUrl
248 : null;
249 const openingRef = useRef<AbortController | null>(null);
250 const [opening, setOpening] = useState(false);
251
252 useFocusEffect(
253 useCallback(() => {
254 setOpening(false);
255 return () => {
256 openingRef.current?.abort();
257 openingRef.current = null;
258 };
259 }, [props.environmentId, attachment.id, httpBaseUrl]),
260 );
261
262 return (
263 <Pressable
264 accessibilityRole="button"
265 accessibilityLabel={`Open ${attachment.name}`}
266 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
267 disabled={opening || httpBaseUrl === null}
268 className="flex-row items-center gap-2 py-1"
269 onPress={() => {
270 if (httpBaseUrl === null || openingRef.current) return;
271 const controller = new AbortController();
272 openingRef.current = controller;
273 setOpening(true);
274 void (async () => {
275 try {
276 const result = await createAssetUrl({
277 environmentId: props.environmentId,
278 input: {
279 resource: {
280 _tag: "attachment",
281 attachmentId: attachment.id,
282 fileName: attachment.name,
283 mimeType: attachment.mimeType,
284 },
285 },
286 });
287 if (controller.signal.aborted) return;
288 if (result._tag === "Failure") {
289 throw squashAtomCommandFailure(result);
290 }
291 const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
292 if (url === null) {
293 throw new Error("The attachment could not be opened.");
294 }
295 await downloadAndShareAttachment({ url, attachment, signal: controller.signal });
296 } catch (error) {
297 if (!controller.signal.aborted) {
298 Alert.alert(
299 "Could not open attachment",
300 error instanceof Error ? error.message : "The attachment is unavailable.",
301 );
302 }
303 } finally {
304 if (openingRef.current === controller) {
305 openingRef.current = null;
306 setOpening(false);
307 }
308 }
309 })();
310 }}
311 >
312 {opening ? (
313 <ActivityIndicator size="small" />
314 ) : (
315 <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />
316 )}
317 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
318 {attachment.name}
319 </Text>
320 <Text className="text-xs text-foreground-muted">
321 {formatAttachmentSize(attachment.sizeBytes)}
322 </Text>
323 </Pressable>
324 );
325}
326
327/**
328 * An attachment type this build does not know (newer server). Rendered as an
329 * inert row: the name is still useful, but there is nothing to open.
330 */
331function MessageAttachmentUnknown(props: { readonly name: string }) {
332 return (
333 <View className="flex-row items-center gap-2 py-1">
334 <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />
335 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
336 {props.name}
337 </Text>
338 </View>
339 );
340}
341
210342function ThreadMarkdownImageView(props: {
211343 readonly uri: string | null;
212344 readonly sourceKey: string;
......@@ -1032,9 +1164,7 @@ function renderFeedEntry(
10321164 const isUser = message.role === "user";
10331165 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
10341166 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
1035 const attachments = (message.attachments ?? []).filter(
1036 (attachment) => attachment.type === "image",
1037 );
1167 const attachments = message.attachments ?? [];
10381168 const hasReviewCommentContext = message.text.includes("<review_comment");
10391169 // A bubble that sizes itself from its content cannot lay out a block whose
10401170 // intrinsic width overflows `maxWidth`: Android positions the bubble's
......@@ -1082,7 +1212,7 @@ function renderFeedEntry(
10821212 />
10831213 ) : null}
10841214 {attachments.map((attachment) => {
1085 return (
1215 return isImageAttachment(attachment) ? (
10861216 <MessageAttachmentImage
10871217 key={attachment.id}
10881218 environmentId={props.environmentId}
......@@ -1090,6 +1220,14 @@ function renderFeedEntry(
10901220 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
10911221 onPressImage={props.onPressImage}
10921222 />
1223 ) : isFileAttachment(attachment) ? (
1224 <MessageAttachmentFile
1225 key={attachment.id}
1226 environmentId={props.environmentId}
1227 attachment={attachment}
1228 />
1229 ) : (
1230 <MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
10931231 );
10941232 })}
10951233 </View>
......@@ -1144,7 +1282,7 @@ function renderFeedEntry(
11441282 )
11451283 ) : null}
11461284 {attachments.map((attachment) => {
1147 return (
1285 return isImageAttachment(attachment) ? (
11481286 <MessageAttachmentImage
11491287 key={attachment.id}
11501288 environmentId={props.environmentId}
......@@ -1152,6 +1290,14 @@ function renderFeedEntry(
11521290 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
11531291 onPressImage={props.onPressImage}
11541292 />
1293 ) : isFileAttachment(attachment) ? (
1294 <MessageAttachmentFile
1295 key={attachment.id}
1296 environmentId={props.environmentId}
1297 attachment={attachment}
1298 />
1299 ) : (
1300 <MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
11551301 );
11561302 })}
11571303 {showAssistantMeta ? (
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
......@@ -794,6 +794,7 @@ function ThreadRouteContent(
794794 onOpenConnectionEditor={handleOpenConnectionEditor}
795795 onChangeDraftMessage={composer.onChangeDraftMessage}
796796 onPickDraftImages={composer.onPickDraftImages}
797 onPickDraftFiles={composer.onPickDraftFiles}
797798 onNativePasteImages={composer.onNativePasteImages}
798799 onRemoveDraftImage={composer.onRemoveDraftImage}
799800 serverConfig={serverConfig}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+36-13
......@@ -27,7 +27,7 @@ import { pipe } from "effect/Function";
2727
2828import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
2929import type { TurnCommandMetadata } from "../../lib/commandMetadata";
30import type { DraftComposerImageAttachment } from "../../lib/composerImages";
30import type { DraftComposerAttachment } from "../../lib/composerImages";
3131import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
3232import {
3333 buildModelOptions,
......@@ -48,18 +48,22 @@ import {
4848 isComposerDraftEmpty,
4949 removeComposerDraftAttachment,
5050 replaceComposerDraftAttachments,
51 scheduleUnusedComposerAttachmentCleanup,
5152 setComposerDraftText,
5253 setStickyComposerModelSelection,
5354 updateComposerDraftSettings,
5455 useComposerDraft,
5556 useStickyComposerModelSelection,
5657} from "../../state/use-composer-drafts";
58import {
59 capturePendingTaskEditorWriteBaseline,
60 flushPendingTaskEditorWrite,
61} from "../../state/pending-task-editor-writes";
5762import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
5863import { vcsEnvironment } from "../../state/vcs";
5964import {
6065 flattenQueuedThreadMessages,
6166 threadOutboxManager,
62 updateThreadOutboxMessage,
6367 type QueuedThreadMessage,
6468} from "../../state/thread-outbox";
6569import {
......@@ -135,7 +139,7 @@ type NewTaskFlowContextValue = {
135139 readonly draftKey: string | null;
136140 readonly editingPendingTask: QueuedThreadMessage | null;
137141 readonly prompt: string;
138 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
142 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
139143 readonly submitting: boolean;
140144 readonly branchQuery: string;
141145 readonly branchesLoading: boolean;
......@@ -174,8 +178,9 @@ type NewTaskFlowContextValue = {
174178 readonly cancelEditingPendingTask: () => void;
175179 readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null;
176180 readonly setPrompt: (value: string) => void;
177 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
178 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
181 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void;
182 /** Appends draft attachments; returns how many the live cap rejected. */
183 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => number;
179184 readonly removeAttachment: (imageId: string) => void;
180185 readonly clearAttachments: () => void;
181186 readonly setSubmitting: (value: boolean) => void;
......@@ -230,6 +235,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
230235 // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act
231236 // on a task whose editing session already ended this render.
232237 const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null);
238 // Outbox revision this editor session may write after its predecessor save.
239 // Unrelated accepted writes still beat the dismissed session's CAS.
240 const editingRevisionRef = useRef(Promise.resolve(0));
233241
234242 const reset = useCallback(() => {
235243 setSelectedEnvironmentId(null);
......@@ -513,7 +521,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
513521 [selectedProjectDraftKey],
514522 );
515523 const replaceAttachments = useCallback(
516 (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {
524 (nextAttachments: ReadonlyArray<DraftComposerAttachment>) => {
517525 if (!selectedProjectDraftKey) {
518526 return;
519527 }
......@@ -521,12 +529,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
521529 },
522530 [selectedProjectDraftKey],
523531 );
532 // Returns how many attachments the live cap rejected so the caller can
533 // tell the user (a concurrent add can fill the draft mid-pick).
524534 const appendAttachments = useCallback(
525 (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {
535 (nextAttachments: ReadonlyArray<DraftComposerAttachment>): number => {
526536 if (!selectedProjectDraftKey) {
527 return;
537 return 0;
528538 }
529 appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments);
539 return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments);
530540 },
531541 [selectedProjectDraftKey],
532542 );
......@@ -835,6 +845,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
835845 setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId));
836846 activeEditingMessageId = message.messageId;
837847 editingPendingTaskRef.current = message;
848 editingRevisionRef.current = capturePendingTaskEditorWriteBaseline(message.messageId);
838849 setEditingPendingTask(message);
839850 // Hold the outbox drain off this task while it is open in the editor.
840851 holdEditingQueuedMessage(message.messageId);
......@@ -932,6 +943,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
932943 }
933944 clearComposerDraft(pendingTaskDraftKey(editing.messageId));
934945 releaseEditingQueuedMessage(editing.messageId);
946 scheduleUnusedComposerAttachmentCleanup(editing.attachments);
935947 }
936948 setEditingPendingTask(null);
937949 }, []);
......@@ -984,17 +996,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
984996 return;
985997 }
986998
987 // update() rewrites the task only if it is still queued — a concurrent
988 // delete or delivery wins, so the flush cannot resurrect it.
989 void updateThreadOutboxMessage(message)
990 .then(() => {
999 // The write handoff lets a reopened editor follow this editor's pending
1000 // save. Its CAS still rejects unrelated queue edits, deletes, and
1001 // deliveries, so the flush cannot resurrect or overwrite them.
1002 void flushPendingTaskEditorWrite({
1003 message,
1004 baseline: editingRevisionRef.current,
1005 draftKey: pendingTaskDraftKey(editing.messageId),
1006 })
1007 .then((savedDraftStillCurrent) => {
9911008 // If this task was reopened (possibly in a fresh provider) while
9921009 // the save was in flight, that session owns the draft and the lock.
9931010 if (activeEditingMessageId === editing.messageId) {
9941011 return;
9951012 }
1013 if (!savedDraftStillCurrent) {
1014 // A newer queue write won the CAS, or a newer editor changed this
1015 // draft. Keep the draft and drain lock so reopening can retry it.
1016 return;
1017 }
9961018 clearComposerDraft(pendingTaskDraftKey(editing.messageId));
9971019 releaseEditingQueuedMessage(editing.messageId);
1020 scheduleUnusedComposerAttachmentCleanup(editing.attachments);
9981021 })
9991022 .catch((error) => {
10001023 // Keep the drain lock and the draft: delivering the stale payload
apps/mobile/src/features/threads/use-project-actions.ts+60-2
......@@ -14,13 +14,17 @@ import * as Cause from "effect/Cause";
1414import { AsyncResult } from "effect/unstable/reactivity";
1515
1616import { threadEnvironment } from "../../state/threads";
17import type { DraftComposerImageAttachment } from "../../lib/composerImages";
17import type { DraftComposerAttachment } from "../../lib/composerImages";
18import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/attachmentUpload";
1819import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
1920import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
2021import { randomHex } from "../../lib/uuid";
2122import { useAtomCommand } from "../../state/use-atom-command";
23import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts";
2224import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
2325import { validateProjectThreadCreation } from "./projectThreadCreationValidation";
26import { appAtomRegistry } from "../../state/atom-registry";
27import { serverEnvironment } from "../../state/server";
2428
2529export function useCreateProjectThread() {
2630 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
......@@ -36,7 +40,10 @@ export function useCreateProjectThread() {
3640 readonly runtimeMode: RuntimeMode;
3741 readonly interactionMode: ProviderInteractionMode;
3842 readonly initialMessageText: string;
39 readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>;
43 readonly initialAttachments: ReadonlyArray<DraftComposerAttachment>;
44 readonly onAttachmentsUploaded: (
45 attachments: ReadonlyArray<DraftComposerAttachment>,
46 ) => Promise<void>;
4047 /** Reuse identifiers from a queued pending task instead of minting new ones. */
4148 readonly turnMetadata?: TurnCommandMetadata;
4249 }) => {
......@@ -56,6 +63,50 @@ export function useCreateProjectThread() {
5663 return AsyncResult.failure(Cause.fail(validationError));
5764 }
5865
66 const validateLiveFileAttachments = (
67 attachments: ReadonlyArray<DraftComposerAttachment>,
68 ): string | null =>
69 validateDraftFileAttachments({
70 attachments,
71 serverConfig: appAtomRegistry.get(
72 serverEnvironment.configValueAtom(input.project.environmentId),
73 ),
74 });
75 const initialAttachmentError = validateLiveFileAttachments(input.initialAttachments);
76 if (initialAttachmentError !== null) {
77 setPendingConnectionError(initialAttachmentError);
78 return AsyncResult.failure(Cause.fail(new Error(initialAttachmentError)));
79 }
80
81 let prepared: Awaited<ReturnType<typeof prepareTurnAttachments>>;
82 try {
83 // If persisting the references into the draft throws, the owner call
84 // deletes the pending uploads it minted before rethrowing.
85 prepared = await prepareTurnAttachments({
86 environmentId: input.project.environmentId,
87 attachments: input.initialAttachments,
88 persistUploadedReferences: async (draftAttachments) => {
89 await input.onAttachmentsUploaded(draftAttachments);
90 return "persisted";
91 },
92 });
93 } catch (error) {
94 const message = error instanceof Error ? error.message : "An attachment could not upload.";
95 setPendingConnectionError(message);
96 return AsyncResult.failure(Cause.fail(new Error(message)));
97 }
98 if (prepared.status !== "ready") {
99 const message = "The attachments are no longer available.";
100 setPendingConnectionError(message);
101 return AsyncResult.failure(Cause.fail(new Error(message)));
102 }
103
104 const preparedAttachmentError = validateLiveFileAttachments(prepared.draftAttachments);
105 if (preparedAttachmentError !== null) {
106 setPendingConnectionError(preparedAttachmentError);
107 return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError)));
108 }
109
59110 const result = await startTurn({
60111 environmentId: input.project.environmentId,
61112 input: buildProjectThreadStartTurnInput({
......@@ -67,6 +118,7 @@ export function useCreateProjectThread() {
67118 createdAt: metadata.createdAt,
68119 text: initialMessageText,
69120 attachments: input.initialAttachments,
121 uploadedAttachments: prepared.attachments,
70122 modelSelection: input.modelSelection,
71123 runtimeMode: input.runtimeMode,
72124 interactionMode: input.interactionMode,
......@@ -84,7 +136,13 @@ export function useCreateProjectThread() {
84136 );
85137 return AsyncResult.failure(result.cause);
86138 }
139 // The started turn holds its own copy of the bytes; a failed delete is
140 // surfaced without failing the started task.
141 await prepared.releaseUploads().catch((error) => {
142 console.warn("[project-thread] could not delete consumed pending uploads", error);
143 });
87144 setPendingConnectionError(null);
145 scheduleUnusedComposerAttachmentCleanup(input.initialAttachments);
88146
89147 return mapAtomCommandResult(result, () =>
90148 scopeThreadRef(input.project.environmentId, threadId),
apps/mobile/src/lib/attachmentDownload.test.ts created+270
......@@ -0,0 +1,270 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 directories: new Set<string>(),
5 deleted: vi.fn(),
6 download: vi.fn(),
7 share: vi.fn(),
8 available: vi.fn(),
9 uuid: vi.fn(),
10}));
11
12vi.mock("expo-file-system", () => {
13 class Directory {
14 readonly uri: string;
15
16 constructor(...parts: Array<string | Directory>) {
17 this.uri = parts.map((part) => (typeof part === "string" ? part : part.uri)).join("/");
18 }
19
20 get name(): string {
21 return this.uri.split("/").at(-1)!;
22 }
23
24 get exists(): boolean {
25 return mocks.directories.has(this.uri);
26 }
27
28 create(): void {
29 mocks.directories.add(this.uri);
30 }
31
32 list(): Directory[] {
33 const prefix = `${this.uri}/`;
34 return [...mocks.directories]
35 .filter((uri) => uri.startsWith(prefix) && !uri.slice(prefix.length).includes("/"))
36 .map((uri) => new Directory(uri));
37 }
38
39 delete(): void {
40 mocks.deleted(this.uri);
41 mocks.directories.delete(this.uri);
42 }
43 }
44
45 class File {
46 static downloadFileAsync = mocks.download;
47 readonly uri: string;
48
49 constructor(directory: Directory, name: string) {
50 this.uri = `${directory.uri}/${encodeURIComponent(name)}`;
51 }
52 }
53
54 return { Directory, File, Paths: { cache: "file:///cache" } };
55});
56
57vi.mock("expo-sharing", () => ({
58 isAvailableAsync: mocks.available,
59 shareAsync: mocks.share,
60}));
61
62vi.mock("./uuid", () => ({ uuidv4: mocks.uuid }));
63
64import { downloadAndShareAttachment } from "./attachmentDownload";
65import { isForegroundHandoffActive } from "./foreground-handoff";
66
67const NOW = 1_787_990_400_000;
68const DAY_MS = 24 * 60 * 60_000;
69const CACHE = "file:///cache/t3-attachment-downloads";
70const input = {
71 url: "https://chosen-environment.example/api/assets/signed-token/report.pdf",
72 attachment: { name: "report.pdf", mimeType: "application/pdf" },
73};
74
75beforeEach(() => {
76 mocks.directories.clear();
77 mocks.deleted.mockReset();
78 mocks.download.mockReset();
79 mocks.share.mockReset();
80 mocks.available.mockReset();
81 mocks.uuid.mockReset();
82 mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file);
83 mocks.share.mockResolvedValue(undefined);
84 mocks.available.mockResolvedValue(true);
85 let sequence = 0;
86 mocks.uuid.mockImplementation(
87 () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`,
88 );
89 vi.spyOn(Date, "now").mockReturnValue(NOW);
90});
91
92afterEach(() => {
93 vi.restoreAllMocks();
94 expect(isForegroundHandoffActive()).toBe(false);
95});
96
97describe("downloadAndShareAttachment", () => {
98 it("downloads the chosen environment's signed URL and shares the local file", async () => {
99 const controller = new AbortController();
100 await downloadAndShareAttachment({ ...input, signal: controller.signal });
101
102 expect(mocks.download).toHaveBeenCalledWith(
103 input.url,
104 expect.objectContaining({ uri: expect.stringMatching(/\/report\.pdf$/) }),
105 { signal: controller.signal },
106 );
107 expect(mocks.share).toHaveBeenCalledWith(
108 expect.stringMatching(/^file:\/\/\/cache\/.+\/report\.pdf$/),
109 {
110 mimeType: "application/pdf",
111 dialogTitle: "report.pdf",
112 },
113 );
114 expect(mocks.deleted).not.toHaveBeenCalled();
115 });
116
117 it("shares videos even when the server serves their bytes inline", async () => {
118 await downloadAndShareAttachment({
119 url: "https://relay-environment.example/api/assets/signed-video/clip.mp4",
120 attachment: { name: "clip.mp4", mimeType: 'video/mp4; codecs="avc1"' },
121 signal: new AbortController().signal,
122 });
123
124 expect(mocks.share).toHaveBeenCalledWith(expect.stringMatching(/\/clip\.mp4$/), {
125 mimeType: "video/mp4",
126 dialogTitle: "clip.mp4",
127 });
128 });
129
130 it.each([
131 ["../../résumé.pdf", "résumé.pdf"],
132 ["C:\\folder\\clip.mp4", "clip.mp4"],
133 ["a?query#part%2F.txt", "a?query#part%2F.txt"],
134 ["Report #5 - 100%.pdf", "Report #5 - 100%.pdf"],
135 ["..", "attachment"],
136 [" ", "attachment"],
137 ["\ud800file\u0000.txt", "_file_.txt"],
138 [".env", ".env"],
139 ])("uses a safe basename for %j", async (name, expected) => {
140 await downloadAndShareAttachment({
141 ...input,
142 attachment: { ...input.attachment, name },
143 signal: new AbortController().signal,
144 });
145 const file = mocks.download.mock.calls[0]![1] as { uri: string };
146 expect(decodeURIComponent(file.uri.split("/").at(-1)!)).toBe(expected);
147 });
148
149 it("preserves ordinary long filenames that fit within the filesystem limit", async () => {
150 const name =
151 "Project quarterly report with detailed implementation and delivery notes for August 2026.pdf";
152 await downloadAndShareAttachment({
153 ...input,
154 attachment: { ...input.attachment, name },
155 signal: new AbortController().signal,
156 });
157 const file = mocks.download.mock.calls[0]![1] as { uri: string };
158 expect(decodeURIComponent(file.uri.split("/").at(-1)!)).toBe(name);
159 });
160
161 it("bounds the UTF-8 filename length while preserving its extension", async () => {
162 await downloadAndShareAttachment({
163 ...input,
164 attachment: { name: `${"🙂".repeat(80)}.mp4`, mimeType: "video/mp4" },
165 signal: new AbortController().signal,
166 });
167 const file = mocks.download.mock.calls[0]![1] as { uri: string };
168 const name = decodeURIComponent(file.uri.split("/").at(-1)!);
169 expect(name.endsWith(".mp4")).toBe(true);
170 expect(new TextEncoder().encode(name).length).toBeLessThanOrEqual(255);
171 });
172
173 it("reports unavailable sharing before downloading or creating files", async () => {
174 mocks.available.mockResolvedValue(false);
175 await expect(
176 downloadAndShareAttachment({ ...input, signal: new AbortController().signal }),
177 ).rejects.toThrow("Saving and sharing files is unavailable on this device.");
178 expect(mocks.download).not.toHaveBeenCalled();
179 expect(mocks.directories.size).toBe(0);
180 });
181
182 it("cleans an interrupted download only after the native request settles", async () => {
183 const started = Promise.withResolvers<void>();
184 const download = Promise.withResolvers<{ uri: string }>();
185 mocks.download.mockImplementation(() => {
186 started.resolve();
187 return download.promise;
188 });
189 const controller = new AbortController();
190 const task = downloadAndShareAttachment({ ...input, signal: controller.signal });
191 await started.promise;
192 controller.abort();
193 expect(mocks.deleted).not.toHaveBeenCalled();
194 download.reject(new Error("Canceled native request"));
195 await task;
196 expect(mocks.deleted).toHaveBeenCalledTimes(1);
197 expect(mocks.share).not.toHaveBeenCalled();
198 });
199
200 it("does not open a late download after cancellation", async () => {
201 const started = Promise.withResolvers<{ uri: string }>();
202 const download = Promise.withResolvers<{ uri: string }>();
203 mocks.download.mockImplementation((_url: string, file: { uri: string }) => {
204 started.resolve(file);
205 return download.promise;
206 });
207 const controller = new AbortController();
208 const task = downloadAndShareAttachment({ ...input, signal: controller.signal });
209 const file = await started.promise;
210 controller.abort();
211 download.resolve(file);
212 await task;
213 expect(mocks.share).not.toHaveBeenCalled();
214 expect(mocks.deleted).toHaveBeenCalledTimes(1);
215 });
216
217 it("retains an export when its row unmounts during the native handoff", async () => {
218 const opened = Promise.withResolvers<void>();
219 const share = Promise.withResolvers<void>();
220 mocks.share.mockImplementation(() => {
221 expect(isForegroundHandoffActive()).toBe(true);
222 opened.resolve();
223 return share.promise;
224 });
225 const controller = new AbortController();
226 const task = downloadAndShareAttachment({ ...input, signal: controller.signal });
227 await opened.promise;
228 controller.abort();
229 share.resolve();
230 await task;
231 expect(mocks.deleted).not.toHaveBeenCalled();
232 });
233
234 it("cleans failed exports and releases the foreground handoff", async () => {
235 mocks.share.mockRejectedValue(new Error("No activity can open this file"));
236 await expect(
237 downloadAndShareAttachment({ ...input, signal: new AbortController().signal }),
238 ).rejects.toThrow("Could not open the share sheet. Try again.");
239 expect(mocks.deleted).toHaveBeenCalledTimes(1);
240 });
241
242 it("removes expired exports while leaving recent and unrelated cache entries alone", async () => {
243 const old = `${CACHE}/${NOW - DAY_MS - 1}-00000000-0000-4000-8000-000000000010`;
244 const recent = `${CACHE}/${NOW - DAY_MS + 1}-00000000-0000-4000-8000-000000000011`;
245 const unrelated = `${CACHE}/unrelated`;
246 mocks.directories.add(old).add(recent).add(unrelated);
247
248 await downloadAndShareAttachment({ ...input, signal: new AbortController().signal });
249 expect(mocks.deleted.mock.calls).toEqual([[old]]);
250 expect(mocks.directories.has(recent)).toBe(true);
251 expect(mocks.directories.has(unrelated)).toBe(true);
252 });
253
254 it("does not prune an active export even if it passes the cache expiry", async () => {
255 const opened = Promise.withResolvers<void>();
256 const share = Promise.withResolvers<void>();
257 mocks.share.mockImplementationOnce(() => {
258 opened.resolve();
259 return share.promise;
260 });
261 const first = downloadAndShareAttachment({ ...input, signal: new AbortController().signal });
262 await opened.promise;
263 vi.mocked(Date.now).mockReturnValue(NOW + DAY_MS + 1);
264
265 await downloadAndShareAttachment({ ...input, signal: new AbortController().signal });
266 expect(mocks.deleted).not.toHaveBeenCalled();
267 share.resolve();
268 await first;
269 });
270});
apps/mobile/src/lib/attachmentDownload.ts created+128
......@@ -0,0 +1,128 @@
1import type { ChatFileAttachment } from "@t3tools/contracts";
2import type { Directory } from "expo-file-system";
3
4import { beginForegroundHandoff } from "./foreground-handoff";
5import { uuidv4 } from "./uuid";
6
7const ATTACHMENT_DOWNLOAD_DIRECTORY = "t3-attachment-downloads";
8const DOWNLOAD_RETENTION_MS = 24 * 60 * 60_000;
9const DOWNLOAD_DIRECTORY_NAME = /^(\d+)-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
10const activeDirectories = new Set<string>();
11
12function downloadFileName(name: string): string {
13 const basename = name.split(/[\\/]/).at(-1) ?? "";
14 const sanitized = Array.from(basename, (character) => {
15 const codePoint = character.codePointAt(0)!;
16 return codePoint < 32 ||
17 (codePoint >= 127 && codePoint <= 159) ||
18 (codePoint >= 0xd800 && codePoint <= 0xdfff)
19 ? "_"
20 : character;
21 })
22 .join("")
23 .trim();
24 if (!sanitized || /^\.+$/.test(sanitized)) {
25 return "attachment";
26 }
27 const encoder = new TextEncoder();
28 if (encoder.encode(sanitized).byteLength <= 255) {
29 return sanitized;
30 }
31 const extensionMatch = /\.[a-z0-9]{1,16}$/i.exec(sanitized);
32 const extension = extensionMatch && extensionMatch.index > 0 ? extensionMatch[0] : "";
33 const stem = extension ? sanitized.slice(0, -extension.length) : sanitized;
34 let remainingBytes = 255 - encoder.encode(extension).byteLength;
35 let shortStem = "";
36 for (const character of stem) {
37 const bytes = encoder.encode(character).byteLength;
38 if (bytes > remainingBytes) break;
39 shortStem += character;
40 remainingBytes -= bytes;
41 }
42 return `${shortStem || "attachment"}${extension}`;
43}
44
45function removeDownloadDirectory(directory: Directory): void {
46 try {
47 if (directory.exists) {
48 directory.delete();
49 }
50 } catch (error) {
51 console.warn("[attachment-downloads] could not remove a cached file", error);
52 }
53}
54
55/** Downloads original bytes for the native save/share sheet, including inline video responses. */
56export async function downloadAndShareAttachment(input: {
57 readonly url: string;
58 readonly attachment: Pick<ChatFileAttachment, "name" | "mimeType">;
59 readonly signal: AbortSignal;
60}): Promise<void> {
61 const [{ Directory, File, Paths }, Sharing] = await Promise.all([
62 import("expo-file-system"),
63 import("expo-sharing"),
64 ]);
65 if (input.signal.aborted) return;
66 const canShare = await Sharing.isAvailableAsync();
67 if (input.signal.aborted) return;
68 if (!canShare) {
69 throw new Error("Saving and sharing files is unavailable on this device.");
70 }
71
72 const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY);
73 cache.create({ idempotent: true, intermediates: true });
74 const now = Date.now();
75 try {
76 for (const entry of cache.list()) {
77 const match = DOWNLOAD_DIRECTORY_NAME.exec(entry.name);
78 if (
79 entry instanceof Directory &&
80 match &&
81 Number(match[1]) < now - DOWNLOAD_RETENTION_MS &&
82 !activeDirectories.has(entry.uri)
83 ) {
84 removeDownloadDirectory(entry);
85 }
86 }
87 } catch (error) {
88 console.warn("[attachment-downloads] could not inspect cached files", error);
89 }
90
91 const directory = new Directory(cache, `${now}-${uuidv4()}`);
92 activeDirectories.add(directory.uri);
93 let shared = false;
94 let openingShareSheet = false;
95 try {
96 directory.create();
97 const destination = new File(directory, downloadFileName(input.attachment.name));
98 const file = await File.downloadFileAsync(input.url, destination, { signal: input.signal });
99 if (input.signal.aborted) return;
100
101 openingShareSheet = true;
102 const endHandoff = beginForegroundHandoff();
103 try {
104 await Sharing.shareAsync(file.uri, {
105 mimeType: input.attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream",
106 dialogTitle: input.attachment.name,
107 });
108 shared = true;
109 } finally {
110 endHandoff();
111 }
112 } catch (cause) {
113 if (input.signal.aborted) return;
114 throw new Error(
115 openingShareSheet
116 ? "Could not open the share sheet. Try again."
117 : "Could not download the attachment. Check the connection and try again.",
118 { cause },
119 );
120 } finally {
121 activeDirectories.delete(directory.uri);
122 // A receiver can still be reading after Android's chooser returns.
123 // Successful exports expire on a later open; partial downloads do not.
124 if (!shared) {
125 removeDownloadDirectory(directory);
126 }
127 }
128}
apps/mobile/src/lib/attachmentUpload.test.ts created+461
......@@ -0,0 +1,461 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5const mocks = vi.hoisted(() => ({
6 documentUri: "file:///documents",
7 createAssetUrl: vi.fn(),
8 createUploadUrl: Symbol("create-upload-url"),
9 executeAtomQuery: vi.fn(),
10 removeUpload: Symbol("remove-upload"),
11 preparedConnection: Symbol("prepared-connection"),
12 runAtomCommand: vi.fn(),
13 readAtom: vi.fn(),
14 upload: vi.fn(),
15}));
16
17vi.mock("@t3tools/client-runtime/state/runtime", () => ({
18 // The client-runtime attachments module resolves the same file through its
19 // relative import, so these fakes also feed runAttachmentUploadCycle and
20 // verifyPersistedAttachmentUpload.
21 createEnvironmentRpcCommand: () => Symbol("rpc-command"),
22 executeAtomQuery: mocks.executeAtomQuery,
23 runAtomCommand: mocks.runAtomCommand,
24 squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
25}));
26
27vi.mock("../state/atom-registry", () => ({
28 appAtomRegistry: { get: mocks.readAtom },
29}));
30
31vi.mock("../state/assets", () => ({
32 assetEnvironment: { createUrl: mocks.createAssetUrl },
33}));
34
35vi.mock("../state/attachments", () => ({
36 attachmentEnvironment: {
37 createUploadUrl: mocks.createUploadUrl,
38 remove: mocks.removeUpload,
39 },
40}));
41
42vi.mock("../state/session", () => ({
43 environmentSession: {
44 preparedConnectionValueAtom: () => mocks.preparedConnection,
45 },
46}));
47
48// Cuts the expo-crypto -> react-native import chain out of the test graph.
49vi.mock("./uuid", () => ({
50 uuidv4: () => "uuid",
51 randomHex: () => "0000",
52}));
53
54vi.mock("expo-file-system", () => ({
55 File: class {
56 constructor(readonly uri: string) {}
57
58 upload(url: string, options: unknown) {
59 return mocks.upload(this.uri, url, options);
60 }
61 },
62 Paths: {
63 get document() {
64 return { uri: mocks.documentUri };
65 },
66 },
67 UploadType: { BINARY_CONTENT: 0 },
68}));
69
70import {
71 prepareTurnAttachments,
72 releasePendingAttachmentUploads,
73 withUploadedMobileAttachmentReferences,
74 validateDraftFileAttachments,
75} from "./attachmentUpload";
76import type { DraftComposerAttachment } from "./composerImages";
77
78const environmentId = EnvironmentId.make("environment-1");
79const MINTED_ID = "pending-00000000-0000-4000-8000-000000000001-pdf";
80
81const image = {
82 id: "image-1",
83 type: "image",
84 name: "screenshot.png",
85 mimeType: "image/png",
86 sizeBytes: 3,
87 dataUrl: "data:image/png;base64,YWJj",
88 previewUri: "file:///images/screenshot.png",
89} as const satisfies DraftComposerAttachment;
90
91const file = {
92 id: "file-1",
93 type: "file",
94 name: "report.pdf",
95 mimeType: "application/pdf",
96 sizeBytes: 42,
97 fileUri: "file:///documents/report.pdf",
98} as const satisfies DraftComposerAttachment;
99
100describe("validateDraftFileAttachments", () => {
101 it("allows legacy image-only sends without server config", () => {
102 expect(validateDraftFileAttachments({ attachments: [image], serverConfig: null })).toBeNull();
103 });
104
105 it("blocks files while config is unknown or file uploads are unsupported", () => {
106 expect(validateDraftFileAttachments({ attachments: [file], serverConfig: null })).toBe(
107 "Server attachment support is still loading.",
108 );
109 expect(
110 validateDraftFileAttachments({
111 attachments: [file],
112 serverConfig: { environment: { capabilities: { attachmentUploads: true } } },
113 }),
114 ).toBe("This server does not support file attachments.");
115 });
116
117 it("uses the current clamped limit and allows valid mixed attachments", () => {
118 const lowerLimit = {
119 environment: {
120 capabilities: {
121 attachmentUploads: true,
122 fileAttachments: { maxUploadBytes: 20 },
123 },
124 },
125 };
126 expect(validateDraftFileAttachments({ attachments: [file], serverConfig: lowerLimit })).toBe(
127 "'report.pdf' exceeds the 20 bytes attachment limit.",
128 );
129 const allowed = {
130 environment: {
131 capabilities: {
132 attachmentUploads: true,
133 fileAttachments: { maxUploadBytes: 100 },
134 },
135 },
136 };
137 expect(
138 validateDraftFileAttachments({ attachments: [image, file], serverConfig: allowed }),
139 ).toBeNull();
140 });
141});
142
143function removeCallsFor(attachmentId: string): number {
144 return mocks.runAtomCommand.mock.calls.filter(
145 ([, command, target]) =>
146 command === mocks.removeUpload &&
147 (target as { input: { attachmentId: string } }).input.attachmentId === attachmentId,
148 ).length;
149}
150
151describe("prepareTurnAttachments", () => {
152 beforeEach(() => {
153 mocks.documentUri = "file:///documents";
154 mocks.createAssetUrl.mockReset();
155 mocks.createAssetUrl.mockImplementation((target: unknown) => target);
156 mocks.executeAtomQuery.mockReset();
157 mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });
158 mocks.runAtomCommand.mockReset();
159 mocks.readAtom.mockReset();
160 mocks.upload.mockReset();
161 mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));
162 mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>
163 command === mocks.createUploadUrl
164 ? {
165 _tag: "Success",
166 value: {
167 attachmentId: MINTED_ID,
168 relativeUrl: "/api/attachments/upload/signed",
169 expiresAt: 1,
170 },
171 }
172 : { _tag: "Success", value: undefined },
173 );
174 mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} });
175 });
176
177 it("keeps existing image attachments on the legacy wire path", async () => {
178 const prepared = await prepareTurnAttachments({ environmentId, attachments: [image] });
179
180 expect(prepared.status).toBe("ready");
181 if (prepared.status !== "ready") return;
182 expect(prepared.attachments).toEqual([
183 {
184 type: "image",
185 name: "screenshot.png",
186 mimeType: "image/png",
187 sizeBytes: 3,
188 dataUrl: "data:image/png;base64,YWJj",
189 },
190 ]);
191 expect(prepared.pendingAttachmentIds).toEqual([]);
192 expect(mocks.upload).not.toHaveBeenCalled();
193 });
194
195 it("uploads generic file bytes directly and keeps mixed attachment order", async () => {
196 const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] });
197
198 expect(mocks.upload).toHaveBeenCalledWith(
199 "file:///documents/report.pdf",
200 "https://environment.example/api/attachments/upload/signed",
201 {
202 httpMethod: "POST",
203 uploadType: 0,
204 headers: { "Content-Type": "application/pdf" },
205 },
206 );
207 expect(prepared.status).toBe("ready");
208 if (prepared.status !== "ready") return;
209 expect(prepared.attachments[0]).toEqual({
210 type: "file",
211 id: MINTED_ID,
212 name: "report.pdf",
213 mimeType: "application/pdf",
214 sizeBytes: 42,
215 });
216 expect(prepared.attachments[1]?.type).toBe("image");
217 expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]);
218 expect(prepared.draftAttachments[0]).toEqual({
219 ...file,
220 uploadedAttachmentId: MINTED_ID,
221 uploadEnvironmentId: environmentId,
222 });
223 });
224
225 it("uses the current connection when an environment reconnects during URL creation", async () => {
226 mocks.readAtom
227 .mockReturnValueOnce(Option.some({ httpBaseUrl: "https://old-environment.example/" }))
228 .mockReturnValueOnce(Option.some({ httpBaseUrl: "https://new-environment.example/" }));
229
230 await prepareTurnAttachments({ environmentId, attachments: [file] });
231
232 expect(mocks.upload).toHaveBeenCalledWith(
233 file.fileUri,
234 "https://new-environment.example/api/attachments/upload/signed",
235 expect.anything(),
236 );
237 });
238
239 it("uploads a restored draft file from the current iOS document container", async () => {
240 const fileName = "33333333-3333-4333-8333-333333333333-report%20%23.pdf";
241 const restoredFile = {
242 ...file,
243 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,
244 };
245 mocks.documentUri =
246 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
247 const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`;
248 mocks.upload.mockImplementation(async (uri: string) => {
249 if (uri !== currentUri) {
250 throw new Error("File does not exist in the previous application container.");
251 }
252 return { status: 204, body: "", headers: {} };
253 });
254
255 const prepared = await prepareTurnAttachments({
256 environmentId,
257 attachments: [restoredFile],
258 });
259
260 expect(prepared.status).toBe("ready");
261 expect(mocks.upload).toHaveBeenCalledWith(
262 currentUri,
263 "https://environment.example/api/attachments/upload/signed",
264 expect.anything(),
265 );
266 });
267
268 it("adds uploaded file references to durable drafts without changing images", () => {
269 expect(
270 withUploadedMobileAttachmentReferences({
271 environmentId,
272 attachments: [file, image],
273 uploadedAttachments: [
274 {
275 type: "file",
276 id: "pending-existing-pdf",
277 name: file.name,
278 mimeType: file.mimeType,
279 sizeBytes: file.sizeBytes,
280 },
281 {
282 type: "image",
283 name: image.name,
284 mimeType: image.mimeType,
285 sizeBytes: image.sizeBytes,
286 dataUrl: image.dataUrl,
287 },
288 ],
289 }),
290 ).toEqual([
291 {
292 ...file,
293 uploadedAttachmentId: "pending-existing-pdf",
294 uploadEnvironmentId: environmentId,
295 },
296 image,
297 ]);
298 });
299
300 it("reuses a pending file upload from a previous outbox attempt", async () => {
301 const previouslyUploaded = {
302 ...file,
303 uploadedAttachmentId: "pending-existing-pdf",
304 uploadEnvironmentId: environmentId,
305 };
306
307 const prepared = await prepareTurnAttachments({
308 environmentId,
309 attachments: [previouslyUploaded, image],
310 });
311
312 expect(prepared.status).toBe("ready");
313 if (prepared.status !== "ready") return;
314 expect(prepared.attachments).toEqual([
315 {
316 type: "file",
317 id: "pending-existing-pdf",
318 name: "report.pdf",
319 mimeType: "application/pdf",
320 sizeBytes: 42,
321 },
322 {
323 type: "image",
324 name: "screenshot.png",
325 mimeType: "image/png",
326 sizeBytes: 3,
327 dataUrl: "data:image/png;base64,YWJj",
328 },
329 ]);
330 expect(prepared.pendingAttachmentIds).toEqual(["pending-existing-pdf"]);
331 expect(mocks.upload).not.toHaveBeenCalled();
332 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
333 });
334
335 it("uploads a file again when its saved pending upload has expired", async () => {
336 mocks.executeAtomQuery.mockResolvedValueOnce({
337 _tag: "Failure",
338 error: { _tag: "AssetAttachmentNotFoundError" },
339 });
340 const previouslyUploaded = {
341 ...file,
342 uploadedAttachmentId: "pending-expired-pdf",
343 uploadEnvironmentId: environmentId,
344 };
345
346 const prepared = await prepareTurnAttachments({
347 environmentId,
348 attachments: [previouslyUploaded],
349 });
350
351 expect(mocks.upload).toHaveBeenCalledOnce();
352 expect(prepared.status).toBe("ready");
353 if (prepared.status !== "ready") return;
354 expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]);
355 });
356
357 it("removes pending uploads when the native HTTP request fails", async () => {
358 mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} });
359
360 await expect(prepareTurnAttachments({ environmentId, attachments: [file] })).rejects.toThrow(
361 "Upload failed for 'report.pdf' (500).",
362 );
363 expect(removeCallsFor(MINTED_ID)).toBe(1);
364 });
365
366 it("keeps a previously persisted upload when a later attachment fails", async () => {
367 const previouslyUploaded = {
368 ...file,
369 id: "file-existing",
370 uploadedAttachmentId: "pending-existing-pdf",
371 uploadEnvironmentId: environmentId,
372 };
373 mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} });
374
375 await expect(
376 prepareTurnAttachments({ environmentId, attachments: [previouslyUploaded, file] }),
377 ).rejects.toThrow("Upload failed for 'report.pdf' (500).");
378
379 expect(removeCallsFor("pending-existing-pdf")).toBe(0);
380 });
381
382 it("deletes the minted uploads when the owner abandons the send", async () => {
383 const result = await prepareTurnAttachments({
384 environmentId,
385 attachments: [file],
386 persistUploadedReferences: async () => "abandon",
387 });
388
389 expect(result.status).toBe("abandoned");
390 expect(removeCallsFor(MINTED_ID)).toBe(1);
391 });
392
393 it("deletes the minted uploads when persisting the references throws", async () => {
394 await expect(
395 prepareTurnAttachments({
396 environmentId,
397 attachments: [file],
398 persistUploadedReferences: async () => {
399 throw new Error("draft write failed");
400 },
401 }),
402 ).rejects.toThrow("draft write failed");
403
404 expect(removeCallsFor(MINTED_ID)).toBe(1);
405 });
406
407 it("skips persisting when every reference is already stored", async () => {
408 const previouslyUploaded = {
409 ...file,
410 uploadedAttachmentId: "pending-existing-pdf",
411 uploadEnvironmentId: environmentId,
412 };
413 const persist = vi.fn(async () => "persisted" as const);
414
415 const prepared = await prepareTurnAttachments({
416 environmentId,
417 attachments: [previouslyUploaded],
418 persistUploadedReferences: persist,
419 });
420
421 expect(prepared.status).toBe("ready");
422 expect(persist).not.toHaveBeenCalled();
423 });
424});
425
426describe("releasePendingAttachmentUploads", () => {
427 beforeEach(() => {
428 mocks.runAtomCommand.mockReset();
429 });
430
431 it("retries a failed delete once before reporting it", async () => {
432 mocks.runAtomCommand
433 .mockResolvedValueOnce({ _tag: "Failure", error: new Error("offline") })
434 .mockResolvedValue({ _tag: "Success", value: undefined });
435
436 await expect(
437 releasePendingAttachmentUploads(environmentId, ["pending-a"]),
438 ).resolves.toBeUndefined();
439 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(2);
440 });
441
442 it("throws when a delete keeps failing so the caller sees the leak", async () => {
443 mocks.runAtomCommand.mockResolvedValue({ _tag: "Failure", error: new Error("offline") });
444
445 await expect(releasePendingAttachmentUploads(environmentId, ["pending-a"])).rejects.toThrow(
446 "pending-a",
447 );
448 });
449
450 it("treats an already-deleted pending upload as released", async () => {
451 mocks.runAtomCommand.mockResolvedValue({
452 _tag: "Failure",
453 error: { _tag: "AssetAttachmentNotFoundError" },
454 });
455
456 await expect(
457 releasePendingAttachmentUploads(environmentId, ["pending-a"]),
458 ).resolves.toBeUndefined();
459 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
460 });
461});
apps/mobile/src/lib/attachmentUpload.ts created+318
......@@ -0,0 +1,318 @@
1import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
2import {
3 clampFileAttachmentUploadBytes,
4 fileAttachmentTooLargeMessage,
5 isAssetAttachmentNotFoundFailure,
6 runAttachmentUploadCycle,
7 verifyPersistedAttachmentUpload,
8} from "@t3tools/client-runtime/state/attachments";
9import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
10import type {
11 ChatFileAttachment,
12 EnvironmentId,
13 UploadChatImageAttachment,
14} from "@t3tools/contracts";
15import * as Option from "effect/Option";
16
17import { appAtomRegistry } from "../state/atom-registry";
18import { assetEnvironment } from "../state/assets";
19import { attachmentEnvironment } from "../state/attachments";
20import { environmentSession } from "../state/session";
21import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
22import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
23
24/**
25 * This module owns the server side of a composer attachment's lifecycle.
26 * `prepareTurnAttachments` acquires pending uploads (verifying and reusing
27 * persisted ones), hands the uploaded ids back to the attachment's durable
28 * owner (queued outbox message or composer draft), and returns a release
29 * handle for after the turn consumed the bytes. Nothing outside this module
30 * mints or deletes pending uploads. The local-file side of the lifecycle is
31 * owned by `removeThreadOutboxMessage` / the composer draft mutators, which
32 * release files through `releaseUnusedComposerAttachmentFiles`.
33 */
34export type UploadedMobileAttachment = UploadChatImageAttachment | ChatFileAttachment;
35
36export function validateDraftFileAttachments(input: {
37 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
38 readonly serverConfig: {
39 readonly environment: {
40 readonly capabilities: {
41 readonly attachmentUploads?: boolean;
42 readonly fileAttachments?: { readonly maxUploadBytes: number };
43 };
44 };
45 } | null;
46}): string | null {
47 const files = input.attachments.filter((attachment) => attachment.type === "file");
48 if (files.length === 0) return null;
49 if (input.serverConfig === null) return "Server attachment support is still loading.";
50 const capabilities = input.serverConfig.environment.capabilities;
51 if (capabilities.attachmentUploads !== true || capabilities.fileAttachments === undefined) {
52 return "This server does not support file attachments.";
53 }
54 const maxBytes = clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes);
55 const oversized = files.find((attachment) => attachment.sizeBytes > maxBytes);
56 return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null;
57}
58
59/** Keep uploaded file ids on durable drafts so a later send can reuse their bytes. */
60export function withUploadedMobileAttachmentReferences(input: {
61 readonly environmentId: EnvironmentId;
62 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
63 readonly uploadedAttachments: ReadonlyArray<UploadedMobileAttachment>;
64}): ReadonlyArray<DraftComposerAttachment> {
65 return input.attachments.map((attachment, index) => {
66 const uploaded = input.uploadedAttachments[index];
67 if (
68 attachment.type !== "file" ||
69 uploaded?.type !== "file" ||
70 (attachment.uploadedAttachmentId === uploaded.id &&
71 attachment.uploadEnvironmentId === input.environmentId)
72 ) {
73 return attachment;
74 }
75 return {
76 ...attachment,
77 uploadedAttachmentId: uploaded.id,
78 uploadEnvironmentId: input.environmentId,
79 };
80 });
81}
82
83/**
84 * Deletes pending uploads the client no longer references. Every delete result
85 * is inspected; failed deletes are retried once and a persistent failure
86 * throws, so a caller can never silently leak the outcome. (The server also
87 * expires pending uploads, so a leaked id self-heals eventually.)
88 */
89export async function releasePendingAttachmentUploads(
90 environmentId: EnvironmentId,
91 attachmentIds: ReadonlyArray<string>,
92): Promise<void> {
93 const deleteOnce = async (attachmentId: string): Promise<boolean> => {
94 const result = await runAtomCommand(
95 appAtomRegistry,
96 attachmentEnvironment.remove,
97 { environmentId, input: { attachmentId } },
98 { reportFailure: false, reportDefect: false },
99 );
100 return (
101 result._tag === "Success" ||
102 isAssetAttachmentNotFoundFailure(squashAtomCommandFailure(result))
103 );
104 };
105
106 const failedAttachmentIds: string[] = [];
107 for (const attachmentId of attachmentIds) {
108 if (!(await deleteOnce(attachmentId)) && !(await deleteOnce(attachmentId))) {
109 failedAttachmentIds.push(attachmentId);
110 }
111 }
112 if (failedAttachmentIds.length > 0) {
113 throw new Error(
114 `Could not delete ${failedAttachmentIds.length} pending attachment upload(s): ${failedAttachmentIds.join(", ")}.`,
115 );
116 }
117}
118
119async function releaseCreatedUploadsQuietly(
120 environmentId: EnvironmentId,
121 attachmentIds: ReadonlyArray<string>,
122): Promise<void> {
123 try {
124 await releasePendingAttachmentUploads(environmentId, attachmentIds);
125 } catch (error) {
126 // The original failure must propagate; the leaked pending uploads expire
127 // on the server.
128 console.warn("[attachments] could not delete abandoned pending uploads", error);
129 }
130}
131
132export interface PreparedTurnAttachments {
133 readonly status: "ready";
134 /** Wire attachments for `startTurn`, in the original composer order. */
135 readonly attachments: ReadonlyArray<UploadedMobileAttachment>;
136 /** Composer attachments annotated with the uploaded pending ids. */
137 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
138 /** Every pending upload backing this turn (reused and newly minted). */
139 readonly pendingAttachmentIds: ReadonlyArray<string>;
140 /** Deletes all pending uploads once the delivered turn holds the bytes. */
141 readonly releaseUploads: () => Promise<void>;
142}
143
144export type PrepareTurnAttachmentsResult =
145 | PreparedTurnAttachments
146 | { readonly status: "abandoned" };
147
148async function uploadFileBytes(
149 attachment: Extract<DraftComposerAttachment, { readonly type: "file" }>,
150 url: string,
151): Promise<void> {
152 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}).`);
163 }
164}
165
166/**
167 * Acquires server-side uploads for one turn's attachments and persists the
168 * uploaded ids into the attachments' durable owner.
169 *
170 * `persistUploadedReferences` runs once the bytes are on the server and only
171 * when new ids appeared. It must write the annotated attachments into the
172 * owner (queued message or draft) so a retry after a crash reuses the bytes.
173 * Returning `"abandon"` (owner no longer wants the send) or throwing deletes
174 * the pending uploads this call minted, so the owner cannot leak them.
175 */
176export async function prepareTurnAttachments(input: {
177 readonly environmentId: EnvironmentId;
178 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
179 readonly persistUploadedReferences?: (
180 draftAttachments: ReadonlyArray<DraftComposerAttachment>,
181 ) => Promise<"persisted" | "abandon">;
182}): Promise<PrepareTurnAttachmentsResult> {
183 const { environmentId } = input;
184 const files = input.attachments.filter((attachment) => attachment.type === "file");
185 const ready = (
186 attachments: ReadonlyArray<UploadedMobileAttachment>,
187 pendingAttachmentIds: ReadonlyArray<string>,
188 draftAttachments: ReadonlyArray<DraftComposerAttachment>,
189 ): PreparedTurnAttachments => ({
190 status: "ready",
191 attachments,
192 draftAttachments,
193 pendingAttachmentIds,
194 releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds),
195 });
196
197 if (files.length === 0) {
198 return ready(
199 toUploadChatImageAttachments(
200 input.attachments.filter((attachment) => attachment.type === "image"),
201 ),
202 [],
203 input.attachments,
204 );
205 }
206
207 const connection = appAtomRegistry.get(
208 environmentSession.preparedConnectionValueAtom(environmentId),
209 );
210 if (Option.isNone(connection)) {
211 throw new Error("The environment is not connected.");
212 }
213
214 const uploadedAttachments: UploadedMobileAttachment[] = [];
215 const pendingAttachmentIds: string[] = [];
216 const createdAttachmentIds: string[] = [];
217 try {
218 for (const attachment of input.attachments) {
219 if (attachment.type === "image") {
220 uploadedAttachments.push(...toUploadChatImageAttachments([attachment]));
221 continue;
222 }
223
224 // Reuse the bytes from a previous attempt when their pending upload is
225 // still alive on this environment.
226 if (
227 attachment.uploadEnvironmentId === environmentId &&
228 attachment.uploadedAttachmentId !== undefined
229 ) {
230 const verification = await verifyPersistedAttachmentUpload({
231 registry: appAtomRegistry,
232 createAssetUrl: assetEnvironment.createUrl,
233 environmentId,
234 attachmentId: attachment.uploadedAttachmentId,
235 });
236 if (verification.status === "failed") {
237 throw verification.error;
238 }
239 if (verification.status === "verified") {
240 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 });
248 continue;
249 }
250 // "missing": the pending upload expired, upload the bytes again.
251 }
252
253 const result = await runAttachmentUploadCycle({
254 registry: appAtomRegistry,
255 createUploadUrl: attachmentEnvironment.createUploadUrl,
256 remove: attachmentEnvironment.remove,
257 environmentId,
258 upload: {
259 type: "file",
260 name: attachment.name,
261 mimeType: attachment.mimeType,
262 sizeBytes: attachment.sizeBytes,
263 },
264 // Read the connection at transfer time: the environment may have
265 // reconnected on a new base URL since this cycle started.
266 resolveUploadUrl: (relativeUrl) => {
267 const currentConnection = appAtomRegistry.get(
268 environmentSession.preparedConnectionValueAtom(environmentId),
269 );
270 return Option.isNone(currentConnection)
271 ? null
272 : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl);
273 },
274 transport: (url) => ({
275 done: uploadFileBytes(attachment, url),
276 // expo-file-system uploads cannot abort mid-flight.
277 abort: () => {},
278 }),
279 onMinted: (attachmentId) => {
280 pendingAttachmentIds.push(attachmentId);
281 createdAttachmentIds.push(attachmentId);
282 return "continue";
283 },
284 });
285 if (result.status !== "uploaded") {
286 throw result.status === "failed" && result.error !== undefined
287 ? result.error
288 : new Error(`Upload failed for '${attachment.name}'.`);
289 }
290 uploadedAttachments.push({
291 type: "file",
292 id: result.attachmentId,
293 name: attachment.name,
294 mimeType: attachment.mimeType,
295 sizeBytes: attachment.sizeBytes,
296 });
297 }
298
299 const draftAttachments = withUploadedMobileAttachmentReferences({
300 environmentId,
301 attachments: input.attachments,
302 uploadedAttachments,
303 });
304 const referencesChanged = draftAttachments.some(
305 (attachment, index) => attachment !== input.attachments[index],
306 );
307 if (referencesChanged && input.persistUploadedReferences) {
308 if ((await input.persistUploadedReferences(draftAttachments)) === "abandon") {
309 await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);
310 return { status: "abandoned" };
311 }
312 }
313 return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments);
314 } catch (error) {
315 await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);
316 throw error;
317 }
318}
apps/mobile/src/lib/composer-image-schema.ts+17
......@@ -1,4 +1,5 @@
11import * as Schema from "effect/Schema";
2import { EnvironmentId } from "@t3tools/contracts";
23
34export const DraftComposerImageAttachmentSchema = Schema.Struct({
45 id: Schema.String,
......@@ -9,3 +10,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({
910 sizeBytes: Schema.Number,
1011 dataUrl: Schema.String,
1112});
13
14export const DraftComposerFileAttachmentSchema = Schema.Struct({
15 id: Schema.String,
16 type: Schema.Literal("file"),
17 name: Schema.String,
18 mimeType: Schema.String,
19 sizeBytes: Schema.Number,
20 fileUri: Schema.String,
21 uploadedAttachmentId: Schema.optional(Schema.String),
22 uploadEnvironmentId: Schema.optional(EnvironmentId),
23});
24
25export const DraftComposerAttachmentSchema = Schema.Union([
26 DraftComposerImageAttachmentSchema,
27 DraftComposerFileAttachmentSchema,
28]);
apps/mobile/src/lib/composerAttachmentFiles.test.ts created+56
......@@ -0,0 +1,56 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 composerAttachmentFileReferenceKey,
5 resolveOwnedComposerAttachmentFileUri,
6} from "./composerAttachmentFiles";
7
8const OLD_CONTAINER = "11111111-1111-4111-8111-111111111111";
9const CURRENT_CONTAINER = "22222222-2222-4222-8222-222222222222";
10const FILE_NAME = "33333333-3333-4333-8333-333333333333-report%20%252F%20%23.pdf";
11
12describe("owned attachment paths", () => {
13 it.each([
14 "file:///var/mobile/Containers/Data/Application/",
15 "file:///Users/dev/Library/Developer/CoreSimulator/Devices/device/data/Containers/Data/Application/",
16 ])("resolves saved files after an iOS container move under %s", (prefix) => {
17 const oldUri = `${prefix}${OLD_CONTAINER}/Documents/t3-composer-attachments/${FILE_NAME}`;
18 const documentUri = `${prefix}${CURRENT_CONTAINER}/Documents/`;
19 const currentUri = `${documentUri}t3-composer-attachments/${FILE_NAME}`;
20
21 expect(resolveOwnedComposerAttachmentFileUri(oldUri, documentUri)).toBe(currentUri);
22 expect(composerAttachmentFileReferenceKey(oldUri)).toBe(
23 composerAttachmentFileReferenceKey(currentUri),
24 );
25 });
26
27 it("recognizes the private/var alias without changing the stored filename", () => {
28 const oldUri = `file:///private/var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/t3-composer-attachments/${FILE_NAME}`;
29 const documentUri = `file:///var/mobile/Containers/Data/Application/${CURRENT_CONTAINER}/Documents/`;
30 const currentUri = `${documentUri}t3-composer-attachments/${FILE_NAME}`;
31
32 expect(resolveOwnedComposerAttachmentFileUri(oldUri, documentUri)).toBe(currentUri);
33 expect(composerAttachmentFileReferenceKey(oldUri)).toBe(
34 composerAttachmentFileReferenceKey(currentUri),
35 );
36 });
37
38 it.each([
39 `file:///private/var/mobile/Containers/Shared/FileProvider/other/Documents/t3-composer-attachments/${FILE_NAME}`,
40 `file:///var/mobile/Containers/Shared/AppGroup/other/t3-composer-attachments/${FILE_NAME}`,
41 `file:///var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/report.pdf`,
42 `file:///var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/t3-composer-attachments/report.pdf`,
43 `file:///downloads/t3-composer-attachments/${FILE_NAME}`,
44 `content://shared/t3-composer-attachments/${FILE_NAME}`,
45 `https://example.com/t3-composer-attachments/${FILE_NAME}`,
46 `file:///var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/t3-composer-attachments/..%2F..%2Fsender.pdf`,
47 `file:///var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/t3-composer-attachments/${FILE_NAME}%2Fnested.pdf`,
48 ])("does not rebase an external or escaped path: %s", (uri) => {
49 expect(
50 resolveOwnedComposerAttachmentFileUri(
51 uri,
52 `file:///var/mobile/Containers/Data/Application/${CURRENT_CONTAINER}/Documents/`,
53 ),
54 ).toBeNull();
55 });
56});
apps/mobile/src/lib/composerAttachmentFiles.ts created+82
......@@ -0,0 +1,82 @@
1export const COMPOSER_ATTACHMENT_DIRECTORY = "t3-composer-attachments";
2
3const UUID_PATTERN = "[a-f\\d]{8}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{12}";
4const GENERATED_FILE_NAME = new RegExp(`^${UUID_PATTERN}-`, "i");
5const IOS_DOCUMENTS_PATH = new RegExp(
6 `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`,
7 "i",
8);
9
10function fileUriPath(uri: string): string | null {
11 try {
12 const url = new URL(uri);
13 if (url.protocol !== "file:" || url.hostname || url.search || url.hash) {
14 return null;
15 }
16 const path = decodeURIComponent(url.pathname);
17 if (path.includes("\\") || path.includes("\0") || path.split("/").includes("..")) {
18 return null;
19 }
20 return path.startsWith("/private/var/") ? path.slice("/private".length) : path;
21 } catch {
22 return null;
23 }
24}
25
26function ownedFileLocation(uri: string) {
27 const path = fileUriPath(uri);
28 if (path === null) {
29 return null;
30 }
31 const separator = `/${COMPOSER_ATTACHMENT_DIRECTORY}/`;
32 const index = path.lastIndexOf(separator);
33 const name = index < 0 ? "" : path.slice(index + separator.length);
34 if (!name || name === "." || name.includes("/")) {
35 return null;
36 }
37 return { documentPath: path.slice(0, index), name };
38}
39
40/** Compares references across iOS data-container moves without rewriting saved drafts. */
41export function composerAttachmentFileReferenceKey(uri: string): string {
42 const location = ownedFileLocation(uri);
43 if (!location) {
44 return uri;
45 }
46 const containerPrefix = GENERATED_FILE_NAME.test(location.name)
47 ? IOS_DOCUMENTS_PATH.exec(location.documentPath)?.[1]
48 : undefined;
49 const documentPath = containerPrefix
50 ? `${containerPrefix}<app>/Documents`
51 : location.documentPath;
52 return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`;
53}
54
55/**
56 * Resolves only our saved attachment copies. iOS preserves Documents on updates
57 * but can change its container UUID. Picker and open-in-place source URIs must
58 * bypass this resolver so another app's document keeps its original location.
59 */
60export function resolveOwnedComposerAttachmentFileUri(
61 uri: string,
62 documentDirectoryUri: string,
63): string | null {
64 const location = ownedFileLocation(uri);
65 const documentPath = fileUriPath(documentDirectoryUri)?.replace(/\/+$/, "");
66 if (!location || !documentPath) {
67 return null;
68 }
69 if (location.documentPath !== documentPath) {
70 const currentContainerPrefix = IOS_DOCUMENTS_PATH.exec(documentPath)?.[1];
71 if (
72 !currentContainerPrefix ||
73 currentContainerPrefix !== IOS_DOCUMENTS_PATH.exec(location.documentPath)?.[1] ||
74 !GENERATED_FILE_NAME.test(location.name)
75 ) {
76 return null;
77 }
78 }
79 const resolved = new URL(documentDirectoryUri);
80 resolved.pathname = `${resolved.pathname.replace(/\/+$/, "")}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`;
81 return resolved.href;
82}
apps/mobile/src/lib/composerFiles.test.ts created+386
......@@ -0,0 +1,386 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 documentUri: "file:///documents",
5 pickFile: vi.fn(),
6 copy: vi.fn(),
7 delete: vi.fn(),
8 open: vi.fn(),
9 size: vi.fn(),
10}));
11
12vi.mock("expo-file-system", () => {
13 class Directory {
14 readonly uri: string;
15
16 constructor(root: string | { readonly uri: string }, name: string) {
17 this.uri = `${typeof root === "string" ? root : root.uri}/${name}`;
18 }
19
20 create(): void {}
21 }
22
23 class File {
24 static pickFileAsync = mocks.pickFile;
25
26 readonly uri: string;
27
28 constructor(source: string | Directory, name?: string) {
29 this.uri = source instanceof Directory ? `${source.uri}/${name}` : source;
30 }
31
32 get exists(): boolean {
33 return true;
34 }
35
36 get size(): number | null {
37 return mocks.size(this.uri) ?? null;
38 }
39
40 create(): void {}
41
42 open(mode: string) {
43 return mocks.open(this.uri, mode);
44 }
45
46 async copy(destination: File): Promise<void> {
47 mocks.copy(this.uri, destination.uri);
48 }
49
50 delete(): void {
51 mocks.delete(this.uri);
52 }
53 }
54
55 return {
56 Directory,
57 File,
58 FileMode: { ReadOnly: "r", WriteOnly: "w" },
59 Paths: {
60 get document() {
61 return { uri: mocks.documentUri };
62 },
63 },
64 };
65});
66
67vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" }));
68
69import {
70 persistComposerAttachmentFile,
71 pickComposerFiles,
72 removePersistedComposerAttachmentFile,
73} from "./composerImages";
74
75describe("pickComposerFiles", () => {
76 beforeEach(() => {
77 mocks.documentUri = "file:///documents";
78 mocks.pickFile.mockReset();
79 mocks.copy.mockReset();
80 mocks.delete.mockReset();
81 mocks.open.mockReset();
82 mocks.size.mockReset();
83 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
84 });
85
86 it("copies picked files into app-owned storage without loading their contents", async () => {
87 mocks.pickFile.mockResolvedValue({
88 canceled: false,
89 result: [
90 {
91 uri: "file:///downloads/report.pdf",
92 name: "report.pdf",
93 type: "application/pdf",
94 size: 42,
95 },
96 ],
97 });
98
99 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
100 files: [
101 {
102 id: "attachment-id",
103 type: "file",
104 name: "report.pdf",
105 mimeType: "application/pdf",
106 sizeBytes: 42,
107 fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
108 },
109 ],
110 error: null,
111 });
112 expect(mocks.copy).toHaveBeenCalledWith(
113 "file:///downloads/report.pdf",
114 "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
115 );
116 });
117
118 it("falls back to a usable name when the picker reports a blank one", async () => {
119 mocks.pickFile.mockResolvedValue({
120 canceled: false,
121 result: [
122 {
123 uri: "file:///downloads/unnamed",
124 name: " ",
125 type: "application/pdf",
126 size: 42,
127 },
128 ],
129 });
130
131 const result = await pickComposerFiles({ existingCount: 0 });
132 expect(result.error).toBeNull();
133 expect(result.files).toHaveLength(1);
134 expect(result.files[0]?.name).toBe("file");
135 });
136
137 it("rejects files that exceed the environment's advertised upload limit", async () => {
138 mocks.pickFile.mockResolvedValue({
139 canceled: false,
140 result: [
141 {
142 uri: "file:///downloads/archive.zip",
143 name: "archive.zip",
144 type: "application/zip",
145 size: 2 * 1024 * 1024,
146 },
147 ],
148 });
149
150 await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({
151 files: [],
152 error: "'archive.zip' exceeds the 1 MB attachment limit.",
153 });
154 expect(mocks.copy).not.toHaveBeenCalled();
155 });
156
157 it("never accepts files above the 50 MB contract limit", async () => {
158 mocks.pickFile.mockResolvedValue({
159 canceled: false,
160 result: [
161 {
162 uri: "file:///downloads/archive.zip",
163 name: "archive.zip",
164 type: "application/zip",
165 size: 51 * 1024 * 1024,
166 },
167 ],
168 });
169
170 await expect(
171 pickComposerFiles({ existingCount: 0, maxBytes: 80 * 1024 * 1024 }),
172 ).resolves.toEqual({
173 files: [],
174 error: "'archive.zip' exceeds the 50 MB attachment limit.",
175 });
176 });
177
178 it("rejects a file that grew after the picker reported its size", async () => {
179 mocks.pickFile.mockResolvedValue({
180 canceled: false,
181 result: [
182 {
183 uri: "file:///downloads/archive.zip",
184 name: "archive.zip",
185 type: "application/zip",
186 size: 42,
187 },
188 ],
189 });
190 mocks.size.mockReturnValue(2 * 1024 * 1024);
191
192 await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({
193 files: [],
194 error: "'archive.zip' exceeds the 1 MB attachment limit.",
195 });
196 expect(mocks.copy).not.toHaveBeenCalled();
197 });
198
199 it("stops copying an unknown-size content URI when it exceeds the attachment limit", async () => {
200 const maxBytes = 1024 * 1024;
201 let remainingBytes = maxBytes + 1;
202 const source = {
203 readBytes: vi.fn((length: number) => {
204 const size = Math.min(length, remainingBytes);
205 remainingBytes -= size;
206 return new Uint8Array(size);
207 }),
208 close: vi.fn(),
209 };
210 const destination = { writeBytes: vi.fn(), close: vi.fn() };
211 mocks.open.mockImplementation((uri: string) =>
212 uri.startsWith("content:") ? source : destination,
213 );
214
215 await expect(
216 persistComposerAttachmentFile("content://shared/large", "large.bin", maxBytes),
217 ).rejects.toThrow("'large.bin' exceeds the 1 MB attachment limit.");
218
219 expect(source.close).toHaveBeenCalledOnce();
220 expect(destination.close).toHaveBeenCalledOnce();
221 expect(mocks.delete).toHaveBeenCalledWith(
222 "file:///documents/t3-composer-attachments/attachment-id-large.bin",
223 );
224 expect(mocks.copy).not.toHaveBeenCalled();
225 });
226
227 it("rejects a copy that delivered more bytes than the source reported", async () => {
228 const maxBytes = 1024 * 1024;
229 // An Android content: stream can report a small size and still deliver
230 // more bytes; the persisted copy is what must satisfy the limit.
231 mocks.size.mockImplementation((uri: string) =>
232 uri.startsWith("content:") ? 42 : 2 * 1024 * 1024,
233 );
234
235 await expect(
236 persistComposerAttachmentFile("content://shared/liar", "liar.bin", maxBytes),
237 ).rejects.toThrow("'liar.bin' exceeds the 1 MB attachment limit.");
238
239 expect(mocks.copy).toHaveBeenCalledOnce();
240 expect(mocks.delete).toHaveBeenCalledWith(
241 "file:///documents/t3-composer-attachments/attachment-id-liar.bin",
242 );
243 });
244
245 it("reports an empty file without calling it oversized", async () => {
246 mocks.size.mockReturnValue(0);
247 mocks.pickFile.mockResolvedValue({
248 canceled: false,
249 result: [
250 {
251 uri: "file:///downloads/empty.txt",
252 name: "empty.txt",
253 type: "text/plain",
254 size: 0,
255 },
256 ],
257 });
258
259 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
260 files: [],
261 error: "'empty.txt' is empty or could not be read.",
262 });
263 });
264
265 it("copies an Android SAF file when the picker reports an unknown zero size", async () => {
266 const reader = {
267 readBytes: vi
268 .fn()
269 .mockReturnValueOnce(new Uint8Array(42))
270 .mockReturnValueOnce(new Uint8Array()),
271 close: vi.fn(),
272 };
273 const writer = { writeBytes: vi.fn(), close: vi.fn() };
274 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? 0 : 42));
275 mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer));
276 mocks.pickFile.mockResolvedValue({
277 canceled: false,
278 result: [
279 {
280 uri: "content://shared/report",
281 name: "report.pdf",
282 type: "application/pdf",
283 size: 0,
284 },
285 ],
286 });
287
288 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
289 files: [
290 {
291 id: "attachment-id",
292 type: "file",
293 name: "report.pdf",
294 mimeType: "application/pdf",
295 sizeBytes: 42,
296 fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
297 },
298 ],
299 error: null,
300 });
301 });
302
303 it("uses the remaining slot for the first valid file after an oversized selection", async () => {
304 mocks.pickFile.mockResolvedValue({
305 canceled: false,
306 result: [
307 {
308 uri: "file:///downloads/huge.zip",
309 name: "huge.zip",
310 type: "application/zip",
311 size: 2 * 1024 * 1024,
312 },
313 {
314 uri: "file:///downloads/report.pdf",
315 name: "report.pdf",
316 type: "application/pdf",
317 size: 42,
318 },
319 ],
320 });
321
322 const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 });
323
324 expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]);
325 });
326
327 it("removes the partial destination file when a copy fails midway", async () => {
328 mocks.copy.mockImplementation(() => {
329 throw new Error("disk full");
330 });
331
332 await expect(
333 persistComposerAttachmentFile("file:///downloads/report.pdf", "report.pdf"),
334 ).rejects.toThrow("disk full");
335
336 expect(mocks.delete).toHaveBeenCalledWith(
337 "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
338 );
339 });
340
341 it("deletes app-owned attachments without touching user-owned files", async () => {
342 await removePersistedComposerAttachmentFile(
343 "file:///documents/t3-composer-attachments/report.pdf",
344 );
345 await removePersistedComposerAttachmentFile("file:///downloads/report.pdf");
346
347 expect(mocks.delete).toHaveBeenCalledOnce();
348 expect(mocks.delete).toHaveBeenCalledWith(
349 "file:///documents/t3-composer-attachments/report.pdf",
350 );
351 });
352
353 it("removes a restored attachment from the current iOS document container", async () => {
354 const fileName = "33333333-3333-4333-8333-333333333333-report%20%23.pdf";
355 const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`;
356 mocks.documentUri =
357 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
358
359 await removePersistedComposerAttachmentFile(oldUri);
360 await removePersistedComposerAttachmentFile(
361 `file:///var/mobile/Containers/Shared/FileProvider/other/Documents/t3-composer-attachments/${fileName}`,
362 );
363 await removePersistedComposerAttachmentFile(
364 `${mocks.documentUri}/t3-composer-attachments/..%2F..%2Fsender.pdf`,
365 );
366
367 expect(mocks.delete.mock.calls).toEqual([
368 [`${mocks.documentUri}/t3-composer-attachments/${fileName}`],
369 ]);
370 });
371
372 it("copies an open-in-place source from its actual container without rebasing it", async () => {
373 const sourceUri =
374 "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-report.pdf";
375 mocks.documentUri =
376 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
377
378 await persistComposerAttachmentFile(sourceUri, "report.pdf");
379
380 expect(mocks.copy).toHaveBeenCalledWith(
381 sourceUri,
382 `${mocks.documentUri}/t3-composer-attachments/attachment-id-report.pdf`,
383 );
384 expect(mocks.delete).not.toHaveBeenCalled();
385 });
386});
apps/mobile/src/lib/composerImages.ts+207
......@@ -1,10 +1,21 @@
1import {
2 clampFileAttachmentUploadBytes,
3 fileAttachmentTooLargeMessage,
4} from "@t3tools/client-runtime/state/attachments";
15import {
26 isProviderSendTurnSupportedImageMimeType,
37 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
49 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
10 type EnvironmentId,
511 type UploadChatImageAttachment,
612} from "@t3tools/contracts";
13import type { PickMultipleFilesResult } from "expo-file-system";
714import { estimateBase64ByteSize } from "./base64";
15import {
16 COMPOSER_ATTACHMENT_DIRECTORY,
17 resolveOwnedComposerAttachmentFileUri,
18} from "./composerAttachmentFiles";
819import { beginForegroundHandoff } from "./foreground-handoff";
920import { uuidv4 } from "./uuid";
1021
......@@ -13,6 +24,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment
1324 readonly previewUri: string;
1425}
1526
27export interface DraftComposerFileAttachment {
28 readonly id: string;
29 readonly type: "file";
30 readonly name: string;
31 readonly mimeType: string;
32 readonly sizeBytes: number;
33 readonly fileUri: string;
34 readonly uploadedAttachmentId?: string;
35 readonly uploadEnvironmentId?: EnvironmentId;
36}
37
38export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;
39
1640/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */
1741export function toUploadChatImageAttachments(
1842 attachments: ReadonlyArray<DraftComposerImageAttachment>,
......@@ -27,6 +51,189 @@ export function toUploadChatImageAttachments(
2751}
2852
2953const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";
54const ATTACHMENT_COPY_CHUNK_BYTES = 64 * 1024;
55
56export async function persistComposerAttachmentFile(
57 uri: string,
58 name: string,
59 maxBytes?: number,
60): Promise<string> {
61 const { Directory, File, FileMode, Paths } = await import("expo-file-system");
62 const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY);
63 directory.create({ idempotent: true, intermediates: true });
64 const safeName =
65 Array.from(name, (character) =>
66 character === "/" || character === "\\" || character.charCodeAt(0) < 32 ? "-" : character,
67 ).join("") || "file";
68 const destination = new File(directory, `${uuidv4()}-${safeName}`);
69 const source = new File(uri);
70 const sourceSize = source.size;
71 if (
72 maxBytes !== undefined &&
73 (sourceSize === null || (sourceSize === 0 && uri.startsWith("content:")))
74 ) {
75 destination.create();
76 try {
77 const reader = source.open(FileMode.ReadOnly);
78 try {
79 const writer = destination.open(FileMode.WriteOnly);
80 try {
81 let copiedBytes = 0;
82 while (true) {
83 const chunk = reader.readBytes(
84 Math.min(ATTACHMENT_COPY_CHUNK_BYTES, maxBytes - copiedBytes + 1),
85 );
86 if (chunk.byteLength === 0) {
87 break;
88 }
89 copiedBytes += chunk.byteLength;
90 if (copiedBytes > maxBytes) {
91 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
92 }
93 writer.writeBytes(chunk);
94 }
95 } finally {
96 writer.close();
97 }
98 } finally {
99 reader.close();
100 }
101 } catch (error) {
102 if (destination.exists) {
103 destination.delete();
104 }
105 throw error;
106 }
107 return destination.uri;
108 }
109
110 if (maxBytes !== undefined && sourceSize !== null && sourceSize > maxBytes) {
111 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
112 }
113 try {
114 await source.copy(destination);
115 } catch (error) {
116 // A failed copy can leave a partial destination file behind with no URI
117 // returned to release it later; delete it before surfacing the failure.
118 try {
119 if (destination.exists) {
120 destination.delete();
121 }
122 } catch (cleanupError) {
123 console.warn("[composer-attachments] could not remove a partial copy", cleanupError);
124 }
125 throw error;
126 }
127 // An Android content: stream can deliver more bytes than the size it
128 // reported before the copy. Validate the persisted copy so an oversized
129 // file is never retained under a stale recorded size.
130 const copiedSize = destination.size;
131 if (maxBytes !== undefined && copiedSize !== null && copiedSize > maxBytes) {
132 try {
133 if (destination.exists) {
134 destination.delete();
135 }
136 } catch (cleanupError) {
137 console.warn("[composer-attachments] could not remove an oversized copy", cleanupError);
138 }
139 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
140 }
141 return destination.uri;
142}
143
144export async function removePersistedComposerAttachmentFile(uri: string): Promise<void> {
145 try {
146 const { File, Paths } = await import("expo-file-system");
147 const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri);
148 if (ownedUri === null) {
149 return;
150 }
151 const file = new File(ownedUri);
152 if (file.exists) {
153 file.delete();
154 }
155 } catch (error) {
156 console.warn("[composer-attachments] could not remove local file", error);
157 }
158}
159
160export async function pickComposerFiles(input: {
161 readonly existingCount: number;
162 readonly maxBytes?: number;
163}): Promise<{
164 readonly files: ReadonlyArray<DraftComposerFileAttachment>;
165 readonly error: string | null;
166}> {
167 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
168 if (remainingSlots <= 0) {
169 return {
170 files: [],
171 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
172 };
173 }
174
175 const { File } = await import("expo-file-system");
176 const endHandoff = beginForegroundHandoff();
177 let result: PickMultipleFilesResult;
178 try {
179 result = await File.pickFileAsync({ multipleFiles: true });
180 } finally {
181 endHandoff();
182 }
183 if (result.canceled) {
184 return { files: [], error: null };
185 }
186
187 const maxBytes = clampFileAttachmentUploadBytes(
188 input.maxBytes ?? PROVIDER_SEND_TURN_MAX_FILE_BYTES,
189 );
190 const attachments: DraftComposerFileAttachment[] = [];
191 let error: string | null = null;
192 let exceededAttachmentLimit = false;
193 for (const file of result.result) {
194 if (attachments.length >= remainingSlots) {
195 exceededAttachmentLimit = true;
196 break;
197 }
198 // A SAF/document picker can hand back a blank display name; the wire
199 // contract rejects empty names at send time, so fall back before the name
200 // reaches storage, errors, or the attachment itself.
201 const name = file.name.trim().length > 0 ? file.name : "file";
202 const sizeBytes = file.size ?? null;
203 if (sizeBytes !== null && sizeBytes > maxBytes) {
204 error = fileAttachmentTooLargeMessage(name, maxBytes);
205 continue;
206 }
207 try {
208 const fileUri = await persistComposerAttachmentFile(file.uri, name, maxBytes);
209 const storedSizeBytes = new File(fileUri).size ?? sizeBytes ?? 0;
210 if (storedSizeBytes <= 0) {
211 await removePersistedComposerAttachmentFile(fileUri);
212 error = `'${name}' is empty or could not be read.`;
213 continue;
214 }
215 if (storedSizeBytes > maxBytes) {
216 await removePersistedComposerAttachmentFile(fileUri);
217 error = fileAttachmentTooLargeMessage(name, maxBytes);
218 continue;
219 }
220 attachments.push({
221 id: uuidv4(),
222 type: "file",
223 name,
224 mimeType: file.type || "application/octet-stream",
225 sizeBytes: storedSizeBytes,
226 fileUri,
227 });
228 } catch (cause) {
229 error = cause instanceof Error ? cause.message : `Could not read '${name}'.`;
230 }
231 }
232 if (exceededAttachmentLimit) {
233 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
234 }
235 return { files: attachments, error };
236}
30237
31238async function loadImagePicker() {
32239 try {
apps/mobile/src/lib/projectThreadStartTurn.ts+10-3
......@@ -2,13 +2,15 @@ import {
22 CommandId,
33 MessageId,
44 ThreadId,
5 type ChatFileAttachment,
56 type ModelSelection,
67 type ProjectId,
78 type ProviderInteractionMode,
89 type RuntimeMode,
10 type UploadChatImageAttachment,
911} from "@t3tools/contracts";
1012
11import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages";
13import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
1214
1315export function deriveThreadTitleFromPrompt(value: string): string {
1416 const trimmed = value.trim();
......@@ -28,7 +30,8 @@ export interface ProjectThreadStartTurnSpec {
2830 readonly messageId: string;
2931 readonly createdAt: string;
3032 readonly text: string;
31 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
33 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
34 readonly uploadedAttachments?: ReadonlyArray<UploadChatImageAttachment | ChatFileAttachment>;
3235 readonly modelSelection: ModelSelection;
3336 readonly runtimeMode: RuntimeMode;
3437 readonly interactionMode: ProviderInteractionMode;
......@@ -55,7 +58,11 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
5558 messageId: MessageId.make(spec.messageId),
5659 role: "user" as const,
5760 text: spec.text,
58 attachments: toUploadChatImageAttachments(spec.attachments),
61 attachments:
62 spec.uploadedAttachments ??
63 toUploadChatImageAttachments(
64 spec.attachments.filter((attachment) => attachment.type === "image"),
65 ),
5966 },
6067 modelSelection: spec.modelSelection,
6168 titleSeed: title,
apps/mobile/src/state/attachments.ts created+5
......@@ -0,0 +1,5 @@
1import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime);
apps/mobile/src/state/pending-task-editor-writes.test.ts created+353
......@@ -0,0 +1,353 @@
1import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import type { QueuedThreadMessage } from "./thread-outbox-model";
5
6const harness = vi.hoisted(() => ({
7 manager: null as unknown as ReturnType<
8 typeof import("./thread-outbox-manager").createThreadOutboxManager
9 >,
10 writeGates: [] as Array<{
11 readonly promise: Promise<void>;
12 readonly started: (message: QueuedThreadMessage) => void;
13 }>,
14}));
15
16vi.mock("./thread-outbox", async () => {
17 const { createThreadOutboxManager } = await import("./thread-outbox-manager");
18 const { appAtomRegistry } = await import("./atom-registry");
19 harness.manager = createThreadOutboxManager({
20 registry: appAtomRegistry,
21 storage: {
22 load: async () => [],
23 write: async (message) => {
24 const pending = harness.writeGates.shift();
25 if (pending) {
26 pending.started(message);
27 await pending.promise;
28 }
29 },
30 remove: async () => undefined,
31 },
32 });
33 const manager = harness.manager;
34 return {
35 threadOutboxManager: manager,
36 flushThreadOutbox: async () => undefined,
37 threadOutboxRevision: (messageId: QueuedThreadMessage["messageId"]) =>
38 manager.revisionOf(messageId),
39 updateThreadOutboxMessage: (message: QueuedThreadMessage, expectedRevision?: number) =>
40 manager.update(message, expectedRevision),
41 };
42});
43
44import { appAtomRegistry } from "./atom-registry";
45import {
46 capturePendingTaskEditorWriteBaseline,
47 flushPendingTaskEditorWrite,
48} from "./pending-task-editor-writes";
49import {
50 composerDraftsAtom,
51 getComposerDraftSnapshot,
52 type ComposerDraft,
53} from "./use-composer-drafts";
54
55function queuedMessage(messageId: string, text: string): QueuedThreadMessage {
56 return {
57 environmentId: EnvironmentId.make("environment-1"),
58 threadId: ThreadId.make("thread-1"),
59 messageId: MessageId.make(messageId),
60 commandId: CommandId.make(`command-${messageId}`),
61 text,
62 attachments: [],
63 createdAt: "2026-08-28T12:00:00.000Z",
64 };
65}
66
67function draft(text: string): ComposerDraft {
68 return {
69 text,
70 attachments: [],
71 runtimeMode: "full-access",
72 };
73}
74
75function setDraft(draftKey: string, value: ComposerDraft): void {
76 appAtomRegistry.set(composerDraftsAtom, { [draftKey]: value });
77}
78
79function queuedMessageText(messageId: QueuedThreadMessage["messageId"]): string | null {
80 const messages = Object.values(
81 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
82 ).flat();
83 return messages.find((message) => message.messageId === messageId)?.text ?? null;
84}
85
86function blockNextWrite() {
87 let resolveWrite!: () => void;
88 let rejectWrite!: (error: Error) => void;
89 let markStarted!: (message: QueuedThreadMessage) => void;
90 const promise = new Promise<void>((resolve, reject) => {
91 resolveWrite = resolve;
92 rejectWrite = reject;
93 });
94 const started = new Promise<QueuedThreadMessage>((resolve) => {
95 markStarted = resolve;
96 });
97 harness.writeGates.push({ promise, started: markStarted });
98 return {
99 started,
100 resolve: resolveWrite,
101 reject: rejectWrite,
102 };
103}
104
105beforeEach(() => {
106 harness.writeGates.length = 0;
107 appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {});
108 appAtomRegistry.set(composerDraftsAtom, {});
109});
110
111describe("pending task editor writes", () => {
112 it("chains a reopened editor that closes before the previous save finishes", async () => {
113 const original = queuedMessage("message-close-before-save", "original");
114 const firstEdit = queuedMessage("message-close-before-save", "first edit");
115 const secondEdit = queuedMessage("message-close-before-save", "second edit");
116 const draftKey = "pending-task:message-close-before-save";
117 await harness.manager.enqueue(original);
118
119 setDraft(draftKey, draft(firstEdit.text));
120 const firstBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
121 const firstWriteGate = blockNextWrite();
122 const firstSave = flushPendingTaskEditorWrite({
123 message: firstEdit,
124 baseline: firstBaseline,
125 draftKey,
126 });
127 await firstWriteGate.started;
128
129 const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
130 setDraft(draftKey, draft(secondEdit.text));
131 const secondWriteGate = blockNextWrite();
132 const secondSave = flushPendingTaskEditorWrite({
133 message: secondEdit,
134 baseline: secondBaseline,
135 draftKey,
136 });
137
138 firstWriteGate.resolve();
139 await expect(firstSave).resolves.toBe(false);
140 await expect(secondWriteGate.started).resolves.toMatchObject({ text: secondEdit.text });
141 secondWriteGate.resolve();
142
143 await expect(secondSave).resolves.toBe(true);
144 expect(queuedMessageText(original.messageId)).toBe(secondEdit.text);
145 });
146
147 it("keeps a captured predecessor after that predecessor finishes", async () => {
148 const original = queuedMessage("message-finished-predecessor", "original");
149 const firstEdit = queuedMessage("message-finished-predecessor", "first edit");
150 const secondEdit = queuedMessage("message-finished-predecessor", "second edit");
151 const draftKey = "pending-task:message-finished-predecessor";
152 await harness.manager.enqueue(original);
153
154 setDraft(draftKey, draft(firstEdit.text));
155 const firstWriteGate = blockNextWrite();
156 const firstSave = flushPendingTaskEditorWrite({
157 message: firstEdit,
158 baseline: capturePendingTaskEditorWriteBaseline(original.messageId),
159 draftKey,
160 });
161 await firstWriteGate.started;
162 const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
163
164 firstWriteGate.resolve();
165 await expect(firstSave).resolves.toBe(true);
166
167 setDraft(draftKey, draft(secondEdit.text));
168 const secondWriteGate = blockNextWrite();
169 const secondSave = flushPendingTaskEditorWrite({
170 message: secondEdit,
171 baseline: secondBaseline,
172 draftKey,
173 });
174 await secondWriteGate.started;
175 secondWriteGate.resolve();
176
177 await expect(secondSave).resolves.toBe(true);
178 expect(queuedMessageText(original.messageId)).toBe(secondEdit.text);
179 });
180
181 it("chains three rapid editor saves in order", async () => {
182 const original = queuedMessage("message-three-saves", "original");
183 const firstEdit = queuedMessage("message-three-saves", "first edit");
184 const secondEdit = queuedMessage("message-three-saves", "second edit");
185 const thirdEdit = queuedMessage("message-three-saves", "third edit");
186 const draftKey = "pending-task:message-three-saves";
187 await harness.manager.enqueue(original);
188
189 setDraft(draftKey, draft(firstEdit.text));
190 const firstWriteGate = blockNextWrite();
191 const firstSave = flushPendingTaskEditorWrite({
192 message: firstEdit,
193 baseline: capturePendingTaskEditorWriteBaseline(original.messageId),
194 draftKey,
195 });
196 await firstWriteGate.started;
197
198 const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
199 setDraft(draftKey, draft(secondEdit.text));
200 const secondWriteGate = blockNextWrite();
201 const secondSave = flushPendingTaskEditorWrite({
202 message: secondEdit,
203 baseline: secondBaseline,
204 draftKey,
205 });
206
207 const thirdBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
208 setDraft(draftKey, draft(thirdEdit.text));
209 const thirdWriteGate = blockNextWrite();
210 const thirdSave = flushPendingTaskEditorWrite({
211 message: thirdEdit,
212 baseline: thirdBaseline,
213 draftKey,
214 });
215
216 firstWriteGate.resolve();
217 await expect(firstSave).resolves.toBe(false);
218 await secondWriteGate.started;
219 secondWriteGate.resolve();
220 await expect(secondSave).resolves.toBe(false);
221 await thirdWriteGate.started;
222 thirdWriteGate.resolve();
223
224 await expect(thirdSave).resolves.toBe(true);
225 expect(queuedMessageText(original.messageId)).toBe(thirdEdit.text);
226 });
227
228 it("keeps the handed-off revision when the middle editor write fails", async () => {
229 const original = queuedMessage("message-middle-failure", "original");
230 const firstEdit = queuedMessage("message-middle-failure", "first edit");
231 const failedEdit = queuedMessage("message-middle-failure", "failed edit");
232 const finalEdit = queuedMessage("message-middle-failure", "final edit");
233 const draftKey = "pending-task:message-middle-failure";
234 await harness.manager.enqueue(original);
235
236 setDraft(draftKey, draft(firstEdit.text));
237 const firstWriteGate = blockNextWrite();
238 const firstSave = flushPendingTaskEditorWrite({
239 message: firstEdit,
240 baseline: capturePendingTaskEditorWriteBaseline(original.messageId),
241 draftKey,
242 });
243 await firstWriteGate.started;
244
245 const failedBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
246 setDraft(draftKey, draft(failedEdit.text));
247 const failedWriteGate = blockNextWrite();
248 const failedSave = flushPendingTaskEditorWrite({
249 message: failedEdit,
250 baseline: failedBaseline,
251 draftKey,
252 });
253
254 const finalBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
255 setDraft(draftKey, draft(finalEdit.text));
256 const finalWriteGate = blockNextWrite();
257 const finalSave = flushPendingTaskEditorWrite({
258 message: finalEdit,
259 baseline: finalBaseline,
260 draftKey,
261 });
262
263 firstWriteGate.resolve();
264 await expect(firstSave).resolves.toBe(false);
265 await failedWriteGate.started;
266 failedWriteGate.reject(new Error("disk full"));
267 await expect(failedSave).rejects.toMatchObject({ _tag: "ThreadOutboxManagerError" });
268 await finalWriteGate.started;
269 finalWriteGate.resolve();
270
271 await expect(finalSave).resolves.toBe(true);
272 expect(queuedMessageText(original.messageId)).toBe(finalEdit.text);
273 });
274
275 it("does not overwrite an unrelated update accepted after capture", async () => {
276 const original = queuedMessage("message-unrelated-update", "original");
277 const editorEdit = queuedMessage("message-unrelated-update", "editor edit");
278 const unrelatedEdit = queuedMessage("message-unrelated-update", "unrelated edit");
279 const draftKey = "pending-task:message-unrelated-update";
280 await harness.manager.enqueue(original);
281
282 const editorBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
283 const revision = harness.manager.revisionOf(original.messageId);
284 await expect(harness.manager.update(unrelatedEdit, revision)).resolves.toBe(true);
285 setDraft(draftKey, draft(editorEdit.text));
286
287 await expect(
288 flushPendingTaskEditorWrite({
289 message: editorEdit,
290 baseline: editorBaseline,
291 draftKey,
292 }),
293 ).resolves.toBe(false);
294 expect(queuedMessageText(original.messageId)).toBe(unrelatedEdit.text);
295 });
296
297 it("lets a later editor retry after its predecessor write fails", async () => {
298 const original = queuedMessage("message-write-retry", "original");
299 const failedEdit = queuedMessage("message-write-retry", "failed edit");
300 const retryEdit = queuedMessage("message-write-retry", "retry edit");
301 const draftKey = "pending-task:message-write-retry";
302 await harness.manager.enqueue(original);
303
304 setDraft(draftKey, draft(failedEdit.text));
305 const failedWriteGate = blockNextWrite();
306 const failedSave = flushPendingTaskEditorWrite({
307 message: failedEdit,
308 baseline: capturePendingTaskEditorWriteBaseline(original.messageId),
309 draftKey,
310 });
311 await failedWriteGate.started;
312
313 const retryBaseline = capturePendingTaskEditorWriteBaseline(original.messageId);
314 setDraft(draftKey, draft(retryEdit.text));
315 const retryWriteGate = blockNextWrite();
316 const retrySave = flushPendingTaskEditorWrite({
317 message: retryEdit,
318 baseline: retryBaseline,
319 draftKey,
320 });
321
322 failedWriteGate.reject(new Error("disk full"));
323 await expect(failedSave).rejects.toMatchObject({ _tag: "ThreadOutboxManagerError" });
324 await retryWriteGate.started;
325 retryWriteGate.resolve();
326
327 await expect(retrySave).resolves.toBe(true);
328 expect(queuedMessageText(original.messageId)).toBe(retryEdit.text);
329 });
330
331 it("does not permit cleanup after a newer editor makes the draft unsendable", async () => {
332 const original = queuedMessage("message-unsendable-draft", "original");
333 const editorEdit = queuedMessage("message-unsendable-draft", "saved edit");
334 const draftKey = "pending-task:message-unsendable-draft";
335 await harness.manager.enqueue(original);
336
337 setDraft(draftKey, draft(editorEdit.text));
338 const writeGate = blockNextWrite();
339 const save = flushPendingTaskEditorWrite({
340 message: editorEdit,
341 baseline: capturePendingTaskEditorWriteBaseline(original.messageId),
342 draftKey,
343 });
344 await writeGate.started;
345
346 setDraft(draftKey, draft(""));
347 writeGate.resolve();
348
349 await expect(save).resolves.toBe(false);
350 expect(getComposerDraftSnapshot(draftKey).text).toBe("");
351 expect(queuedMessageText(original.messageId)).toBe(editorEdit.text);
352 });
353});
apps/mobile/src/state/pending-task-editor-writes.ts created+89
......@@ -0,0 +1,89 @@
1import type { QueuedThreadMessage } from "./thread-outbox";
2import { threadOutboxRevision, updateThreadOutboxMessage } from "./thread-outbox";
3import { getComposerDraftSnapshot, sameComposerDraftState } from "./use-composer-drafts";
4
5type PendingTaskEditorWriteResult =
6 | {
7 readonly status: "complete";
8 readonly updated: boolean;
9 readonly nextRevision: number;
10 }
11 | {
12 readonly status: "failed";
13 readonly error: unknown;
14 readonly nextRevision: number;
15 };
16
17const pendingWrites = new Map<
18 QueuedThreadMessage["messageId"],
19 Promise<PendingTaskEditorWriteResult>
20>();
21
22/**
23 * Captures this editor's outbox revision and any editor save it must follow.
24 * The returned promise keeps that predecessor even after its map entry clears.
25 */
26export function capturePendingTaskEditorWriteBaseline(
27 messageId: QueuedThreadMessage["messageId"],
28): Promise<number> {
29 const capturedRevision = threadOutboxRevision(messageId);
30 const predecessor = pendingWrites.get(messageId);
31 if (!predecessor) {
32 return Promise.resolve(capturedRevision);
33 }
34 return predecessor.then(
35 ({ nextRevision }) => Math.max(capturedRevision, nextRevision),
36 () => capturedRevision,
37 );
38}
39
40/**
41 * Saves one dismissed editor after its captured predecessor. A true result
42 * means both the outbox write and this editor's draft snapshot still match.
43 */
44export function flushPendingTaskEditorWrite(input: {
45 readonly message: QueuedThreadMessage;
46 readonly baseline: Promise<number>;
47 readonly draftKey: string;
48}): Promise<boolean> {
49 const { message } = input;
50 const draftSnapshot = getComposerDraftSnapshot(input.draftKey);
51 const write = input.baseline.then(
52 async (expectedRevision): Promise<PendingTaskEditorWriteResult> => {
53 try {
54 const updated = await updateThreadOutboxMessage(message, expectedRevision);
55 return {
56 status: "complete",
57 updated,
58 nextRevision: expectedRevision + (updated ? 1 : 0),
59 };
60 } catch (error) {
61 // A failed write does not advance the outbox, but later editor saves
62 // still need the expected revision handed off by its predecessor.
63 return {
64 status: "failed",
65 error,
66 nextRevision: expectedRevision,
67 };
68 }
69 },
70 );
71
72 pendingWrites.set(message.messageId, write);
73 const removeWrite = (): void => {
74 if (pendingWrites.get(message.messageId) === write) {
75 pendingWrites.delete(message.messageId);
76 }
77 };
78 void write.then(removeWrite, removeWrite);
79
80 return write.then((result) => {
81 if (result.status === "failed") {
82 throw result.error;
83 }
84 return (
85 result.updated &&
86 sameComposerDraftState(draftSnapshot, getComposerDraftSnapshot(input.draftKey))
87 );
88 });
89}
apps/mobile/src/state/thread-outbox-manager.ts+195-31
......@@ -46,8 +46,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
4646 ((message: string, error: unknown) => {
4747 console.warn(message, error);
4848 });
49 let loadPromise: Promise<void> | null = null;
49 let loadPromise: Promise<boolean> | null = null;
5050 let mutationQueue: Promise<void> = Promise.resolve();
51 // Monotonic per-message write counter. Every accepted write (enqueue publish
52 // or update) bumps it, so a writer that captured a revision before slow work
53 // (an attachment upload) is rejected before its stale payload reaches disk.
54 const revisions = new Map<MessageId, number>();
55 const bumpRevision = (messageId: MessageId): void => {
56 revisions.set(messageId, (revisions.get(messageId) ?? 0) + 1);
57 };
5158
5259 const serialize = <A>(mutation: () => Promise<A>): Promise<A> => {
5360 const result = mutationQueue.then(mutation, mutation);
......@@ -65,13 +72,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
6572 options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages));
6673 };
6774
68 const load = (): Promise<void> => {
75 // Resolves true when hydration completed; false when the read failed (the
76 // next call retries). Destructive callers (the attachment sweep) must not
77 // treat a failed hydration as an empty queue.
78 const load = (): Promise<boolean> => {
6979 if (loadPromise !== null) {
7080 return loadPromise;
7181 }
7282 loadPromise = serialize(async () => {
7383 const persistedMessages = await options.storage.load();
7484 setMessages([...persistedMessages, ...currentMessages()]);
85 return true;
7586 }).catch((cause) => {
7687 loadPromise = null;
7788 warn(
......@@ -84,6 +95,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
8495 cause,
8596 }),
8697 );
98 return false;
8799 });
88100 return loadPromise;
89101 };
......@@ -93,6 +105,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
93105 // the message back out if it fails (durability only matters for crash
94106 // recovery, not for the in-session queue).
95107 const enqueue = (message: QueuedThreadMessage): Promise<void> => {
108 bumpRevision(message.messageId);
96109 setMessages([
97110 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
98111 message,
......@@ -105,6 +118,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
105118 // id may have optimistically replaced this attempt while the write was
106119 // in flight, and its entry must survive this attempt's failure.
107120 setMessages(currentMessages().filter((candidate) => candidate !== message));
121 // A concurrent update losing its post-write race compensates by
122 // persisting this message's payload before this write settles. When
123 // no same-id entry survives the rollback, drop that disk copy too, or
124 // a restart resurrects a message the queue no longer holds.
125 if (!currentMessages().some((candidate) => candidate.messageId === message.messageId)) {
126 try {
127 await options.storage.remove(message);
128 } catch {
129 // Best effort: bootstrap reconciles the queue against storage.
130 }
131 }
108132 throw new ThreadOutboxManagerError({
109133 operation: "enqueue",
110134 environmentId: message.environmentId,
......@@ -126,12 +150,22 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
126150 // Rewrites an already-queued message. A no-op when the message has been
127151 // removed in the meantime (e.g. deleted or delivered), so a trailing editor
128152 // flush can never resurrect it. Returns whether the message was updated.
129 const update = (message: QueuedThreadMessage): Promise<boolean> =>
153 //
154 // `expectedRevision` makes the update a compare-and-set: pass the revision
155 // read before starting slow work, and the update is rejected before the
156 // stale payload is persisted when any other write was accepted since. An
157 // enqueue can still publish synchronously while the durable write below is
158 // in flight, so the revision is re-checked after the write too; the stale
159 // payload it just persisted is then overwritten with the winning payload
160 // inside this mutation, so a crash before the winner's own serialized write
161 // cannot leave stale state on disk.
162 const update = (message: QueuedThreadMessage, expectedRevision?: number): Promise<boolean> =>
130163 serialize(async () => {
131 const exists = currentMessages().some(
132 (candidate) => candidate.messageId === message.messageId,
133 );
134 if (!exists) {
164 const staleOrMissing = (): boolean =>
165 !currentMessages().some((candidate) => candidate.messageId === message.messageId) ||
166 (expectedRevision !== undefined &&
167 (revisions.get(message.messageId) ?? 0) !== expectedRevision);
168 if (staleOrMissing()) {
135169 return false;
136170 }
137171 try {
......@@ -145,6 +179,21 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
145179 cause,
146180 });
147181 }
182 if (staleOrMissing()) {
183 const winner = currentMessages().find(
184 (candidate) => candidate.messageId === message.messageId,
185 );
186 if (winner !== undefined) {
187 try {
188 await options.storage.write(winner);
189 } catch {
190 // The winner's own serialized write follows this mutation and
191 // owns the failure handling for its payload.
192 }
193 }
194 return false;
195 }
196 bumpRevision(message.messageId);
148197 setMessages([
149198 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
150199 message,
......@@ -152,8 +201,29 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
152201 return true;
153202 });
154203
155 const remove = (message: QueuedThreadMessage): Promise<void> =>
204 // `expectedRevision` makes the removal a compare-and-set too: an edit
205 // accepted after the caller decided to remove (restore-to-composer reads
206 // the payload it is about to delete) keeps the newer message queued.
207 // `canRemove` adds a live ownership check for state such as an open editor,
208 // which can change without writing a new message revision.
209 const remove = (
210 message: QueuedThreadMessage,
211 expectedRevision?: number,
212 canRemove?: () => boolean,
213 ): Promise<QueuedThreadMessage | null> =>
156214 serialize(async () => {
215 const removalCanceled = (): boolean =>
216 (expectedRevision !== undefined &&
217 (revisions.get(message.messageId) ?? 0) !== expectedRevision) ||
218 canRemove?.() === false;
219 if (removalCanceled()) {
220 return null;
221 }
222 // The live payload may carry attachments an accepted update added after
223 // the caller's snapshot; the caller releases files from what actually
224 // leaves the queue.
225 const removed =
226 currentMessages().find((candidate) => candidate.messageId === message.messageId) ?? message;
157227 try {
158228 await options.storage.remove(message);
159229 } catch (cause) {
......@@ -165,13 +235,46 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
165235 cause,
166236 });
167237 }
238 if (removalCanceled()) {
239 // An enqueue or editor lock can win while storage removal is in
240 // flight. Restore the live payload here, before any queued mutation
241 // gets its turn, so this canceled removal is durable on its own.
242 const winner = currentMessages().find(
243 (candidate) => candidate.messageId === message.messageId,
244 );
245 if (winner !== undefined) {
246 try {
247 await options.storage.write(winner);
248 } catch (cause) {
249 throw new ThreadOutboxManagerError({
250 operation: "remove",
251 environmentId: message.environmentId,
252 threadId: message.threadId,
253 messageId: message.messageId,
254 cause,
255 });
256 }
257 }
258 return null;
259 }
168260 setMessages(
169261 currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
170262 );
263 // Tombstone, not delete: a same-id retry restarting at revision 1 would
264 // otherwise match a stale writer's expectedRevision from before the
265 // removal (ABA).
266 bumpRevision(message.messageId);
267 return removed;
171268 });
172269
173 const clearEnvironment = (environmentId: EnvironmentId): Promise<void> =>
174 serialize(async () => {
270 const clearEnvironment = (
271 environmentId: EnvironmentId,
272 ): Promise<ReadonlyArray<QueuedThreadMessage>> => {
273 // Enqueues publish before their serialized writes. Capture revisions now,
274 // but wait for earlier mutations before reading messages: a message that
275 // changes after this request must not enter the clear set.
276 const revisionsAtRequest = new Map(revisions);
277 return serialize(async () => {
175278 const persisted = await options.storage.load().catch((cause) => {
176279 warn(
177280 "[thread-outbox] failed to load messages while clearing environment",
......@@ -188,32 +291,91 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
188291 const allMessages = flattenQueuedThreadMessages(
189292 groupQueuedThreadMessages([...persisted, ...currentMessages()]),
190293 );
191 const removedMessageIds = new Set<MessageId>();
294 const candidates = allMessages.filter(
295 (message) =>
296 message.environmentId === environmentId &&
297 (revisions.get(message.messageId) ?? 0) ===
298 (revisionsAtRequest.get(message.messageId) ?? 0),
299 );
300 const candidateRevisions = new Map(
301 candidates.map(
302 (message) => [message.messageId, revisions.get(message.messageId) ?? 0] as const,
303 ),
304 );
305 const removedFromStorage = new Set<MessageId>();
192306
193307 await Promise.all(
194 allMessages
195 .filter((message) => message.environmentId === environmentId)
196 .map(async (message) => {
197 try {
198 await options.storage.remove(message);
199 removedMessageIds.add(message.messageId);
200 } catch (cause) {
201 warn(
202 "[thread-outbox] failed to clear persisted message",
203 new ThreadOutboxManagerError({
204 operation: "clear-environment-remove",
205 environmentId: message.environmentId,
206 threadId: message.threadId,
207 messageId: message.messageId,
208 cause,
209 }),
210 );
211 }
212 }),
308 candidates.map(async (message) => {
309 try {
310 await options.storage.remove(message);
311 removedFromStorage.add(message.messageId);
312 } catch (cause) {
313 warn(
314 "[thread-outbox] failed to clear persisted message",
315 new ThreadOutboxManagerError({
316 operation: "clear-environment-remove",
317 environmentId: message.environmentId,
318 threadId: message.threadId,
319 messageId: message.messageId,
320 cause,
321 }),
322 );
323 }
324 }),
213325 );
214326
215 setMessages(allMessages.filter((message) => !removedMessageIds.has(message.messageId)));
327 // A same-id enqueue can publish while one of the removes above waits.
328 // Put its payload back before the later serialized enqueue write runs.
329 await Promise.all(
330 candidates.map(async (message) => {
331 if (
332 !removedFromStorage.has(message.messageId) ||
333 (revisions.get(message.messageId) ?? 0) === candidateRevisions.get(message.messageId)
334 ) {
335 return;
336 }
337 const retained = currentMessages().find(
338 (candidate) => candidate.messageId === message.messageId,
339 );
340 if (retained === undefined) {
341 return;
342 }
343 try {
344 await options.storage.write(retained);
345 } catch (cause) {
346 warn(
347 "[thread-outbox] failed to restore message retained during environment clear",
348 new ThreadOutboxManagerError({
349 operation: "clear-environment-remove",
350 environmentId: retained.environmentId,
351 threadId: retained.threadId,
352 messageId: retained.messageId,
353 cause,
354 }),
355 );
356 }
357 }),
358 );
359
360 const removed = candidates.filter(
361 (message) =>
362 removedFromStorage.has(message.messageId) &&
363 (revisions.get(message.messageId) ?? 0) === candidateRevisions.get(message.messageId),
364 );
365 const removedMessageIds = new Set(removed.map((message) => message.messageId));
366 const reconciledMessages = flattenQueuedThreadMessages(
367 groupQueuedThreadMessages([...allMessages, ...currentMessages()]),
368 ).filter((message) => !removedMessageIds.has(message.messageId));
369 for (const message of removed) {
370 bumpRevision(message.messageId);
371 }
372 setMessages(reconciledMessages);
373 // The caller releases these messages' attachment files; reporting what
374 // was actually removed keeps the release set honest even when this
375 // function's own load produced the messages.
376 return removed;
216377 });
378 };
217379
218380 return {
219381 queuedMessagesByThreadKeyAtom,
......@@ -221,6 +383,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
221383 load,
222384 enqueue,
223385 confirmQueued,
386 /** Current write revision for a queued message; input to update's CAS. */
387 revisionOf: (messageId: MessageId): number => revisions.get(messageId) ?? 0,
224388 update,
225389 remove,
226390 clearEnvironment,
apps/mobile/src/state/thread-outbox-model.ts+52-6
......@@ -1,4 +1,8 @@
11import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
2import {
3 clampFileAttachmentUploadBytes,
4 fileAttachmentTooLargeMessage,
5} from "@t3tools/client-runtime/state/attachments";
26import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
37import {
48 CommandId,
......@@ -17,8 +21,8 @@ import {
1721} from "@t3tools/contracts";
1822import * as Schema from "effect/Schema";
1923
20import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
21import type { DraftComposerImageAttachment } from "../lib/composerImages";
24import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
25import type { DraftComposerAttachment } from "../lib/composerImages";
2226import { scopedThreadKey } from "../lib/scopedEntities";
2327
2428const THREAD_OUTBOX_SCHEMA_VERSION = 3;
......@@ -43,7 +47,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({
4347 messageId: MessageId,
4448 commandId: CommandId,
4549 text: Schema.String,
46 attachments: Schema.Array(DraftComposerImageAttachmentSchema),
50 attachments: Schema.Array(DraftComposerAttachmentSchema),
4751 modelSelection: Schema.optional(ModelSelection),
4852 runtimeMode: Schema.optional(RuntimeMode),
4953 interactionMode: Schema.optional(ProviderInteractionMode),
......@@ -72,7 +76,7 @@ export interface QueuedThreadMessage {
7276 readonly messageId: MessageId;
7377 readonly commandId: CommandId;
7478 readonly text: string;
75 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
79 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
7680 readonly modelSelection?: ModelSelectionType;
7781 readonly runtimeMode?: RuntimeModeType;
7882 readonly interactionMode?: ProviderInteractionModeType;
......@@ -172,6 +176,48 @@ export function resolveThreadOutboxDeliveryAction(input: {
172176 return input.environmentConnected ? "send" : "wait";
173177}
174178
179export type ThreadOutboxDispatchStep =
180 | { readonly step: "wait" }
181 | { readonly step: "remove" }
182 | { readonly step: "retry" }
183 | { readonly step: "restore"; readonly reason: string }
184 | { readonly step: "send" };
185
186/**
187 * Orders the resolved delivery action against the file-capability gate. The
188 * gate applies only to a message that will send: a message whose thread
189 * already exists (or is gone) must be removed even while the server config is
190 * still loading, and a missing config defers with a retry instead of parking
191 * the message forever.
192 */
193export function resolveThreadOutboxDispatchStep(input: {
194 readonly deliveryAction: ThreadOutboxDeliveryAction;
195 readonly fileAttachments: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>;
196 /** Null while the environment's server config has not synced yet. */
197 readonly serverConfig: { readonly maxFileUploadBytes: number | undefined } | null;
198}): ThreadOutboxDispatchStep {
199 if (input.deliveryAction !== "send") {
200 return { step: input.deliveryAction };
201 }
202 if (input.fileAttachments.length === 0) {
203 return { step: "send" };
204 }
205 if (input.serverConfig === null) {
206 return { step: "retry" };
207 }
208 const maxBytes = input.serverConfig.maxFileUploadBytes;
209 if (maxBytes === undefined) {
210 return { step: "restore", reason: "This server does not support file attachments." };
211 }
212 const effectiveMaxBytes = clampFileAttachmentUploadBytes(maxBytes);
213 const oversized = input.fileAttachments.find(
214 (attachment) => attachment.sizeBytes > effectiveMaxBytes,
215 );
216 return oversized
217 ? { step: "restore", reason: fileAttachmentTooLargeMessage(oversized.name, effectiveMaxBytes) }
218 : { step: "send" };
219}
220
175221/**
176222 * A queued creation can only be dispatched once its payload would pass server
177223 * validation; incomplete payloads stay pending until the user edits them.
......@@ -209,7 +255,7 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
209255}
210256
211257export type ThreadOutboxCommandStage = "settings-sync" | "start-turn";
212export type ThreadOutboxFailureAction = "retry" | "discard";
258export type ThreadOutboxFailureAction = "retry" | "restore";
213259
214260export function resolveThreadOutboxFailureAction(input: {
215261 readonly stage: ThreadOutboxCommandStage;
......@@ -223,5 +269,5 @@ export function resolveThreadOutboxFailureAction(input: {
223269 ) {
224270 return "retry";
225271 }
226 return "discard";
272 return "restore";
227273}
apps/mobile/src/state/thread-outbox-removal.test.ts created+305
......@@ -0,0 +1,305 @@
1import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4const harness = vi.hoisted(() => ({
5 cleanup: vi.fn(),
6 clearDraft: vi.fn<typeof import("./use-composer-drafts").clearComposerDraft>(),
7 flushDrafts: vi.fn<typeof import("./use-composer-drafts").flushComposerDrafts>(async () => {}),
8 waitForDrafts: vi.fn<typeof import("./use-composer-drafts").waitForComposerDraftsLoaded>(
9 async () => {},
10 ),
11 manager: null as unknown as ReturnType<
12 typeof import("./thread-outbox-manager").createThreadOutboxManager
13 >,
14}));
15
16vi.mock("./thread-outbox", async () => {
17 const { createThreadOutboxManager } = await import("./thread-outbox-manager");
18 const { appAtomRegistry } = await import("./atom-registry");
19 harness.manager = createThreadOutboxManager({
20 registry: appAtomRegistry,
21 storage: {
22 load: async () => [],
23 write: async () => undefined,
24 remove: async () => undefined,
25 },
26 });
27 return { threadOutboxManager: harness.manager };
28});
29
30vi.mock("./use-composer-drafts", async (importOriginal) => {
31 const original = await importOriginal<typeof import("./use-composer-drafts")>();
32 return {
33 ...original,
34 clearComposerDraft: harness.clearDraft,
35 flushComposerDrafts: harness.flushDrafts,
36 scheduleUnusedComposerAttachmentCleanup: harness.cleanup,
37 waitForComposerDraftsLoaded: harness.waitForDrafts,
38 };
39});
40
41import { appAtomRegistry } from "./atom-registry";
42import { clearThreadOutboxEnvironment, removeThreadOutboxMessage } from "./thread-outbox-removal";
43import type { QueuedThreadMessage } from "./thread-outbox-model";
44import { composerDraftsAtom } from "./use-composer-drafts";
45
46function queuedMessage(input: {
47 readonly environmentId: string;
48 readonly messageId: string;
49 readonly fileUri: string;
50 readonly creation?: true;
51}): QueuedThreadMessage {
52 return {
53 environmentId: EnvironmentId.make(input.environmentId),
54 threadId: ThreadId.make(`thread-${input.messageId}`),
55 messageId: MessageId.make(input.messageId),
56 commandId: CommandId.make(`command-${input.messageId}`),
57 text: "Review the report",
58 attachments: [
59 {
60 id: `file-${input.messageId}`,
61 type: "file",
62 name: "report.pdf",
63 mimeType: "application/pdf",
64 sizeBytes: 42,
65 fileUri: input.fileUri,
66 },
67 ],
68 ...(input.creation
69 ? {
70 creation: {
71 projectId: ProjectId.make(`project-${input.messageId}`),
72 workspaceMode: "local" as const,
73 branch: null,
74 worktreePath: null,
75 },
76 }
77 : {}),
78 createdAt: "2026-08-24T12:00:00.000Z",
79 };
80}
81
82afterEach(() => {
83 appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {});
84 appAtomRegistry.set(composerDraftsAtom, {});
85 harness.cleanup.mockClear();
86 harness.clearDraft.mockClear();
87 harness.flushDrafts.mockReset();
88 harness.flushDrafts.mockResolvedValue(undefined);
89 harness.waitForDrafts.mockReset();
90 harness.waitForDrafts.mockResolvedValue(undefined);
91});
92
93describe("thread outbox removal", () => {
94 it("releases a removed message's attachment files with the removal itself", async () => {
95 const message = queuedMessage({
96 environmentId: "environment-1",
97 messageId: "message-1",
98 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
99 });
100 await harness.manager.enqueue(message);
101
102 await removeThreadOutboxMessage(message);
103
104 expect(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).toEqual({});
105 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(message.attachments);
106 });
107
108 it("keeps an edited message and its files when a revision-checked removal loses", async () => {
109 const message = queuedMessage({
110 environmentId: "environment-1",
111 messageId: "message-edited",
112 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
113 creation: true,
114 });
115 await harness.manager.enqueue(message);
116 const revision = harness.manager.revisionOf(message.messageId);
117 const edited = { ...message, text: "edited while restoring" };
118 await harness.manager.update(edited);
119
120 await expect(removeThreadOutboxMessage(message, revision)).resolves.toBe(false);
121
122 expect(harness.cleanup).not.toHaveBeenCalled();
123 expect(harness.waitForDrafts).not.toHaveBeenCalled();
124 expect(harness.clearDraft).not.toHaveBeenCalled();
125 expect(harness.flushDrafts).not.toHaveBeenCalled();
126 const remaining = Object.values(
127 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
128 ).flat();
129 expect(remaining).toEqual([edited]);
130 });
131
132 it("clears a removed pending task draft and includes its editor-only files", async () => {
133 const message = queuedMessage({
134 environmentId: "environment-1",
135 messageId: "message-pending",
136 fileUri: "file:///documents/t3-composer-attachments/queued.pdf",
137 creation: true,
138 });
139 const editorOnlyFile = {
140 id: "file-editor-only",
141 type: "file" as const,
142 name: "editor-only.pdf",
143 mimeType: "application/pdf",
144 sizeBytes: 84,
145 fileUri: "file:///documents/t3-composer-attachments/editor-only.pdf",
146 };
147 const draftKey = `pending-task:${message.messageId}`;
148 appAtomRegistry.set(composerDraftsAtom, {
149 [draftKey]: { text: "edited", attachments: [editorOnlyFile] },
150 });
151 await harness.manager.enqueue(message);
152
153 await expect(removeThreadOutboxMessage(message)).resolves.toBe(true);
154
155 expect(harness.waitForDrafts).toHaveBeenCalledOnce();
156 expect(harness.clearDraft).toHaveBeenCalledExactlyOnceWith(draftKey, {
157 deferAttachmentCleanup: true,
158 });
159 expect(harness.flushDrafts).toHaveBeenCalledOnce();
160 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith([
161 ...message.attachments,
162 editorOnlyFile,
163 ]);
164 expect(harness.flushDrafts.mock.invocationCallOrder[0]).toBeLessThan(
165 harness.cleanup.mock.invocationCallOrder[0]!,
166 );
167 });
168
169 it("does not flush composer drafts when a removed creation has no editor draft", async () => {
170 const message = queuedMessage({
171 environmentId: "environment-1",
172 messageId: "message-without-editor-draft",
173 fileUri: "file:///documents/t3-composer-attachments/queued.pdf",
174 creation: true,
175 });
176 await harness.manager.enqueue(message);
177
178 await expect(removeThreadOutboxMessage(message)).resolves.toBe(true);
179
180 expect(harness.waitForDrafts).toHaveBeenCalledOnce();
181 expect(harness.clearDraft).not.toHaveBeenCalled();
182 expect(harness.flushDrafts).not.toHaveBeenCalled();
183 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(message.attachments);
184 });
185
186 it("releases only the cleared environment's queued attachment files", async () => {
187 const cleared = queuedMessage({
188 environmentId: "environment-1",
189 messageId: "message-cleared",
190 fileUri: "file:///documents/t3-composer-attachments/cleared.pdf",
191 });
192 const kept = queuedMessage({
193 environmentId: "environment-2",
194 messageId: "message-kept",
195 fileUri: "file:///documents/t3-composer-attachments/kept.pdf",
196 });
197 await harness.manager.enqueue(cleared);
198 await harness.manager.enqueue(kept);
199
200 await clearThreadOutboxEnvironment(cleared.environmentId);
201
202 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(cleared.attachments);
203 const remaining = Object.values(
204 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
205 ).flat();
206 expect(remaining.map((message) => message.messageId)).toEqual([kept.messageId]);
207 });
208
209 it("clears only removed pending drafts and keeps drafts for live messages", async () => {
210 const cleared = queuedMessage({
211 environmentId: "environment-1",
212 messageId: "message-cleared-pending",
213 fileUri: "file:///documents/t3-composer-attachments/cleared-pending.pdf",
214 creation: true,
215 });
216 const replaced = queuedMessage({
217 environmentId: "environment-1",
218 messageId: "message-replaced-pending",
219 fileUri: "file:///documents/t3-composer-attachments/replaced-pending.pdf",
220 creation: true,
221 });
222 const kept = queuedMessage({
223 environmentId: "environment-2",
224 messageId: "message-kept-pending",
225 fileUri: "file:///documents/t3-composer-attachments/kept-pending.pdf",
226 creation: true,
227 });
228 const replacement = { ...replaced, text: "replacement queued while drafts hydrate" };
229 const editorOnlyFile = {
230 id: "file-cleared-editor",
231 type: "file" as const,
232 name: "cleared-editor.pdf",
233 mimeType: "application/pdf",
234 sizeBytes: 84,
235 fileUri: "file:///documents/t3-composer-attachments/cleared-editor.pdf",
236 };
237 const hydrationStarted = Promise.withResolvers<void>();
238 const hydrationBarrier = Promise.withResolvers<void>();
239 harness.waitForDrafts.mockImplementationOnce(async () => {
240 hydrationStarted.resolve();
241 await hydrationBarrier.promise;
242 });
243 const clearedDraftKey = `pending-task:${cleared.messageId}`;
244 appAtomRegistry.set(composerDraftsAtom, {
245 [clearedDraftKey]: { text: "edited", attachments: [editorOnlyFile] },
246 [`pending-task:${replaced.messageId}`]: { text: "replacement", attachments: [] },
247 [`pending-task:${kept.messageId}`]: { text: "other environment", attachments: [] },
248 });
249 await Promise.all([
250 harness.manager.enqueue(cleared),
251 harness.manager.enqueue(replaced),
252 harness.manager.enqueue(kept),
253 ]);
254
255 const clearing = clearThreadOutboxEnvironment(cleared.environmentId);
256 await hydrationStarted.promise;
257 const replacing = harness.manager.enqueue(replacement);
258 hydrationBarrier.resolve();
259 await clearing;
260 await replacing;
261
262 expect(harness.clearDraft).toHaveBeenCalledExactlyOnceWith(clearedDraftKey, {
263 deferAttachmentCleanup: true,
264 });
265 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith([
266 ...cleared.attachments,
267 ...replaced.attachments,
268 editorOnlyFile,
269 ]);
270 const remaining = Object.values(
271 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
272 ).flat();
273 expect(remaining).toEqual(expect.arrayContaining([replacement, kept]));
274 });
275
276 it("keeps removal successful when pending draft persistence fails", async () => {
277 const message = queuedMessage({
278 environmentId: "environment-1",
279 messageId: "message-draft-flush-fails",
280 fileUri: "file:///documents/t3-composer-attachments/queued.pdf",
281 creation: true,
282 });
283 const flushError = new Error("composer storage unavailable");
284 const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
285 harness.flushDrafts.mockRejectedValueOnce(flushError);
286 appAtomRegistry.set(composerDraftsAtom, {
287 [`pending-task:${message.messageId}`]: { text: "edited", attachments: [] },
288 });
289 await harness.manager.enqueue(message);
290
291 try {
292 await expect(removeThreadOutboxMessage(message)).resolves.toBe(true);
293
294 expect(harness.clearDraft).toHaveBeenCalledOnce();
295 expect(harness.cleanup).not.toHaveBeenCalled();
296 expect(warning).toHaveBeenCalledWith(
297 "[thread-outbox] failed to clean up removed pending task drafts",
298 flushError,
299 );
300 expect(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).toEqual({});
301 } finally {
302 warning.mockRestore();
303 }
304 });
305});
apps/mobile/src/state/thread-outbox-removal.ts created+91
......@@ -0,0 +1,91 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3import { appAtomRegistry } from "./atom-registry";
4import { threadOutboxManager } from "./thread-outbox";
5import type { QueuedThreadMessage } from "./thread-outbox-model";
6import {
7 clearComposerDraft,
8 composerDraftsAtom,
9 flushComposerDrafts,
10 scheduleUnusedComposerAttachmentCleanup,
11 waitForComposerDraftsLoaded,
12} from "./use-composer-drafts";
13
14async function cleanUpRemovedMessages(
15 removedMessages: ReadonlyArray<QueuedThreadMessage>,
16): Promise<void> {
17 const attachments = removedMessages.flatMap((message) => message.attachments);
18 const removedCreations = removedMessages.filter((message) => message.creation !== undefined);
19 if (removedCreations.length === 0) {
20 scheduleUnusedComposerAttachmentCleanup(attachments);
21 return;
22 }
23
24 try {
25 await waitForComposerDraftsLoaded();
26 const liveMessageIds = new Set(
27 Object.values(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom))
28 .flat()
29 .map((message) => message.messageId),
30 );
31 const drafts = appAtomRegistry.get(composerDraftsAtom);
32 let clearedDraft = false;
33 for (const message of removedCreations) {
34 if (liveMessageIds.has(message.messageId)) {
35 continue;
36 }
37 const draftKey = `pending-task:${message.messageId}`;
38 const draft = drafts[draftKey];
39 if (draft === undefined) {
40 continue;
41 }
42 attachments.push(...draft.attachments);
43 clearComposerDraft(draftKey, { deferAttachmentCleanup: true });
44 clearedDraft = true;
45 }
46 if (clearedDraft) {
47 await flushComposerDrafts();
48 }
49 } catch (error) {
50 // The outbox removal is already durable. Keep the files and report the
51 // secondary cleanup failure without changing the successful result.
52 console.warn("[thread-outbox] failed to clean up removed pending task drafts", error);
53 return;
54 }
55
56 scheduleUnusedComposerAttachmentCleanup(attachments);
57}
58
59/**
60 * The only way a queued message leaves the outbox. Removal also releases the
61 * message's local attachment files (via the reference-counting sweep, so a
62 * file still referenced by a draft or another queued message survives).
63 * Keeping release inside the removal call means no call site can forget it.
64 *
65 * `expectedRevision` (from `threadOutboxRevision`) and `canRemove` make the
66 * removal a compare-and-set: when an edit was accepted or an editor takes the
67 * message, it stays queued, nothing is released, and this returns false.
68 */
69export async function removeThreadOutboxMessage(
70 message: QueuedThreadMessage,
71 expectedRevision?: number,
72 canRemove?: () => boolean,
73): Promise<boolean> {
74 const removed = await threadOutboxManager.remove(message, expectedRevision, canRemove);
75 if (removed === null) {
76 return false;
77 }
78 // The removed payload, not the caller's snapshot: an accepted update may
79 // have added files the snapshot never saw.
80 await cleanUpRemovedMessages([removed]);
81 return true;
82}
83
84/** Removes every queued message of an environment and releases their files. */
85export async function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise<void> {
86 // clearEnvironment loads and merges persisted messages itself and reports
87 // what it actually removed, so the release set cannot miss messages a
88 // failed earlier hydration would have hidden.
89 const removed = await threadOutboxManager.clearEnvironment(environmentId);
90 await cleanUpRemovedMessages(removed);
91}
apps/mobile/src/state/thread-outbox.test.ts+492-1
......@@ -16,6 +16,7 @@ import {
1616 isQueuedThreadCreationSendable,
1717 modelSelectionsEqual,
1818 resolveThreadOutboxDeliveryAction,
19 resolveThreadOutboxDispatchStep,
1920 resolveThreadOutboxFailureAction,
2021 resolveQueuedThreadSettings,
2122 shouldRetryThreadOutboxDelivery,
......@@ -78,6 +79,29 @@ describe("thread outbox", () => {
7879 ).toThrow();
7980 });
8081
82 it("persists generic attachment paths without embedding their contents", () => {
83 const message = {
84 ...queuedMessage({
85 messageId: "message-file",
86 createdAt: "2026-06-08T10:00:01.000Z",
87 }),
88 attachments: [
89 {
90 id: "file-1",
91 type: "file" as const,
92 name: "report.pdf",
93 mimeType: "application/pdf",
94 sizeBytes: 42,
95 fileUri: "file:///documents/report.pdf",
96 uploadedAttachmentId: "pending-report-pdf",
97 uploadEnvironmentId: EnvironmentId.make("environment-1"),
98 },
99 ],
100 } satisfies QueuedThreadMessage;
101
102 expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message);
103 });
104
81105 it("persists the exact selector snapshot while remaining compatible with v1 messages", () => {
82106 const legacyMessage = queuedMessage({
83107 messageId: "message-1",
......@@ -357,6 +381,34 @@ describe("thread outbox", () => {
357381 registry.dispose();
358382 });
359383
384 it("drops the disk entry when a failed enqueue leaves no queued message behind", async () => {
385 const registry = AtomRegistry.make();
386 const removed: string[] = [];
387 const manager = createThreadOutboxManager({
388 registry,
389 storage: {
390 load: async () => [],
391 write: async () => {
392 throw new Error("disk full");
393 },
394 remove: async (message) => {
395 removed.push(message.messageId);
396 },
397 },
398 });
399 const message = queuedMessage({
400 messageId: "message-1",
401 createdAt: "2026-06-08T10:00:01.000Z",
402 });
403
404 // A concurrent update losing its race can compensate-write this payload
405 // to disk before this write fails; rollback must clear that copy or a
406 // restart resurrects the message.
407 await expect(manager.enqueue(message)).rejects.toBeInstanceOf(ThreadOutboxManagerError);
408 expect(removed).toEqual(["message-1"]);
409 registry.dispose();
410 });
411
360412 it("keeps a same-id retry queued when the first attempt's write fails", async () => {
361413 const registry = AtomRegistry.make();
362414 let failNextWrite = true;
......@@ -457,6 +509,445 @@ describe("thread outbox", () => {
457509 registry.dispose();
458510 });
459511
512 it("rejects a stale revision before its payload reaches durable storage", async () => {
513 const registry = AtomRegistry.make();
514 const writes: string[] = [];
515 const manager = createThreadOutboxManager({
516 registry,
517 storage: {
518 load: async () => [],
519 write: async (message) => {
520 writes.push(message.text);
521 },
522 remove: async () => undefined,
523 },
524 });
525 const original = queuedMessage({
526 messageId: "message-edit-race",
527 createdAt: "2026-06-08T10:00:01.000Z",
528 });
529 const edited = { ...original, text: "keep my changes" };
530
531 await manager.enqueue(original);
532 // Revision captured before slow work (an attachment upload) starts.
533 const revision = manager.revisionOf(original.messageId);
534 await manager.update(edited);
535
536 await expect(manager.update({ ...original, text: "stale upload" }, revision)).resolves.toBe(
537 false,
538 );
539 // The losing writer was rejected before persisting: no stale payload can
540 // sit on disk waiting to resurrect on the next load.
541 expect(writes).toEqual([original.text, "keep my changes"]);
542 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
543 "environment-1:thread-1": [edited],
544 });
545 registry.dispose();
546 });
547
548 it("does not publish a stale attachment update after a replacement appears during its write", async () => {
549 const registry = AtomRegistry.make();
550 const writes: string[] = [];
551 let resumeWrite: () => void = () => {};
552 let signalWriteStarted: () => void = () => {};
553 const writeStarted = new Promise<void>((resolve) => {
554 signalWriteStarted = resolve;
555 });
556 const writeBarrier = new Promise<void>((resolve) => {
557 resumeWrite = resolve;
558 });
559 const manager = createThreadOutboxManager({
560 registry,
561 storage: {
562 load: async () => [],
563 write: async (message) => {
564 writes.push(message.text);
565 if (message.text === "stale upload") {
566 signalWriteStarted();
567 await writeBarrier;
568 }
569 },
570 remove: async () => undefined,
571 },
572 });
573 const original = queuedMessage({
574 messageId: "message-write-race",
575 createdAt: "2026-06-08T10:00:01.000Z",
576 });
577 const replacement = { ...original, text: "newer edit" };
578
579 await manager.enqueue(original);
580 const update = manager.update(
581 { ...original, text: "stale upload" },
582 manager.revisionOf(original.messageId),
583 );
584 await writeStarted;
585 const enqueue = manager.enqueue(replacement);
586 resumeWrite();
587
588 await expect(update).resolves.toBe(false);
589 // The losing update re-writes the winning payload inside its own
590 // mutation, before the replacement's serialized write lands, so a crash
591 // between the two cannot leave the stale payload on disk.
592 expect(writes).toEqual([original.text, "stale upload", "newer edit", "newer edit"]);
593 await enqueue;
594 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
595 "environment-1:thread-1": [replacement],
596 });
597 registry.dispose();
598 });
599
600 it("refuses to remove a message that was rewritten after the removal decision", async () => {
601 const registry = AtomRegistry.make();
602 const stored = new Map<MessageId, QueuedThreadMessage>();
603 const manager = createThreadOutboxManager({
604 registry,
605 storage: {
606 load: async () => [...stored.values()],
607 write: async (message) => {
608 stored.set(message.messageId, message);
609 },
610 remove: async (message) => {
611 stored.delete(message.messageId);
612 },
613 },
614 });
615 const original = queuedMessage({
616 messageId: "message-remove-race",
617 createdAt: "2026-06-08T10:00:01.000Z",
618 });
619 const edited = { ...original, text: "edited while restoring" };
620
621 await manager.enqueue(original);
622 // Revision captured when restore-to-composer read the payload it intends
623 // to remove; the edit accepted afterwards must survive the removal.
624 const revision = manager.revisionOf(original.messageId);
625 await manager.update(edited);
626
627 await expect(manager.remove(original, revision)).resolves.toBe(null);
628 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
629 "environment-1:thread-1": [edited],
630 });
631 expect(stored.get(original.messageId)).toEqual(edited);
632
633 await expect(manager.remove(edited, manager.revisionOf(edited.messageId))).resolves.toEqual(
634 edited,
635 );
636 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({});
637 registry.dispose();
638 });
639
640 it("keeps a retry enqueued when its publish races a revision-checked removal", async () => {
641 const registry = AtomRegistry.make();
642 const stored = new Map<MessageId, QueuedThreadMessage>();
643 const removeStarted = Promise.withResolvers<void>();
644 const removeBarrier = Promise.withResolvers<void>();
645 const replacementWriteStarted = Promise.withResolvers<void>();
646 const replacementWriteBarrier = Promise.withResolvers<void>();
647 const original = queuedMessage({
648 messageId: "message-remove-enqueue-race",
649 createdAt: "2026-06-08T10:00:01.000Z",
650 });
651 const retried = { ...original, text: "retried" };
652 const manager = createThreadOutboxManager({
653 registry,
654 storage: {
655 load: async () => [...stored.values()],
656 write: async (message) => {
657 if (message === retried) {
658 replacementWriteStarted.resolve();
659 await replacementWriteBarrier.promise;
660 }
661 stored.set(message.messageId, message);
662 },
663 remove: async (message) => {
664 removeStarted.resolve();
665 await removeBarrier.promise;
666 stored.delete(message.messageId);
667 },
668 },
669 });
670
671 await manager.enqueue(original);
672 const removal = manager.remove(original, manager.revisionOf(original.messageId));
673 let removalSettled = false;
674 void removal.then(() => {
675 removalSettled = true;
676 });
677 await removeStarted.promise;
678 // Published synchronously while the durable remove is still in flight.
679 const enqueue = manager.enqueue(retried);
680 removeBarrier.resolve();
681 await replacementWriteStarted.promise;
682
683 // The canceled removal itself restores the durable winner. The queued
684 // enqueue write has not had a chance to run yet.
685 expect(removalSettled).toBe(false);
686 replacementWriteBarrier.resolve();
687 await expect(removal).resolves.toBe(null);
688 expect(stored.get(original.messageId)).toEqual(retried);
689 await enqueue;
690 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
691 "environment-1:thread-1": [retried],
692 });
693 expect(stored.get(original.messageId)).toEqual(retried);
694 registry.dispose();
695 });
696
697 it("restores a message when its live removal predicate changes during storage removal", async () => {
698 const registry = AtomRegistry.make();
699 const stored = new Map<MessageId, QueuedThreadMessage>();
700 const removeStarted = Promise.withResolvers<void>();
701 const removeBarrier = Promise.withResolvers<void>();
702 const manager = createThreadOutboxManager({
703 registry,
704 storage: {
705 load: async () => [...stored.values()],
706 write: async (message) => {
707 stored.set(message.messageId, message);
708 },
709 remove: async (message) => {
710 removeStarted.resolve();
711 await removeBarrier.promise;
712 stored.delete(message.messageId);
713 },
714 },
715 });
716 const message = queuedMessage({
717 messageId: "message-remove-predicate-race",
718 createdAt: "2026-06-08T10:00:01.000Z",
719 });
720 let canRemove = true;
721
722 await manager.enqueue(message);
723 const removal = manager.remove(message, manager.revisionOf(message.messageId), () => canRemove);
724 await removeStarted.promise;
725 canRemove = false;
726 removeBarrier.resolve();
727
728 await expect(removal).resolves.toBe(null);
729 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
730 "environment-1:thread-1": [message],
731 });
732 expect(stored.get(message.messageId)).toEqual(message);
733 registry.dispose();
734 });
735
736 it("preserves concurrent enqueues while clearing an environment", async () => {
737 const registry = AtomRegistry.make();
738 const stored = new Map<MessageId, QueuedThreadMessage>();
739 const removeStarted = Promise.withResolvers<void>();
740 const removeBarrier = Promise.withResolvers<void>();
741 const manager = createThreadOutboxManager({
742 registry,
743 storage: {
744 load: async () => [...stored.values()],
745 write: async (message) => {
746 stored.set(message.messageId, message);
747 },
748 remove: async (message) => {
749 if (message.environmentId === EnvironmentId.make("environment-clear")) {
750 removeStarted.resolve();
751 await removeBarrier.promise;
752 }
753 stored.delete(message.messageId);
754 },
755 },
756 });
757 const replaced = queuedMessage({
758 environmentId: "environment-clear",
759 messageId: "message-replaced-during-clear",
760 createdAt: "2026-06-08T10:00:01.000Z",
761 });
762 const removed = queuedMessage({
763 environmentId: "environment-clear",
764 messageId: "message-removed-by-clear",
765 createdAt: "2026-06-08T10:00:02.000Z",
766 });
767 const kept = queuedMessage({
768 environmentId: "environment-keep",
769 messageId: "message-other-environment",
770 createdAt: "2026-06-08T10:00:03.000Z",
771 });
772 const replacement = { ...replaced, text: "replacement" };
773 const added = queuedMessage({
774 environmentId: "environment-clear",
775 messageId: "message-added-during-clear",
776 createdAt: "2026-06-08T10:00:04.000Z",
777 });
778
779 await Promise.all([manager.enqueue(replaced), manager.enqueue(removed), manager.enqueue(kept)]);
780 const clearing = manager.clearEnvironment(replaced.environmentId);
781 await removeStarted.promise;
782 const replacing = manager.enqueue(replacement);
783 const adding = manager.enqueue(added);
784 removeBarrier.resolve();
785
786 await expect(clearing).resolves.toEqual([removed]);
787 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
788 "environment-clear:thread-1": [replacement, added],
789 "environment-keep:thread-1": [kept],
790 });
791 expect(stored.get(replacement.messageId)).toEqual(replacement);
792 expect(stored.has(removed.messageId)).toBe(false);
793
794 await Promise.all([replacing, adding]);
795 expect([...stored.values()]).toEqual(expect.arrayContaining([replacement, added, kept]));
796 registry.dispose();
797 });
798
799 it("does not restore a message removed before a queued environment clear starts", async () => {
800 const registry = AtomRegistry.make();
801 const stored = new Map<MessageId, QueuedThreadMessage>();
802 const removeStarted = Promise.withResolvers<void>();
803 const removeBarrier = Promise.withResolvers<void>();
804 let removeCalls = 0;
805 const manager = createThreadOutboxManager({
806 registry,
807 storage: {
808 load: async () => [...stored.values()],
809 write: async (message) => {
810 stored.set(message.messageId, message);
811 },
812 remove: async (message) => {
813 removeCalls += 1;
814 if (removeCalls === 1) {
815 removeStarted.resolve();
816 await removeBarrier.promise;
817 }
818 stored.delete(message.messageId);
819 },
820 },
821 });
822 const message = queuedMessage({
823 environmentId: "environment-clear",
824 messageId: "message-removed-before-clear",
825 createdAt: "2026-06-08T10:00:01.000Z",
826 });
827
828 await manager.enqueue(message);
829 const removal = manager.remove(message);
830 await removeStarted.promise;
831 const clearing = manager.clearEnvironment(message.environmentId);
832 removeBarrier.resolve();
833
834 await expect(removal).resolves.toEqual(message);
835 await expect(clearing).resolves.toEqual([]);
836 expect(removeCalls).toBe(1);
837 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({});
838 expect(stored.has(message.messageId)).toBe(false);
839 registry.dispose();
840 });
841
842 it("keeps an enqueue published while an environment clear waits to start", async () => {
843 const registry = AtomRegistry.make();
844 const stored = new Map<MessageId, QueuedThreadMessage>();
845 const mutationStarted = Promise.withResolvers<void>();
846 const mutationBarrier = Promise.withResolvers<void>();
847 let removeCalls = 0;
848 const manager = createThreadOutboxManager({
849 registry,
850 storage: {
851 load: async () => [...stored.values()],
852 write: async (message) => {
853 stored.set(message.messageId, message);
854 },
855 remove: async () => {
856 removeCalls += 1;
857 },
858 },
859 });
860 const blocker = manager.serialize(async () => {
861 mutationStarted.resolve();
862 await mutationBarrier.promise;
863 });
864 await mutationStarted.promise;
865 const clearing = manager.clearEnvironment(EnvironmentId.make("environment-clear"));
866 const added = queuedMessage({
867 environmentId: "environment-clear",
868 messageId: "message-enqueued-before-clear-start",
869 createdAt: "2026-06-08T10:00:01.000Z",
870 });
871 const enqueue = manager.enqueue(added);
872 mutationBarrier.resolve();
873
874 await blocker;
875 await expect(clearing).resolves.toEqual([]);
876 expect(removeCalls).toBe(0);
877 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
878 "environment-clear:thread-1": [added],
879 });
880 await enqueue;
881 expect(stored.get(added.messageId)).toEqual(added);
882 registry.dispose();
883 });
884
885 it("removes an already-created pending task before the file-capability gate runs", () => {
886 // The creation's startTurn already made the thread, so the resolver wants
887 // the queued message removed. A missing server config (or missing file
888 // support) must not turn that into a restore, which would duplicate the
889 // task as a draft.
890 const fileAttachments = [{ name: "report.pdf", sizeBytes: 42 }];
891 expect(
892 resolveThreadOutboxDispatchStep({
893 deliveryAction: "remove",
894 fileAttachments,
895 serverConfig: null,
896 }),
897 ).toEqual({ step: "remove" });
898 expect(
899 resolveThreadOutboxDispatchStep({
900 deliveryAction: "remove",
901 fileAttachments,
902 serverConfig: { maxFileUploadBytes: undefined },
903 }),
904 ).toEqual({ step: "remove" });
905 });
906
907 it("retries instead of parking a file message while the server config loads", () => {
908 expect(
909 resolveThreadOutboxDispatchStep({
910 deliveryAction: "send",
911 fileAttachments: [{ name: "report.pdf", sizeBytes: 42 }],
912 serverConfig: null,
913 }),
914 ).toEqual({ step: "retry" });
915 });
916
917 it("gates a sending file message on the server's file support and limit", () => {
918 expect(
919 resolveThreadOutboxDispatchStep({
920 deliveryAction: "send",
921 fileAttachments: [{ name: "report.pdf", sizeBytes: 42 }],
922 serverConfig: { maxFileUploadBytes: undefined },
923 }),
924 ).toEqual({ step: "restore", reason: "This server does not support file attachments." });
925 expect(
926 resolveThreadOutboxDispatchStep({
927 deliveryAction: "send",
928 fileAttachments: [{ name: "big.zip", sizeBytes: 2 * 1024 * 1024 }],
929 serverConfig: { maxFileUploadBytes: 1024 * 1024 },
930 }),
931 ).toEqual({ step: "restore", reason: "'big.zip' exceeds the 1 MB attachment limit." });
932 expect(
933 resolveThreadOutboxDispatchStep({
934 deliveryAction: "send",
935 fileAttachments: [{ name: "report.pdf", sizeBytes: 42 }],
936 serverConfig: { maxFileUploadBytes: 1024 * 1024 },
937 }),
938 ).toEqual({ step: "send" });
939 });
940
941 it("sends a message without file attachments before the server config loads", () => {
942 expect(
943 resolveThreadOutboxDispatchStep({
944 deliveryAction: "send",
945 fileAttachments: [],
946 serverConfig: null,
947 }),
948 ).toEqual({ step: "send" });
949 });
950
460951 it("only removes a missing-thread message after shell synchronization is live", () => {
461952 expect(
462953 resolveThreadOutboxDeliveryAction({
......@@ -618,6 +1109,6 @@ describe("thread outbox", () => {
6181109 error: deterministicFailure,
6191110 interrupted: false,
6201111 }),
621 ).toBe("discard");
1112 ).toBe("restore");
6221113 });
6231114});
apps/mobile/src/state/thread-outbox.ts+16-10
......@@ -1,5 +1,3 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
31import { appAtomRegistry } from "./atom-registry";
42import { createThreadOutboxManager } from "./thread-outbox-manager";
53import type { QueuedThreadMessage } from "./thread-outbox-model";
......@@ -36,15 +34,23 @@ export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage):
3634 return threadOutboxManager.confirmQueued(message);
3735}
3836
39/** Rewrite a queued message; no-op (false) if it was removed in the meantime. */
40export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> {
41 return threadOutboxManager.update(message);
37/**
38 * Rewrite a queued message; no-op (false) if it was removed in the meantime,
39 * or (with `expectedRevision` from `threadOutboxRevision`) if any other write
40 * was accepted since the revision was read.
41 */
42export function updateThreadOutboxMessage(
43 message: QueuedThreadMessage,
44 expectedRevision?: number,
45): Promise<boolean> {
46 return threadOutboxManager.update(message, expectedRevision);
4247}
4348
44export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {
45 return threadOutboxManager.remove(message);
49/** Snapshot of a queued message's write revision, for update's CAS. */
50export function threadOutboxRevision(messageId: QueuedThreadMessage["messageId"]): number {
51 return threadOutboxManager.revisionOf(messageId);
4652}
4753
48export function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise<void> {
49 return threadOutboxManager.clearEnvironment(environmentId);
50}
54// Removal lives in `thread-outbox-removal.ts`: taking a message out of the
55// outbox must also release its local attachment files, and that owner needs
56// the composer draft state this module must not depend on.
apps/mobile/src/state/use-composer-drafts.test.ts+691-33
......@@ -1,6 +1,12 @@
11import { afterEach, describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
3import { vi } from "vite-plus/test";
2import {
3 CommandId,
4 EnvironmentId,
5 MessageId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
9import { onTestFinished, vi } from "vite-plus/test";
410
511const composerDraftFileMocks = vi.hoisted(() => {
612 let document = "";
......@@ -79,14 +85,39 @@ const composerDraftFileMocks = vi.hoisted(() => {
7985 };
8086});
8187
88const composerAttachmentCleanupMocks = vi.hoisted(() => ({
89 remove: vi.fn(async () => undefined),
90 releaseUploads: vi.fn(async () => undefined),
91}));
92
93const incomingShareStorageMocks = vi.hoisted(() => ({
94 load: vi.fn<typeof import("../features/sharing/incoming-share-storage").loadIncomingShareDrafts>(
95 async () => [],
96 ),
97}));
98
8299vi.mock("expo-file-system", () => ({
83100 Directory: composerDraftFileMocks.Directory,
84101 File: composerDraftFileMocks.File,
85102 Paths: { document: "/documents" },
86103}));
87104
105vi.mock("../lib/composerImages", () => ({
106 removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,
107}));
108
109vi.mock("../lib/attachmentUpload", () => ({
110 releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,
111}));
112
113vi.mock("../features/sharing/incoming-share-storage", () => ({
114 loadIncomingShareDrafts: incomingShareStorageMocks.load,
115}));
116
88117import { appAtomRegistry } from "./atom-registry";
118import { threadOutboxManager } from "./thread-outbox";
89119import {
120 appendComposerDraftAttachments,
90121 clearComposerDraftContentState,
91122 clearComposerDraftsEnvironment,
92123 ComposerDraftPersistenceError,
......@@ -100,12 +131,15 @@ import {
100131 flushComposerDrafts,
101132 getComposerDraftSnapshot,
102133 mergeComposerDraftContentState,
134 releaseUnusedComposerAttachmentFiles,
103135 removeComposerDraftsForEnvironment,
104136 resetComposerDraftsLoadState,
105137 restoreComposerDraftSnapshotState,
106138 setComposerDraftText,
107139 setStickyComposerModelSelection,
108140 stickyComposerModelSelectionAtom,
141 undoComposerDraftMerge,
142 undoComposerDraftMergeState,
109143} from "./use-composer-drafts";
110144
111145const DRAFT: ComposerDraft = {
......@@ -123,9 +157,449 @@ afterEach(() => {
123157 composerDraftFileMocks.resetWrites();
124158 appAtomRegistry.set(composerDraftsAtom, {});
125159 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
160 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
161 composerAttachmentCleanupMocks.remove.mockClear();
162 composerAttachmentCleanupMocks.releaseUploads.mockReset();
163 composerAttachmentCleanupMocks.releaseUploads.mockResolvedValue(undefined);
164 incomingShareStorageMocks.load.mockReset();
165 incomingShareStorageMocks.load.mockResolvedValue([]);
126166});
127167
128168describe("mobile composer drafts", () => {
169 // Hydration is one-shot per module instance and the attachment sweep now
170 // triggers it too, so this test must observe it before any sweep test runs.
171 it("waits for persisted drafts before copying content between projects", async () => {
172 const sourceKey = "new-task:environment-1:project-1";
173 const targetKey = "new-task:environment-1:project-2";
174 const unrelatedKey = "environment-1:thread-1";
175 const source = { text: "Current task", attachments: [] } satisfies ComposerDraft;
176 const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft;
177 const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft;
178
179 composerDraftFileMocks.setDocument({
180 schemaVersion: 1,
181 drafts: {
182 [targetKey]: target,
183 [unrelatedKey]: unrelated,
184 },
185 });
186 composerDraftFileMocks.blockRead();
187 appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source });
188
189 const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey);
190 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source });
191
192 composerDraftFileMocks.releaseRead();
193 await copy;
194
195 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
196 [sourceKey]: source,
197 [targetKey]: target,
198 [unrelatedKey]: unrelated,
199 });
200 });
201
202 it("hydrates generic file attachments from their saved local paths", () => {
203 const file = {
204 id: "file-1",
205 type: "file" as const,
206 name: "report.pdf",
207 mimeType: "application/pdf",
208 sizeBytes: 42,
209 fileUri: "file:///documents/report.pdf",
210 };
211
212 expect(
213 decodePersistedComposerDrafts({
214 schemaVersion: 1,
215 drafts: {
216 "environment-1:thread-1": { text: "Review this file", attachments: [file] },
217 },
218 }),
219 ).toEqual({
220 "environment-1:thread-1": { text: "Review this file", attachments: [file] },
221 });
222 });
223
224 it("caps appended attachments at the send limit against the live draft", () => {
225 const makeAttachment = (id: string) => ({
226 id,
227 type: "file" as const,
228 name: `${id}.pdf`,
229 mimeType: "application/pdf",
230 sizeBytes: 42,
231 fileUri: `file:///documents/t3-composer-attachments/${id}.pdf`,
232 });
233 const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`));
234 appAtomRegistry.set(composerDraftsAtom, {
235 "environment-1:thread-cap": { text: "send this", attachments: existing },
236 });
237
238 const rejected = appendComposerDraftAttachments("environment-1:thread-cap", [
239 makeAttachment("incoming-1"),
240 makeAttachment("incoming-2"),
241 ]);
242
243 expect(rejected).toBe(1);
244 const draft = appAtomRegistry.get(composerDraftsAtom)["environment-1:thread-cap"];
245 expect(draft?.attachments).toHaveLength(8);
246 expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");
247
248 // Restore paths bypass the cap so a failed send never drops its files.
249 const overflowRejected = appendComposerDraftAttachments(
250 "environment-1:thread-cap",
251 [makeAttachment("restored-1")],
252 { allowOverflow: true },
253 );
254 expect(overflowRejected).toBe(0);
255 expect(
256 appAtomRegistry.get(composerDraftsAtom)["environment-1:thread-cap"]?.attachments,
257 ).toHaveLength(9);
258 });
259
260 it("keeps shared attachment files until every draft releases them", async () => {
261 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
262 onTestFinished(() => outboxLoad.mockRestore());
263 const file = {
264 id: "file-1",
265 type: "file" as const,
266 name: "report.pdf",
267 mimeType: "application/pdf",
268 sizeBytes: 42,
269 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
270 };
271 appAtomRegistry.set(composerDraftsAtom, {
272 source: { text: "First draft", attachments: [file] },
273 copied: { text: "Second draft", attachments: [file] },
274 });
275
276 await releaseUnusedComposerAttachmentFiles([file]);
277 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
278
279 appAtomRegistry.set(composerDraftsAtom, {
280 copied: { text: "Second draft", attachments: [file] },
281 });
282 await releaseUnusedComposerAttachmentFiles([file]);
283 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
284
285 appAtomRegistry.set(composerDraftsAtom, {});
286 await releaseUnusedComposerAttachmentFiles([file]);
287 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
288 });
289
290 it("keeps a failed-send draft's pending upload for retry", async () => {
291 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
292 onTestFinished(() => outboxLoad.mockRestore());
293 const file = {
294 id: "file-failed-send",
295 type: "file" as const,
296 name: "report.pdf",
297 mimeType: "application/pdf",
298 sizeBytes: 42,
299 fileUri: "file:///documents/t3-composer-attachments/failed-send.pdf",
300 uploadedAttachmentId: "pending-failed-send",
301 uploadEnvironmentId: EnvironmentId.make("environment-1"),
302 };
303 appAtomRegistry.set(composerDraftsAtom, {
304 "environment-1:thread-1": { text: "Retry this send", attachments: [file] },
305 });
306
307 await releaseUnusedComposerAttachmentFiles([file]);
308
309 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
310 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
311 });
312
313 it("removes an unreferenced local file and its pending upload", async () => {
314 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
315 onTestFinished(() => outboxLoad.mockRestore());
316 const environmentId = EnvironmentId.make("environment-1");
317 const file = {
318 id: "file-discarded",
319 type: "file" as const,
320 name: "report.pdf",
321 mimeType: "application/pdf",
322 sizeBytes: 42,
323 fileUri: "file:///documents/t3-composer-attachments/discarded.pdf",
324 uploadedAttachmentId: "pending-discarded",
325 uploadEnvironmentId: environmentId,
326 };
327
328 await releaseUnusedComposerAttachmentFiles([file]);
329
330 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
331 expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [
332 "pending-discarded",
333 ]);
334 });
335
336 it("keeps a pending upload referenced through another local file", async () => {
337 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
338 onTestFinished(() => outboxLoad.mockRestore());
339 const environmentId = EnvironmentId.make("environment-1");
340 const discarded = {
341 id: "file-discarded-copy",
342 type: "file" as const,
343 name: "report.pdf",
344 mimeType: "application/pdf",
345 sizeBytes: 42,
346 fileUri: "file:///documents/t3-composer-attachments/discarded-copy.pdf",
347 uploadedAttachmentId: "pending-shared",
348 uploadEnvironmentId: environmentId,
349 };
350 const retained = {
351 ...discarded,
352 id: "file-retained-copy",
353 fileUri: "file:///documents/t3-composer-attachments/retained-copy.pdf",
354 };
355 appAtomRegistry.set(composerDraftsAtom, {
356 "environment-1:thread-1": { text: "Keep this copy", attachments: [retained] },
357 });
358
359 await releaseUnusedComposerAttachmentFiles([discarded]);
360
361 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(discarded.fileUri);
362 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
363 });
364
365 it("completes local cleanup when pending upload deletion fails", async () => {
366 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
367 onTestFinished(() => outboxLoad.mockRestore());
368 const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
369 onTestFinished(() => warning.mockRestore());
370 composerAttachmentCleanupMocks.releaseUploads.mockRejectedValueOnce(
371 new Error("environment disconnected"),
372 );
373 const file = {
374 id: "file-delete-failed",
375 type: "file" as const,
376 name: "report.pdf",
377 mimeType: "application/pdf",
378 sizeBytes: 42,
379 fileUri: "file:///documents/t3-composer-attachments/delete-failed.pdf",
380 uploadedAttachmentId: "pending-delete-failed",
381 uploadEnvironmentId: EnvironmentId.make("environment-1"),
382 };
383
384 await expect(releaseUnusedComposerAttachmentFiles([file])).resolves.toBeUndefined();
385
386 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
387 expect(warning).toHaveBeenCalledWith(
388 "[composer-attachments] could not remove pending upload",
389 expect.objectContaining({ attachmentId: "pending-delete-failed" }),
390 );
391 });
392
393 it("keeps local attachment files while an outbox message still needs them", async () => {
394 const file = {
395 id: "file-queued",
396 type: "file" as const,
397 name: "report.pdf",
398 mimeType: "application/pdf",
399 sizeBytes: 42,
400 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
401 };
402 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
403 "environment-1:thread-1": [
404 {
405 environmentId: EnvironmentId.make("environment-1"),
406 threadId: ThreadId.make("thread-1"),
407 messageId: MessageId.make("message-1"),
408 commandId: CommandId.make("command-1"),
409 text: "Review the report",
410 attachments: [file],
411 createdAt: "2026-08-24T12:00:00.000Z",
412 },
413 ],
414 });
415
416 await releaseUnusedComposerAttachmentFiles([file]);
417
418 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
419 });
420
421 it("loads persisted outbox messages before deciding an attachment file is unused", async () => {
422 const file = {
423 id: "file-persisted",
424 type: "file" as const,
425 name: "report.pdf",
426 mimeType: "application/pdf",
427 sizeBytes: 42,
428 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
429 };
430 const load = vi.spyOn(threadOutboxManager, "load").mockImplementation(async () => {
431 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
432 "environment-1:thread-1": [
433 {
434 environmentId: EnvironmentId.make("environment-1"),
435 threadId: ThreadId.make("thread-1"),
436 messageId: MessageId.make("message-persisted"),
437 commandId: CommandId.make("command-persisted"),
438 text: "Review the report",
439 attachments: [file],
440 createdAt: "2026-08-24T12:00:00.000Z",
441 },
442 ],
443 });
444 return true;
445 });
446
447 try {
448 await releaseUnusedComposerAttachmentFiles([file]);
449
450 expect(load).toHaveBeenCalledOnce();
451 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
452 } finally {
453 load.mockRestore();
454 }
455 });
456
457 it("keeps a file until its incoming share is consumed", async () => {
458 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
459 onTestFinished(() => outboxLoad.mockRestore());
460 const file = {
461 id: "file-incoming",
462 type: "file" as const,
463 name: "report.pdf",
464 mimeType: "application/pdf",
465 sizeBytes: 42,
466 fileUri: "file:///documents/t3-composer-attachments/incoming.pdf",
467 };
468 incomingShareStorageMocks.load
469 .mockResolvedValueOnce([
470 {
471 schemaVersion: 1,
472 id: "share-1",
473 createdAt: "2026-08-28T12:00:00.000Z",
474 text: "Review this file",
475 attachments: [file],
476 warnings: [],
477 },
478 ])
479 .mockResolvedValueOnce([]);
480
481 await releaseUnusedComposerAttachmentFiles([file]);
482
483 expect(incomingShareStorageMocks.load).toHaveBeenLastCalledWith({ strict: true });
484 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
485
486 await releaseUnusedComposerAttachmentFiles([file]);
487
488 expect(incomingShareStorageMocks.load).toHaveBeenCalledTimes(2);
489 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
490 });
491
492 it("does not delete files when incoming share ownership cannot be loaded", async () => {
493 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
494 onTestFinished(() => outboxLoad.mockRestore());
495 const file = {
496 id: "file-incoming-unknown",
497 type: "file" as const,
498 name: "report.pdf",
499 mimeType: "application/pdf",
500 sizeBytes: 42,
501 fileUri: "file:///documents/t3-composer-attachments/incoming-unknown.pdf",
502 };
503 const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
504 incomingShareStorageMocks.load.mockRejectedValueOnce(new Error("inbox unavailable"));
505 onTestFinished(() => warning.mockRestore());
506
507 await releaseUnusedComposerAttachmentFiles([file]);
508
509 expect(incomingShareStorageMocks.load).toHaveBeenCalledWith({ strict: true });
510 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
511 });
512
513 it.each(["draft", "outbox", "inbox"] as const)(
514 "preserves relocated files still referenced by a persisted %s",
515 async (owner) => {
516 const fileName = "33333333-3333-4333-8333-333333333333-report.pdf";
517 const oldFile = {
518 id: "file-relocated",
519 type: "file" as const,
520 name: "report.pdf",
521 mimeType: "application/pdf",
522 sizeBytes: 42,
523 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,
524 };
525 const currentFile = {
526 ...oldFile,
527 fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`,
528 };
529 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
530 onTestFinished(() => outboxLoad.mockRestore());
531 if (owner === "draft") {
532 composerDraftFileMocks.setDocument({
533 schemaVersion: 1,
534 drafts: { "environment-1:thread-1": { text: "Saved draft", attachments: [oldFile] } },
535 });
536 resetComposerDraftsLoadState();
537 } else if (owner === "outbox") {
538 outboxLoad.mockImplementation(async () => {
539 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
540 "environment-1:thread-1": [
541 {
542 environmentId: EnvironmentId.make("environment-1"),
543 threadId: ThreadId.make("thread-1"),
544 messageId: MessageId.make("message-relocated"),
545 commandId: CommandId.make("command-relocated"),
546 text: "Queued draft",
547 attachments: [oldFile],
548 createdAt: "2026-08-28T12:00:00.000Z",
549 },
550 ],
551 });
552 return true;
553 });
554 } else {
555 incomingShareStorageMocks.load.mockResolvedValue([
556 {
557 schemaVersion: 1,
558 id: "share-relocated",
559 createdAt: "2026-08-28T12:00:00.000Z",
560 text: "Incoming file",
561 attachments: [oldFile],
562 warnings: [],
563 },
564 ]);
565 }
566
567 await releaseUnusedComposerAttachmentFiles([currentFile]);
568
569 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
570
571 appAtomRegistry.set(composerDraftsAtom, {});
572 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
573 outboxLoad.mockResolvedValue(true);
574 incomingShareStorageMocks.load.mockResolvedValue([]);
575 await releaseUnusedComposerAttachmentFiles([currentFile]);
576
577 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(currentFile.fileUri);
578 },
579 );
580
581 it("does not delete attachment files when the draft removal cannot be saved", async () => {
582 const file = {
583 id: "file-unsaved",
584 type: "file" as const,
585 name: "report.pdf",
586 mimeType: "application/pdf",
587 sizeBytes: 42,
588 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
589 };
590 setComposerDraftText("environment-1:thread-1", "Unsaved draft");
591 composerDraftFileMocks.setWriteError(new Error("storage unavailable"));
592
593 try {
594 await expect(releaseUnusedComposerAttachmentFiles([file])).rejects.toBeInstanceOf(
595 ComposerDraftPersistenceError,
596 );
597 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
598 } finally {
599 composerDraftFileMocks.setWriteError(null);
600 }
601 });
602
129603 it("hydrates selector state even when the message content is empty", () => {
130604 expect(
131605 decodePersistedComposerDrafts({
......@@ -603,37 +1077,6 @@ describe("mobile composer drafts", () => {
6031077 });
6041078 });
6051079
606 it("waits for persisted drafts before copying content between projects", async () => {
607 const sourceKey = "new-task:environment-1:project-1";
608 const targetKey = "new-task:environment-1:project-2";
609 const unrelatedKey = "environment-1:thread-1";
610 const source = { text: "Current task", attachments: [] } satisfies ComposerDraft;
611 const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft;
612 const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft;
613
614 composerDraftFileMocks.setDocument({
615 schemaVersion: 1,
616 drafts: {
617 [targetKey]: target,
618 [unrelatedKey]: unrelated,
619 },
620 });
621 composerDraftFileMocks.blockRead();
622 appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source });
623
624 const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey);
625 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source });
626
627 composerDraftFileMocks.releaseRead();
628 await copy;
629
630 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
631 [sourceKey]: source,
632 [targetKey]: target,
633 [unrelatedKey]: unrelated,
634 });
635 });
636
6371080 it("lands a still-debounced draft write when flushed", async () => {
6381081 const draftKey = "environment-1:thread-1";
6391082 setComposerDraftText(draftKey, "typed right before the restart");
......@@ -656,4 +1099,219 @@ describe("mobile composer drafts", () => {
6561099 composerDraftFileMocks.setWriteError(null);
6571100 }
6581101 });
1102
1103 it("restores the pre-merge snapshot when the draft is untouched since the merge", () => {
1104 const draftKey = "environment-1:thread-1";
1105 const snapshot: ComposerDraft = { text: "typed before", attachments: [] };
1106 const merged: ComposerDraft = {
1107 text: "typed before\n\nqueued text",
1108 attachments: [],
1109 runtimeMode: "approval-required",
1110 };
1111
1112 expect(undoComposerDraftMergeState({ [draftKey]: merged }, draftKey, snapshot, merged)).toEqual(
1113 { [draftKey]: snapshot },
1114 );
1115 expect(
1116 undoComposerDraftMergeState(
1117 { [draftKey]: merged },
1118 draftKey,
1119 { text: "", attachments: [] },
1120 merged,
1121 ),
1122 ).toEqual({});
1123 });
1124
1125 it("persists an async merge rollback with the sticky model selection", async () => {
1126 const draftKey = "environment-1:thread-1";
1127 const snapshot: ComposerDraft = { text: "typed before", attachments: [] };
1128 const merged: ComposerDraft = {
1129 text: "typed before\n\nqueued text",
1130 attachments: [],
1131 };
1132 composerDraftFileMocks.setDocument({
1133 schemaVersion: 1,
1134 drafts: { [draftKey]: merged },
1135 stickyModelSelection: {
1136 instanceId: "codex",
1137 model: "gpt-5.6-sol",
1138 },
1139 });
1140
1141 await undoComposerDraftMerge(draftKey, snapshot, merged);
1142
1143 expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({
1144 schemaVersion: 1,
1145 drafts: { [draftKey]: snapshot },
1146 stickyModelSelection: {
1147 instanceId: "codex",
1148 model: "gpt-5.6-sol",
1149 },
1150 });
1151 });
1152
1153 it("returns merge-written settings to the snapshot but keeps user-edited ones", () => {
1154 const draftKey = "environment-1:thread-1";
1155 const snapshot: ComposerDraft = {
1156 text: "typed before",
1157 attachments: [],
1158 runtimeMode: "approval-required",
1159 interactionMode: "default",
1160 };
1161 const merged: ComposerDraft = {
1162 text: "typed before\n\nqueued text",
1163 attachments: [],
1164 runtimeMode: "full-access",
1165 interactionMode: "default",
1166 };
1167 // The user edited the text (forcing the partial undo) and also switched
1168 // interaction mode, but never touched the merge-written runtime mode.
1169 const edited: ComposerDraft = {
1170 text: "typed EDITED before\n\nqueued text",
1171 attachments: [],
1172 runtimeMode: "full-access",
1173 interactionMode: "plan",
1174 };
1175
1176 expect(undoComposerDraftMergeState({ [draftKey]: edited }, draftKey, snapshot, merged)).toEqual(
1177 {
1178 [draftKey]: {
1179 text: "typed EDITED before",
1180 attachments: [],
1181 runtimeMode: "approval-required",
1182 interactionMode: "plan",
1183 },
1184 },
1185 );
1186 });
1187
1188 it("takes out only what the merge inserted when the user edited during it", () => {
1189 const draftKey = "environment-1:thread-1";
1190 const keptAttachment = {
1191 id: "kept",
1192 type: "file" as const,
1193 name: "kept.pdf",
1194 mimeType: "application/pdf",
1195 sizeBytes: 1,
1196 fileUri: "file:///documents/t3-composer-attachments/kept.pdf",
1197 };
1198 const insertedAttachment = {
1199 id: "inserted",
1200 type: "file" as const,
1201 name: "inserted.pdf",
1202 mimeType: "application/pdf",
1203 sizeBytes: 1,
1204 fileUri: "file:///documents/t3-composer-attachments/inserted.pdf",
1205 };
1206 const userAttachment = { ...keptAttachment, id: "user-added" };
1207 const snapshot: ComposerDraft = { text: "typed before", attachments: [keptAttachment] };
1208 const merged: ComposerDraft = {
1209 text: "typed before\n\nqueued text",
1210 attachments: [keptAttachment, insertedAttachment],
1211 };
1212 // The user rewrote the leading text and attached a file mid-recovery.
1213 const edited: ComposerDraft = {
1214 text: "typed EDITED before\n\nqueued text",
1215 attachments: [keptAttachment, insertedAttachment, userAttachment],
1216 };
1217
1218 expect(undoComposerDraftMergeState({ [draftKey]: edited }, draftKey, snapshot, merged)).toEqual(
1219 {
1220 [draftKey]: {
1221 text: "typed EDITED before",
1222 attachments: [keptAttachment, userAttachment],
1223 },
1224 },
1225 );
1226
1227 // Edits that broke the merged suffix keep their text untouched; only the
1228 // inserted attachments still come out.
1229 const rewritten: ComposerDraft = {
1230 text: "totally rewritten",
1231 attachments: [insertedAttachment],
1232 };
1233 expect(
1234 undoComposerDraftMergeState({ [draftKey]: rewritten }, draftKey, snapshot, merged),
1235 ).toEqual({
1236 [draftKey]: { text: "totally rewritten", attachments: [] },
1237 });
1238 });
1239
1240 it("keeps text appended after a merge when rolling it back", () => {
1241 const draftKey = "environment-1:thread-1";
1242 const snapshot: ComposerDraft = { text: "typed before", attachments: [] };
1243 const content = { text: "queued text", attachments: [] };
1244 const merged = mergeComposerDraftContentState({ [draftKey]: snapshot }, draftKey, content)[
1245 draftKey
1246 ]!;
1247 const edited: ComposerDraft = {
1248 ...merged,
1249 text: `${merged.text}\n\nuser follow-up`,
1250 };
1251
1252 const rolledBack = undoComposerDraftMergeState(
1253 { [draftKey]: edited },
1254 draftKey,
1255 snapshot,
1256 merged,
1257 );
1258
1259 expect(rolledBack[draftKey]?.text).toBe("typed before\n\nuser follow-up");
1260 const retried = mergeComposerDraftContentState(rolledBack, draftKey, content);
1261 expect(retried[draftKey]?.text.match(/queued text/g)).toHaveLength(1);
1262 });
1263
1264 it("spares a file re-owned between the sweep's scan and its deletion", async () => {
1265 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1266 onTestFinished(() => outboxLoad.mockRestore());
1267 const fileFor = (id: string) => ({
1268 id,
1269 type: "file" as const,
1270 name: `${id}.pdf`,
1271 mimeType: "application/pdf",
1272 sizeBytes: 42,
1273 fileUri: `file:///documents/t3-composer-attachments/${id}.pdf`,
1274 });
1275 const first = fileFor("file-first");
1276 const reowned = fileFor("file-reowned");
1277 // A restore re-owns the second file while the first deletion is in
1278 // flight, after the sweep already decided both were unused.
1279 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
1280 appAtomRegistry.set(composerDraftsAtom, {
1281 "environment-1:thread-1": { text: "restored", attachments: [reowned] },
1282 });
1283 });
1284
1285 await releaseUnusedComposerAttachmentFiles([first, reowned]);
1286
1287 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[first.fileUri]]);
1288 });
1289
1290 // Uses a fresh module instance (hydration is one-shot), so it stays last.
1291 it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => {
1292 const file = {
1293 id: "file-cold-start",
1294 type: "file" as const,
1295 name: "report.pdf",
1296 mimeType: "application/pdf",
1297 sizeBytes: 42,
1298 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
1299 };
1300 composerDraftFileMocks.setDocument({
1301 schemaVersion: 1,
1302 drafts: {
1303 "environment-1:thread-1": { text: "Persisted draft", attachments: [file] },
1304 },
1305 });
1306 vi.resetModules();
1307 const fresh = await import("./use-composer-drafts");
1308 const freshRegistry = (await import("./atom-registry")).appAtomRegistry;
1309
1310 await fresh.releaseUnusedComposerAttachmentFiles([file]);
1311
1312 expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({
1313 "environment-1:thread-1": { text: "Persisted draft", attachments: [file] },
1314 });
1315 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1316 });
6591317});
apps/mobile/src/state/use-composer-drafts.ts+321-15
......@@ -14,10 +14,12 @@ import { useEffect } from "react";
1414import { Atom } from "effect/unstable/reactivity";
1515
1616import { writeFileAtomically } from "../lib/atomic-file";
17import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
18import type { DraftComposerImageAttachment } from "../lib/composerImages";
17import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
18import { composerAttachmentFileReferenceKey } from "../lib/composerAttachmentFiles";
19import type { DraftComposerAttachment } from "../lib/composerImages";
1920import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
2021import { appAtomRegistry } from "./atom-registry";
22import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox";
2123
2224const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;
2325const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";
......@@ -40,7 +42,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo
4042
4143export interface ComposerDraft {
4244 readonly text: string;
43 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
45 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
4446 readonly importedShareIds?: ReadonlyArray<string>;
4547 readonly modelSelection?: ModelSelection;
4648 readonly runtimeMode?: RuntimeMode;
......@@ -50,7 +52,7 @@ export interface ComposerDraft {
5052
5153export interface ComposerDraftContent {
5254 readonly text: string;
53 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
55 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
5456 readonly sourceShareId?: string;
5557}
5658
......@@ -75,7 +77,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
7577
7678const ComposerDraftSchema = Schema.Struct({
7779 text: Schema.String,
78 attachments: Schema.Array(DraftComposerImageAttachmentSchema),
80 attachments: Schema.Array(DraftComposerAttachmentSchema),
7981 importedShareIds: Schema.optional(Schema.Array(Schema.String)),
8082 modelSelection: Schema.optional(ModelSelectionSchema),
8183 runtimeMode: Schema.optional(RuntimeModeSchema),
......@@ -284,6 +286,156 @@ export async function flushComposerDrafts(): Promise<void> {
284286 } while (persistTimer !== null);
285287}
286288
289function isComposerAttachmentFileReferenced(fileUri: string): boolean {
290 const referenceKey = composerAttachmentFileReferenceKey(fileUri);
291 const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom));
292 const queuedMessages = Object.values(
293 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
294 ).flat();
295 return [...drafts, ...queuedMessages].some((owner) =>
296 owner.attachments.some(
297 (attachment) =>
298 attachment.type === "file" &&
299 composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey,
300 ),
301 );
302}
303
304function isComposerAttachmentUploadReferenced(
305 environmentId: EnvironmentId,
306 attachmentId: string,
307): boolean {
308 const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom));
309 const queuedMessages = Object.values(
310 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
311 ).flat();
312 return [...drafts, ...queuedMessages].some((owner) =>
313 owner.attachments.some(
314 (attachment) =>
315 attachment.type === "file" &&
316 attachment.uploadEnvironmentId === environmentId &&
317 attachment.uploadedAttachmentId === attachmentId,
318 ),
319 );
320}
321
322export async function releaseUnusedComposerAttachmentFiles(
323 attachments: ReadonlyArray<DraftComposerAttachment>,
324): Promise<void> {
325 const candidates = new Set(
326 attachments
327 .filter((attachment) => attachment.type === "file")
328 .map((attachment) => attachment.fileUri),
329 );
330 const uploadCandidates = new Map<EnvironmentId, Set<string>>();
331 for (const attachment of attachments) {
332 if (
333 attachment.type !== "file" ||
334 attachment.uploadEnvironmentId === undefined ||
335 attachment.uploadedAttachmentId === undefined
336 ) {
337 continue;
338 }
339 const ids = uploadCandidates.get(attachment.uploadEnvironmentId) ?? new Set<string>();
340 ids.add(attachment.uploadedAttachmentId);
341 uploadCandidates.set(attachment.uploadEnvironmentId, ids);
342 }
343 if (candidates.size === 0) {
344 return;
345 }
346
347 // Persisted drafts must hydrate before the reference scan. On a cold start
348 // the atom is still empty, and every file a persisted draft owns would look
349 // unused. Hydrate before flushing so a pending pre-hydration write cannot
350 // land an incomplete snapshot either.
351 await waitForComposerDraftsLoaded();
352 await flushComposerDrafts();
353 if (!(await threadOutboxManager.load())) {
354 // An unreadable outbox store must not look like an empty queue: deleting
355 // now would take bytes a persisted queued message still needs. Skip the
356 // sweep; the next one retries hydration.
357 return;
358 }
359 await flushThreadOutbox();
360
361 const allFilesReferenced = [...candidates].every(isComposerAttachmentFileReferenced);
362 const allUploadsReferenced = [...uploadCandidates].every(([environmentId, attachmentIds]) =>
363 [...attachmentIds].every((attachmentId) =>
364 isComposerAttachmentUploadReferenced(environmentId, attachmentId),
365 ),
366 );
367 if (allFilesReferenced && allUploadsReferenced) {
368 return;
369 }
370
371 let incomingShareFileUris: ReadonlySet<string>;
372 try {
373 const { loadIncomingShareDrafts } = await import("../features/sharing/incoming-share-storage");
374 const incomingShares = await loadIncomingShareDrafts({ strict: true });
375 incomingShareFileUris = new Set(
376 incomingShares.flatMap((share) =>
377 share.attachments.flatMap((attachment) =>
378 attachment.type === "file"
379 ? [composerAttachmentFileReferenceKey(attachment.fileUri)]
380 : [],
381 ),
382 ),
383 );
384 } catch (error) {
385 console.warn("[composer-attachments] could not verify incoming share ownership", error);
386 return;
387 }
388
389 const { removePersistedComposerAttachmentFile } = await import("../lib/composerImages");
390 for (const fileUri of candidates) {
391 // Re-check ownership immediately before each deletion: a restore or edit
392 // can re-own a file after an earlier scan decided it was unused.
393 if (
394 isComposerAttachmentFileReferenced(fileUri) ||
395 incomingShareFileUris.has(composerAttachmentFileReferenceKey(fileUri))
396 ) {
397 continue;
398 }
399 await removePersistedComposerAttachmentFile(fileUri);
400 }
401
402 if (uploadCandidates.size > 0) {
403 const { releasePendingAttachmentUploads } = await import("../lib/attachmentUpload");
404 for (const [environmentId, attachmentIds] of uploadCandidates) {
405 for (const attachmentId of attachmentIds) {
406 // A different draft or queued message can reuse the same pending
407 // upload with another local URI. Re-check the server-side ownership
408 // key immediately before deletion.
409 if (isComposerAttachmentUploadReferenced(environmentId, attachmentId)) {
410 continue;
411 }
412 try {
413 await releasePendingAttachmentUploads(environmentId, [attachmentId]);
414 } catch (error) {
415 // The server expires stale pending uploads. Local discard must still
416 // complete when the environment is disconnected or deletion fails.
417 console.warn("[composer-attachments] could not remove pending upload", {
418 environmentId,
419 attachmentId,
420 error,
421 });
422 }
423 }
424 }
425 }
426}
427
428export function scheduleUnusedComposerAttachmentCleanup(
429 attachments: ReadonlyArray<DraftComposerAttachment>,
430): void {
431 if (!attachments.some((attachment) => attachment.type === "file")) {
432 return;
433 }
434 void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => {
435 console.warn("[composer-attachments] could not remove unused files", error);
436 });
437}
438
287439function schedulePersistComposerState(): void {
288440 if (persistTimer !== null) {
289441 clearTimeout(persistTimer);
......@@ -344,6 +496,14 @@ export function ensureComposerDraftsLoaded(): void {
344496 });
345497}
346498
499/** Wait until persisted drafts have been merged into the in-memory composer state. */
500export async function waitForComposerDraftsLoaded(): Promise<void> {
501 ensureComposerDraftsLoaded();
502 if (loadPromise !== null) {
503 await loadPromise;
504 }
505}
506
347507function updateComposerDrafts(
348508 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,
349509): void {
......@@ -392,29 +552,49 @@ export function appendComposerDraftText(draftKey: string, value: string): void {
392552 });
393553}
394554
555/**
556 * Appends attachments to a draft, capped at the send limit against the draft's
557 * live state (callers may have counted before an await; the picker can race
558 * concurrent adds). Overflowed file attachments are released. Returns how many
559 * were rejected. Restore paths pass allowOverflow so a failed send never drops
560 * the message's own attachments.
561 */
395562export function appendComposerDraftAttachments(
396563 draftKey: string,
397 attachments: ReadonlyArray<DraftComposerImageAttachment>,
398): void {
564 attachments: ReadonlyArray<DraftComposerAttachment>,
565 options?: { readonly allowOverflow?: boolean },
566): number {
399567 if (attachments.length === 0) {
400 return;
568 return 0;
401569 }
570 let rejected: ReadonlyArray<DraftComposerAttachment> = [];
402571 updateComposerDrafts((current) => {
403572 const existing = normalizeDraft(current[draftKey]);
573 const remaining = options?.allowOverflow
574 ? attachments.length
575 : Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - existing.attachments.length);
576 const accepted = attachments.slice(0, remaining);
577 rejected = attachments.slice(remaining);
578 if (accepted.length === 0) {
579 return current;
580 }
404581 return {
405582 ...current,
406583 [draftKey]: {
407584 ...existing,
408 attachments: [...existing.attachments, ...attachments],
585 attachments: [...existing.attachments, ...accepted],
409586 },
410587 };
411588 });
589 scheduleUnusedComposerAttachmentCleanup(rejected);
590 return rejected.length;
412591}
413592
414593export function replaceComposerDraftAttachments(
415594 draftKey: string,
416 attachments: ReadonlyArray<DraftComposerImageAttachment>,
595 attachments: ReadonlyArray<DraftComposerAttachment>,
417596): void {
597 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
418598 updateComposerDrafts((current) => {
419599 const draft = {
420600 ...normalizeDraft(current[draftKey]),
......@@ -430,9 +610,14 @@ export function replaceComposerDraftAttachments(
430610 [draftKey]: draft,
431611 };
432612 });
613 const retainedIds = new Set(attachments.map((attachment) => attachment.id));
614 scheduleUnusedComposerAttachmentCleanup(
615 previousAttachments.filter((attachment) => !retainedIds.has(attachment.id)),
616 );
433617}
434618
435619export function removeComposerDraftAttachment(draftKey: string, imageId: string): void {
620 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
436621 updateComposerDrafts((current) => {
437622 const existing = normalizeDraft(current[draftKey]);
438623 const draft = {
......@@ -449,6 +634,9 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
449634 [draftKey]: draft,
450635 };
451636 });
637 scheduleUnusedComposerAttachmentCleanup(
638 previousAttachments.filter((attachment) => attachment.id === imageId),
639 );
452640}
453641
454642export function updateComposerDraftSettings(
......@@ -691,17 +879,130 @@ export async function restoreComposerDraftSnapshot(
691879 );
692880}
693881
882export function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): boolean {
883 return (
884 a.text === b.text &&
885 a.attachments === b.attachments &&
886 a.importedShareIds === b.importedShareIds &&
887 a.modelSelection === b.modelSelection &&
888 a.runtimeMode === b.runtimeMode &&
889 a.interactionMode === b.interactionMode &&
890 a.workspaceSelection === b.workspaceSelection
891 );
892}
893
894/**
895 * Undoes an abandoned mergeComposerDraftContent. When the draft is untouched
896 * since `merged` (the state captured right after the merge), the pre-merge
897 * snapshot comes back exactly. When the user edited the draft during the
898 * merge's awaits, only what the merge inserted (the appended text and the new
899 * attachments) is taken back out, so the user's edits survive the rollback.
900 */
901export function undoComposerDraftMergeState(
902 current: Record<string, ComposerDraft>,
903 draftKey: string,
904 snapshot: ComposerDraft,
905 merged: ComposerDraft,
906): Record<string, ComposerDraft> {
907 const existing = normalizeDraft(current[draftKey]);
908 if (sameComposerDraftState(existing, merged)) {
909 return restoreComposerDraftSnapshotState(current, draftKey, snapshot);
910 }
911 const insertedText = merged.text.startsWith(snapshot.text)
912 ? merged.text.slice(snapshot.text.length)
913 : "";
914 const snapshotAttachmentIds = new Set(snapshot.attachments.map((attachment) => attachment.id));
915 const insertedAttachmentIds = new Set(
916 merged.attachments
917 .filter((attachment) => !snapshotAttachmentIds.has(attachment.id))
918 .map((attachment) => attachment.id),
919 );
920 // A setting still holding the merge's value is the merge's doing: restore
921 // the snapshot's. One the user changed since the merge stays theirs.
922 const undoSetting = <
923 K extends "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection",
924 >(
925 key: K,
926 ): ComposerDraft[K] => (existing[key] === merged[key] ? snapshot[key] : existing[key]);
927 const text =
928 insertedText.length > 0 && existing.text.startsWith(merged.text)
929 ? snapshot.text + existing.text.slice(merged.text.length)
930 : insertedText.length > 0 && existing.text.endsWith(insertedText)
931 ? existing.text.slice(0, existing.text.length - insertedText.length)
932 : existing.text;
933 const draft = {
934 ...existing,
935 text,
936 attachments: existing.attachments.filter(
937 (attachment) => !insertedAttachmentIds.has(attachment.id),
938 ),
939 modelSelection: undoSetting("modelSelection"),
940 runtimeMode: undoSetting("runtimeMode"),
941 interactionMode: undoSetting("interactionMode"),
942 workspaceSelection: undoSetting("workspaceSelection"),
943 };
944 if (isEmptyDraft(draft)) {
945 const next = { ...current };
946 delete next[draftKey];
947 return next;
948 }
949 return {
950 ...current,
951 [draftKey]: draft,
952 };
953}
954
955/** Applies undoComposerDraftMergeState and lands it durably. */
956export async function undoComposerDraftMerge(
957 draftKey: string,
958 snapshot: ComposerDraft,
959 merged: ComposerDraft,
960): Promise<void> {
961 ensureComposerDraftsLoaded();
962 if (loadPromise !== null) {
963 await loadPromise;
964 }
965 if (persistTimer !== null) {
966 clearTimeout(persistTimer);
967 persistTimer = null;
968 }
969 const next = undoComposerDraftMergeState(
970 appAtomRegistry.get(composerDraftsAtom),
971 draftKey,
972 snapshot,
973 merged,
974 );
975 appAtomRegistry.set(composerDraftsAtom, next);
976 await persistenceQueue.run(() =>
977 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
978 );
979}
980
694981export function clearComposerDraftContent(
695982 draftKey: string,
696983 options?: {
697984 readonly clearModelSelection?: boolean;
698985 readonly clearWorkspaceSelection?: boolean;
986 // Send clears the draft while the durable outbox write is still in
987 // flight. Sweeping then would race the write: a failed enqueue rolls the
988 // message out of the queue mid-sweep and its files get deleted right
989 // before the failure handler restores them. The sender re-schedules
990 // cleanup once the write settles.
991 readonly deferAttachmentCleanup?: boolean;
699992 },
700993): void {
994 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
701995 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
996 if (!options?.deferAttachmentCleanup) {
997 scheduleUnusedComposerAttachmentCleanup(previousAttachments);
998 }
702999}
7031000
704export function clearComposerDraft(draftKey: string): void {
1001export function clearComposerDraft(
1002 draftKey: string,
1003 options?: { readonly deferAttachmentCleanup?: boolean },
1004): void {
1005 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
7051006 updateComposerDrafts((current) => {
7061007 if (!current[draftKey]) {
7071008 return current;
......@@ -710,6 +1011,9 @@ export function clearComposerDraft(draftKey: string): void {
7101011 delete next[draftKey];
7111012 return next;
7121013 });
1014 if (!options?.deferAttachmentCleanup) {
1015 scheduleUnusedComposerAttachmentCleanup(previousAttachments);
1016 }
7131017}
7141018
7151019export function removeComposerDraftsForEnvironment(
......@@ -732,10 +1036,11 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
7321036 await loadPromise;
7331037 }
7341038
735 const next = removeComposerDraftsForEnvironment(
736 appAtomRegistry.get(composerDraftsAtom),
737 environmentId,
738 );
1039 const current = appAtomRegistry.get(composerDraftsAtom);
1040 const next = removeComposerDraftsForEnvironment(current, environmentId);
1041 const removedAttachments = Object.entries(current)
1042 .filter(([draftKey]) => next[draftKey] === undefined)
1043 .flatMap(([, draft]) => draft.attachments);
7391044
7401045 if (persistTimer !== null) {
7411046 clearTimeout(persistTimer);
......@@ -745,6 +1050,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
7451050 await persistenceQueue.run(() =>
7461051 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
7471052 );
1053 await releaseUnusedComposerAttachmentFiles(removedAttachments);
7481054}
7491055
7501056export function useComposerDraft(draftKey: string | null): ComposerDraft {
apps/mobile/src/state/use-thread-composer-state.ts+89-21
......@@ -6,6 +6,7 @@ import * as Cause from "effect/Cause";
66import {
77 CommandId,
88 MessageId,
9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
910 type EnvironmentId,
1011 type ModelSelection,
1112 type ProviderInteractionMode,
......@@ -26,6 +27,7 @@ import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
2627import {
2728 convertPastedImagesToAttachments,
2829 pasteComposerClipboard,
30 pickComposerFiles,
2931 pickComposerImages,
3032} from "../lib/composerImages";
3133import type { DraftComposerImageAttachment } from "../lib/composerImages";
......@@ -42,6 +44,7 @@ import {
4244 getComposerDraftSnapshot,
4345 mergeComposerDraftContent,
4446 removeComposerDraftAttachment,
47 scheduleUnusedComposerAttachmentCleanup,
4548 setComposerDraftText,
4649 updateComposerDraftSettings,
4750 useComposerDraft,
......@@ -65,7 +68,14 @@ export function appendReviewCommentToDraft(input: {
6568 const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : "";
6669 setComposerDraftText(threadKey, `${existing}${separator}${input.text}`);
6770 if (input.attachments && input.attachments.length > 0) {
68 appendComposerDraftAttachments(threadKey, input.attachments);
71 // Capped: a review comment is new content, not a send-failure restore, so
72 // it must not push the draft over the send limit. Overflow is released.
73 const rejectedCount = appendComposerDraftAttachments(threadKey, input.attachments);
74 if (rejectedCount > 0) {
75 setPendingConnectionError(
76 `${rejectedCount} comment attachment${rejectedCount === 1 ? " was" : "s were"} not added. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`,
77 );
78 }
6979 }
7080}
7181
......@@ -171,6 +181,17 @@ export function useThreadComposerState() {
171181 if (text.length === 0 && attachments.length === 0) {
172182 return null;
173183 }
184 // A send-failure restore appends with allowOverflow so it never drops the
185 // user's files, which can leave the draft over the cap. Sending it anyway
186 // would enqueue a message that outbox recovery rejects forever, so block
187 // here until the user removes attachments.
188 if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
189 Alert.alert(
190 "Too many attachments",
191 `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`,
192 );
193 return null;
194 }
174195
175196 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
176197 (entry) => entry.instanceId === thread.modelSelection.instanceId,
......@@ -255,18 +276,26 @@ export function useThreadComposerState() {
255276 interactionMode: draft.interactionMode ?? thread.interactionMode,
256277 createdAt: metadata.createdAt,
257278 });
258 clearComposerDraftContent(threadKey);
259 enqueuePromise.catch((error: unknown) => {
260 // Restore text via merge (idempotent) but attachments via the uncapped
261 // append: the merge path slots existing attachments first and truncates
262 // at the send limit, which would silently drop this message's images if
263 // the user attached new ones while the write was in flight.
264 void mergeComposerDraftContent(threadKey, { text, attachments: [] });
265 appendComposerDraftAttachments(threadKey, attachments);
266 setPendingConnectionError(
267 error instanceof Error ? error.message : "Failed to save the queued message.",
268 );
269 });
279 clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });
280 enqueuePromise.then(
281 () => {
282 // The queued message owns the files now; the sweep sees that and
283 // spares them. Deferred to here so a failed write cannot roll the
284 // message out of the queue mid-sweep and lose the bytes.
285 scheduleUnusedComposerAttachmentCleanup(attachments);
286 },
287 (error: unknown) => {
288 // Restore text via merge (idempotent) but attachments via the uncapped
289 // append: the merge path slots existing attachments first and truncates
290 // at the send limit, which would silently drop this message's images if
291 // the user attached new ones while the write was in flight.
292 void mergeComposerDraftContent(threadKey, { text, attachments: [] });
293 appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true });
294 setPendingConnectionError(
295 error instanceof Error ? error.message : "Failed to save the queued message.",
296 );
297 },
298 );
270299 return messageId;
271300 }, [
272301 selectedEnvironmentRuntime?.serverConfig?.providers,
......@@ -296,14 +325,50 @@ export function useThreadComposerState() {
296325 const result = await pickComposerImages({
297326 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
298327 });
299 if (result.images.length > 0) {
300 appendComposerDraftAttachments(threadKey, result.images);
301 }
302 if (result.error) {
303 setPendingConnectionError(result.error);
328 const rejectedImageCount = appendComposerDraftAttachments(threadKey, result.images);
329 const problems = [
330 ...(result.error ? [result.error] : []),
331 ...(rejectedImageCount > 0
332 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]
333 : []),
334 ];
335 if (problems.length > 0) {
336 Alert.alert("Could not attach image", problems.join("\n\n"));
304337 }
305338 }, [composerDrafts, selectedThreadShell]);
306339
340 const onPickDraftFiles = useCallback(async () => {
341 if (!selectedThreadShell) {
342 return;
343 }
344 const maxBytes =
345 selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.fileAttachments
346 ?.maxUploadBytes;
347 if (maxBytes === undefined) {
348 Alert.alert("Could not attach file", "This server does not support file attachments.");
349 return;
350 }
351
352 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
353 // pickComposerFiles clamps the advertised limit to the contract maximum.
354 const result = await pickComposerFiles({
355 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
356 maxBytes,
357 });
358 const rejectedCount = appendComposerDraftAttachments(threadKey, result.files);
359 // The picker error and the live-cap rejection can both happen in one
360 // pick; report both in a single alert.
361 const problems = [
362 ...(result.error ? [result.error] : []),
363 ...(rejectedCount > 0
364 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`]
365 : []),
366 ];
367 if (problems.length > 0) {
368 Alert.alert("Could not attach file", problems.join("\n\n"));
369 }
370 }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]);
371
307372 const onPasteIntoDraft = useCallback(async () => {
308373 if (!selectedThreadShell) {
309374 return;
......@@ -313,14 +378,16 @@ export function useThreadComposerState() {
313378 const result = await pasteComposerClipboard({
314379 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
315380 });
316 if (result.images.length > 0) {
317 appendComposerDraftAttachments(threadKey, result.images);
318 }
381 const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images);
319382 if (result.text) {
320383 appendComposerDraftText(threadKey, result.text);
321384 }
322385 if (result.error) {
323386 setPendingConnectionError(result.error);
387 } else if (rejectedPasteCount > 0) {
388 setPendingConnectionError(
389 `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
390 );
324391 }
325392 }, [composerDrafts, selectedThreadShell]);
326393
......@@ -404,6 +471,7 @@ export function useThreadComposerState() {
404471 interactionMode,
405472 onChangeDraftMessage,
406473 onPickDraftImages,
474 onPickDraftFiles,
407475 onPasteIntoDraft,
408476 onNativePasteImages,
409477 onRemoveDraftImage,
apps/mobile/src/state/use-thread-outbox-drain.test.ts created+574
......@@ -0,0 +1,574 @@
1import {
2 CommandId,
3 EnvironmentId,
4 MessageId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11import type { PreparedTurnAttachments } from "../lib/attachmentUpload";
12
13const harness = vi.hoisted(() => ({
14 manager: null as unknown as ReturnType<
15 typeof import("./thread-outbox-manager").createThreadOutboxManager
16 >,
17 removePersistedFile: vi.fn(async () => undefined),
18 removeOutboxMessage: vi.fn(async (_message: QueuedThreadMessage) => undefined),
19 prepareTurnAttachments: vi.fn<typeof import("../lib/attachmentUpload").prepareTurnAttachments>(),
20 setPendingConnectionError: vi.fn(),
21 draftFile: (() => {
22 let document = "";
23 let writeError: Error | null = null;
24 return {
25 setDocument(value: unknown) {
26 document = JSON.stringify(value);
27 },
28 setWriteError(error: Error | null) {
29 writeError = error;
30 },
31 Directory: class {
32 create() {}
33 },
34 File: class {
35 exists = true;
36 parentDirectory = null;
37
38 create() {}
39
40 moveSync() {}
41
42 async text() {
43 return document;
44 }
45
46 write(value: string) {
47 if (writeError) {
48 throw writeError;
49 }
50 document = value;
51 }
52 },
53 };
54 })(),
55}));
56
57vi.mock("expo-file-system", () => ({
58 Directory: harness.draftFile.Directory,
59 File: harness.draftFile.File,
60 Paths: { document: "/documents" },
61}));
62
63vi.mock("../lib/composerImages", () => ({
64 removePersistedComposerAttachmentFile: harness.removePersistedFile,
65 toUploadChatImageAttachments: () => [],
66}));
67
68vi.mock("../lib/uuid", () => ({
69 uuidv4: () => "00000000-0000-4000-8000-000000000000",
70 randomHex: () => "abcd",
71}));
72
73vi.mock("../lib/attachmentUpload", () => ({
74 prepareTurnAttachments: harness.prepareTurnAttachments,
75}));
76
77vi.mock("./entities", () => ({
78 useProjects: () => [],
79 useServerConfigs: () => new Map(),
80 useThreadShells: () => [],
81}));
82
83vi.mock("./threads", () => ({
84 threadEnvironment: {},
85}));
86
87vi.mock("./use-atom-command", () => ({
88 useAtomCommand: () => async () => undefined,
89}));
90
91vi.mock("./use-thread-outbox", async () => {
92 const { Atom } = await import("effect/unstable/reactivity");
93 return {
94 editingQueuedMessageIdsAtom: Atom.make<Record<string, boolean>>({}).pipe(Atom.keepAlive),
95 useThreadOutboxMessages: () => ({}),
96 useThreadOutboxShellStatuses: () => new Map(),
97 };
98});
99
100vi.mock("./use-remote-environment-registry", () => ({
101 setPendingConnectionError: harness.setPendingConnectionError,
102 useRemoteConnectionStatus: () => ({ connectedEnvironments: [] }),
103}));
104
105vi.mock("./thread-outbox", async () => {
106 const { createThreadOutboxManager } = await import("./thread-outbox-manager");
107 const { appAtomRegistry } = await import("./atom-registry");
108 harness.manager = createThreadOutboxManager({
109 registry: appAtomRegistry,
110 storage: {
111 load: async () => [],
112 write: async () => undefined,
113 remove: (message) => harness.removeOutboxMessage(message),
114 },
115 });
116 const manager = harness.manager;
117 return {
118 threadOutboxManager: manager,
119 flushThreadOutbox: async () => undefined,
120 ensureThreadOutboxLoaded: () => undefined,
121 confirmThreadOutboxMessageQueued: (message: never) => manager.confirmQueued(message),
122 updateThreadOutboxMessage: (message: never, expectedRevision?: number) =>
123 manager.update(message, expectedRevision),
124 threadOutboxRevision: (messageId: never) => manager.revisionOf(messageId),
125 };
126});
127
128import { appAtomRegistry } from "./atom-registry";
129import type { QueuedThreadMessage } from "./thread-outbox-model";
130import * as composerDrafts from "./use-composer-drafts";
131import { editingQueuedMessageIdsAtom } from "./use-thread-outbox";
132import {
133 completeQueuedMessageDelivery,
134 prepareQueuedMessageAttachments,
135 recoverEditedCreationAfterDelivery,
136 removeAcknowledgedExistingThreadMessage,
137 restoreRejectedQueuedMessage,
138} from "./use-thread-outbox-drain";
139
140function queuedMessage(input: {
141 readonly messageId: string;
142 readonly text: string;
143 readonly fileUri?: string;
144}): QueuedThreadMessage {
145 return {
146 environmentId: EnvironmentId.make("environment-1"),
147 threadId: ThreadId.make("thread-1"),
148 messageId: MessageId.make(input.messageId),
149 commandId: CommandId.make(`command-${input.messageId}`),
150 text: input.text,
151 attachments: input.fileUri
152 ? [
153 {
154 id: `file-${input.messageId}`,
155 type: "file",
156 name: "report.pdf",
157 mimeType: "application/pdf",
158 sizeBytes: 42,
159 fileUri: input.fileUri,
160 },
161 ]
162 : [],
163 createdAt: "2026-08-24T12:00:00.000Z",
164 };
165}
166
167function withReusedFileUpload(
168 message: QueuedThreadMessage,
169 attachmentId: string,
170): QueuedThreadMessage {
171 return {
172 ...message,
173 attachments: message.attachments.map((attachment) =>
174 attachment.type === "file"
175 ? {
176 ...attachment,
177 uploadedAttachmentId: attachmentId,
178 uploadEnvironmentId: message.environmentId,
179 }
180 : attachment,
181 ),
182 };
183}
184
185function remainingMessages(): ReadonlyArray<QueuedThreadMessage> {
186 return Object.values(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).flat();
187}
188
189beforeEach(() => {
190 harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} });
191});
192
193afterEach(() => {
194 appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {});
195 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {});
196 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});
197 harness.draftFile.setWriteError(null);
198 harness.removePersistedFile.mockClear();
199 harness.removeOutboxMessage.mockClear();
200 harness.prepareTurnAttachments.mockReset();
201 harness.setPendingConnectionError.mockClear();
202});
203
204describe("thread outbox attachment preparation", () => {
205 it("abandons reused uploads when an editor saves changed text during verification", async () => {
206 const message = withReusedFileUpload(
207 queuedMessage({
208 messageId: "message-reused-upload-race",
209 text: "original text",
210 fileUri: "file:///documents/t3-composer-attachments/reused.pdf",
211 }),
212 "pending-reused-upload",
213 );
214 const preparationStarted = Promise.withResolvers<void>();
215 const preparationBarrier = Promise.withResolvers<PreparedTurnAttachments>();
216 const releaseUploads = vi.fn(async () => undefined);
217 harness.prepareTurnAttachments.mockImplementationOnce(async () => {
218 preparationStarted.resolve();
219 return preparationBarrier.promise;
220 });
221 await harness.manager.enqueue(message);
222 appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true });
223
224 const preparation = prepareQueuedMessageAttachments(message);
225 await preparationStarted.promise;
226 const edited = { ...message, text: "saved editor text" };
227 await harness.manager.update(edited);
228 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});
229 preparationBarrier.resolve({
230 status: "ready",
231 attachments: [],
232 draftAttachments: message.attachments,
233 pendingAttachmentIds: ["pending-reused-upload"],
234 releaseUploads,
235 });
236
237 await expect(preparation).resolves.toEqual({ status: "abandoned" });
238 expect(remainingMessages()).toEqual([edited]);
239 expect(releaseUploads).not.toHaveBeenCalled();
240 });
241
242 it("keeps an unchanged queued payload ready after attachment reuse", async () => {
243 const message = withReusedFileUpload(
244 queuedMessage({
245 messageId: "message-reused-upload-current",
246 text: "unchanged text",
247 fileUri: "file:///documents/t3-composer-attachments/current.pdf",
248 }),
249 "pending-reused-upload",
250 );
251 const releaseUploads = vi.fn(async () => undefined);
252 harness.prepareTurnAttachments.mockResolvedValueOnce({
253 status: "ready",
254 attachments: [],
255 draftAttachments: message.attachments,
256 pendingAttachmentIds: ["pending-reused-upload"],
257 releaseUploads,
258 });
259 await harness.manager.enqueue(message);
260 const revision = harness.manager.revisionOf(message.messageId);
261 appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true });
262
263 await expect(prepareQueuedMessageAttachments(message)).resolves.toMatchObject({
264 status: "ready",
265 persistedMessage: message,
266 deliveryRevision: revision,
267 });
268 expect(releaseUploads).not.toHaveBeenCalled();
269 });
270
271 it("uses the known next revision after persisting uploaded references", async () => {
272 const message = queuedMessage({
273 messageId: "message-new-upload-revision",
274 text: "upload this file",
275 fileUri: "file:///documents/t3-composer-attachments/new.pdf",
276 });
277 const uploadedAttachments = message.attachments.map((attachment) =>
278 attachment.type === "file"
279 ? {
280 ...attachment,
281 uploadedAttachmentId: "pending-new-upload",
282 uploadEnvironmentId: message.environmentId,
283 }
284 : attachment,
285 );
286 harness.prepareTurnAttachments.mockImplementationOnce(async (input) => {
287 expect(await input.persistUploadedReferences?.(uploadedAttachments)).toBe("persisted");
288 return {
289 status: "ready",
290 attachments: [],
291 draftAttachments: uploadedAttachments,
292 pendingAttachmentIds: ["pending-new-upload"],
293 releaseUploads: async () => undefined,
294 };
295 });
296 await harness.manager.enqueue(message);
297 const revision = harness.manager.revisionOf(message.messageId);
298
299 const result = await prepareQueuedMessageAttachments(message);
300
301 expect(result).toMatchObject({
302 status: "ready",
303 persistedMessage: { attachments: uploadedAttachments },
304 deliveryRevision: revision + 1,
305 });
306 expect(harness.manager.revisionOf(message.messageId)).toBe(revision + 1);
307 });
308
309 it("does not prepare a payload that was already replaced", async () => {
310 const message = queuedMessage({ messageId: "message-stale-before-upload", text: "old" });
311 await harness.manager.enqueue(message);
312 const edited = { ...message, text: "new" };
313 await harness.manager.update(edited);
314
315 await expect(prepareQueuedMessageAttachments(message)).resolves.toEqual({
316 status: "abandoned",
317 });
318 expect(harness.prepareTurnAttachments).not.toHaveBeenCalled();
319 expect(remainingMessages()).toEqual([edited]);
320 });
321});
322
323describe("thread outbox drain delivery cleanup", () => {
324 it("retries only cleanup after an acknowledged send removal fails", async () => {
325 const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" });
326 const acknowledged = new Set([message.messageId]);
327 harness.removeOutboxMessage.mockRejectedValueOnce(new Error("storage unavailable"));
328 await harness.manager.enqueue(message);
329
330 await expect(removeAcknowledgedExistingThreadMessage(message, acknowledged)).resolves.toBe(
331 false,
332 );
333 expect(remainingMessages()).toEqual([message]);
334 expect(acknowledged).toEqual(new Set([message.messageId]));
335
336 await expect(removeAcknowledgedExistingThreadMessage(message, acknowledged)).resolves.toBe(
337 true,
338 );
339 expect(remainingMessages()).toEqual([]);
340 expect(acknowledged).toEqual(new Set());
341 });
342
343 it("keeps an edited message and its files when delivery cleanup loses the revision race", async () => {
344 const message = queuedMessage({
345 messageId: "message-edited",
346 text: "original",
347 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
348 });
349 await harness.manager.enqueue(message);
350 const deliveryRevision = harness.manager.revisionOf(message.messageId);
351 const edited = { ...message, text: "edited while the turn delivered" };
352 await harness.manager.update(edited);
353
354 await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited");
355
356 expect(remainingMessages()).toEqual([edited]);
357 expect(harness.removePersistedFile).not.toHaveBeenCalled();
358 });
359
360 it("removes the delivered message when no edit was accepted", async () => {
361 const message = queuedMessage({ messageId: "message-clean", text: "hello" });
362 await harness.manager.enqueue(message);
363 const deliveryRevision = harness.manager.revisionOf(message.messageId);
364
365 await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed");
366
367 expect(remainingMessages()).toEqual([]);
368 });
369
370 it("keeps a delivered message when its editor opens during storage removal", async () => {
371 const message = queuedMessage({
372 messageId: "message-editor-removal-race",
373 text: "keep editor changes",
374 fileUri: "file:///documents/t3-composer-attachments/editor-race.pdf",
375 });
376 const removeStarted = Promise.withResolvers<void>();
377 const removeBarrier = Promise.withResolvers<void>();
378 harness.removeOutboxMessage.mockImplementationOnce(async () => {
379 removeStarted.resolve();
380 await removeBarrier.promise;
381 });
382 await harness.manager.enqueue(message);
383 const deliveryRevision = harness.manager.revisionOf(message.messageId);
384
385 const cleanup = completeQueuedMessageDelivery(message, deliveryRevision);
386 await removeStarted.promise;
387 appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true });
388 removeBarrier.resolve();
389
390 await expect(cleanup).resolves.toBe("edited");
391 expect(remainingMessages()).toEqual([message]);
392 expect(harness.removePersistedFile).not.toHaveBeenCalled();
393 });
394});
395
396describe("thread outbox delivered creation recovery", () => {
397 it("keeps an edit accepted while the older payload is persisted to the draft", async () => {
398 const message = queuedMessage({
399 messageId: "message-recovery-race",
400 text: "original queued text",
401 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
402 });
403 const originalMergeComposerDraftContent = composerDrafts.mergeComposerDraftContent;
404 const mergeCompleted = Promise.withResolvers<void>();
405 const releaseRecovery = Promise.withResolvers<void>();
406 const mergeSpy = vi
407 .spyOn(composerDrafts, "mergeComposerDraftContent")
408 .mockImplementation(async (draftKey, content) => {
409 const result = await originalMergeComposerDraftContent(draftKey, content);
410 mergeCompleted.resolve();
411 await releaseRecovery.promise;
412 return result;
413 });
414
415 try {
416 await harness.manager.enqueue(message);
417 const recovery = recoverEditedCreationAfterDelivery(message);
418 await mergeCompleted.promise;
419
420 const newer = { ...message, text: "edited while recovery persisted the draft" };
421 await harness.manager.update(newer);
422
423 releaseRecovery.resolve();
424 await expect(recovery).resolves.toBe(false);
425
426 expect(remainingMessages()).toEqual([newer]);
427 expect(
428 composerDrafts.getComposerDraftSnapshot(`${message.environmentId}:${message.threadId}`),
429 ).toMatchObject({ text: message.text, attachments: [] });
430 expect(harness.removePersistedFile).not.toHaveBeenCalled();
431 } finally {
432 releaseRecovery.resolve();
433 mergeSpy.mockRestore();
434 }
435 });
436
437 it("leaves recovery to an editor that opens while the draft persists", async () => {
438 const message = queuedMessage({
439 messageId: "message-recovery-editor",
440 text: "recover this text",
441 fileUri: "file:///documents/t3-composer-attachments/editor.pdf",
442 });
443 const originalMergeComposerDraftContent = composerDrafts.mergeComposerDraftContent;
444 const mergeCompleted = Promise.withResolvers<void>();
445 const releaseRecovery = Promise.withResolvers<void>();
446 const mergeSpy = vi
447 .spyOn(composerDrafts, "mergeComposerDraftContent")
448 .mockImplementation(async (draftKey, content) => {
449 const result = await originalMergeComposerDraftContent(draftKey, content);
450 mergeCompleted.resolve();
451 await releaseRecovery.promise;
452 return result;
453 });
454
455 try {
456 await harness.manager.enqueue(message);
457 const recovery = recoverEditedCreationAfterDelivery(message);
458 await mergeCompleted.promise;
459 appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true });
460
461 releaseRecovery.resolve();
462 await expect(recovery).resolves.toBe(true);
463
464 expect(remainingMessages()).toEqual([message]);
465 expect(
466 composerDrafts.getComposerDraftSnapshot(`${message.environmentId}:${message.threadId}`),
467 ).toMatchObject({ text: message.text, attachments: [] });
468 expect(harness.removePersistedFile).not.toHaveBeenCalled();
469 } finally {
470 releaseRecovery.resolve();
471 mergeSpy.mockRestore();
472 }
473 });
474
475 it("retries a failed removal without duplicating recovered draft content", async () => {
476 const message = queuedMessage({
477 messageId: "message-recovery-removal",
478 text: "recover once",
479 fileUri: "file:///documents/t3-composer-attachments/retry.pdf",
480 });
481 const draftKey = `${message.environmentId}:${message.threadId}`;
482 const removeSpy = vi
483 .spyOn(harness.manager, "remove")
484 .mockRejectedValueOnce(new Error("storage unavailable"));
485
486 try {
487 await harness.manager.enqueue(message);
488
489 await expect(recoverEditedCreationAfterDelivery(message)).resolves.toBe(false);
490 expect(remainingMessages()).toEqual([message]);
491
492 await expect(recoverEditedCreationAfterDelivery(message)).resolves.toBe(true);
493
494 const draft = composerDrafts.getComposerDraftSnapshot(draftKey);
495 expect(draft.text).toBe(message.text);
496 expect(draft.attachments).toEqual(message.attachments);
497 expect(remainingMessages()).toEqual([]);
498 expect(harness.removePersistedFile).not.toHaveBeenCalled();
499 } finally {
500 removeSpy.mockRestore();
501 }
502 });
503
504 it("keeps the queue entry when the recovered draft cannot persist", async () => {
505 const message = queuedMessage({
506 messageId: "message-recovery-persistence",
507 text: "recover after persistence returns",
508 });
509 await harness.manager.enqueue(message);
510 harness.draftFile.setWriteError(new Error("disk full"));
511
512 await expect(recoverEditedCreationAfterDelivery(message)).resolves.toBe(false);
513
514 expect(remainingMessages()).toEqual([message]);
515 });
516});
517
518describe("thread outbox recovery rollback", () => {
519 it("restores a rejected new task into its durable project draft", async () => {
520 const message: QueuedThreadMessage = {
521 ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),
522 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
523 creation: {
524 projectId: ProjectId.make("project-1"),
525 workspaceMode: "local",
526 branch: null,
527 worktreePath: null,
528 },
529 };
530 await harness.manager.enqueue(message);
531
532 await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe(
533 "restored",
534 );
535
536 expect(
537 composerDrafts.getComposerDraftSnapshot(
538 `new-task:${message.environmentId}:${message.creation!.projectId}`,
539 ),
540 ).toMatchObject({
541 text: message.text,
542 attachments: message.attachments,
543 modelSelection: message.modelSelection,
544 });
545 expect(remainingMessages()).toEqual([]);
546 expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server");
547 });
548
549 it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => {
550 const message = queuedMessage({ messageId: "message-restore", text: "queued text" });
551 const draftKey = `${message.environmentId}:${message.threadId}`;
552 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {
553 [draftKey]: { text: "typed offline", attachments: [] },
554 });
555 await harness.manager.enqueue(message);
556
557 harness.draftFile.setWriteError(new Error("disk full"));
558 await expect(restoreRejectedQueuedMessage(message, "too large")).resolves.toBe("retry");
559
560 // The merge was rolled back and the message stayed queued for the retry.
561 expect(composerDrafts.getComposerDraftSnapshot(draftKey).text).toBe("typed offline");
562 expect(remainingMessages()).toEqual([message]);
563
564 harness.draftFile.setWriteError(null);
565 await expect(restoreRejectedQueuedMessage(message, "too large")).resolves.toBe("restored");
566
567 // The recovered text landed exactly once and the message left the queue.
568 expect(composerDrafts.getComposerDraftSnapshot(draftKey).text).toBe(
569 "typed offline\n\nqueued text",
570 );
571 expect(remainingMessages()).toEqual([]);
572 expect(harness.setPendingConnectionError).toHaveBeenCalledWith("too large");
573 });
574});
apps/mobile/src/state/use-thread-outbox-drain.ts+725-54
......@@ -8,6 +8,7 @@ import {
88 CommandId,
99 DEFAULT_PROVIDER_INTERACTION_MODE,
1010 DEFAULT_RUNTIME_MODE,
11 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1112 type MessageId,
1213} from "@t3tools/contracts";
1314import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
......@@ -15,36 +16,56 @@ import * as Cause from "effect/Cause";
1516import { AsyncResult, Atom } from "effect/unstable/reactivity";
1617import { useCallback, useEffect, useRef, useState } from "react";
1718
18import { scopedThreadKey } from "../lib/scopedEntities";
19import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
1920import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
20import { toUploadChatImageAttachments } from "../lib/composerImages";
21import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
2122import { randomHex } from "../lib/uuid";
2223import { appAtomRegistry } from "./atom-registry";
23import { useProjects, useThreadShells } from "./entities";
24import { useProjects, useServerConfigs, useThreadShells } from "./entities";
2425import {
2526 confirmThreadOutboxMessageQueued,
2627 ensureThreadOutboxLoaded,
27 removeThreadOutboxMessage,
28 threadOutboxManager,
29 threadOutboxRevision,
30 updateThreadOutboxMessage,
2831} from "./thread-outbox";
32import { removeThreadOutboxMessage } from "./thread-outbox-removal";
2933import {
3034 isQueuedThreadCreationSendable,
3135 modelSelectionsEqual,
3236 resolveThreadOutboxDeliveryAction,
37 resolveThreadOutboxDispatchStep,
3338 resolveThreadOutboxFailureAction,
3439 resolveQueuedThreadSettings,
40 shouldRetryThreadOutboxDelivery,
3541 threadOutboxRetryDelayMs,
3642 type QueuedThreadCreation,
3743 type QueuedThreadMessage,
3844 type ThreadOutboxCommandStage,
3945} from "./thread-outbox-model";
40import { threadEnvironment } from "./threads";
46import { environmentThreadShells, threadEnvironment } from "./threads";
47import {
48 appendComposerDraftAttachments,
49 composerDraftsAtom,
50 flushComposerDrafts,
51 type ComposerDraft,
52 getComposerDraftSnapshot,
53 mergeComposerDraftContent,
54 replaceComposerDraftAttachments,
55 undoComposerDraftMerge,
56 updateComposerDraftSettings,
57 waitForComposerDraftsLoaded,
58} from "./use-composer-drafts";
4159import { useAtomCommand } from "./use-atom-command";
4260import {
4361 editingQueuedMessageIdsAtom,
4462 useThreadOutboxMessages,
4563 useThreadOutboxShellStatuses,
4664} from "./use-thread-outbox";
47import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
65import {
66 setPendingConnectionError,
67 useRemoteConnectionStatus,
68} from "./use-remote-environment-registry";
4869
4970export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
5071 Atom.keepAlive,
......@@ -85,6 +106,384 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma
85106 return CommandId.make(`${message.commandId}:${setting}`);
86107}
87108
109/**
110 * Uploads a queued message's attachments and persists the uploaded ids back
111 * onto the queued message. The revision-checked update means an edit accepted
112 * while the bytes uploaded wins: this attempt abandons and the next drain pass
113 * re-reads the message.
114 * `deliveryRevision` is the revision of the payload this attempt will send,
115 * used for the delivery removal's compare-and-set.
116 */
117export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThreadMessage): Promise<
118 | {
119 readonly status: "ready";
120 readonly prepared: PreparedTurnAttachments;
121 readonly persistedMessage: QueuedThreadMessage;
122 readonly deliveryRevision: number;
123 }
124 | { readonly status: "abandoned" }
125> {
126 if (!(await confirmThreadOutboxMessageQueued(queuedMessage))) {
127 return { status: "abandoned" };
128 }
129 const revision = threadOutboxRevision(queuedMessage.messageId);
130 if (!isQueuedMessagePayloadCurrent(queuedMessage, revision)) {
131 return { status: "abandoned" };
132 }
133 let persistedMessage = queuedMessage;
134 let deliveryRevision = revision;
135 const result = await prepareTurnAttachments({
136 environmentId: queuedMessage.environmentId,
137 attachments: queuedMessage.attachments,
138 persistUploadedReferences: async (draftAttachments) => {
139 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
140 return "abandon";
141 }
142 const updatedMessage = { ...queuedMessage, attachments: draftAttachments };
143 if (!(await updateThreadOutboxMessage(updatedMessage, revision))) {
144 return "abandon";
145 }
146 persistedMessage = updatedMessage;
147 deliveryRevision = revision + 1;
148 return "persisted";
149 },
150 });
151 if (
152 result.status === "abandoned" ||
153 !isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)
154 ) {
155 return { status: "abandoned" };
156 }
157 return { status: "ready", prepared: result, persistedMessage, deliveryRevision };
158}
159
160function isQueuedMessagePayloadCurrent(
161 message: QueuedThreadMessage,
162 expectedRevision: number,
163): boolean {
164 return (
165 threadOutboxRevision(message.messageId) === expectedRevision &&
166 Object.values(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom))
167 .flat()
168 .some((candidate) => candidate === message)
169 );
170}
171
172/**
173 * Removes a delivered message from the outbox. The revision and editor checks
174 * preserve a creation payload when its pending-task editor owns newer work.
175 * The outcome tells the caller whether removal completed, ownership changed,
176 * or storage cleanup failed. Exported for tests.
177 */
178export async function completeQueuedMessageDelivery(
179 queuedMessage: QueuedThreadMessage,
180 deliveryRevision: number,
181): Promise<"removed" | "edited" | "failed"> {
182 // The editor may have taken the entry while startTurn was in flight; its
183 // unsaved edits have not bumped the revision yet, so the CAS alone would
184 // let removal win and the editor would lose them once it saves.
185 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
186 return "edited";
187 }
188 try {
189 // Removal also releases the message's local attachment files.
190 const removed = await removeThreadOutboxMessage(
191 queuedMessage,
192 deliveryRevision,
193 () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId],
194 );
195 if (!removed) {
196 console.warn(
197 "[thread-outbox] delivered message was edited before cleanup; keeping the newer message",
198 {
199 environmentId: queuedMessage.environmentId,
200 threadId: queuedMessage.threadId,
201 messageId: queuedMessage.messageId,
202 },
203 );
204 return "edited";
205 }
206 return "removed";
207 } catch (error) {
208 console.warn("[thread-outbox] failed to remove delivered queued message", {
209 environmentId: queuedMessage.environmentId,
210 threadId: queuedMessage.threadId,
211 messageId: queuedMessage.messageId,
212 error,
213 });
214 return "failed";
215 }
216}
217
218/** Retries local cleanup for an existing-thread send acknowledged in this drain lifetime. */
219export async function removeAcknowledgedExistingThreadMessage(
220 queuedMessage: QueuedThreadMessage,
221 acknowledgedMessageIds: Set<MessageId>,
222): Promise<boolean> {
223 try {
224 const removed = await removeThreadOutboxMessage(queuedMessage);
225 if (removed) {
226 acknowledgedMessageIds.delete(queuedMessage.messageId);
227 }
228 return removed;
229 } catch (error) {
230 console.warn("[thread-outbox] failed to remove acknowledged queued message", {
231 environmentId: queuedMessage.environmentId,
232 threadId: queuedMessage.threadId,
233 messageId: queuedMessage.messageId,
234 error,
235 });
236 return false;
237 }
238}
239
240/**
241 * A creation delivered its startTurn but an edit won the cleanup race, so the
242 * edited payload is still queued. The next drain would see the created thread
243 * and take the creation "remove" path, silently discarding the edit; hand the
244 * edited content to the new thread's composer instead and remove the entry.
245 * Returns true when recovery is complete or an open editor owns the next
246 * action, and false when the drain should retry with backoff.
247 * Exported for tests; the drain is the only production caller.
248 */
249export async function recoverEditedCreationAfterDelivery(
250 queuedMessage: QueuedThreadMessage,
251): Promise<boolean> {
252 const kept = Object.values(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom))
253 .flat()
254 .find((candidate) => candidate.messageId === queuedMessage.messageId);
255 if (!kept) {
256 return true;
257 }
258 const keptRevision = threadOutboxRevision(kept.messageId);
259 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[kept.messageId]) {
260 return true;
261 }
262 const draftKey = scopedThreadKey(kept.environmentId, kept.threadId);
263 try {
264 // Merge before removing: the draft's reference keeps the removal sweep
265 // from deleting the attachment files. allowOverflow mirrors the
266 // send-failure restore; the send path refuses over-cap drafts, so the
267 // state stays recoverable.
268 await mergeComposerDraftContent(draftKey, { text: kept.text, attachments: [] });
269 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[kept.messageId]) {
270 return true;
271 }
272 if (threadOutboxRevision(kept.messageId) !== keptRevision) {
273 return false;
274 }
275 const existingAttachmentIds = new Set(
276 getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id),
277 );
278 appendComposerDraftAttachments(
279 draftKey,
280 kept.attachments.filter((attachment) => !existingAttachmentIds.has(attachment.id)),
281 { allowOverflow: true },
282 );
283 // Only settings the queued message actually carries: spreading explicit
284 // undefined would clear choices the user already made on the draft.
285 updateComposerDraftSettings(draftKey, {
286 ...(kept.modelSelection !== undefined ? { modelSelection: kept.modelSelection } : {}),
287 ...(kept.runtimeMode !== undefined ? { runtimeMode: kept.runtimeMode } : {}),
288 ...(kept.interactionMode !== undefined ? { interactionMode: kept.interactionMode } : {}),
289 });
290 // The append only schedules a debounced write; the queue entry is the
291 // only durable copy until the draft lands, so flush before removing.
292 await flushComposerDrafts();
293 } catch (error) {
294 // Keep the entry queued. The drain retries with backoff, and the merge is
295 // idempotent so content that persisted before the failure is not repeated.
296 console.warn("[thread-outbox] could not hand an edited pending task to the composer", error);
297 return false;
298 }
299 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[kept.messageId]) {
300 return true;
301 }
302 try {
303 return await removeThreadOutboxMessage(
304 kept,
305 keptRevision,
306 () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[kept.messageId],
307 );
308 } catch (error) {
309 console.warn("[thread-outbox] could not remove recovered pending task", error);
310 return false;
311 }
312}
313
314/** Exported for tests; the drain is the only production caller. */
315export async function restoreRejectedQueuedMessage(
316 queuedMessage: QueuedThreadMessage,
317 message: string,
318): Promise<"restored" | "deferred" | "blocked" | "retry"> {
319 const draftKey = recoveryDraftKey(queuedMessage);
320 // Set once the merge publishes, cleared once the queued message is removed.
321 // The catch below uses it to take the merged content back out, so a retry
322 // after a mid-recovery failure cannot append the recovered text again.
323 let rollback: { readonly snapshot: ComposerDraft; readonly merged: ComposerDraft } | null = null;
324 try {
325 if (
326 appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] ||
327 !(await confirmThreadOutboxMessageQueued(queuedMessage)) ||
328 appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]
329 ) {
330 return "deferred";
331 }
332 // The confirmation above checked this exact payload is what is queued, so
333 // the current revision guards the removal at the end against an edit
334 // accepted while this recovery ran.
335 const revision = threadOutboxRevision(queuedMessage.messageId);
336
337 await waitForComposerDraftsLoaded();
338 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
339 return "deferred";
340 }
341 const originalDraft = getComposerDraftSnapshot(draftKey);
342 const existingAttachmentIds = new Set(
343 originalDraft.attachments.map((attachment) => attachment.id),
344 );
345 const addedAttachmentCount = queuedMessage.attachments.filter(
346 (attachment) => !existingAttachmentIds.has(attachment.id),
347 ).length;
348 if (existingAttachmentIds.size + addedAttachmentCount > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
349 setPendingConnectionError(
350 `Remove attachments from the draft before restoring this message. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`,
351 );
352 return "blocked";
353 }
354
355 let mergedDraft: ComposerDraft;
356 try {
357 await mergeComposerDraftContent(draftKey, {
358 text: queuedMessage.text,
359 attachments: queuedMessage.attachments,
360 });
361 } finally {
362 // Snapshots for the rollbacks below: undoComposerDraftMerge restores
363 // the original draft only while it is untouched, and otherwise takes
364 // out just what this recovery inserted so edits typed during the awaits
365 // survive. Captured in a finally because mergeComposerDraftContent
366 // publishes before its persistence await: even its failure leaves the
367 // merged content in the draft.
368 mergedDraft = getComposerDraftSnapshot(draftKey);
369 rollback = { snapshot: originalDraft, merged: mergedDraft };
370 }
371 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
372 await undoComposerDraftMerge(draftKey, originalDraft, mergedDraft);
373 return "deferred";
374 }
375 updateComposerDraftSettings(draftKey, {
376 ...(queuedMessage.modelSelection ? { modelSelection: queuedMessage.modelSelection } : {}),
377 ...(queuedMessage.runtimeMode ? { runtimeMode: queuedMessage.runtimeMode } : {}),
378 ...(queuedMessage.interactionMode ? { interactionMode: queuedMessage.interactionMode } : {}),
379 ...(queuedMessage.creation
380 ? {
381 workspaceSelection: {
382 mode: queuedMessage.creation.workspaceMode,
383 branch: queuedMessage.creation.branch,
384 worktreePath: queuedMessage.creation.worktreePath,
385 ...(queuedMessage.creation.startFromOrigin !== undefined
386 ? { startFromOrigin: queuedMessage.creation.startFromOrigin }
387 : {}),
388 },
389 }
390 : {}),
391 });
392 const restoredDraft = getComposerDraftSnapshot(draftKey);
393 rollback = { snapshot: originalDraft, merged: restoredDraft };
394 await flushComposerDrafts();
395 if (
396 appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] ||
397 !(await confirmThreadOutboxMessageQueued(queuedMessage)) ||
398 appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]
399 ) {
400 await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft);
401 return "deferred";
402 }
403 // Revision-checked: an edit that landed after the confirmation above
404 // must not be deleted with the pre-edit payload this recovery restored.
405 if (
406 !(await removeThreadOutboxMessage(
407 queuedMessage,
408 revision,
409 () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId],
410 ))
411 ) {
412 await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft);
413 return "deferred";
414 }
415 // The queued message is gone; from here the draft owns the content and
416 // must never be rolled back.
417 rollback = null;
418 setPendingConnectionError(message);
419 return "restored";
420 } catch (error) {
421 if (rollback !== null) {
422 // Take the recovered content back out (keeping edits typed since) so
423 // the retry's merge starts clean instead of appending a duplicate. The
424 // in-memory rollback lands even when its own persistence write fails.
425 await undoComposerDraftMerge(draftKey, rollback.snapshot, rollback.merged).catch(
426 (undoError) => {
427 console.warn("[thread-outbox] failed to persist a recovery rollback", undoError);
428 },
429 );
430 }
431 console.warn("[thread-outbox] failed to restore an undeliverable message", error);
432 setPendingConnectionError(
433 error instanceof Error ? error.message : "The unsent message could not be restored.",
434 );
435 return "retry";
436 }
437}
438
439function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {
440 return queuedMessage.creation
441 ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}`
442 : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);
443}
444
445async function preserveUploadedAttachmentsForEditor(
446 originalMessage: QueuedThreadMessage,
447 uploadedMessage: QueuedThreadMessage,
448): Promise<void> {
449 if (!originalMessage.creation) {
450 return;
451 }
452
453 const draftKey = `pending-task:${originalMessage.messageId}`;
454 const draft = getComposerDraftSnapshot(draftKey);
455 const uploadedById = new Map(
456 uploadedMessage.attachments
457 .filter((attachment) => attachment.type === "file")
458 .map((attachment) => [attachment.id, attachment] as const),
459 );
460 let changed = false;
461 const nextAttachments = draft.attachments.map((attachment) => {
462 if (attachment.type !== "file") {
463 return attachment;
464 }
465 const uploaded = uploadedById.get(attachment.id);
466 if (
467 !uploaded?.uploadedAttachmentId ||
468 uploaded.uploadEnvironmentId !== originalMessage.environmentId ||
469 (attachment.uploadedAttachmentId === uploaded.uploadedAttachmentId &&
470 attachment.uploadEnvironmentId === uploaded.uploadEnvironmentId)
471 ) {
472 return attachment;
473 }
474 changed = true;
475 return {
476 ...attachment,
477 uploadedAttachmentId: uploaded.uploadedAttachmentId,
478 uploadEnvironmentId: uploaded.uploadEnvironmentId,
479 };
480 });
481 if (changed) {
482 replaceComposerDraftAttachments(draftKey, nextAttachments);
483 await flushComposerDrafts();
484 }
485}
486
88487export function useThreadOutboxDrain(): void {
89488 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
90489 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
......@@ -102,11 +501,76 @@ export function useThreadOutboxDrain(): void {
102501 const shellStatuses = useThreadOutboxShellStatuses();
103502 const threads = useThreadShells();
104503 const projects = useProjects();
504 const serverConfigs = useServerConfigs();
105505 const { connectedEnvironments } = useRemoteConnectionStatus();
106506 const [retryTick, setRetryTick] = useState(0);
107507 const retryAttemptRef = useRef(new Map<MessageId, number>());
108508 const retryNotBeforeRef = useRef(new Map<MessageId, number>());
109509 const retryTimersRef = useRef(new Map<MessageId, ReturnType<typeof setTimeout>>());
510 const acknowledgedExistingThreadMessageIdsRef = useRef(new Set<MessageId>());
511 const blockedRecoverySubscriptionsRef = useRef(
512 new Map<
513 MessageId,
514 { readonly message: QueuedThreadMessage; readonly unsubscribe: () => void }
515 >(),
516 );
517
518 const scheduleQueuedMessageRetry = useCallback((messageId: MessageId) => {
519 const retryAttempt = (retryAttemptRef.current.get(messageId) ?? 0) + 1;
520 retryAttemptRef.current.set(messageId, retryAttempt);
521 const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt);
522 retryNotBeforeRef.current.set(messageId, Date.now() + retryDelayMs);
523 const pendingTimer = retryTimersRef.current.get(messageId);
524 if (pendingTimer !== undefined) {
525 clearTimeout(pendingTimer);
526 }
527 const retryTimer = setTimeout(() => {
528 retryTimersRef.current.delete(messageId);
529 setRetryTick((current) => current + 1);
530 }, retryDelayMs);
531 retryTimersRef.current.set(messageId, retryTimer);
532 }, []);
533
534 const restoreQueuedMessage = useCallback(
535 async (queuedMessage: QueuedThreadMessage, message: string): Promise<boolean> => {
536 const result = await restoreRejectedQueuedMessage(queuedMessage, message);
537 if (result !== "blocked") {
538 return result !== "retry";
539 }
540
541 if (!blockedRecoverySubscriptionsRef.current.has(queuedMessage.messageId)) {
542 const draftKey = recoveryDraftKey(queuedMessage);
543 const editorDraftKey = queuedMessage.creation
544 ? `pending-task:${queuedMessage.messageId}`
545 : null;
546 const currentDrafts = appAtomRegistry.get(composerDraftsAtom);
547 const blockedAttachments = currentDrafts[draftKey]?.attachments;
548 const editorAttachments =
549 editorDraftKey === null ? undefined : currentDrafts[editorDraftKey]?.attachments;
550 const unsubscribe = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => {
551 if (
552 drafts[draftKey]?.attachments === blockedAttachments &&
553 (editorDraftKey === null || drafts[editorDraftKey]?.attachments === editorAttachments)
554 ) {
555 return;
556 }
557 const active = blockedRecoverySubscriptionsRef.current.get(queuedMessage.messageId);
558 if (!active) {
559 return;
560 }
561 blockedRecoverySubscriptionsRef.current.delete(queuedMessage.messageId);
562 active.unsubscribe();
563 setRetryTick((current) => current + 1);
564 });
565 blockedRecoverySubscriptionsRef.current.set(queuedMessage.messageId, {
566 message: queuedMessage,
567 unsubscribe,
568 });
569 }
570 return true;
571 },
572 [],
573 );
110574
111575 useEffect(() => {
112576 ensureThreadOutboxLoaded();
......@@ -115,6 +579,10 @@ export function useThreadOutboxDrain(): void {
115579 clearTimeout(timer);
116580 }
117581 retryTimersRef.current.clear();
582 for (const blocked of blockedRecoverySubscriptionsRef.current.values()) {
583 blocked.unsubscribe();
584 }
585 blockedRecoverySubscriptionsRef.current.clear();
118586 };
119587 }, []);
120588
......@@ -122,53 +590,36 @@ export function useThreadOutboxDrain(): void {
122590 const reportFailure = (
123591 commandResult: AtomCommandResult<unknown, unknown>,
124592 stage: ThreadOutboxCommandStage,
125 ): boolean => {
593 ): { readonly action: "retry" | "restore"; readonly message: string } | null => {
126594 if (!AsyncResult.isFailure(commandResult)) {
127 return false;
595 return null;
128596 }
597 const error = Cause.squash(commandResult.cause);
129598 const action = resolveThreadOutboxFailureAction({
130599 stage,
131 error: Cause.squash(commandResult.cause),
600 error,
132601 interrupted: Cause.hasInterruptsOnly(commandResult.cause),
133602 });
134 const retry = action === "retry";
135603 console.warn("[thread-outbox] queued message delivery failed", {
136604 environmentId: queuedMessage.environmentId,
137605 threadId: queuedMessage.threadId,
138606 messageId: queuedMessage.messageId,
139607 stage,
140608 cause: commandResult.cause,
141 retry,
609 action,
142610 });
143 return retry;
611 return {
612 action,
613 message: error instanceof Error ? error.message : "The message could not be sent.",
614 };
144615 };
145 const completeDelivery = async (
146 deliveryResult: AtomCommandResult<unknown, unknown>,
147 ): Promise<boolean> => {
148 if (reportFailure(deliveryResult, "start-turn")) {
149 return false;
150 }
151
152 try {
153 await removeThreadOutboxMessage(queuedMessage);
154 return true;
155 } catch (error) {
156 console.warn("[thread-outbox] failed to remove delivered queued message", {
157 environmentId: queuedMessage.environmentId,
158 threadId: queuedMessage.threadId,
159 messageId: queuedMessage.messageId,
160 error,
161 });
162 return false;
163 }
164 };
165 return { reportFailure, completeDelivery };
616 return { reportFailure };
166617 }, []);
167618
168619 const sendQueuedMessage = useCallback(
169620 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
170621 const settings = resolveQueuedThreadSettings(queuedMessage, thread);
171 const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage);
622 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
172623
173624 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
174625 const updateResult = await updateThreadMetadata({
......@@ -217,6 +668,37 @@ export function useThreadOutboxDrain(): void {
217668 }
218669 }
219670
671 let prepared: PreparedTurnAttachments;
672 let persistedMessage: QueuedThreadMessage;
673 let deliveryRevision: number;
674 try {
675 const preparedResult = await prepareQueuedMessageAttachments(queuedMessage);
676 if (preparedResult.status === "abandoned") {
677 return true;
678 }
679 prepared = preparedResult.prepared;
680 persistedMessage = preparedResult.persistedMessage;
681 deliveryRevision = preparedResult.deliveryRevision;
682 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
683 await preserveUploadedAttachmentsForEditor(
684 queuedMessage,
685 preparedResult.persistedMessage,
686 );
687 return true;
688 }
689 } catch (error) {
690 console.warn("[thread-outbox] failed to upload attachments", error);
691 if (!shouldRetryThreadOutboxDelivery(error)) {
692 return restoreQueuedMessage(
693 queuedMessage,
694 error instanceof Error ? error.message : "An attachment could not upload.",
695 );
696 }
697 return false;
698 }
699 if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) {
700 return true;
701 }
220702 const deliveryResult = await startTurn({
221703 environmentId: queuedMessage.environmentId,
222704 input: {
......@@ -226,7 +708,7 @@ export function useThreadOutboxDrain(): void {
226708 messageId: queuedMessage.messageId,
227709 role: "user",
228710 text: queuedMessage.text,
229 attachments: toUploadChatImageAttachments(queuedMessage.attachments),
711 attachments: prepared.attachments,
230712 },
231713 modelSelection: settings.modelSelection,
232714 runtimeMode: settings.runtimeMode,
......@@ -234,7 +716,26 @@ export function useThreadOutboxDrain(): void {
234716 createdAt: queuedMessage.createdAt,
235717 },
236718 });
237 return completeDelivery(deliveryResult);
719 const failure = reportFailure(deliveryResult, "start-turn");
720 if (failure?.action === "retry") {
721 return false;
722 }
723 if (failure?.action === "restore") {
724 return restoreQueuedMessage(persistedMessage, failure.message);
725 }
726 acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId);
727 const delivered =
728 (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed";
729 if (delivered) {
730 acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId);
731 // The delivered turn holds its own copy of the bytes. A failed delete
732 // is surfaced (never fails the delivered turn); the server also
733 // expires leaked pending uploads.
734 await prepared.releaseUploads().catch((error) => {
735 console.warn("[thread-outbox] could not delete consumed pending uploads", error);
736 });
737 }
738 return delivered;
238739 },
239740 [
240741 makeDeliveryHelpers,
......@@ -242,6 +743,7 @@ export function useThreadOutboxDrain(): void {
242743 setThreadRuntimeMode,
243744 startTurn,
244745 updateThreadMetadata,
746 restoreQueuedMessage,
245747 ],
246748 );
247749
......@@ -255,7 +757,37 @@ export function useThreadOutboxDrain(): void {
255757 if (modelSelection === undefined) {
256758 return false;
257759 }
258 const { completeDelivery } = makeDeliveryHelpers(queuedMessage);
760 let prepared: PreparedTurnAttachments;
761 let persistedMessage: QueuedThreadMessage;
762 let deliveryRevision: number;
763 try {
764 const preparedResult = await prepareQueuedMessageAttachments(queuedMessage);
765 if (preparedResult.status === "abandoned") {
766 return true;
767 }
768 prepared = preparedResult.prepared;
769 persistedMessage = preparedResult.persistedMessage;
770 deliveryRevision = preparedResult.deliveryRevision;
771 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
772 await preserveUploadedAttachmentsForEditor(
773 queuedMessage,
774 preparedResult.persistedMessage,
775 );
776 return true;
777 }
778 } catch (error) {
779 console.warn("[thread-outbox] failed to upload attachments", error);
780 if (!shouldRetryThreadOutboxDelivery(error)) {
781 return restoreQueuedMessage(
782 queuedMessage,
783 error instanceof Error ? error.message : "An attachment could not upload.",
784 );
785 }
786 return false;
787 }
788 if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) {
789 return true;
790 }
259791 const deliveryResult = await startTurn({
260792 environmentId: queuedMessage.environmentId,
261793 input: buildProjectThreadStartTurnInput({
......@@ -267,6 +799,7 @@ export function useThreadOutboxDrain(): void {
267799 createdAt: queuedMessage.createdAt,
268800 text: queuedMessage.text.trim(),
269801 attachments: queuedMessage.attachments,
802 uploadedAttachments: prepared.attachments,
270803 modelSelection,
271804 runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE,
272805 interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -277,9 +810,35 @@ export function useThreadOutboxDrain(): void {
277810 worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
278811 }),
279812 });
280 return completeDelivery(deliveryResult);
813 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
814 const failure = reportFailure(deliveryResult, "start-turn");
815 if (failure?.action === "retry") {
816 return false;
817 }
818 if (failure?.action === "restore") {
819 return restoreQueuedMessage(persistedMessage, failure.message);
820 }
821 const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision);
822 if (outcome === "edited") {
823 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
824 // The editor holds the entry with unsaved edits; merging the queue
825 // payload now would duplicate the delivered turn. Once the editor
826 // saves, the duplicate-creation removal below recovers the edits.
827 return true;
828 }
829 // The thread exists now, so the next drain would remove the edited
830 // payload as a duplicate creation. Hand it to the thread's composer.
831 return recoverEditedCreationAfterDelivery(persistedMessage);
832 }
833 if (outcome === "removed") {
834 await prepared.releaseUploads().catch((error) => {
835 console.warn("[thread-outbox] could not delete consumed pending uploads", error);
836 });
837 return true;
838 }
839 return false;
281840 },
282 [makeDeliveryHelpers, startTurn],
841 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],
283842 );
284843
285844 useEffect(() => {
......@@ -287,14 +846,63 @@ export function useThreadOutboxDrain(): void {
287846 return;
288847 }
289848
849 const queuedMessageIds = new Set(
850 Object.values(queuedMessagesByThreadKey)
851 .flat()
852 .map((message) => message.messageId),
853 );
854 for (const messageId of acknowledgedExistingThreadMessageIdsRef.current) {
855 if (!queuedMessageIds.has(messageId)) {
856 acknowledgedExistingThreadMessageIdsRef.current.delete(messageId);
857 }
858 }
859
290860 for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) {
291861 const nextQueuedMessage = queuedMessages[0];
292862 if (!nextQueuedMessage) {
293863 continue;
294864 }
865 if (
866 nextQueuedMessage.creation === undefined &&
867 acknowledgedExistingThreadMessageIdsRef.current.has(nextQueuedMessage.messageId)
868 ) {
869 if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
870 continue;
871 }
872 beginDispatchingQueuedMessage(nextQueuedMessage.messageId);
873 void removeAcknowledgedExistingThreadMessage(
874 nextQueuedMessage,
875 acknowledgedExistingThreadMessageIdsRef.current,
876 )
877 .then((removed) => {
878 if (!removed) {
879 scheduleQueuedMessageRetry(nextQueuedMessage.messageId);
880 return;
881 }
882 retryAttemptRef.current.delete(nextQueuedMessage.messageId);
883 retryNotBeforeRef.current.delete(nextQueuedMessage.messageId);
884 const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId);
885 if (pendingTimer !== undefined) {
886 clearTimeout(pendingTimer);
887 retryTimersRef.current.delete(nextQueuedMessage.messageId);
888 }
889 })
890 .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId));
891 return;
892 }
295893 if (editingQueuedMessageIds[nextQueuedMessage.messageId]) {
296894 continue;
297895 }
896 const blockedRecovery = blockedRecoverySubscriptionsRef.current.get(
897 nextQueuedMessage.messageId,
898 );
899 if (blockedRecovery) {
900 if (blockedRecovery.message === nextQueuedMessage) {
901 continue;
902 }
903 blockedRecoverySubscriptionsRef.current.delete(nextQueuedMessage.messageId);
904 blockedRecovery.unsubscribe();
905 }
298906 if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
299907 continue;
300908 }
......@@ -316,9 +924,53 @@ export function useThreadOutboxDrain(): void {
316924 environmentConnected: environment?.connectionState === "connected",
317925 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
318926 });
319 if (deliveryAction === "wait") {
927 // The delivery action resolves first; the file-capability gate applies
928 // only to a message that will send. Gating earlier would restore a
929 // creation whose startTurn already made the thread as a duplicate draft
930 // instead of removing it.
931 const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId);
932 const dispatchStep = resolveThreadOutboxDispatchStep({
933 deliveryAction,
934 fileAttachments: nextQueuedMessage.attachments.filter(
935 (attachment) => attachment.type === "file",
936 ),
937 serverConfig: serverConfig
938 ? {
939 maxFileUploadBytes:
940 serverConfig.environment.capabilities.fileAttachments?.maxUploadBytes,
941 }
942 : null,
943 });
944 if (dispatchStep.step === "wait") {
945 continue;
946 }
947 if (dispatchStep.step === "retry") {
948 // The environment is connected but its config has not synced yet.
949 // Back off and retry instead of parking the message forever.
950 scheduleQueuedMessageRetry(nextQueuedMessage.messageId);
320951 continue;
321952 }
953 if (dispatchStep.step === "restore") {
954 const attachmentError = dispatchStep.reason;
955 beginDispatchingQueuedMessage(nextQueuedMessage.messageId);
956 void confirmThreadOutboxMessageQueued(nextQueuedMessage)
957 .then((queued) => {
958 if (
959 !queued ||
960 appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]
961 ) {
962 return true;
963 }
964 return restoreQueuedMessage(nextQueuedMessage, attachmentError);
965 })
966 .then((restored) => {
967 if (!restored) {
968 scheduleQueuedMessageRetry(nextQueuedMessage.messageId);
969 }
970 })
971 .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId));
972 return;
973 }
322974 // The live project shell is preferred for the workspace path, with the
323975 // snapshot taken at enqueue time as the fallback so a task never dies
324976 // just because its project shell is not loaded.
......@@ -368,8 +1020,36 @@ export function useThreadOutboxDrain(): void {
3681020 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) {
3691021 return true;
3701022 }
1023 // The shell state is equally stale. Re-run the same delivery policy
1024 // against the live thread snapshot so a vanished thread or newly
1025 // created target defers, while busy existing threads can still steer.
1026 if (deliveryAction === "send") {
1027 const liveThread = findThread(
1028 appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
1029 nextQueuedMessage,
1030 );
1031 const liveThreadBusy =
1032 liveThread?.session?.status === "running" || liveThread?.session?.status === "starting";
1033 const liveDeliveryAction = resolveThreadOutboxDeliveryAction({
1034 isCreation: creation !== undefined,
1035 threadExists: liveThread !== undefined,
1036 shellStatus,
1037 environmentConnected: environment?.connectionState === "connected",
1038 threadBusy: liveThreadBusy,
1039 });
1040 if (liveDeliveryAction !== "send") {
1041 return true;
1042 }
1043 }
3711044 return deliveryAction === "remove"
372 ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
1045 ? creation !== undefined
1046 ? // A creation entry that survived its delivery cleanup either
1047 // holds edits (recover them) or the delivered payload (a
1048 // recovered duplicate the user can delete). Restart loses any
1049 // in-memory distinction, and losing edits is the worse failure,
1050 // so recovery is unconditional here.
1051 recoverEditedCreationAfterDelivery(nextQueuedMessage)
1052 : removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
3731053 : creation !== undefined
3741054 ? creationProjectCwd !== null
3751055 ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd)
......@@ -391,19 +1071,7 @@ export function useThreadOutboxDrain(): void {
3911071 return;
3921072 }
3931073
394 const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1;
395 retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt);
396 const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt);
397 retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs);
398 const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId);
399 if (pendingTimer !== undefined) {
400 clearTimeout(pendingTimer);
401 }
402 const retryTimer = setTimeout(() => {
403 retryTimersRef.current.delete(nextQueuedMessage.messageId);
404 setRetryTick((current) => current + 1);
405 }, retryDelayMs);
406 retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer);
1074 scheduleQueuedMessageRetry(nextQueuedMessage.messageId);
4071075 })
4081076 .finally(() => {
4091077 finishDispatchingQueuedMessage(nextQueuedMessage.messageId);
......@@ -417,8 +1085,11 @@ export function useThreadOutboxDrain(): void {
4171085 projects,
4181086 queuedMessagesByThreadKey,
4191087 retryTick,
1088 restoreQueuedMessage,
1089 scheduleQueuedMessageRetry,
4201090 sendQueuedCreation,
4211091 sendQueuedMessage,
1092 serverConfigs,
4221093 shellStatuses,
4231094 threads,
4241095 ]);
docs/user/composer.md+10-7
......@@ -4,20 +4,23 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee
44composer and shows how many characters need to be removed. Shorten the draft or split it into
55multiple messages, then send again in the same thread.
66
7You can attach images up to 10 MB. On servers that support file uploads, web and desktop can also
7You can attach images up to 10 MB. On servers that support file uploads, you can also
88attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised
99by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files
1010upload directly to the environment, where your agent can read, copy, or edit them by their file path.
1111
1212On web and desktop, attachments upload as soon as you add them. The send button becomes available
13after every upload finishes. Failed uploads can be retried or removed. On mobile, attachments are
14currently limited to images.
13after every upload finishes. Failed uploads can be retried or removed. On mobile, the **+** control
14offers Photos, and adds Files when the connected server supports file uploads. You can share a file
15into T3 Code from any app through the system share sheet. Mobile uploads happen when the message
16sends, so queued messages keep their files until they deliver. Select a received file on mobile
17to save it or open it in another app through the system share sheet.
1518
16Select a video attachment before or after sending to play it with the browser's built-in controls.
17Playback depends on the video formats and codecs that the browser supports.
19On web and desktop, select a video attachment before or after sending to play it with the browser's
20built-in controls. Playback depends on the video formats and codecs that the browser supports.
1821
19If you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach
20again** next to it. Attach the file again or remove it, then send.
22On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name
23and shows **Attach again** next to it. Attach the file again or remove it, then send.
2124
2225On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
2326the composer or paste them into a message.
patches/expo-sharing@57.0.16.patch created+123
......@@ -0,0 +1,123 @@
1diff --git a/android/src/main/java/expo/modules/sharing/SharingRecords.kt b/android/src/main/java/expo/modules/sharing/SharingRecords.kt
2index 0b87b53f632e82f9b4c09c066d5a67e4ab2525a4..65f1c57faef3ba8caac9e137341f2b8312bb5f86 100644
3--- a/android/src/main/java/expo/modules/sharing/SharingRecords.kt
4+++ b/android/src/main/java/expo/modules/sharing/SharingRecords.kt
5@@ -52,7 +52,8 @@ enum class ContentType(val value: String) : Enumerable {
6 internal data class SharePayload(
7 @Field var value: String = "",
8 @Field var shareType: ShareType = ShareType.Text,
9- @Field var mimeType: String = "text/plain"
10+ @Field var mimeType: String = "text/plain",
11+ @Field var originalName: String? = null
12 ) : Record
13
14 @OptimizedRecord
15diff --git a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt b/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt
16index 8dd50e8f04022d183884a57e4d61a7e1711b9e49..481cdb4bc64ea5ae49919071f686324f6d56d55c 100644
17--- a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt
18+++ b/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt
19@@ -3,6 +3,7 @@ package expo.modules.sharing
20 import android.content.Context
21 import android.content.Intent
22 import android.net.Uri
23+import android.provider.OpenableColumns
24
25 internal class SimpleShareIntentDataParser {
26 companion object {
27@@ -17,21 +18,26 @@ internal class SimpleShareIntentDataParser {
28 }
29
30 private fun handleSendAction(context: Context, intent: Intent, type: String): List<SharePayload> {
31- return if (type == "text/plain") {
32- val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return emptyList()
33- val isUrl = android.util.Patterns.WEB_URL.matcher(text).matches()
34-
35- listOf(
36+ val stream = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
37+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
38+ if (stream == null) {
39+ if (!type.startsWith("text/") || text == null) return emptyList()
40+ return listOf(
41 SharePayload().apply {
42 value = text
43- shareType = if (isUrl) ShareType.Url else ShareType.Text
44- mimeType = "text/plain"
45+ shareType = if (android.util.Patterns.WEB_URL.matcher(text).matches()) ShareType.Url else ShareType.Text
46+ mimeType = type
47 }
48 )
49- } else {
50- val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
51- listOfNotNull(uri?.let { createUriPayload(context, it, type) })
52 }
53+ val filePayload = createUriPayload(context, stream, type)
54+ if (text.isNullOrBlank()) return listOf(filePayload)
55+ val textPayload = SharePayload().apply {
56+ value = text
57+ shareType = if (android.util.Patterns.WEB_URL.matcher(text).matches()) ShareType.Url else ShareType.Text
58+ mimeType = "text/plain"
59+ }
60+ return listOf(textPayload, filePayload)
61 }
62
63 private fun handleSendMultipleAction(context: Context, intent: Intent, type: String): List<SharePayload> {
64@@ -41,10 +47,25 @@ internal class SimpleShareIntentDataParser {
65
66 private fun createUriPayload(context: Context, uri: Uri, defaultType: String): SharePayload {
67 val specificType = context.contentResolver.getType(uri) ?: defaultType
68+ // A blank DISPLAY_NAME must stay null: consumers treat originalName as
69+ // the attachment name, and an empty name fails contract validation.
70+ val displayName = runCatching {
71+ context.contentResolver.query(
72+ uri,
73+ arrayOf(OpenableColumns.DISPLAY_NAME),
74+ null,
75+ null,
76+ null
77+ )?.use { cursor ->
78+ val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
79+ if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null
80+ }
81+ }.getOrNull()?.takeIf { it.isNotBlank() }
82 return SharePayload().apply {
83 value = uri.toString()
84- shareType = ShareType.fromMimeType(specificType)
85+ shareType = if (specificType.startsWith("text/")) ShareType.File else ShareType.fromMimeType(specificType)
86 mimeType = specificType
87+ originalName = displayName
88 }
89 }
90 }
91diff --git a/build/Sharing.types.d.ts b/build/Sharing.types.d.ts
92index e9820b061bddce91657ab7144fde98d0543a3b8e..364d63273cb3e209c6490de6dd869a01c8728fcc 100644
93--- a/build/Sharing.types.d.ts
94+++ b/build/Sharing.types.d.ts
95@@ -77,6 +77,11 @@ export type SharePayload = {
96 * @default 'text/plain'
97 */
98 mimeType?: string;
99+ /**
100+ * The display name of a shared file, when the source app provided one.
101+ * Populated on Android from the content resolver's `DISPLAY_NAME`.
102+ */
103+ originalName?: string | null;
104 };
105 export type BaseResolvedSharePayload = SharePayload & {
106 /**
107diff --git a/src/Sharing.types.ts b/src/Sharing.types.ts
108index d9a5ad38f5fe2856b365eb91b71abd640314a5cc..21ea45f04b88cce272e2e02de24c5e4eb8157e82 100644
109--- a/src/Sharing.types.ts
110+++ b/src/Sharing.types.ts
111@@ -85,6 +85,12 @@ export type SharePayload = {
112 * @default 'text/plain'
113 */
114 mimeType?: string;
115+
116+ /**
117+ * The display name of a shared file, when the source app provided one.
118+ * Populated on Android from the content resolver's `DISPLAY_NAME`.
119+ */
120+ originalName?: string | null;
121 };
122
123 export type BaseResolvedSharePayload = SharePayload & {
pnpm-lock.yaml+3-2
......@@ -91,6 +91,7 @@ patchedDependencies:
9191 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
9292 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
9393 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
94 expo-sharing@57.0.16: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45
9495 react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
9596 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787
9697 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
......@@ -363,7 +364,7 @@ importers:
363364 version: 57.0.2(expo@57.0.18)
364365 expo-sharing:
365366 specifier: ~57.0.16
366 version: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
367 version: 57.0.16(patch_hash=8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
367368 expo-splash-screen:
368369 specifier: ~57.0.8
369370 version: 57.0.8(expo@57.0.18)(typescript@6.0.3)
......@@ -16542,7 +16543,7 @@ snapshots:
1654216543
1654316544 expo-server@57.0.3: {}
1654416545
16545 expo-sharing@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3):
16546 expo-sharing@57.0.16(patch_hash=8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3):
1654616547 dependencies:
1654716548 '@expo/config-plugins': 57.0.9(typescript@6.0.3)
1654816549 '@expo/config-types': 57.0.2
pnpm-workspace.yaml+1
......@@ -153,6 +153,7 @@ patchedDependencies:
153153 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
154154 "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch
155155 effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch
156 expo-sharing@57.0.16: patches/expo-sharing@57.0.16.patch
156157 react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch
157158 react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch
158159 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch