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] = [...@@ -148,12 +148,13 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
148 supportsText: true,148 supportsText: true,
149 supportsWebUrlWithMaxCount: 1,149 supportsWebUrlWithMaxCount: 1,
150 supportsImageWithMaxCount: 8,150 supportsImageWithMaxCount: 8,
151 supportsFileWithMaxCount: 8,
151 },152 },
152 },153 },
153 android: {154 android: {
154 enabled: true,155 enabled: true,
155 singleShareMimeTypes: ["text/plain", "image/*"],156 singleShareMimeTypes: ["*/*"],
156 multipleShareMimeTypes: ["image/*"],157 multipleShareMimeTypes: ["*/*"],
157 },158 },
158 },159 },
159];160];
apps/mobile/src/components/ComposerAttachmentStrip.tsx+36-18
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import { SymbolView } from "../components/AppSymbol";1import { SymbolView } from "../components/AppSymbol";
2import { Image, Pressable, ScrollView, View } from "react-native";2import { 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
6export interface ComposerAttachmentStripProps {7export interface ComposerAttachmentStripProps {
7 /** Attachment images to display. */8 /** Attachments to display. */
8 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;9 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
9 /** Called when the user taps the remove button on an image. */10 /** Called when the user removes an attachment. */
10 readonly onRemove: (imageId: string) => void;11 readonly onRemove: (imageId: string) => void;
11 /** Called when the user taps on an image thumbnail to preview it. */12 /** Called when the user taps on an image thumbnail to preview it. */
12 readonly onPressImage?: (previewUri: string) => void;13 readonly onPressImage?: (previewUri: string) => void;
...@@ -19,8 +20,7 @@ export interface ComposerAttachmentStripProps {...@@ -19,8 +20,7 @@ export interface ComposerAttachmentStripProps {
19}20}
2021
21/**22/**
22 * A horizontally-scrollable strip of image attachment thumbnails with remove23 * Attachment thumbnails used by the thread composer and the new-task draft screen.
23 * buttons. Used by both the thread composer and the new-task draft screen.
24 */24 */
25export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {25export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
26 const size = props.imageSize ?? 72;26 const size = props.imageSize ?? 72;
...@@ -40,29 +40,47 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {...@@ -40,29 +40,47 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
40 className="grow-0"40 className="grow-0"
41 >41 >
42 <View className="flex-row gap-2.5">42 <View className="flex-row gap-2.5">
43 {props.attachments.map((image) => (43 {props.attachments.map((attachment) => (
44 <View44 <View
45 key={image.id}45 key={attachment.id}
46 className="relative"46 className="relative"
47 style={{47 style={{
48 paddingTop: removeButtonGutter,48 paddingTop: removeButtonGutter,
49 paddingRight: removeButtonGutter,49 paddingRight: removeButtonGutter,
50 }}50 }}
51 >51 >
52 <Pressable52 {attachment.type === "image" ? (
53 onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined}53 <Pressable
54 >54 onPress={
55 <Image55 props.onPressImage ? () => props.onPressImage!(attachment.previewUri) : undefined
56 source={{ uri: image.previewUri }}56 }
57 className="bg-subtle"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"
58 style={{72 style={{
59 width: size,73 width: size,
60 height: size,74 height: size,
61 borderRadius: radius,75 borderRadius: radius,
62 }}76 }}
63 resizeMode="cover"77 >
64 />78 <SymbolView name="doc.text" size={22} tintColor="#a3a3a3" type="monochrome" />
65 </Pressable>79 <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
80 {attachment.name}
81 </Text>
82 </View>
83 )}
66 <Pressable84 <Pressable
67 className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55"85 className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55"
68 style={{86 style={{
...@@ -70,7 +88,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {...@@ -70,7 +88,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
70 right: removeButtonPlacement === "gutter" ? 0 : 4,88 right: removeButtonPlacement === "gutter" ? 0 : 4,
71 }}89 }}
72 hitSlop={6}90 hitSlop={6}
73 onPress={() => props.onRemove(image.id)}91 onPress={() => props.onRemove(attachment.id)}
74 >92 >
75 <SymbolView93 <SymbolView
76 name="xmark"94 name="xmark"
apps/mobile/src/connection/platform.ts+1-1
...@@ -29,7 +29,7 @@ import { authClientMetadata } from "../lib/authClientMetadata";...@@ -29,7 +29,7 @@ import { authClientMetadata } from "../lib/authClientMetadata";
29import * as Runtime from "../lib/runtime";29import * as Runtime from "../lib/runtime";
30import * as MobileStorage from "../persistence/mobile-storage";30import * as MobileStorage from "../persistence/mobile-storage";
31import { appAtomRegistry } from "../state/atom-registry";31import { appAtomRegistry } from "../state/atom-registry";
32import { clearThreadOutboxEnvironment } from "../state/thread-outbox";32import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal";
33import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";33import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
34import { mobileApplicationActiveWakeup } from "./app-state-wakeups";34import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
35import { connectionStorageLayer } from "./storage";35import { connectionStorageLayer } from "./storage";
apps/mobile/src/features/home/usePendingTaskListActions.ts+1-1
...@@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native";...@@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native";
2import { useCallback } from "react";2import { useCallback } from "react";
3import { Alert } from "react-native";3import { Alert } from "react-native";
44
5import { removeThreadOutboxMessage } from "../../state/thread-outbox";5import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
6import type { PendingNewTask } from "../../state/use-pending-new-tasks";6import type { PendingNewTask } from "../../state/use-pending-new-tasks";
7import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";7import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
88
apps/mobile/src/features/sharing/IncomingShareProvider.tsx+52-12
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import Constants from "expo-constants";1import Constants from "expo-constants";
2import * as Crypto from "expo-crypto";2import * as Crypto from "expo-crypto";
3import { PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts";
3import {4import {
4 clearSharedPayloads,5 clearSharedPayloads,
5 getResolvedSharedPayloadsAsync,6 getResolvedSharedPayloadsAsync,
...@@ -12,11 +13,13 @@ import { Alert, AppState, Platform } from "react-native";...@@ -12,11 +13,13 @@ import { Alert, AppState, Platform } from "react-native";
1213
13import {14import {
14 buildIncomingShareDraft,15 buildIncomingShareDraft,
16 isShareFileUriUnderOwnedRoots,
15 type IncomingShareDestination,17 type IncomingShareDestination,
16 type IncomingShareDraft,18 type IncomingShareDraft,
17} from "./incoming-share-model";19} from "./incoming-share-model";
18import { createIncomingSharePayloadReader } from "./incoming-share-native";20import { createIncomingSharePayloadReader } from "./incoming-share-native";
19import { IncomingShareInbox } from "./incoming-share-inbox";21import { IncomingShareInbox } from "./incoming-share-inbox";
22import { persistComposerAttachmentFile } from "../../lib/composerImages";
20import {23import {
21 loadIncomingShareDrafts,24 loadIncomingShareDrafts,
22 removeIncomingShareDraft,25 removeIncomingShareDraft,
...@@ -54,7 +57,7 @@ const getIncomingSharePayloads = createIncomingSharePayloadReader({...@@ -54,7 +57,7 @@ const getIncomingSharePayloads = createIncomingSharePayloadReader({
54 readPayloads: getSharedPayloads,57 readPayloads: getSharedPayloads,
55});58});
5659
57async function resolvedPayloadsForImages(): Promise<ReadonlyArray<ResolvedSharePayload>> {60async function resolvedPayloadsForFiles(): Promise<ReadonlyArray<ResolvedSharePayload>> {
58 try {61 try {
59 return await getResolvedSharedPayloadsAsync();62 return await getResolvedSharedPayloadsAsync();
60 } catch (error) {63 } catch (error) {
...@@ -84,12 +87,29 @@ async function readBase64(uri: string): Promise<string> {...@@ -84,12 +87,29 @@ async function readBase64(uri: string): Promise<string> {
84 return new File(uri).base64();87 return new File(uri).base64();
85}88}
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
87async function removeOwnedFile(uri: string): Promise<void> {95async function removeOwnedFile(uri: string): Promise<void> {
88 if (!uri.startsWith("file:")) {96 if (!uri.startsWith("file:")) {
89 return;97 return;
90 }98 }
91 try {99 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 }
93 const file = new File(uri);113 const file = new File(uri);
94 if (file.exists) {114 if (file.exists) {
95 file.delete();115 file.delete();
...@@ -99,21 +119,23 @@ async function removeOwnedFile(uri: string): Promise<void> {...@@ -99,21 +119,23 @@ async function removeOwnedFile(uri: string): Promise<void> {
99 }119 }
100}120}
101121
102async function removeReplayedImagePayloadFiles(122async function removeReplayedPayloadFiles(payloads: ReadonlyArray<SharePayload>): Promise<void> {
103 payloads: ReadonlyArray<SharePayload>,
104): Promise<void> {
105 const uris = new Set<string>();123 const uris = new Set<string>();
106 for (const payload of payloads) {124 for (const payload of payloads) {
107 if (payload.shareType === "image") {125 if (["image", "file", "audio", "video"].includes(payload.shareType)) {
108 uris.add(payload.value);126 uris.add(payload.value);
109 }127 }
110 }128 }
111 if (uris.size === 0) {129 if (uris.size === 0) {
112 return;130 return;
113 }131 }
114 const resolvedPayloads = await resolvedPayloadsForImages();132 const resolvedPayloads = payloads.some((payload) =>
133 ["file", "audio", "video"].includes(payload.shareType),
134 )
135 ? []
136 : await resolvedPayloadsForFiles();
115 for (const payload of resolvedPayloads) {137 for (const payload of resolvedPayloads) {
116 if (payload.shareType === "image" && payload.contentUri) {138 if (["image", "file", "audio", "video"].includes(payload.shareType) && payload.contentUri) {
117 uris.add(payload.contentUri);139 uris.add(payload.contentUri);
118 }140 }
119 }141 }
...@@ -131,14 +153,29 @@ const incomingShareInbox = new IncomingShareInbox({...@@ -131,14 +153,29 @@ const incomingShareInbox = new IncomingShareInbox({
131 clearPayloads: clearSharedPayloads,153 clearPayloads: clearSharedPayloads,
132 buildDraft: async ({ payloads, id, createdAt }) => {154 buildDraft: async ({ payloads, id, createdAt }) => {
133 const cleanupUris = new Set<string>();155 const cleanupUris = new Set<string>();
134 const resolvedPayloads = payloads.some((payload) => payload.shareType === "image")156 const persistedUris = new Set<string>();
135 ? await resolvedPayloadsForImages()157 const hasGenericFilePayload = payloads.some((payload) =>
136 : [];158 ["file", "audio", "video"].includes(payload.shareType),
159 );
160 const resolvedPayloads =
161 !hasGenericFilePayload && payloads.some((payload) => payload.shareType === "image")
162 ? await resolvedPayloadsForFiles()
163 : [];
137 const draft = await buildIncomingShareDraft({164 const draft = await buildIncomingShareDraft({
138 payloads,165 payloads,
139 resolvedPayloads,166 resolvedPayloads,
140 fileReader: {167 fileReader: {
141 readBase64,168 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,
142 removeOwnedFile: (uri) => {179 removeOwnedFile: (uri) => {
143 cleanupUris.add(uri);180 cleanupUris.add(uri);
144 },181 },
...@@ -151,9 +188,12 @@ const incomingShareInbox = new IncomingShareInbox({...@@ -151,9 +188,12 @@ const incomingShareInbox = new IncomingShareInbox({
151 cleanup: async () => {188 cleanup: async () => {
152 await Promise.all([...cleanupUris].map(removeOwnedFile));189 await Promise.all([...cleanupUris].map(removeOwnedFile));
153 },190 },
191 rollback: async () => {
192 await Promise.all([...persistedUris].map(removeOwnedFile));
193 },
154 };194 };
155 },195 },
156 cleanupReplayedPayloads: removeReplayedImagePayloadFiles,196 cleanupReplayedPayloads: removeReplayedPayloadFiles,
157 idForPayloads: incomingShareIdForPayloads,197 idForPayloads: incomingShareIdForPayloads,
158 now: () => new Date().toISOString(),198 now: () => new Date().toISOString(),
159 onClearError: (error) => {199 onClearError: (error) => {
apps/mobile/src/features/sharing/incoming-share-inbox.test.ts+3
...@@ -136,11 +136,13 @@ describe("IncomingShareInbox", () => {...@@ -136,11 +136,13 @@ describe("IncomingShareInbox", () => {
136 it("does not acknowledge a supported payload when its durable write fails", async () => {136 it("does not acknowledge a supported payload when its durable write fails", async () => {
137 const clearPayloads = vi.fn();137 const clearPayloads = vi.fn();
138 const cleanup = vi.fn(async () => undefined);138 const cleanup = vi.fn(async () => undefined);
139 const rollback = vi.fn(async () => undefined);
139 const { inbox } = createHarness({140 const { inbox } = createHarness({
140 clearPayloads,141 clearPayloads,
141 buildDraft: async ({ id, createdAt }) => ({142 buildDraft: async ({ id, createdAt }) => ({
142 draft: draft(id, createdAt),143 draft: draft(id, createdAt),
143 cleanup,144 cleanup,
145 rollback,
144 }),146 }),
145 writeDraft: async () => {147 writeDraft: async () => {
146 throw new Error("disk full");148 throw new Error("disk full");
...@@ -150,6 +152,7 @@ describe("IncomingShareInbox", () => {...@@ -150,6 +152,7 @@ describe("IncomingShareInbox", () => {
150 await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full");152 await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full");
151 expect(clearPayloads).not.toHaveBeenCalled();153 expect(clearPayloads).not.toHaveBeenCalled();
152 expect(cleanup).not.toHaveBeenCalled();154 expect(cleanup).not.toHaveBeenCalled();
155 expect(rollback).toHaveBeenCalledOnce();
153 });156 });
154157
155 it("durably reserves a share for one project before draft import", async () => {158 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 {...@@ -20,6 +20,7 @@ export interface IncomingShareInboxDependencies {
20 }) => Promise<{20 }) => Promise<{
21 readonly draft: IncomingShareDraft;21 readonly draft: IncomingShareDraft;
22 readonly cleanup: () => Promise<void>;22 readonly cleanup: () => Promise<void>;
23 readonly rollback?: () => Promise<void>;
23 }>;24 }>;
24 readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>;25 readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>;
25 readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>;26 readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>;
...@@ -116,7 +117,14 @@ export class IncomingShareInbox {...@@ -116,7 +117,14 @@ export class IncomingShareInbox {
116 // The durable inbox write is the transaction boundary. Never clear the117 // The durable inbox write is the transaction boundary. Never clear the
117 // native handoff first: a process termination must leave one recoverable118 // native handoff first: a process termination must leave one recoverable
118 // copy on one side of the boundary.119 // 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 }
120 await this.cleanup(built.cleanup);128 await this.cleanup(built.cleanup);
121 this.clearNativePayloads();129 this.clearNativePayloads();
122 return sortAndDedupeIncomingShares([draft, ...persisted]);130 return sortAndDedupeIncomingShares([draft, ...persisted]);
apps/mobile/src/features/sharing/incoming-share-model.test.ts+518-1
...@@ -1,11 +1,18 @@...@@ -1,11 +1,18 @@
1import { describe, expect, it, vi } from "@effect/vitest";1import { describe, expect, it, vi } from "@effect/vitest";
2import {2import {
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
4 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,5 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import type { ResolvedSharePayload, SharePayload } from "expo-sharing";7import 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
10describe("incoming native shares", () => {17describe("incoming native shares", () => {
11 it("converts shared text, URLs, and images into a durable composer draft", async () => {18 it("converts shared text, URLs, and images into a durable composer draft", async () => {
...@@ -96,6 +103,411 @@ describe("incoming native shares", () => {...@@ -96,6 +103,411 @@ describe("incoming native shares", () => {
96 expect(hasIncomingShareContent(result)).toBe(false);103 expect(hasIncomingShareContent(result)).toBe(false);
97 });104 });
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
99 it("releases every temporary file when a share exceeds the attachment limit", async () => {511 it("releases every temporary file when a share exceeds the attachment limit", async () => {
100 const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({512 const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({
101 shareType: "image" as const,513 shareType: "image" as const,
...@@ -193,4 +605,109 @@ describe("incoming native shares", () => {...@@ -193,4 +605,109 @@ describe("incoming native shares", () => {
193 expect(result.attachments).toHaveLength(1);605 expect(result.attachments).toHaveLength(1);
194 expect(result.warnings).toEqual([]);606 expect(result.warnings).toEqual([]);
195 });607 });
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 });
196});713});
apps/mobile/src/features/sharing/incoming-share-model.ts+233-10
...@@ -1,13 +1,18 @@...@@ -1,13 +1,18 @@
1import {
2 clampFileAttachmentUploadBytes,
3 fileAttachmentTooLargeMessage,
4} from "@t3tools/client-runtime/state/attachments";
1import {5import {
2 isProviderSendTurnSupportedImageMimeType,6 isProviderSendTurnSupportedImageMimeType,
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,7 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
4 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,9 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5} from "@t3tools/contracts";10} from "@t3tools/contracts";
6import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
7import type { ResolvedSharePayload, SharePayload } from "expo-sharing";12import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
813
9import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema";14import { DraftComposerAttachmentSchema } from "../../lib/composer-image-schema";
10import type { DraftComposerImageAttachment } from "../../lib/composerImages";15import type { DraftComposerAttachment } from "../../lib/composerImages";
11import { estimateBase64ByteSize } from "../../lib/base64";16import { estimateBase64ByteSize } from "../../lib/base64";
1217
13export interface IncomingShareDraft {18export interface IncomingShareDraft {
...@@ -16,7 +21,7 @@ export interface IncomingShareDraft {...@@ -16,7 +21,7 @@ export interface IncomingShareDraft {
16 readonly createdAt: string;21 readonly createdAt: string;
17 readonly destination?: IncomingShareDestination;22 readonly destination?: IncomingShareDestination;
18 readonly text: string;23 readonly text: string;
19 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;24 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
20 readonly warnings: ReadonlyArray<string>;25 readonly warnings: ReadonlyArray<string>;
21}26}
2227
...@@ -36,7 +41,7 @@ export const IncomingShareDraftSchema = Schema.Struct({...@@ -36,7 +41,7 @@ export const IncomingShareDraftSchema = Schema.Struct({
36 createdAt: Schema.String,41 createdAt: Schema.String,
37 destination: Schema.optional(IncomingShareDestinationSchema),42 destination: Schema.optional(IncomingShareDestinationSchema),
38 text: Schema.String,43 text: Schema.String,
39 attachments: Schema.Array(DraftComposerImageAttachmentSchema),44 attachments: Schema.Array(DraftComposerAttachmentSchema),
40 warnings: Schema.Array(Schema.String),45 warnings: Schema.Array(Schema.String),
41});46});
4247
...@@ -46,9 +51,126 @@ export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft {...@@ -46,9 +51,126 @@ export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft {
46 return decodeIncomingShareDraftSync(value);51 return decodeIncomingShareDraftSync(value);
47}52}
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
49export interface IncomingShareFileReader {103export interface IncomingShareFileReader {
50 readonly readBase64: (uri: string) => Promise<string>;104 readonly readBase64: (uri: string) => Promise<string>;
51 readonly removeOwnedFile: (uri: string) => Promise<void> | void;105 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 };
52}174}
53175
54function sharedText(payloads: ReadonlyArray<SharePayload>): string {176function sharedText(payloads: ReadonlyArray<SharePayload>): string {
...@@ -119,8 +241,11 @@ function fallbackName(uri: string, index: number, mimeType: string): string {...@@ -119,8 +241,11 @@ function fallbackName(uri: string, index: number, mimeType: string): string {
119 } catch {241 } catch {
120 // Fall through to a deterministic attachment name.242 // Fall through to a deterministic attachment name.
121 }243 }
122 const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png";244 const family = mimeType.split("/")[0]?.toLowerCase();
123 return `shared-image-${index + 1}.${extension}`;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}`;
124}249}
125250
126export async function buildIncomingShareDraft(input: {251export async function buildIncomingShareDraft(input: {
...@@ -130,13 +255,18 @@ export async function buildIncomingShareDraft(input: {...@@ -130,13 +255,18 @@ export async function buildIncomingShareDraft(input: {
130 readonly id: string;255 readonly id: string;
131 readonly createdAt: string;256 readonly createdAt: string;
132}): Promise<IncomingShareDraft> {257}): Promise<IncomingShareDraft> {
133 const attachments: DraftComposerImageAttachment[] = [];258 const attachments: DraftComposerAttachment[] = [];
134 const warnings: string[] = [];259 const warnings: string[] = [];
135 const consumedResolvedPayloadIndexes = new Set<number>();260 const consumedResolvedPayloadIndexes = new Set<number>();
136 let warnedAttachmentLimit = false;261 let warnedAttachmentLimit = false;
137262
138 for (const [index, payload] of input.payloads.entries()) {263 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 ) {
140 continue;270 continue;
141 }271 }
142 const resolved = resolvedImageFor(272 const resolved = resolvedImageFor(
...@@ -149,7 +279,7 @@ export async function buildIncomingShareDraft(input: {...@@ -149,7 +279,7 @@ export async function buildIncomingShareDraft(input: {
149 if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {279 if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
150 if (!warnedAttachmentLimit) {280 if (!warnedAttachmentLimit) {
151 warnings.push(281 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.`,
153 );283 );
154 warnedAttachmentLimit = true;284 warnedAttachmentLimit = true;
155 }285 }
...@@ -157,7 +287,100 @@ export async function buildIncomingShareDraft(input: {...@@ -157,7 +287,100 @@ export async function buildIncomingShareDraft(input: {
157 continue;287 continue;
158 }288 }
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 }
161 if (!uri || !mimeType.startsWith("image/")) {384 if (!uri || !mimeType.startsWith("image/")) {
162 warnings.push("One shared item was not a supported image.");385 warnings.push("One shared item was not a supported image.");
163 await releaseOwnedFiles(input.fileReader, [uri, payload.value]);386 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) {...@@ -33,7 +33,9 @@ async function getFile(shareId: string) {
33 return new File(await getDirectory(), fileName(shareId));33 return new File(await getDirectory(), fileName(shareId));
34}34}
3535
36export async function loadIncomingShareDrafts(): Promise<ReadonlyArray<IncomingShareDraft>> {36export async function loadIncomingShareDrafts(options?: {
37 readonly strict?: boolean;
38}): Promise<ReadonlyArray<IncomingShareDraft>> {
37 try {39 try {
38 const { File } = await import("expo-file-system");40 const { File } = await import("expo-file-system");
39 const drafts: IncomingShareDraft[] = [];41 const drafts: IncomingShareDraft[] = [];
...@@ -44,14 +46,18 @@ export async function loadIncomingShareDrafts(): Promise<ReadonlyArray<IncomingS...@@ -44,14 +46,18 @@ export async function loadIncomingShareDrafts(): Promise<ReadonlyArray<IncomingS
44 try {46 try {
45 drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown));47 drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown));
46 } catch (cause) {48 } catch (cause) {
47 console.warn(49 const error = new IncomingShareStorageError({ operation: "load", shareId: null, cause });
48 "[incoming-share] ignored invalid persisted share",50 if (options?.strict) {
49 new IncomingShareStorageError({ operation: "load", shareId: null, cause }),51 throw error;
50 );52 }
53 console.warn("[incoming-share] ignored invalid persisted share", error);
51 }54 }
52 }55 }
53 return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt));56 return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
54 } catch (cause) {57 } catch (cause) {
58 if (cause instanceof IncomingShareStorageError) {
59 throw cause;
60 }
55 throw new IncomingShareStorageError({ operation: "load", shareId: null, cause });61 throw new IncomingShareStorageError({ operation: "load", shareId: null, cause });
56 }62 }
57}63}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+142-23
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 isAtomCommandInterrupted,20 isAtomCommandInterrupted,
21 squashAtomCommandFailure,21 squashAtomCommandFailure,
22} from "@t3tools/client-runtime/state/runtime";22} from "@t3tools/client-runtime/state/runtime";
23import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
2324
24import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";25import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
25import {26import {
...@@ -42,20 +43,27 @@ import {...@@ -42,20 +43,27 @@ import {
42} from "./use-thread-settings-sheet-presentation";43} from "./use-thread-settings-sheet-presentation";
4344
44import { makeTurnCommandMetadata } from "../../lib/commandMetadata";45import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
45import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";46import {
47 convertPastedImagesToAttachments,
48 pickComposerFiles,
49 pickComposerImages,
50} from "../../lib/composerImages";
46import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";51import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
47import {52import {
48 clearComposerDraftContent,53 clearComposerDraftContent,
54 flushComposerDrafts,
49 getComposerDraftSnapshot,55 getComposerDraftSnapshot,
50 mergeComposerDraftContent,56 mergeComposerDraftContent,
51 restoreComposerDraftSnapshot,57 restoreComposerDraftSnapshot,
58 scheduleUnusedComposerAttachmentCleanup,
52 type ComposerDraft,59 type ComposerDraft,
53} from "../../state/use-composer-drafts";60} from "../../state/use-composer-drafts";
54import { useEnvironmentServerConfig, useProjects } from "../../state/entities";61import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
55import { resolveSelectableModelSelection } from "../../lib/modelOptions";62import { resolveSelectableModelSelection } from "../../lib/modelOptions";
56import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";63import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
57import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";64import { 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";
59import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";67import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
60import { useNewTaskFlow } from "./new-task-flow-provider";68import { useNewTaskFlow } from "./new-task-flow-provider";
61import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";69import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
...@@ -66,6 +74,9 @@ import {...@@ -66,6 +74,9 @@ import {
66 resolveNewTaskWorkspaceLabel,74 resolveNewTaskWorkspaceLabel,
67} from "./new-task-context-presentation";75} from "./new-task-context-presentation";
68import { useIncomingShare } from "../sharing/IncomingShareProvider";76import { useIncomingShare } from "../sharing/IncomingShareProvider";
77import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model";
78import { appAtomRegistry } from "../../state/atom-registry";
79import { serverEnvironment } from "../../state/server";
6980
70function NewTaskWorkspaceIcon(props: {81function NewTaskWorkspaceIcon(props: {
71 readonly workspaceMode: "local" | "worktree";82 readonly workspaceMode: "local" | "worktree";
...@@ -215,9 +226,18 @@ export function NewTaskDraftScreen(props: {...@@ -215,9 +226,18 @@ export function NewTaskDraftScreen(props: {
215 );226 );
216 const isProjectPickerReturnActive =227 const isProjectPickerReturnActive =
217 isReturningToProjectPicker && !requestedInitialProjectAvailable;228 isReturningToProjectPicker && !requestedInitialProjectAvailable;
229 const isIncomingShareAwaitingServerConfig = Boolean(
230 incomingShare?.attachments.some((attachment) => attachment.type === "file") &&
231 selectedEnvironmentServerConfig === null,
232 );
218 const isIncomingShareTransferPending = Boolean(233 const isIncomingShareTransferPending = Boolean(
219 incomingShare && cancelledIncomingShareId !== props.incomingShareId,234 incomingShare &&
235 cancelledIncomingShareId !== props.incomingShareId &&
236 !isIncomingShareAwaitingServerConfig,
220 );237 );
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.
221 const composerMenu = useComposerCommandMenu({241 const composerMenu = useComposerCommandMenu({
222 draftMessage: flow.prompt,242 draftMessage: flow.prompt,
223 environmentId: selectedProject?.environmentId ?? null,243 environmentId: selectedProject?.environmentId ?? null,
...@@ -227,12 +247,14 @@ export function NewTaskDraftScreen(props: {...@@ -227,12 +247,14 @@ export function NewTaskDraftScreen(props: {
227 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null,247 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null,
228 selectedProviderStatus: flow.selectedProviderStatus,248 selectedProviderStatus: flow.selectedProviderStatus,
229 hasThread: false,249 hasThread: false,
230 enabled: isComposerFocused && !isIncomingShareTransferPending,250 enabled: isComposerFocused && !isComposerInteractionLocked,
231 onChangeDraftMessage: flow.setPrompt,251 onChangeDraftMessage: flow.setPrompt,
232 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,252 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
233 });253 });
234 usePreventRemove(254 usePreventRemove(
235 (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport,255 (isIncomingShareTransferPending && !isProjectPickerReturnActive) ||
256 isCancellingShareImport ||
257 flow.submitting,
236 () => undefined,258 () => undefined,
237 );259 );
238 const hasImportedIncomingShare = Boolean(260 const hasImportedIncomingShare = Boolean(
...@@ -446,6 +468,13 @@ export function NewTaskDraftScreen(props: {...@@ -446,6 +468,13 @@ export function NewTaskDraftScreen(props: {
446 return;468 return;
447 }469 }
448470
471 if (
472 incomingShare.attachments.some((attachment) => attachment.type === "file") &&
473 selectedEnvironmentServerConfig === null
474 ) {
475 return;
476 }
477
449 if (alertedUnavailableIncomingShareIdRef.current === shareId) {478 if (alertedUnavailableIncomingShareIdRef.current === shareId) {
450 alertedUnavailableIncomingShareIdRef.current = null;479 alertedUnavailableIncomingShareIdRef.current = null;
451 }480 }
...@@ -455,6 +484,7 @@ export function NewTaskDraftScreen(props: {...@@ -455,6 +484,7 @@ export function NewTaskDraftScreen(props: {
455 shareImportDraftBackupRef.current.set(importKey, draftBackup);484 shareImportDraftBackupRef.current.set(importKey, draftBackup);
456 const importToken = Symbol(importKey);485 const importToken = Symbol(importKey);
457 let didReserveShare = false;486 let didReserveShare = false;
487 let didConsumeShare = false;
458 let needsDraftRestore = false;488 let needsDraftRestore = false;
459 activeShareImportTokenRef.current = importToken;489 activeShareImportTokenRef.current = importToken;
460 setImportingShareKey(importKey);490 setImportingShareKey(importKey);
...@@ -472,10 +502,19 @@ export function NewTaskDraftScreen(props: {...@@ -472,10 +502,19 @@ export function NewTaskDraftScreen(props: {
472 ) {502 ) {
473 return;503 return;
474 }504 }
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 }
475 needsDraftRestore = true;514 needsDraftRestore = true;
476 const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, {515 const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, {
477 text: incomingShare.text,516 text: incomingShare.text,
478 attachments: incomingShare.attachments,517 attachments: selectedAttachments.attachments,
479 sourceShareId: shareId,518 sourceShareId: shareId,
480 });519 });
481 if (520 if (
...@@ -489,13 +528,25 @@ export function NewTaskDraftScreen(props: {...@@ -489,13 +528,25 @@ export function NewTaskDraftScreen(props: {
489 return;528 return;
490 }529 }
491 await consumeShare(shareId);530 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 );
492 if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {543 if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {
493 return;544 return;
494 }545 }
495 const warnings = [...incomingShare.warnings];546 const warnings = [...incomingShare.warnings, ...selectedAttachments.warnings];
496 if (skippedAttachmentCount > 0) {547 if (skippedAttachmentCount > 0) {
497 warnings.push(548 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.`,
499 );550 );
500 }551 }
501 if (warnings.length > 0) {552 if (warnings.length > 0) {
...@@ -526,8 +577,16 @@ export function NewTaskDraftScreen(props: {...@@ -526,8 +577,16 @@ export function NewTaskDraftScreen(props: {
526 setIsCancellingShareImport(true);577 setIsCancellingShareImport(true);
527 try {578 try {
528 if (needsDraftRestore) {579 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;
529 await restoreComposerDraftSnapshot(draftKey, draftBackup);585 await restoreComposerDraftSnapshot(draftKey, draftBackup);
530 needsDraftRestore = false;586 needsDraftRestore = false;
587 if (didConsumeShare) {
588 scheduleUnusedComposerAttachmentCleanup(mergedAttachments);
589 }
531 }590 }
532 if (didReserveShare) {591 if (didReserveShare) {
533 await releaseShareReservation(shareId, {592 await releaseShareReservation(shareId, {
...@@ -602,6 +661,7 @@ export function NewTaskDraftScreen(props: {...@@ -602,6 +661,7 @@ export function NewTaskDraftScreen(props: {
602 props.initialProjectRef?.projectId,661 props.initialProjectRef?.projectId,
603 releaseShareReservation,662 releaseShareReservation,
604 reserveShare,663 reserveShare,
664 selectedEnvironmentServerConfig,
605 selectedProject,665 selectedProject,
606 shareImportAttempt,666 shareImportAttempt,
607 ]);667 ]);
...@@ -633,12 +693,47 @@ export function NewTaskDraftScreen(props: {...@@ -633,12 +693,47 @@ export function NewTaskDraftScreen(props: {
633 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;693 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
634694
635 async function handlePickImages(): Promise<void> {695 async function handlePickImages(): Promise<void> {
636 if (isIncomingShareTransferPending) {696 if (isComposerInteractionLocked) {
637 return;697 return;
638 }698 }
639 const result = await pickComposerImages({ existingCount: flow.attachments.length });699 const result = await pickComposerImages({ existingCount: flow.attachments.length });
640 if (result.images.length > 0) {700 const rejectedCount = result.images.length > 0 ? flow.appendAttachments(result.images) : 0;
641 flow.appendAttachments(result.images);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"));
642 }737 }
643 }738 }
644739
...@@ -693,6 +788,16 @@ export function NewTaskDraftScreen(props: {...@@ -693,6 +788,16 @@ export function NewTaskDraftScreen(props: {
693 ) {788 ) {
694 return;789 return;
695 }790 }
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
697 const editingPendingTask = flow.editingPendingTask;802 const editingPendingTask = flow.editingPendingTask;
698803
...@@ -764,6 +869,10 @@ export function NewTaskDraftScreen(props: {...@@ -764,6 +869,10 @@ export function NewTaskDraftScreen(props: {
764 interactionMode,869 interactionMode,
765 initialMessageText,870 initialMessageText,
766 initialAttachments: draft.attachments,871 initialAttachments: draft.attachments,
872 onAttachmentsUploaded: async (attachments) => {
873 flow.replaceAttachments(attachments);
874 await flushComposerDrafts();
875 },
767 ...(editingPendingTask876 ...(editingPendingTask
768 ? {877 ? {
769 turnMetadata: {878 turnMetadata: {
...@@ -839,7 +948,7 @@ export function NewTaskDraftScreen(props: {...@@ -839,7 +948,7 @@ export function NewTaskDraftScreen(props: {
839 // The context-first screen intentionally opens with the keyboard closed.948 // The context-first screen intentionally opens with the keyboard closed.
840 // Focusing is a user action, so presenting the form sheet has one motion.949 // Focusing is a user action, so presenting the form sheet has one motion.
841 autoFocus={false}950 autoFocus={false}
842 editable={!isIncomingShareTransferPending}951 editable={!isComposerInteractionLocked}
843 multiline952 multiline
844 scrollEnabled953 scrollEnabled
845 value={flow.prompt}954 value={flow.prompt}
...@@ -873,7 +982,7 @@ export function NewTaskDraftScreen(props: {...@@ -873,7 +982,7 @@ export function NewTaskDraftScreen(props: {
873 navigation.goBack();982 navigation.goBack();
874 };983 };
875 const chooseProject = () => {984 const chooseProject = () => {
876 if (isIncomingShareTransferPending) {985 if (isComposerInteractionLocked) {
877 return;986 return;
878 }987 }
879 promptInputRef.current?.blur();988 promptInputRef.current?.blur();
...@@ -881,7 +990,7 @@ export function NewTaskDraftScreen(props: {...@@ -881,7 +990,7 @@ export function NewTaskDraftScreen(props: {
881 navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId }));990 navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId }));
882 };991 };
883 const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {992 const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {
884 if (isIncomingShareTransferPending) {993 if (isComposerInteractionLocked) {
885 return;994 return;
886 }995 }
887 promptInputRef.current?.blur();996 promptInputRef.current?.blur();
...@@ -901,7 +1010,7 @@ export function NewTaskDraftScreen(props: {...@@ -901,7 +1010,7 @@ export function NewTaskDraftScreen(props: {
901 accessibilityHint="Opens the project picker"1010 accessibilityHint="Opens the project picker"
902 accessibilityLabel={`Change project from ${selectedProject.title}`}1011 accessibilityLabel={`Change project from ${selectedProject.title}`}
903 accessibilityRole="button"1012 accessibilityRole="button"
904 disabled={isIncomingShareTransferPending}1013 disabled={isComposerInteractionLocked}
905 onPress={chooseProject}1014 onPress={chooseProject}
906 className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65"1015 className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65"
907 >1016 >
...@@ -919,7 +1028,7 @@ export function NewTaskDraftScreen(props: {...@@ -919,7 +1028,7 @@ export function NewTaskDraftScreen(props: {
919 <ComposerInlineControl1028 <ComposerInlineControl
920 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}1029 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
921 chevronDirection="right"1030 chevronDirection="right"
922 disabled={isIncomingShareTransferPending}1031 disabled={isComposerInteractionLocked}
923 icon="desktopcomputer"1032 icon="desktopcomputer"
924 label={`on ${selectedEnvironmentLabel}`}1033 label={`on ${selectedEnvironmentLabel}`}
925 maxWidth={260}1034 maxWidth={260}
...@@ -954,7 +1063,7 @@ export function NewTaskDraftScreen(props: {...@@ -954,7 +1063,7 @@ export function NewTaskDraftScreen(props: {
954 <ComposerInlineControl1063 <ComposerInlineControl
955 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}1064 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
956 accessibilityLabel={workspaceLabel}1065 accessibilityLabel={workspaceLabel}
957 disabled={isIncomingShareTransferPending}1066 disabled={isComposerInteractionLocked}
958 iconNode={1067 iconNode={
959 <NewTaskWorkspaceIcon1068 <NewTaskWorkspaceIcon
960 workspaceMode={flow.workspaceMode}1069 workspaceMode={flow.workspaceMode}
...@@ -970,7 +1079,7 @@ export function NewTaskDraftScreen(props: {...@@ -970,7 +1079,7 @@ export function NewTaskDraftScreen(props: {
970 <ComposerInlineControl1079 <ComposerInlineControl
971 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}1080 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
972 chevronDirection="right"1081 chevronDirection="right"
973 disabled={isIncomingShareTransferPending}1082 disabled={isComposerInteractionLocked}
974 icon="arrow.triangle.branch"1083 icon="arrow.triangle.branch"
975 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}1084 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
976 maxWidth={190}1085 maxWidth={190}
...@@ -1010,7 +1119,7 @@ export function NewTaskDraftScreen(props: {...@@ -1010,7 +1119,7 @@ export function NewTaskDraftScreen(props: {
1010 attachments={flow.attachments}1119 attachments={flow.attachments}
1011 imageBorderRadius={16}1120 imageBorderRadius={16}
1012 imageSize={72}1121 imageSize={72}
1013 onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment}1122 onRemove={isComposerInteractionLocked ? () => undefined : flow.removeAttachment}
1014 />1123 />
1015 </View>1124 </View>
1016 ) : null}1125 ) : null}
...@@ -1021,14 +1130,24 @@ export function NewTaskDraftScreen(props: {...@@ -1021,14 +1130,24 @@ export function NewTaskDraftScreen(props: {
1021 <ComposerToolbarScroller contentPaddingRight={8} fadeSurface="sheet">1130 <ComposerToolbarScroller contentPaddingRight={8} fadeSurface="sheet">
1022 <ComposerToolbarButton1131 <ComposerToolbarButton
1023 accessibilityLabel="Add attachment"1132 accessibilityLabel="Add attachment"
1024 disabled={isIncomingShareTransferPending}1133 disabled={isComposerInteractionLocked}
1025 icon="plus"1134 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 }}
1027 showChevron={false}1146 showChevron={false}
1028 />1147 />
1029 <ComposerInlineControl1148 <ComposerInlineControl
1030 accessibilityLabel="Model and reasoning settings"1149 accessibilityLabel="Model and reasoning settings"
1031 disabled={isIncomingShareTransferPending}1150 disabled={isComposerInteractionLocked}
1032 emphasized1151 emphasized
1033 iconNode={1152 iconNode={
1034 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />1153 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
...@@ -1041,7 +1160,7 @@ export function NewTaskDraftScreen(props: {...@@ -1041,7 +1160,7 @@ export function NewTaskDraftScreen(props: {
1041 <ComposerInlineControl1160 <ComposerInlineControl
1042 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}1161 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}
1043 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}1162 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}
1044 disabled={isIncomingShareTransferPending}1163 disabled={isComposerInteractionLocked}
1045 emphasized1164 emphasized
1046 icon={1165 icon={
1047 flow.interactionMode === "plan"1166 flow.interactionMode === "plan"
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+2-2
...@@ -99,8 +99,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -99,8 +99,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
99 ? incomingShare.attachments.length === 099 ? incomingShare.attachments.length === 0
100 ? "Choose a project for what you shared"100 ? "Choose a project for what you shared"
101 : incomingShare.attachments.length === 1101 : incomingShare.attachments.length === 1
102 ? "Choose a project for the image 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} images you shared`103 : `Choose a project for the ${incomingShare.attachments.length} ${incomingShare.attachments.every((attachment) => attachment.type === "image") ? "images" : "files"} you shared`
104 : null;104 : null;
105 const screenTitle = incomingShare ? "Start a task" : "Choose project";105 const screenTitle = incomingShare ? "Start a task" : "Choose project";
106 const projectEmptyState = deriveProjectEmptyState(catalogState);106 const projectEmptyState = deriveProjectEmptyState(catalogState);
apps/mobile/src/features/threads/ThreadComposer.tsx+45-13
...@@ -19,7 +19,15 @@ import {...@@ -19,7 +19,15 @@ import {
19 useState,19 useState,
20 type RefObject,20 type RefObject,
21} from "react";21} 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";
23import ImageViewing from "react-native-image-viewing";31import ImageViewing from "react-native-image-viewing";
24import Animated, {32import Animated, {
25 FadeIn,33 FadeIn,
...@@ -36,6 +44,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re...@@ -36,6 +44,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re
36import { scopedThreadKey } from "../../lib/scopedEntities";44import { scopedThreadKey } from "../../lib/scopedEntities";
3745
38import { AppText as Text } from "../../components/AppText";46import { AppText as Text } from "../../components/AppText";
47import { SymbolView } from "../../components/AppSymbol";
39import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";48import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
40import { GlassSurface } from "../../components/GlassSurface";49import { GlassSurface } from "../../components/GlassSurface";
41import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";50import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
...@@ -46,7 +55,7 @@ import {...@@ -46,7 +55,7 @@ import {
46} from "../../components/ComposerToolbar";55} from "../../components/ComposerToolbar";
47import { ControlPill } from "../../components/ControlPill";56import { ControlPill } from "../../components/ControlPill";
48import { ProviderIcon } from "../../components/ProviderIcon";57import { ProviderIcon } from "../../components/ProviderIcon";
49import type { DraftComposerImageAttachment } from "../../lib/composerImages";58import type { DraftComposerAttachment } from "../../lib/composerImages";
50import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";59import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
51import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";60import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
52import type { RemoteClientConnectionState } from "../../lib/connection";61import type { RemoteClientConnectionState } from "../../lib/connection";
...@@ -76,7 +85,7 @@ export const COMPOSER_EXPANDED_CHROME = 156;...@@ -76,7 +85,7 @@ export const COMPOSER_EXPANDED_CHROME = 156;
7685
77export interface ThreadComposerProps {86export interface ThreadComposerProps {
78 readonly draftMessage: string;87 readonly draftMessage: string;
79 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;88 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
80 readonly placeholder: string;89 readonly placeholder: string;
81 readonly contentMaxWidth?: number;90 readonly contentMaxWidth?: number;
82 readonly bottomInset?: number;91 readonly bottomInset?: number;
...@@ -97,6 +106,7 @@ export interface ThreadComposerProps {...@@ -97,6 +106,7 @@ export interface ThreadComposerProps {
97 readonly editorRef?: RefObject<ComposerEditorHandle | null>;106 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
98 readonly onChangeDraftMessage: (value: string) => void;107 readonly onChangeDraftMessage: (value: string) => void;
99 readonly onPickDraftImages: () => Promise<void>;108 readonly onPickDraftImages: () => Promise<void>;
109 readonly onPickDraftFiles: () => Promise<void>;
100 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;110 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
101 readonly onRemoveDraftImage: (imageId: string) => void;111 readonly onRemoveDraftImage: (imageId: string) => void;
102 readonly onStopThread: () => void;112 readonly onStopThread: () => void;
...@@ -597,15 +607,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -597,15 +607,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
597 </View>607 </View>
598 {!isExpanded && props.draftAttachments.length > 0 ? (608 {!isExpanded && props.draftAttachments.length > 0 ? (
599 <View className="flex-row gap-1 pl-1">609 <View className="flex-row gap-1 pl-1">
600 {props.draftAttachments.slice(0, 3).map((image) => (610 {props.draftAttachments.slice(0, 3).map((attachment) =>
601 <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}>611 attachment.type === "image" ? (
602 <Image612 <Pressable
603 source={{ uri: image.previewUri }}613 key={attachment.id}
604 className="size-[30px] rounded-lg bg-subtle"614 onPress={() => onPressImage(attachment.previewUri)}
605 resizeMode="cover"615 >
606 />616 <Image
607 </Pressable>617 source={{ uri: attachment.previewUri }}
608 ))}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 )}
609 {props.draftAttachments.length > 3 ? (631 {props.draftAttachments.length > 3 ? (
610 <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong">632 <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong">
611 <Text className="text-foreground-muted text-2xs font-t3-bold">633 <Text className="text-foreground-muted text-2xs font-t3-bold">
...@@ -650,7 +672,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -650,7 +672,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
650 <ComposerToolbarButton672 <ComposerToolbarButton
651 accessibilityLabel="Add attachment"673 accessibilityLabel="Add attachment"
652 icon="plus"674 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 }}
654 showChevron={false}686 showChevron={false}
655 />687 />
656 <View className="min-w-0 flex-1" style={{ maxWidth: 152 }}>688 <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";...@@ -54,7 +54,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
54import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";54import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
55import type { ComposerEditorHandle } from "../../components/ComposerEditor";55import type { ComposerEditorHandle } from "../../components/ComposerEditor";
56import type { StatusTone } from "../../components/StatusPill";56import type { StatusTone } from "../../components/StatusPill";
57import type { DraftComposerImageAttachment } from "../../lib/composerImages";57import type { DraftComposerAttachment } from "../../lib/composerImages";
58import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";58import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
59import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";59import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
60import { scopedThreadKey } from "../../lib/scopedEntities";60import { scopedThreadKey } from "../../lib/scopedEntities";
...@@ -101,7 +101,7 @@ export interface ThreadDetailScreenProps {...@@ -101,7 +101,7 @@ export interface ThreadDetailScreenProps {
101 readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;101 readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;
102 readonly respondingUserInputId: ApprovalRequestId | null;102 readonly respondingUserInputId: ApprovalRequestId | null;
103 readonly draftMessage: string;103 readonly draftMessage: string;
104 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;104 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
105 readonly connectionStateLabel: EnvironmentConnectionPhase;105 readonly connectionStateLabel: EnvironmentConnectionPhase;
106 /** Message sync status for the selected thread (drives the composer status pill). */106 /** Message sync status for the selected thread (drives the composer status pill). */
107 readonly threadSyncStatus?: EnvironmentThreadStatus;107 readonly threadSyncStatus?: EnvironmentThreadStatus;
...@@ -118,6 +118,7 @@ export interface ThreadDetailScreenProps {...@@ -118,6 +118,7 @@ export interface ThreadDetailScreenProps {
118 readonly onOpenConnectionEditor: () => void;118 readonly onOpenConnectionEditor: () => void;
119 readonly onChangeDraftMessage: (value: string) => void;119 readonly onChangeDraftMessage: (value: string) => void;
120 readonly onPickDraftImages: () => Promise<void>;120 readonly onPickDraftImages: () => Promise<void>;
121 readonly onPickDraftFiles: () => Promise<void>;
121 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;122 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
122 readonly onRemoveDraftImage: (imageId: string) => void;123 readonly onRemoveDraftImage: (imageId: string) => void;
123 readonly onStopThread: () => void;124 readonly onStopThread: () => void;
...@@ -776,6 +777,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -776,6 +777,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
776 bottomInset={composerBottomInset}777 bottomInset={composerBottomInset}
777 onChangeDraftMessage={props.onChangeDraftMessage}778 onChangeDraftMessage={props.onChangeDraftMessage}
778 onPickDraftImages={props.onPickDraftImages}779 onPickDraftImages={props.onPickDraftImages}
780 onPickDraftFiles={props.onPickDraftFiles}
779 onNativePasteImages={props.onNativePasteImages}781 onNativePasteImages={props.onNativePasteImages}
780 onRemoveDraftImage={props.onRemoveDraftImage}782 onRemoveDraftImage={props.onRemoveDraftImage}
781 onStopThread={props.onStopThread}783 onStopThread={props.onStopThread}
apps/mobile/src/features/threads/ThreadFeed.tsx+154-8
...@@ -1,12 +1,23 @@...@@ -1,12 +1,23 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { 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";
5import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";16import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
6import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";17import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
7import { SymbolView } from "../../components/AppSymbol";18import { SymbolView } from "../../components/AppSymbol";
8import { HeaderHeightContext } from "@react-navigation/elements";19import { HeaderHeightContext } from "@react-navigation/elements";
9import { useNavigation } from "@react-navigation/native";20import { useFocusEffect, useNavigation } from "@react-navigation/native";
10import {21import {
11 memo,22 memo,
12 useCallback,23 useCallback,
...@@ -27,6 +38,7 @@ import {...@@ -27,6 +38,7 @@ import {
27} from "react-native-nitro-markdown";38} from "react-native-nitro-markdown";
28import {39import {
29 ActivityIndicator,40 ActivityIndicator,
41 Alert,
30 Image,42 Image,
31 Platform,43 Platform,
32 type LayoutChangeEvent,44 type LayoutChangeEvent,
...@@ -57,6 +69,7 @@ import { useFontFamily } from "../../lib/useFontFamily";...@@ -57,6 +69,7 @@ import { useFontFamily } from "../../lib/useFontFamily";
57import { scopedThreadKey } from "../../lib/scopedEntities";69import { scopedThreadKey } from "../../lib/scopedEntities";
58import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";70import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
59import { tryOpenExternalUrl } from "../../lib/openExternalUrl";71import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
72import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
60import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";73import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
61import {74import {
62 hasNativeSelectableMarkdownText,75 hasNativeSelectableMarkdownText,
...@@ -112,7 +125,10 @@ import {...@@ -112,7 +125,10 @@ import {
112 WORK_GROUP_TOGGLE_HEIGHT,125 WORK_GROUP_TOGGLE_HEIGHT,
113} from "./thread-work-log";126} from "./thread-work-log";
114import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";127import { 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";
116import { resolveWorkspaceRelativeFilePath } from "../files/filePath";132import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
117import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";133import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
118134
...@@ -207,6 +223,122 @@ function MessageAttachmentImage(props: {...@@ -207,6 +223,122 @@ function MessageAttachmentImage(props: {
207 );223 );
208}224}
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
210function ThreadMarkdownImageView(props: {342function ThreadMarkdownImageView(props: {
211 readonly uri: string | null;343 readonly uri: string | null;
212 readonly sourceKey: string;344 readonly sourceKey: string;
...@@ -1032,9 +1164,7 @@ function renderFeedEntry(...@@ -1032,9 +1164,7 @@ function renderFeedEntry(
1032 const isUser = message.role === "user";1164 const isUser = message.role === "user";
1033 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;1165 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
1034 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);1166 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
1035 const attachments = (message.attachments ?? []).filter(1167 const attachments = message.attachments ?? [];
1036 (attachment) => attachment.type === "image",
1037 );
1038 const hasReviewCommentContext = message.text.includes("<review_comment");1168 const hasReviewCommentContext = message.text.includes("<review_comment");
1039 // A bubble that sizes itself from its content cannot lay out a block whose1169 // A bubble that sizes itself from its content cannot lay out a block whose
1040 // intrinsic width overflows `maxWidth`: Android positions the bubble's1170 // intrinsic width overflows `maxWidth`: Android positions the bubble's
...@@ -1082,7 +1212,7 @@ function renderFeedEntry(...@@ -1082,7 +1212,7 @@ function renderFeedEntry(
1082 />1212 />
1083 ) : null}1213 ) : null}
1084 {attachments.map((attachment) => {1214 {attachments.map((attachment) => {
1085 return (1215 return isImageAttachment(attachment) ? (
1086 <MessageAttachmentImage1216 <MessageAttachmentImage
1087 key={attachment.id}1217 key={attachment.id}
1088 environmentId={props.environmentId}1218 environmentId={props.environmentId}
...@@ -1090,6 +1220,14 @@ function renderFeedEntry(...@@ -1090,6 +1220,14 @@ function renderFeedEntry(
1090 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"1220 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
1091 onPressImage={props.onPressImage}1221 onPressImage={props.onPressImage}
1092 />1222 />
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} />
1093 );1231 );
1094 })}1232 })}
1095 </View>1233 </View>
...@@ -1144,7 +1282,7 @@ function renderFeedEntry(...@@ -1144,7 +1282,7 @@ function renderFeedEntry(
1144 )1282 )
1145 ) : null}1283 ) : null}
1146 {attachments.map((attachment) => {1284 {attachments.map((attachment) => {
1147 return (1285 return isImageAttachment(attachment) ? (
1148 <MessageAttachmentImage1286 <MessageAttachmentImage
1149 key={attachment.id}1287 key={attachment.id}
1150 environmentId={props.environmentId}1288 environmentId={props.environmentId}
...@@ -1152,6 +1290,14 @@ function renderFeedEntry(...@@ -1152,6 +1290,14 @@ function renderFeedEntry(
1152 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"1290 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
1153 onPressImage={props.onPressImage}1291 onPressImage={props.onPressImage}
1154 />1292 />
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} />
1155 );1301 );
1156 })}1302 })}
1157 {showAssistantMeta ? (1303 {showAssistantMeta ? (
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
...@@ -794,6 +794,7 @@ function ThreadRouteContent(...@@ -794,6 +794,7 @@ function ThreadRouteContent(
794 onOpenConnectionEditor={handleOpenConnectionEditor}794 onOpenConnectionEditor={handleOpenConnectionEditor}
795 onChangeDraftMessage={composer.onChangeDraftMessage}795 onChangeDraftMessage={composer.onChangeDraftMessage}
796 onPickDraftImages={composer.onPickDraftImages}796 onPickDraftImages={composer.onPickDraftImages}
797 onPickDraftFiles={composer.onPickDraftFiles}
797 onNativePasteImages={composer.onNativePasteImages}798 onNativePasteImages={composer.onNativePasteImages}
798 onRemoveDraftImage={composer.onRemoveDraftImage}799 onRemoveDraftImage={composer.onRemoveDraftImage}
799 serverConfig={serverConfig}800 serverConfig={serverConfig}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+36-13
...@@ -27,7 +27,7 @@ import { pipe } from "effect/Function";...@@ -27,7 +27,7 @@ import { pipe } from "effect/Function";
2727
28import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";28import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
29import type { TurnCommandMetadata } from "../../lib/commandMetadata";29import type { TurnCommandMetadata } from "../../lib/commandMetadata";
30import type { DraftComposerImageAttachment } from "../../lib/composerImages";30import type { DraftComposerAttachment } from "../../lib/composerImages";
31import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";31import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
32import {32import {
33 buildModelOptions,33 buildModelOptions,
...@@ -48,18 +48,22 @@ import {...@@ -48,18 +48,22 @@ import {
48 isComposerDraftEmpty,48 isComposerDraftEmpty,
49 removeComposerDraftAttachment,49 removeComposerDraftAttachment,
50 replaceComposerDraftAttachments,50 replaceComposerDraftAttachments,
51 scheduleUnusedComposerAttachmentCleanup,
51 setComposerDraftText,52 setComposerDraftText,
52 setStickyComposerModelSelection,53 setStickyComposerModelSelection,
53 updateComposerDraftSettings,54 updateComposerDraftSettings,
54 useComposerDraft,55 useComposerDraft,
55 useStickyComposerModelSelection,56 useStickyComposerModelSelection,
56} from "../../state/use-composer-drafts";57} from "../../state/use-composer-drafts";
58import {
59 capturePendingTaskEditorWriteBaseline,
60 flushPendingTaskEditorWrite,
61} from "../../state/pending-task-editor-writes";
57import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";62import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
58import { vcsEnvironment } from "../../state/vcs";63import { vcsEnvironment } from "../../state/vcs";
59import {64import {
60 flattenQueuedThreadMessages,65 flattenQueuedThreadMessages,
61 threadOutboxManager,66 threadOutboxManager,
62 updateThreadOutboxMessage,
63 type QueuedThreadMessage,67 type QueuedThreadMessage,
64} from "../../state/thread-outbox";68} from "../../state/thread-outbox";
65import {69import {
...@@ -135,7 +139,7 @@ type NewTaskFlowContextValue = {...@@ -135,7 +139,7 @@ type NewTaskFlowContextValue = {
135 readonly draftKey: string | null;139 readonly draftKey: string | null;
136 readonly editingPendingTask: QueuedThreadMessage | null;140 readonly editingPendingTask: QueuedThreadMessage | null;
137 readonly prompt: string;141 readonly prompt: string;
138 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;142 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
139 readonly submitting: boolean;143 readonly submitting: boolean;
140 readonly branchQuery: string;144 readonly branchQuery: string;
141 readonly branchesLoading: boolean;145 readonly branchesLoading: boolean;
...@@ -174,8 +178,9 @@ type NewTaskFlowContextValue = {...@@ -174,8 +178,9 @@ type NewTaskFlowContextValue = {
174 readonly cancelEditingPendingTask: () => void;178 readonly cancelEditingPendingTask: () => void;
175 readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null;179 readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null;
176 readonly setPrompt: (value: string) => void;180 readonly setPrompt: (value: string) => void;
177 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;181 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void;
178 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;182 /** Appends draft attachments; returns how many the live cap rejected. */
183 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => number;
179 readonly removeAttachment: (imageId: string) => void;184 readonly removeAttachment: (imageId: string) => void;
180 readonly clearAttachments: () => void;185 readonly clearAttachments: () => void;
181 readonly setSubmitting: (value: boolean) => void;186 readonly setSubmitting: (value: boolean) => void;
...@@ -230,6 +235,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -230,6 +235,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
230 // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act235 // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act
231 // on a task whose editing session already ended this render.236 // on a task whose editing session already ended this render.
232 const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null);237 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
234 const reset = useCallback(() => {242 const reset = useCallback(() => {
235 setSelectedEnvironmentId(null);243 setSelectedEnvironmentId(null);
...@@ -513,7 +521,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -513,7 +521,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
513 [selectedProjectDraftKey],521 [selectedProjectDraftKey],
514 );522 );
515 const replaceAttachments = useCallback(523 const replaceAttachments = useCallback(
516 (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {524 (nextAttachments: ReadonlyArray<DraftComposerAttachment>) => {
517 if (!selectedProjectDraftKey) {525 if (!selectedProjectDraftKey) {
518 return;526 return;
519 }527 }
...@@ -521,12 +529,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -521,12 +529,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
521 },529 },
522 [selectedProjectDraftKey],530 [selectedProjectDraftKey],
523 );531 );
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).
524 const appendAttachments = useCallback(534 const appendAttachments = useCallback(
525 (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {535 (nextAttachments: ReadonlyArray<DraftComposerAttachment>): number => {
526 if (!selectedProjectDraftKey) {536 if (!selectedProjectDraftKey) {
527 return;537 return 0;
528 }538 }
529 appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments);539 return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments);
530 },540 },
531 [selectedProjectDraftKey],541 [selectedProjectDraftKey],
532 );542 );
...@@ -835,6 +845,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -835,6 +845,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
835 setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId));845 setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId));
836 activeEditingMessageId = message.messageId;846 activeEditingMessageId = message.messageId;
837 editingPendingTaskRef.current = message;847 editingPendingTaskRef.current = message;
848 editingRevisionRef.current = capturePendingTaskEditorWriteBaseline(message.messageId);
838 setEditingPendingTask(message);849 setEditingPendingTask(message);
839 // Hold the outbox drain off this task while it is open in the editor.850 // Hold the outbox drain off this task while it is open in the editor.
840 holdEditingQueuedMessage(message.messageId);851 holdEditingQueuedMessage(message.messageId);
...@@ -932,6 +943,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -932,6 +943,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
932 }943 }
933 clearComposerDraft(pendingTaskDraftKey(editing.messageId));944 clearComposerDraft(pendingTaskDraftKey(editing.messageId));
934 releaseEditingQueuedMessage(editing.messageId);945 releaseEditingQueuedMessage(editing.messageId);
946 scheduleUnusedComposerAttachmentCleanup(editing.attachments);
935 }947 }
936 setEditingPendingTask(null);948 setEditingPendingTask(null);
937 }, []);949 }, []);
...@@ -984,17 +996,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -984,17 +996,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
984 return;996 return;
985 }997 }
986998
987 // update() rewrites the task only if it is still queued — a concurrent999 // The write handoff lets a reopened editor follow this editor's pending
988 // delete or delivery wins, so the flush cannot resurrect it.1000 // save. Its CAS still rejects unrelated queue edits, deletes, and
989 void updateThreadOutboxMessage(message)1001 // deliveries, so the flush cannot resurrect or overwrite them.
990 .then(() => {1002 void flushPendingTaskEditorWrite({
1003 message,
1004 baseline: editingRevisionRef.current,
1005 draftKey: pendingTaskDraftKey(editing.messageId),
1006 })
1007 .then((savedDraftStillCurrent) => {
991 // If this task was reopened (possibly in a fresh provider) while1008 // If this task was reopened (possibly in a fresh provider) while
992 // the save was in flight, that session owns the draft and the lock.1009 // the save was in flight, that session owns the draft and the lock.
993 if (activeEditingMessageId === editing.messageId) {1010 if (activeEditingMessageId === editing.messageId) {
994 return;1011 return;
995 }1012 }
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 }
996 clearComposerDraft(pendingTaskDraftKey(editing.messageId));1018 clearComposerDraft(pendingTaskDraftKey(editing.messageId));
997 releaseEditingQueuedMessage(editing.messageId);1019 releaseEditingQueuedMessage(editing.messageId);
1020 scheduleUnusedComposerAttachmentCleanup(editing.attachments);
998 })1021 })
999 .catch((error) => {1022 .catch((error) => {
1000 // Keep the drain lock and the draft: delivering the stale payload1023 // 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";...@@ -14,13 +14,17 @@ import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/unstable/reactivity";14import { AsyncResult } from "effect/unstable/reactivity";
1515
16import { threadEnvironment } from "../../state/threads";16import { threadEnvironment } from "../../state/threads";
17import type { DraftComposerImageAttachment } from "../../lib/composerImages";17import type { DraftComposerAttachment } from "../../lib/composerImages";
18import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/attachmentUpload";
18import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";19import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
19import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";20import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
20import { randomHex } from "../../lib/uuid";21import { randomHex } from "../../lib/uuid";
21import { useAtomCommand } from "../../state/use-atom-command";22import { useAtomCommand } from "../../state/use-atom-command";
23import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts";
22import { setPendingConnectionError } from "../../state/use-remote-environment-registry";24import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
23import { validateProjectThreadCreation } from "./projectThreadCreationValidation";25import { validateProjectThreadCreation } from "./projectThreadCreationValidation";
26import { appAtomRegistry } from "../../state/atom-registry";
27import { serverEnvironment } from "../../state/server";
2428
25export function useCreateProjectThread() {29export function useCreateProjectThread() {
26 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });30 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
...@@ -36,7 +40,10 @@ export function useCreateProjectThread() {...@@ -36,7 +40,10 @@ export function useCreateProjectThread() {
36 readonly runtimeMode: RuntimeMode;40 readonly runtimeMode: RuntimeMode;
37 readonly interactionMode: ProviderInteractionMode;41 readonly interactionMode: ProviderInteractionMode;
38 readonly initialMessageText: string;42 readonly initialMessageText: string;
39 readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>;43 readonly initialAttachments: ReadonlyArray<DraftComposerAttachment>;
44 readonly onAttachmentsUploaded: (
45 attachments: ReadonlyArray<DraftComposerAttachment>,
46 ) => Promise<void>;
40 /** Reuse identifiers from a queued pending task instead of minting new ones. */47 /** Reuse identifiers from a queued pending task instead of minting new ones. */
41 readonly turnMetadata?: TurnCommandMetadata;48 readonly turnMetadata?: TurnCommandMetadata;
42 }) => {49 }) => {
...@@ -56,6 +63,50 @@ export function useCreateProjectThread() {...@@ -56,6 +63,50 @@ export function useCreateProjectThread() {
56 return AsyncResult.failure(Cause.fail(validationError));63 return AsyncResult.failure(Cause.fail(validationError));
57 }64 }
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
59 const result = await startTurn({110 const result = await startTurn({
60 environmentId: input.project.environmentId,111 environmentId: input.project.environmentId,
61 input: buildProjectThreadStartTurnInput({112 input: buildProjectThreadStartTurnInput({
...@@ -67,6 +118,7 @@ export function useCreateProjectThread() {...@@ -67,6 +118,7 @@ export function useCreateProjectThread() {
67 createdAt: metadata.createdAt,118 createdAt: metadata.createdAt,
68 text: initialMessageText,119 text: initialMessageText,
69 attachments: input.initialAttachments,120 attachments: input.initialAttachments,
121 uploadedAttachments: prepared.attachments,
70 modelSelection: input.modelSelection,122 modelSelection: input.modelSelection,
71 runtimeMode: input.runtimeMode,123 runtimeMode: input.runtimeMode,
72 interactionMode: input.interactionMode,124 interactionMode: input.interactionMode,
...@@ -84,7 +136,13 @@ export function useCreateProjectThread() {...@@ -84,7 +136,13 @@ export function useCreateProjectThread() {
84 );136 );
85 return AsyncResult.failure(result.cause);137 return AsyncResult.failure(result.cause);
86 }138 }
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 });
87 setPendingConnectionError(null);144 setPendingConnectionError(null);
145 scheduleUnusedComposerAttachmentCleanup(input.initialAttachments);
88146
89 return mapAtomCommandResult(result, () =>147 return mapAtomCommandResult(result, () =>
90 scopeThreadRef(input.project.environmentId, threadId),148 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 @@...@@ -1,4 +1,5 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { EnvironmentId } from "@t3tools/contracts";
23
3export const DraftComposerImageAttachmentSchema = Schema.Struct({4export const DraftComposerImageAttachmentSchema = Schema.Struct({
4 id: Schema.String,5 id: Schema.String,
...@@ -9,3 +10,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({...@@ -9,3 +10,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({
9 sizeBytes: Schema.Number,10 sizeBytes: Schema.Number,
10 dataUrl: Schema.String,11 dataUrl: Schema.String,
11});12});
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 @@...@@ -1,10 +1,21 @@
1import {
2 clampFileAttachmentUploadBytes,
3 fileAttachmentTooLargeMessage,
4} from "@t3tools/client-runtime/state/attachments";
1import {5import {
2 isProviderSendTurnSupportedImageMimeType,6 isProviderSendTurnSupportedImageMimeType,
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,7 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
4 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,9 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
10 type EnvironmentId,
5 type UploadChatImageAttachment,11 type UploadChatImageAttachment,
6} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import type { PickMultipleFilesResult } from "expo-file-system";
7import { estimateBase64ByteSize } from "./base64";14import { estimateBase64ByteSize } from "./base64";
15import {
16 COMPOSER_ATTACHMENT_DIRECTORY,
17 resolveOwnedComposerAttachmentFileUri,
18} from "./composerAttachmentFiles";
8import { beginForegroundHandoff } from "./foreground-handoff";19import { beginForegroundHandoff } from "./foreground-handoff";
9import { uuidv4 } from "./uuid";20import { uuidv4 } from "./uuid";
1021
...@@ -13,6 +24,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment...@@ -13,6 +24,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment
13 readonly previewUri: string;24 readonly previewUri: string;
14}25}
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
16/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */40/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */
17export function toUploadChatImageAttachments(41export function toUploadChatImageAttachments(
18 attachments: ReadonlyArray<DraftComposerImageAttachment>,42 attachments: ReadonlyArray<DraftComposerImageAttachment>,
...@@ -27,6 +51,189 @@ export function toUploadChatImageAttachments(...@@ -27,6 +51,189 @@ export function toUploadChatImageAttachments(
27}51}
2852
29const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";53const 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
31async function loadImagePicker() {238async function loadImagePicker() {
32 try {239 try {
apps/mobile/src/lib/projectThreadStartTurn.ts+10-3
...@@ -2,13 +2,15 @@ import {...@@ -2,13 +2,15 @@ import {
2 CommandId,2 CommandId,
3 MessageId,3 MessageId,
4 ThreadId,4 ThreadId,
5 type ChatFileAttachment,
5 type ModelSelection,6 type ModelSelection,
6 type ProjectId,7 type ProjectId,
7 type ProviderInteractionMode,8 type ProviderInteractionMode,
8 type RuntimeMode,9 type RuntimeMode,
10 type UploadChatImageAttachment,
9} from "@t3tools/contracts";11} from "@t3tools/contracts";
1012
11import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages";13import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
1214
13export function deriveThreadTitleFromPrompt(value: string): string {15export function deriveThreadTitleFromPrompt(value: string): string {
14 const trimmed = value.trim();16 const trimmed = value.trim();
...@@ -28,7 +30,8 @@ export interface ProjectThreadStartTurnSpec {...@@ -28,7 +30,8 @@ export interface ProjectThreadStartTurnSpec {
28 readonly messageId: string;30 readonly messageId: string;
29 readonly createdAt: string;31 readonly createdAt: string;
30 readonly text: string;32 readonly text: string;
31 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;33 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
34 readonly uploadedAttachments?: ReadonlyArray<UploadChatImageAttachment | ChatFileAttachment>;
32 readonly modelSelection: ModelSelection;35 readonly modelSelection: ModelSelection;
33 readonly runtimeMode: RuntimeMode;36 readonly runtimeMode: RuntimeMode;
34 readonly interactionMode: ProviderInteractionMode;37 readonly interactionMode: ProviderInteractionMode;
...@@ -55,7 +58,11 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe...@@ -55,7 +58,11 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
55 messageId: MessageId.make(spec.messageId),58 messageId: MessageId.make(spec.messageId),
56 role: "user" as const,59 role: "user" as const,
57 text: spec.text,60 text: spec.text,
58 attachments: toUploadChatImageAttachments(spec.attachments),61 attachments:
62 spec.uploadedAttachments ??
63 toUploadChatImageAttachments(
64 spec.attachments.filter((attachment) => attachment.type === "image"),
65 ),
59 },66 },
60 modelSelection: spec.modelSelection,67 modelSelection: spec.modelSelection,
61 titleSeed: title,68 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) {...@@ -46,8 +46,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
46 ((message: string, error: unknown) => {46 ((message: string, error: unknown) => {
47 console.warn(message, error);47 console.warn(message, error);
48 });48 });
49 let loadPromise: Promise<void> | null = null;49 let loadPromise: Promise<boolean> | null = null;
50 let mutationQueue: Promise<void> = Promise.resolve();50 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
52 const serialize = <A>(mutation: () => Promise<A>): Promise<A> => {59 const serialize = <A>(mutation: () => Promise<A>): Promise<A> => {
53 const result = mutationQueue.then(mutation, mutation);60 const result = mutationQueue.then(mutation, mutation);
...@@ -65,13 +72,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -65,13 +72,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
65 options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages));72 options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages));
66 };73 };
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> => {
69 if (loadPromise !== null) {79 if (loadPromise !== null) {
70 return loadPromise;80 return loadPromise;
71 }81 }
72 loadPromise = serialize(async () => {82 loadPromise = serialize(async () => {
73 const persistedMessages = await options.storage.load();83 const persistedMessages = await options.storage.load();
74 setMessages([...persistedMessages, ...currentMessages()]);84 setMessages([...persistedMessages, ...currentMessages()]);
85 return true;
75 }).catch((cause) => {86 }).catch((cause) => {
76 loadPromise = null;87 loadPromise = null;
77 warn(88 warn(
...@@ -84,6 +95,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -84,6 +95,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
84 cause,95 cause,
85 }),96 }),
86 );97 );
98 return false;
87 });99 });
88 return loadPromise;100 return loadPromise;
89 };101 };
...@@ -93,6 +105,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -93,6 +105,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
93 // the message back out if it fails (durability only matters for crash105 // the message back out if it fails (durability only matters for crash
94 // recovery, not for the in-session queue).106 // recovery, not for the in-session queue).
95 const enqueue = (message: QueuedThreadMessage): Promise<void> => {107 const enqueue = (message: QueuedThreadMessage): Promise<void> => {
108 bumpRevision(message.messageId);
96 setMessages([109 setMessages([
97 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),110 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
98 message,111 message,
...@@ -105,6 +118,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -105,6 +118,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
105 // id may have optimistically replaced this attempt while the write was118 // id may have optimistically replaced this attempt while the write was
106 // in flight, and its entry must survive this attempt's failure.119 // in flight, and its entry must survive this attempt's failure.
107 setMessages(currentMessages().filter((candidate) => candidate !== message));120 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 }
108 throw new ThreadOutboxManagerError({132 throw new ThreadOutboxManagerError({
109 operation: "enqueue",133 operation: "enqueue",
110 environmentId: message.environmentId,134 environmentId: message.environmentId,
...@@ -126,12 +150,22 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -126,12 +150,22 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
126 // Rewrites an already-queued message. A no-op when the message has been150 // Rewrites an already-queued message. A no-op when the message has been
127 // removed in the meantime (e.g. deleted or delivered), so a trailing editor151 // removed in the meantime (e.g. deleted or delivered), so a trailing editor
128 // flush can never resurrect it. Returns whether the message was updated.152 // 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> =>
130 serialize(async () => {163 serialize(async () => {
131 const exists = currentMessages().some(164 const staleOrMissing = (): boolean =>
132 (candidate) => candidate.messageId === message.messageId,165 !currentMessages().some((candidate) => candidate.messageId === message.messageId) ||
133 );166 (expectedRevision !== undefined &&
134 if (!exists) {167 (revisions.get(message.messageId) ?? 0) !== expectedRevision);
168 if (staleOrMissing()) {
135 return false;169 return false;
136 }170 }
137 try {171 try {
...@@ -145,6 +179,21 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -145,6 +179,21 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
145 cause,179 cause,
146 });180 });
147 }181 }
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);
148 setMessages([197 setMessages([
149 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),198 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
150 message,199 message,
...@@ -152,8 +201,29 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -152,8 +201,29 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
152 return true;201 return true;
153 });202 });
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> =>
156 serialize(async () => {214 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;
157 try {227 try {
158 await options.storage.remove(message);228 await options.storage.remove(message);
159 } catch (cause) {229 } catch (cause) {
...@@ -165,13 +235,46 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -165,13 +235,46 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
165 cause,235 cause,
166 });236 });
167 }237 }
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 }
168 setMessages(260 setMessages(
169 currentMessages().filter((candidate) => candidate.messageId !== message.messageId),261 currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
170 );262 );
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;
171 });268 });
172269
173 const clearEnvironment = (environmentId: EnvironmentId): Promise<void> =>270 const clearEnvironment = (
174 serialize(async () => {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 () => {
175 const persisted = await options.storage.load().catch((cause) => {278 const persisted = await options.storage.load().catch((cause) => {
176 warn(279 warn(
177 "[thread-outbox] failed to load messages while clearing environment",280 "[thread-outbox] failed to load messages while clearing environment",
...@@ -188,32 +291,91 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -188,32 +291,91 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
188 const allMessages = flattenQueuedThreadMessages(291 const allMessages = flattenQueuedThreadMessages(
189 groupQueuedThreadMessages([...persisted, ...currentMessages()]),292 groupQueuedThreadMessages([...persisted, ...currentMessages()]),
190 );293 );
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
193 await Promise.all(307 await Promise.all(
194 allMessages308 candidates.map(async (message) => {
195 .filter((message) => message.environmentId === environmentId)309 try {
196 .map(async (message) => {310 await options.storage.remove(message);
197 try {311 removedFromStorage.add(message.messageId);
198 await options.storage.remove(message);312 } catch (cause) {
199 removedMessageIds.add(message.messageId);313 warn(
200 } catch (cause) {314 "[thread-outbox] failed to clear persisted message",
201 warn(315 new ThreadOutboxManagerError({
202 "[thread-outbox] failed to clear persisted message",316 operation: "clear-environment-remove",
203 new ThreadOutboxManagerError({317 environmentId: message.environmentId,
204 operation: "clear-environment-remove",318 threadId: message.threadId,
205 environmentId: message.environmentId,319 messageId: message.messageId,
206 threadId: message.threadId,320 cause,
207 messageId: message.messageId,321 }),
208 cause,322 );
209 }),323 }
210 );324 }),
211 }
212 }),
213 );325 );
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;
216 });377 });
378 };
217379
218 return {380 return {
219 queuedMessagesByThreadKeyAtom,381 queuedMessagesByThreadKeyAtom,
...@@ -221,6 +383,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -221,6 +383,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
221 load,383 load,
222 enqueue,384 enqueue,
223 confirmQueued,385 confirmQueued,
386 /** Current write revision for a queued message; input to update's CAS. */
387 revisionOf: (messageId: MessageId): number => revisions.get(messageId) ?? 0,
224 update,388 update,
225 remove,389 remove,
226 clearEnvironment,390 clearEnvironment,
apps/mobile/src/state/thread-outbox-model.ts+52-6
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";1import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
2import {
3 clampFileAttachmentUploadBytes,
4 fileAttachmentTooLargeMessage,
5} from "@t3tools/client-runtime/state/attachments";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";6import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
3import {7import {
4 CommandId,8 CommandId,
...@@ -17,8 +21,8 @@ import {...@@ -17,8 +21,8 @@ import {
17} from "@t3tools/contracts";21} from "@t3tools/contracts";
18import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
1923
20import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";24import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
21import type { DraftComposerImageAttachment } from "../lib/composerImages";25import type { DraftComposerAttachment } from "../lib/composerImages";
22import { scopedThreadKey } from "../lib/scopedEntities";26import { scopedThreadKey } from "../lib/scopedEntities";
2327
24const THREAD_OUTBOX_SCHEMA_VERSION = 3;28const THREAD_OUTBOX_SCHEMA_VERSION = 3;
...@@ -43,7 +47,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({...@@ -43,7 +47,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({
43 messageId: MessageId,47 messageId: MessageId,
44 commandId: CommandId,48 commandId: CommandId,
45 text: Schema.String,49 text: Schema.String,
46 attachments: Schema.Array(DraftComposerImageAttachmentSchema),50 attachments: Schema.Array(DraftComposerAttachmentSchema),
47 modelSelection: Schema.optional(ModelSelection),51 modelSelection: Schema.optional(ModelSelection),
48 runtimeMode: Schema.optional(RuntimeMode),52 runtimeMode: Schema.optional(RuntimeMode),
49 interactionMode: Schema.optional(ProviderInteractionMode),53 interactionMode: Schema.optional(ProviderInteractionMode),
...@@ -72,7 +76,7 @@ export interface QueuedThreadMessage {...@@ -72,7 +76,7 @@ export interface QueuedThreadMessage {
72 readonly messageId: MessageId;76 readonly messageId: MessageId;
73 readonly commandId: CommandId;77 readonly commandId: CommandId;
74 readonly text: string;78 readonly text: string;
75 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;79 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
76 readonly modelSelection?: ModelSelectionType;80 readonly modelSelection?: ModelSelectionType;
77 readonly runtimeMode?: RuntimeModeType;81 readonly runtimeMode?: RuntimeModeType;
78 readonly interactionMode?: ProviderInteractionModeType;82 readonly interactionMode?: ProviderInteractionModeType;
...@@ -172,6 +176,48 @@ export function resolveThreadOutboxDeliveryAction(input: {...@@ -172,6 +176,48 @@ export function resolveThreadOutboxDeliveryAction(input: {
172 return input.environmentConnected ? "send" : "wait";176 return input.environmentConnected ? "send" : "wait";
173}177}
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
175/**221/**
176 * A queued creation can only be dispatched once its payload would pass server222 * A queued creation can only be dispatched once its payload would pass server
177 * validation; incomplete payloads stay pending until the user edits them.223 * validation; incomplete payloads stay pending until the user edits them.
...@@ -209,7 +255,7 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {...@@ -209,7 +255,7 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
209}255}
210256
211export type ThreadOutboxCommandStage = "settings-sync" | "start-turn";257export type ThreadOutboxCommandStage = "settings-sync" | "start-turn";
212export type ThreadOutboxFailureAction = "retry" | "discard";258export type ThreadOutboxFailureAction = "retry" | "restore";
213259
214export function resolveThreadOutboxFailureAction(input: {260export function resolveThreadOutboxFailureAction(input: {
215 readonly stage: ThreadOutboxCommandStage;261 readonly stage: ThreadOutboxCommandStage;
...@@ -223,5 +269,5 @@ export function resolveThreadOutboxFailureAction(input: {...@@ -223,5 +269,5 @@ export function resolveThreadOutboxFailureAction(input: {
223 ) {269 ) {
224 return "retry";270 return "retry";
225 }271 }
226 return "discard";272 return "restore";
227}273}
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 {...@@ -16,6 +16,7 @@ import {
16 isQueuedThreadCreationSendable,16 isQueuedThreadCreationSendable,
17 modelSelectionsEqual,17 modelSelectionsEqual,
18 resolveThreadOutboxDeliveryAction,18 resolveThreadOutboxDeliveryAction,
19 resolveThreadOutboxDispatchStep,
19 resolveThreadOutboxFailureAction,20 resolveThreadOutboxFailureAction,
20 resolveQueuedThreadSettings,21 resolveQueuedThreadSettings,
21 shouldRetryThreadOutboxDelivery,22 shouldRetryThreadOutboxDelivery,
...@@ -78,6 +79,29 @@ describe("thread outbox", () => {...@@ -78,6 +79,29 @@ describe("thread outbox", () => {
78 ).toThrow();79 ).toThrow();
79 });80 });
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
81 it("persists the exact selector snapshot while remaining compatible with v1 messages", () => {105 it("persists the exact selector snapshot while remaining compatible with v1 messages", () => {
82 const legacyMessage = queuedMessage({106 const legacyMessage = queuedMessage({
83 messageId: "message-1",107 messageId: "message-1",
...@@ -357,6 +381,34 @@ describe("thread outbox", () => {...@@ -357,6 +381,34 @@ describe("thread outbox", () => {
357 registry.dispose();381 registry.dispose();
358 });382 });
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
360 it("keeps a same-id retry queued when the first attempt's write fails", async () => {412 it("keeps a same-id retry queued when the first attempt's write fails", async () => {
361 const registry = AtomRegistry.make();413 const registry = AtomRegistry.make();
362 let failNextWrite = true;414 let failNextWrite = true;
...@@ -457,6 +509,445 @@ describe("thread outbox", () => {...@@ -457,6 +509,445 @@ describe("thread outbox", () => {
457 registry.dispose();509 registry.dispose();
458 });510 });
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
460 it("only removes a missing-thread message after shell synchronization is live", () => {951 it("only removes a missing-thread message after shell synchronization is live", () => {
461 expect(952 expect(
462 resolveThreadOutboxDeliveryAction({953 resolveThreadOutboxDeliveryAction({
...@@ -618,6 +1109,6 @@ describe("thread outbox", () => {...@@ -618,6 +1109,6 @@ describe("thread outbox", () => {
618 error: deterministicFailure,1109 error: deterministicFailure,
619 interrupted: false,1110 interrupted: false,
620 }),1111 }),
621 ).toBe("discard");1112 ).toBe("restore");
622 });1113 });
623});1114});
apps/mobile/src/state/thread-outbox.ts+16-10
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3import { appAtomRegistry } from "./atom-registry";1import { appAtomRegistry } from "./atom-registry";
4import { createThreadOutboxManager } from "./thread-outbox-manager";2import { createThreadOutboxManager } from "./thread-outbox-manager";
5import type { QueuedThreadMessage } from "./thread-outbox-model";3import type { QueuedThreadMessage } from "./thread-outbox-model";
...@@ -36,15 +34,23 @@ export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage):...@@ -36,15 +34,23 @@ export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage):
36 return threadOutboxManager.confirmQueued(message);34 return threadOutboxManager.confirmQueued(message);
37}35}
3836
39/** Rewrite a queued message; no-op (false) if it was removed in the meantime. */37/**
40export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> {38 * Rewrite a queued message; no-op (false) if it was removed in the meantime,
41 return threadOutboxManager.update(message);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);
42}47}
4348
44export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {49/** Snapshot of a queued message's write revision, for update's CAS. */
45 return threadOutboxManager.remove(message);50export function threadOutboxRevision(messageId: QueuedThreadMessage["messageId"]): number {
51 return threadOutboxManager.revisionOf(messageId);
46}52}
4753
48export function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise<void> {54// Removal lives in `thread-outbox-removal.ts`: taking a message out of the
49 return threadOutboxManager.clearEnvironment(environmentId);55// outbox must also release its local attachment files, and that owner needs
50}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 @@...@@ -1,6 +1,12 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";2import {
3import { vi } from "vite-plus/test";3 CommandId,
4 EnvironmentId,
5 MessageId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
9import { onTestFinished, vi } from "vite-plus/test";
410
5const composerDraftFileMocks = vi.hoisted(() => {11const composerDraftFileMocks = vi.hoisted(() => {
6 let document = "";12 let document = "";
...@@ -79,14 +85,39 @@ const composerDraftFileMocks = vi.hoisted(() => {...@@ -79,14 +85,39 @@ const composerDraftFileMocks = vi.hoisted(() => {
79 };85 };
80});86});
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
82vi.mock("expo-file-system", () => ({99vi.mock("expo-file-system", () => ({
83 Directory: composerDraftFileMocks.Directory,100 Directory: composerDraftFileMocks.Directory,
84 File: composerDraftFileMocks.File,101 File: composerDraftFileMocks.File,
85 Paths: { document: "/documents" },102 Paths: { document: "/documents" },
86}));103}));
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
88import { appAtomRegistry } from "./atom-registry";117import { appAtomRegistry } from "./atom-registry";
118import { threadOutboxManager } from "./thread-outbox";
89import {119import {
120 appendComposerDraftAttachments,
90 clearComposerDraftContentState,121 clearComposerDraftContentState,
91 clearComposerDraftsEnvironment,122 clearComposerDraftsEnvironment,
92 ComposerDraftPersistenceError,123 ComposerDraftPersistenceError,
...@@ -100,12 +131,15 @@ import {...@@ -100,12 +131,15 @@ import {
100 flushComposerDrafts,131 flushComposerDrafts,
101 getComposerDraftSnapshot,132 getComposerDraftSnapshot,
102 mergeComposerDraftContentState,133 mergeComposerDraftContentState,
134 releaseUnusedComposerAttachmentFiles,
103 removeComposerDraftsForEnvironment,135 removeComposerDraftsForEnvironment,
104 resetComposerDraftsLoadState,136 resetComposerDraftsLoadState,
105 restoreComposerDraftSnapshotState,137 restoreComposerDraftSnapshotState,
106 setComposerDraftText,138 setComposerDraftText,
107 setStickyComposerModelSelection,139 setStickyComposerModelSelection,
108 stickyComposerModelSelectionAtom,140 stickyComposerModelSelectionAtom,
141 undoComposerDraftMerge,
142 undoComposerDraftMergeState,
109} from "./use-composer-drafts";143} from "./use-composer-drafts";
110144
111const DRAFT: ComposerDraft = {145const DRAFT: ComposerDraft = {
...@@ -123,9 +157,449 @@ afterEach(() => {...@@ -123,9 +157,449 @@ afterEach(() => {
123 composerDraftFileMocks.resetWrites();157 composerDraftFileMocks.resetWrites();
124 appAtomRegistry.set(composerDraftsAtom, {});158 appAtomRegistry.set(composerDraftsAtom, {});
125 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);159 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([]);
126});166});
127167
128describe("mobile composer drafts", () => {168describe("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
129 it("hydrates selector state even when the message content is empty", () => {603 it("hydrates selector state even when the message content is empty", () => {
130 expect(604 expect(
131 decodePersistedComposerDrafts({605 decodePersistedComposerDrafts({
...@@ -603,37 +1077,6 @@ describe("mobile composer drafts", () => {...@@ -603,37 +1077,6 @@ describe("mobile composer drafts", () => {
603 });1077 });
604 });1078 });
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
637 it("lands a still-debounced draft write when flushed", async () => {1080 it("lands a still-debounced draft write when flushed", async () => {
638 const draftKey = "environment-1:thread-1";1081 const draftKey = "environment-1:thread-1";
639 setComposerDraftText(draftKey, "typed right before the restart");1082 setComposerDraftText(draftKey, "typed right before the restart");
...@@ -656,4 +1099,219 @@ describe("mobile composer drafts", () => {...@@ -656,4 +1099,219 @@ describe("mobile composer drafts", () => {
656 composerDraftFileMocks.setWriteError(null);1099 composerDraftFileMocks.setWriteError(null);
657 }1100 }
658 });1101 });
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 });
659});1317});
apps/mobile/src/state/use-composer-drafts.ts+321-15
...@@ -14,10 +14,12 @@ import { useEffect } from "react";...@@ -14,10 +14,12 @@ import { useEffect } from "react";
14import { Atom } from "effect/unstable/reactivity";14import { Atom } from "effect/unstable/reactivity";
1515
16import { writeFileAtomically } from "../lib/atomic-file";16import { writeFileAtomically } from "../lib/atomic-file";
17import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";17import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
18import type { DraftComposerImageAttachment } from "../lib/composerImages";18import { composerAttachmentFileReferenceKey } from "../lib/composerAttachmentFiles";
19import type { DraftComposerAttachment } from "../lib/composerImages";
19import { SerializedAsyncQueue } from "../lib/serialized-async-queue";20import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
20import { appAtomRegistry } from "./atom-registry";21import { appAtomRegistry } from "./atom-registry";
22import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox";
2123
22const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;24const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;
23const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";25const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";
...@@ -40,7 +42,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo...@@ -40,7 +42,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo
4042
41export interface ComposerDraft {43export interface ComposerDraft {
42 readonly text: string;44 readonly text: string;
43 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;45 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
44 readonly importedShareIds?: ReadonlyArray<string>;46 readonly importedShareIds?: ReadonlyArray<string>;
45 readonly modelSelection?: ModelSelection;47 readonly modelSelection?: ModelSelection;
46 readonly runtimeMode?: RuntimeMode;48 readonly runtimeMode?: RuntimeMode;
...@@ -50,7 +52,7 @@ export interface ComposerDraft {...@@ -50,7 +52,7 @@ export interface ComposerDraft {
5052
51export interface ComposerDraftContent {53export interface ComposerDraftContent {
52 readonly text: string;54 readonly text: string;
53 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;55 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
54 readonly sourceShareId?: string;56 readonly sourceShareId?: string;
55}57}
5658
...@@ -75,7 +77,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({...@@ -75,7 +77,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
7577
76const ComposerDraftSchema = Schema.Struct({78const ComposerDraftSchema = Schema.Struct({
77 text: Schema.String,79 text: Schema.String,
78 attachments: Schema.Array(DraftComposerImageAttachmentSchema),80 attachments: Schema.Array(DraftComposerAttachmentSchema),
79 importedShareIds: Schema.optional(Schema.Array(Schema.String)),81 importedShareIds: Schema.optional(Schema.Array(Schema.String)),
80 modelSelection: Schema.optional(ModelSelectionSchema),82 modelSelection: Schema.optional(ModelSelectionSchema),
81 runtimeMode: Schema.optional(RuntimeModeSchema),83 runtimeMode: Schema.optional(RuntimeModeSchema),
...@@ -284,6 +286,156 @@ export async function flushComposerDrafts(): Promise<void> {...@@ -284,6 +286,156 @@ export async function flushComposerDrafts(): Promise<void> {
284 } while (persistTimer !== null);286 } while (persistTimer !== null);
285}287}
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
287function schedulePersistComposerState(): void {439function schedulePersistComposerState(): void {
288 if (persistTimer !== null) {440 if (persistTimer !== null) {
289 clearTimeout(persistTimer);441 clearTimeout(persistTimer);
...@@ -344,6 +496,14 @@ export function ensureComposerDraftsLoaded(): void {...@@ -344,6 +496,14 @@ export function ensureComposerDraftsLoaded(): void {
344 });496 });
345}497}
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
347function updateComposerDrafts(507function updateComposerDrafts(
348 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,508 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,
349): void {509): void {
...@@ -392,29 +552,49 @@ export function appendComposerDraftText(draftKey: string, value: string): void {...@@ -392,29 +552,49 @@ export function appendComposerDraftText(draftKey: string, value: string): void {
392 });552 });
393}553}
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 */
395export function appendComposerDraftAttachments(562export function appendComposerDraftAttachments(
396 draftKey: string,563 draftKey: string,
397 attachments: ReadonlyArray<DraftComposerImageAttachment>,564 attachments: ReadonlyArray<DraftComposerAttachment>,
398): void {565 options?: { readonly allowOverflow?: boolean },
566): number {
399 if (attachments.length === 0) {567 if (attachments.length === 0) {
400 return;568 return 0;
401 }569 }
570 let rejected: ReadonlyArray<DraftComposerAttachment> = [];
402 updateComposerDrafts((current) => {571 updateComposerDrafts((current) => {
403 const existing = normalizeDraft(current[draftKey]);572 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 }
404 return {581 return {
405 ...current,582 ...current,
406 [draftKey]: {583 [draftKey]: {
407 ...existing,584 ...existing,
408 attachments: [...existing.attachments, ...attachments],585 attachments: [...existing.attachments, ...accepted],
409 },586 },
410 };587 };
411 });588 });
589 scheduleUnusedComposerAttachmentCleanup(rejected);
590 return rejected.length;
412}591}
413592
414export function replaceComposerDraftAttachments(593export function replaceComposerDraftAttachments(
415 draftKey: string,594 draftKey: string,
416 attachments: ReadonlyArray<DraftComposerImageAttachment>,595 attachments: ReadonlyArray<DraftComposerAttachment>,
417): void {596): void {
597 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
418 updateComposerDrafts((current) => {598 updateComposerDrafts((current) => {
419 const draft = {599 const draft = {
420 ...normalizeDraft(current[draftKey]),600 ...normalizeDraft(current[draftKey]),
...@@ -430,9 +610,14 @@ export function replaceComposerDraftAttachments(...@@ -430,9 +610,14 @@ export function replaceComposerDraftAttachments(
430 [draftKey]: draft,610 [draftKey]: draft,
431 };611 };
432 });612 });
613 const retainedIds = new Set(attachments.map((attachment) => attachment.id));
614 scheduleUnusedComposerAttachmentCleanup(
615 previousAttachments.filter((attachment) => !retainedIds.has(attachment.id)),
616 );
433}617}
434618
435export function removeComposerDraftAttachment(draftKey: string, imageId: string): void {619export function removeComposerDraftAttachment(draftKey: string, imageId: string): void {
620 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
436 updateComposerDrafts((current) => {621 updateComposerDrafts((current) => {
437 const existing = normalizeDraft(current[draftKey]);622 const existing = normalizeDraft(current[draftKey]);
438 const draft = {623 const draft = {
...@@ -449,6 +634,9 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)...@@ -449,6 +634,9 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
449 [draftKey]: draft,634 [draftKey]: draft,
450 };635 };
451 });636 });
637 scheduleUnusedComposerAttachmentCleanup(
638 previousAttachments.filter((attachment) => attachment.id === imageId),
639 );
452}640}
453641
454export function updateComposerDraftSettings(642export function updateComposerDraftSettings(
...@@ -691,17 +879,130 @@ export async function restoreComposerDraftSnapshot(...@@ -691,17 +879,130 @@ export async function restoreComposerDraftSnapshot(
691 );879 );
692}880}
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
694export function clearComposerDraftContent(981export function clearComposerDraftContent(
695 draftKey: string,982 draftKey: string,
696 options?: {983 options?: {
697 readonly clearModelSelection?: boolean;984 readonly clearModelSelection?: boolean;
698 readonly clearWorkspaceSelection?: boolean;985 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;
699 },992 },
700): void {993): void {
994 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
701 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));995 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
996 if (!options?.deferAttachmentCleanup) {
997 scheduleUnusedComposerAttachmentCleanup(previousAttachments);
998 }
702}999}
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;
705 updateComposerDrafts((current) => {1006 updateComposerDrafts((current) => {
706 if (!current[draftKey]) {1007 if (!current[draftKey]) {
707 return current;1008 return current;
...@@ -710,6 +1011,9 @@ export function clearComposerDraft(draftKey: string): void {...@@ -710,6 +1011,9 @@ export function clearComposerDraft(draftKey: string): void {
710 delete next[draftKey];1011 delete next[draftKey];
711 return next;1012 return next;
712 });1013 });
1014 if (!options?.deferAttachmentCleanup) {
1015 scheduleUnusedComposerAttachmentCleanup(previousAttachments);
1016 }
713}1017}
7141018
715export function removeComposerDraftsForEnvironment(1019export function removeComposerDraftsForEnvironment(
...@@ -732,10 +1036,11 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI...@@ -732,10 +1036,11 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
732 await loadPromise;1036 await loadPromise;
733 }1037 }
7341038
735 const next = removeComposerDraftsForEnvironment(1039 const current = appAtomRegistry.get(composerDraftsAtom);
736 appAtomRegistry.get(composerDraftsAtom),1040 const next = removeComposerDraftsForEnvironment(current, environmentId);
737 environmentId,1041 const removedAttachments = Object.entries(current)
738 );1042 .filter(([draftKey]) => next[draftKey] === undefined)
1043 .flatMap(([, draft]) => draft.attachments);
7391044
740 if (persistTimer !== null) {1045 if (persistTimer !== null) {
741 clearTimeout(persistTimer);1046 clearTimeout(persistTimer);
...@@ -745,6 +1050,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI...@@ -745,6 +1050,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
745 await persistenceQueue.run(() =>1050 await persistenceQueue.run(() =>
746 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),1051 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
747 );1052 );
1053 await releaseUnusedComposerAttachmentFiles(removedAttachments);
748}1054}
7491055
750export function useComposerDraft(draftKey: string | null): ComposerDraft {1056export 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";...@@ -6,6 +6,7 @@ import * as Cause from "effect/Cause";
6import {6import {
7 CommandId,7 CommandId,
8 MessageId,8 MessageId,
9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
9 type EnvironmentId,10 type EnvironmentId,
10 type ModelSelection,11 type ModelSelection,
11 type ProviderInteractionMode,12 type ProviderInteractionMode,
...@@ -26,6 +27,7 @@ import { makeQueuedMessageMetadata } from "../lib/commandMetadata";...@@ -26,6 +27,7 @@ import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
26import {27import {
27 convertPastedImagesToAttachments,28 convertPastedImagesToAttachments,
28 pasteComposerClipboard,29 pasteComposerClipboard,
30 pickComposerFiles,
29 pickComposerImages,31 pickComposerImages,
30} from "../lib/composerImages";32} from "../lib/composerImages";
31import type { DraftComposerImageAttachment } from "../lib/composerImages";33import type { DraftComposerImageAttachment } from "../lib/composerImages";
...@@ -42,6 +44,7 @@ import {...@@ -42,6 +44,7 @@ import {
42 getComposerDraftSnapshot,44 getComposerDraftSnapshot,
43 mergeComposerDraftContent,45 mergeComposerDraftContent,
44 removeComposerDraftAttachment,46 removeComposerDraftAttachment,
47 scheduleUnusedComposerAttachmentCleanup,
45 setComposerDraftText,48 setComposerDraftText,
46 updateComposerDraftSettings,49 updateComposerDraftSettings,
47 useComposerDraft,50 useComposerDraft,
...@@ -65,7 +68,14 @@ export function appendReviewCommentToDraft(input: {...@@ -65,7 +68,14 @@ export function appendReviewCommentToDraft(input: {
65 const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : "";68 const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : "";
66 setComposerDraftText(threadKey, `${existing}${separator}${input.text}`);69 setComposerDraftText(threadKey, `${existing}${separator}${input.text}`);
67 if (input.attachments && input.attachments.length > 0) {70 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 }
69 }79 }
70}80}
7181
...@@ -171,6 +181,17 @@ export function useThreadComposerState() {...@@ -171,6 +181,17 @@ export function useThreadComposerState() {
171 if (text.length === 0 && attachments.length === 0) {181 if (text.length === 0 && attachments.length === 0) {
172 return null;182 return null;
173 }183 }
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
175 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(196 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
176 (entry) => entry.instanceId === thread.modelSelection.instanceId,197 (entry) => entry.instanceId === thread.modelSelection.instanceId,
...@@ -255,18 +276,26 @@ export function useThreadComposerState() {...@@ -255,18 +276,26 @@ export function useThreadComposerState() {
255 interactionMode: draft.interactionMode ?? thread.interactionMode,276 interactionMode: draft.interactionMode ?? thread.interactionMode,
256 createdAt: metadata.createdAt,277 createdAt: metadata.createdAt,
257 });278 });
258 clearComposerDraftContent(threadKey);279 clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });
259 enqueuePromise.catch((error: unknown) => {280 enqueuePromise.then(
260 // Restore text via merge (idempotent) but attachments via the uncapped281 () => {
261 // append: the merge path slots existing attachments first and truncates282 // The queued message owns the files now; the sweep sees that and
262 // at the send limit, which would silently drop this message's images if283 // spares them. Deferred to here so a failed write cannot roll the
263 // the user attached new ones while the write was in flight.284 // message out of the queue mid-sweep and lose the bytes.
264 void mergeComposerDraftContent(threadKey, { text, attachments: [] });285 scheduleUnusedComposerAttachmentCleanup(attachments);
265 appendComposerDraftAttachments(threadKey, attachments);286 },
266 setPendingConnectionError(287 (error: unknown) => {
267 error instanceof Error ? error.message : "Failed to save the queued message.",288 // Restore text via merge (idempotent) but attachments via the uncapped
268 );289 // append: the merge path slots existing attachments first and truncates
269 });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 );
270 return messageId;299 return messageId;
271 }, [300 }, [
272 selectedEnvironmentRuntime?.serverConfig?.providers,301 selectedEnvironmentRuntime?.serverConfig?.providers,
...@@ -296,14 +325,50 @@ export function useThreadComposerState() {...@@ -296,14 +325,50 @@ export function useThreadComposerState() {
296 const result = await pickComposerImages({325 const result = await pickComposerImages({
297 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,326 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
298 });327 });
299 if (result.images.length > 0) {328 const rejectedImageCount = appendComposerDraftAttachments(threadKey, result.images);
300 appendComposerDraftAttachments(threadKey, result.images);329 const problems = [
301 }330 ...(result.error ? [result.error] : []),
302 if (result.error) {331 ...(rejectedImageCount > 0
303 setPendingConnectionError(result.error);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"));
304 }337 }
305 }, [composerDrafts, selectedThreadShell]);338 }, [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
307 const onPasteIntoDraft = useCallback(async () => {372 const onPasteIntoDraft = useCallback(async () => {
308 if (!selectedThreadShell) {373 if (!selectedThreadShell) {
309 return;374 return;
...@@ -313,14 +378,16 @@ export function useThreadComposerState() {...@@ -313,14 +378,16 @@ export function useThreadComposerState() {
313 const result = await pasteComposerClipboard({378 const result = await pasteComposerClipboard({
314 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,379 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
315 });380 });
316 if (result.images.length > 0) {381 const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images);
317 appendComposerDraftAttachments(threadKey, result.images);
318 }
319 if (result.text) {382 if (result.text) {
320 appendComposerDraftText(threadKey, result.text);383 appendComposerDraftText(threadKey, result.text);
321 }384 }
322 if (result.error) {385 if (result.error) {
323 setPendingConnectionError(result.error);386 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 );
324 }391 }
325 }, [composerDrafts, selectedThreadShell]);392 }, [composerDrafts, selectedThreadShell]);
326393
...@@ -404,6 +471,7 @@ export function useThreadComposerState() {...@@ -404,6 +471,7 @@ export function useThreadComposerState() {
404 interactionMode,471 interactionMode,
405 onChangeDraftMessage,472 onChangeDraftMessage,
406 onPickDraftImages,473 onPickDraftImages,
474 onPickDraftFiles,
407 onPasteIntoDraft,475 onPasteIntoDraft,
408 onNativePasteImages,476 onNativePasteImages,
409 onRemoveDraftImage,477 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 {...@@ -8,6 +8,7 @@ import {
8 CommandId,8 CommandId,
9 DEFAULT_PROVIDER_INTERACTION_MODE,9 DEFAULT_PROVIDER_INTERACTION_MODE,
10 DEFAULT_RUNTIME_MODE,10 DEFAULT_RUNTIME_MODE,
11 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
11 type MessageId,12 type MessageId,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";14import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
...@@ -15,36 +16,56 @@ import * as Cause from "effect/Cause";...@@ -15,36 +16,56 @@ import * as Cause from "effect/Cause";
15import { AsyncResult, Atom } from "effect/unstable/reactivity";16import { AsyncResult, Atom } from "effect/unstable/reactivity";
16import { useCallback, useEffect, useRef, useState } from "react";17import { useCallback, useEffect, useRef, useState } from "react";
1718
18import { scopedThreadKey } from "../lib/scopedEntities";19import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
19import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";20import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
20import { toUploadChatImageAttachments } from "../lib/composerImages";21import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
21import { randomHex } from "../lib/uuid";22import { randomHex } from "../lib/uuid";
22import { appAtomRegistry } from "./atom-registry";23import { appAtomRegistry } from "./atom-registry";
23import { useProjects, useThreadShells } from "./entities";24import { useProjects, useServerConfigs, useThreadShells } from "./entities";
24import {25import {
25 confirmThreadOutboxMessageQueued,26 confirmThreadOutboxMessageQueued,
26 ensureThreadOutboxLoaded,27 ensureThreadOutboxLoaded,
27 removeThreadOutboxMessage,28 threadOutboxManager,
29 threadOutboxRevision,
30 updateThreadOutboxMessage,
28} from "./thread-outbox";31} from "./thread-outbox";
32import { removeThreadOutboxMessage } from "./thread-outbox-removal";
29import {33import {
30 isQueuedThreadCreationSendable,34 isQueuedThreadCreationSendable,
31 modelSelectionsEqual,35 modelSelectionsEqual,
32 resolveThreadOutboxDeliveryAction,36 resolveThreadOutboxDeliveryAction,
37 resolveThreadOutboxDispatchStep,
33 resolveThreadOutboxFailureAction,38 resolveThreadOutboxFailureAction,
34 resolveQueuedThreadSettings,39 resolveQueuedThreadSettings,
40 shouldRetryThreadOutboxDelivery,
35 threadOutboxRetryDelayMs,41 threadOutboxRetryDelayMs,
36 type QueuedThreadCreation,42 type QueuedThreadCreation,
37 type QueuedThreadMessage,43 type QueuedThreadMessage,
38 type ThreadOutboxCommandStage,44 type ThreadOutboxCommandStage,
39} from "./thread-outbox-model";45} 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";
41import { useAtomCommand } from "./use-atom-command";59import { useAtomCommand } from "./use-atom-command";
42import {60import {
43 editingQueuedMessageIdsAtom,61 editingQueuedMessageIdsAtom,
44 useThreadOutboxMessages,62 useThreadOutboxMessages,
45 useThreadOutboxShellStatuses,63 useThreadOutboxShellStatuses,
46} from "./use-thread-outbox";64} from "./use-thread-outbox";
47import { useRemoteConnectionStatus } from "./use-remote-environment-registry";65import {
66 setPendingConnectionError,
67 useRemoteConnectionStatus,
68} from "./use-remote-environment-registry";
4869
49export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(70export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
50 Atom.keepAlive,71 Atom.keepAlive,
...@@ -85,6 +106,384 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma...@@ -85,6 +106,384 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma
85 return CommandId.make(`${message.commandId}:${setting}`);106 return CommandId.make(`${message.commandId}:${setting}`);
86}107}
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
88export function useThreadOutboxDrain(): void {487export function useThreadOutboxDrain(): void {
89 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });488 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
90 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {489 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
...@@ -102,11 +501,76 @@ export function useThreadOutboxDrain(): void {...@@ -102,11 +501,76 @@ export function useThreadOutboxDrain(): void {
102 const shellStatuses = useThreadOutboxShellStatuses();501 const shellStatuses = useThreadOutboxShellStatuses();
103 const threads = useThreadShells();502 const threads = useThreadShells();
104 const projects = useProjects();503 const projects = useProjects();
504 const serverConfigs = useServerConfigs();
105 const { connectedEnvironments } = useRemoteConnectionStatus();505 const { connectedEnvironments } = useRemoteConnectionStatus();
106 const [retryTick, setRetryTick] = useState(0);506 const [retryTick, setRetryTick] = useState(0);
107 const retryAttemptRef = useRef(new Map<MessageId, number>());507 const retryAttemptRef = useRef(new Map<MessageId, number>());
108 const retryNotBeforeRef = useRef(new Map<MessageId, number>());508 const retryNotBeforeRef = useRef(new Map<MessageId, number>());
109 const retryTimersRef = useRef(new Map<MessageId, ReturnType<typeof setTimeout>>());509 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
111 useEffect(() => {575 useEffect(() => {
112 ensureThreadOutboxLoaded();576 ensureThreadOutboxLoaded();
...@@ -115,6 +579,10 @@ export function useThreadOutboxDrain(): void {...@@ -115,6 +579,10 @@ export function useThreadOutboxDrain(): void {
115 clearTimeout(timer);579 clearTimeout(timer);
116 }580 }
117 retryTimersRef.current.clear();581 retryTimersRef.current.clear();
582 for (const blocked of blockedRecoverySubscriptionsRef.current.values()) {
583 blocked.unsubscribe();
584 }
585 blockedRecoverySubscriptionsRef.current.clear();
118 };586 };
119 }, []);587 }, []);
120588
...@@ -122,53 +590,36 @@ export function useThreadOutboxDrain(): void {...@@ -122,53 +590,36 @@ export function useThreadOutboxDrain(): void {
122 const reportFailure = (590 const reportFailure = (
123 commandResult: AtomCommandResult<unknown, unknown>,591 commandResult: AtomCommandResult<unknown, unknown>,
124 stage: ThreadOutboxCommandStage,592 stage: ThreadOutboxCommandStage,
125 ): boolean => {593 ): { readonly action: "retry" | "restore"; readonly message: string } | null => {
126 if (!AsyncResult.isFailure(commandResult)) {594 if (!AsyncResult.isFailure(commandResult)) {
127 return false;595 return null;
128 }596 }
597 const error = Cause.squash(commandResult.cause);
129 const action = resolveThreadOutboxFailureAction({598 const action = resolveThreadOutboxFailureAction({
130 stage,599 stage,
131 error: Cause.squash(commandResult.cause),600 error,
132 interrupted: Cause.hasInterruptsOnly(commandResult.cause),601 interrupted: Cause.hasInterruptsOnly(commandResult.cause),
133 });602 });
134 const retry = action === "retry";
135 console.warn("[thread-outbox] queued message delivery failed", {603 console.warn("[thread-outbox] queued message delivery failed", {
136 environmentId: queuedMessage.environmentId,604 environmentId: queuedMessage.environmentId,
137 threadId: queuedMessage.threadId,605 threadId: queuedMessage.threadId,
138 messageId: queuedMessage.messageId,606 messageId: queuedMessage.messageId,
139 stage,607 stage,
140 cause: commandResult.cause,608 cause: commandResult.cause,
141 retry,609 action,
142 });610 });
143 return retry;611 return {
612 action,
613 message: error instanceof Error ? error.message : "The message could not be sent.",
614 };
144 };615 };
145 const completeDelivery = async (616 return { reportFailure };
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 };
166 }, []);617 }, []);
167618
168 const sendQueuedMessage = useCallback(619 const sendQueuedMessage = useCallback(
169 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {620 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
170 const settings = resolveQueuedThreadSettings(queuedMessage, thread);621 const settings = resolveQueuedThreadSettings(queuedMessage, thread);
171 const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage);622 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
172623
173 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {624 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
174 const updateResult = await updateThreadMetadata({625 const updateResult = await updateThreadMetadata({
...@@ -217,6 +668,37 @@ export function useThreadOutboxDrain(): void {...@@ -217,6 +668,37 @@ export function useThreadOutboxDrain(): void {
217 }668 }
218 }669 }
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 }
220 const deliveryResult = await startTurn({702 const deliveryResult = await startTurn({
221 environmentId: queuedMessage.environmentId,703 environmentId: queuedMessage.environmentId,
222 input: {704 input: {
...@@ -226,7 +708,7 @@ export function useThreadOutboxDrain(): void {...@@ -226,7 +708,7 @@ export function useThreadOutboxDrain(): void {
226 messageId: queuedMessage.messageId,708 messageId: queuedMessage.messageId,
227 role: "user",709 role: "user",
228 text: queuedMessage.text,710 text: queuedMessage.text,
229 attachments: toUploadChatImageAttachments(queuedMessage.attachments),711 attachments: prepared.attachments,
230 },712 },
231 modelSelection: settings.modelSelection,713 modelSelection: settings.modelSelection,
232 runtimeMode: settings.runtimeMode,714 runtimeMode: settings.runtimeMode,
...@@ -234,7 +716,26 @@ export function useThreadOutboxDrain(): void {...@@ -234,7 +716,26 @@ export function useThreadOutboxDrain(): void {
234 createdAt: queuedMessage.createdAt,716 createdAt: queuedMessage.createdAt,
235 },717 },
236 });718 });
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;
238 },739 },
239 [740 [
240 makeDeliveryHelpers,741 makeDeliveryHelpers,
...@@ -242,6 +743,7 @@ export function useThreadOutboxDrain(): void {...@@ -242,6 +743,7 @@ export function useThreadOutboxDrain(): void {
242 setThreadRuntimeMode,743 setThreadRuntimeMode,
243 startTurn,744 startTurn,
244 updateThreadMetadata,745 updateThreadMetadata,
746 restoreQueuedMessage,
245 ],747 ],
246 );748 );
247749
...@@ -255,7 +757,37 @@ export function useThreadOutboxDrain(): void {...@@ -255,7 +757,37 @@ export function useThreadOutboxDrain(): void {
255 if (modelSelection === undefined) {757 if (modelSelection === undefined) {
256 return false;758 return false;
257 }759 }
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 }
259 const deliveryResult = await startTurn({791 const deliveryResult = await startTurn({
260 environmentId: queuedMessage.environmentId,792 environmentId: queuedMessage.environmentId,
261 input: buildProjectThreadStartTurnInput({793 input: buildProjectThreadStartTurnInput({
...@@ -267,6 +799,7 @@ export function useThreadOutboxDrain(): void {...@@ -267,6 +799,7 @@ export function useThreadOutboxDrain(): void {
267 createdAt: queuedMessage.createdAt,799 createdAt: queuedMessage.createdAt,
268 text: queuedMessage.text.trim(),800 text: queuedMessage.text.trim(),
269 attachments: queuedMessage.attachments,801 attachments: queuedMessage.attachments,
802 uploadedAttachments: prepared.attachments,
270 modelSelection,803 modelSelection,
271 runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE,804 runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE,
272 interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,805 interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
...@@ -277,9 +810,35 @@ export function useThreadOutboxDrain(): void {...@@ -277,9 +810,35 @@ export function useThreadOutboxDrain(): void {
277 worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),810 worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
278 }),811 }),
279 });812 });
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;
281 },840 },
282 [makeDeliveryHelpers, startTurn],841 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],
283 );842 );
284843
285 useEffect(() => {844 useEffect(() => {
...@@ -287,14 +846,63 @@ export function useThreadOutboxDrain(): void {...@@ -287,14 +846,63 @@ export function useThreadOutboxDrain(): void {
287 return;846 return;
288 }847 }
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
290 for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) {860 for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) {
291 const nextQueuedMessage = queuedMessages[0];861 const nextQueuedMessage = queuedMessages[0];
292 if (!nextQueuedMessage) {862 if (!nextQueuedMessage) {
293 continue;863 continue;
294 }864 }
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 }
295 if (editingQueuedMessageIds[nextQueuedMessage.messageId]) {893 if (editingQueuedMessageIds[nextQueuedMessage.messageId]) {
296 continue;894 continue;
297 }895 }
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 }
298 if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {906 if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
299 continue;907 continue;
300 }908 }
...@@ -316,9 +924,53 @@ export function useThreadOutboxDrain(): void {...@@ -316,9 +924,53 @@ export function useThreadOutboxDrain(): void {
316 environmentConnected: environment?.connectionState === "connected",924 environmentConnected: environment?.connectionState === "connected",
317 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",925 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
318 });926 });
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);
320 continue;951 continue;
321 }952 }
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 }
322 // The live project shell is preferred for the workspace path, with the974 // The live project shell is preferred for the workspace path, with the
323 // snapshot taken at enqueue time as the fallback so a task never dies975 // snapshot taken at enqueue time as the fallback so a task never dies
324 // just because its project shell is not loaded.976 // just because its project shell is not loaded.
...@@ -368,8 +1020,36 @@ export function useThreadOutboxDrain(): void {...@@ -368,8 +1020,36 @@ export function useThreadOutboxDrain(): void {
368 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) {1020 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) {
369 return true;1021 return true;
370 }1022 }
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 }
371 return deliveryAction === "remove"1044 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")
373 : creation !== undefined1053 : creation !== undefined
374 ? creationProjectCwd !== null1054 ? creationProjectCwd !== null
375 ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd)1055 ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd)
...@@ -391,19 +1071,7 @@ export function useThreadOutboxDrain(): void {...@@ -391,19 +1071,7 @@ export function useThreadOutboxDrain(): void {
391 return;1071 return;
392 }1072 }
3931073
394 const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1;1074 scheduleQueuedMessageRetry(nextQueuedMessage.messageId);
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);
407 })1075 })
408 .finally(() => {1076 .finally(() => {
409 finishDispatchingQueuedMessage(nextQueuedMessage.messageId);1077 finishDispatchingQueuedMessage(nextQueuedMessage.messageId);
...@@ -417,8 +1085,11 @@ export function useThreadOutboxDrain(): void {...@@ -417,8 +1085,11 @@ export function useThreadOutboxDrain(): void {
417 projects,1085 projects,
418 queuedMessagesByThreadKey,1086 queuedMessagesByThreadKey,
419 retryTick,1087 retryTick,
1088 restoreQueuedMessage,
1089 scheduleQueuedMessageRetry,
420 sendQueuedCreation,1090 sendQueuedCreation,
421 sendQueuedMessage,1091 sendQueuedMessage,
1092 serverConfigs,
422 shellStatuses,1093 shellStatuses,
423 threads,1094 threads,
424 ]);1095 ]);
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...@@ -4,20 +4,23 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee
4composer and shows how many characters need to be removed. Shorten the draft or split it into4composer and shows how many characters need to be removed. Shorten the draft or split it into
5multiple messages, then send again in the same thread.5multiple 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 also7You can attach images up to 10 MB. On servers that support file uploads, you can also
8attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised8attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised
9by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files9by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files
10upload directly to the environment, where your agent can read, copy, or edit them by their file path.10upload directly to the environment, where your agent can read, copy, or edit them by their file path.
1111
12On web and desktop, attachments upload as soon as you add them. The send button becomes available12On 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 are13after every upload finishes. Failed uploads can be retried or removed. On mobile, the **+** control
14currently limited to images.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.19On web and desktop, select a video attachment before or after sending to play it with the browser's
17Playback depends on the video formats and codecs that the browser supports.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 **Attach22On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name
20again** next to it. Attach the file again or remove it, then send.23and shows **Attach again** next to it. Attach the file again or remove it, then send.
2124
22On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into25On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
23the composer or paste them into a message.26the 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:...@@ -91,6 +91,7 @@ patchedDependencies:
91 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd91 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
92 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e229555292 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
93 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb693 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
94 expo-sharing@57.0.16: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45
94 react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d395 react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
95 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e78796 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787
96 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b608842267597 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
...@@ -363,7 +364,7 @@ importers:...@@ -363,7 +364,7 @@ importers:
363 version: 57.0.2(expo@57.0.18)364 version: 57.0.2(expo@57.0.18)
364 expo-sharing:365 expo-sharing:
365 specifier: ~57.0.16366 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)
367 expo-splash-screen:368 expo-splash-screen:
368 specifier: ~57.0.8369 specifier: ~57.0.8
369 version: 57.0.8(expo@57.0.18)(typescript@6.0.3)370 version: 57.0.8(expo@57.0.18)(typescript@6.0.3)
...@@ -16542,7 +16543,7 @@ snapshots:...@@ -16542,7 +16543,7 @@ snapshots:
1654216543
16543 expo-server@57.0.3: {}16544 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):
16546 dependencies:16547 dependencies:
16547 '@expo/config-plugins': 57.0.9(typescript@6.0.3)16548 '@expo/config-plugins': 57.0.9(typescript@6.0.3)
16548 '@expo/config-types': 57.0.216549 '@expo/config-types': 57.0.2
pnpm-workspace.yaml+1
...@@ -153,6 +153,7 @@ patchedDependencies:...@@ -153,6 +153,7 @@ patchedDependencies:
153 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch153 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
154 "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch154 "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch
155 effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch155 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
156 react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch157 react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch
157 react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch158 react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch
158 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch159 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch