| 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,12 +148,13 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [ |
| 148 | supportsText: true, | 148 | supportsText: true, |
| 149 | supportsWebUrlWithMaxCount: 1, | 149 | supportsWebUrlWithMaxCount: 1, |
| 150 | supportsImageWithMaxCount: 8, | 150 | supportsImageWithMaxCount: 8, |
| 151 | supportsFileWithMaxCount: 8, | ||
| 151 | }, | 152 | }, |
| 152 | }, | 153 | }, |
| 153 | android: { | 154 | android: { |
| 154 | enabled: true, | 155 | enabled: true, |
| 155 | singleShareMimeTypes: ["text/plain", "image/*"], | 156 | singleShareMimeTypes: ["*/*"], |
| 156 | multipleShareMimeTypes: ["image/*"], | 157 | multipleShareMimeTypes: ["*/*"], |
| 157 | }, | 158 | }, |
| 158 | }, | 159 | }, |
| 159 | ]; | 160 | ]; |
apps/mobile/src/components/ComposerAttachmentStrip.tsx+36-18| ... | @@ -1,12 +1,13 @@ | ... | @@ -1,12 +1,13 @@ |
| 1 | import { SymbolView } from "../components/AppSymbol"; | 1 | import { SymbolView } from "../components/AppSymbol"; |
| 2 | import { Image, Pressable, ScrollView, View } from "react-native"; | 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 | export interface ComposerAttachmentStripProps { | 7 | export interface ComposerAttachmentStripProps { |
| 7 | /** Attachment images to display. */ | 8 | /** Attachments to display. */ |
| 8 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 9 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 9 | /** Called when the user taps the remove button on an image. */ | 10 | /** Called when the user removes an attachment. */ |
| 10 | readonly onRemove: (imageId: string) => void; | 11 | readonly onRemove: (imageId: string) => void; |
| 11 | /** Called when the user taps on an image thumbnail to preview it. */ | 12 | /** Called when the user taps on an image thumbnail to preview it. */ |
| 12 | readonly onPressImage?: (previewUri: string) => void; | 13 | readonly onPressImage?: (previewUri: string) => void; |
| ... | @@ -19,8 +20,7 @@ export interface ComposerAttachmentStripProps { | ... | @@ -19,8 +20,7 @@ export interface ComposerAttachmentStripProps { |
| 19 | } | 20 | } |
| 20 | 21 | ||
| 21 | /** | 22 | /** |
| 22 | * A horizontally-scrollable strip of image attachment thumbnails with remove | 23 | * Attachment thumbnails used by the thread composer and the new-task draft screen. |
| 23 | * buttons. Used by both the thread composer and the new-task draft screen. | ||
| 24 | */ | 24 | */ |
| 25 | export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { | 25 | export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 26 | const size = props.imageSize ?? 72; | 26 | const size = props.imageSize ?? 72; |
| ... | @@ -40,29 +40,47 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { | ... | @@ -40,29 +40,47 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 40 | className="grow-0" | 40 | className="grow-0" |
| 41 | > | 41 | > |
| 42 | <View className="flex-row gap-2.5"> | 42 | <View className="flex-row gap-2.5"> |
| 43 | {props.attachments.map((image) => ( | 43 | {props.attachments.map((attachment) => ( |
| 44 | <View | 44 | <View |
| 45 | key={image.id} | 45 | key={attachment.id} |
| 46 | className="relative" | 46 | className="relative" |
| 47 | style={{ | 47 | style={{ |
| 48 | paddingTop: removeButtonGutter, | 48 | paddingTop: removeButtonGutter, |
| 49 | paddingRight: removeButtonGutter, | 49 | paddingRight: removeButtonGutter, |
| 50 | }} | 50 | }} |
| 51 | > | 51 | > |
| 52 | <Pressable | 52 | {attachment.type === "image" ? ( |
| 53 | onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined} | 53 | <Pressable |
| 54 | > | 54 | onPress={ |
| 55 | <Image | 55 | props.onPressImage ? () => props.onPressImage!(attachment.previewUri) : undefined |
| 56 | source={{ uri: image.previewUri }} | 56 | } |
| 57 | className="bg-subtle" | 57 | > |
| 58 | <Image | ||
| 59 | source={{ uri: attachment.previewUri }} | ||
| 60 | style={{ | ||
| 61 | width: size, | ||
| 62 | height: size, | ||
| 63 | borderRadius: radius, | ||
| 64 | }} | ||
| 65 | className="bg-subtle" | ||
| 66 | resizeMode="cover" | ||
| 67 | /> | ||
| 68 | </Pressable> | ||
| 69 | ) : ( | ||
| 70 | <View | ||
| 71 | className="items-center justify-center gap-1 bg-subtle px-2" | ||
| 58 | style={{ | 72 | style={{ |
| 59 | width: size, | 73 | width: size, |
| 60 | height: size, | 74 | height: size, |
| 61 | borderRadius: radius, | 75 | borderRadius: radius, |
| 62 | }} | 76 | }} |
| 63 | resizeMode="cover" | 77 | > |
| 64 | /> | 78 | <SymbolView name="doc.text" size={22} tintColor="#a3a3a3" type="monochrome" /> |
| 65 | </Pressable> | 79 | <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}> |
| 80 | {attachment.name} | ||
| 81 | </Text> | ||
| 82 | </View> | ||
| 83 | )} | ||
| 66 | <Pressable | 84 | <Pressable |
| 67 | className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55" | 85 | className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55" |
| 68 | style={{ | 86 | style={{ |
| ... | @@ -70,7 +88,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { | ... | @@ -70,7 +88,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { |
| 70 | right: removeButtonPlacement === "gutter" ? 0 : 4, | 88 | right: removeButtonPlacement === "gutter" ? 0 : 4, |
| 71 | }} | 89 | }} |
| 72 | hitSlop={6} | 90 | hitSlop={6} |
| 73 | onPress={() => props.onRemove(image.id)} | 91 | onPress={() => props.onRemove(attachment.id)} |
| 74 | > | 92 | > |
| 75 | <SymbolView | 93 | <SymbolView |
| 76 | name="xmark" | 94 | name="xmark" |
apps/mobile/src/connection/platform.ts+1-1| ... | @@ -29,7 +29,7 @@ import { authClientMetadata } from "../lib/authClientMetadata"; | ... | @@ -29,7 +29,7 @@ import { authClientMetadata } from "../lib/authClientMetadata"; |
| 29 | import * as Runtime from "../lib/runtime"; | 29 | import * as Runtime from "../lib/runtime"; |
| 30 | import * as MobileStorage from "../persistence/mobile-storage"; | 30 | import * as MobileStorage from "../persistence/mobile-storage"; |
| 31 | import { appAtomRegistry } from "../state/atom-registry"; | 31 | import { appAtomRegistry } from "../state/atom-registry"; |
| 32 | import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; | 32 | import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; |
| 33 | import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; | 33 | import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; |
| 34 | import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; | 34 | import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; |
| 35 | import { connectionStorageLayer } from "./storage"; | 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,7 +2,7 @@ import { useNavigation } from "@react-navigation/native"; |
| 2 | import { useCallback } from "react"; | 2 | import { useCallback } from "react"; |
| 3 | import { Alert } from "react-native"; | 3 | import { Alert } from "react-native"; |
| 4 | 4 | ||
| 5 | import { removeThreadOutboxMessage } from "../../state/thread-outbox"; | 5 | import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; |
| 6 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 6 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 7 | import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox"; | 7 | import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox"; |
| 8 | 8 |
apps/mobile/src/features/sharing/IncomingShareProvider.tsx+52-12| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import Constants from "expo-constants"; | 1 | import Constants from "expo-constants"; |
| 2 | import * as Crypto from "expo-crypto"; | 2 | import * as Crypto from "expo-crypto"; |
| 3 | import { PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts"; | ||
| 3 | import { | 4 | import { |
| 4 | clearSharedPayloads, | 5 | clearSharedPayloads, |
| 5 | getResolvedSharedPayloadsAsync, | 6 | getResolvedSharedPayloadsAsync, |
| ... | @@ -12,11 +13,13 @@ import { Alert, AppState, Platform } from "react-native"; | ... | @@ -12,11 +13,13 @@ import { Alert, AppState, Platform } from "react-native"; |
| 12 | 13 | ||
| 13 | import { | 14 | import { |
| 14 | buildIncomingShareDraft, | 15 | buildIncomingShareDraft, |
| 16 | isShareFileUriUnderOwnedRoots, | ||
| 15 | type IncomingShareDestination, | 17 | type IncomingShareDestination, |
| 16 | type IncomingShareDraft, | 18 | type IncomingShareDraft, |
| 17 | } from "./incoming-share-model"; | 19 | } from "./incoming-share-model"; |
| 18 | import { createIncomingSharePayloadReader } from "./incoming-share-native"; | 20 | import { createIncomingSharePayloadReader } from "./incoming-share-native"; |
| 19 | import { IncomingShareInbox } from "./incoming-share-inbox"; | 21 | import { IncomingShareInbox } from "./incoming-share-inbox"; |
| 22 | import { persistComposerAttachmentFile } from "../../lib/composerImages"; | ||
| 20 | import { | 23 | import { |
| 21 | loadIncomingShareDrafts, | 24 | loadIncomingShareDrafts, |
| 22 | removeIncomingShareDraft, | 25 | removeIncomingShareDraft, |
| ... | @@ -54,7 +57,7 @@ const getIncomingSharePayloads = createIncomingSharePayloadReader({ | ... | @@ -54,7 +57,7 @@ const getIncomingSharePayloads = createIncomingSharePayloadReader({ |
| 54 | readPayloads: getSharedPayloads, | 57 | readPayloads: getSharedPayloads, |
| 55 | }); | 58 | }); |
| 56 | 59 | ||
| 57 | async function resolvedPayloadsForImages(): Promise<ReadonlyArray<ResolvedSharePayload>> { | 60 | async function resolvedPayloadsForFiles(): Promise<ReadonlyArray<ResolvedSharePayload>> { |
| 58 | try { | 61 | try { |
| 59 | return await getResolvedSharedPayloadsAsync(); | 62 | return await getResolvedSharedPayloadsAsync(); |
| 60 | } catch (error) { | 63 | } catch (error) { |
| ... | @@ -84,12 +87,29 @@ async function readBase64(uri: string): Promise<string> { | ... | @@ -84,12 +87,29 @@ async function readBase64(uri: string): Promise<string> { |
| 84 | return new File(uri).base64(); | 87 | return new File(uri).base64(); |
| 85 | } | 88 | } |
| 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 | async function removeOwnedFile(uri: string): Promise<void> { | 95 | async function removeOwnedFile(uri: string): Promise<void> { |
| 88 | if (!uri.startsWith("file:")) { | 96 | if (!uri.startsWith("file:")) { |
| 89 | return; | 97 | return; |
| 90 | } | 98 | } |
| 91 | try { | 99 | try { |
| 92 | const { File } = await import("expo-file-system"); | 100 | const { File, Paths } = await import("expo-file-system"); |
| 101 | // Only delete files in directories this app owns: its documents and cache | ||
| 102 | // sandbox and its share-extension App Group container. An iOS | ||
| 103 | // open-in-place share points at the sender's own storage; deleting that | ||
| 104 | // URI would destroy the user's document. | ||
| 105 | const ownedRootUris = [ | ||
| 106 | Paths.document.uri, | ||
| 107 | Paths.cache.uri, | ||
| 108 | ...Object.values(Paths.appleSharedContainers ?? {}).map((directory) => directory.uri), | ||
| 109 | ]; | ||
| 110 | if (!isShareFileUriUnderOwnedRoots(uri, ownedRootUris)) { | ||
| 111 | return; | ||
| 112 | } | ||
| 93 | const file = new File(uri); | 113 | const file = new File(uri); |
| 94 | if (file.exists) { | 114 | if (file.exists) { |
| 95 | file.delete(); | 115 | file.delete(); |
| ... | @@ -99,21 +119,23 @@ async function removeOwnedFile(uri: string): Promise<void> { | ... | @@ -99,21 +119,23 @@ async function removeOwnedFile(uri: string): Promise<void> { |
| 99 | } | 119 | } |
| 100 | } | 120 | } |
| 101 | 121 | ||
| 102 | async function removeReplayedImagePayloadFiles( | 122 | async function removeReplayedPayloadFiles(payloads: ReadonlyArray<SharePayload>): Promise<void> { |
| 103 | payloads: ReadonlyArray<SharePayload>, | ||
| 104 | ): Promise<void> { | ||
| 105 | const uris = new Set<string>(); | 123 | const uris = new Set<string>(); |
| 106 | for (const payload of payloads) { | 124 | for (const payload of payloads) { |
| 107 | if (payload.shareType === "image") { | 125 | if (["image", "file", "audio", "video"].includes(payload.shareType)) { |
| 108 | uris.add(payload.value); | 126 | uris.add(payload.value); |
| 109 | } | 127 | } |
| 110 | } | 128 | } |
| 111 | if (uris.size === 0) { | 129 | if (uris.size === 0) { |
| 112 | return; | 130 | return; |
| 113 | } | 131 | } |
| 114 | const resolvedPayloads = await resolvedPayloadsForImages(); | 132 | const resolvedPayloads = payloads.some((payload) => |
| 133 | ["file", "audio", "video"].includes(payload.shareType), | ||
| 134 | ) | ||
| 135 | ? [] | ||
| 136 | : await resolvedPayloadsForFiles(); | ||
| 115 | for (const payload of resolvedPayloads) { | 137 | for (const payload of resolvedPayloads) { |
| 116 | if (payload.shareType === "image" && payload.contentUri) { | 138 | if (["image", "file", "audio", "video"].includes(payload.shareType) && payload.contentUri) { |
| 117 | uris.add(payload.contentUri); | 139 | uris.add(payload.contentUri); |
| 118 | } | 140 | } |
| 119 | } | 141 | } |
| ... | @@ -131,14 +153,29 @@ const incomingShareInbox = new IncomingShareInbox({ | ... | @@ -131,14 +153,29 @@ const incomingShareInbox = new IncomingShareInbox({ |
| 131 | clearPayloads: clearSharedPayloads, | 153 | clearPayloads: clearSharedPayloads, |
| 132 | buildDraft: async ({ payloads, id, createdAt }) => { | 154 | buildDraft: async ({ payloads, id, createdAt }) => { |
| 133 | const cleanupUris = new Set<string>(); | 155 | const cleanupUris = new Set<string>(); |
| 134 | const resolvedPayloads = payloads.some((payload) => payload.shareType === "image") | 156 | const persistedUris = new Set<string>(); |
| 135 | ? await resolvedPayloadsForImages() | 157 | const hasGenericFilePayload = payloads.some((payload) => |
| 136 | : []; | 158 | ["file", "audio", "video"].includes(payload.shareType), |
| 159 | ); | ||
| 160 | const resolvedPayloads = | ||
| 161 | !hasGenericFilePayload && payloads.some((payload) => payload.shareType === "image") | ||
| 162 | ? await resolvedPayloadsForFiles() | ||
| 163 | : []; | ||
| 137 | const draft = await buildIncomingShareDraft({ | 164 | const draft = await buildIncomingShareDraft({ |
| 138 | payloads, | 165 | payloads, |
| 139 | resolvedPayloads, | 166 | resolvedPayloads, |
| 140 | fileReader: { | 167 | fileReader: { |
| 141 | readBase64, | 168 | readBase64, |
| 169 | persistFile: async (uri, name) => { | ||
| 170 | const persistedUri = await persistComposerAttachmentFile( | ||
| 171 | uri, | ||
| 172 | name, | ||
| 173 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, | ||
| 174 | ); | ||
| 175 | persistedUris.add(persistedUri); | ||
| 176 | return persistedUri; | ||
| 177 | }, | ||
| 178 | readSize: readFileSize, | ||
| 142 | removeOwnedFile: (uri) => { | 179 | removeOwnedFile: (uri) => { |
| 143 | cleanupUris.add(uri); | 180 | cleanupUris.add(uri); |
| 144 | }, | 181 | }, |
| ... | @@ -151,9 +188,12 @@ const incomingShareInbox = new IncomingShareInbox({ | ... | @@ -151,9 +188,12 @@ const incomingShareInbox = new IncomingShareInbox({ |
| 151 | cleanup: async () => { | 188 | cleanup: async () => { |
| 152 | await Promise.all([...cleanupUris].map(removeOwnedFile)); | 189 | await Promise.all([...cleanupUris].map(removeOwnedFile)); |
| 153 | }, | 190 | }, |
| 191 | rollback: async () => { | ||
| 192 | await Promise.all([...persistedUris].map(removeOwnedFile)); | ||
| 193 | }, | ||
| 154 | }; | 194 | }; |
| 155 | }, | 195 | }, |
| 156 | cleanupReplayedPayloads: removeReplayedImagePayloadFiles, | 196 | cleanupReplayedPayloads: removeReplayedPayloadFiles, |
| 157 | idForPayloads: incomingShareIdForPayloads, | 197 | idForPayloads: incomingShareIdForPayloads, |
| 158 | now: () => new Date().toISOString(), | 198 | now: () => new Date().toISOString(), |
| 159 | onClearError: (error) => { | 199 | onClearError: (error) => { |
apps/mobile/src/features/sharing/incoming-share-inbox.test.ts+3| ... | @@ -136,11 +136,13 @@ describe("IncomingShareInbox", () => { | ... | @@ -136,11 +136,13 @@ describe("IncomingShareInbox", () => { |
| 136 | it("does not acknowledge a supported payload when its durable write fails", async () => { | 136 | it("does not acknowledge a supported payload when its durable write fails", async () => { |
| 137 | const clearPayloads = vi.fn(); | 137 | const clearPayloads = vi.fn(); |
| 138 | const cleanup = vi.fn(async () => undefined); | 138 | const cleanup = vi.fn(async () => undefined); |
| 139 | const rollback = vi.fn(async () => undefined); | ||
| 139 | const { inbox } = createHarness({ | 140 | const { inbox } = createHarness({ |
| 140 | clearPayloads, | 141 | clearPayloads, |
| 141 | buildDraft: async ({ id, createdAt }) => ({ | 142 | buildDraft: async ({ id, createdAt }) => ({ |
| 142 | draft: draft(id, createdAt), | 143 | draft: draft(id, createdAt), |
| 143 | cleanup, | 144 | cleanup, |
| 145 | rollback, | ||
| 144 | }), | 146 | }), |
| 145 | writeDraft: async () => { | 147 | writeDraft: async () => { |
| 146 | throw new Error("disk full"); | 148 | throw new Error("disk full"); |
| ... | @@ -150,6 +152,7 @@ describe("IncomingShareInbox", () => { | ... | @@ -150,6 +152,7 @@ describe("IncomingShareInbox", () => { |
| 150 | await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full"); | 152 | await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full"); |
| 151 | expect(clearPayloads).not.toHaveBeenCalled(); | 153 | expect(clearPayloads).not.toHaveBeenCalled(); |
| 152 | expect(cleanup).not.toHaveBeenCalled(); | 154 | expect(cleanup).not.toHaveBeenCalled(); |
| 155 | expect(rollback).toHaveBeenCalledOnce(); | ||
| 153 | }); | 156 | }); |
| 154 | 157 | ||
| 155 | it("durably reserves a share for one project before draft import", async () => { | 158 | it("durably reserves a share for one project before draft import", async () => { |
apps/mobile/src/features/sharing/incoming-share-inbox.ts+9-1| ... | @@ -20,6 +20,7 @@ export interface IncomingShareInboxDependencies { | ... | @@ -20,6 +20,7 @@ export interface IncomingShareInboxDependencies { |
| 20 | }) => Promise<{ | 20 | }) => Promise<{ |
| 21 | readonly draft: IncomingShareDraft; | 21 | readonly draft: IncomingShareDraft; |
| 22 | readonly cleanup: () => Promise<void>; | 22 | readonly cleanup: () => Promise<void>; |
| 23 | readonly rollback?: () => Promise<void>; | ||
| 23 | }>; | 24 | }>; |
| 24 | readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>; | 25 | readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>; |
| 25 | readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>; | 26 | readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>; |
| ... | @@ -116,7 +117,14 @@ export class IncomingShareInbox { | ... | @@ -116,7 +117,14 @@ export class IncomingShareInbox { |
| 116 | // The durable inbox write is the transaction boundary. Never clear the | 117 | // The durable inbox write is the transaction boundary. Never clear the |
| 117 | // native handoff first: a process termination must leave one recoverable | 118 | // native handoff first: a process termination must leave one recoverable |
| 118 | // copy on one side of the boundary. | 119 | // copy on one side of the boundary. |
| 119 | await this.dependencies.writeDraft(draft); | 120 | try { |
| 121 | await this.dependencies.writeDraft(draft); | ||
| 122 | } catch (error) { | ||
| 123 | if (built.rollback) { | ||
| 124 | await this.cleanup(built.rollback); | ||
| 125 | } | ||
| 126 | throw error; | ||
| 127 | } | ||
| 120 | await this.cleanup(built.cleanup); | 128 | await this.cleanup(built.cleanup); |
| 121 | this.clearNativePayloads(); | 129 | this.clearNativePayloads(); |
| 122 | return sortAndDedupeIncomingShares([draft, ...persisted]); | 130 | return sortAndDedupeIncomingShares([draft, ...persisted]); |
apps/mobile/src/features/sharing/incoming-share-model.test.ts+518-1| ... | @@ -1,11 +1,18 @@ | ... | @@ -1,11 +1,18 @@ |
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | 1 | import { describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import { | 2 | import { |
| 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 4 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, | ||
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 5 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; | 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 | describe("incoming native shares", () => { | 17 | describe("incoming native shares", () => { |
| 11 | it("converts shared text, URLs, and images into a durable composer draft", async () => { | 18 | it("converts shared text, URLs, and images into a durable composer draft", async () => { |
| ... | @@ -96,6 +103,411 @@ describe("incoming native shares", () => { | ... | @@ -96,6 +103,411 @@ describe("incoming native shares", () => { |
| 96 | expect(hasIncomingShareContent(result)).toBe(false); | 103 | expect(hasIncomingShareContent(result)).toBe(false); |
| 97 | }); | 104 | }); |
| 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 | it("releases every temporary file when a share exceeds the attachment limit", async () => { | 511 | it("releases every temporary file when a share exceeds the attachment limit", async () => { |
| 100 | const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ | 512 | const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ |
| 101 | shareType: "image" as const, | 513 | shareType: "image" as const, |
| ... | @@ -193,4 +605,109 @@ describe("incoming native shares", () => { | ... | @@ -193,4 +605,109 @@ describe("incoming native shares", () => { |
| 193 | expect(result.attachments).toHaveLength(1); | 605 | expect(result.attachments).toHaveLength(1); |
| 194 | expect(result.warnings).toEqual([]); | 606 | expect(result.warnings).toEqual([]); |
| 195 | }); | 607 | }); |
| 608 | |||
| 609 | it("releases a persisted copy when a later step fails to read it", async () => { | ||
| 610 | const file: SharePayload = { | ||
| 611 | shareType: "file", | ||
| 612 | value: "content://shared/report", | ||
| 613 | mimeType: "application/pdf", | ||
| 614 | }; | ||
| 615 | const persistedUri = "file:///documents/t3-composer-attachments/report.pdf"; | ||
| 616 | const removeOwnedFile = vi.fn(async (_uri: string) => undefined); | ||
| 617 | |||
| 618 | const result = await buildIncomingShareDraft({ | ||
| 619 | id: "share-persist-leak", | ||
| 620 | createdAt: "2026-07-16T08:00:00.000Z", | ||
| 621 | payloads: [file], | ||
| 622 | resolvedPayloads: [], | ||
| 623 | fileReader: { | ||
| 624 | readBase64: async () => "unused", | ||
| 625 | persistFile: async () => persistedUri, | ||
| 626 | readSize: async (uri) => { | ||
| 627 | if (uri === persistedUri) { | ||
| 628 | throw new Error("read failed"); | ||
| 629 | } | ||
| 630 | return null; | ||
| 631 | }, | ||
| 632 | removeOwnedFile, | ||
| 633 | }, | ||
| 634 | }); | ||
| 635 | |||
| 636 | expect(result.attachments).toEqual([]); | ||
| 637 | expect(result.warnings).toEqual(["read failed"]); | ||
| 638 | expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri); | ||
| 639 | }); | ||
| 640 | }); | ||
| 641 | |||
| 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,13 +1,18 @@ |
| 1 | import { | ||
| 2 | clampFileAttachmentUploadBytes, | ||
| 3 | fileAttachmentTooLargeMessage, | ||
| 4 | } from "@t3tools/client-runtime/state/attachments"; | ||
| 1 | import { | 5 | import { |
| 2 | isProviderSendTurnSupportedImageMimeType, | 6 | isProviderSendTurnSupportedImageMimeType, |
| 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 7 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 8 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, | ||
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 9 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 5 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 6 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 7 | import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; | 12 | import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; |
| 8 | 13 | ||
| 9 | import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema"; | 14 | import { DraftComposerAttachmentSchema } from "../../lib/composer-image-schema"; |
| 10 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 15 | import type { DraftComposerAttachment } from "../../lib/composerImages"; |
| 11 | import { estimateBase64ByteSize } from "../../lib/base64"; | 16 | import { estimateBase64ByteSize } from "../../lib/base64"; |
| 12 | 17 | ||
| 13 | export interface IncomingShareDraft { | 18 | export interface IncomingShareDraft { |
| ... | @@ -16,7 +21,7 @@ export interface IncomingShareDraft { | ... | @@ -16,7 +21,7 @@ export interface IncomingShareDraft { |
| 16 | readonly createdAt: string; | 21 | readonly createdAt: string; |
| 17 | readonly destination?: IncomingShareDestination; | 22 | readonly destination?: IncomingShareDestination; |
| 18 | readonly text: string; | 23 | readonly text: string; |
| 19 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 24 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 20 | readonly warnings: ReadonlyArray<string>; | 25 | readonly warnings: ReadonlyArray<string>; |
| 21 | } | 26 | } |
| 22 | 27 | ||
| ... | @@ -36,7 +41,7 @@ export const IncomingShareDraftSchema = Schema.Struct({ | ... | @@ -36,7 +41,7 @@ export const IncomingShareDraftSchema = Schema.Struct({ |
| 36 | createdAt: Schema.String, | 41 | createdAt: Schema.String, |
| 37 | destination: Schema.optional(IncomingShareDestinationSchema), | 42 | destination: Schema.optional(IncomingShareDestinationSchema), |
| 38 | text: Schema.String, | 43 | text: Schema.String, |
| 39 | attachments: Schema.Array(DraftComposerImageAttachmentSchema), | 44 | attachments: Schema.Array(DraftComposerAttachmentSchema), |
| 40 | warnings: Schema.Array(Schema.String), | 45 | warnings: Schema.Array(Schema.String), |
| 41 | }); | 46 | }); |
| 42 | 47 | ||
| ... | @@ -46,9 +51,126 @@ export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft { | ... | @@ -46,9 +51,126 @@ export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft { |
| 46 | return decodeIncomingShareDraftSync(value); | 51 | return decodeIncomingShareDraftSync(value); |
| 47 | } | 52 | } |
| 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 | export interface IncomingShareFileReader { | 103 | export interface IncomingShareFileReader { |
| 50 | readonly readBase64: (uri: string) => Promise<string>; | 104 | readonly readBase64: (uri: string) => Promise<string>; |
| 51 | readonly removeOwnedFile: (uri: string) => Promise<void> | void; | 105 | readonly removeOwnedFile: (uri: string) => Promise<void> | void; |
| 106 | readonly persistFile?: (uri: string, name: string) => Promise<string>; | ||
| 107 | readonly readSize?: (uri: string) => Promise<number | null>; | ||
| 108 | } | ||
| 109 | |||
| 110 | /** Apply the destination server's file support after the user chooses a project. */ | ||
| 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 | function sharedText(payloads: ReadonlyArray<SharePayload>): string { | 176 | function sharedText(payloads: ReadonlyArray<SharePayload>): string { |
| ... | @@ -119,8 +241,11 @@ function fallbackName(uri: string, index: number, mimeType: string): string { | ... | @@ -119,8 +241,11 @@ function fallbackName(uri: string, index: number, mimeType: string): string { |
| 119 | } catch { | 241 | } catch { |
| 120 | // Fall through to a deterministic attachment name. | 242 | // Fall through to a deterministic attachment name. |
| 121 | } | 243 | } |
| 122 | const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png"; | 244 | const family = mimeType.split("/")[0]?.toLowerCase(); |
| 123 | return `shared-image-${index + 1}.${extension}`; | 245 | const kind = family === "image" || family === "audio" || family === "video" ? family : "file"; |
| 246 | const extension = | ||
| 247 | mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || (kind === "image" ? "png" : "bin"); | ||
| 248 | return `shared-${kind}-${index + 1}.${extension}`; | ||
| 124 | } | 249 | } |
| 125 | 250 | ||
| 126 | export async function buildIncomingShareDraft(input: { | 251 | export async function buildIncomingShareDraft(input: { |
| ... | @@ -130,13 +255,18 @@ export async function buildIncomingShareDraft(input: { | ... | @@ -130,13 +255,18 @@ export async function buildIncomingShareDraft(input: { |
| 130 | readonly id: string; | 255 | readonly id: string; |
| 131 | readonly createdAt: string; | 256 | readonly createdAt: string; |
| 132 | }): Promise<IncomingShareDraft> { | 257 | }): Promise<IncomingShareDraft> { |
| 133 | const attachments: DraftComposerImageAttachment[] = []; | 258 | const attachments: DraftComposerAttachment[] = []; |
| 134 | const warnings: string[] = []; | 259 | const warnings: string[] = []; |
| 135 | const consumedResolvedPayloadIndexes = new Set<number>(); | 260 | const consumedResolvedPayloadIndexes = new Set<number>(); |
| 136 | let warnedAttachmentLimit = false; | 261 | let warnedAttachmentLimit = false; |
| 137 | 262 | ||
| 138 | for (const [index, payload] of input.payloads.entries()) { | 263 | for (const [index, payload] of input.payloads.entries()) { |
| 139 | if (payload.shareType !== "image") { | 264 | if ( |
| 265 | payload.shareType !== "image" && | ||
| 266 | payload.shareType !== "file" && | ||
| 267 | payload.shareType !== "audio" && | ||
| 268 | payload.shareType !== "video" | ||
| 269 | ) { | ||
| 140 | continue; | 270 | continue; |
| 141 | } | 271 | } |
| 142 | const resolved = resolvedImageFor( | 272 | const resolved = resolvedImageFor( |
| ... | @@ -149,7 +279,7 @@ export async function buildIncomingShareDraft(input: { | ... | @@ -149,7 +279,7 @@ export async function buildIncomingShareDraft(input: { |
| 149 | if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | 279 | if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { |
| 150 | if (!warnedAttachmentLimit) { | 280 | if (!warnedAttachmentLimit) { |
| 151 | warnings.push( | 281 | warnings.push( |
| 152 | `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, | 282 | `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared ${payload.shareType === "image" ? "images" : "files"} were attached.`, |
| 153 | ); | 283 | ); |
| 154 | warnedAttachmentLimit = true; | 284 | warnedAttachmentLimit = true; |
| 155 | } | 285 | } |
| ... | @@ -157,7 +287,100 @@ export async function buildIncomingShareDraft(input: { | ... | @@ -157,7 +287,100 @@ export async function buildIncomingShareDraft(input: { |
| 157 | continue; | 287 | continue; |
| 158 | } | 288 | } |
| 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 | if (!uri || !mimeType.startsWith("image/")) { | 384 | if (!uri || !mimeType.startsWith("image/")) { |
| 162 | warnings.push("One shared item was not a supported image."); | 385 | warnings.push("One shared item was not a supported image."); |
| 163 | await releaseOwnedFiles(input.fileReader, [uri, payload.value]); | 386 | await releaseOwnedFiles(input.fileReader, [uri, payload.value]); |
apps/mobile/src/features/sharing/incoming-share-storage.test.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 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,7 +33,9 @@ async function getFile(shareId: string) { |
| 33 | return new File(await getDirectory(), fileName(shareId)); | 33 | return new File(await getDirectory(), fileName(shareId)); |
| 34 | } | 34 | } |
| 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 | try { | 39 | try { |
| 38 | const { File } = await import("expo-file-system"); | 40 | const { File } = await import("expo-file-system"); |
| 39 | const drafts: IncomingShareDraft[] = []; | 41 | const drafts: IncomingShareDraft[] = []; |
| ... | @@ -44,14 +46,18 @@ export async function loadIncomingShareDrafts(): Promise<ReadonlyArray<IncomingS | ... | @@ -44,14 +46,18 @@ export async function loadIncomingShareDrafts(): Promise<ReadonlyArray<IncomingS |
| 44 | try { | 46 | try { |
| 45 | drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); | 47 | drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); |
| 46 | } catch (cause) { | 48 | } catch (cause) { |
| 47 | console.warn( | 49 | const error = new IncomingShareStorageError({ operation: "load", shareId: null, cause }); |
| 48 | "[incoming-share] ignored invalid persisted share", | 50 | if (options?.strict) { |
| 49 | new IncomingShareStorageError({ operation: "load", shareId: null, cause }), | 51 | throw error; |
| 50 | ); | 52 | } |
| 53 | console.warn("[incoming-share] ignored invalid persisted share", error); | ||
| 51 | } | 54 | } |
| 52 | } | 55 | } |
| 53 | return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); | 56 | return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); |
| 54 | } catch (cause) { | 57 | } catch (cause) { |
| 58 | if (cause instanceof IncomingShareStorageError) { | ||
| 59 | throw cause; | ||
| 60 | } | ||
| 55 | throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); | 61 | throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); |
| 56 | } | 62 | } |
| 57 | } | 63 | } |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+142-23| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | isAtomCommandInterrupted, | 20 | isAtomCommandInterrupted, |
| 21 | squashAtomCommandFailure, | 21 | squashAtomCommandFailure, |
| 22 | } from "@t3tools/client-runtime/state/runtime"; | 22 | } from "@t3tools/client-runtime/state/runtime"; |
| 23 | import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; | ||
| 23 | 24 | ||
| 24 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; | 25 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 25 | import { | 26 | import { |
| ... | @@ -42,20 +43,27 @@ import { | ... | @@ -42,20 +43,27 @@ import { |
| 42 | } from "./use-thread-settings-sheet-presentation"; | 43 | } from "./use-thread-settings-sheet-presentation"; |
| 43 | 44 | ||
| 44 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | 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 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 51 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 47 | import { | 52 | import { |
| 48 | clearComposerDraftContent, | 53 | clearComposerDraftContent, |
| 54 | flushComposerDrafts, | ||
| 49 | getComposerDraftSnapshot, | 55 | getComposerDraftSnapshot, |
| 50 | mergeComposerDraftContent, | 56 | mergeComposerDraftContent, |
| 51 | restoreComposerDraftSnapshot, | 57 | restoreComposerDraftSnapshot, |
| 58 | scheduleUnusedComposerAttachmentCleanup, | ||
| 52 | type ComposerDraft, | 59 | type ComposerDraft, |
| 53 | } from "../../state/use-composer-drafts"; | 60 | } from "../../state/use-composer-drafts"; |
| 54 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; | 61 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; |
| 55 | import { resolveSelectableModelSelection } from "../../lib/modelOptions"; | 62 | import { resolveSelectableModelSelection } from "../../lib/modelOptions"; |
| 56 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; | 63 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 57 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 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 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | 67 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 60 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 68 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 61 | import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; | 69 | import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; |
| ... | @@ -66,6 +74,9 @@ import { | ... | @@ -66,6 +74,9 @@ import { |
| 66 | resolveNewTaskWorkspaceLabel, | 74 | resolveNewTaskWorkspaceLabel, |
| 67 | } from "./new-task-context-presentation"; | 75 | } from "./new-task-context-presentation"; |
| 68 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; | 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 | function NewTaskWorkspaceIcon(props: { | 81 | function NewTaskWorkspaceIcon(props: { |
| 71 | readonly workspaceMode: "local" | "worktree"; | 82 | readonly workspaceMode: "local" | "worktree"; |
| ... | @@ -215,9 +226,18 @@ export function NewTaskDraftScreen(props: { | ... | @@ -215,9 +226,18 @@ export function NewTaskDraftScreen(props: { |
| 215 | ); | 226 | ); |
| 216 | const isProjectPickerReturnActive = | 227 | const isProjectPickerReturnActive = |
| 217 | isReturningToProjectPicker && !requestedInitialProjectAvailable; | 228 | isReturningToProjectPicker && !requestedInitialProjectAvailable; |
| 229 | const isIncomingShareAwaitingServerConfig = Boolean( | ||
| 230 | incomingShare?.attachments.some((attachment) => attachment.type === "file") && | ||
| 231 | selectedEnvironmentServerConfig === null, | ||
| 232 | ); | ||
| 218 | const isIncomingShareTransferPending = Boolean( | 233 | const isIncomingShareTransferPending = Boolean( |
| 219 | incomingShare && cancelledIncomingShareId !== props.incomingShareId, | 234 | incomingShare && |
| 235 | cancelledIncomingShareId !== props.incomingShareId && | ||
| 236 | !isIncomingShareAwaitingServerConfig, | ||
| 220 | ); | 237 | ); |
| 238 | const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; | ||
| 239 | // Also guard while a submit is in flight: an Android back press or iOS | ||
| 240 | // Cancel would otherwise abandon the screen while the task still starts. | ||
| 221 | const composerMenu = useComposerCommandMenu({ | 241 | const composerMenu = useComposerCommandMenu({ |
| 222 | draftMessage: flow.prompt, | 242 | draftMessage: flow.prompt, |
| 223 | environmentId: selectedProject?.environmentId ?? null, | 243 | environmentId: selectedProject?.environmentId ?? null, |
| ... | @@ -227,12 +247,14 @@ export function NewTaskDraftScreen(props: { | ... | @@ -227,12 +247,14 @@ export function NewTaskDraftScreen(props: { |
| 227 | : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null, | 247 | : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null, |
| 228 | selectedProviderStatus: flow.selectedProviderStatus, | 248 | selectedProviderStatus: flow.selectedProviderStatus, |
| 229 | hasThread: false, | 249 | hasThread: false, |
| 230 | enabled: isComposerFocused && !isIncomingShareTransferPending, | 250 | enabled: isComposerFocused && !isComposerInteractionLocked, |
| 231 | onChangeDraftMessage: flow.setPrompt, | 251 | onChangeDraftMessage: flow.setPrompt, |
| 232 | onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, | 252 | onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, |
| 233 | }); | 253 | }); |
| 234 | usePreventRemove( | 254 | usePreventRemove( |
| 235 | (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport, | 255 | (isIncomingShareTransferPending && !isProjectPickerReturnActive) || |
| 256 | isCancellingShareImport || | ||
| 257 | flow.submitting, | ||
| 236 | () => undefined, | 258 | () => undefined, |
| 237 | ); | 259 | ); |
| 238 | const hasImportedIncomingShare = Boolean( | 260 | const hasImportedIncomingShare = Boolean( |
| ... | @@ -446,6 +468,13 @@ export function NewTaskDraftScreen(props: { | ... | @@ -446,6 +468,13 @@ export function NewTaskDraftScreen(props: { |
| 446 | return; | 468 | return; |
| 447 | } | 469 | } |
| 448 | 470 | ||
| 471 | if ( | ||
| 472 | incomingShare.attachments.some((attachment) => attachment.type === "file") && | ||
| 473 | selectedEnvironmentServerConfig === null | ||
| 474 | ) { | ||
| 475 | return; | ||
| 476 | } | ||
| 477 | |||
| 449 | if (alertedUnavailableIncomingShareIdRef.current === shareId) { | 478 | if (alertedUnavailableIncomingShareIdRef.current === shareId) { |
| 450 | alertedUnavailableIncomingShareIdRef.current = null; | 479 | alertedUnavailableIncomingShareIdRef.current = null; |
| 451 | } | 480 | } |
| ... | @@ -455,6 +484,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -455,6 +484,7 @@ export function NewTaskDraftScreen(props: { |
| 455 | shareImportDraftBackupRef.current.set(importKey, draftBackup); | 484 | shareImportDraftBackupRef.current.set(importKey, draftBackup); |
| 456 | const importToken = Symbol(importKey); | 485 | const importToken = Symbol(importKey); |
| 457 | let didReserveShare = false; | 486 | let didReserveShare = false; |
| 487 | let didConsumeShare = false; | ||
| 458 | let needsDraftRestore = false; | 488 | let needsDraftRestore = false; |
| 459 | activeShareImportTokenRef.current = importToken; | 489 | activeShareImportTokenRef.current = importToken; |
| 460 | setImportingShareKey(importKey); | 490 | setImportingShareKey(importKey); |
| ... | @@ -472,10 +502,19 @@ export function NewTaskDraftScreen(props: { | ... | @@ -472,10 +502,19 @@ export function NewTaskDraftScreen(props: { |
| 472 | ) { | 502 | ) { |
| 473 | return; | 503 | return; |
| 474 | } | 504 | } |
| 505 | const selectedAttachments = selectIncomingShareAttachmentsForServer({ | ||
| 506 | attachments: incomingShare.attachments, | ||
| 507 | serverConfig: appAtomRegistry.get( | ||
| 508 | serverEnvironment.configValueAtom(destinationProject.environmentId), | ||
| 509 | ), | ||
| 510 | }); | ||
| 511 | if (selectedAttachments.status === "pending") { | ||
| 512 | throw new Error("Server attachment support is still loading."); | ||
| 513 | } | ||
| 475 | needsDraftRestore = true; | 514 | needsDraftRestore = true; |
| 476 | const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, { | 515 | const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, { |
| 477 | text: incomingShare.text, | 516 | text: incomingShare.text, |
| 478 | attachments: incomingShare.attachments, | 517 | attachments: selectedAttachments.attachments, |
| 479 | sourceShareId: shareId, | 518 | sourceShareId: shareId, |
| 480 | }); | 519 | }); |
| 481 | if ( | 520 | if ( |
| ... | @@ -489,13 +528,25 @@ export function NewTaskDraftScreen(props: { | ... | @@ -489,13 +528,25 @@ export function NewTaskDraftScreen(props: { |
| 489 | return; | 528 | return; |
| 490 | } | 529 | } |
| 491 | await consumeShare(shareId); | 530 | await consumeShare(shareId); |
| 531 | didConsumeShare = true; | ||
| 532 | // The consumed inbox draft was the last owner of files that never made | ||
| 533 | // it into the composer draft (unsupported server, oversize, limit | ||
| 534 | // skips). Release them before any early return: an unmount or a | ||
| 535 | // superseding import must not leak them, and the sweep re-checks | ||
| 536 | // ownership so it cannot delete a file another draft picked up. | ||
| 537 | const retainedAttachmentIds = new Set( | ||
| 538 | getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), | ||
| 539 | ); | ||
| 540 | scheduleUnusedComposerAttachmentCleanup( | ||
| 541 | incomingShare.attachments.filter((attachment) => !retainedAttachmentIds.has(attachment.id)), | ||
| 542 | ); | ||
| 492 | if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { | 543 | if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { |
| 493 | return; | 544 | return; |
| 494 | } | 545 | } |
| 495 | const warnings = [...incomingShare.warnings]; | 546 | const warnings = [...incomingShare.warnings, ...selectedAttachments.warnings]; |
| 496 | if (skippedAttachmentCount > 0) { | 547 | if (skippedAttachmentCount > 0) { |
| 497 | warnings.push( | 548 | warnings.push( |
| 498 | `${skippedAttachmentCount} shared image${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, | 549 | `${skippedAttachmentCount} shared file${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, |
| 499 | ); | 550 | ); |
| 500 | } | 551 | } |
| 501 | if (warnings.length > 0) { | 552 | if (warnings.length > 0) { |
| ... | @@ -526,8 +577,16 @@ export function NewTaskDraftScreen(props: { | ... | @@ -526,8 +577,16 @@ export function NewTaskDraftScreen(props: { |
| 526 | setIsCancellingShareImport(true); | 577 | setIsCancellingShareImport(true); |
| 527 | try { | 578 | try { |
| 528 | if (needsDraftRestore) { | 579 | if (needsDraftRestore) { |
| 580 | // The restore drops the share's merged-in attachments | ||
| 581 | // from the draft. Sweep them only when the inbox entry | ||
| 582 | // was consumed: before that, the inbox still references | ||
| 583 | // these files and must keep them for a later import. | ||
| 584 | const mergedAttachments = getComposerDraftSnapshot(draftKey).attachments; | ||
| 529 | await restoreComposerDraftSnapshot(draftKey, draftBackup); | 585 | await restoreComposerDraftSnapshot(draftKey, draftBackup); |
| 530 | needsDraftRestore = false; | 586 | needsDraftRestore = false; |
| 587 | if (didConsumeShare) { | ||
| 588 | scheduleUnusedComposerAttachmentCleanup(mergedAttachments); | ||
| 589 | } | ||
| 531 | } | 590 | } |
| 532 | if (didReserveShare) { | 591 | if (didReserveShare) { |
| 533 | await releaseShareReservation(shareId, { | 592 | await releaseShareReservation(shareId, { |
| ... | @@ -602,6 +661,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -602,6 +661,7 @@ export function NewTaskDraftScreen(props: { |
| 602 | props.initialProjectRef?.projectId, | 661 | props.initialProjectRef?.projectId, |
| 603 | releaseShareReservation, | 662 | releaseShareReservation, |
| 604 | reserveShare, | 663 | reserveShare, |
| 664 | selectedEnvironmentServerConfig, | ||
| 605 | selectedProject, | 665 | selectedProject, |
| 606 | shareImportAttempt, | 666 | shareImportAttempt, |
| 607 | ]); | 667 | ]); |
| ... | @@ -633,12 +693,47 @@ export function NewTaskDraftScreen(props: { | ... | @@ -633,12 +693,47 @@ export function NewTaskDraftScreen(props: { |
| 633 | const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; | 693 | const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; |
| 634 | 694 | ||
| 635 | async function handlePickImages(): Promise<void> { | 695 | async function handlePickImages(): Promise<void> { |
| 636 | if (isIncomingShareTransferPending) { | 696 | if (isComposerInteractionLocked) { |
| 637 | return; | 697 | return; |
| 638 | } | 698 | } |
| 639 | const result = await pickComposerImages({ existingCount: flow.attachments.length }); | 699 | const result = await pickComposerImages({ existingCount: flow.attachments.length }); |
| 640 | if (result.images.length > 0) { | 700 | const rejectedCount = result.images.length > 0 ? flow.appendAttachments(result.images) : 0; |
| 641 | flow.appendAttachments(result.images); | 701 | const problems = [ |
| 702 | ...(result.error ? [result.error] : []), | ||
| 703 | ...(rejectedCount > 0 | ||
| 704 | ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`] | ||
| 705 | : []), | ||
| 706 | ]; | ||
| 707 | if (problems.length > 0) { | ||
| 708 | Alert.alert("Could not attach photo", problems.join("\n\n")); | ||
| 709 | } | ||
| 710 | } | ||
| 711 | |||
| 712 | async function handlePickFiles(): Promise<void> { | ||
| 713 | if (isComposerInteractionLocked) { | ||
| 714 | return; | ||
| 715 | } | ||
| 716 | const maxBytes = | ||
| 717 | selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments?.maxUploadBytes; | ||
| 718 | if (maxBytes === undefined) { | ||
| 719 | Alert.alert("File attachments are not available on this server."); | ||
| 720 | return; | ||
| 721 | } | ||
| 722 | const result = await pickComposerFiles({ | ||
| 723 | existingCount: flow.attachments.length, | ||
| 724 | maxBytes, | ||
| 725 | }); | ||
| 726 | const rejectedCount = result.files.length > 0 ? flow.appendAttachments(result.files) : 0; | ||
| 727 | // The picker error and the live-cap rejection can both happen in one | ||
| 728 | // pick; report both in a single alert. | ||
| 729 | const problems = [ | ||
| 730 | ...(result.error ? [result.error] : []), | ||
| 731 | ...(rejectedCount > 0 | ||
| 732 | ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`] | ||
| 733 | : []), | ||
| 734 | ]; | ||
| 735 | if (problems.length > 0) { | ||
| 736 | Alert.alert("Could not attach file", problems.join("\n\n")); | ||
| 642 | } | 737 | } |
| 643 | } | 738 | } |
| 644 | 739 | ||
| ... | @@ -693,6 +788,16 @@ export function NewTaskDraftScreen(props: { | ... | @@ -693,6 +788,16 @@ export function NewTaskDraftScreen(props: { |
| 693 | ) { | 788 | ) { |
| 694 | return; | 789 | return; |
| 695 | } | 790 | } |
| 791 | // A failed-send restore can leave the draft over the cap on purpose (it | ||
| 792 | // never drops the user's files); starting anyway would upload everything | ||
| 793 | // and have the server reject the turn. | ||
| 794 | if (draft.attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | ||
| 795 | Alert.alert( | ||
| 796 | "Too many attachments", | ||
| 797 | `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, | ||
| 798 | ); | ||
| 799 | return; | ||
| 800 | } | ||
| 696 | 801 | ||
| 697 | const editingPendingTask = flow.editingPendingTask; | 802 | const editingPendingTask = flow.editingPendingTask; |
| 698 | 803 | ||
| ... | @@ -764,6 +869,10 @@ export function NewTaskDraftScreen(props: { | ... | @@ -764,6 +869,10 @@ export function NewTaskDraftScreen(props: { |
| 764 | interactionMode, | 869 | interactionMode, |
| 765 | initialMessageText, | 870 | initialMessageText, |
| 766 | initialAttachments: draft.attachments, | 871 | initialAttachments: draft.attachments, |
| 872 | onAttachmentsUploaded: async (attachments) => { | ||
| 873 | flow.replaceAttachments(attachments); | ||
| 874 | await flushComposerDrafts(); | ||
| 875 | }, | ||
| 767 | ...(editingPendingTask | 876 | ...(editingPendingTask |
| 768 | ? { | 877 | ? { |
| 769 | turnMetadata: { | 878 | turnMetadata: { |
| ... | @@ -839,7 +948,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -839,7 +948,7 @@ export function NewTaskDraftScreen(props: { |
| 839 | // The context-first screen intentionally opens with the keyboard closed. | 948 | // The context-first screen intentionally opens with the keyboard closed. |
| 840 | // Focusing is a user action, so presenting the form sheet has one motion. | 949 | // Focusing is a user action, so presenting the form sheet has one motion. |
| 841 | autoFocus={false} | 950 | autoFocus={false} |
| 842 | editable={!isIncomingShareTransferPending} | 951 | editable={!isComposerInteractionLocked} |
| 843 | multiline | 952 | multiline |
| 844 | scrollEnabled | 953 | scrollEnabled |
| 845 | value={flow.prompt} | 954 | value={flow.prompt} |
| ... | @@ -873,7 +982,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -873,7 +982,7 @@ export function NewTaskDraftScreen(props: { |
| 873 | navigation.goBack(); | 982 | navigation.goBack(); |
| 874 | }; | 983 | }; |
| 875 | const chooseProject = () => { | 984 | const chooseProject = () => { |
| 876 | if (isIncomingShareTransferPending) { | 985 | if (isComposerInteractionLocked) { |
| 877 | return; | 986 | return; |
| 878 | } | 987 | } |
| 879 | promptInputRef.current?.blur(); | 988 | promptInputRef.current?.blur(); |
| ... | @@ -881,7 +990,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -881,7 +990,7 @@ export function NewTaskDraftScreen(props: { |
| 881 | navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId })); | 990 | navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId })); |
| 882 | }; | 991 | }; |
| 883 | const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => { | 992 | const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => { |
| 884 | if (isIncomingShareTransferPending) { | 993 | if (isComposerInteractionLocked) { |
| 885 | return; | 994 | return; |
| 886 | } | 995 | } |
| 887 | promptInputRef.current?.blur(); | 996 | promptInputRef.current?.blur(); |
| ... | @@ -901,7 +1010,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -901,7 +1010,7 @@ export function NewTaskDraftScreen(props: { |
| 901 | accessibilityHint="Opens the project picker" | 1010 | accessibilityHint="Opens the project picker" |
| 902 | accessibilityLabel={`Change project from ${selectedProject.title}`} | 1011 | accessibilityLabel={`Change project from ${selectedProject.title}`} |
| 903 | accessibilityRole="button" | 1012 | accessibilityRole="button" |
| 904 | disabled={isIncomingShareTransferPending} | 1013 | disabled={isComposerInteractionLocked} |
| 905 | onPress={chooseProject} | 1014 | onPress={chooseProject} |
| 906 | className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65" | 1015 | className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65" |
| 907 | > | 1016 | > |
| ... | @@ -919,7 +1028,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -919,7 +1028,7 @@ export function NewTaskDraftScreen(props: { |
| 919 | <ComposerInlineControl | 1028 | <ComposerInlineControl |
| 920 | accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} | 1029 | accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} |
| 921 | chevronDirection="right" | 1030 | chevronDirection="right" |
| 922 | disabled={isIncomingShareTransferPending} | 1031 | disabled={isComposerInteractionLocked} |
| 923 | icon="desktopcomputer" | 1032 | icon="desktopcomputer" |
| 924 | label={`on ${selectedEnvironmentLabel}`} | 1033 | label={`on ${selectedEnvironmentLabel}`} |
| 925 | maxWidth={260} | 1034 | maxWidth={260} |
| ... | @@ -954,7 +1063,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -954,7 +1063,7 @@ export function NewTaskDraftScreen(props: { |
| 954 | <ComposerInlineControl | 1063 | <ComposerInlineControl |
| 955 | accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} | 1064 | accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} |
| 956 | accessibilityLabel={workspaceLabel} | 1065 | accessibilityLabel={workspaceLabel} |
| 957 | disabled={isIncomingShareTransferPending} | 1066 | disabled={isComposerInteractionLocked} |
| 958 | iconNode={ | 1067 | iconNode={ |
| 959 | <NewTaskWorkspaceIcon | 1068 | <NewTaskWorkspaceIcon |
| 960 | workspaceMode={flow.workspaceMode} | 1069 | workspaceMode={flow.workspaceMode} |
| ... | @@ -970,7 +1079,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -970,7 +1079,7 @@ export function NewTaskDraftScreen(props: { |
| 970 | <ComposerInlineControl | 1079 | <ComposerInlineControl |
| 971 | accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`} | 1080 | accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`} |
| 972 | chevronDirection="right" | 1081 | chevronDirection="right" |
| 973 | disabled={isIncomingShareTransferPending} | 1082 | disabled={isComposerInteractionLocked} |
| 974 | icon="arrow.triangle.branch" | 1083 | icon="arrow.triangle.branch" |
| 975 | label={showBranchLoading ? "Loading branches…" : selectedBranchLabel} | 1084 | label={showBranchLoading ? "Loading branches…" : selectedBranchLabel} |
| 976 | maxWidth={190} | 1085 | maxWidth={190} |
| ... | @@ -1010,7 +1119,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1010,7 +1119,7 @@ export function NewTaskDraftScreen(props: { |
| 1010 | attachments={flow.attachments} | 1119 | attachments={flow.attachments} |
| 1011 | imageBorderRadius={16} | 1120 | imageBorderRadius={16} |
| 1012 | imageSize={72} | 1121 | imageSize={72} |
| 1013 | onRemove={isIncomingShareTransferPending ? () => undefined : flow.removeAttachment} | 1122 | onRemove={isComposerInteractionLocked ? () => undefined : flow.removeAttachment} |
| 1014 | /> | 1123 | /> |
| 1015 | </View> | 1124 | </View> |
| 1016 | ) : null} | 1125 | ) : null} |
| ... | @@ -1021,14 +1130,24 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1021,14 +1130,24 @@ export function NewTaskDraftScreen(props: { |
| 1021 | <ComposerToolbarScroller contentPaddingRight={8} fadeSurface="sheet"> | 1130 | <ComposerToolbarScroller contentPaddingRight={8} fadeSurface="sheet"> |
| 1022 | <ComposerToolbarButton | 1131 | <ComposerToolbarButton |
| 1023 | accessibilityLabel="Add attachment" | 1132 | accessibilityLabel="Add attachment" |
| 1024 | disabled={isIncomingShareTransferPending} | 1133 | disabled={isComposerInteractionLocked} |
| 1025 | icon="plus" | 1134 | icon="plus" |
| 1026 | onPress={() => void handlePickImages()} | 1135 | onPress={() => { |
| 1136 | if (selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments) { | ||
| 1137 | Alert.alert("Add attachment", undefined, [ | ||
| 1138 | { text: "Photos", onPress: () => void handlePickImages() }, | ||
| 1139 | { text: "Files", onPress: () => void handlePickFiles() }, | ||
| 1140 | { text: "Cancel", style: "cancel" }, | ||
| 1141 | ]); | ||
| 1142 | return; | ||
| 1143 | } | ||
| 1144 | void handlePickImages(); | ||
| 1145 | }} | ||
| 1027 | showChevron={false} | 1146 | showChevron={false} |
| 1028 | /> | 1147 | /> |
| 1029 | <ComposerInlineControl | 1148 | <ComposerInlineControl |
| 1030 | accessibilityLabel="Model and reasoning settings" | 1149 | accessibilityLabel="Model and reasoning settings" |
| 1031 | disabled={isIncomingShareTransferPending} | 1150 | disabled={isComposerInteractionLocked} |
| 1032 | emphasized | 1151 | emphasized |
| 1033 | iconNode={ | 1152 | iconNode={ |
| 1034 | <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} /> | 1153 | <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} /> |
| ... | @@ -1041,7 +1160,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1041,7 +1160,7 @@ export function NewTaskDraftScreen(props: { |
| 1041 | <ComposerInlineControl | 1160 | <ComposerInlineControl |
| 1042 | accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`} | 1161 | accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`} |
| 1043 | accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`} | 1162 | accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`} |
| 1044 | disabled={isIncomingShareTransferPending} | 1163 | disabled={isComposerInteractionLocked} |
| 1045 | emphasized | 1164 | emphasized |
| 1046 | icon={ | 1165 | icon={ |
| 1047 | flow.interactionMode === "plan" | 1166 | flow.interactionMode === "plan" |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+2-2| ... | @@ -99,8 +99,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -99,8 +99,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 99 | ? incomingShare.attachments.length === 0 | 99 | ? incomingShare.attachments.length === 0 |
| 100 | ? "Choose a project for what you shared" | 100 | ? "Choose a project for what you shared" |
| 101 | : incomingShare.attachments.length === 1 | 101 | : incomingShare.attachments.length === 1 |
| 102 | ? "Choose a project for the image you shared" | 102 | ? `Choose a project for the ${incomingShare.attachments[0]?.type === "image" ? "image" : "file"} you shared` |
| 103 | : `Choose a project for the ${incomingShare.attachments.length} images you shared` | 103 | : `Choose a project for the ${incomingShare.attachments.length} ${incomingShare.attachments.every((attachment) => attachment.type === "image") ? "images" : "files"} you shared` |
| 104 | : null; | 104 | : null; |
| 105 | const screenTitle = incomingShare ? "Start a task" : "Choose project"; | 105 | const screenTitle = incomingShare ? "Start a task" : "Choose project"; |
| 106 | const projectEmptyState = deriveProjectEmptyState(catalogState); | 106 | const projectEmptyState = deriveProjectEmptyState(catalogState); |
apps/mobile/src/features/threads/ThreadComposer.tsx+45-13| ... | @@ -19,7 +19,15 @@ import { | ... | @@ -19,7 +19,15 @@ import { |
| 19 | useState, | 19 | useState, |
| 20 | type RefObject, | 20 | type RefObject, |
| 21 | } from "react"; | 21 | } from "react"; |
| 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 | import ImageViewing from "react-native-image-viewing"; | 31 | import ImageViewing from "react-native-image-viewing"; |
| 24 | import Animated, { | 32 | import Animated, { |
| 25 | FadeIn, | 33 | FadeIn, |
| ... | @@ -36,6 +44,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re | ... | @@ -36,6 +44,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re |
| 36 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 44 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 37 | 45 | ||
| 38 | import { AppText as Text } from "../../components/AppText"; | 46 | import { AppText as Text } from "../../components/AppText"; |
| 47 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 39 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 48 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 40 | import { GlassSurface } from "../../components/GlassSurface"; | 49 | import { GlassSurface } from "../../components/GlassSurface"; |
| 41 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; | 50 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; |
| ... | @@ -46,7 +55,7 @@ import { | ... | @@ -46,7 +55,7 @@ import { |
| 46 | } from "../../components/ComposerToolbar"; | 55 | } from "../../components/ComposerToolbar"; |
| 47 | import { ControlPill } from "../../components/ControlPill"; | 56 | import { ControlPill } from "../../components/ControlPill"; |
| 48 | import { ProviderIcon } from "../../components/ProviderIcon"; | 57 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 49 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 58 | import type { DraftComposerAttachment } from "../../lib/composerImages"; |
| 50 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 59 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 51 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 60 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 52 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 61 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| ... | @@ -76,7 +85,7 @@ export const COMPOSER_EXPANDED_CHROME = 156; | ... | @@ -76,7 +85,7 @@ export const COMPOSER_EXPANDED_CHROME = 156; |
| 76 | 85 | ||
| 77 | export interface ThreadComposerProps { | 86 | export interface ThreadComposerProps { |
| 78 | readonly draftMessage: string; | 87 | readonly draftMessage: string; |
| 79 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 88 | readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; |
| 80 | readonly placeholder: string; | 89 | readonly placeholder: string; |
| 81 | readonly contentMaxWidth?: number; | 90 | readonly contentMaxWidth?: number; |
| 82 | readonly bottomInset?: number; | 91 | readonly bottomInset?: number; |
| ... | @@ -97,6 +106,7 @@ export interface ThreadComposerProps { | ... | @@ -97,6 +106,7 @@ export interface ThreadComposerProps { |
| 97 | readonly editorRef?: RefObject<ComposerEditorHandle | null>; | 106 | readonly editorRef?: RefObject<ComposerEditorHandle | null>; |
| 98 | readonly onChangeDraftMessage: (value: string) => void; | 107 | readonly onChangeDraftMessage: (value: string) => void; |
| 99 | readonly onPickDraftImages: () => Promise<void>; | 108 | readonly onPickDraftImages: () => Promise<void>; |
| 109 | readonly onPickDraftFiles: () => Promise<void>; | ||
| 100 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 110 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 101 | readonly onRemoveDraftImage: (imageId: string) => void; | 111 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 102 | readonly onStopThread: () => void; | 112 | readonly onStopThread: () => void; |
| ... | @@ -597,15 +607,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -597,15 +607,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 597 | </View> | 607 | </View> |
| 598 | {!isExpanded && props.draftAttachments.length > 0 ? ( | 608 | {!isExpanded && props.draftAttachments.length > 0 ? ( |
| 599 | <View className="flex-row gap-1 pl-1"> | 609 | <View className="flex-row gap-1 pl-1"> |
| 600 | {props.draftAttachments.slice(0, 3).map((image) => ( | 610 | {props.draftAttachments.slice(0, 3).map((attachment) => |
| 601 | <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}> | 611 | attachment.type === "image" ? ( |
| 602 | <Image | 612 | <Pressable |
| 603 | source={{ uri: image.previewUri }} | 613 | key={attachment.id} |
| 604 | className="size-[30px] rounded-lg bg-subtle" | 614 | onPress={() => onPressImage(attachment.previewUri)} |
| 605 | resizeMode="cover" | 615 | > |
| 606 | /> | 616 | <Image |
| 607 | </Pressable> | 617 | source={{ uri: attachment.previewUri }} |
| 608 | ))} | 618 | className="size-[30px] rounded-lg bg-subtle" |
| 619 | resizeMode="cover" | ||
| 620 | /> | ||
| 621 | </Pressable> | ||
| 622 | ) : ( | ||
| 623 | <View | ||
| 624 | key={attachment.id} | ||
| 625 | className="size-[30px] items-center justify-center rounded-lg bg-subtle" | ||
| 626 | > | ||
| 627 | <SymbolView name="doc.text" size={15} tintColor="#a3a3a3" type="monochrome" /> | ||
| 628 | </View> | ||
| 629 | ), | ||
| 630 | )} | ||
| 609 | {props.draftAttachments.length > 3 ? ( | 631 | {props.draftAttachments.length > 3 ? ( |
| 610 | <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong"> | 632 | <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong"> |
| 611 | <Text className="text-foreground-muted text-2xs font-t3-bold"> | 633 | <Text className="text-foreground-muted text-2xs font-t3-bold"> |
| ... | @@ -650,7 +672,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -650,7 +672,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 650 | <ComposerToolbarButton | 672 | <ComposerToolbarButton |
| 651 | accessibilityLabel="Add attachment" | 673 | accessibilityLabel="Add attachment" |
| 652 | icon="plus" | 674 | icon="plus" |
| 653 | onPress={() => void props.onPickDraftImages()} | 675 | onPress={() => { |
| 676 | if (props.serverConfig?.environment.capabilities.fileAttachments) { | ||
| 677 | Alert.alert("Add attachment", undefined, [ | ||
| 678 | { text: "Photos", onPress: () => void props.onPickDraftImages() }, | ||
| 679 | { text: "Files", onPress: () => void props.onPickDraftFiles() }, | ||
| 680 | { text: "Cancel", style: "cancel" }, | ||
| 681 | ]); | ||
| 682 | return; | ||
| 683 | } | ||
| 684 | void props.onPickDraftImages(); | ||
| 685 | }} | ||
| 654 | showChevron={false} | 686 | showChevron={false} |
| 655 | /> | 687 | /> |
| 656 | <View className="min-w-0 flex-1" style={{ maxWidth: 152 }}> | 688 | <View className="min-w-0 flex-1" style={{ maxWidth: 152 }}> |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4-2| ... | @@ -54,7 +54,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -54,7 +54,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 54 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 54 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 55 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 55 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 56 | import type { StatusTone } from "../../components/StatusPill"; | 56 | import type { StatusTone } from "../../components/StatusPill"; |
| 57 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 57 | import type { DraftComposerAttachment } from "../../lib/composerImages"; |
| 58 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; | 58 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; |
| 59 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | 59 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 60 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 60 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| ... | @@ -101,7 +101,7 @@ export interface ThreadDetailScreenProps { | ... | @@ -101,7 +101,7 @@ export interface ThreadDetailScreenProps { |
| 101 | readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null; | 101 | readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null; |
| 102 | readonly respondingUserInputId: ApprovalRequestId | null; | 102 | readonly respondingUserInputId: ApprovalRequestId | null; |
| 103 | readonly draftMessage: string; | 103 | readonly draftMessage: string; |
| 104 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 104 | readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; |
| 105 | readonly connectionStateLabel: EnvironmentConnectionPhase; | 105 | readonly connectionStateLabel: EnvironmentConnectionPhase; |
| 106 | /** Message sync status for the selected thread (drives the composer status pill). */ | 106 | /** Message sync status for the selected thread (drives the composer status pill). */ |
| 107 | readonly threadSyncStatus?: EnvironmentThreadStatus; | 107 | readonly threadSyncStatus?: EnvironmentThreadStatus; |
| ... | @@ -118,6 +118,7 @@ export interface ThreadDetailScreenProps { | ... | @@ -118,6 +118,7 @@ export interface ThreadDetailScreenProps { |
| 118 | readonly onOpenConnectionEditor: () => void; | 118 | readonly onOpenConnectionEditor: () => void; |
| 119 | readonly onChangeDraftMessage: (value: string) => void; | 119 | readonly onChangeDraftMessage: (value: string) => void; |
| 120 | readonly onPickDraftImages: () => Promise<void>; | 120 | readonly onPickDraftImages: () => Promise<void>; |
| 121 | readonly onPickDraftFiles: () => Promise<void>; | ||
| 121 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 122 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 122 | readonly onRemoveDraftImage: (imageId: string) => void; | 123 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 123 | readonly onStopThread: () => void; | 124 | readonly onStopThread: () => void; |
| ... | @@ -776,6 +777,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -776,6 +777,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 776 | bottomInset={composerBottomInset} | 777 | bottomInset={composerBottomInset} |
| 777 | onChangeDraftMessage={props.onChangeDraftMessage} | 778 | onChangeDraftMessage={props.onChangeDraftMessage} |
| 778 | onPickDraftImages={props.onPickDraftImages} | 779 | onPickDraftImages={props.onPickDraftImages} |
| 780 | onPickDraftFiles={props.onPickDraftFiles} | ||
| 779 | onNativePasteImages={props.onNativePasteImages} | 781 | onNativePasteImages={props.onNativePasteImages} |
| 780 | onRemoveDraftImage={props.onRemoveDraftImage} | 782 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 781 | onStopThread={props.onStopThread} | 783 | onStopThread={props.onStopThread} |
apps/mobile/src/features/threads/ThreadFeed.tsx+154-8| ... | @@ -1,12 +1,23 @@ | ... | @@ -1,12 +1,23 @@ |
| 1 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { type LegendListRef } from "@legendapp/list/react-native"; | 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 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | 16 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; |
| 6 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 17 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 7 | import { SymbolView } from "../../components/AppSymbol"; | 18 | import { SymbolView } from "../../components/AppSymbol"; |
| 8 | import { HeaderHeightContext } from "@react-navigation/elements"; | 19 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 9 | import { useNavigation } from "@react-navigation/native"; | 20 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 10 | import { | 21 | import { |
| 11 | memo, | 22 | memo, |
| 12 | useCallback, | 23 | useCallback, |
| ... | @@ -27,6 +38,7 @@ import { | ... | @@ -27,6 +38,7 @@ import { |
| 27 | } from "react-native-nitro-markdown"; | 38 | } from "react-native-nitro-markdown"; |
| 28 | import { | 39 | import { |
| 29 | ActivityIndicator, | 40 | ActivityIndicator, |
| 41 | Alert, | ||
| 30 | Image, | 42 | Image, |
| 31 | Platform, | 43 | Platform, |
| 32 | type LayoutChangeEvent, | 44 | type LayoutChangeEvent, |
| ... | @@ -57,6 +69,7 @@ import { useFontFamily } from "../../lib/useFontFamily"; | ... | @@ -57,6 +69,7 @@ import { useFontFamily } from "../../lib/useFontFamily"; |
| 57 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 69 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 58 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 70 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 59 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 71 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 72 | import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; | ||
| 60 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; | 73 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 61 | import { | 74 | import { |
| 62 | hasNativeSelectableMarkdownText, | 75 | hasNativeSelectableMarkdownText, |
| ... | @@ -112,7 +125,10 @@ import { | ... | @@ -112,7 +125,10 @@ import { |
| 112 | WORK_GROUP_TOGGLE_HEIGHT, | 125 | WORK_GROUP_TOGGLE_HEIGHT, |
| 113 | } from "./thread-work-log"; | 126 | } from "./thread-work-log"; |
| 114 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | 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 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | 132 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 117 | import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; | 133 | import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; |
| 118 | 134 | ||
| ... | @@ -207,6 +223,122 @@ function MessageAttachmentImage(props: { | ... | @@ -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 | function ThreadMarkdownImageView(props: { | 342 | function ThreadMarkdownImageView(props: { |
| 211 | readonly uri: string | null; | 343 | readonly uri: string | null; |
| 212 | readonly sourceKey: string; | 344 | readonly sourceKey: string; |
| ... | @@ -1032,9 +1164,7 @@ function renderFeedEntry( | ... | @@ -1032,9 +1164,7 @@ function renderFeedEntry( |
| 1032 | const isUser = message.role === "user"; | 1164 | const isUser = message.role === "user"; |
| 1033 | const styles = isUser ? markdownStyles.user : markdownStyles.assistant; | 1165 | const styles = isUser ? markdownStyles.user : markdownStyles.assistant; |
| 1034 | const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); | 1166 | const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); |
| 1035 | const attachments = (message.attachments ?? []).filter( | 1167 | const attachments = message.attachments ?? []; |
| 1036 | (attachment) => attachment.type === "image", | ||
| 1037 | ); | ||
| 1038 | const hasReviewCommentContext = message.text.includes("<review_comment"); | 1168 | const hasReviewCommentContext = message.text.includes("<review_comment"); |
| 1039 | // A bubble that sizes itself from its content cannot lay out a block whose | 1169 | // A bubble that sizes itself from its content cannot lay out a block whose |
| 1040 | // intrinsic width overflows `maxWidth`: Android positions the bubble's | 1170 | // intrinsic width overflows `maxWidth`: Android positions the bubble's |
| ... | @@ -1082,7 +1212,7 @@ function renderFeedEntry( | ... | @@ -1082,7 +1212,7 @@ function renderFeedEntry( |
| 1082 | /> | 1212 | /> |
| 1083 | ) : null} | 1213 | ) : null} |
| 1084 | {attachments.map((attachment) => { | 1214 | {attachments.map((attachment) => { |
| 1085 | return ( | 1215 | return isImageAttachment(attachment) ? ( |
| 1086 | <MessageAttachmentImage | 1216 | <MessageAttachmentImage |
| 1087 | key={attachment.id} | 1217 | key={attachment.id} |
| 1088 | environmentId={props.environmentId} | 1218 | environmentId={props.environmentId} |
| ... | @@ -1090,6 +1220,14 @@ function renderFeedEntry( | ... | @@ -1090,6 +1220,14 @@ function renderFeedEntry( |
| 1090 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" | 1220 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" |
| 1091 | onPressImage={props.onPressImage} | 1221 | onPressImage={props.onPressImage} |
| 1092 | /> | 1222 | /> |
| 1223 | ) : isFileAttachment(attachment) ? ( | ||
| 1224 | <MessageAttachmentFile | ||
| 1225 | key={attachment.id} | ||
| 1226 | environmentId={props.environmentId} | ||
| 1227 | attachment={attachment} | ||
| 1228 | /> | ||
| 1229 | ) : ( | ||
| 1230 | <MessageAttachmentUnknown key={attachment.id} name={attachment.name} /> | ||
| 1093 | ); | 1231 | ); |
| 1094 | })} | 1232 | })} |
| 1095 | </View> | 1233 | </View> |
| ... | @@ -1144,7 +1282,7 @@ function renderFeedEntry( | ... | @@ -1144,7 +1282,7 @@ function renderFeedEntry( |
| 1144 | ) | 1282 | ) |
| 1145 | ) : null} | 1283 | ) : null} |
| 1146 | {attachments.map((attachment) => { | 1284 | {attachments.map((attachment) => { |
| 1147 | return ( | 1285 | return isImageAttachment(attachment) ? ( |
| 1148 | <MessageAttachmentImage | 1286 | <MessageAttachmentImage |
| 1149 | key={attachment.id} | 1287 | key={attachment.id} |
| 1150 | environmentId={props.environmentId} | 1288 | environmentId={props.environmentId} |
| ... | @@ -1152,6 +1290,14 @@ function renderFeedEntry( | ... | @@ -1152,6 +1290,14 @@ function renderFeedEntry( |
| 1152 | className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" | 1290 | className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" |
| 1153 | onPressImage={props.onPressImage} | 1291 | onPressImage={props.onPressImage} |
| 1154 | /> | 1292 | /> |
| 1293 | ) : isFileAttachment(attachment) ? ( | ||
| 1294 | <MessageAttachmentFile | ||
| 1295 | key={attachment.id} | ||
| 1296 | environmentId={props.environmentId} | ||
| 1297 | attachment={attachment} | ||
| 1298 | /> | ||
| 1299 | ) : ( | ||
| 1300 | <MessageAttachmentUnknown key={attachment.id} name={attachment.name} /> | ||
| 1155 | ); | 1301 | ); |
| 1156 | })} | 1302 | })} |
| 1157 | {showAssistantMeta ? ( | 1303 | {showAssistantMeta ? ( |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1| ... | @@ -794,6 +794,7 @@ function ThreadRouteContent( | ... | @@ -794,6 +794,7 @@ function ThreadRouteContent( |
| 794 | onOpenConnectionEditor={handleOpenConnectionEditor} | 794 | onOpenConnectionEditor={handleOpenConnectionEditor} |
| 795 | onChangeDraftMessage={composer.onChangeDraftMessage} | 795 | onChangeDraftMessage={composer.onChangeDraftMessage} |
| 796 | onPickDraftImages={composer.onPickDraftImages} | 796 | onPickDraftImages={composer.onPickDraftImages} |
| 797 | onPickDraftFiles={composer.onPickDraftFiles} | ||
| 797 | onNativePasteImages={composer.onNativePasteImages} | 798 | onNativePasteImages={composer.onNativePasteImages} |
| 798 | onRemoveDraftImage={composer.onRemoveDraftImage} | 799 | onRemoveDraftImage={composer.onRemoveDraftImage} |
| 799 | serverConfig={serverConfig} | 800 | serverConfig={serverConfig} |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+36-13| ... | @@ -27,7 +27,7 @@ import { pipe } from "effect/Function"; | ... | @@ -27,7 +27,7 @@ import { pipe } from "effect/Function"; |
| 27 | 27 | ||
| 28 | import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; | 28 | import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; |
| 29 | import type { TurnCommandMetadata } from "../../lib/commandMetadata"; | 29 | import type { TurnCommandMetadata } from "../../lib/commandMetadata"; |
| 30 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 30 | import type { DraftComposerAttachment } from "../../lib/composerImages"; |
| 31 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | 31 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 32 | import { | 32 | import { |
| 33 | buildModelOptions, | 33 | buildModelOptions, |
| ... | @@ -48,18 +48,22 @@ import { | ... | @@ -48,18 +48,22 @@ import { |
| 48 | isComposerDraftEmpty, | 48 | isComposerDraftEmpty, |
| 49 | removeComposerDraftAttachment, | 49 | removeComposerDraftAttachment, |
| 50 | replaceComposerDraftAttachments, | 50 | replaceComposerDraftAttachments, |
| 51 | scheduleUnusedComposerAttachmentCleanup, | ||
| 51 | setComposerDraftText, | 52 | setComposerDraftText, |
| 52 | setStickyComposerModelSelection, | 53 | setStickyComposerModelSelection, |
| 53 | updateComposerDraftSettings, | 54 | updateComposerDraftSettings, |
| 54 | useComposerDraft, | 55 | useComposerDraft, |
| 55 | useStickyComposerModelSelection, | 56 | useStickyComposerModelSelection, |
| 56 | } from "../../state/use-composer-drafts"; | 57 | } from "../../state/use-composer-drafts"; |
| 58 | import { | ||
| 59 | capturePendingTaskEditorWriteBaseline, | ||
| 60 | flushPendingTaskEditorWrite, | ||
| 61 | } from "../../state/pending-task-editor-writes"; | ||
| 57 | import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; | 62 | import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; |
| 58 | import { vcsEnvironment } from "../../state/vcs"; | 63 | import { vcsEnvironment } from "../../state/vcs"; |
| 59 | import { | 64 | import { |
| 60 | flattenQueuedThreadMessages, | 65 | flattenQueuedThreadMessages, |
| 61 | threadOutboxManager, | 66 | threadOutboxManager, |
| 62 | updateThreadOutboxMessage, | ||
| 63 | type QueuedThreadMessage, | 67 | type QueuedThreadMessage, |
| 64 | } from "../../state/thread-outbox"; | 68 | } from "../../state/thread-outbox"; |
| 65 | import { | 69 | import { |
| ... | @@ -135,7 +139,7 @@ type NewTaskFlowContextValue = { | ... | @@ -135,7 +139,7 @@ type NewTaskFlowContextValue = { |
| 135 | readonly draftKey: string | null; | 139 | readonly draftKey: string | null; |
| 136 | readonly editingPendingTask: QueuedThreadMessage | null; | 140 | readonly editingPendingTask: QueuedThreadMessage | null; |
| 137 | readonly prompt: string; | 141 | readonly prompt: string; |
| 138 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 142 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 139 | readonly submitting: boolean; | 143 | readonly submitting: boolean; |
| 140 | readonly branchQuery: string; | 144 | readonly branchQuery: string; |
| 141 | readonly branchesLoading: boolean; | 145 | readonly branchesLoading: boolean; |
| ... | @@ -174,8 +178,9 @@ type NewTaskFlowContextValue = { | ... | @@ -174,8 +178,9 @@ type NewTaskFlowContextValue = { |
| 174 | readonly cancelEditingPendingTask: () => void; | 178 | readonly cancelEditingPendingTask: () => void; |
| 175 | readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; | 179 | readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; |
| 176 | readonly setPrompt: (value: string) => void; | 180 | readonly setPrompt: (value: string) => void; |
| 177 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; | 181 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void; |
| 178 | readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; | 182 | /** Appends draft attachments; returns how many the live cap rejected. */ |
| 183 | readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => number; | ||
| 179 | readonly removeAttachment: (imageId: string) => void; | 184 | readonly removeAttachment: (imageId: string) => void; |
| 180 | readonly clearAttachments: () => void; | 185 | readonly clearAttachments: () => void; |
| 181 | readonly setSubmitting: (value: boolean) => void; | 186 | readonly setSubmitting: (value: boolean) => void; |
| ... | @@ -230,6 +235,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -230,6 +235,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 230 | // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act | 235 | // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act |
| 231 | // on a task whose editing session already ended this render. | 236 | // on a task whose editing session already ended this render. |
| 232 | const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); | 237 | const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); |
| 238 | // Outbox revision this editor session may write after its predecessor save. | ||
| 239 | // Unrelated accepted writes still beat the dismissed session's CAS. | ||
| 240 | const editingRevisionRef = useRef(Promise.resolve(0)); | ||
| 233 | 241 | ||
| 234 | const reset = useCallback(() => { | 242 | const reset = useCallback(() => { |
| 235 | setSelectedEnvironmentId(null); | 243 | setSelectedEnvironmentId(null); |
| ... | @@ -513,7 +521,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -513,7 +521,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 513 | [selectedProjectDraftKey], | 521 | [selectedProjectDraftKey], |
| 514 | ); | 522 | ); |
| 515 | const replaceAttachments = useCallback( | 523 | const replaceAttachments = useCallback( |
| 516 | (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => { | 524 | (nextAttachments: ReadonlyArray<DraftComposerAttachment>) => { |
| 517 | if (!selectedProjectDraftKey) { | 525 | if (!selectedProjectDraftKey) { |
| 518 | return; | 526 | return; |
| 519 | } | 527 | } |
| ... | @@ -521,12 +529,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -521,12 +529,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 521 | }, | 529 | }, |
| 522 | [selectedProjectDraftKey], | 530 | [selectedProjectDraftKey], |
| 523 | ); | 531 | ); |
| 532 | // Returns how many attachments the live cap rejected so the caller can | ||
| 533 | // tell the user (a concurrent add can fill the draft mid-pick). | ||
| 524 | const appendAttachments = useCallback( | 534 | const appendAttachments = useCallback( |
| 525 | (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => { | 535 | (nextAttachments: ReadonlyArray<DraftComposerAttachment>): number => { |
| 526 | if (!selectedProjectDraftKey) { | 536 | if (!selectedProjectDraftKey) { |
| 527 | return; | 537 | return 0; |
| 528 | } | 538 | } |
| 529 | appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments); | 539 | return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments); |
| 530 | }, | 540 | }, |
| 531 | [selectedProjectDraftKey], | 541 | [selectedProjectDraftKey], |
| 532 | ); | 542 | ); |
| ... | @@ -835,6 +845,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -835,6 +845,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 835 | setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId)); | 845 | setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId)); |
| 836 | activeEditingMessageId = message.messageId; | 846 | activeEditingMessageId = message.messageId; |
| 837 | editingPendingTaskRef.current = message; | 847 | editingPendingTaskRef.current = message; |
| 848 | editingRevisionRef.current = capturePendingTaskEditorWriteBaseline(message.messageId); | ||
| 838 | setEditingPendingTask(message); | 849 | setEditingPendingTask(message); |
| 839 | // Hold the outbox drain off this task while it is open in the editor. | 850 | // Hold the outbox drain off this task while it is open in the editor. |
| 840 | holdEditingQueuedMessage(message.messageId); | 851 | holdEditingQueuedMessage(message.messageId); |
| ... | @@ -932,6 +943,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -932,6 +943,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 932 | } | 943 | } |
| 933 | clearComposerDraft(pendingTaskDraftKey(editing.messageId)); | 944 | clearComposerDraft(pendingTaskDraftKey(editing.messageId)); |
| 934 | releaseEditingQueuedMessage(editing.messageId); | 945 | releaseEditingQueuedMessage(editing.messageId); |
| 946 | scheduleUnusedComposerAttachmentCleanup(editing.attachments); | ||
| 935 | } | 947 | } |
| 936 | setEditingPendingTask(null); | 948 | setEditingPendingTask(null); |
| 937 | }, []); | 949 | }, []); |
| ... | @@ -984,17 +996,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -984,17 +996,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 984 | return; | 996 | return; |
| 985 | } | 997 | } |
| 986 | 998 | ||
| 987 | // update() rewrites the task only if it is still queued — a concurrent | 999 | // The write handoff lets a reopened editor follow this editor's pending |
| 988 | // delete or delivery wins, so the flush cannot resurrect it. | 1000 | // save. Its CAS still rejects unrelated queue edits, deletes, and |
| 989 | void updateThreadOutboxMessage(message) | 1001 | // deliveries, so the flush cannot resurrect or overwrite them. |
| 990 | .then(() => { | 1002 | void flushPendingTaskEditorWrite({ |
| 1003 | message, | ||
| 1004 | baseline: editingRevisionRef.current, | ||
| 1005 | draftKey: pendingTaskDraftKey(editing.messageId), | ||
| 1006 | }) | ||
| 1007 | .then((savedDraftStillCurrent) => { | ||
| 991 | // If this task was reopened (possibly in a fresh provider) while | 1008 | // If this task was reopened (possibly in a fresh provider) while |
| 992 | // the save was in flight, that session owns the draft and the lock. | 1009 | // the save was in flight, that session owns the draft and the lock. |
| 993 | if (activeEditingMessageId === editing.messageId) { | 1010 | if (activeEditingMessageId === editing.messageId) { |
| 994 | return; | 1011 | return; |
| 995 | } | 1012 | } |
| 1013 | if (!savedDraftStillCurrent) { | ||
| 1014 | // A newer queue write won the CAS, or a newer editor changed this | ||
| 1015 | // draft. Keep the draft and drain lock so reopening can retry it. | ||
| 1016 | return; | ||
| 1017 | } | ||
| 996 | clearComposerDraft(pendingTaskDraftKey(editing.messageId)); | 1018 | clearComposerDraft(pendingTaskDraftKey(editing.messageId)); |
| 997 | releaseEditingQueuedMessage(editing.messageId); | 1019 | releaseEditingQueuedMessage(editing.messageId); |
| 1020 | scheduleUnusedComposerAttachmentCleanup(editing.attachments); | ||
| 998 | }) | 1021 | }) |
| 999 | .catch((error) => { | 1022 | .catch((error) => { |
| 1000 | // Keep the drain lock and the draft: delivering the stale payload | 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,13 +14,17 @@ import * as Cause from "effect/Cause"; |
| 14 | import { AsyncResult } from "effect/unstable/reactivity"; | 14 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 15 | 15 | ||
| 16 | import { threadEnvironment } from "../../state/threads"; | 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 | import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; | 19 | import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; |
| 19 | import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; | 20 | import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; |
| 20 | import { randomHex } from "../../lib/uuid"; | 21 | import { randomHex } from "../../lib/uuid"; |
| 21 | import { useAtomCommand } from "../../state/use-atom-command"; | 22 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 23 | import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts"; | ||
| 22 | import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; | 24 | import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; |
| 23 | import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; | 25 | import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; |
| 26 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 27 | import { serverEnvironment } from "../../state/server"; | ||
| 24 | 28 | ||
| 25 | export function useCreateProjectThread() { | 29 | export function useCreateProjectThread() { |
| 26 | const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); | 30 | const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); |
| ... | @@ -36,7 +40,10 @@ export function useCreateProjectThread() { | ... | @@ -36,7 +40,10 @@ export function useCreateProjectThread() { |
| 36 | readonly runtimeMode: RuntimeMode; | 40 | readonly runtimeMode: RuntimeMode; |
| 37 | readonly interactionMode: ProviderInteractionMode; | 41 | readonly interactionMode: ProviderInteractionMode; |
| 38 | readonly initialMessageText: string; | 42 | readonly initialMessageText: string; |
| 39 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 43 | readonly initialAttachments: ReadonlyArray<DraftComposerAttachment>; |
| 44 | readonly onAttachmentsUploaded: ( | ||
| 45 | attachments: ReadonlyArray<DraftComposerAttachment>, | ||
| 46 | ) => Promise<void>; | ||
| 40 | /** Reuse identifiers from a queued pending task instead of minting new ones. */ | 47 | /** Reuse identifiers from a queued pending task instead of minting new ones. */ |
| 41 | readonly turnMetadata?: TurnCommandMetadata; | 48 | readonly turnMetadata?: TurnCommandMetadata; |
| 42 | }) => { | 49 | }) => { |
| ... | @@ -56,6 +63,50 @@ export function useCreateProjectThread() { | ... | @@ -56,6 +63,50 @@ export function useCreateProjectThread() { |
| 56 | return AsyncResult.failure(Cause.fail(validationError)); | 63 | return AsyncResult.failure(Cause.fail(validationError)); |
| 57 | } | 64 | } |
| 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 | const result = await startTurn({ | 110 | const result = await startTurn({ |
| 60 | environmentId: input.project.environmentId, | 111 | environmentId: input.project.environmentId, |
| 61 | input: buildProjectThreadStartTurnInput({ | 112 | input: buildProjectThreadStartTurnInput({ |
| ... | @@ -67,6 +118,7 @@ export function useCreateProjectThread() { | ... | @@ -67,6 +118,7 @@ export function useCreateProjectThread() { |
| 67 | createdAt: metadata.createdAt, | 118 | createdAt: metadata.createdAt, |
| 68 | text: initialMessageText, | 119 | text: initialMessageText, |
| 69 | attachments: input.initialAttachments, | 120 | attachments: input.initialAttachments, |
| 121 | uploadedAttachments: prepared.attachments, | ||
| 70 | modelSelection: input.modelSelection, | 122 | modelSelection: input.modelSelection, |
| 71 | runtimeMode: input.runtimeMode, | 123 | runtimeMode: input.runtimeMode, |
| 72 | interactionMode: input.interactionMode, | 124 | interactionMode: input.interactionMode, |
| ... | @@ -84,7 +136,13 @@ export function useCreateProjectThread() { | ... | @@ -84,7 +136,13 @@ export function useCreateProjectThread() { |
| 84 | ); | 136 | ); |
| 85 | return AsyncResult.failure(result.cause); | 137 | return AsyncResult.failure(result.cause); |
| 86 | } | 138 | } |
| 139 | // The started turn holds its own copy of the bytes; a failed delete is | ||
| 140 | // surfaced without failing the started task. | ||
| 141 | await prepared.releaseUploads().catch((error) => { | ||
| 142 | console.warn("[project-thread] could not delete consumed pending uploads", error); | ||
| 143 | }); | ||
| 87 | setPendingConnectionError(null); | 144 | setPendingConnectionError(null); |
| 145 | scheduleUnusedComposerAttachmentCleanup(input.initialAttachments); | ||
| 88 | 146 | ||
| 89 | return mapAtomCommandResult(result, () => | 147 | return mapAtomCommandResult(result, () => |
| 90 | scopeThreadRef(input.project.environmentId, threadId), | 148 | scopeThreadRef(input.project.environmentId, threadId), |
apps/mobile/src/lib/attachmentDownload.test.ts created+270| ... | @@ -0,0 +1,270 @@ | ||
| 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,4 +1,5 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | export const DraftComposerImageAttachmentSchema = Schema.Struct({ | 4 | export const DraftComposerImageAttachmentSchema = Schema.Struct({ |
| 4 | id: Schema.String, | 5 | id: Schema.String, |
| ... | @@ -9,3 +10,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ | ... | @@ -9,3 +10,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ |
| 9 | sizeBytes: Schema.Number, | 10 | sizeBytes: Schema.Number, |
| 10 | dataUrl: Schema.String, | 11 | dataUrl: Schema.String, |
| 11 | }); | 12 | }); |
| 13 | |||
| 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,10 +1,21 @@ |
| 1 | import { | ||
| 2 | clampFileAttachmentUploadBytes, | ||
| 3 | fileAttachmentTooLargeMessage, | ||
| 4 | } from "@t3tools/client-runtime/state/attachments"; | ||
| 1 | import { | 5 | import { |
| 2 | isProviderSendTurnSupportedImageMimeType, | 6 | isProviderSendTurnSupportedImageMimeType, |
| 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 7 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 8 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, | ||
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 9 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 10 | type EnvironmentId, | ||
| 5 | type UploadChatImageAttachment, | 11 | type UploadChatImageAttachment, |
| 6 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import type { PickMultipleFilesResult } from "expo-file-system"; | ||
| 7 | import { estimateBase64ByteSize } from "./base64"; | 14 | import { estimateBase64ByteSize } from "./base64"; |
| 15 | import { | ||
| 16 | COMPOSER_ATTACHMENT_DIRECTORY, | ||
| 17 | resolveOwnedComposerAttachmentFileUri, | ||
| 18 | } from "./composerAttachmentFiles"; | ||
| 8 | import { beginForegroundHandoff } from "./foreground-handoff"; | 19 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| 9 | import { uuidv4 } from "./uuid"; | 20 | import { uuidv4 } from "./uuid"; |
| 10 | 21 | ||
| ... | @@ -13,6 +24,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment | ... | @@ -13,6 +24,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment |
| 13 | readonly previewUri: string; | 24 | readonly previewUri: string; |
| 14 | } | 25 | } |
| 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 | /** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ | 40 | /** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ |
| 17 | export function toUploadChatImageAttachments( | 41 | export function toUploadChatImageAttachments( |
| 18 | attachments: ReadonlyArray<DraftComposerImageAttachment>, | 42 | attachments: ReadonlyArray<DraftComposerImageAttachment>, |
| ... | @@ -27,6 +51,189 @@ export function toUploadChatImageAttachments( | ... | @@ -27,6 +51,189 @@ export function toUploadChatImageAttachments( |
| 27 | } | 51 | } |
| 28 | 52 | ||
| 29 | const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; | 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 | async function loadImagePicker() { | 238 | async function loadImagePicker() { |
| 32 | try { | 239 | try { |
apps/mobile/src/lib/projectThreadStartTurn.ts+10-3| ... | @@ -2,13 +2,15 @@ import { | ... | @@ -2,13 +2,15 @@ import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | MessageId, | 3 | MessageId, |
| 4 | ThreadId, | 4 | ThreadId, |
| 5 | type ChatFileAttachment, | ||
| 5 | type ModelSelection, | 6 | type ModelSelection, |
| 6 | type ProjectId, | 7 | type ProjectId, |
| 7 | type ProviderInteractionMode, | 8 | type ProviderInteractionMode, |
| 8 | type RuntimeMode, | 9 | type RuntimeMode, |
| 10 | type UploadChatImageAttachment, | ||
| 9 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 10 | 12 | ||
| 11 | import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; | 13 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; |
| 12 | 14 | ||
| 13 | export function deriveThreadTitleFromPrompt(value: string): string { | 15 | export function deriveThreadTitleFromPrompt(value: string): string { |
| 14 | const trimmed = value.trim(); | 16 | const trimmed = value.trim(); |
| ... | @@ -28,7 +30,8 @@ export interface ProjectThreadStartTurnSpec { | ... | @@ -28,7 +30,8 @@ export interface ProjectThreadStartTurnSpec { |
| 28 | readonly messageId: string; | 30 | readonly messageId: string; |
| 29 | readonly createdAt: string; | 31 | readonly createdAt: string; |
| 30 | readonly text: string; | 32 | readonly text: string; |
| 31 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 33 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 34 | readonly uploadedAttachments?: ReadonlyArray<UploadChatImageAttachment | ChatFileAttachment>; | ||
| 32 | readonly modelSelection: ModelSelection; | 35 | readonly modelSelection: ModelSelection; |
| 33 | readonly runtimeMode: RuntimeMode; | 36 | readonly runtimeMode: RuntimeMode; |
| 34 | readonly interactionMode: ProviderInteractionMode; | 37 | readonly interactionMode: ProviderInteractionMode; |
| ... | @@ -55,7 +58,11 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe | ... | @@ -55,7 +58,11 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe |
| 55 | messageId: MessageId.make(spec.messageId), | 58 | messageId: MessageId.make(spec.messageId), |
| 56 | role: "user" as const, | 59 | role: "user" as const, |
| 57 | text: spec.text, | 60 | text: spec.text, |
| 58 | attachments: toUploadChatImageAttachments(spec.attachments), | 61 | attachments: |
| 62 | spec.uploadedAttachments ?? | ||
| 63 | toUploadChatImageAttachments( | ||
| 64 | spec.attachments.filter((attachment) => attachment.type === "image"), | ||
| 65 | ), | ||
| 59 | }, | 66 | }, |
| 60 | modelSelection: spec.modelSelection, | 67 | modelSelection: spec.modelSelection, |
| 61 | titleSeed: title, | 68 | titleSeed: title, |
apps/mobile/src/state/attachments.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 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,8 +46,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 46 | ((message: string, error: unknown) => { | 46 | ((message: string, error: unknown) => { |
| 47 | console.warn(message, error); | 47 | console.warn(message, error); |
| 48 | }); | 48 | }); |
| 49 | let loadPromise: Promise<void> | null = null; | 49 | let loadPromise: Promise<boolean> | null = null; |
| 50 | let mutationQueue: Promise<void> = Promise.resolve(); | 50 | let mutationQueue: Promise<void> = Promise.resolve(); |
| 51 | // Monotonic per-message write counter. Every accepted write (enqueue publish | ||
| 52 | // or update) bumps it, so a writer that captured a revision before slow work | ||
| 53 | // (an attachment upload) is rejected before its stale payload reaches disk. | ||
| 54 | const revisions = new Map<MessageId, number>(); | ||
| 55 | const bumpRevision = (messageId: MessageId): void => { | ||
| 56 | revisions.set(messageId, (revisions.get(messageId) ?? 0) + 1); | ||
| 57 | }; | ||
| 51 | 58 | ||
| 52 | const serialize = <A>(mutation: () => Promise<A>): Promise<A> => { | 59 | const serialize = <A>(mutation: () => Promise<A>): Promise<A> => { |
| 53 | const result = mutationQueue.then(mutation, mutation); | 60 | const result = mutationQueue.then(mutation, mutation); |
| ... | @@ -65,13 +72,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -65,13 +72,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 65 | options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages)); | 72 | options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages)); |
| 66 | }; | 73 | }; |
| 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 | if (loadPromise !== null) { | 79 | if (loadPromise !== null) { |
| 70 | return loadPromise; | 80 | return loadPromise; |
| 71 | } | 81 | } |
| 72 | loadPromise = serialize(async () => { | 82 | loadPromise = serialize(async () => { |
| 73 | const persistedMessages = await options.storage.load(); | 83 | const persistedMessages = await options.storage.load(); |
| 74 | setMessages([...persistedMessages, ...currentMessages()]); | 84 | setMessages([...persistedMessages, ...currentMessages()]); |
| 85 | return true; | ||
| 75 | }).catch((cause) => { | 86 | }).catch((cause) => { |
| 76 | loadPromise = null; | 87 | loadPromise = null; |
| 77 | warn( | 88 | warn( |
| ... | @@ -84,6 +95,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -84,6 +95,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 84 | cause, | 95 | cause, |
| 85 | }), | 96 | }), |
| 86 | ); | 97 | ); |
| 98 | return false; | ||
| 87 | }); | 99 | }); |
| 88 | return loadPromise; | 100 | return loadPromise; |
| 89 | }; | 101 | }; |
| ... | @@ -93,6 +105,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -93,6 +105,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 93 | // the message back out if it fails (durability only matters for crash | 105 | // the message back out if it fails (durability only matters for crash |
| 94 | // recovery, not for the in-session queue). | 106 | // recovery, not for the in-session queue). |
| 95 | const enqueue = (message: QueuedThreadMessage): Promise<void> => { | 107 | const enqueue = (message: QueuedThreadMessage): Promise<void> => { |
| 108 | bumpRevision(message.messageId); | ||
| 96 | setMessages([ | 109 | setMessages([ |
| 97 | ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), | 110 | ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), |
| 98 | message, | 111 | message, |
| ... | @@ -105,6 +118,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -105,6 +118,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 105 | // id may have optimistically replaced this attempt while the write was | 118 | // id may have optimistically replaced this attempt while the write was |
| 106 | // in flight, and its entry must survive this attempt's failure. | 119 | // in flight, and its entry must survive this attempt's failure. |
| 107 | setMessages(currentMessages().filter((candidate) => candidate !== message)); | 120 | setMessages(currentMessages().filter((candidate) => candidate !== message)); |
| 121 | // A concurrent update losing its post-write race compensates by | ||
| 122 | // persisting this message's payload before this write settles. When | ||
| 123 | // no same-id entry survives the rollback, drop that disk copy too, or | ||
| 124 | // a restart resurrects a message the queue no longer holds. | ||
| 125 | if (!currentMessages().some((candidate) => candidate.messageId === message.messageId)) { | ||
| 126 | try { | ||
| 127 | await options.storage.remove(message); | ||
| 128 | } catch { | ||
| 129 | // Best effort: bootstrap reconciles the queue against storage. | ||
| 130 | } | ||
| 131 | } | ||
| 108 | throw new ThreadOutboxManagerError({ | 132 | throw new ThreadOutboxManagerError({ |
| 109 | operation: "enqueue", | 133 | operation: "enqueue", |
| 110 | environmentId: message.environmentId, | 134 | environmentId: message.environmentId, |
| ... | @@ -126,12 +150,22 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -126,12 +150,22 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 126 | // Rewrites an already-queued message. A no-op when the message has been | 150 | // Rewrites an already-queued message. A no-op when the message has been |
| 127 | // removed in the meantime (e.g. deleted or delivered), so a trailing editor | 151 | // removed in the meantime (e.g. deleted or delivered), so a trailing editor |
| 128 | // flush can never resurrect it. Returns whether the message was updated. | 152 | // flush can never resurrect it. Returns whether the message was updated. |
| 129 | const update = (message: QueuedThreadMessage): Promise<boolean> => | 153 | // |
| 154 | // `expectedRevision` makes the update a compare-and-set: pass the revision | ||
| 155 | // read before starting slow work, and the update is rejected before the | ||
| 156 | // stale payload is persisted when any other write was accepted since. An | ||
| 157 | // enqueue can still publish synchronously while the durable write below is | ||
| 158 | // in flight, so the revision is re-checked after the write too; the stale | ||
| 159 | // payload it just persisted is then overwritten with the winning payload | ||
| 160 | // inside this mutation, so a crash before the winner's own serialized write | ||
| 161 | // cannot leave stale state on disk. | ||
| 162 | const update = (message: QueuedThreadMessage, expectedRevision?: number): Promise<boolean> => | ||
| 130 | serialize(async () => { | 163 | serialize(async () => { |
| 131 | const exists = currentMessages().some( | 164 | const staleOrMissing = (): boolean => |
| 132 | (candidate) => candidate.messageId === message.messageId, | 165 | !currentMessages().some((candidate) => candidate.messageId === message.messageId) || |
| 133 | ); | 166 | (expectedRevision !== undefined && |
| 134 | if (!exists) { | 167 | (revisions.get(message.messageId) ?? 0) !== expectedRevision); |
| 168 | if (staleOrMissing()) { | ||
| 135 | return false; | 169 | return false; |
| 136 | } | 170 | } |
| 137 | try { | 171 | try { |
| ... | @@ -145,6 +179,21 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -145,6 +179,21 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 145 | cause, | 179 | cause, |
| 146 | }); | 180 | }); |
| 147 | } | 181 | } |
| 182 | if (staleOrMissing()) { | ||
| 183 | const winner = currentMessages().find( | ||
| 184 | (candidate) => candidate.messageId === message.messageId, | ||
| 185 | ); | ||
| 186 | if (winner !== undefined) { | ||
| 187 | try { | ||
| 188 | await options.storage.write(winner); | ||
| 189 | } catch { | ||
| 190 | // The winner's own serialized write follows this mutation and | ||
| 191 | // owns the failure handling for its payload. | ||
| 192 | } | ||
| 193 | } | ||
| 194 | return false; | ||
| 195 | } | ||
| 196 | bumpRevision(message.messageId); | ||
| 148 | setMessages([ | 197 | setMessages([ |
| 149 | ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), | 198 | ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), |
| 150 | message, | 199 | message, |
| ... | @@ -152,8 +201,29 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -152,8 +201,29 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 152 | return true; | 201 | return true; |
| 153 | }); | 202 | }); |
| 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 | serialize(async () => { | 214 | serialize(async () => { |
| 215 | const removalCanceled = (): boolean => | ||
| 216 | (expectedRevision !== undefined && | ||
| 217 | (revisions.get(message.messageId) ?? 0) !== expectedRevision) || | ||
| 218 | canRemove?.() === false; | ||
| 219 | if (removalCanceled()) { | ||
| 220 | return null; | ||
| 221 | } | ||
| 222 | // The live payload may carry attachments an accepted update added after | ||
| 223 | // the caller's snapshot; the caller releases files from what actually | ||
| 224 | // leaves the queue. | ||
| 225 | const removed = | ||
| 226 | currentMessages().find((candidate) => candidate.messageId === message.messageId) ?? message; | ||
| 157 | try { | 227 | try { |
| 158 | await options.storage.remove(message); | 228 | await options.storage.remove(message); |
| 159 | } catch (cause) { | 229 | } catch (cause) { |
| ... | @@ -165,13 +235,46 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -165,13 +235,46 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 165 | cause, | 235 | cause, |
| 166 | }); | 236 | }); |
| 167 | } | 237 | } |
| 238 | if (removalCanceled()) { | ||
| 239 | // An enqueue or editor lock can win while storage removal is in | ||
| 240 | // flight. Restore the live payload here, before any queued mutation | ||
| 241 | // gets its turn, so this canceled removal is durable on its own. | ||
| 242 | const winner = currentMessages().find( | ||
| 243 | (candidate) => candidate.messageId === message.messageId, | ||
| 244 | ); | ||
| 245 | if (winner !== undefined) { | ||
| 246 | try { | ||
| 247 | await options.storage.write(winner); | ||
| 248 | } catch (cause) { | ||
| 249 | throw new ThreadOutboxManagerError({ | ||
| 250 | operation: "remove", | ||
| 251 | environmentId: message.environmentId, | ||
| 252 | threadId: message.threadId, | ||
| 253 | messageId: message.messageId, | ||
| 254 | cause, | ||
| 255 | }); | ||
| 256 | } | ||
| 257 | } | ||
| 258 | return null; | ||
| 259 | } | ||
| 168 | setMessages( | 260 | setMessages( |
| 169 | currentMessages().filter((candidate) => candidate.messageId !== message.messageId), | 261 | currentMessages().filter((candidate) => candidate.messageId !== message.messageId), |
| 170 | ); | 262 | ); |
| 263 | // Tombstone, not delete: a same-id retry restarting at revision 1 would | ||
| 264 | // otherwise match a stale writer's expectedRevision from before the | ||
| 265 | // removal (ABA). | ||
| 266 | bumpRevision(message.messageId); | ||
| 267 | return removed; | ||
| 171 | }); | 268 | }); |
| 172 | 269 | ||
| 173 | const clearEnvironment = (environmentId: EnvironmentId): Promise<void> => | 270 | const clearEnvironment = ( |
| 174 | serialize(async () => { | 271 | environmentId: EnvironmentId, |
| 272 | ): Promise<ReadonlyArray<QueuedThreadMessage>> => { | ||
| 273 | // Enqueues publish before their serialized writes. Capture revisions now, | ||
| 274 | // but wait for earlier mutations before reading messages: a message that | ||
| 275 | // changes after this request must not enter the clear set. | ||
| 276 | const revisionsAtRequest = new Map(revisions); | ||
| 277 | return serialize(async () => { | ||
| 175 | const persisted = await options.storage.load().catch((cause) => { | 278 | const persisted = await options.storage.load().catch((cause) => { |
| 176 | warn( | 279 | warn( |
| 177 | "[thread-outbox] failed to load messages while clearing environment", | 280 | "[thread-outbox] failed to load messages while clearing environment", |
| ... | @@ -188,32 +291,91 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -188,32 +291,91 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 188 | const allMessages = flattenQueuedThreadMessages( | 291 | const allMessages = flattenQueuedThreadMessages( |
| 189 | groupQueuedThreadMessages([...persisted, ...currentMessages()]), | 292 | groupQueuedThreadMessages([...persisted, ...currentMessages()]), |
| 190 | ); | 293 | ); |
| 191 | const removedMessageIds = new Set<MessageId>(); | 294 | const candidates = allMessages.filter( |
| 295 | (message) => | ||
| 296 | message.environmentId === environmentId && | ||
| 297 | (revisions.get(message.messageId) ?? 0) === | ||
| 298 | (revisionsAtRequest.get(message.messageId) ?? 0), | ||
| 299 | ); | ||
| 300 | const candidateRevisions = new Map( | ||
| 301 | candidates.map( | ||
| 302 | (message) => [message.messageId, revisions.get(message.messageId) ?? 0] as const, | ||
| 303 | ), | ||
| 304 | ); | ||
| 305 | const removedFromStorage = new Set<MessageId>(); | ||
| 192 | 306 | ||
| 193 | await Promise.all( | 307 | await Promise.all( |
| 194 | allMessages | 308 | candidates.map(async (message) => { |
| 195 | .filter((message) => message.environmentId === environmentId) | 309 | try { |
| 196 | .map(async (message) => { | 310 | await options.storage.remove(message); |
| 197 | try { | 311 | removedFromStorage.add(message.messageId); |
| 198 | await options.storage.remove(message); | 312 | } catch (cause) { |
| 199 | removedMessageIds.add(message.messageId); | 313 | warn( |
| 200 | } catch (cause) { | 314 | "[thread-outbox] failed to clear persisted message", |
| 201 | warn( | 315 | new ThreadOutboxManagerError({ |
| 202 | "[thread-outbox] failed to clear persisted message", | 316 | operation: "clear-environment-remove", |
| 203 | new ThreadOutboxManagerError({ | 317 | environmentId: message.environmentId, |
| 204 | operation: "clear-environment-remove", | 318 | threadId: message.threadId, |
| 205 | environmentId: message.environmentId, | 319 | messageId: message.messageId, |
| 206 | threadId: message.threadId, | 320 | cause, |
| 207 | messageId: message.messageId, | 321 | }), |
| 208 | cause, | 322 | ); |
| 209 | }), | 323 | } |
| 210 | ); | 324 | }), |
| 211 | } | ||
| 212 | }), | ||
| 213 | ); | 325 | ); |
| 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 | return { | 380 | return { |
| 219 | queuedMessagesByThreadKeyAtom, | 381 | queuedMessagesByThreadKeyAtom, |
| ... | @@ -221,6 +383,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -221,6 +383,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 221 | load, | 383 | load, |
| 222 | enqueue, | 384 | enqueue, |
| 223 | confirmQueued, | 385 | confirmQueued, |
| 386 | /** Current write revision for a queued message; input to update's CAS. */ | ||
| 387 | revisionOf: (messageId: MessageId): number => revisions.get(messageId) ?? 0, | ||
| 224 | update, | 388 | update, |
| 225 | remove, | 389 | remove, |
| 226 | clearEnvironment, | 390 | clearEnvironment, |
apps/mobile/src/state/thread-outbox-model.ts+52-6| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; | 1 | import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; |
| 2 | import { | ||
| 3 | clampFileAttachmentUploadBytes, | ||
| 4 | fileAttachmentTooLargeMessage, | ||
| 5 | } from "@t3tools/client-runtime/state/attachments"; | ||
| 2 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; | 6 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { | 7 | import { |
| 4 | CommandId, | 8 | CommandId, |
| ... | @@ -17,8 +21,8 @@ import { | ... | @@ -17,8 +21,8 @@ import { |
| 17 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 18 | import * as Schema from "effect/Schema"; | 22 | import * as Schema from "effect/Schema"; |
| 19 | 23 | ||
| 20 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; | 24 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 21 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 25 | import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 22 | import { scopedThreadKey } from "../lib/scopedEntities"; | 26 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 23 | 27 | ||
| 24 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; | 28 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; |
| ... | @@ -43,7 +47,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ | ... | @@ -43,7 +47,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ |
| 43 | messageId: MessageId, | 47 | messageId: MessageId, |
| 44 | commandId: CommandId, | 48 | commandId: CommandId, |
| 45 | text: Schema.String, | 49 | text: Schema.String, |
| 46 | attachments: Schema.Array(DraftComposerImageAttachmentSchema), | 50 | attachments: Schema.Array(DraftComposerAttachmentSchema), |
| 47 | modelSelection: Schema.optional(ModelSelection), | 51 | modelSelection: Schema.optional(ModelSelection), |
| 48 | runtimeMode: Schema.optional(RuntimeMode), | 52 | runtimeMode: Schema.optional(RuntimeMode), |
| 49 | interactionMode: Schema.optional(ProviderInteractionMode), | 53 | interactionMode: Schema.optional(ProviderInteractionMode), |
| ... | @@ -72,7 +76,7 @@ export interface QueuedThreadMessage { | ... | @@ -72,7 +76,7 @@ export interface QueuedThreadMessage { |
| 72 | readonly messageId: MessageId; | 76 | readonly messageId: MessageId; |
| 73 | readonly commandId: CommandId; | 77 | readonly commandId: CommandId; |
| 74 | readonly text: string; | 78 | readonly text: string; |
| 75 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 79 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 76 | readonly modelSelection?: ModelSelectionType; | 80 | readonly modelSelection?: ModelSelectionType; |
| 77 | readonly runtimeMode?: RuntimeModeType; | 81 | readonly runtimeMode?: RuntimeModeType; |
| 78 | readonly interactionMode?: ProviderInteractionModeType; | 82 | readonly interactionMode?: ProviderInteractionModeType; |
| ... | @@ -172,6 +176,48 @@ export function resolveThreadOutboxDeliveryAction(input: { | ... | @@ -172,6 +176,48 @@ export function resolveThreadOutboxDeliveryAction(input: { |
| 172 | return input.environmentConnected ? "send" : "wait"; | 176 | return input.environmentConnected ? "send" : "wait"; |
| 173 | } | 177 | } |
| 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 | * A queued creation can only be dispatched once its payload would pass server | 222 | * A queued creation can only be dispatched once its payload would pass server |
| 177 | * validation; incomplete payloads stay pending until the user edits them. | 223 | * validation; incomplete payloads stay pending until the user edits them. |
| ... | @@ -209,7 +255,7 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { | ... | @@ -209,7 +255,7 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { |
| 209 | } | 255 | } |
| 210 | 256 | ||
| 211 | export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; | 257 | export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; |
| 212 | export type ThreadOutboxFailureAction = "retry" | "discard"; | 258 | export type ThreadOutboxFailureAction = "retry" | "restore"; |
| 213 | 259 | ||
| 214 | export function resolveThreadOutboxFailureAction(input: { | 260 | export function resolveThreadOutboxFailureAction(input: { |
| 215 | readonly stage: ThreadOutboxCommandStage; | 261 | readonly stage: ThreadOutboxCommandStage; |
| ... | @@ -223,5 +269,5 @@ export function resolveThreadOutboxFailureAction(input: { | ... | @@ -223,5 +269,5 @@ export function resolveThreadOutboxFailureAction(input: { |
| 223 | ) { | 269 | ) { |
| 224 | return "retry"; | 270 | return "retry"; |
| 225 | } | 271 | } |
| 226 | return "discard"; | 272 | return "restore"; |
| 227 | } | 273 | } |
apps/mobile/src/state/thread-outbox-removal.test.ts created+305| ... | @@ -0,0 +1,305 @@ | ||
| 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,6 +16,7 @@ import { |
| 16 | isQueuedThreadCreationSendable, | 16 | isQueuedThreadCreationSendable, |
| 17 | modelSelectionsEqual, | 17 | modelSelectionsEqual, |
| 18 | resolveThreadOutboxDeliveryAction, | 18 | resolveThreadOutboxDeliveryAction, |
| 19 | resolveThreadOutboxDispatchStep, | ||
| 19 | resolveThreadOutboxFailureAction, | 20 | resolveThreadOutboxFailureAction, |
| 20 | resolveQueuedThreadSettings, | 21 | resolveQueuedThreadSettings, |
| 21 | shouldRetryThreadOutboxDelivery, | 22 | shouldRetryThreadOutboxDelivery, |
| ... | @@ -78,6 +79,29 @@ describe("thread outbox", () => { | ... | @@ -78,6 +79,29 @@ describe("thread outbox", () => { |
| 78 | ).toThrow(); | 79 | ).toThrow(); |
| 79 | }); | 80 | }); |
| 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 | it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { | 105 | it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { |
| 82 | const legacyMessage = queuedMessage({ | 106 | const legacyMessage = queuedMessage({ |
| 83 | messageId: "message-1", | 107 | messageId: "message-1", |
| ... | @@ -357,6 +381,34 @@ describe("thread outbox", () => { | ... | @@ -357,6 +381,34 @@ describe("thread outbox", () => { |
| 357 | registry.dispose(); | 381 | registry.dispose(); |
| 358 | }); | 382 | }); |
| 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 | it("keeps a same-id retry queued when the first attempt's write fails", async () => { | 412 | it("keeps a same-id retry queued when the first attempt's write fails", async () => { |
| 361 | const registry = AtomRegistry.make(); | 413 | const registry = AtomRegistry.make(); |
| 362 | let failNextWrite = true; | 414 | let failNextWrite = true; |
| ... | @@ -457,6 +509,445 @@ describe("thread outbox", () => { | ... | @@ -457,6 +509,445 @@ describe("thread outbox", () => { |
| 457 | registry.dispose(); | 509 | registry.dispose(); |
| 458 | }); | 510 | }); |
| 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 | it("only removes a missing-thread message after shell synchronization is live", () => { | 951 | it("only removes a missing-thread message after shell synchronization is live", () => { |
| 461 | expect( | 952 | expect( |
| 462 | resolveThreadOutboxDeliveryAction({ | 953 | resolveThreadOutboxDeliveryAction({ |
| ... | @@ -618,6 +1109,6 @@ describe("thread outbox", () => { | ... | @@ -618,6 +1109,6 @@ describe("thread outbox", () => { |
| 618 | error: deterministicFailure, | 1109 | error: deterministicFailure, |
| 619 | interrupted: false, | 1110 | interrupted: false, |
| 620 | }), | 1111 | }), |
| 621 | ).toBe("discard"); | 1112 | ).toBe("restore"); |
| 622 | }); | 1113 | }); |
| 623 | }); | 1114 | }); |
apps/mobile/src/state/thread-outbox.ts+16-10| ... | @@ -1,5 +1,3 @@ | ... | @@ -1,5 +1,3 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { appAtomRegistry } from "./atom-registry"; | 1 | import { appAtomRegistry } from "./atom-registry"; |
| 4 | import { createThreadOutboxManager } from "./thread-outbox-manager"; | 2 | import { createThreadOutboxManager } from "./thread-outbox-manager"; |
| 5 | import type { QueuedThreadMessage } from "./thread-outbox-model"; | 3 | import type { QueuedThreadMessage } from "./thread-outbox-model"; |
| ... | @@ -36,15 +34,23 @@ export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage): | ... | @@ -36,15 +34,23 @@ export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage): |
| 36 | return threadOutboxManager.confirmQueued(message); | 34 | return threadOutboxManager.confirmQueued(message); |
| 37 | } | 35 | } |
| 38 | 36 | ||
| 39 | /** Rewrite a queued message; no-op (false) if it was removed in the meantime. */ | 37 | /** |
| 40 | export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> { | 38 | * Rewrite a queued message; no-op (false) if it was removed in the meantime, |
| 41 | return threadOutboxManager.update(message); | 39 | * or (with `expectedRevision` from `threadOutboxRevision`) if any other write |
| 40 | * was accepted since the revision was read. | ||
| 41 | */ | ||
| 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> { | 49 | /** Snapshot of a queued message's write revision, for update's CAS. */ |
| 45 | return threadOutboxManager.remove(message); | 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> { | 54 | // Removal lives in `thread-outbox-removal.ts`: taking a message out of the |
| 49 | return threadOutboxManager.clearEnvironment(environmentId); | 55 | // outbox must also release its local attachment files, and that owner needs |
| 50 | } | 56 | // the composer draft state this module must not depend on. |
apps/mobile/src/state/use-composer-drafts.test.ts+691-33| ... | @@ -1,6 +1,12 @@ | ... | @@ -1,6 +1,12 @@ |
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | 2 | import { |
| 3 | import { vi } from "vite-plus/test"; | 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 | const composerDraftFileMocks = vi.hoisted(() => { | 11 | const composerDraftFileMocks = vi.hoisted(() => { |
| 6 | let document = ""; | 12 | let document = ""; |
| ... | @@ -79,14 +85,39 @@ const composerDraftFileMocks = vi.hoisted(() => { | ... | @@ -79,14 +85,39 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 79 | }; | 85 | }; |
| 80 | }); | 86 | }); |
| 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 | vi.mock("expo-file-system", () => ({ | 99 | vi.mock("expo-file-system", () => ({ |
| 83 | Directory: composerDraftFileMocks.Directory, | 100 | Directory: composerDraftFileMocks.Directory, |
| 84 | File: composerDraftFileMocks.File, | 101 | File: composerDraftFileMocks.File, |
| 85 | Paths: { document: "/documents" }, | 102 | Paths: { document: "/documents" }, |
| 86 | })); | 103 | })); |
| 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 | import { appAtomRegistry } from "./atom-registry"; | 117 | import { appAtomRegistry } from "./atom-registry"; |
| 118 | import { threadOutboxManager } from "./thread-outbox"; | ||
| 89 | import { | 119 | import { |
| 120 | appendComposerDraftAttachments, | ||
| 90 | clearComposerDraftContentState, | 121 | clearComposerDraftContentState, |
| 91 | clearComposerDraftsEnvironment, | 122 | clearComposerDraftsEnvironment, |
| 92 | ComposerDraftPersistenceError, | 123 | ComposerDraftPersistenceError, |
| ... | @@ -100,12 +131,15 @@ import { | ... | @@ -100,12 +131,15 @@ import { |
| 100 | flushComposerDrafts, | 131 | flushComposerDrafts, |
| 101 | getComposerDraftSnapshot, | 132 | getComposerDraftSnapshot, |
| 102 | mergeComposerDraftContentState, | 133 | mergeComposerDraftContentState, |
| 134 | releaseUnusedComposerAttachmentFiles, | ||
| 103 | removeComposerDraftsForEnvironment, | 135 | removeComposerDraftsForEnvironment, |
| 104 | resetComposerDraftsLoadState, | 136 | resetComposerDraftsLoadState, |
| 105 | restoreComposerDraftSnapshotState, | 137 | restoreComposerDraftSnapshotState, |
| 106 | setComposerDraftText, | 138 | setComposerDraftText, |
| 107 | setStickyComposerModelSelection, | 139 | setStickyComposerModelSelection, |
| 108 | stickyComposerModelSelectionAtom, | 140 | stickyComposerModelSelectionAtom, |
| 141 | undoComposerDraftMerge, | ||
| 142 | undoComposerDraftMergeState, | ||
| 109 | } from "./use-composer-drafts"; | 143 | } from "./use-composer-drafts"; |
| 110 | 144 | ||
| 111 | const DRAFT: ComposerDraft = { | 145 | const DRAFT: ComposerDraft = { |
| ... | @@ -123,9 +157,449 @@ afterEach(() => { | ... | @@ -123,9 +157,449 @@ afterEach(() => { |
| 123 | composerDraftFileMocks.resetWrites(); | 157 | composerDraftFileMocks.resetWrites(); |
| 124 | appAtomRegistry.set(composerDraftsAtom, {}); | 158 | appAtomRegistry.set(composerDraftsAtom, {}); |
| 125 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); | 159 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); |
| 160 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); | ||
| 161 | composerAttachmentCleanupMocks.remove.mockClear(); | ||
| 162 | composerAttachmentCleanupMocks.releaseUploads.mockReset(); | ||
| 163 | composerAttachmentCleanupMocks.releaseUploads.mockResolvedValue(undefined); | ||
| 164 | incomingShareStorageMocks.load.mockReset(); | ||
| 165 | incomingShareStorageMocks.load.mockResolvedValue([]); | ||
| 126 | }); | 166 | }); |
| 127 | 167 | ||
| 128 | describe("mobile composer drafts", () => { | 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 | it("hydrates selector state even when the message content is empty", () => { | 603 | it("hydrates selector state even when the message content is empty", () => { |
| 130 | expect( | 604 | expect( |
| 131 | decodePersistedComposerDrafts({ | 605 | decodePersistedComposerDrafts({ |
| ... | @@ -603,37 +1077,6 @@ describe("mobile composer drafts", () => { | ... | @@ -603,37 +1077,6 @@ describe("mobile composer drafts", () => { |
| 603 | }); | 1077 | }); |
| 604 | }); | 1078 | }); |
| 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 | it("lands a still-debounced draft write when flushed", async () => { | 1080 | it("lands a still-debounced draft write when flushed", async () => { |
| 638 | const draftKey = "environment-1:thread-1"; | 1081 | const draftKey = "environment-1:thread-1"; |
| 639 | setComposerDraftText(draftKey, "typed right before the restart"); | 1082 | setComposerDraftText(draftKey, "typed right before the restart"); |
| ... | @@ -656,4 +1099,219 @@ describe("mobile composer drafts", () => { | ... | @@ -656,4 +1099,219 @@ describe("mobile composer drafts", () => { |
| 656 | composerDraftFileMocks.setWriteError(null); | 1099 | composerDraftFileMocks.setWriteError(null); |
| 657 | } | 1100 | } |
| 658 | }); | 1101 | }); |
| 1102 | |||
| 1103 | it("restores the pre-merge snapshot when the draft is untouched since the merge", () => { | ||
| 1104 | const draftKey = "environment-1:thread-1"; | ||
| 1105 | const snapshot: ComposerDraft = { text: "typed before", attachments: [] }; | ||
| 1106 | const merged: ComposerDraft = { | ||
| 1107 | text: "typed before\n\nqueued text", | ||
| 1108 | attachments: [], | ||
| 1109 | runtimeMode: "approval-required", | ||
| 1110 | }; | ||
| 1111 | |||
| 1112 | expect(undoComposerDraftMergeState({ [draftKey]: merged }, draftKey, snapshot, merged)).toEqual( | ||
| 1113 | { [draftKey]: snapshot }, | ||
| 1114 | ); | ||
| 1115 | expect( | ||
| 1116 | undoComposerDraftMergeState( | ||
| 1117 | { [draftKey]: merged }, | ||
| 1118 | draftKey, | ||
| 1119 | { text: "", attachments: [] }, | ||
| 1120 | merged, | ||
| 1121 | ), | ||
| 1122 | ).toEqual({}); | ||
| 1123 | }); | ||
| 1124 | |||
| 1125 | it("persists an async merge rollback with the sticky model selection", async () => { | ||
| 1126 | const draftKey = "environment-1:thread-1"; | ||
| 1127 | const snapshot: ComposerDraft = { text: "typed before", attachments: [] }; | ||
| 1128 | const merged: ComposerDraft = { | ||
| 1129 | text: "typed before\n\nqueued text", | ||
| 1130 | attachments: [], | ||
| 1131 | }; | ||
| 1132 | composerDraftFileMocks.setDocument({ | ||
| 1133 | schemaVersion: 1, | ||
| 1134 | drafts: { [draftKey]: merged }, | ||
| 1135 | stickyModelSelection: { | ||
| 1136 | instanceId: "codex", | ||
| 1137 | model: "gpt-5.6-sol", | ||
| 1138 | }, | ||
| 1139 | }); | ||
| 1140 | |||
| 1141 | await undoComposerDraftMerge(draftKey, snapshot, merged); | ||
| 1142 | |||
| 1143 | expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({ | ||
| 1144 | schemaVersion: 1, | ||
| 1145 | drafts: { [draftKey]: snapshot }, | ||
| 1146 | stickyModelSelection: { | ||
| 1147 | instanceId: "codex", | ||
| 1148 | model: "gpt-5.6-sol", | ||
| 1149 | }, | ||
| 1150 | }); | ||
| 1151 | }); | ||
| 1152 | |||
| 1153 | it("returns merge-written settings to the snapshot but keeps user-edited ones", () => { | ||
| 1154 | const draftKey = "environment-1:thread-1"; | ||
| 1155 | const snapshot: ComposerDraft = { | ||
| 1156 | text: "typed before", | ||
| 1157 | attachments: [], | ||
| 1158 | runtimeMode: "approval-required", | ||
| 1159 | interactionMode: "default", | ||
| 1160 | }; | ||
| 1161 | const merged: ComposerDraft = { | ||
| 1162 | text: "typed before\n\nqueued text", | ||
| 1163 | attachments: [], | ||
| 1164 | runtimeMode: "full-access", | ||
| 1165 | interactionMode: "default", | ||
| 1166 | }; | ||
| 1167 | // The user edited the text (forcing the partial undo) and also switched | ||
| 1168 | // interaction mode, but never touched the merge-written runtime mode. | ||
| 1169 | const edited: ComposerDraft = { | ||
| 1170 | text: "typed EDITED before\n\nqueued text", | ||
| 1171 | attachments: [], | ||
| 1172 | runtimeMode: "full-access", | ||
| 1173 | interactionMode: "plan", | ||
| 1174 | }; | ||
| 1175 | |||
| 1176 | expect(undoComposerDraftMergeState({ [draftKey]: edited }, draftKey, snapshot, merged)).toEqual( | ||
| 1177 | { | ||
| 1178 | [draftKey]: { | ||
| 1179 | text: "typed EDITED before", | ||
| 1180 | attachments: [], | ||
| 1181 | runtimeMode: "approval-required", | ||
| 1182 | interactionMode: "plan", | ||
| 1183 | }, | ||
| 1184 | }, | ||
| 1185 | ); | ||
| 1186 | }); | ||
| 1187 | |||
| 1188 | it("takes out only what the merge inserted when the user edited during it", () => { | ||
| 1189 | const draftKey = "environment-1:thread-1"; | ||
| 1190 | const keptAttachment = { | ||
| 1191 | id: "kept", | ||
| 1192 | type: "file" as const, | ||
| 1193 | name: "kept.pdf", | ||
| 1194 | mimeType: "application/pdf", | ||
| 1195 | sizeBytes: 1, | ||
| 1196 | fileUri: "file:///documents/t3-composer-attachments/kept.pdf", | ||
| 1197 | }; | ||
| 1198 | const insertedAttachment = { | ||
| 1199 | id: "inserted", | ||
| 1200 | type: "file" as const, | ||
| 1201 | name: "inserted.pdf", | ||
| 1202 | mimeType: "application/pdf", | ||
| 1203 | sizeBytes: 1, | ||
| 1204 | fileUri: "file:///documents/t3-composer-attachments/inserted.pdf", | ||
| 1205 | }; | ||
| 1206 | const userAttachment = { ...keptAttachment, id: "user-added" }; | ||
| 1207 | const snapshot: ComposerDraft = { text: "typed before", attachments: [keptAttachment] }; | ||
| 1208 | const merged: ComposerDraft = { | ||
| 1209 | text: "typed before\n\nqueued text", | ||
| 1210 | attachments: [keptAttachment, insertedAttachment], | ||
| 1211 | }; | ||
| 1212 | // The user rewrote the leading text and attached a file mid-recovery. | ||
| 1213 | const edited: ComposerDraft = { | ||
| 1214 | text: "typed EDITED before\n\nqueued text", | ||
| 1215 | attachments: [keptAttachment, insertedAttachment, userAttachment], | ||
| 1216 | }; | ||
| 1217 | |||
| 1218 | expect(undoComposerDraftMergeState({ [draftKey]: edited }, draftKey, snapshot, merged)).toEqual( | ||
| 1219 | { | ||
| 1220 | [draftKey]: { | ||
| 1221 | text: "typed EDITED before", | ||
| 1222 | attachments: [keptAttachment, userAttachment], | ||
| 1223 | }, | ||
| 1224 | }, | ||
| 1225 | ); | ||
| 1226 | |||
| 1227 | // Edits that broke the merged suffix keep their text untouched; only the | ||
| 1228 | // inserted attachments still come out. | ||
| 1229 | const rewritten: ComposerDraft = { | ||
| 1230 | text: "totally rewritten", | ||
| 1231 | attachments: [insertedAttachment], | ||
| 1232 | }; | ||
| 1233 | expect( | ||
| 1234 | undoComposerDraftMergeState({ [draftKey]: rewritten }, draftKey, snapshot, merged), | ||
| 1235 | ).toEqual({ | ||
| 1236 | [draftKey]: { text: "totally rewritten", attachments: [] }, | ||
| 1237 | }); | ||
| 1238 | }); | ||
| 1239 | |||
| 1240 | it("keeps text appended after a merge when rolling it back", () => { | ||
| 1241 | const draftKey = "environment-1:thread-1"; | ||
| 1242 | const snapshot: ComposerDraft = { text: "typed before", attachments: [] }; | ||
| 1243 | const content = { text: "queued text", attachments: [] }; | ||
| 1244 | const merged = mergeComposerDraftContentState({ [draftKey]: snapshot }, draftKey, content)[ | ||
| 1245 | draftKey | ||
| 1246 | ]!; | ||
| 1247 | const edited: ComposerDraft = { | ||
| 1248 | ...merged, | ||
| 1249 | text: `${merged.text}\n\nuser follow-up`, | ||
| 1250 | }; | ||
| 1251 | |||
| 1252 | const rolledBack = undoComposerDraftMergeState( | ||
| 1253 | { [draftKey]: edited }, | ||
| 1254 | draftKey, | ||
| 1255 | snapshot, | ||
| 1256 | merged, | ||
| 1257 | ); | ||
| 1258 | |||
| 1259 | expect(rolledBack[draftKey]?.text).toBe("typed before\n\nuser follow-up"); | ||
| 1260 | const retried = mergeComposerDraftContentState(rolledBack, draftKey, content); | ||
| 1261 | expect(retried[draftKey]?.text.match(/queued text/g)).toHaveLength(1); | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | it("spares a file re-owned between the sweep's scan and its deletion", async () => { | ||
| 1265 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | ||
| 1266 | onTestFinished(() => outboxLoad.mockRestore()); | ||
| 1267 | const fileFor = (id: string) => ({ | ||
| 1268 | id, | ||
| 1269 | type: "file" as const, | ||
| 1270 | name: `${id}.pdf`, | ||
| 1271 | mimeType: "application/pdf", | ||
| 1272 | sizeBytes: 42, | ||
| 1273 | fileUri: `file:///documents/t3-composer-attachments/${id}.pdf`, | ||
| 1274 | }); | ||
| 1275 | const first = fileFor("file-first"); | ||
| 1276 | const reowned = fileFor("file-reowned"); | ||
| 1277 | // A restore re-owns the second file while the first deletion is in | ||
| 1278 | // flight, after the sweep already decided both were unused. | ||
| 1279 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | ||
| 1280 | appAtomRegistry.set(composerDraftsAtom, { | ||
| 1281 | "environment-1:thread-1": { text: "restored", attachments: [reowned] }, | ||
| 1282 | }); | ||
| 1283 | }); | ||
| 1284 | |||
| 1285 | await releaseUnusedComposerAttachmentFiles([first, reowned]); | ||
| 1286 | |||
| 1287 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[first.fileUri]]); | ||
| 1288 | }); | ||
| 1289 | |||
| 1290 | // Uses a fresh module instance (hydration is one-shot), so it stays last. | ||
| 1291 | it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => { | ||
| 1292 | const file = { | ||
| 1293 | id: "file-cold-start", | ||
| 1294 | type: "file" as const, | ||
| 1295 | name: "report.pdf", | ||
| 1296 | mimeType: "application/pdf", | ||
| 1297 | sizeBytes: 42, | ||
| 1298 | fileUri: "file:///documents/t3-composer-attachments/report.pdf", | ||
| 1299 | }; | ||
| 1300 | composerDraftFileMocks.setDocument({ | ||
| 1301 | schemaVersion: 1, | ||
| 1302 | drafts: { | ||
| 1303 | "environment-1:thread-1": { text: "Persisted draft", attachments: [file] }, | ||
| 1304 | }, | ||
| 1305 | }); | ||
| 1306 | vi.resetModules(); | ||
| 1307 | const fresh = await import("./use-composer-drafts"); | ||
| 1308 | const freshRegistry = (await import("./atom-registry")).appAtomRegistry; | ||
| 1309 | |||
| 1310 | await fresh.releaseUnusedComposerAttachmentFiles([file]); | ||
| 1311 | |||
| 1312 | expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({ | ||
| 1313 | "environment-1:thread-1": { text: "Persisted draft", attachments: [file] }, | ||
| 1314 | }); | ||
| 1315 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | ||
| 1316 | }); | ||
| 659 | }); | 1317 | }); |
apps/mobile/src/state/use-composer-drafts.ts+321-15| ... | @@ -14,10 +14,12 @@ import { useEffect } from "react"; | ... | @@ -14,10 +14,12 @@ import { useEffect } from "react"; |
| 14 | import { Atom } from "effect/unstable/reactivity"; | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 15 | 15 | ||
| 16 | import { writeFileAtomically } from "../lib/atomic-file"; | 16 | import { writeFileAtomically } from "../lib/atomic-file"; |
| 17 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; | 17 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 18 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 18 | import { composerAttachmentFileReferenceKey } from "../lib/composerAttachmentFiles"; |
| 19 | import type { DraftComposerAttachment } from "../lib/composerImages"; | ||
| 19 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; | 20 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 20 | import { appAtomRegistry } from "./atom-registry"; | 21 | import { appAtomRegistry } from "./atom-registry"; |
| 22 | import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; | ||
| 21 | 23 | ||
| 22 | const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; | 24 | const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; |
| 23 | const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; | 25 | const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; |
| ... | @@ -40,7 +42,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo | ... | @@ -40,7 +42,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo |
| 40 | 42 | ||
| 41 | export interface ComposerDraft { | 43 | export interface ComposerDraft { |
| 42 | readonly text: string; | 44 | readonly text: string; |
| 43 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 45 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 44 | readonly importedShareIds?: ReadonlyArray<string>; | 46 | readonly importedShareIds?: ReadonlyArray<string>; |
| 45 | readonly modelSelection?: ModelSelection; | 47 | readonly modelSelection?: ModelSelection; |
| 46 | readonly runtimeMode?: RuntimeMode; | 48 | readonly runtimeMode?: RuntimeMode; |
| ... | @@ -50,7 +52,7 @@ export interface ComposerDraft { | ... | @@ -50,7 +52,7 @@ export interface ComposerDraft { |
| 50 | 52 | ||
| 51 | export interface ComposerDraftContent { | 53 | export interface ComposerDraftContent { |
| 52 | readonly text: string; | 54 | readonly text: string; |
| 53 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 55 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; |
| 54 | readonly sourceShareId?: string; | 56 | readonly sourceShareId?: string; |
| 55 | } | 57 | } |
| 56 | 58 | ||
| ... | @@ -75,7 +77,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ | ... | @@ -75,7 +77,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ |
| 75 | 77 | ||
| 76 | const ComposerDraftSchema = Schema.Struct({ | 78 | const ComposerDraftSchema = Schema.Struct({ |
| 77 | text: Schema.String, | 79 | text: Schema.String, |
| 78 | attachments: Schema.Array(DraftComposerImageAttachmentSchema), | 80 | attachments: Schema.Array(DraftComposerAttachmentSchema), |
| 79 | importedShareIds: Schema.optional(Schema.Array(Schema.String)), | 81 | importedShareIds: Schema.optional(Schema.Array(Schema.String)), |
| 80 | modelSelection: Schema.optional(ModelSelectionSchema), | 82 | modelSelection: Schema.optional(ModelSelectionSchema), |
| 81 | runtimeMode: Schema.optional(RuntimeModeSchema), | 83 | runtimeMode: Schema.optional(RuntimeModeSchema), |
| ... | @@ -284,6 +286,156 @@ export async function flushComposerDrafts(): Promise<void> { | ... | @@ -284,6 +286,156 @@ export async function flushComposerDrafts(): Promise<void> { |
| 284 | } while (persistTimer !== null); | 286 | } while (persistTimer !== null); |
| 285 | } | 287 | } |
| 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 | function schedulePersistComposerState(): void { | 439 | function schedulePersistComposerState(): void { |
| 288 | if (persistTimer !== null) { | 440 | if (persistTimer !== null) { |
| 289 | clearTimeout(persistTimer); | 441 | clearTimeout(persistTimer); |
| ... | @@ -344,6 +496,14 @@ export function ensureComposerDraftsLoaded(): void { | ... | @@ -344,6 +496,14 @@ export function ensureComposerDraftsLoaded(): void { |
| 344 | }); | 496 | }); |
| 345 | } | 497 | } |
| 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 | function updateComposerDrafts( | 507 | function updateComposerDrafts( |
| 348 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, | 508 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, |
| 349 | ): void { | 509 | ): void { |
| ... | @@ -392,29 +552,49 @@ export function appendComposerDraftText(draftKey: string, value: string): void { | ... | @@ -392,29 +552,49 @@ export function appendComposerDraftText(draftKey: string, value: string): void { |
| 392 | }); | 552 | }); |
| 393 | } | 553 | } |
| 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 | export function appendComposerDraftAttachments( | 562 | export function appendComposerDraftAttachments( |
| 396 | draftKey: string, | 563 | draftKey: string, |
| 397 | attachments: ReadonlyArray<DraftComposerImageAttachment>, | 564 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 398 | ): void { | 565 | options?: { readonly allowOverflow?: boolean }, |
| 566 | ): number { | ||
| 399 | if (attachments.length === 0) { | 567 | if (attachments.length === 0) { |
| 400 | return; | 568 | return 0; |
| 401 | } | 569 | } |
| 570 | let rejected: ReadonlyArray<DraftComposerAttachment> = []; | ||
| 402 | updateComposerDrafts((current) => { | 571 | updateComposerDrafts((current) => { |
| 403 | const existing = normalizeDraft(current[draftKey]); | 572 | const existing = normalizeDraft(current[draftKey]); |
| 573 | const remaining = options?.allowOverflow | ||
| 574 | ? attachments.length | ||
| 575 | : Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - existing.attachments.length); | ||
| 576 | const accepted = attachments.slice(0, remaining); | ||
| 577 | rejected = attachments.slice(remaining); | ||
| 578 | if (accepted.length === 0) { | ||
| 579 | return current; | ||
| 580 | } | ||
| 404 | return { | 581 | return { |
| 405 | ...current, | 582 | ...current, |
| 406 | [draftKey]: { | 583 | [draftKey]: { |
| 407 | ...existing, | 584 | ...existing, |
| 408 | attachments: [...existing.attachments, ...attachments], | 585 | attachments: [...existing.attachments, ...accepted], |
| 409 | }, | 586 | }, |
| 410 | }; | 587 | }; |
| 411 | }); | 588 | }); |
| 589 | scheduleUnusedComposerAttachmentCleanup(rejected); | ||
| 590 | return rejected.length; | ||
| 412 | } | 591 | } |
| 413 | 592 | ||
| 414 | export function replaceComposerDraftAttachments( | 593 | export function replaceComposerDraftAttachments( |
| 415 | draftKey: string, | 594 | draftKey: string, |
| 416 | attachments: ReadonlyArray<DraftComposerImageAttachment>, | 595 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 417 | ): void { | 596 | ): void { |
| 597 | const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; | ||
| 418 | updateComposerDrafts((current) => { | 598 | updateComposerDrafts((current) => { |
| 419 | const draft = { | 599 | const draft = { |
| 420 | ...normalizeDraft(current[draftKey]), | 600 | ...normalizeDraft(current[draftKey]), |
| ... | @@ -430,9 +610,14 @@ export function replaceComposerDraftAttachments( | ... | @@ -430,9 +610,14 @@ export function replaceComposerDraftAttachments( |
| 430 | [draftKey]: draft, | 610 | [draftKey]: draft, |
| 431 | }; | 611 | }; |
| 432 | }); | 612 | }); |
| 613 | const retainedIds = new Set(attachments.map((attachment) => attachment.id)); | ||
| 614 | scheduleUnusedComposerAttachmentCleanup( | ||
| 615 | previousAttachments.filter((attachment) => !retainedIds.has(attachment.id)), | ||
| 616 | ); | ||
| 433 | } | 617 | } |
| 434 | 618 | ||
| 435 | export function removeComposerDraftAttachment(draftKey: string, imageId: string): void { | 619 | export function removeComposerDraftAttachment(draftKey: string, imageId: string): void { |
| 620 | const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; | ||
| 436 | updateComposerDrafts((current) => { | 621 | updateComposerDrafts((current) => { |
| 437 | const existing = normalizeDraft(current[draftKey]); | 622 | const existing = normalizeDraft(current[draftKey]); |
| 438 | const draft = { | 623 | const draft = { |
| ... | @@ -449,6 +634,9 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) | ... | @@ -449,6 +634,9 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) |
| 449 | [draftKey]: draft, | 634 | [draftKey]: draft, |
| 450 | }; | 635 | }; |
| 451 | }); | 636 | }); |
| 637 | scheduleUnusedComposerAttachmentCleanup( | ||
| 638 | previousAttachments.filter((attachment) => attachment.id === imageId), | ||
| 639 | ); | ||
| 452 | } | 640 | } |
| 453 | 641 | ||
| 454 | export function updateComposerDraftSettings( | 642 | export function updateComposerDraftSettings( |
| ... | @@ -691,17 +879,130 @@ export async function restoreComposerDraftSnapshot( | ... | @@ -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 | export function clearComposerDraftContent( | 981 | export function clearComposerDraftContent( |
| 695 | draftKey: string, | 982 | draftKey: string, |
| 696 | options?: { | 983 | options?: { |
| 697 | readonly clearModelSelection?: boolean; | 984 | readonly clearModelSelection?: boolean; |
| 698 | readonly clearWorkspaceSelection?: boolean; | 985 | readonly clearWorkspaceSelection?: boolean; |
| 986 | // Send clears the draft while the durable outbox write is still in | ||
| 987 | // flight. Sweeping then would race the write: a failed enqueue rolls the | ||
| 988 | // message out of the queue mid-sweep and its files get deleted right | ||
| 989 | // before the failure handler restores them. The sender re-schedules | ||
| 990 | // cleanup once the write settles. | ||
| 991 | readonly deferAttachmentCleanup?: boolean; | ||
| 699 | }, | 992 | }, |
| 700 | ): void { | 993 | ): void { |
| 994 | const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; | ||
| 701 | updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); | 995 | updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); |
| 996 | if (!options?.deferAttachmentCleanup) { | ||
| 997 | scheduleUnusedComposerAttachmentCleanup(previousAttachments); | ||
| 998 | } | ||
| 702 | } | 999 | } |
| 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 | updateComposerDrafts((current) => { | 1006 | updateComposerDrafts((current) => { |
| 706 | if (!current[draftKey]) { | 1007 | if (!current[draftKey]) { |
| 707 | return current; | 1008 | return current; |
| ... | @@ -710,6 +1011,9 @@ export function clearComposerDraft(draftKey: string): void { | ... | @@ -710,6 +1011,9 @@ export function clearComposerDraft(draftKey: string): void { |
| 710 | delete next[draftKey]; | 1011 | delete next[draftKey]; |
| 711 | return next; | 1012 | return next; |
| 712 | }); | 1013 | }); |
| 1014 | if (!options?.deferAttachmentCleanup) { | ||
| 1015 | scheduleUnusedComposerAttachmentCleanup(previousAttachments); | ||
| 1016 | } | ||
| 713 | } | 1017 | } |
| 714 | 1018 | ||
| 715 | export function removeComposerDraftsForEnvironment( | 1019 | export function removeComposerDraftsForEnvironment( |
| ... | @@ -732,10 +1036,11 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI | ... | @@ -732,10 +1036,11 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI |
| 732 | await loadPromise; | 1036 | await loadPromise; |
| 733 | } | 1037 | } |
| 734 | 1038 | ||
| 735 | const next = removeComposerDraftsForEnvironment( | 1039 | const current = appAtomRegistry.get(composerDraftsAtom); |
| 736 | appAtomRegistry.get(composerDraftsAtom), | 1040 | const next = removeComposerDraftsForEnvironment(current, environmentId); |
| 737 | environmentId, | 1041 | const removedAttachments = Object.entries(current) |
| 738 | ); | 1042 | .filter(([draftKey]) => next[draftKey] === undefined) |
| 1043 | .flatMap(([, draft]) => draft.attachments); | ||
| 739 | 1044 | ||
| 740 | if (persistTimer !== null) { | 1045 | if (persistTimer !== null) { |
| 741 | clearTimeout(persistTimer); | 1046 | clearTimeout(persistTimer); |
| ... | @@ -745,6 +1050,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI | ... | @@ -745,6 +1050,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI |
| 745 | await persistenceQueue.run(() => | 1050 | await persistenceQueue.run(() => |
| 746 | writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), | 1051 | writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), |
| 747 | ); | 1052 | ); |
| 1053 | await releaseUnusedComposerAttachmentFiles(removedAttachments); | ||
| 748 | } | 1054 | } |
| 749 | 1055 | ||
| 750 | export function useComposerDraft(draftKey: string | null): ComposerDraft { | 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 +6,7 @@ import * as Cause from "effect/Cause"; |
| 6 | import { | 6 | import { |
| 7 | CommandId, | 7 | CommandId, |
| 8 | MessageId, | 8 | MessageId, |
| 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 9 | type EnvironmentId, | 10 | type EnvironmentId, |
| 10 | type ModelSelection, | 11 | type ModelSelection, |
| 11 | type ProviderInteractionMode, | 12 | type ProviderInteractionMode, |
| ... | @@ -26,6 +27,7 @@ import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; | ... | @@ -26,6 +27,7 @@ import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; |
| 26 | import { | 27 | import { |
| 27 | convertPastedImagesToAttachments, | 28 | convertPastedImagesToAttachments, |
| 28 | pasteComposerClipboard, | 29 | pasteComposerClipboard, |
| 30 | pickComposerFiles, | ||
| 29 | pickComposerImages, | 31 | pickComposerImages, |
| 30 | } from "../lib/composerImages"; | 32 | } from "../lib/composerImages"; |
| 31 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 33 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| ... | @@ -42,6 +44,7 @@ import { | ... | @@ -42,6 +44,7 @@ import { |
| 42 | getComposerDraftSnapshot, | 44 | getComposerDraftSnapshot, |
| 43 | mergeComposerDraftContent, | 45 | mergeComposerDraftContent, |
| 44 | removeComposerDraftAttachment, | 46 | removeComposerDraftAttachment, |
| 47 | scheduleUnusedComposerAttachmentCleanup, | ||
| 45 | setComposerDraftText, | 48 | setComposerDraftText, |
| 46 | updateComposerDraftSettings, | 49 | updateComposerDraftSettings, |
| 47 | useComposerDraft, | 50 | useComposerDraft, |
| ... | @@ -65,7 +68,14 @@ export function appendReviewCommentToDraft(input: { | ... | @@ -65,7 +68,14 @@ export function appendReviewCommentToDraft(input: { |
| 65 | const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; | 68 | const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; |
| 66 | setComposerDraftText(threadKey, `${existing}${separator}${input.text}`); | 69 | setComposerDraftText(threadKey, `${existing}${separator}${input.text}`); |
| 67 | if (input.attachments && input.attachments.length > 0) { | 70 | if (input.attachments && input.attachments.length > 0) { |
| 68 | appendComposerDraftAttachments(threadKey, input.attachments); | 71 | // Capped: a review comment is new content, not a send-failure restore, so |
| 72 | // it must not push the draft over the send limit. Overflow is released. | ||
| 73 | const rejectedCount = appendComposerDraftAttachments(threadKey, input.attachments); | ||
| 74 | if (rejectedCount > 0) { | ||
| 75 | setPendingConnectionError( | ||
| 76 | `${rejectedCount} comment attachment${rejectedCount === 1 ? " was" : "s were"} not added. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, | ||
| 77 | ); | ||
| 78 | } | ||
| 69 | } | 79 | } |
| 70 | } | 80 | } |
| 71 | 81 | ||
| ... | @@ -171,6 +181,17 @@ export function useThreadComposerState() { | ... | @@ -171,6 +181,17 @@ export function useThreadComposerState() { |
| 171 | if (text.length === 0 && attachments.length === 0) { | 181 | if (text.length === 0 && attachments.length === 0) { |
| 172 | return null; | 182 | return null; |
| 173 | } | 183 | } |
| 184 | // A send-failure restore appends with allowOverflow so it never drops the | ||
| 185 | // user's files, which can leave the draft over the cap. Sending it anyway | ||
| 186 | // would enqueue a message that outbox recovery rejects forever, so block | ||
| 187 | // here until the user removes attachments. | ||
| 188 | if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | ||
| 189 | Alert.alert( | ||
| 190 | "Too many attachments", | ||
| 191 | `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, | ||
| 192 | ); | ||
| 193 | return null; | ||
| 194 | } | ||
| 174 | 195 | ||
| 175 | const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( | 196 | const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( |
| 176 | (entry) => entry.instanceId === thread.modelSelection.instanceId, | 197 | (entry) => entry.instanceId === thread.modelSelection.instanceId, |
| ... | @@ -255,18 +276,26 @@ export function useThreadComposerState() { | ... | @@ -255,18 +276,26 @@ export function useThreadComposerState() { |
| 255 | interactionMode: draft.interactionMode ?? thread.interactionMode, | 276 | interactionMode: draft.interactionMode ?? thread.interactionMode, |
| 256 | createdAt: metadata.createdAt, | 277 | createdAt: metadata.createdAt, |
| 257 | }); | 278 | }); |
| 258 | clearComposerDraftContent(threadKey); | 279 | clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); |
| 259 | enqueuePromise.catch((error: unknown) => { | 280 | enqueuePromise.then( |
| 260 | // Restore text via merge (idempotent) but attachments via the uncapped | 281 | () => { |
| 261 | // append: the merge path slots existing attachments first and truncates | 282 | // The queued message owns the files now; the sweep sees that and |
| 262 | // at the send limit, which would silently drop this message's images if | 283 | // spares them. Deferred to here so a failed write cannot roll the |
| 263 | // the user attached new ones while the write was in flight. | 284 | // message out of the queue mid-sweep and lose the bytes. |
| 264 | void mergeComposerDraftContent(threadKey, { text, attachments: [] }); | 285 | scheduleUnusedComposerAttachmentCleanup(attachments); |
| 265 | appendComposerDraftAttachments(threadKey, attachments); | 286 | }, |
| 266 | setPendingConnectionError( | 287 | (error: unknown) => { |
| 267 | error instanceof Error ? error.message : "Failed to save the queued message.", | 288 | // Restore text via merge (idempotent) but attachments via the uncapped |
| 268 | ); | 289 | // append: the merge path slots existing attachments first and truncates |
| 269 | }); | 290 | // at the send limit, which would silently drop this message's images if |
| 291 | // the user attached new ones while the write was in flight. | ||
| 292 | void mergeComposerDraftContent(threadKey, { text, attachments: [] }); | ||
| 293 | appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); | ||
| 294 | setPendingConnectionError( | ||
| 295 | error instanceof Error ? error.message : "Failed to save the queued message.", | ||
| 296 | ); | ||
| 297 | }, | ||
| 298 | ); | ||
| 270 | return messageId; | 299 | return messageId; |
| 271 | }, [ | 300 | }, [ |
| 272 | selectedEnvironmentRuntime?.serverConfig?.providers, | 301 | selectedEnvironmentRuntime?.serverConfig?.providers, |
| ... | @@ -296,14 +325,50 @@ export function useThreadComposerState() { | ... | @@ -296,14 +325,50 @@ export function useThreadComposerState() { |
| 296 | const result = await pickComposerImages({ | 325 | const result = await pickComposerImages({ |
| 297 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | 326 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, |
| 298 | }); | 327 | }); |
| 299 | if (result.images.length > 0) { | 328 | const rejectedImageCount = appendComposerDraftAttachments(threadKey, result.images); |
| 300 | appendComposerDraftAttachments(threadKey, result.images); | 329 | const problems = [ |
| 301 | } | 330 | ...(result.error ? [result.error] : []), |
| 302 | if (result.error) { | 331 | ...(rejectedImageCount > 0 |
| 303 | setPendingConnectionError(result.error); | 332 | ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`] |
| 333 | : []), | ||
| 334 | ]; | ||
| 335 | if (problems.length > 0) { | ||
| 336 | Alert.alert("Could not attach image", problems.join("\n\n")); | ||
| 304 | } | 337 | } |
| 305 | }, [composerDrafts, selectedThreadShell]); | 338 | }, [composerDrafts, selectedThreadShell]); |
| 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 | const onPasteIntoDraft = useCallback(async () => { | 372 | const onPasteIntoDraft = useCallback(async () => { |
| 308 | if (!selectedThreadShell) { | 373 | if (!selectedThreadShell) { |
| 309 | return; | 374 | return; |
| ... | @@ -313,14 +378,16 @@ export function useThreadComposerState() { | ... | @@ -313,14 +378,16 @@ export function useThreadComposerState() { |
| 313 | const result = await pasteComposerClipboard({ | 378 | const result = await pasteComposerClipboard({ |
| 314 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | 379 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, |
| 315 | }); | 380 | }); |
| 316 | if (result.images.length > 0) { | 381 | const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images); |
| 317 | appendComposerDraftAttachments(threadKey, result.images); | ||
| 318 | } | ||
| 319 | if (result.text) { | 382 | if (result.text) { |
| 320 | appendComposerDraftText(threadKey, result.text); | 383 | appendComposerDraftText(threadKey, result.text); |
| 321 | } | 384 | } |
| 322 | if (result.error) { | 385 | if (result.error) { |
| 323 | setPendingConnectionError(result.error); | 386 | setPendingConnectionError(result.error); |
| 387 | } else if (rejectedPasteCount > 0) { | ||
| 388 | setPendingConnectionError( | ||
| 389 | `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, | ||
| 390 | ); | ||
| 324 | } | 391 | } |
| 325 | }, [composerDrafts, selectedThreadShell]); | 392 | }, [composerDrafts, selectedThreadShell]); |
| 326 | 393 | ||
| ... | @@ -404,6 +471,7 @@ export function useThreadComposerState() { | ... | @@ -404,6 +471,7 @@ export function useThreadComposerState() { |
| 404 | interactionMode, | 471 | interactionMode, |
| 405 | onChangeDraftMessage, | 472 | onChangeDraftMessage, |
| 406 | onPickDraftImages, | 473 | onPickDraftImages, |
| 474 | onPickDraftFiles, | ||
| 407 | onPasteIntoDraft, | 475 | onPasteIntoDraft, |
| 408 | onNativePasteImages, | 476 | onNativePasteImages, |
| 409 | onRemoveDraftImage, | 477 | onRemoveDraftImage, |
apps/mobile/src/state/use-thread-outbox-drain.test.ts created+574| ... | @@ -0,0 +1,574 @@ | ||
| 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,6 +8,7 @@ import { |
| 8 | CommandId, | 8 | CommandId, |
| 9 | DEFAULT_PROVIDER_INTERACTION_MODE, | 9 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 10 | DEFAULT_RUNTIME_MODE, | 10 | DEFAULT_RUNTIME_MODE, |
| 11 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 11 | type MessageId, | 12 | type MessageId, |
| 12 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 13 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; | 14 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| ... | @@ -15,36 +16,56 @@ import * as Cause from "effect/Cause"; | ... | @@ -15,36 +16,56 @@ import * as Cause from "effect/Cause"; |
| 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 16 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 16 | import { useCallback, useEffect, useRef, useState } from "react"; | 17 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 17 | 18 | ||
| 18 | import { scopedThreadKey } from "../lib/scopedEntities"; | 19 | import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; |
| 19 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; | 20 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; |
| 20 | import { toUploadChatImageAttachments } from "../lib/composerImages"; | 21 | import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; |
| 21 | import { randomHex } from "../lib/uuid"; | 22 | import { randomHex } from "../lib/uuid"; |
| 22 | import { appAtomRegistry } from "./atom-registry"; | 23 | import { appAtomRegistry } from "./atom-registry"; |
| 23 | import { useProjects, useThreadShells } from "./entities"; | 24 | import { useProjects, useServerConfigs, useThreadShells } from "./entities"; |
| 24 | import { | 25 | import { |
| 25 | confirmThreadOutboxMessageQueued, | 26 | confirmThreadOutboxMessageQueued, |
| 26 | ensureThreadOutboxLoaded, | 27 | ensureThreadOutboxLoaded, |
| 27 | removeThreadOutboxMessage, | 28 | threadOutboxManager, |
| 29 | threadOutboxRevision, | ||
| 30 | updateThreadOutboxMessage, | ||
| 28 | } from "./thread-outbox"; | 31 | } from "./thread-outbox"; |
| 32 | import { removeThreadOutboxMessage } from "./thread-outbox-removal"; | ||
| 29 | import { | 33 | import { |
| 30 | isQueuedThreadCreationSendable, | 34 | isQueuedThreadCreationSendable, |
| 31 | modelSelectionsEqual, | 35 | modelSelectionsEqual, |
| 32 | resolveThreadOutboxDeliveryAction, | 36 | resolveThreadOutboxDeliveryAction, |
| 37 | resolveThreadOutboxDispatchStep, | ||
| 33 | resolveThreadOutboxFailureAction, | 38 | resolveThreadOutboxFailureAction, |
| 34 | resolveQueuedThreadSettings, | 39 | resolveQueuedThreadSettings, |
| 40 | shouldRetryThreadOutboxDelivery, | ||
| 35 | threadOutboxRetryDelayMs, | 41 | threadOutboxRetryDelayMs, |
| 36 | type QueuedThreadCreation, | 42 | type QueuedThreadCreation, |
| 37 | type QueuedThreadMessage, | 43 | type QueuedThreadMessage, |
| 38 | type ThreadOutboxCommandStage, | 44 | type ThreadOutboxCommandStage, |
| 39 | } from "./thread-outbox-model"; | 45 | } from "./thread-outbox-model"; |
| 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 | import { useAtomCommand } from "./use-atom-command"; | 59 | import { useAtomCommand } from "./use-atom-command"; |
| 42 | import { | 60 | import { |
| 43 | editingQueuedMessageIdsAtom, | 61 | editingQueuedMessageIdsAtom, |
| 44 | useThreadOutboxMessages, | 62 | useThreadOutboxMessages, |
| 45 | useThreadOutboxShellStatuses, | 63 | useThreadOutboxShellStatuses, |
| 46 | } from "./use-thread-outbox"; | 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 | export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( | 70 | export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( |
| 50 | Atom.keepAlive, | 71 | Atom.keepAlive, |
| ... | @@ -85,6 +106,384 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma | ... | @@ -85,6 +106,384 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma |
| 85 | return CommandId.make(`${message.commandId}:${setting}`); | 106 | return CommandId.make(`${message.commandId}:${setting}`); |
| 86 | } | 107 | } |
| 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 | export function useThreadOutboxDrain(): void { | 487 | export function useThreadOutboxDrain(): void { |
| 89 | const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); | 488 | const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); |
| 90 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | 489 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| ... | @@ -102,11 +501,76 @@ export function useThreadOutboxDrain(): void { | ... | @@ -102,11 +501,76 @@ export function useThreadOutboxDrain(): void { |
| 102 | const shellStatuses = useThreadOutboxShellStatuses(); | 501 | const shellStatuses = useThreadOutboxShellStatuses(); |
| 103 | const threads = useThreadShells(); | 502 | const threads = useThreadShells(); |
| 104 | const projects = useProjects(); | 503 | const projects = useProjects(); |
| 504 | const serverConfigs = useServerConfigs(); | ||
| 105 | const { connectedEnvironments } = useRemoteConnectionStatus(); | 505 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 106 | const [retryTick, setRetryTick] = useState(0); | 506 | const [retryTick, setRetryTick] = useState(0); |
| 107 | const retryAttemptRef = useRef(new Map<MessageId, number>()); | 507 | const retryAttemptRef = useRef(new Map<MessageId, number>()); |
| 108 | const retryNotBeforeRef = useRef(new Map<MessageId, number>()); | 508 | const retryNotBeforeRef = useRef(new Map<MessageId, number>()); |
| 109 | const retryTimersRef = useRef(new Map<MessageId, ReturnType<typeof setTimeout>>()); | 509 | const retryTimersRef = useRef(new Map<MessageId, ReturnType<typeof setTimeout>>()); |
| 510 | const acknowledgedExistingThreadMessageIdsRef = useRef(new Set<MessageId>()); | ||
| 511 | const blockedRecoverySubscriptionsRef = useRef( | ||
| 512 | new Map< | ||
| 513 | MessageId, | ||
| 514 | { readonly message: QueuedThreadMessage; readonly unsubscribe: () => void } | ||
| 515 | >(), | ||
| 516 | ); | ||
| 517 | |||
| 518 | const scheduleQueuedMessageRetry = useCallback((messageId: MessageId) => { | ||
| 519 | const retryAttempt = (retryAttemptRef.current.get(messageId) ?? 0) + 1; | ||
| 520 | retryAttemptRef.current.set(messageId, retryAttempt); | ||
| 521 | const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); | ||
| 522 | retryNotBeforeRef.current.set(messageId, Date.now() + retryDelayMs); | ||
| 523 | const pendingTimer = retryTimersRef.current.get(messageId); | ||
| 524 | if (pendingTimer !== undefined) { | ||
| 525 | clearTimeout(pendingTimer); | ||
| 526 | } | ||
| 527 | const retryTimer = setTimeout(() => { | ||
| 528 | retryTimersRef.current.delete(messageId); | ||
| 529 | setRetryTick((current) => current + 1); | ||
| 530 | }, retryDelayMs); | ||
| 531 | retryTimersRef.current.set(messageId, retryTimer); | ||
| 532 | }, []); | ||
| 533 | |||
| 534 | const restoreQueuedMessage = useCallback( | ||
| 535 | async (queuedMessage: QueuedThreadMessage, message: string): Promise<boolean> => { | ||
| 536 | const result = await restoreRejectedQueuedMessage(queuedMessage, message); | ||
| 537 | if (result !== "blocked") { | ||
| 538 | return result !== "retry"; | ||
| 539 | } | ||
| 540 | |||
| 541 | if (!blockedRecoverySubscriptionsRef.current.has(queuedMessage.messageId)) { | ||
| 542 | const draftKey = recoveryDraftKey(queuedMessage); | ||
| 543 | const editorDraftKey = queuedMessage.creation | ||
| 544 | ? `pending-task:${queuedMessage.messageId}` | ||
| 545 | : null; | ||
| 546 | const currentDrafts = appAtomRegistry.get(composerDraftsAtom); | ||
| 547 | const blockedAttachments = currentDrafts[draftKey]?.attachments; | ||
| 548 | const editorAttachments = | ||
| 549 | editorDraftKey === null ? undefined : currentDrafts[editorDraftKey]?.attachments; | ||
| 550 | const unsubscribe = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => { | ||
| 551 | if ( | ||
| 552 | drafts[draftKey]?.attachments === blockedAttachments && | ||
| 553 | (editorDraftKey === null || drafts[editorDraftKey]?.attachments === editorAttachments) | ||
| 554 | ) { | ||
| 555 | return; | ||
| 556 | } | ||
| 557 | const active = blockedRecoverySubscriptionsRef.current.get(queuedMessage.messageId); | ||
| 558 | if (!active) { | ||
| 559 | return; | ||
| 560 | } | ||
| 561 | blockedRecoverySubscriptionsRef.current.delete(queuedMessage.messageId); | ||
| 562 | active.unsubscribe(); | ||
| 563 | setRetryTick((current) => current + 1); | ||
| 564 | }); | ||
| 565 | blockedRecoverySubscriptionsRef.current.set(queuedMessage.messageId, { | ||
| 566 | message: queuedMessage, | ||
| 567 | unsubscribe, | ||
| 568 | }); | ||
| 569 | } | ||
| 570 | return true; | ||
| 571 | }, | ||
| 572 | [], | ||
| 573 | ); | ||
| 110 | 574 | ||
| 111 | useEffect(() => { | 575 | useEffect(() => { |
| 112 | ensureThreadOutboxLoaded(); | 576 | ensureThreadOutboxLoaded(); |
| ... | @@ -115,6 +579,10 @@ export function useThreadOutboxDrain(): void { | ... | @@ -115,6 +579,10 @@ export function useThreadOutboxDrain(): void { |
| 115 | clearTimeout(timer); | 579 | clearTimeout(timer); |
| 116 | } | 580 | } |
| 117 | retryTimersRef.current.clear(); | 581 | retryTimersRef.current.clear(); |
| 582 | for (const blocked of blockedRecoverySubscriptionsRef.current.values()) { | ||
| 583 | blocked.unsubscribe(); | ||
| 584 | } | ||
| 585 | blockedRecoverySubscriptionsRef.current.clear(); | ||
| 118 | }; | 586 | }; |
| 119 | }, []); | 587 | }, []); |
| 120 | 588 | ||
| ... | @@ -122,53 +590,36 @@ export function useThreadOutboxDrain(): void { | ... | @@ -122,53 +590,36 @@ export function useThreadOutboxDrain(): void { |
| 122 | const reportFailure = ( | 590 | const reportFailure = ( |
| 123 | commandResult: AtomCommandResult<unknown, unknown>, | 591 | commandResult: AtomCommandResult<unknown, unknown>, |
| 124 | stage: ThreadOutboxCommandStage, | 592 | stage: ThreadOutboxCommandStage, |
| 125 | ): boolean => { | 593 | ): { readonly action: "retry" | "restore"; readonly message: string } | null => { |
| 126 | if (!AsyncResult.isFailure(commandResult)) { | 594 | if (!AsyncResult.isFailure(commandResult)) { |
| 127 | return false; | 595 | return null; |
| 128 | } | 596 | } |
| 597 | const error = Cause.squash(commandResult.cause); | ||
| 129 | const action = resolveThreadOutboxFailureAction({ | 598 | const action = resolveThreadOutboxFailureAction({ |
| 130 | stage, | 599 | stage, |
| 131 | error: Cause.squash(commandResult.cause), | 600 | error, |
| 132 | interrupted: Cause.hasInterruptsOnly(commandResult.cause), | 601 | interrupted: Cause.hasInterruptsOnly(commandResult.cause), |
| 133 | }); | 602 | }); |
| 134 | const retry = action === "retry"; | ||
| 135 | console.warn("[thread-outbox] queued message delivery failed", { | 603 | console.warn("[thread-outbox] queued message delivery failed", { |
| 136 | environmentId: queuedMessage.environmentId, | 604 | environmentId: queuedMessage.environmentId, |
| 137 | threadId: queuedMessage.threadId, | 605 | threadId: queuedMessage.threadId, |
| 138 | messageId: queuedMessage.messageId, | 606 | messageId: queuedMessage.messageId, |
| 139 | stage, | 607 | stage, |
| 140 | cause: commandResult.cause, | 608 | cause: commandResult.cause, |
| 141 | retry, | 609 | action, |
| 142 | }); | 610 | }); |
| 143 | return retry; | 611 | return { |
| 612 | action, | ||
| 613 | message: error instanceof Error ? error.message : "The message could not be sent.", | ||
| 614 | }; | ||
| 144 | }; | 615 | }; |
| 145 | const completeDelivery = async ( | 616 | return { reportFailure }; |
| 146 | deliveryResult: AtomCommandResult<unknown, unknown>, | ||
| 147 | ): Promise<boolean> => { | ||
| 148 | if (reportFailure(deliveryResult, "start-turn")) { | ||
| 149 | return false; | ||
| 150 | } | ||
| 151 | |||
| 152 | try { | ||
| 153 | await removeThreadOutboxMessage(queuedMessage); | ||
| 154 | return true; | ||
| 155 | } catch (error) { | ||
| 156 | console.warn("[thread-outbox] failed to remove delivered queued message", { | ||
| 157 | environmentId: queuedMessage.environmentId, | ||
| 158 | threadId: queuedMessage.threadId, | ||
| 159 | messageId: queuedMessage.messageId, | ||
| 160 | error, | ||
| 161 | }); | ||
| 162 | return false; | ||
| 163 | } | ||
| 164 | }; | ||
| 165 | return { reportFailure, completeDelivery }; | ||
| 166 | }, []); | 617 | }, []); |
| 167 | 618 | ||
| 168 | const sendQueuedMessage = useCallback( | 619 | const sendQueuedMessage = useCallback( |
| 169 | async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { | 620 | async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { |
| 170 | const settings = resolveQueuedThreadSettings(queuedMessage, thread); | 621 | const settings = resolveQueuedThreadSettings(queuedMessage, thread); |
| 171 | const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage); | 622 | const { reportFailure } = makeDeliveryHelpers(queuedMessage); |
| 172 | 623 | ||
| 173 | if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { | 624 | if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { |
| 174 | const updateResult = await updateThreadMetadata({ | 625 | const updateResult = await updateThreadMetadata({ |
| ... | @@ -217,6 +668,37 @@ export function useThreadOutboxDrain(): void { | ... | @@ -217,6 +668,37 @@ export function useThreadOutboxDrain(): void { |
| 217 | } | 668 | } |
| 218 | } | 669 | } |
| 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 | const deliveryResult = await startTurn({ | 702 | const deliveryResult = await startTurn({ |
| 221 | environmentId: queuedMessage.environmentId, | 703 | environmentId: queuedMessage.environmentId, |
| 222 | input: { | 704 | input: { |
| ... | @@ -226,7 +708,7 @@ export function useThreadOutboxDrain(): void { | ... | @@ -226,7 +708,7 @@ export function useThreadOutboxDrain(): void { |
| 226 | messageId: queuedMessage.messageId, | 708 | messageId: queuedMessage.messageId, |
| 227 | role: "user", | 709 | role: "user", |
| 228 | text: queuedMessage.text, | 710 | text: queuedMessage.text, |
| 229 | attachments: toUploadChatImageAttachments(queuedMessage.attachments), | 711 | attachments: prepared.attachments, |
| 230 | }, | 712 | }, |
| 231 | modelSelection: settings.modelSelection, | 713 | modelSelection: settings.modelSelection, |
| 232 | runtimeMode: settings.runtimeMode, | 714 | runtimeMode: settings.runtimeMode, |
| ... | @@ -234,7 +716,26 @@ export function useThreadOutboxDrain(): void { | ... | @@ -234,7 +716,26 @@ export function useThreadOutboxDrain(): void { |
| 234 | createdAt: queuedMessage.createdAt, | 716 | createdAt: queuedMessage.createdAt, |
| 235 | }, | 717 | }, |
| 236 | }); | 718 | }); |
| 237 | return completeDelivery(deliveryResult); | 719 | const failure = reportFailure(deliveryResult, "start-turn"); |
| 720 | if (failure?.action === "retry") { | ||
| 721 | return false; | ||
| 722 | } | ||
| 723 | if (failure?.action === "restore") { | ||
| 724 | return restoreQueuedMessage(persistedMessage, failure.message); | ||
| 725 | } | ||
| 726 | acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId); | ||
| 727 | const delivered = | ||
| 728 | (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; | ||
| 729 | if (delivered) { | ||
| 730 | acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId); | ||
| 731 | // The delivered turn holds its own copy of the bytes. A failed delete | ||
| 732 | // is surfaced (never fails the delivered turn); the server also | ||
| 733 | // expires leaked pending uploads. | ||
| 734 | await prepared.releaseUploads().catch((error) => { | ||
| 735 | console.warn("[thread-outbox] could not delete consumed pending uploads", error); | ||
| 736 | }); | ||
| 737 | } | ||
| 738 | return delivered; | ||
| 238 | }, | 739 | }, |
| 239 | [ | 740 | [ |
| 240 | makeDeliveryHelpers, | 741 | makeDeliveryHelpers, |
| ... | @@ -242,6 +743,7 @@ export function useThreadOutboxDrain(): void { | ... | @@ -242,6 +743,7 @@ export function useThreadOutboxDrain(): void { |
| 242 | setThreadRuntimeMode, | 743 | setThreadRuntimeMode, |
| 243 | startTurn, | 744 | startTurn, |
| 244 | updateThreadMetadata, | 745 | updateThreadMetadata, |
| 746 | restoreQueuedMessage, | ||
| 245 | ], | 747 | ], |
| 246 | ); | 748 | ); |
| 247 | 749 | ||
| ... | @@ -255,7 +757,37 @@ export function useThreadOutboxDrain(): void { | ... | @@ -255,7 +757,37 @@ export function useThreadOutboxDrain(): void { |
| 255 | if (modelSelection === undefined) { | 757 | if (modelSelection === undefined) { |
| 256 | return false; | 758 | return false; |
| 257 | } | 759 | } |
| 258 | const { completeDelivery } = makeDeliveryHelpers(queuedMessage); | 760 | let prepared: PreparedTurnAttachments; |
| 761 | let persistedMessage: QueuedThreadMessage; | ||
| 762 | let deliveryRevision: number; | ||
| 763 | try { | ||
| 764 | const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); | ||
| 765 | if (preparedResult.status === "abandoned") { | ||
| 766 | return true; | ||
| 767 | } | ||
| 768 | prepared = preparedResult.prepared; | ||
| 769 | persistedMessage = preparedResult.persistedMessage; | ||
| 770 | deliveryRevision = preparedResult.deliveryRevision; | ||
| 771 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { | ||
| 772 | await preserveUploadedAttachmentsForEditor( | ||
| 773 | queuedMessage, | ||
| 774 | preparedResult.persistedMessage, | ||
| 775 | ); | ||
| 776 | return true; | ||
| 777 | } | ||
| 778 | } catch (error) { | ||
| 779 | console.warn("[thread-outbox] failed to upload attachments", error); | ||
| 780 | if (!shouldRetryThreadOutboxDelivery(error)) { | ||
| 781 | return restoreQueuedMessage( | ||
| 782 | queuedMessage, | ||
| 783 | error instanceof Error ? error.message : "An attachment could not upload.", | ||
| 784 | ); | ||
| 785 | } | ||
| 786 | return false; | ||
| 787 | } | ||
| 788 | if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { | ||
| 789 | return true; | ||
| 790 | } | ||
| 259 | const deliveryResult = await startTurn({ | 791 | const deliveryResult = await startTurn({ |
| 260 | environmentId: queuedMessage.environmentId, | 792 | environmentId: queuedMessage.environmentId, |
| 261 | input: buildProjectThreadStartTurnInput({ | 793 | input: buildProjectThreadStartTurnInput({ |
| ... | @@ -267,6 +799,7 @@ export function useThreadOutboxDrain(): void { | ... | @@ -267,6 +799,7 @@ export function useThreadOutboxDrain(): void { |
| 267 | createdAt: queuedMessage.createdAt, | 799 | createdAt: queuedMessage.createdAt, |
| 268 | text: queuedMessage.text.trim(), | 800 | text: queuedMessage.text.trim(), |
| 269 | attachments: queuedMessage.attachments, | 801 | attachments: queuedMessage.attachments, |
| 802 | uploadedAttachments: prepared.attachments, | ||
| 270 | modelSelection, | 803 | modelSelection, |
| 271 | runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, | 804 | runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 272 | interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, | 805 | interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | @@ -277,9 +810,35 @@ export function useThreadOutboxDrain(): void { | ... | @@ -277,9 +810,35 @@ export function useThreadOutboxDrain(): void { |
| 277 | worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), | 810 | worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), |
| 278 | }), | 811 | }), |
| 279 | }); | 812 | }); |
| 280 | return completeDelivery(deliveryResult); | 813 | const { reportFailure } = makeDeliveryHelpers(queuedMessage); |
| 814 | const failure = reportFailure(deliveryResult, "start-turn"); | ||
| 815 | if (failure?.action === "retry") { | ||
| 816 | return false; | ||
| 817 | } | ||
| 818 | if (failure?.action === "restore") { | ||
| 819 | return restoreQueuedMessage(persistedMessage, failure.message); | ||
| 820 | } | ||
| 821 | const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); | ||
| 822 | if (outcome === "edited") { | ||
| 823 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { | ||
| 824 | // The editor holds the entry with unsaved edits; merging the queue | ||
| 825 | // payload now would duplicate the delivered turn. Once the editor | ||
| 826 | // saves, the duplicate-creation removal below recovers the edits. | ||
| 827 | return true; | ||
| 828 | } | ||
| 829 | // The thread exists now, so the next drain would remove the edited | ||
| 830 | // payload as a duplicate creation. Hand it to the thread's composer. | ||
| 831 | return recoverEditedCreationAfterDelivery(persistedMessage); | ||
| 832 | } | ||
| 833 | if (outcome === "removed") { | ||
| 834 | await prepared.releaseUploads().catch((error) => { | ||
| 835 | console.warn("[thread-outbox] could not delete consumed pending uploads", error); | ||
| 836 | }); | ||
| 837 | return true; | ||
| 838 | } | ||
| 839 | return false; | ||
| 281 | }, | 840 | }, |
| 282 | [makeDeliveryHelpers, startTurn], | 841 | [makeDeliveryHelpers, restoreQueuedMessage, startTurn], |
| 283 | ); | 842 | ); |
| 284 | 843 | ||
| 285 | useEffect(() => { | 844 | useEffect(() => { |
| ... | @@ -287,14 +846,63 @@ export function useThreadOutboxDrain(): void { | ... | @@ -287,14 +846,63 @@ export function useThreadOutboxDrain(): void { |
| 287 | return; | 846 | return; |
| 288 | } | 847 | } |
| 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 | for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { | 860 | for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { |
| 291 | const nextQueuedMessage = queuedMessages[0]; | 861 | const nextQueuedMessage = queuedMessages[0]; |
| 292 | if (!nextQueuedMessage) { | 862 | if (!nextQueuedMessage) { |
| 293 | continue; | 863 | continue; |
| 294 | } | 864 | } |
| 865 | if ( | ||
| 866 | nextQueuedMessage.creation === undefined && | ||
| 867 | acknowledgedExistingThreadMessageIdsRef.current.has(nextQueuedMessage.messageId) | ||
| 868 | ) { | ||
| 869 | if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { | ||
| 870 | continue; | ||
| 871 | } | ||
| 872 | beginDispatchingQueuedMessage(nextQueuedMessage.messageId); | ||
| 873 | void removeAcknowledgedExistingThreadMessage( | ||
| 874 | nextQueuedMessage, | ||
| 875 | acknowledgedExistingThreadMessageIdsRef.current, | ||
| 876 | ) | ||
| 877 | .then((removed) => { | ||
| 878 | if (!removed) { | ||
| 879 | scheduleQueuedMessageRetry(nextQueuedMessage.messageId); | ||
| 880 | return; | ||
| 881 | } | ||
| 882 | retryAttemptRef.current.delete(nextQueuedMessage.messageId); | ||
| 883 | retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); | ||
| 884 | const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); | ||
| 885 | if (pendingTimer !== undefined) { | ||
| 886 | clearTimeout(pendingTimer); | ||
| 887 | retryTimersRef.current.delete(nextQueuedMessage.messageId); | ||
| 888 | } | ||
| 889 | }) | ||
| 890 | .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); | ||
| 891 | return; | ||
| 892 | } | ||
| 295 | if (editingQueuedMessageIds[nextQueuedMessage.messageId]) { | 893 | if (editingQueuedMessageIds[nextQueuedMessage.messageId]) { |
| 296 | continue; | 894 | continue; |
| 297 | } | 895 | } |
| 896 | const blockedRecovery = blockedRecoverySubscriptionsRef.current.get( | ||
| 897 | nextQueuedMessage.messageId, | ||
| 898 | ); | ||
| 899 | if (blockedRecovery) { | ||
| 900 | if (blockedRecovery.message === nextQueuedMessage) { | ||
| 901 | continue; | ||
| 902 | } | ||
| 903 | blockedRecoverySubscriptionsRef.current.delete(nextQueuedMessage.messageId); | ||
| 904 | blockedRecovery.unsubscribe(); | ||
| 905 | } | ||
| 298 | if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { | 906 | if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { |
| 299 | continue; | 907 | continue; |
| 300 | } | 908 | } |
| ... | @@ -316,9 +924,53 @@ export function useThreadOutboxDrain(): void { | ... | @@ -316,9 +924,53 @@ export function useThreadOutboxDrain(): void { |
| 316 | environmentConnected: environment?.connectionState === "connected", | 924 | environmentConnected: environment?.connectionState === "connected", |
| 317 | threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", | 925 | threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", |
| 318 | }); | 926 | }); |
| 319 | if (deliveryAction === "wait") { | 927 | // The delivery action resolves first; the file-capability gate applies |
| 928 | // only to a message that will send. Gating earlier would restore a | ||
| 929 | // creation whose startTurn already made the thread as a duplicate draft | ||
| 930 | // instead of removing it. | ||
| 931 | const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId); | ||
| 932 | const dispatchStep = resolveThreadOutboxDispatchStep({ | ||
| 933 | deliveryAction, | ||
| 934 | fileAttachments: nextQueuedMessage.attachments.filter( | ||
| 935 | (attachment) => attachment.type === "file", | ||
| 936 | ), | ||
| 937 | serverConfig: serverConfig | ||
| 938 | ? { | ||
| 939 | maxFileUploadBytes: | ||
| 940 | serverConfig.environment.capabilities.fileAttachments?.maxUploadBytes, | ||
| 941 | } | ||
| 942 | : null, | ||
| 943 | }); | ||
| 944 | if (dispatchStep.step === "wait") { | ||
| 945 | continue; | ||
| 946 | } | ||
| 947 | if (dispatchStep.step === "retry") { | ||
| 948 | // The environment is connected but its config has not synced yet. | ||
| 949 | // Back off and retry instead of parking the message forever. | ||
| 950 | scheduleQueuedMessageRetry(nextQueuedMessage.messageId); | ||
| 320 | continue; | 951 | continue; |
| 321 | } | 952 | } |
| 953 | if (dispatchStep.step === "restore") { | ||
| 954 | const attachmentError = dispatchStep.reason; | ||
| 955 | beginDispatchingQueuedMessage(nextQueuedMessage.messageId); | ||
| 956 | void confirmThreadOutboxMessageQueued(nextQueuedMessage) | ||
| 957 | .then((queued) => { | ||
| 958 | if ( | ||
| 959 | !queued || | ||
| 960 | appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId] | ||
| 961 | ) { | ||
| 962 | return true; | ||
| 963 | } | ||
| 964 | return restoreQueuedMessage(nextQueuedMessage, attachmentError); | ||
| 965 | }) | ||
| 966 | .then((restored) => { | ||
| 967 | if (!restored) { | ||
| 968 | scheduleQueuedMessageRetry(nextQueuedMessage.messageId); | ||
| 969 | } | ||
| 970 | }) | ||
| 971 | .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); | ||
| 972 | return; | ||
| 973 | } | ||
| 322 | // The live project shell is preferred for the workspace path, with the | 974 | // The live project shell is preferred for the workspace path, with the |
| 323 | // snapshot taken at enqueue time as the fallback so a task never dies | 975 | // snapshot taken at enqueue time as the fallback so a task never dies |
| 324 | // just because its project shell is not loaded. | 976 | // just because its project shell is not loaded. |
| ... | @@ -368,8 +1020,36 @@ export function useThreadOutboxDrain(): void { | ... | @@ -368,8 +1020,36 @@ export function useThreadOutboxDrain(): void { |
| 368 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) { | 1020 | if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) { |
| 369 | return true; | 1021 | return true; |
| 370 | } | 1022 | } |
| 1023 | // The shell state is equally stale. Re-run the same delivery policy | ||
| 1024 | // against the live thread snapshot so a vanished thread or newly | ||
| 1025 | // created target defers, while busy existing threads can still steer. | ||
| 1026 | if (deliveryAction === "send") { | ||
| 1027 | const liveThread = findThread( | ||
| 1028 | appAtomRegistry.get(environmentThreadShells.threadShellsAtom), | ||
| 1029 | nextQueuedMessage, | ||
| 1030 | ); | ||
| 1031 | const liveThreadBusy = | ||
| 1032 | liveThread?.session?.status === "running" || liveThread?.session?.status === "starting"; | ||
| 1033 | const liveDeliveryAction = resolveThreadOutboxDeliveryAction({ | ||
| 1034 | isCreation: creation !== undefined, | ||
| 1035 | threadExists: liveThread !== undefined, | ||
| 1036 | shellStatus, | ||
| 1037 | environmentConnected: environment?.connectionState === "connected", | ||
| 1038 | threadBusy: liveThreadBusy, | ||
| 1039 | }); | ||
| 1040 | if (liveDeliveryAction !== "send") { | ||
| 1041 | return true; | ||
| 1042 | } | ||
| 1043 | } | ||
| 371 | return deliveryAction === "remove" | 1044 | return deliveryAction === "remove" |
| 372 | ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") | 1045 | ? creation !== undefined |
| 1046 | ? // A creation entry that survived its delivery cleanup either | ||
| 1047 | // holds edits (recover them) or the delivered payload (a | ||
| 1048 | // recovered duplicate the user can delete). Restart loses any | ||
| 1049 | // in-memory distinction, and losing edits is the worse failure, | ||
| 1050 | // so recovery is unconditional here. | ||
| 1051 | recoverEditedCreationAfterDelivery(nextQueuedMessage) | ||
| 1052 | : removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") | ||
| 373 | : creation !== undefined | 1053 | : creation !== undefined |
| 374 | ? creationProjectCwd !== null | 1054 | ? creationProjectCwd !== null |
| 375 | ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd) | 1055 | ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd) |
| ... | @@ -391,19 +1071,7 @@ export function useThreadOutboxDrain(): void { | ... | @@ -391,19 +1071,7 @@ export function useThreadOutboxDrain(): void { |
| 391 | return; | 1071 | return; |
| 392 | } | 1072 | } |
| 393 | 1073 | ||
| 394 | const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1; | 1074 | scheduleQueuedMessageRetry(nextQueuedMessage.messageId); |
| 395 | retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt); | ||
| 396 | const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); | ||
| 397 | retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs); | ||
| 398 | const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); | ||
| 399 | if (pendingTimer !== undefined) { | ||
| 400 | clearTimeout(pendingTimer); | ||
| 401 | } | ||
| 402 | const retryTimer = setTimeout(() => { | ||
| 403 | retryTimersRef.current.delete(nextQueuedMessage.messageId); | ||
| 404 | setRetryTick((current) => current + 1); | ||
| 405 | }, retryDelayMs); | ||
| 406 | retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer); | ||
| 407 | }) | 1075 | }) |
| 408 | .finally(() => { | 1076 | .finally(() => { |
| 409 | finishDispatchingQueuedMessage(nextQueuedMessage.messageId); | 1077 | finishDispatchingQueuedMessage(nextQueuedMessage.messageId); |
| ... | @@ -417,8 +1085,11 @@ export function useThreadOutboxDrain(): void { | ... | @@ -417,8 +1085,11 @@ export function useThreadOutboxDrain(): void { |
| 417 | projects, | 1085 | projects, |
| 418 | queuedMessagesByThreadKey, | 1086 | queuedMessagesByThreadKey, |
| 419 | retryTick, | 1087 | retryTick, |
| 1088 | restoreQueuedMessage, | ||
| 1089 | scheduleQueuedMessageRetry, | ||
| 420 | sendQueuedCreation, | 1090 | sendQueuedCreation, |
| 421 | sendQueuedMessage, | 1091 | sendQueuedMessage, |
| 1092 | serverConfigs, | ||
| 422 | shellStatuses, | 1093 | shellStatuses, |
| 423 | threads, | 1094 | threads, |
| 424 | ]); | 1095 | ]); |
docs/user/composer.md+10-7| ... | @@ -4,20 +4,23 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee | ... | @@ -4,20 +4,23 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee |
| 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into | 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into |
| 5 | multiple messages, then send again in the same thread. | 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 | attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised | 8 | attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised |
| 9 | by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files | 9 | by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files |
| 10 | upload directly to the environment, where your agent can read, copy, or edit them by their file path. | 10 | upload directly to the environment, where your agent can read, copy, or edit them by their file path. |
| 11 | 11 | ||
| 12 | On web and desktop, attachments upload as soon as you add them. The send button becomes available | 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 | 13 | after every upload finishes. Failed uploads can be retried or removed. On mobile, the **+** control |
| 14 | currently limited to images. | 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. | 19 | On web and desktop, select a video attachment before or after sending to play it with the browser's |
| 17 | Playback depends on the video formats and codecs that the browser supports. | 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 | 22 | On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name |
| 20 | again** next to it. Attach the file again or remove it, then send. | 23 | and shows **Attach again** next to it. Attach the file again or remove it, then send. |
| 21 | 24 | ||
| 22 | On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into | 25 | On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into |
| 23 | the composer or paste them into a message. | 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,6 +91,7 @@ patchedDependencies: |
| 91 | '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd | 91 | '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd |
| 92 | '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 | 92 | '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 |
| 93 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 | 93 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 |
| 94 | expo-sharing@57.0.16: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 | ||
| 94 | react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 | 95 | react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 |
| 95 | react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 | 96 | react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 |
| 96 | react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 | 97 | react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 |
| ... | @@ -363,7 +364,7 @@ importers: | ... | @@ -363,7 +364,7 @@ importers: |
| 363 | version: 57.0.2(expo@57.0.18) | 364 | version: 57.0.2(expo@57.0.18) |
| 364 | expo-sharing: | 365 | expo-sharing: |
| 365 | specifier: ~57.0.16 | 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 | expo-splash-screen: | 368 | expo-splash-screen: |
| 368 | specifier: ~57.0.8 | 369 | specifier: ~57.0.8 |
| 369 | version: 57.0.8(expo@57.0.18)(typescript@6.0.3) | 370 | version: 57.0.8(expo@57.0.18)(typescript@6.0.3) |
| ... | @@ -16542,7 +16543,7 @@ snapshots: | ... | @@ -16542,7 +16543,7 @@ snapshots: |
| 16542 | 16543 | ||
| 16543 | expo-server@57.0.3: {} | 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 | dependencies: | 16547 | dependencies: |
| 16547 | '@expo/config-plugins': 57.0.9(typescript@6.0.3) | 16548 | '@expo/config-plugins': 57.0.9(typescript@6.0.3) |
| 16548 | '@expo/config-types': 57.0.2 | 16549 | '@expo/config-types': 57.0.2 |
pnpm-workspace.yaml+1| ... | @@ -153,6 +153,7 @@ patchedDependencies: | ... | @@ -153,6 +153,7 @@ patchedDependencies: |
| 153 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch | 153 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch |
| 154 | "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch | 154 | "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch |
| 155 | effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch | 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 | react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch | 157 | react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch |
| 157 | react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch | 158 | react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch |
| 158 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch | 159 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch |