authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 10:20:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 10:20:53-07:00
logc4353bc6b972fa527579b530f01cc8744b2407d2
tree23b3e95bb6a146489ca46d76790dbd26d68306b6
parent5eab021a5185e492b6b021f83564143d5263e8a6
signature Signed by PGP key B5690EEEBB952194

fix(mobile): read file-backed image drafts before enabling them (#9713)

Accept file-backed image drafts and v4 outbox records while keeping current inline image creation and v3 outbox writes. Retain image files during previews, uploads, and legacy inline reads. Preserve image MIME types and stop canceled sends after asynchronous reads. The later file-backed writers remain held until new native runtime fingerprints contain these readers and the storage guards. Continue Wout Stiens' mobile draft work with separate reader-only corrections. The original contribution remains unchanged. Created with GPT-6 Astra (preview) in Codex. Co-Authored-By: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

18 files changed, 615 insertions(+), 218 deletions(-)

apps/mobile/src/components/ComposerAttachmentStrip.tsx+73-27
...@@ -1,10 +1,16 @@...@@ -1,10 +1,16 @@
1import { SymbolView } from "../components/AppSymbol";1import { SymbolView } from "../components/AppSymbol";
2import { videoMimeType } from "@t3tools/shared/video";2import { videoMimeType } from "@t3tools/shared/video";
3import { useMemo } from "react";3import { useEffect, useMemo, useState } from "react";
4import { Image, Pressable, ScrollView, View } from "react-native";4import { Image, Pressable, ScrollView, View } from "react-native";
55
6import { AppText as Text } from "./AppText";6import { AppText as Text } from "./AppText";
7import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";7import {
8 isFileBackedComposerAttachment,
9 type DraftComposerAttachment,
10 type DraftComposerFileAttachment,
11 type DraftComposerImageAttachment,
12} from "../lib/composerImages";
13import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";
8import { VideoAttachmentTile } from "./VideoAttachmentTile";14import { VideoAttachmentTile } from "./VideoAttachmentTile";
9import type { MediaActionsSource } from "../lib/mediaActions";15import type { MediaActionsSource } from "../lib/mediaActions";
10import { PresentationSource } from "./NativePresentation";16import { PresentationSource } from "./NativePresentation";
...@@ -87,35 +93,75 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr...@@ -87,35 +93,75 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr
87 );93 );
88}94}
8995
96/**
97 * Thumbnail URI for a draft image. File-backed previews rebase into the
98 * current iOS data container (its UUID changes across installs); the raw
99 * persisted URI renders meanwhile, which is correct everywhere but after a
100 * container move.
101 */
102function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string {
103 const { fileUri, previewUri } = attachment;
104 const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null);
105 useEffect(() => {
106 if (fileUri === undefined) return;
107 let cancelled = false;
108 void (async () => {
109 const { Paths } = await import("expo-file-system");
110 const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);
111 // Re-render only when the container actually moved.
112 if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned });
113 })();
114 return () => {
115 cancelled = true;
116 };
117 }, [fileUri, previewUri]);
118 return fileUri !== undefined && rebased?.fileUri === fileUri ? rebased.uri : previewUri;
119}
120
121function ComposerImageAttachment(
122 props: ComposerAttachmentThumbnailProps & { readonly attachment: DraftComposerImageAttachment },
123) {
124 const { attachment } = props;
125 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
126 const previewUri = useComposerImagePreviewUri(attachment);
127 const sourceIdentifier = `draft-image:${attachment.id}`;
128 return (
129 <PresentationSource identifier={sourceIdentifier}>
130 <Pressable
131 accessibilityRole="imagebutton"
132 accessibilityLabel={`Open ${attachment.name}`}
133 disabled={!props.onPressPreview}
134 onPress={() =>
135 props.onPressPreview?.(
136 // File-backed images open through the retain-lease + container
137 // rebase path; legacy drafts still carry their inline bytes.
138 isFileBackedComposerAttachment(attachment)
139 ? { kind: "image", attachment, name: attachment.name, sourceIdentifier }
140 : {
141 kind: "image",
142 uri: attachment.dataUrl ?? attachment.previewUri,
143 name: attachment.name,
144 sourceIdentifier,
145 },
146 )
147 }
148 >
149 <Image
150 source={{ uri: previewUri }}
151 style={style}
152 className="bg-subtle"
153 resizeMode="cover"
154 />
155 </Pressable>
156 </PresentationSource>
157 );
158}
159
90function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {160function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {
91 const { attachment } = props;161 const { attachment } = props;
92 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };162 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
93 if (attachment.type === "image") {163 if (attachment.type === "image") {
94 const sourceIdentifier = `draft-image:${attachment.id}`;164 return <ComposerImageAttachment {...props} attachment={attachment} />;
95 return (
96 <PresentationSource identifier={sourceIdentifier}>
97 <Pressable
98 accessibilityRole="imagebutton"
99 accessibilityLabel={`Open ${attachment.name}`}
100 disabled={!props.onPressPreview}
101 onPress={() =>
102 props.onPressPreview?.({
103 kind: "image",
104 uri: attachment.dataUrl,
105 name: attachment.name,
106 sourceIdentifier,
107 })
108 }
109 >
110 <Image
111 source={{ uri: attachment.previewUri }}
112 style={style}
113 className="bg-subtle"
114 resizeMode="cover"
115 />
116 </Pressable>
117 </PresentationSource>
118 );
119 }165 }
120 const onPressVideo = props.onPressVideo;166 const onPressVideo = props.onPressVideo;
121 if (onPressVideo && videoMimeType(attachment) !== null) {167 if (onPressVideo && videoMimeType(attachment) !== null) {
apps/mobile/src/components/FilePreviewModal.tsx+2-2
...@@ -3,7 +3,7 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts";...@@ -3,7 +3,7 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
3import { useEffect, useEffectEvent, useState } from "react";3import { useEffect, useEffectEvent, useState } from "react";
4import { Alert, Keyboard } from "react-native";4import { Alert, Keyboard } from "react-native";
55
6import type { DraftComposerFileAttachment } from "../lib/composerImages";6import type { FileBackedComposerAttachment } from "../lib/composerImages";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import type { MediaActionsSource } from "../lib/mediaActions";8import type { MediaActionsSource } from "../lib/mediaActions";
9import { useAssetUrlState } from "../state/assets";9import { useAssetUrlState } from "../state/assets";
...@@ -22,7 +22,7 @@ export interface ResolvedFilePreviewSource {...@@ -22,7 +22,7 @@ export interface ResolvedFilePreviewSource {
22export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &22export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
23 (23 (
24 | { readonly uri: string }24 | { readonly uri: string }
25 | { readonly attachment: DraftComposerFileAttachment }25 | { readonly attachment: FileBackedComposerAttachment }
26 | { readonly environmentId: EnvironmentId; readonly resource: AssetResource }26 | { readonly environmentId: EnvironmentId; readonly resource: AssetResource }
27 );27 );
2828
apps/mobile/src/features/threads/use-project-actions.ts-1
...@@ -138,7 +138,6 @@ export function useCreateProjectThread() {...@@ -138,7 +138,6 @@ export function useCreateProjectThread() {
138 messageId: metadata.messageId,138 messageId: metadata.messageId,
139 createdAt: metadata.createdAt,139 createdAt: metadata.createdAt,
140 text: initialMessageText,140 text: initialMessageText,
141 attachments: input.initialAttachments,
142 uploadedAttachments: prepared.attachments,141 uploadedAttachments: prepared.attachments,
143 modelSelection: input.modelSelection,142 modelSelection: input.modelSelection,
144 runtimeMode: input.runtimeMode,143 runtimeMode: input.runtimeMode,
apps/mobile/src/lib/attachmentUpload.test.ts+117
...@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({...@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
14 upload: vi.fn(),14 upload: vi.fn(),
15 writeFile: vi.fn(),15 writeFile: vi.fn(),
16 deleteFile: vi.fn(),16 deleteFile: vi.fn(),
17 readBase64: vi.fn(),
17}));18}));
1819
19vi.mock("@t3tools/client-runtime/state/runtime", () => ({20vi.mock("@t3tools/client-runtime/state/runtime", () => ({
...@@ -30,6 +31,11 @@ vi.mock("../state/atom-registry", () => ({...@@ -30,6 +31,11 @@ vi.mock("../state/atom-registry", () => ({
30 appAtomRegistry: { get: mocks.readAtom },31 appAtomRegistry: { get: mocks.readAtom },
31}));32}));
3233
34// The real read lease and cleanup are covered by the composer ownership suite.
35vi.mock("../state/use-composer-drafts", () => ({
36 retainComposerAttachmentFileForPreview: () => () => {},
37}));
38
33vi.mock("../state/assets", () => ({39vi.mock("../state/assets", () => ({
34 assetEnvironment: { createUrl: mocks.createAssetUrl },40 assetEnvironment: { createUrl: mocks.createAssetUrl },
35}));41}));
...@@ -68,6 +74,10 @@ vi.mock("expo-file-system", () => ({...@@ -68,6 +74,10 @@ vi.mock("expo-file-system", () => ({
68 mocks.deleteFile(this.uri);74 mocks.deleteFile(this.uri);
69 }75 }
7076
77 async base64() {
78 return mocks.readBase64(this.uri);
79 }
80
71 upload(url: string, options: unknown) {81 upload(url: string, options: unknown) {
72 return mocks.upload(this.uri, url, options);82 return mocks.upload(this.uri, url, options);
73 }83 }
...@@ -102,6 +112,16 @@ const image = {...@@ -102,6 +112,16 @@ const image = {
102 previewUri: "file:///images/screenshot.png",112 previewUri: "file:///images/screenshot.png",
103} as const satisfies DraftComposerAttachment;113} as const satisfies DraftComposerAttachment;
104114
115const fileBackedImage = {
116 id: "image-2",
117 type: "image",
118 name: "photo.png",
119 mimeType: "image/png",
120 sizeBytes: 3,
121 fileUri: "file:///documents/t3-composer-attachments/photo.png",
122 previewUri: "file:///documents/t3-composer-attachments/photo.png",
123} as const satisfies DraftComposerAttachment;
124
105const file = {125const file = {
106 id: "file-1",126 id: "file-1",
107 type: "file",127 type: "file",
...@@ -174,6 +194,8 @@ describe("prepareTurnAttachments", () => {...@@ -174,6 +194,8 @@ describe("prepareTurnAttachments", () => {
174 mocks.upload.mockReset();194 mocks.upload.mockReset();
175 mocks.writeFile.mockReset();195 mocks.writeFile.mockReset();
176 mocks.deleteFile.mockReset();196 mocks.deleteFile.mockReset();
197 mocks.readBase64.mockReset();
198 mocks.readBase64.mockResolvedValue("YWJj");
177 mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));199 mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));
178 mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>200 mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>
179 command === mocks.createUploadUrl201 command === mocks.createUploadUrl
...@@ -208,6 +230,101 @@ describe("prepareTurnAttachments", () => {...@@ -208,6 +230,101 @@ describe("prepareTurnAttachments", () => {
208 expect(mocks.upload).not.toHaveBeenCalled();230 expect(mocks.upload).not.toHaveBeenCalled();
209 });231 });
210232
233 it("inlines a file-backed image lazily when the server lacks image uploads", async () => {
234 const prepared = await prepareTurnAttachments({
235 environmentId,
236 attachments: [fileBackedImage],
237 });
238
239 expect(prepared.status).toBe("ready");
240 if (prepared.status !== "ready") return;
241 expect(prepared.attachments).toEqual([
242 {
243 type: "image",
244 name: "photo.png",
245 mimeType: "image/png",
246 sizeBytes: 3,
247 dataUrl: "data:image/png;base64,YWJj",
248 },
249 ]);
250 expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith(fileBackedImage.fileUri);
251 expect(mocks.upload).not.toHaveBeenCalled();
252 });
253
254 it("reads restored images from the current iOS document container for legacy sends", async () => {
255 const fileName = "33333333-3333-4333-8333-333333333333-photo.png";
256 mocks.documentUri =
257 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
258 const oldUri = `file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`;
259 const prepared = await prepareTurnAttachments({
260 environmentId,
261 attachments: [{ ...fileBackedImage, fileUri: oldUri, previewUri: oldUri }],
262 });
263 expect(prepared.status).toBe("ready");
264 expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith(
265 `${mocks.documentUri}/t3-composer-attachments/${fileName}`,
266 );
267 });
268
269 it("rejects a legacy image without inline bytes or a file", async () => {
270 const { fileUri: _, ...missingImage } = fileBackedImage;
271 await expect(
272 prepareTurnAttachments({ environmentId, attachments: [missingImage] }),
273 ).rejects.toThrow("'photo.png' is no longer available. Attach the image again.");
274 });
275
276 it.each(["resolve", "reject"] as const)(
277 "abandons a canceled legacy image read that later %ss",
278 async (outcome) => {
279 const readStarted = Promise.withResolvers<void>();
280 const read = Promise.withResolvers<string>();
281 const controller = new AbortController();
282 mocks.readBase64.mockImplementation(() => {
283 readStarted.resolve();
284 return read.promise;
285 });
286 const preparing = prepareTurnAttachments({
287 environmentId,
288 attachments: [fileBackedImage],
289 signal: controller.signal,
290 });
291 await readStarted.promise;
292 controller.abort();
293 if (outcome === "resolve") read.resolve("YWJj");
294 else read.reject(new Error("Native read failed after cancellation"));
295
296 await expect(preparing).resolves.toEqual({ status: "abandoned" });
297 },
298 );
299
300 it("uploads a file-backed image from its owned copy without staging base64", async () => {
301 const prepared = await prepareTurnAttachments({
302 environmentId,
303 attachments: [fileBackedImage],
304 supportsImageUploads: true,
305 });
306
307 expect(mocks.upload).toHaveBeenCalledWith(
308 fileBackedImage.fileUri,
309 "https://environment.example/api/attachments/upload/signed",
310 expect.objectContaining({ headers: { "Content-Type": "image/png" } }),
311 );
312 expect(mocks.readBase64).not.toHaveBeenCalled();
313 expect(mocks.writeFile).not.toHaveBeenCalled();
314 expect(mocks.deleteFile).not.toHaveBeenCalled();
315 expect(prepared.status).toBe("ready");
316 if (prepared.status !== "ready") return;
317 expect(prepared.attachments).toEqual([
318 {
319 type: "image",
320 id: MINTED_ID,
321 name: "photo.png",
322 mimeType: "image/png",
323 sizeBytes: 3,
324 },
325 ]);
326 });
327
211 it("uploads generic file bytes directly and keeps mixed attachment order", async () => {328 it("uploads generic file bytes directly and keeps mixed attachment order", async () => {
212 const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] });329 const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] });
213330
apps/mobile/src/lib/attachmentUpload.ts+70-16
...@@ -20,8 +20,13 @@ import { appAtomRegistry } from "../state/atom-registry";...@@ -20,8 +20,13 @@ import { appAtomRegistry } from "../state/atom-registry";
20import { assetEnvironment } from "../state/assets";20import { assetEnvironment } from "../state/assets";
21import { attachmentEnvironment } from "../state/attachments";21import { attachmentEnvironment } from "../state/attachments";
22import { environmentSession } from "../state/session";22import { environmentSession } from "../state/session";
23import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
23import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";24import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
24import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";25import {
26 isFileBackedComposerAttachment,
27 type DraftComposerAttachment,
28 type DraftComposerImageAttachment,
29} from "./composerImages";
25import { uuidv4 } from "./uuid";30import { uuidv4 } from "./uuid";
2631
27/**32/**
...@@ -179,6 +184,48 @@ function attachmentUploadInput(attachment: DraftComposerAttachment) {...@@ -179,6 +184,48 @@ function attachmentUploadInput(attachment: DraftComposerAttachment) {
179 return { ...fields, mimeType };184 return { ...fields, mimeType };
180}185}
181186
187/**
188 * Wire shape for startTurn on servers without attachment uploads: pure inline
189 * uploads without client draft id / previewUri. File-backed images read their
190 * base64 from disk lazily, only when this legacy path is actually taken.
191 */
192async function toUploadChatImageAttachments(
193 attachments: ReadonlyArray<DraftComposerImageAttachment>,
194): Promise<ReadonlyArray<UploadChatImageAttachment>> {
195 return Promise.all(
196 attachments.map(async (attachment) => ({
197 type: attachment.type,
198 name: attachment.name,
199 mimeType: attachment.mimeType,
200 sizeBytes: attachment.sizeBytes,
201 dataUrl: await composerImageAttachmentDataUrl(attachment),
202 })),
203 );
204}
205
206/** Inline bytes for one image: legacy drafts carry them, file-backed ones read them from disk. */
207async function composerImageAttachmentDataUrl(
208 attachment: DraftComposerImageAttachment,
209): Promise<string> {
210 if (attachment.dataUrl !== undefined) {
211 return attachment.dataUrl;
212 }
213 if (!isFileBackedComposerAttachment(attachment)) {
214 throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);
215 }
216 const release = retainComposerAttachmentFileForPreview(attachment);
217 try {
218 const { File, Paths } = await import("expo-file-system");
219 const uri =
220 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
221 attachment.fileUri;
222 const base64 = await new File(uri).base64();
223 return `data:${attachment.mimeType};base64,${base64}`;
224 } finally {
225 release();
226 }
227}
228
182async function uploadFileBytes(229async function uploadFileBytes(
183 attachment: DraftComposerAttachment,230 attachment: DraftComposerAttachment,
184 url: string,231 url: string,
...@@ -187,17 +234,21 @@ async function uploadFileBytes(...@@ -187,17 +234,21 @@ async function uploadFileBytes(
187): Promise<void> {234): Promise<void> {
188 const { File, Paths, UploadType } = await import("expo-file-system");235 const { File, Paths, UploadType } = await import("expo-file-system");
189 if (signal.aborted) throw new Error("Upload cancelled.");236 if (signal.aborted) throw new Error("Upload cancelled.");
237 // Legacy image drafts persisted inline bytes and stage them in a temp cache
238 // file for the native uploader. Everything else uploads its owned copy.
239 const fileUri = attachment.fileUri;
240 const inlineDataUrl = attachment.type === "image" ? attachment.dataUrl : undefined;
241 if (fileUri === undefined && inlineDataUrl === undefined) {
242 throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);
243 }
190 const file =244 const file =
191 attachment.type === "image"245 fileUri === undefined
192 ? new File(Paths.cache, `t3-upload-${uuidv4()}`)246 ? new File(Paths.cache, `t3-upload-${uuidv4()}`)
193 : new File(247 : new File(resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri) ?? fileUri);
194 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
195 attachment.fileUri,
196 );
197 try {248 try {
198 if (attachment.type === "image") {249 if (fileUri === undefined && inlineDataUrl !== undefined) {
199 file.create();250 file.create();
200 file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), {251 file.write(inlineDataUrl.slice(inlineDataUrl.indexOf(",") + 1), {
201 encoding: "base64",252 encoding: "base64",
202 });253 });
203 }254 }
...@@ -218,7 +269,7 @@ async function uploadFileBytes(...@@ -218,7 +269,7 @@ async function uploadFileBytes(
218 throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);269 throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);
219 }270 }
220 } finally {271 } finally {
221 if (attachment.type === "image" && file.exists) file.delete();272 if (fileUri === undefined && file.exists) file.delete();
222 }273 }
223}274}
224275
...@@ -259,13 +310,16 @@ export async function prepareTurnAttachments(input: {...@@ -259,13 +310,16 @@ export async function prepareTurnAttachments(input: {
259 });310 });
260311
261 if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) {312 if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) {
262 return ready(313 try {
263 toUploadChatImageAttachments(314 const imageAttachments = await toUploadChatImageAttachments(
264 input.attachments.filter((attachment) => attachment.type === "image"),315 input.attachments.filter((attachment) => attachment.type === "image"),
265 ),316 );
266 [],317 if (input.signal?.aborted) return { status: "abandoned" };
267 input.attachments,318 return ready(imageAttachments, [], input.attachments);
268 );319 } catch (error) {
320 if (input.signal?.aborted) return { status: "abandoned" };
321 throw error;
322 }
269 }323 }
270324
271 const connection = appAtomRegistry.get(325 const connection = appAtomRegistry.get(
...@@ -285,7 +339,7 @@ export async function prepareTurnAttachments(input: {...@@ -285,7 +339,7 @@ export async function prepareTurnAttachments(input: {
285 for (const attachment of input.attachments) {339 for (const attachment of input.attachments) {
286 if (controller.signal.aborted) throw new Error("Upload cancelled.");340 if (controller.signal.aborted) throw new Error("Upload cancelled.");
287 if (attachment.type === "image" && !input.supportsImageUploads) {341 if (attachment.type === "image" && !input.supportsImageUploads) {
288 uploadedAttachments.push(...toUploadChatImageAttachments([attachment]));342 uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment])));
289 continue;343 continue;
290 }344 }
291345
apps/mobile/src/lib/composer-image-schema.ts+11-2
...@@ -8,10 +8,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({...@@ -8,10 +8,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({
8 name: Schema.String,8 name: Schema.String,
9 mimeType: Schema.String,9 mimeType: Schema.String,
10 sizeBytes: Schema.Number,10 sizeBytes: Schema.Number,
11 dataUrl: Schema.String,11 // Accept future file-backed records before enabling the new image writers.
12 fileUri: Schema.optional(Schema.String.check(Schema.isNonEmpty())),
13 dataUrl: Schema.optional(Schema.String.check(Schema.isNonEmpty())),
12 uploadedAttachmentId: Schema.optional(Schema.String),14 uploadedAttachmentId: Schema.optional(Schema.String),
13 uploadEnvironmentId: Schema.optional(EnvironmentId),15 uploadEnvironmentId: Schema.optional(EnvironmentId),
14});16}).check(
17 Schema.makeFilter(
18 ({ fileUri, dataUrl }) =>
19 fileUri !== undefined ||
20 dataUrl !== undefined ||
21 "Image attachment has no file or inline bytes.",
22 ),
23);
1524
16export const DraftComposerFileAttachmentSchema = Schema.Struct({25export const DraftComposerFileAttachmentSchema = Schema.Struct({
17 id: Schema.String,26 id: Schema.String,
apps/mobile/src/lib/composerImages.test.ts+1-31
...@@ -36,37 +36,7 @@ vi.mock("./uuid", () => ({...@@ -36,37 +36,7 @@ vi.mock("./uuid", () => ({
36 uuidv4: () => "attachment-id",36 uuidv4: () => "attachment-id",
37}));37}));
3838
39import {39import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages";
40 convertPastedImagesToAttachments,
41 isOwnedPastedImageUri,
42 toUploadChatImageAttachments,
43} from "./composerImages";
44
45describe("toUploadChatImageAttachments", () => {
46 it("strips client draft id and previewUri for the startTurn wire shape", () => {
47 expect(
48 toUploadChatImageAttachments([
49 {
50 id: "client-draft-id",
51 type: "image",
52 name: "pasted-image.png",
53 mimeType: "image/png",
54 sizeBytes: 12,
55 dataUrl: "data:image/png;base64,AA==",
56 previewUri: "file:///tmp/preview.png",
57 },
58 ]),
59 ).toEqual([
60 {
61 type: "image",
62 name: "pasted-image.png",
63 mimeType: "image/png",
64 sizeBytes: 12,
65 dataUrl: "data:image/png;base64,AA==",
66 },
67 ]);
68 });
69});
7040
71describe("native pasted image cleanup", () => {41describe("native pasted image cleanup", () => {
72 beforeEach(() => {42 beforeEach(() => {
apps/mobile/src/lib/composerImages.ts+13-12
...@@ -20,9 +20,13 @@ import {...@@ -20,9 +20,13 @@ import {
20import { beginForegroundHandoff } from "./foreground-handoff";20import { beginForegroundHandoff } from "./foreground-handoff";
21import { uuidv4 } from "./uuid";21import { uuidv4 } from "./uuid";
2222
23export interface DraftComposerImageAttachment extends UploadChatImageAttachment {23export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> {
24 readonly id: string;24 readonly id: string;
25 readonly previewUri: string;25 readonly previewUri: string;
26 /** Owned image bytes from a file-backed draft. Current writers still use inline bytes. */
27 readonly fileUri?: string;
28 /** Inline bytes from current writers and older drafts. */
29 readonly dataUrl?: string;
26 readonly uploadedAttachmentId?: string;30 readonly uploadedAttachmentId?: string;
27 readonly uploadEnvironmentId?: EnvironmentId;31 readonly uploadEnvironmentId?: EnvironmentId;
28}32}
...@@ -40,17 +44,14 @@ export interface DraftComposerFileAttachment {...@@ -40,17 +44,14 @@ export interface DraftComposerFileAttachment {
4044
41export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;45export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;
4246
43/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */47/** Any composer attachment whose bytes live in the app-owned attachment directory. */
44export function toUploadChatImageAttachments(48export type FileBackedComposerAttachment = DraftComposerAttachment & { readonly fileUri: string };
45 attachments: ReadonlyArray<DraftComposerImageAttachment>,49
46): ReadonlyArray<UploadChatImageAttachment> {50/** Files have a local copy. Images can have one after a file-backed draft is restored. */
47 return attachments.map((attachment) => ({51export function isFileBackedComposerAttachment(
48 type: attachment.type,52 attachment: DraftComposerAttachment,
49 name: attachment.name,53): attachment is FileBackedComposerAttachment {
50 mimeType: attachment.mimeType,54 return attachment.fileUri !== undefined;
51 sizeBytes: attachment.sizeBytes,
52 dataUrl: attachment.dataUrl,
53 }));
54}55}
5556
56const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";57const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";
apps/mobile/src/lib/localAttachmentPreview.test.ts+18
...@@ -46,6 +46,24 @@ beforeEach(() => {...@@ -46,6 +46,24 @@ beforeEach(() => {
46});46});
4747
48describe("loadLocalAttachmentPreview", () => {48describe("loadLocalAttachmentPreview", () => {
49 it("keeps an image MIME type when its filename has a video extension", async () => {
50 const image = {
51 ...attachment,
52 type: "image" as const,
53 name: "photo.mov",
54 mimeType: "image/png",
55 previewUri: attachment.fileUri,
56 };
57 const preview = await loadLocalAttachmentPreview(image, new AbortController().signal);
58 await preview!.share(new AbortController().signal);
59 expect(mocks.share).toHaveBeenCalledWith(
60 expect.objectContaining({
61 attachment: { name: "photo.mov", mimeType: "image/png" },
62 }),
63 );
64 preview!.dispose();
65 });
66
49 it("retains and shares a PDF with its original filename and type", async () => {67 it("retains and shares a PDF with its original filename and type", async () => {
50 const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" };68 const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" };
51 const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal);69 const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal);
apps/mobile/src/lib/localAttachmentPreview.ts+6-3
...@@ -1,13 +1,13 @@...@@ -1,13 +1,13 @@
1import { videoMimeType } from "@t3tools/shared/video";1import { videoMimeType } from "@t3tools/shared/video";
22
3import type { DraftComposerFileAttachment } from "./composerImages";3import type { FileBackedComposerAttachment } from "./composerImages";
4import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";4import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
77
8/** Retains the draft original for preview and gives each outgoing share its own lease. */8/** Retains the draft original for preview and gives each outgoing share its own lease. */
9export async function loadLocalAttachmentPreview(9export async function loadLocalAttachmentPreview(
10 attachment: DraftComposerFileAttachment,10 attachment: FileBackedComposerAttachment,
11 signal: AbortSignal,11 signal: AbortSignal,
12): Promise<AttachmentPreviewFile | null> {12): Promise<AttachmentPreviewFile | null> {
13 if (signal.aborted) return null;13 if (signal.aborted) return null;
...@@ -41,7 +41,10 @@ export async function loadLocalAttachmentPreview(...@@ -41,7 +41,10 @@ export async function loadLocalAttachmentPreview(
41 uri: file.uri,41 uri: file.uri,
42 attachment: {42 attachment: {
43 name: attachment.name,43 name: attachment.name,
44 mimeType: videoMimeType(attachment) ?? attachment.mimeType,44 mimeType:
45 attachment.type === "file"
46 ? (videoMimeType(attachment) ?? attachment.mimeType)
47 : attachment.mimeType,
45 },48 },
46 signal: shareSignal,49 signal: shareSignal,
47 sourceIdentifier,50 sourceIdentifier,
apps/mobile/src/lib/projectThreadStartTurn.test.ts+2-4
...@@ -6,15 +6,13 @@ import {...@@ -6,15 +6,13 @@ import {
6 ThreadId,6 ThreadId,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";8import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
9import { describe, expect, it, vi } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
1010
11import {11import {
12 buildProjectThreadStartTurnInput,12 buildProjectThreadStartTurnInput,
13 deriveThreadTitleFromPrompt,13 deriveThreadTitleFromPrompt,
14} from "./projectThreadStartTurn";14} from "./projectThreadStartTurn";
1515
16vi.mock("./composerImages", () => ({ toUploadChatImageAttachments: () => [] }));
17
18describe("project thread title", () => {16describe("project thread title", () => {
19 it("keeps ordinary titles and the empty-prompt fallback", () => {17 it("keeps ordinary titles and the empty-prompt fallback", () => {
20 expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser");18 expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser");
...@@ -52,7 +50,7 @@ describe("project thread title", () => {...@@ -52,7 +50,7 @@ describe("project thread title", () => {
52 messageId: "message",50 messageId: "message",
53 createdAt: "2026-09-01T00:00:00Z",51 createdAt: "2026-09-01T00:00:00Z",
54 text,52 text,
55 attachments: [],53 uploadedAttachments: [],
56 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },54 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
57 runtimeMode: "full-access",55 runtimeMode: "full-access",
58 interactionMode: "default",56 interactionMode: "default",
apps/mobile/src/lib/projectThreadStartTurn.ts+3-8
...@@ -9,7 +9,6 @@ import {...@@ -9,7 +9,6 @@ import {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";10import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
1111
12import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
13import type { UploadedMobileAttachment } from "./attachmentUpload";12import type { UploadedMobileAttachment } from "./attachmentUpload";
1413
15export function deriveThreadTitleFromPrompt(value: string): string {14export function deriveThreadTitleFromPrompt(value: string): string {
...@@ -30,8 +29,8 @@ export interface ProjectThreadStartTurnSpec {...@@ -30,8 +29,8 @@ export interface ProjectThreadStartTurnSpec {
30 readonly messageId: string;29 readonly messageId: string;
31 readonly createdAt: string;30 readonly createdAt: string;
32 readonly text: string;31 readonly text: string;
33 readonly attachments: ReadonlyArray<DraftComposerAttachment>;32 /** Wire attachments from `prepareTurnAttachments`, in composer order. */
34 readonly uploadedAttachments?: ReadonlyArray<UploadedMobileAttachment>;33 readonly uploadedAttachments: ReadonlyArray<UploadedMobileAttachment>;
35 readonly modelSelection: ModelSelection;34 readonly modelSelection: ModelSelection;
36 readonly runtimeMode: RuntimeMode;35 readonly runtimeMode: RuntimeMode;
37 readonly interactionMode: ProviderInteractionMode;36 readonly interactionMode: ProviderInteractionMode;
...@@ -58,11 +57,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe...@@ -58,11 +57,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
58 messageId: MessageId.make(spec.messageId),57 messageId: MessageId.make(spec.messageId),
59 role: "user" as const,58 role: "user" as const,
60 text: spec.text,59 text: spec.text,
61 attachments:60 attachments: spec.uploadedAttachments,
62 spec.uploadedAttachments ??
63 toUploadChatImageAttachments(
64 spec.attachments.filter((attachment) => attachment.type === "image"),
65 ),
66 },61 },
67 modelSelection: spec.modelSelection,62 modelSelection: spec.modelSelection,
68 titleSeed: title,63 titleSeed: title,
apps/mobile/src/state/composer-attachment-uploads.ts+4-4
...@@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity";...@@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity";
4import { useEffect, useRef } from "react";4import { useEffect, useRef } from "react";
55
6import { prepareTurnAttachments } from "../lib/attachmentUpload";6import { prepareTurnAttachments } from "../lib/attachmentUpload";
7import { isFileBackedComposerAttachment } from "../lib/composerImages";
7import {8import {
8 composerAttachmentUploadKey,9 composerAttachmentUploadKey,
9 composerDraftEnvironmentId,10 composerDraftEnvironmentId,
...@@ -60,10 +61,9 @@ export function useComposerAttachmentUploadWorker() {...@@ -60,10 +61,9 @@ export function useComposerAttachmentUploadWorker() {
60 const queue = createComposerAttachmentUploadQueue({61 const queue = createComposerAttachmentUploadQueue({
61 onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states),62 onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states),
62 upload: async ({ environmentId, attachment }, signal, onProgress) => {63 upload: async ({ environmentId, attachment }, signal, onProgress) => {
63 const release =64 const release = isFileBackedComposerAttachment(attachment)
64 attachment.type === "file"65 ? retainComposerAttachmentFileForPreview(attachment)
65 ? retainComposerAttachmentFileForPreview(attachment)66 : undefined;
66 : undefined;
67 try {67 try {
68 const result = await prepareTurnAttachments({68 const result = await prepareTurnAttachments({
69 environmentId,69 environmentId,
apps/mobile/src/state/thread-outbox-model.ts+2-1
...@@ -27,6 +27,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages";...@@ -27,6 +27,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages";
27import { scopedThreadKey } from "../lib/scopedEntities";27import { scopedThreadKey } from "../lib/scopedEntities";
28import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";28import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
2929
30// Keep current writes until a compatible native baseline includes the v4 reader.
30const THREAD_OUTBOX_SCHEMA_VERSION = 3;31const THREAD_OUTBOX_SCHEMA_VERSION = 3;
31const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;32const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
3233
...@@ -43,7 +44,7 @@ const QueuedThreadCreationSchema = Schema.Struct({...@@ -43,7 +44,7 @@ const QueuedThreadCreationSchema = Schema.Struct({
43});44});
4445
45export const QueuedThreadMessageSchema = Schema.Struct({46export const QueuedThreadMessageSchema = Schema.Struct({
46 schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]),47 schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION, 4]),
47 environmentId: EnvironmentId,48 environmentId: EnvironmentId,
48 threadId: ThreadId,49 threadId: ThreadId,
49 messageId: MessageId,50 messageId: MessageId,
apps/mobile/src/state/thread-outbox.test.ts+24
...@@ -196,6 +196,30 @@ describe("thread outbox", () => {...@@ -196,6 +196,30 @@ describe("thread outbox", () => {
196 expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message);196 expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message);
197 });197 });
198198
199 it("reads file-backed images from v4 queued messages", () => {
200 const message = {
201 ...queuedMessage({
202 messageId: "message-image",
203 createdAt: "2026-06-08T10:00:01.000Z",
204 }),
205 attachments: [
206 {
207 id: "image-1",
208 type: "image" as const,
209 name: "photo.png",
210 mimeType: "image/png",
211 sizeBytes: 3,
212 fileUri: "file:///documents/t3-composer-attachments/photo.png",
213 previewUri: "file:///documents/t3-composer-attachments/photo.png",
214 uploadedAttachmentId: "pending-photo-png",
215 uploadEnvironmentId: EnvironmentId.make("environment-1"),
216 },
217 ],
218 } satisfies QueuedThreadMessage;
219
220 expect(decodeQueuedThreadMessage({ ...message, schemaVersion: 4 })).toEqual(message);
221 });
222
199 it("persists the exact selector snapshot while remaining compatible with v1 messages", () => {223 it("persists the exact selector snapshot while remaining compatible with v1 messages", () => {
200 const legacyMessage = queuedMessage({224 const legacyMessage = queuedMessage({
201 messageId: "message-1",225 messageId: "message-1",
apps/mobile/src/state/use-composer-drafts.test.ts+260-98
...@@ -17,8 +17,10 @@ const composerDraftFileMocks = vi.hoisted(() => {...@@ -17,8 +17,10 @@ const composerDraftFileMocks = vi.hoisted(() => {
17 let nextWriteBarrier: Promise<void> | null = null;17 let nextWriteBarrier: Promise<void> | null = null;
18 let onWrite: (() => void) | null = null;18 let onWrite: (() => void) | null = null;
19 const writes: string[] = [];19 const writes: string[] = [];
20 const readImage = vi.fn(async () => "YWJj");
2021
21 return {22 return {
23 readImage,
22 blockRead() {24 blockRead() {
23 readBarrier = new Promise<void>((resolve) => {25 readBarrier = new Promise<void>((resolve) => {
24 releaseRead = resolve;26 releaseRead = resolve;
...@@ -73,6 +75,10 @@ const composerDraftFileMocks = vi.hoisted(() => {...@@ -73,6 +75,10 @@ const composerDraftFileMocks = vi.hoisted(() => {
73 return document;75 return document;
74 }76 }
7577
78 async base64() {
79 return readImage();
80 }
81
76 write(value: string) {82 write(value: string) {
77 if (writeError) {83 if (writeError) {
78 throw writeError;84 throw writeError;
...@@ -108,13 +114,29 @@ const incomingShareStorageMocks = vi.hoisted(() => ({...@@ -108,13 +114,29 @@ const incomingShareStorageMocks = vi.hoisted(() => ({
108vi.mock("expo-file-system", () => ({114vi.mock("expo-file-system", () => ({
109 Directory: composerDraftFileMocks.Directory,115 Directory: composerDraftFileMocks.Directory,
110 File: composerDraftFileMocks.File,116 File: composerDraftFileMocks.File,
111 Paths: { document: "/documents" },117 Paths: { document: { uri: "file:///documents" } },
112}));118}));
113119
114vi.mock("../lib/composerImages", () => ({120vi.mock("../lib/composerImages", async (importOriginal) => ({
121 ...(await importOriginal<typeof import("../lib/composerImages")>()),
115 removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,122 removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,
116}));123}));
117124
125vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" }));
126vi.mock("./assets", () => ({ assetEnvironment: {} }));
127vi.mock("./attachments", () => ({ attachmentEnvironment: {} }));
128vi.mock("./session", () => ({ environmentSession: {} }));
129vi.mock("@t3tools/client-runtime/state/runtime", () => ({
130 createEnvironmentRpcCommand: () => Symbol("rpc-command"),
131 executeAtomQuery: () => {
132 throw new Error("Unexpected network query in the inline read test");
133 },
134 runAtomCommand: () => {
135 throw new Error("Unexpected network command in the inline read test");
136 },
137 squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
138}));
139
118vi.mock("../lib/attachmentUpload", () => ({140vi.mock("../lib/attachmentUpload", () => ({
119 releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,141 releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,
120}));142}));
...@@ -128,6 +150,7 @@ import { threadOutboxManager } from "./thread-outbox";...@@ -128,6 +150,7 @@ import { threadOutboxManager } from "./thread-outbox";
128import {150import {
129 appendComposerDraftAttachments,151 appendComposerDraftAttachments,
130 archiveCloudComposerDrafts,152 archiveCloudComposerDrafts,
153 clearComposerDraftContent,
131 clearComposerDraftContentState,154 clearComposerDraftContentState,
132 clearComposerDraftsEnvironment,155 clearComposerDraftsEnvironment,
133 ComposerDraftPersistenceError,156 ComposerDraftPersistenceError,
...@@ -171,6 +194,8 @@ afterEach(() => {...@@ -171,6 +194,8 @@ afterEach(() => {
171 composerDraftFileMocks.setNextWriteBarrier(null);194 composerDraftFileMocks.setNextWriteBarrier(null);
172 composerDraftFileMocks.setOnWrite(null);195 composerDraftFileMocks.setOnWrite(null);
173 composerDraftFileMocks.resetWrites();196 composerDraftFileMocks.resetWrites();
197 composerDraftFileMocks.readImage.mockReset();
198 composerDraftFileMocks.readImage.mockResolvedValue("YWJj");
174 appAtomRegistry.set(composerDraftsAtom, {});199 appAtomRegistry.set(composerDraftsAtom, {});
175 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });200 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
176 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);201 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
...@@ -313,6 +338,106 @@ describe("mobile composer drafts", () => {...@@ -313,6 +338,106 @@ describe("mobile composer drafts", () => {
313 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);338 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
314 });339 });
315340
341 it("retains a referenced file-backed image and releases it once unreferenced", async () => {
342 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
343 onTestFinished(() => outboxLoad.mockRestore());
344 const image = {
345 id: "image-1",
346 type: "image" as const,
347 name: "photo.png",
348 mimeType: "image/png",
349 sizeBytes: 3,
350 fileUri: "file:///documents/t3-composer-attachments/photo.png",
351 previewUri: "file:///documents/t3-composer-attachments/photo.png",
352 };
353 appAtomRegistry.set(composerDraftsAtom, {
354 "environment-1:thread-1": { text: "look at this", attachments: [image] },
355 });
356
357 await releaseUnusedComposerAttachmentFiles([image]);
358 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
359
360 // A queued outbox message must keep the bytes alive after the draft clears.
361 appAtomRegistry.set(composerDraftsAtom, {});
362 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
363 "environment-1:thread-1": [
364 {
365 environmentId: EnvironmentId.make("environment-1"),
366 threadId: ThreadId.make("thread-1"),
367 messageId: MessageId.make("queued-image"),
368 commandId: CommandId.make("command-image"),
369 text: "look at this",
370 attachments: [image],
371 createdAt: "2026-08-31T12:00:00.000Z",
372 },
373 ],
374 });
375 await releaseUnusedComposerAttachmentFiles([image]);
376 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
377
378 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
379 await releaseUnusedComposerAttachmentFiles([image]);
380 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(image.fileUri);
381 });
382
383 it("keeps an image through an inline read after its draft is removed", async () => {
384 const { prepareTurnAttachments } =
385 await vi.importActual<typeof import("../lib/attachmentUpload")>("../lib/attachmentUpload");
386 const image = {
387 id: "image-reading",
388 type: "image" as const,
389 name: "photo.png",
390 mimeType: "image/png",
391 sizeBytes: 3,
392 fileUri: "file:///documents/t3-composer-attachments/photo.png",
393 previewUri: "file:///documents/t3-composer-attachments/photo.png",
394 };
395 const readStarted = Promise.withResolvers<void>();
396 const read = Promise.withResolvers<void>();
397 const removed = Promise.withResolvers<void>();
398 let imageExists = true;
399 composerDraftFileMocks.readImage.mockImplementation(async () => {
400 readStarted.resolve();
401 await read.promise;
402 if (!imageExists) throw new Error("The image was deleted during its read");
403 return "YWJj";
404 });
405 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
406 imageExists = false;
407 removed.resolve();
408 });
409 appendComposerDraftAttachments("environment-1:thread-1", [image]);
410 await flushComposerDrafts();
411 const preparing = prepareTurnAttachments({
412 environmentId: EnvironmentId.make("environment-1"),
413 attachments: [image],
414 });
415 onTestFinished(async () => {
416 read.resolve();
417 await preparing.catch(() => undefined);
418 });
419 await readStarted.promise;
420 clearComposerDraftContent("environment-1:thread-1", { deferAttachmentCleanup: true });
421 await releaseUnusedComposerAttachmentFiles([image]);
422 expect(imageExists).toBe(true);
423
424 read.resolve();
425 const prepared = await preparing;
426 expect(prepared.status).toBe("ready");
427 if (prepared.status !== "ready") return;
428 expect(prepared.attachments).toEqual([
429 {
430 type: "image",
431 name: "photo.png",
432 mimeType: "image/png",
433 sizeBytes: 3,
434 dataUrl: "data:image/png;base64,YWJj",
435 },
436 ]);
437 await removed.promise;
438 expect(imageExists).toBe(false);
439 });
440
316 it("keeps a failed-send draft's pending upload for retry", async () => {441 it("keeps a failed-send draft's pending upload for retry", async () => {
317 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);442 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
318 onTestFinished(() => outboxLoad.mockRestore());443 onTestFinished(() => outboxLoad.mockRestore());
...@@ -394,68 +519,75 @@ describe("mobile composer drafts", () => {...@@ -394,68 +519,75 @@ describe("mobile composer drafts", () => {
394 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();519 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
395 });520 });
396521
397 it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => {522 it.each(["file", "image"] as const)(
398 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);523 "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account",
399 onTestFinished(() => load.mockRestore());524 async (type) => {
400 await waitForComposerDraftsLoaded();525 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
401 const environmentId = EnvironmentId.make("cloud-environment");526 onTestFinished(() => load.mockRestore());
402 const key = `${environmentId}:thread-1`;527 await waitForComposerDraftsLoaded();
403 const file = {528 const environmentId = EnvironmentId.make("cloud-environment");
404 id: "local-pdf",529 const key = `${environmentId}:thread-1`;
405 type: "file" as const,530 const name = type === "image" ? "notes.png" : "notes.pdf";
406 name: "notes.pdf",531 const metadata = {
407 mimeType: "application/pdf",532 id: "local-notes",
408 sizeBytes: 42,533 name,
409 fileUri: "file:///documents/t3-composer-attachments/notes.pdf",534 mimeType: type === "image" ? "image/png" : "application/pdf",
410 uploadEnvironmentId: environmentId,535 sizeBytes: 42,
411 uploadedAttachmentId: "pending-pdf",536 fileUri: `file:///documents/t3-composer-attachments/${name}`,
412 };537 uploadEnvironmentId: environmentId,
413 const queued = {538 uploadedAttachmentId: "pending-notes",
414 environmentId,539 };
415 threadId: ThreadId.make("thread-2"),540 const file =
416 messageId: MessageId.make("queued-1"),541 type === "image"
417 commandId: CommandId.make("command-1"),542 ? { ...metadata, type, previewUri: metadata.fileUri }
418 text: "Send later",543 : { ...metadata, type };
419 attachments: [file],544 const queued = {
420 createdAt: "2026-08-31T12:00:00.000Z",545 environmentId,
421 };546 threadId: ThreadId.make("thread-2"),
422 appAtomRegistry.set(composerDraftsAtom, {547 messageId: MessageId.make("queued-1"),
423 [key]: { text: "Unsent notes", attachments: [file] },548 commandId: CommandId.make("command-1"),
424 "direct-environment:thread-1": DRAFT,549 text: "Send later",
425 "pending-task:queued-1": { text: "Edited queued task", attachments: [file] },550 attachments: [file],
426 });551 createdAt: "2026-08-31T12:00:00.000Z",
427 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] });552 };
428 await archiveCloudComposerDrafts("account-a", new Set([environmentId]));553 appAtomRegistry.set(composerDraftsAtom, {
429 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({554 [key]: { text: "Unsent notes", attachments: [file] },
430 "direct-environment:thread-1": DRAFT,555 "direct-environment:thread-1": DRAFT,
431 });556 "pending-task:queued-1": { text: "Edited queued task", attachments: [file] },
432 // The registry can remove the active outbox and drafts after the backup lands.557 });
433 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});558 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] });
434 await clearComposerDraftsEnvironment(environmentId);559 await archiveCloudComposerDrafts("account-a", new Set([environmentId]));
435 await releaseUnusedComposerAttachmentFiles([file]);560 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
436 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();561 "direct-environment:thread-1": DRAFT,
437 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();562 });
563 // The registry can remove the active outbox and drafts after the backup lands.
564 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
565 await clearComposerDraftsEnvironment(environmentId);
566 await releaseUnusedComposerAttachmentFiles([file]);
567 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
568 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
438569
439 appAtomRegistry.set(composerDraftsAtom, {});570 appAtomRegistry.set(composerDraftsAtom, {});
440 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });571 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
441 resetComposerDraftsLoadState();572 resetComposerDraftsLoadState();
442 await waitForComposerDraftsLoaded();573 await waitForComposerDraftsLoaded();
443 await restoreCloudComposerDrafts("account-b");574 await restoreCloudComposerDrafts("account-b");
444 expect(getComposerDraftSnapshot(key).attachments).toEqual([]);575 expect(getComposerDraftSnapshot(key).attachments).toEqual([]);
445 expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({});576 expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({});
446 const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue();577 const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue();
447 onTestFinished(() => enqueue.mockRestore());578 onTestFinished(() => enqueue.mockRestore());
448 await restoreCloudComposerDrafts("account-a");579 await restoreCloudComposerDrafts("account-a");
449 expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] });580 expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] });
450 expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task");581 expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task");
451 expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued);582 expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued);
452 expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({});583 expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({});
453 const persisted = decodePersistedComposerState(584 const persisted = decodePersistedComposerState(
454 JSON.parse(composerDraftFileMocks.getDocument()),585 JSON.parse(composerDraftFileMocks.getDocument()),
455 );586 );
456 expect(persisted.drafts[key]?.attachments).toEqual([file]);587 expect(persisted.drafts[key]?.attachments).toEqual([file]);
457 expect(persisted.cloudDrafts.accountId).toBe("account-a");588 expect(persisted.cloudDrafts.accountId).toBe("account-a");
458 });589 },
590 );
459591
460 it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => {592 it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => {
461 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);593 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
...@@ -480,45 +612,52 @@ describe("mobile composer drafts", () => {...@@ -480,45 +612,52 @@ describe("mobile composer drafts", () => {
480 ).toEqual(DRAFT);612 ).toEqual(DRAFT);
481 });613 });
482614
483 it("keeps a removed file until both playback and a share copy finish", async () => {615 it.each(["file", "image"] as const)(
484 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);616 "keeps a removed %s until both preview and a share copy finish",
485 onTestFinished(() => outboxLoad.mockRestore());617 async (type) => {
486 const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4";618 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
487 const file = {619 onTestFinished(() => outboxLoad.mockRestore());
488 id: "file-preview",620 const name = type === "image" ? "photo.png" : "recording.mp4";
489 type: "file" as const,621 const fileName = `33333333-3333-4333-8333-333333333333-${name}`;
490 name: "recording.mp4",622 const metadata = {
491 mimeType: "video/mp4",623 id: "file-preview",
492 sizeBytes: 42,624 name,
493 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,625 mimeType: type === "image" ? "image/png" : "video/mp4",
494 };626 sizeBytes: 42,
495 const currentFile = {627 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,
496 ...file,628 };
497 fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`,629 const file =
498 };630 type === "image"
499 const releasePlayback = retainComposerAttachmentFileForPreview(file);631 ? { ...metadata, type, previewUri: metadata.fileUri }
500 const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile);632 : { ...metadata, type };
501 onTestFinished(releasePlayback);633 const currentFile = {
502 onTestFinished(releaseShareCopy);634 ...file,
635 fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`,
636 };
637 const releasePlayback = retainComposerAttachmentFileForPreview(file);
638 const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile);
639 onTestFinished(releasePlayback);
640 onTestFinished(releaseShareCopy);
503641
504 await releaseUnusedComposerAttachmentFiles([currentFile]);642 await releaseUnusedComposerAttachmentFiles([currentFile]);
505 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();643 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
506644
507 releasePlayback();645 releasePlayback();
508 releasePlayback();646 releasePlayback();
509 await releaseUnusedComposerAttachmentFiles([file]);647 await releaseUnusedComposerAttachmentFiles([file]);
510 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();648 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
511649
512 const deleted = Promise.withResolvers<void>();650 const deleted = Promise.withResolvers<void>();
513 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {651 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
514 deleted.resolve();652 deleted.resolve();
515 return undefined;653 return undefined;
516 });654 });
517 releaseShareCopy();655 releaseShareCopy();
518 await deleted.promise;656 await deleted.promise;
519657
520 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]);658 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]);
521 });659 },
660 );
522661
523 it("preserves a preview opened while cleanup is checking the incoming inbox", async () => {662 it("preserves a preview opened while cleanup is checking the incoming inbox", async () => {
524 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);663 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
...@@ -846,6 +985,29 @@ describe("mobile composer drafts", () => {...@@ -846,6 +985,29 @@ describe("mobile composer drafts", () => {
846 }985 }
847 });986 });
848987
988 it("rejects persisted images without image bytes or a file URI", () => {
989 expect(() =>
990 decodePersistedComposerDrafts({
991 schemaVersion: 1,
992 drafts: {
993 "environment-1:thread-1": {
994 text: "Saved image",
995 attachments: [
996 {
997 id: "image-1",
998 type: "image",
999 name: "photo.png",
1000 mimeType: "image/png",
1001 sizeBytes: 3,
1002 previewUri: "file:///photo.png",
1003 },
1004 ],
1005 },
1006 },
1007 }),
1008 ).toThrow();
1009 });
1010
849 it("hydrates selector state even when the message content is empty", () => {1011 it("hydrates selector state even when the message content is empty", () => {
850 expect(1012 expect(
851 decodePersistedComposerDrafts({1013 decodePersistedComposerDrafts({
apps/mobile/src/state/use-composer-drafts.ts+9-8
...@@ -20,7 +20,7 @@ import {...@@ -20,7 +20,7 @@ import {
20 isComposerAttachmentFileRetained,20 isComposerAttachmentFileRetained,
21 retainComposerAttachmentFile,21 retainComposerAttachmentFile,
22} from "../lib/composerAttachmentFiles";22} from "../lib/composerAttachmentFiles";
23import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";23import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
24import { SerializedAsyncQueue } from "../lib/serialized-async-queue";24import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
25import { appAtomRegistry } from "./atom-registry";25import { appAtomRegistry } from "./atom-registry";
26import {26import {
...@@ -376,7 +376,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean {...@@ -376,7 +376,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean {
376 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>376 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>
377 owner.attachments.some(377 owner.attachments.some(
378 (attachment) =>378 (attachment) =>
379 attachment.type === "file" &&379 attachment.fileUri !== undefined &&
380 composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey,380 composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey,
381 ),381 ),
382 );382 );
...@@ -403,9 +403,9 @@ export async function releaseUnusedComposerAttachmentFiles(...@@ -403,9 +403,9 @@ export async function releaseUnusedComposerAttachmentFiles(
403 attachments: ReadonlyArray<DraftComposerAttachment>,403 attachments: ReadonlyArray<DraftComposerAttachment>,
404): Promise<void> {404): Promise<void> {
405 const candidates = new Set(405 const candidates = new Set(
406 attachments406 attachments.flatMap((attachment) =>
407 .filter((attachment) => attachment.type === "file")407 attachment.fileUri !== undefined ? [attachment.fileUri] : [],
408 .map((attachment) => attachment.fileUri),408 ),
409 );409 );
410 const uploadCandidates = new Map<EnvironmentId, Set<string>>();410 const uploadCandidates = new Map<EnvironmentId, Set<string>>();
411 for (const attachment of attachments) {411 for (const attachment of attachments) {
...@@ -454,7 +454,7 @@ export async function releaseUnusedComposerAttachmentFiles(...@@ -454,7 +454,7 @@ export async function releaseUnusedComposerAttachmentFiles(
454 incomingShareFileUris = new Set(454 incomingShareFileUris = new Set(
455 incomingShares.flatMap((share) =>455 incomingShares.flatMap((share) =>
456 share.attachments.flatMap((attachment) =>456 share.attachments.flatMap((attachment) =>
457 attachment.type === "file"457 attachment.fileUri !== undefined
458 ? [composerAttachmentFileReferenceKey(attachment.fileUri)]458 ? [composerAttachmentFileReferenceKey(attachment.fileUri)]
459 : [],459 : [],
460 ),460 ),
...@@ -509,7 +509,8 @@ export function scheduleUnusedComposerAttachmentCleanup(...@@ -509,7 +509,8 @@ export function scheduleUnusedComposerAttachmentCleanup(
509): void {509): void {
510 if (510 if (
511 !attachments.some(511 !attachments.some(
512 (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined,512 (attachment) =>
513 attachment.fileUri !== undefined || attachment.uploadedAttachmentId !== undefined,
513 )514 )
514 ) {515 ) {
515 return;516 return;
...@@ -521,7 +522,7 @@ export function scheduleUnusedComposerAttachmentCleanup(...@@ -521,7 +522,7 @@ export function scheduleUnusedComposerAttachmentCleanup(
521522
522/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */523/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */
523export function retainComposerAttachmentFileForPreview(524export function retainComposerAttachmentFileForPreview(
524 attachment: DraftComposerFileAttachment,525 attachment: FileBackedComposerAttachment,
525): () => void {526): () => void {
526 return retainComposerAttachmentFile(attachment.fileUri, () => {527 return retainComposerAttachmentFile(attachment.fileUri, () => {
527 scheduleUnusedComposerAttachmentCleanup([attachment]);528 scheduleUnusedComposerAttachmentCleanup([attachment]);
apps/mobile/src/state/use-thread-outbox-drain.ts-1
...@@ -873,7 +873,6 @@ export function useThreadOutboxDrain(): void {...@@ -873,7 +873,6 @@ export function useThreadOutboxDrain(): void {
873 messageId: queuedMessage.messageId,873 messageId: queuedMessage.messageId,
874 createdAt: queuedMessage.createdAt,874 createdAt: queuedMessage.createdAt,
875 text: queuedMessage.text.trim(),875 text: queuedMessage.text.trim(),
876 attachments: queuedMessage.attachments,
877 uploadedAttachments: prepared.attachments,876 uploadedAttachments: prepared.attachments,
878 modelSelection: sendSettings.modelSelection,877 modelSelection: sendSettings.modelSelection,
879 runtimeMode: sendSettings.runtimeMode,878 runtimeMode: sendSettings.runtimeMode,