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