authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-31 19:31:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 19:31:47-07:00
log9bc7a56848eb7c7546605e54ff02c6abea176f96
tree2eb6c3e22ef88f33631d8c549f85d736699cb358
parent85b656ff300f71060ad6305c7e1e29a72b442ce9
signature Signed by PGP key B5690EEEBB952194

feat(mobile): upload attachments while composing (#8978)

Co-authored-by: Julius Marminge <julius@mac.lan>

23 files changed, 1622 insertions(+), 118 deletions(-)

apps/mobile/src/Stack.tsx+2
...@@ -73,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";...@@ -73,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
73import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";73import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
74import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";74import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
75import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";75import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
76import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads";
7677
77const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);78const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
7879
...@@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string {...@@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string {
355// each enqueue, shell change, or reconnect.356// each enqueue, shell change, or reconnect.
356function ThreadOutboxDrainWorker() {357function ThreadOutboxDrainWorker() {
357 useThreadOutboxDrain();358 useThreadOutboxDrain();
359 useComposerAttachmentUploadWorker();
358 return null;360 return null;
359}361}
360362
apps/mobile/src/components/ComposerAttachmentStrip.tsx+50-2
...@@ -10,8 +10,14 @@ import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";...@@ -10,8 +10,14 @@ import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
10import { PresentationSource } from "./NativePresentation";10import { PresentationSource } from "./NativePresentation";
11import type { FilePreviewSource } from "./FilePreviewModal";11import type { FilePreviewSource } from "./FilePreviewModal";
12import { isPdfFile } from "../lib/filePreview";12import { isPdfFile } from "../lib/filePreview";
13import type { EnvironmentId } from "@t3tools/contracts";
14import {
15 retryComposerAttachmentUpload,
16 useComposerAttachmentUploadState,
17} from "../state/composer-attachment-uploads";
1318
14export interface ComposerAttachmentStripProps {19export interface ComposerAttachmentStripProps {
20 readonly environmentId?: EnvironmentId;
15 /** Attachments to display. */21 /** Attachments to display. */
16 readonly attachments: ReadonlyArray<DraftComposerAttachment>;22 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
17 /** Called when the user removes an attachment. */23 /** Called when the user removes an attachment. */
...@@ -30,7 +36,8 @@ export interface ComposerAttachmentStripProps {...@@ -30,7 +36,8 @@ export interface ComposerAttachmentStripProps {
30 readonly removeButtonPlacement?: "overlay" | "gutter";36 readonly removeButtonPlacement?: "overlay" | "gutter";
31}37}
3238
33export function ComposerAttachmentThumbnail(props: {39type ComposerAttachmentThumbnailProps = {
40 readonly environmentId?: EnvironmentId;
34 readonly attachment: DraftComposerAttachment;41 readonly attachment: DraftComposerAttachment;
35 readonly size: number;42 readonly size: number;
36 readonly borderRadius: number;43 readonly borderRadius: number;
...@@ -40,7 +47,47 @@ export function ComposerAttachmentThumbnail(props: {...@@ -40,7 +47,47 @@ export function ComposerAttachmentThumbnail(props: {
40 attachment: DraftComposerFileAttachment,47 attachment: DraftComposerFileAttachment,
41 sourceIdentifier: string,48 sourceIdentifier: string,
42 ) => void;49 ) => void;
43}) {50};
51
52export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) {
53 const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id);
54 return (
55 <View style={{ width: props.size, height: props.size }}>
56 <ComposerAttachmentContent {...props} />
57 {upload && upload.status !== "ready" ? (
58 <Pressable
59 accessibilityRole={upload.status === "failed" ? "button" : "text"}
60 accessibilityLabel={
61 upload.status === "failed"
62 ? `Retry uploading ${props.attachment.name}`
63 : `Uploading ${props.attachment.name}, ${Math.floor(upload.progress * 100)}%`
64 }
65 accessibilityHint={upload.status === "failed" ? upload.reason : undefined}
66 disabled={upload.status !== "failed"}
67 onPress={() =>
68 props.environmentId &&
69 retryComposerAttachmentUpload(props.environmentId, props.attachment.id)
70 }
71 className="absolute bottom-0.5 left-0.5 flex-row items-center gap-0.5 rounded-full bg-black/70 px-1 py-0.5"
72 >
73 <SymbolView
74 name={upload.status === "failed" ? "arrow.clockwise" : "arrow.up"}
75 size={props.compact ? 8 : 10}
76 tintColor="#ffffff"
77 type="monochrome"
78 />
79 {!props.compact ? (
80 <Text className="text-2xs text-white">
81 {upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`}
82 </Text>
83 ) : null}
84 </Pressable>
85 ) : null}
86 </View>
87 );
88}
89
90function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {
44 const { attachment } = props;91 const { attachment } = props;
45 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };92 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
46 if (attachment.type === "image") {93 if (attachment.type === "image") {
...@@ -213,6 +260,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {...@@ -213,6 +260,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
213 }}260 }}
214 >261 >
215 <ComposerAttachmentThumbnail262 <ComposerAttachmentThumbnail
263 environmentId={props.environmentId}
216 attachment={attachment}264 attachment={attachment}
217 size={size}265 size={size}
218 borderRadius={radius}266 borderRadius={radius}
apps/mobile/src/features/cloud/CloudAuthProvider.test.ts+6
...@@ -26,6 +26,12 @@ vi.mock("../../connection/catalog", () => ({...@@ -26,6 +26,12 @@ vi.mock("../../connection/catalog", () => ({
26 },26 },
27}));27}));
2828
29vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} }));
30vi.mock("../../state/use-composer-drafts", () => ({
31 getComposerCloudAccountId: vi.fn(async () => null),
32 restoreCloudComposerDrafts: vi.fn(async () => undefined),
33}));
34
29vi.mock("./publicConfig", () => ({35vi.mock("./publicConfig", () => ({
30 resolveCloudPublicConfig: vi.fn(() => ({36 resolveCloudPublicConfig: vi.fn(() => ({
31 clerk: { publishableKey: null },37 clerk: { publishableKey: null },
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+52-30
...@@ -5,14 +5,18 @@ import {...@@ -5,14 +5,18 @@ import {
5 reportAtomCommandResult,5 reportAtomCommandResult,
6 settleAsyncResult,6 settleAsyncResult,
7 settlePromise,7 settlePromise,
8 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";9} from "@t3tools/client-runtime/state/runtime";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import { type ReactNode, useEffect, useRef } from "react";11import { type ReactNode, useEffect, useRef } from "react";
1112
12import { environmentCatalog } from "../../connection/catalog";
13import { runtime } from "../../lib/runtime";13import { runtime } from "../../lib/runtime";
14import { appAtomRegistry } from "../../state/atom-registry";14import { appAtomRegistry } from "../../state/atom-registry";
15import { useAtomCommand } from "../../state/use-atom-command";15import { useAtomCommand } from "../../state/use-atom-command";
16import {
17 getComposerCloudAccountId,
18 restoreCloudComposerDrafts,
19} from "../../state/use-composer-drafts";
16import {20import {
17 releaseAgentAwarenessRelayTokenProvider,21 releaseAgentAwarenessRelayTokenProvider,
18 setAgentAwarenessRelayTokenProvider,22 setAgentAwarenessRelayTokenProvider,
...@@ -20,6 +24,7 @@ import {...@@ -20,6 +24,7 @@ import {
20} from "../agent-awareness/remoteRegistration";24} from "../agent-awareness/remoteRegistration";
21import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";25import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
22import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";26import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
27import { removeCloudEnvironments } from "./cloud-drafts";
2328
24function resetManagedRelayTokenCache() {29function resetManagedRelayTokenCache() {
25 return settleAsyncResult(() =>30 return settleAsyncResult(() =>
...@@ -47,7 +52,7 @@ export function activateCloudRelayAccount(...@@ -47,7 +52,7 @@ export function activateCloudRelayAccount(
4752
48function CloudAuthBridge(props: { readonly children: ReactNode }) {53function CloudAuthBridge(props: { readonly children: ReactNode }) {
49 const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });54 const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
50 const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, {55 const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, {
51 reportFailure: false,56 reportFailure: false,
52 reportDefect: false,57 reportDefect: false,
53 });58 });
...@@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {...@@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
81 clearConnectOnboardingRequest();86 clearConnectOnboardingRequest();
82 }87 }
8388
84 const queueAccountCleanup = (89 const cleanUpAccount = async (
85 previous: {90 previous: {
86 readonly userId: string;91 readonly userId: string;
87 readonly provider: () => Promise<string | null>;92 readonly provider: () => Promise<string | null>;
88 } | null,93 } | null,
94 accountId: string | null,
89 ) => {95 ) => {
90 const previousTransition = accountTransitionRef.current ?? Promise.resolve();96 const removal = await removeRelayEnvironments(accountId);
91 accountTransitionRef.current = previousTransition.then(async () => {97 if (removal._tag !== "Success") throw squashAtomCommandFailure(removal);
92 const cleanup = [98 const cleanup = [
93 resetManagedRelayTokenCache(),99 resetManagedRelayTokenCache(),
94 removeRelayEnvironments(),100 ...(previous
95 ...(previous101 ? [
96 ? [102 settleAsyncResult(() =>
97 settleAsyncResult(() =>103 runtime.runPromiseExit(
98 runtime.runPromiseExit(104 unregisterAgentAwarenessDeviceForCurrentUser(previous.provider),
99 unregisterAgentAwarenessDeviceForCurrentUser(previous.provider),
100 ),
101 ),105 ),
102 ]106 ),
103 : []),107 ]
104 ];108 : []),
105 const results = await Promise.all(cleanup);109 ];
106 for (const result of results) {110 const results = await Promise.all(cleanup);
107 reportAtomCommandResult(result, { label: "cloud account cleanup" });111 for (const result of results) {
108 }112 reportAtomCommandResult(result, { label: "cloud account cleanup" });
109 });113 }
114 };
115 const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => {
116 const previousTransition = accountTransitionRef.current ?? Promise.resolve();
117 accountTransitionRef.current = previousTransition
118 .catch(() => {})
119 .then(() => cleanUpAccount(previous, previousObservedAccount ?? null));
110 return accountTransitionRef.current;120 return accountTransitionRef.current;
111 };121 };
112122
...@@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {...@@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
115 previousTokenProviderRef.current = null;125 previousTokenProviderRef.current = null;
116 deactivateCloudRelayAccount();126 deactivateCloudRelayAccount();
117 if (previousObservedAccount !== null) {127 if (previousObservedAccount !== null) {
118 void queueAccountCleanup(previous);128 void settlePromise(() => queueAccountCleanup(previous)).then((result) => {
129 reportAtomCommandResult(result, { label: "cloud account cleanup" });
130 });
119 }131 }
120 return;132 return;
121 }133 }
...@@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {...@@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
133 }145 }
134 };146 };
135 const activateAfterTransition = (transition: Promise<void>) => {147 const activateAfterTransition = (transition: Promise<void>) => {
136 void (async () => {148 const activation = (async () => {
137 const result = await settlePromise(async () => {149 await transition;
138 await transition;150 if (cancelled) return;
139 activateSession();151 const storedAccount = await getComposerCloudAccountId();
140 });152 if (storedAccount !== null && storedAccount !== userId) {
141 reportAtomCommandResult(result, { label: "cloud account activation" });153 await cleanUpAccount(null, storedAccount);
154 }
155 if (cancelled) return;
156 await restoreCloudComposerDrafts(userId);
157 activateSession();
142 })();158 })();
159 accountTransitionRef.current = activation;
160 void settlePromise(() => activation).then((result) => {
161 reportAtomCommandResult(result, { label: "cloud account activation" });
162 });
143 };163 };
144 if (164 if (
145 previousObservedAccount !== undefined &&165 previousObservedAccount !== undefined &&
...@@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {...@@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
150 deactivateCloudRelayAccount();170 deactivateCloudRelayAccount();
151 activateAfterTransition(queueAccountCleanup(previous));171 activateAfterTransition(queueAccountCleanup(previous));
152 } else {172 } else {
153 activateAfterTransition(accountTransitionRef.current ?? Promise.resolve());173 // A failed disk write can be retried. The persisted account check above
174 // still requires cleanup before activating a different account.
175 activateAfterTransition((accountTransitionRef.current ?? Promise.resolve()).catch(() => {}));
154 }176 }
155177
156 return () => {178 return () => {
apps/mobile/src/features/cloud/cloud-drafts.ts created+46
...@@ -0,0 +1,46 @@
1import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import * as SubscriptionRef from "effect/SubscriptionRef";
6
7import { connectionAtomRuntime } from "../../connection/runtime";
8import { archiveCloudComposerDrafts } from "../../state/use-composer-drafts";
9
10export class CloudDraftArchiveError extends Schema.TaggedErrorClass<CloudDraftArchiveError>()(
11 "CloudDraftArchiveError",
12 {
13 environmentCount: Schema.Number,
14 hasAccountId: Schema.Boolean,
15 cause: Schema.Defect(),
16 },
17) {
18 override get message(): string {
19 return `Could not preserve local drafts for ${this.environmentCount} cloud environments before sign-out.`;
20 }
21}
22
23export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntime, {
24 label: "cloud:preserve-drafts-and-remove-environments",
25 execute: Effect.fn("removeCloudEnvironments")(function* (accountId: string | null) {
26 const registry = yield* EnvironmentRegistry;
27 const entries = yield* SubscriptionRef.get(registry.entries);
28 const environmentIds = new Set(
29 [...entries.values()]
30 .filter((entry) => entry.target._tag === "RelayConnectionTarget")
31 .map((entry) => entry.target.environmentId),
32 );
33 // Credentials are already revoked. A failed backup must leave the local
34 // owners intact so a later sign-in can retry without losing their files.
35 yield* Effect.tryPromise({
36 try: () => archiveCloudComposerDrafts(accountId, environmentIds),
37 catch: (cause) =>
38 new CloudDraftArchiveError({
39 environmentCount: environmentIds.size,
40 hasAccountId: accountId !== null,
41 cause,
42 }),
43 });
44 yield* registry.removeRelayEnvironments();
45 }),
46});
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+21-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomValue } from "@effect/atom-react";
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import {3import {
3 CommonActions,4 CommonActions,
...@@ -35,6 +36,10 @@ import {...@@ -35,6 +36,10 @@ import {
35import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";36import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
36import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";37import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
37import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";38import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
39import {
40 composerAttachmentUploadBlockReason,
41 composerAttachmentUploadsAtom,
42} from "../../state/composer-attachment-uploads";
38import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";43import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
39import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";44import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
40import { ProviderIcon } from "../../components/ProviderIcon";45import { ProviderIcon } from "../../components/ProviderIcon";
...@@ -164,6 +169,16 @@ export function NewTaskDraftScreen(props: {...@@ -164,6 +169,16 @@ export function NewTaskDraftScreen(props: {
164 connectedEnvironments.find(169 connectedEnvironments.find(
165 (environment) => environment.environmentId === selectedProject.environmentId,170 (environment) => environment.environmentId === selectedProject.environmentId,
166 )?.connectionState === "connected";171 )?.connectionState === "connected";
172 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
173 const attachmentBlockReason = selectedProject
174 ? composerAttachmentUploadBlockReason({
175 environmentId: selectedProject.environmentId,
176 attachments: flow.attachments,
177 connected: environmentConnected,
178 serverConfig: selectedEnvironmentServerConfig,
179 states: uploadStates,
180 })
181 : null;
167 const promptInputRef = useRef<ComposerEditorHandle>(null);182 const promptInputRef = useRef<ComposerEditorHandle>(null);
168 const loadedBranchesProjectKeyRef = useRef<string | null>(null);183 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
169 const [isComposerFocused, setIsComposerFocused] = useState(false);184 const [isComposerFocused, setIsComposerFocused] = useState(false);
...@@ -863,6 +878,7 @@ export function NewTaskDraftScreen(props: {...@@ -863,6 +878,7 @@ export function NewTaskDraftScreen(props: {
863 const initialMessageText = draft.text.trim();878 const initialMessageText = draft.text.trim();
864879
865 if (880 if (
881 attachmentBlockReason !== null ||
866 !modelSelection ||882 !modelSelection ||
867 initialMessageText.length === 0 ||883 initialMessageText.length === 0 ||
868 flow.submitting ||884 flow.submitting ||
...@@ -1017,6 +1033,7 @@ export function NewTaskDraftScreen(props: {...@@ -1017,6 +1033,7 @@ export function NewTaskDraftScreen(props: {
10171033
1018 const isAndroid = Platform.OS === "android";1034 const isAndroid = Platform.OS === "android";
1019 const canStart =1035 const canStart =
1036 attachmentBlockReason === null &&
1020 Boolean(flow.selectedProject) &&1037 Boolean(flow.selectedProject) &&
1021 Boolean(flow.selectedModel) &&1038 Boolean(flow.selectedModel) &&
1022 flow.prompt.trim().length > 0 &&1039 flow.prompt.trim().length > 0 &&
...@@ -1216,6 +1233,7 @@ export function NewTaskDraftScreen(props: {...@@ -1216,6 +1233,7 @@ export function NewTaskDraftScreen(props: {
1216 {flow.attachments.length > 0 ? (1233 {flow.attachments.length > 0 ? (
1217 <View className="px-[14px] pb-2.5">1234 <View className="px-[14px] pb-2.5">
1218 <ComposerAttachmentStrip1235 <ComposerAttachmentStrip
1236 environmentId={selectedProject.environmentId}
1219 attachments={flow.attachments}1237 attachments={flow.attachments}
1220 imageBorderRadius={16}1238 imageBorderRadius={16}
1221 imageSize={72}1239 imageSize={72}
...@@ -1317,11 +1335,12 @@ export function NewTaskDraftScreen(props: {...@@ -1317,11 +1335,12 @@ export function NewTaskDraftScreen(props: {
1317 {voicePresentation.showsSend ? (1335 {voicePresentation.showsSend ? (
1318 <ComposerActionButton1336 <ComposerActionButton
1319 accessibilityLabel={1337 accessibilityLabel={
1320 flow.submitting1338 attachmentBlockReason ??
1339 (flow.submitting
1321 ? "Starting task"1340 ? "Starting task"
1322 : environmentConnected1341 : environmentConnected
1323 ? "Start task"1342 ? "Start task"
1324 : "Queue task"1343 : "Queue task")
1325 }1344 }
1326 disabled={!canStart}1345 disabled={!canStart}
1327 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}1346 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
apps/mobile/src/features/threads/ThreadComposer.tsx+19-4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomValue } from "@effect/atom-react";
1import type {2import type {
2 EnvironmentId,3 EnvironmentId,
3 MessageId,4 MessageId,
...@@ -21,6 +22,10 @@ import {...@@ -21,6 +22,10 @@ import {
21} from "react";22} from "react";
22import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";23import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
23import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";24import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
25import {
26 composerAttachmentUploadBlockReason,
27 composerAttachmentUploadsAtom,
28} from "../../state/composer-attachment-uploads";
24import Animated, {29import Animated, {
25 FadeIn,30 FadeIn,
26 FadeInDown,31 FadeInDown,
...@@ -365,7 +370,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -365,7 +370,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
365 const isExpanded = isFocused || settingsSheetPresentation.isActive;370 const isExpanded = isFocused || settingsSheetPresentation.isActive;
366 const showsCompactDictation = isVoiceInputPresented && !isExpanded;371 const showsCompactDictation = isVoiceInputPresented && !isExpanded;
367 const isToolbarVisible = isExpanded || isVoiceInputPresented;372 const isToolbarVisible = isExpanded || isVoiceInputPresented;
368 const canSend = hasContent && !voiceInput.blocksSubmission;373 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
374 const attachmentBlockReason = composerAttachmentUploadBlockReason({
375 environmentId: props.environmentId,
376 attachments: props.draftAttachments,
377 connected: props.connectionState === "connected",
378 serverConfig: props.serverConfig,
379 states: uploadStates,
380 });
381 const canSend = hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null;
369382
370 // Keep the feed inset aligned with the card or compact dictation strip.383 // Keep the feed inset aligned with the card or compact dictation strip.
371 useEffect(() => {384 useEffect(() => {
...@@ -617,6 +630,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -617,6 +630,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
617 exiting={FadeOut.duration(120)}630 exiting={FadeOut.duration(120)}
618 >631 >
619 <ComposerAttachmentStrip632 <ComposerAttachmentStrip
633 environmentId={props.environmentId}
620 attachments={props.draftAttachments}634 attachments={props.draftAttachments}
621 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}635 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}
622 onPressPreview={voiceInput.isBusy ? undefined : onPressPreview}636 onPressPreview={voiceInput.isBusy ? undefined : onPressPreview}
...@@ -668,6 +682,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -668,6 +682,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
668 <View className="flex-row gap-1 pl-1">682 <View className="flex-row gap-1 pl-1">
669 {props.draftAttachments.slice(0, 3).map((attachment) => (683 {props.draftAttachments.slice(0, 3).map((attachment) => (
670 <ComposerAttachmentThumbnail684 <ComposerAttachmentThumbnail
685 environmentId={props.environmentId}
671 key={attachment.id}686 key={attachment.id}
672 attachment={attachment}687 attachment={attachment}
673 size={30}688 size={30}
...@@ -703,10 +718,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -703,10 +718,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
703 />718 />
704 ) : (719 ) : (
705 <ComposerActionButton720 <ComposerActionButton
706 accessibilityLabel={sendLabel}721 accessibilityLabel={attachmentBlockReason ?? sendLabel}
707 icon="arrow.up"722 icon="arrow.up"
708 variant="primary"723 variant="primary"
709 disabled={!hasContent}724 disabled={!canSend}
710 onPress={handleSend}725 onPress={handleSend}
711 />726 />
712 )}727 )}
...@@ -794,7 +809,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -794,7 +809,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
794 />809 />
795 ) : voicePresentation.showsSend ? (810 ) : voicePresentation.showsSend ? (
796 <ComposerActionButton811 <ComposerActionButton
797 accessibilityLabel={sendLabel}812 accessibilityLabel={attachmentBlockReason ?? sendLabel}
798 icon="arrow.up"813 icon="arrow.up"
799 variant="primary"814 variant="primary"
800 disabled={!canSend}815 disabled={!canSend}
apps/mobile/src/features/threads/use-project-actions.ts+3
...@@ -85,6 +85,9 @@ export function useCreateProjectThread() {...@@ -85,6 +85,9 @@ export function useCreateProjectThread() {
85 prepared = await prepareTurnAttachments({85 prepared = await prepareTurnAttachments({
86 environmentId: input.project.environmentId,86 environmentId: input.project.environmentId,
87 attachments: input.initialAttachments,87 attachments: input.initialAttachments,
88 supportsImageUploads:
89 appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId))
90 ?.environment.capabilities.attachmentUploads === true,
88 persistUploadedReferences: async (draftAttachments) => {91 persistUploadedReferences: async (draftAttachments) => {
89 await input.onAttachmentsUploaded(draftAttachments);92 await input.onAttachmentsUploaded(draftAttachments);
90 return "persisted";93 return "persisted";
apps/mobile/src/lib/attachmentUpload.test.ts+136-3
...@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({...@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
12 runAtomCommand: vi.fn(),12 runAtomCommand: vi.fn(),
13 readAtom: vi.fn(),13 readAtom: vi.fn(),
14 upload: vi.fn(),14 upload: vi.fn(),
15 writeFile: vi.fn(),
16 deleteFile: vi.fn(),
15}));17}));
1618
17vi.mock("@t3tools/client-runtime/state/runtime", () => ({19vi.mock("@t3tools/client-runtime/state/runtime", () => ({
...@@ -53,13 +55,25 @@ vi.mock("./uuid", () => ({...@@ -53,13 +55,25 @@ vi.mock("./uuid", () => ({
5355
54vi.mock("expo-file-system", () => ({56vi.mock("expo-file-system", () => ({
55 File: class {57 File: class {
56 constructor(readonly uri: string) {}58 readonly uri: string;
59 exists = true;
60 constructor(uri: string, name?: string) {
61 this.uri = name ? `${uri}/${name}` : uri;
62 }
63 create() {}
64 write(bytes: string, options: unknown) {
65 mocks.writeFile(this.uri, bytes, options);
66 }
67 delete() {
68 mocks.deleteFile(this.uri);
69 }
5770
58 upload(url: string, options: unknown) {71 upload(url: string, options: unknown) {
59 return mocks.upload(this.uri, url, options);72 return mocks.upload(this.uri, url, options);
60 }73 }
61 },74 },
62 Paths: {75 Paths: {
76 cache: "file:///cache",
63 get document() {77 get document() {
64 return { uri: mocks.documentUri };78 return { uri: mocks.documentUri };
65 },79 },
...@@ -158,6 +172,8 @@ describe("prepareTurnAttachments", () => {...@@ -158,6 +172,8 @@ describe("prepareTurnAttachments", () => {
158 mocks.runAtomCommand.mockReset();172 mocks.runAtomCommand.mockReset();
159 mocks.readAtom.mockReset();173 mocks.readAtom.mockReset();
160 mocks.upload.mockReset();174 mocks.upload.mockReset();
175 mocks.writeFile.mockReset();
176 mocks.deleteFile.mockReset();
161 mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));177 mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));
162 mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>178 mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>
163 command === mocks.createUploadUrl179 command === mocks.createUploadUrl
...@@ -198,11 +214,11 @@ describe("prepareTurnAttachments", () => {...@@ -198,11 +214,11 @@ describe("prepareTurnAttachments", () => {
198 expect(mocks.upload).toHaveBeenCalledWith(214 expect(mocks.upload).toHaveBeenCalledWith(
199 "file:///documents/report.pdf",215 "file:///documents/report.pdf",
200 "https://environment.example/api/attachments/upload/signed",216 "https://environment.example/api/attachments/upload/signed",
201 {217 expect.objectContaining({
202 httpMethod: "POST",218 httpMethod: "POST",
203 uploadType: 0,219 uploadType: 0,
204 headers: { "Content-Type": "application/pdf" },220 headers: { "Content-Type": "application/pdf" },
205 },221 }),
206 );222 );
207 expect(prepared.status).toBe("ready");223 expect(prepared.status).toBe("ready");
208 if (prepared.status !== "ready") return;224 if (prepared.status !== "ready") return;
...@@ -354,6 +370,123 @@ describe("prepareTurnAttachments", () => {...@@ -354,6 +370,123 @@ describe("prepareTurnAttachments", () => {
354 expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]);370 expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]);
355 });371 });
356372
373 it("uploads image bytes over HTTP while retaining the durable offline image", async () => {
374 const persisted = vi.fn(async () => "persisted" as const);
375 const prepared = await prepareTurnAttachments({
376 environmentId,
377 attachments: [image],
378 supportsImageUploads: true,
379 persistUploadedReferences: persisted,
380 });
381 expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", {
382 encoding: "base64",
383 });
384 expect(mocks.upload).toHaveBeenCalledWith(
385 "file:///cache/t3-upload-uuid",
386 "https://environment.example/api/attachments/upload/signed",
387 expect.objectContaining({ headers: { "Content-Type": "image/png" } }),
388 );
389 expect(mocks.deleteFile).toHaveBeenCalledExactlyOnceWith("file:///cache/t3-upload-uuid");
390 expect(prepared.status).toBe("ready");
391 if (prepared.status !== "ready") return;
392 expect(prepared.attachments).toEqual([
393 {
394 type: "image",
395 id: MINTED_ID,
396 name: image.name,
397 mimeType: image.mimeType,
398 sizeBytes: image.sizeBytes,
399 },
400 ]);
401 expect(prepared.draftAttachments).toEqual([
402 { ...image, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId },
403 ]);
404 expect(persisted).toHaveBeenCalledWith(prepared.draftAttachments);
405 });
406
407 it("reuses an uploaded image and reuploads its local bytes after server expiry", async () => {
408 const saved = {
409 ...image,
410 uploadedAttachmentId: "saved-image",
411 uploadEnvironmentId: environmentId,
412 };
413 const reused = await prepareTurnAttachments({
414 environmentId,
415 attachments: [saved],
416 supportsImageUploads: true,
417 });
418 expect(reused.status === "ready" && reused.attachments[0]).toEqual({
419 type: "image",
420 id: "saved-image",
421 name: image.name,
422 mimeType: image.mimeType,
423 sizeBytes: image.sizeBytes,
424 });
425 expect(mocks.upload).not.toHaveBeenCalled();
426 mocks.executeAtomQuery.mockResolvedValueOnce({
427 _tag: "Failure",
428 error: { _tag: "AssetAttachmentNotFoundError" },
429 });
430 const restored = await prepareTurnAttachments({
431 environmentId,
432 attachments: [saved],
433 supportsImageUploads: true,
434 });
435 expect(restored.status === "ready" && restored.draftAttachments[0]).toEqual({
436 ...saved,
437 uploadedAttachmentId: MINTED_ID,
438 });
439 expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", {
440 encoding: "base64",
441 });
442 });
443
444 it("does not reuse an image upload from another environment", async () => {
445 const prepared = await prepareTurnAttachments({
446 environmentId,
447 attachments: [
448 {
449 ...image,
450 uploadedAttachmentId: "other-image",
451 uploadEnvironmentId: EnvironmentId.make("other"),
452 },
453 ],
454 supportsImageUploads: true,
455 });
456 expect(mocks.executeAtomQuery).not.toHaveBeenCalled();
457 expect(mocks.upload).toHaveBeenCalledOnce();
458 expect(prepared.status === "ready" && prepared.draftAttachments[0]?.uploadEnvironmentId).toBe(
459 environmentId,
460 );
461 });
462
463 it("aborts an active transfer without dropping local bytes or stamping a partial upload", async () => {
464 const started = Promise.withResolvers<void>();
465 const controller = new AbortController();
466 const persist = vi.fn(async () => "persisted" as const);
467 mocks.upload.mockImplementation(
468 (_uri: string, _url: string, options: { signal: AbortSignal }) =>
469 new Promise((_, reject) => {
470 options.signal.addEventListener("abort", () => reject(new Error("cancelled")), {
471 once: true,
472 });
473 started.resolve();
474 }),
475 );
476 const preparing = prepareTurnAttachments({
477 environmentId,
478 attachments: [file],
479 signal: controller.signal,
480 persistUploadedReferences: persist,
481 });
482 await started.promise;
483 controller.abort();
484 expect(await preparing).toEqual({ status: "abandoned" });
485 expect(persist).not.toHaveBeenCalled();
486 expect(mocks.deleteFile).not.toHaveBeenCalled();
487 expect(removeCallsFor(MINTED_ID)).toBe(1);
488 });
489
357 it("removes pending uploads when the native HTTP request fails", async () => {490 it("removes pending uploads when the native HTTP request fails", async () => {
358 mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} });491 mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} });
359492
apps/mobile/src/lib/attachmentUpload.ts+103-40
...@@ -9,9 +9,11 @@ import {...@@ -9,9 +9,11 @@ import {
9import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";9import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
10import type {10import type {
11 ChatFileAttachment,11 ChatFileAttachment,
12 ChatImageAttachment,
12 EnvironmentId,13 EnvironmentId,
13 UploadChatImageAttachment,14 UploadChatImageAttachment,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts";
15import * as Option from "effect/Option";17import * as Option from "effect/Option";
1618
17import { appAtomRegistry } from "../state/atom-registry";19import { appAtomRegistry } from "../state/atom-registry";
...@@ -20,6 +22,7 @@ import { attachmentEnvironment } from "../state/attachments";...@@ -20,6 +22,7 @@ import { attachmentEnvironment } from "../state/attachments";
20import { environmentSession } from "../state/session";22import { environmentSession } from "../state/session";
21import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";23import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
22import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";24import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
25import { uuidv4 } from "./uuid";
2326
24/**27/**
25 * This module owns the server side of a composer attachment's lifecycle.28 * This module owns the server side of a composer attachment's lifecycle.
...@@ -31,7 +34,10 @@ import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./co...@@ -31,7 +34,10 @@ import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./co
31 * owned by `removeThreadOutboxMessage` / the composer draft mutators, which34 * owned by `removeThreadOutboxMessage` / the composer draft mutators, which
32 * release files through `releaseUnusedComposerAttachmentFiles`.35 * release files through `releaseUnusedComposerAttachmentFiles`.
33 */36 */
34export type UploadedMobileAttachment = UploadChatImageAttachment | ChatFileAttachment;37export type UploadedMobileAttachment =
38 | UploadChatImageAttachment
39 | ChatImageAttachment
40 | ChatFileAttachment;
3541
36export function validateDraftFileAttachments(input: {42export function validateDraftFileAttachments(input: {
37 readonly attachments: ReadonlyArray<DraftComposerAttachment>;43 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
...@@ -56,7 +62,7 @@ export function validateDraftFileAttachments(input: {...@@ -56,7 +62,7 @@ export function validateDraftFileAttachments(input: {
56 return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null;62 return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null;
57}63}
5864
59/** Keep uploaded file ids on durable drafts so a later send can reuse their bytes. */65/** Keep uploaded ids alongside the local bytes so a later send can reuse them. */
60export function withUploadedMobileAttachmentReferences(input: {66export function withUploadedMobileAttachmentReferences(input: {
61 readonly environmentId: EnvironmentId;67 readonly environmentId: EnvironmentId;
62 readonly attachments: ReadonlyArray<DraftComposerAttachment>;68 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
...@@ -65,8 +71,9 @@ export function withUploadedMobileAttachmentReferences(input: {...@@ -65,8 +71,9 @@ export function withUploadedMobileAttachmentReferences(input: {
65 return input.attachments.map((attachment, index) => {71 return input.attachments.map((attachment, index) => {
66 const uploaded = input.uploadedAttachments[index];72 const uploaded = input.uploadedAttachments[index];
67 if (73 if (
68 attachment.type !== "file" ||74 !uploaded ||
69 uploaded?.type !== "file" ||75 !("id" in uploaded) ||
76 attachment.type !== uploaded.type ||
70 (attachment.uploadedAttachmentId === uploaded.id &&77 (attachment.uploadedAttachmentId === uploaded.id &&
71 attachment.uploadEnvironmentId === input.environmentId)78 attachment.uploadEnvironmentId === input.environmentId)
72 ) {79 ) {
...@@ -145,21 +152,73 @@ export type PrepareTurnAttachmentsResult =...@@ -145,21 +152,73 @@ export type PrepareTurnAttachmentsResult =
145 | PreparedTurnAttachments152 | PreparedTurnAttachments
146 | { readonly status: "abandoned" };153 | { readonly status: "abandoned" };
147154
155function uploadedReference(
156 attachment: DraftComposerAttachment,
157 id: string,
158): ChatImageAttachment | ChatFileAttachment {
159 const fields = {
160 id,
161 name: attachment.name,
162 mimeType: attachment.mimeType,
163 sizeBytes: attachment.sizeBytes,
164 };
165 return attachment.type === "image" ? { type: "image", ...fields } : { type: "file", ...fields };
166}
167
168function attachmentUploadInput(attachment: DraftComposerAttachment) {
169 const fields = {
170 name: attachment.name,
171 mimeType: attachment.mimeType,
172 sizeBytes: attachment.sizeBytes,
173 };
174 if (attachment.type === "file") return { type: "file" as const, ...fields };
175 const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find(
176 (type) => type === attachment.mimeType.toLowerCase(),
177 );
178 if (!mimeType) throw new Error(`Unsupported image type for '${attachment.name}'.`);
179 return { ...fields, mimeType };
180}
181
148async function uploadFileBytes(182async function uploadFileBytes(
149 attachment: Extract<DraftComposerAttachment, { readonly type: "file" }>,183 attachment: DraftComposerAttachment,
150 url: string,184 url: string,
185 signal: AbortSignal,
186 onProgress?: (progress: number) => void,
151): Promise<void> {187): Promise<void> {
152 const { File, Paths, UploadType } = await import("expo-file-system");188 const { File, Paths, UploadType } = await import("expo-file-system");
153 const fileUri =189 if (signal.aborted) throw new Error("Upload cancelled.");
154 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??190 const file =
155 attachment.fileUri;191 attachment.type === "image"
156 const result = await new File(fileUri).upload(url, {192 ? new File(Paths.cache, `t3-upload-${uuidv4()}`)
157 httpMethod: "POST",193 : new File(
158 uploadType: UploadType.BINARY_CONTENT,194 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
159 headers: { "Content-Type": attachment.mimeType },195 attachment.fileUri,
160 });196 );
161 if (result.status < 200 || result.status >= 300) {197 try {
162 throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);198 if (attachment.type === "image") {
199 file.create();
200 file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), {
201 encoding: "base64",
202 });
203 }
204 const result = await file.upload(url, {
205 httpMethod: "POST",
206 uploadType: UploadType.BINARY_CONTENT,
207 headers: { "Content-Type": attachment.mimeType },
208 signal,
209 ...(onProgress
210 ? {
211 onProgress: ({ bytesSent, totalBytes }) => {
212 if (totalBytes > 0) onProgress(bytesSent / totalBytes);
213 },
214 }
215 : {}),
216 });
217 if (result.status < 200 || result.status >= 300) {
218 throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);
219 }
220 } finally {
221 if (attachment.type === "image" && file.exists) file.delete();
163 }222 }
164}223}
165224
...@@ -176,11 +235,16 @@ async function uploadFileBytes(...@@ -176,11 +235,16 @@ async function uploadFileBytes(
176export async function prepareTurnAttachments(input: {235export async function prepareTurnAttachments(input: {
177 readonly environmentId: EnvironmentId;236 readonly environmentId: EnvironmentId;
178 readonly attachments: ReadonlyArray<DraftComposerAttachment>;237 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
238 /** Older environments continue to receive inline images. */
239 readonly supportsImageUploads?: boolean;
240 readonly signal?: AbortSignal;
241 readonly onUploadProgress?: (attachmentId: string, progress: number) => void;
179 readonly persistUploadedReferences?: (242 readonly persistUploadedReferences?: (
180 draftAttachments: ReadonlyArray<DraftComposerAttachment>,243 draftAttachments: ReadonlyArray<DraftComposerAttachment>,
181 ) => Promise<"persisted" | "abandon">;244 ) => Promise<"persisted" | "abandon">;
182}): Promise<PrepareTurnAttachmentsResult> {245}): Promise<PrepareTurnAttachmentsResult> {
183 const { environmentId } = input;246 const { environmentId } = input;
247 if (input.signal?.aborted) return { status: "abandoned" };
184 const files = input.attachments.filter((attachment) => attachment.type === "file");248 const files = input.attachments.filter((attachment) => attachment.type === "file");
185 const ready = (249 const ready = (
186 attachments: ReadonlyArray<UploadedMobileAttachment>,250 attachments: ReadonlyArray<UploadedMobileAttachment>,
...@@ -194,7 +258,7 @@ export async function prepareTurnAttachments(input: {...@@ -194,7 +258,7 @@ export async function prepareTurnAttachments(input: {
194 releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds),258 releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds),
195 });259 });
196260
197 if (files.length === 0) {261 if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) {
198 return ready(262 return ready(
199 toUploadChatImageAttachments(263 toUploadChatImageAttachments(
200 input.attachments.filter((attachment) => attachment.type === "image"),264 input.attachments.filter((attachment) => attachment.type === "image"),
...@@ -214,9 +278,13 @@ export async function prepareTurnAttachments(input: {...@@ -214,9 +278,13 @@ export async function prepareTurnAttachments(input: {
214 const uploadedAttachments: UploadedMobileAttachment[] = [];278 const uploadedAttachments: UploadedMobileAttachment[] = [];
215 const pendingAttachmentIds: string[] = [];279 const pendingAttachmentIds: string[] = [];
216 const createdAttachmentIds: string[] = [];280 const createdAttachmentIds: string[] = [];
281 const controller = new AbortController();
282 const abort = () => controller.abort();
283 input.signal?.addEventListener("abort", abort, { once: true });
217 try {284 try {
218 for (const attachment of input.attachments) {285 for (const attachment of input.attachments) {
219 if (attachment.type === "image") {286 if (controller.signal.aborted) throw new Error("Upload cancelled.");
287 if (attachment.type === "image" && !input.supportsImageUploads) {
220 uploadedAttachments.push(...toUploadChatImageAttachments([attachment]));288 uploadedAttachments.push(...toUploadChatImageAttachments([attachment]));
221 continue;289 continue;
222 }290 }
...@@ -238,13 +306,7 @@ export async function prepareTurnAttachments(input: {...@@ -238,13 +306,7 @@ export async function prepareTurnAttachments(input: {
238 }306 }
239 if (verification.status === "verified") {307 if (verification.status === "verified") {
240 pendingAttachmentIds.push(attachment.uploadedAttachmentId);308 pendingAttachmentIds.push(attachment.uploadedAttachmentId);
241 uploadedAttachments.push({309 uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId));
242 type: "file",
243 id: attachment.uploadedAttachmentId,
244 name: attachment.name,
245 mimeType: attachment.mimeType,
246 sizeBytes: attachment.sizeBytes,
247 });
248 continue;310 continue;
249 }311 }
250 // "missing": the pending upload expired, upload the bytes again.312 // "missing": the pending upload expired, upload the bytes again.
...@@ -255,12 +317,7 @@ export async function prepareTurnAttachments(input: {...@@ -255,12 +317,7 @@ export async function prepareTurnAttachments(input: {
255 createUploadUrl: attachmentEnvironment.createUploadUrl,317 createUploadUrl: attachmentEnvironment.createUploadUrl,
256 remove: attachmentEnvironment.remove,318 remove: attachmentEnvironment.remove,
257 environmentId,319 environmentId,
258 upload: {320 upload: attachmentUploadInput(attachment),
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 have321 // Read the connection at transfer time: the environment may have
265 // reconnected on a new base URL since this cycle started.322 // reconnected on a new base URL since this cycle started.
266 resolveUploadUrl: (relativeUrl) => {323 resolveUploadUrl: (relativeUrl) => {
...@@ -272,11 +329,18 @@ export async function prepareTurnAttachments(input: {...@@ -272,11 +329,18 @@ export async function prepareTurnAttachments(input: {
272 : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl);329 : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl);
273 },330 },
274 transport: (url) => ({331 transport: (url) => ({
275 done: uploadFileBytes(attachment, url),332 done: uploadFileBytes(
276 // expo-file-system uploads cannot abort mid-flight.333 attachment,
277 abort: () => {},334 url,
335 controller.signal,
336 input.onUploadProgress
337 ? (progress) => input.onUploadProgress?.(attachment.id, progress)
338 : undefined,
339 ),
340 abort,
278 }),341 }),
279 onMinted: (attachmentId) => {342 onMinted: (attachmentId) => {
343 if (controller.signal.aborted) return "cancel";
280 pendingAttachmentIds.push(attachmentId);344 pendingAttachmentIds.push(attachmentId);
281 createdAttachmentIds.push(attachmentId);345 createdAttachmentIds.push(attachmentId);
282 return "continue";346 return "continue";
...@@ -287,15 +351,11 @@ export async function prepareTurnAttachments(input: {...@@ -287,15 +351,11 @@ export async function prepareTurnAttachments(input: {
287 ? result.error351 ? result.error
288 : new Error(`Upload failed for '${attachment.name}'.`);352 : new Error(`Upload failed for '${attachment.name}'.`);
289 }353 }
290 uploadedAttachments.push({354 uploadedAttachments.push(uploadedReference(attachment, result.attachmentId));
291 type: "file",
292 id: result.attachmentId,
293 name: attachment.name,
294 mimeType: attachment.mimeType,
295 sizeBytes: attachment.sizeBytes,
296 });
297 }355 }
298356
357 if (controller.signal.aborted) throw new Error("Upload cancelled.");
358
299 const draftAttachments = withUploadedMobileAttachmentReferences({359 const draftAttachments = withUploadedMobileAttachmentReferences({
300 environmentId,360 environmentId,
301 attachments: input.attachments,361 attachments: input.attachments,
...@@ -313,6 +373,9 @@ export async function prepareTurnAttachments(input: {...@@ -313,6 +373,9 @@ export async function prepareTurnAttachments(input: {
313 return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments);373 return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments);
314 } catch (error) {374 } catch (error) {
315 await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);375 await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds);
376 if (controller.signal.aborted) return { status: "abandoned" };
316 throw error;377 throw error;
378 } finally {
379 input.signal?.removeEventListener("abort", abort);
317 }380 }
318}381}
apps/mobile/src/lib/composer-image-schema.ts+2
...@@ -9,6 +9,8 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({...@@ -9,6 +9,8 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({
9 mimeType: Schema.String,9 mimeType: Schema.String,
10 sizeBytes: Schema.Number,10 sizeBytes: Schema.Number,
11 dataUrl: Schema.String,11 dataUrl: Schema.String,
12 uploadedAttachmentId: Schema.optional(Schema.String),
13 uploadEnvironmentId: Schema.optional(EnvironmentId),
12});14});
1315
14export const DraftComposerFileAttachmentSchema = Schema.Struct({16export const DraftComposerFileAttachmentSchema = Schema.Struct({
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts created+258
...@@ -0,0 +1,258 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 composerAttachmentUploadBlockReason,
6 composerAttachmentUploadKey,
7 composerDraftEnvironmentId,
8 createComposerAttachmentUploadQueue,
9 type ComposerAttachmentUploadRequest,
10 type ComposerAttachmentUploadState,
11} from "./composerAttachmentUploadQueue";
12
13const environmentId = EnvironmentId.make("environment-1");
14function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest {
15 return {
16 environmentId: environment,
17 attachment: {
18 id,
19 type: "file",
20 name: `${id}.pdf`,
21 mimeType: "application/pdf",
22 sizeBytes: 42,
23 fileUri: `file:///documents/${id}.pdf`,
24 },
25 };
26}
27
28describe("composer attachment upload queue", () => {
29 it("bounds concurrency, deduplicates updates, and drains all attachments", async () => {
30 const gates = new Map<string, ReturnType<typeof Promise.withResolvers<boolean>>>();
31 const fourthStarted = Promise.withResolvers<void>();
32 const firstThreeStarted = Promise.withResolvers<void>();
33 let active = 0;
34 let maximum = 0;
35 const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => {
36 active += 1;
37 maximum = Math.max(maximum, active);
38 const gate = Promise.withResolvers<boolean>();
39 gates.set(input.attachment.id, gate);
40 if (gates.size === 3) firstThreeStarted.resolve();
41 if (gates.size === 4) fourthStarted.resolve();
42 try {
43 return await gate.promise;
44 } finally {
45 active -= 1;
46 }
47 });
48 const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} });
49 const requests = [request("one"), request("two"), request("three"), request("four")];
50 queue.sync(requests);
51 queue.sync(requests);
52 await firstThreeStarted.promise;
53 expect(upload).toHaveBeenCalledTimes(3);
54 gates.get("one")!.resolve(true);
55 await fourthStarted.promise;
56 for (const gate of gates.values()) gate.resolve(true);
57 await queue.settled();
58 queue.sync(requests);
59 await queue.settled();
60 expect(maximum).toBe(3);
61 expect(upload).toHaveBeenCalledTimes(4);
62 queue.dispose();
63 });
64
65 it("cancels on disconnect and resumes from the same local draft on reconnect", async () => {
66 const started = Promise.withResolvers<void>();
67 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
68 let signal: AbortSignal | undefined;
69 const upload = vi.fn(
70 async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => {
71 signal = currentSignal;
72 started.resolve();
73 return new Promise<boolean>((resolve) =>
74 currentSignal.addEventListener("abort", () => resolve(false), { once: true }),
75 );
76 },
77 );
78 const queue = createComposerAttachmentUploadQueue({
79 upload,
80 onChange: (next) => {
81 states = next;
82 },
83 });
84 const local = request("offline-draft");
85 queue.sync([local]);
86 await started.promise;
87 queue.sync([]);
88 await queue.settled();
89 expect(signal?.aborted).toBe(true);
90 expect(states).toEqual({});
91 upload.mockResolvedValueOnce(true);
92 queue.sync([local]);
93 await queue.settled();
94 expect(upload.mock.calls[1]?.[0]).toBe(local);
95 expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({
96 status: "ready",
97 });
98 expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" });
99 queue.dispose();
100 });
101
102 it("ignores a late completion after removal or environment switch", async () => {
103 const gate = Promise.withResolvers<boolean>();
104 const started = Promise.withResolvers<void>();
105 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
106 const upload = vi.fn(async () => {
107 started.resolve();
108 return gate.promise;
109 });
110 const queue = createComposerAttachmentUploadQueue({
111 upload,
112 onChange: (next) => {
113 states = next;
114 },
115 });
116 queue.sync([request("photo")]);
117 await started.promise;
118 upload.mockResolvedValueOnce(true);
119 const other = EnvironmentId.make("environment-2");
120 queue.sync([request("photo", other)]);
121 gate.resolve(true);
122 await queue.settled();
123 expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } });
124 queue.sync([]);
125 expect(states).toEqual({});
126 queue.dispose();
127 });
128
129 it("restarts a re-added attachment after its aborted transfer finishes settling", async () => {
130 const firstStarted = Promise.withResolvers<void>();
131 const firstSettled = Promise.withResolvers<boolean>();
132 const secondStarted = Promise.withResolvers<void>();
133 const secondSettled = Promise.withResolvers<boolean>();
134 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
135 let firstSignal: AbortSignal | undefined;
136 const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => {
137 if (!firstSignal) {
138 firstSignal = signal;
139 firstStarted.resolve();
140 return firstSettled.promise;
141 }
142 secondStarted.resolve();
143 return secondSettled.promise;
144 });
145 const queue = createComposerAttachmentUploadQueue({
146 upload,
147 onChange: (next) => {
148 states = next;
149 },
150 });
151 const local = request("re-added");
152 queue.sync([local]);
153 await firstStarted.promise;
154 queue.sync([]);
155 queue.sync([local]);
156 expect(firstSignal?.aborted).toBe(true);
157 expect(upload).toHaveBeenCalledOnce();
158 firstSettled.resolve(false);
159 await secondStarted.promise;
160 expect(upload).toHaveBeenCalledTimes(2);
161 secondSettled.resolve(true);
162 await queue.settled();
163 expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({
164 status: "ready",
165 });
166 queue.dispose();
167 });
168
169 it("keeps failures stable until retry and reports bounded progress", async () => {
170 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
171 const progress: number[] = [];
172 const upload = vi.fn(
173 async (
174 _request: ComposerAttachmentUploadRequest,
175 _signal: AbortSignal,
176 report: (value: number) => void,
177 ): Promise<boolean> => {
178 report(0.12);
179 report(0.13);
180 report(1.1);
181 throw new Error("Server unavailable");
182 },
183 );
184 const queue = createComposerAttachmentUploadQueue({
185 upload,
186 onChange: (next) => {
187 states = next;
188 const state = next[composerAttachmentUploadKey(environmentId, "file")];
189 if (state?.status === "uploading") progress.push(state.progress);
190 },
191 });
192 queue.sync([request("file")]);
193 await queue.settled();
194 queue.sync([request("file")]);
195 expect(upload).toHaveBeenCalledOnce();
196 expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({
197 status: "failed",
198 reason: "Server unavailable",
199 });
200 expect(progress).toEqual([0, 0.1, 1]);
201 upload.mockImplementationOnce(async () => true);
202 queue.retry(environmentId, "file");
203 await queue.settled();
204 expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" });
205 queue.dispose();
206 });
207
208 it("does not spin when an upload's draft was abandoned before persistence", async () => {
209 const upload = vi.fn(async () => false);
210 const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} });
211 queue.sync([request("discarded")]);
212 await queue.settled();
213 expect(upload).toHaveBeenCalledOnce();
214 queue.dispose();
215 });
216});
217
218describe("draft upload scope and offline submission", () => {
219 it("resolves thread, new-task, and queued-task drafts without crossing environments", () => {
220 expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId);
221 expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId);
222 expect(
223 composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]),
224 ).toBe(environmentId);
225 expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull();
226 const colonEnvironment = EnvironmentId.make("a:vcs-status:b");
227 expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment);
228 expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe(
229 colonEnvironment,
230 );
231 });
232
233 it("allows offline queuing while a connected composer waits for upload or retry", () => {
234 const key = composerAttachmentUploadKey(environmentId, "file");
235 const input = {
236 environmentId,
237 attachments: [request("file").attachment],
238 connected: true,
239 serverConfig: {
240 environment: {
241 capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } },
242 },
243 },
244 states: {},
245 };
246 expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading");
247 expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull();
248 expect(
249 composerAttachmentUploadBlockReason({
250 ...input,
251 states: { [key]: { status: "failed", reason: "Offline" } },
252 }),
253 ).toBe("Retry or remove the failed attachment");
254 expect(
255 composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }),
256 ).toBeNull();
257 });
258});
apps/mobile/src/lib/composerAttachmentUploadQueue.ts created+193
...@@ -0,0 +1,193 @@
1import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
2import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
3
4import type { DraftComposerAttachment } from "./composerImages";
5
6export interface ComposerAttachmentUploadRequest {
7 readonly environmentId: EnvironmentId;
8 readonly attachment: DraftComposerAttachment;
9}
10
11export type ComposerAttachmentUploadState =
12 | { readonly status: "uploading"; readonly progress: number }
13 | { readonly status: "ready" }
14 | { readonly status: "failed"; readonly reason: string };
15
16export function composerAttachmentUploadKey(
17 environmentId: EnvironmentId,
18 attachmentId: string,
19): string {
20 return `${environmentId}:${attachmentId}`;
21}
22
23export function composerDraftEnvironmentId(
24 draftKey: string,
25 queuedMessages: ReadonlyArray<{
26 readonly messageId: string;
27 readonly environmentId: EnvironmentId;
28 }>,
29): EnvironmentId | null {
30 if (draftKey.startsWith("pending-task:")) {
31 return (
32 queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey)
33 ?.environmentId ?? null
34 );
35 }
36 const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey;
37 const separator = scope.lastIndexOf(":");
38 return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null;
39}
40
41type UploadServerConfig = {
42 readonly environment: {
43 readonly capabilities: Pick<
44 ServerConfig["environment"]["capabilities"],
45 "attachmentUploads" | "fileAttachments"
46 >;
47 };
48};
49
50export function canUploadComposerAttachment(
51 attachment: DraftComposerAttachment,
52 config: UploadServerConfig | null | undefined,
53): boolean {
54 const capabilities = config?.environment.capabilities;
55 return (
56 capabilities?.attachmentUploads === true &&
57 (attachment.type === "image" ||
58 (capabilities.fileAttachments !== undefined &&
59 attachment.sizeBytes <=
60 clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes)))
61 );
62}
63
64export function composerAttachmentUploadBlockReason(input: {
65 readonly environmentId: EnvironmentId;
66 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
67 readonly connected: boolean;
68 readonly serverConfig: UploadServerConfig | null;
69 readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
70}): string | null {
71 if (!input.connected) return null;
72 for (const attachment of input.attachments) {
73 if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue;
74 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
75 if (state?.status === "failed") return "Retry or remove the failed attachment";
76 if (state?.status !== "ready") return "Attachment still uploading";
77 }
78 return null;
79}
80
81/** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */
82export function createComposerAttachmentUploadQueue(options: {
83 readonly upload: (
84 request: ComposerAttachmentUploadRequest,
85 signal: AbortSignal,
86 onProgress: (progress: number) => void,
87 ) => Promise<boolean>;
88 readonly onChange: (states: Readonly<Record<string, ComposerAttachmentUploadState>>) => void;
89}) {
90 const jobs = new Map<
91 string,
92 { readonly controller: AbortController; readonly done: Promise<void> }
93 >();
94 let desired = new Map<string, ComposerAttachmentUploadRequest>();
95 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
96 let disposed = false;
97
98 function setState(key: string, state: ComposerAttachmentUploadState | undefined) {
99 const previous = states[key];
100 if (
101 previous === state ||
102 (previous?.status === "uploading" &&
103 state?.status === "uploading" &&
104 previous.progress === state.progress)
105 )
106 return;
107 const next = { ...states };
108 if (state) next[key] = state;
109 else delete next[key];
110 states = next;
111 options.onChange(states);
112 }
113
114 function pump() {
115 if (disposed) return;
116 for (const [key, request] of desired) {
117 if (jobs.size >= 3) break;
118 if (jobs.has(key) || states[key]?.status === "ready" || states[key]?.status === "failed")
119 continue;
120 const controller = new AbortController();
121 setState(key, { status: "uploading", progress: 0 });
122 // Publish the job before starting async work, including synchronous test transports.
123 const done = Promise.resolve()
124 .then(() =>
125 options.upload(request, controller.signal, (progress) => {
126 if (controller.signal.aborted) return;
127 setState(key, {
128 status: "uploading",
129 progress: Math.floor(Math.max(0, Math.min(1, progress)) * 20) / 20,
130 });
131 }),
132 )
133 .then((persisted) => {
134 if (!controller.signal.aborted && desired.has(key)) {
135 if (!persisted) desired.delete(key);
136 setState(key, persisted ? { status: "ready" } : undefined);
137 }
138 })
139 .catch((error: unknown) => {
140 if (!controller.signal.aborted && desired.has(key)) {
141 setState(key, {
142 status: "failed",
143 reason: error instanceof Error ? error.message : "Upload failed. Tap to retry.",
144 });
145 }
146 })
147 .finally(() => {
148 jobs.delete(key);
149 pump();
150 });
151 jobs.set(key, { controller, done });
152 }
153 }
154
155 return {
156 sync(requests: ReadonlyArray<ComposerAttachmentUploadRequest>) {
157 if (disposed) return;
158 desired = new Map(
159 requests.map((request) => [
160 composerAttachmentUploadKey(request.environmentId, request.attachment.id),
161 request,
162 ]),
163 );
164 for (const [key, job] of jobs) {
165 if (!desired.has(key)) job.controller.abort();
166 }
167 for (const key of Object.keys(states)) {
168 if (!desired.has(key)) setState(key, undefined);
169 }
170 for (const key of desired.keys()) {
171 if (!states[key]) setState(key, { status: "uploading", progress: 0 });
172 }
173 pump();
174 },
175 retry(environmentId: EnvironmentId, attachmentId: string) {
176 const key = composerAttachmentUploadKey(environmentId, attachmentId);
177 if (states[key]?.status !== "failed") return;
178 setState(key, undefined);
179 pump();
180 },
181 /** Waits for the current transfers, useful for shutdown and focused verification. */
182 async settled() {
183 while (jobs.size > 0) await Promise.all([...jobs.values()].map((job) => job.done));
184 },
185 dispose() {
186 disposed = true;
187 desired.clear();
188 for (const job of jobs.values()) job.controller.abort();
189 states = {};
190 options.onChange(states);
191 },
192 };
193}
apps/mobile/src/lib/composerImages.ts+2
...@@ -23,6 +23,8 @@ import { uuidv4 } from "./uuid";...@@ -23,6 +23,8 @@ import { uuidv4 } from "./uuid";
23export interface DraftComposerImageAttachment extends UploadChatImageAttachment {23export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
24 readonly id: string;24 readonly id: string;
25 readonly previewUri: string;25 readonly previewUri: string;
26 readonly uploadedAttachmentId?: string;
27 readonly uploadEnvironmentId?: EnvironmentId;
26}28}
2729
28export interface DraftComposerFileAttachment {30export interface DraftComposerFileAttachment {
apps/mobile/src/lib/projectThreadStartTurn.ts+2-3
...@@ -2,15 +2,14 @@ import {...@@ -2,15 +2,14 @@ import {
2 CommandId,2 CommandId,
3 MessageId,3 MessageId,
4 ThreadId,4 ThreadId,
5 type ChatFileAttachment,
6 type ModelSelection,5 type ModelSelection,
7 type ProjectId,6 type ProjectId,
8 type ProviderInteractionMode,7 type ProviderInteractionMode,
9 type RuntimeMode,8 type RuntimeMode,
10 type UploadChatImageAttachment,
11} from "@t3tools/contracts";9} from "@t3tools/contracts";
1210
13import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";11import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
12import type { UploadedMobileAttachment } from "./attachmentUpload";
1413
15export function deriveThreadTitleFromPrompt(value: string): string {14export function deriveThreadTitleFromPrompt(value: string): string {
16 const trimmed = value.trim();15 const trimmed = value.trim();
...@@ -31,7 +30,7 @@ export interface ProjectThreadStartTurnSpec {...@@ -31,7 +30,7 @@ export interface ProjectThreadStartTurnSpec {
31 readonly createdAt: string;30 readonly createdAt: string;
32 readonly text: string;31 readonly text: string;
33 readonly attachments: ReadonlyArray<DraftComposerAttachment>;32 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
34 readonly uploadedAttachments?: ReadonlyArray<UploadChatImageAttachment | ChatFileAttachment>;33 readonly uploadedAttachments?: ReadonlyArray<UploadedMobileAttachment>;
35 readonly modelSelection: ModelSelection;34 readonly modelSelection: ModelSelection;
36 readonly runtimeMode: RuntimeMode;35 readonly runtimeMode: RuntimeMode;
37 readonly interactionMode: ProviderInteractionMode;36 readonly interactionMode: ProviderInteractionMode;
apps/mobile/src/state/composer-attachment-uploads.ts created+126
...@@ -0,0 +1,126 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { Atom } from "effect/unstable/reactivity";
4import { useEffect, useRef } from "react";
5
6import { prepareTurnAttachments } from "../lib/attachmentUpload";
7import {
8 composerAttachmentUploadKey,
9 composerDraftEnvironmentId,
10 canUploadComposerAttachment,
11 createComposerAttachmentUploadQueue,
12 type ComposerAttachmentUploadState,
13} from "../lib/composerAttachmentUploadQueue";
14import { appAtomRegistry } from "./atom-registry";
15import { useServerConfigs } from "./entities";
16import { flattenQueuedThreadMessages, threadOutboxManager } from "./thread-outbox";
17import { useThreadOutboxMessages } from "./use-thread-outbox";
18import {
19 composerDraftsAtom,
20 ensureComposerDraftsLoaded,
21 flushComposerDrafts,
22 retainComposerAttachmentFileForPreview,
23 setComposerDraftAttachmentUpload,
24} from "./use-composer-drafts";
25import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
26
27export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue";
28
29export const composerAttachmentUploadsAtom = Atom.make<
30 Readonly<Record<string, ComposerAttachmentUploadState>>
31>({}).pipe(Atom.keepAlive);
32const uploadStateAtom = Atom.family((key: string) =>
33 Atom.map(composerAttachmentUploadsAtom, (states) => states[key]),
34);
35let uploadQueue: ReturnType<typeof createComposerAttachmentUploadQueue> | null = null;
36
37export function useComposerAttachmentUploadState(
38 environmentId: EnvironmentId | undefined,
39 attachmentId: string,
40) {
41 return useAtomValue(
42 uploadStateAtom(environmentId ? composerAttachmentUploadKey(environmentId, attachmentId) : ""),
43 );
44}
45
46export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) {
47 uploadQueue?.retry(environmentId, attachmentId);
48}
49
50/** Runs outside mounted composers so a transfer can finish after navigation. */
51export function useComposerAttachmentUploadWorker() {
52 const drafts = useAtomValue(composerDraftsAtom);
53 const queuedMessages = useThreadOutboxMessages();
54 const serverConfigs = useServerConfigs();
55 const { connectedEnvironments } = useRemoteConnectionStatus();
56 const queueRef = useRef<ReturnType<typeof createComposerAttachmentUploadQueue> | null>(null);
57
58 useEffect(() => {
59 ensureComposerDraftsLoaded();
60 const queue = createComposerAttachmentUploadQueue({
61 onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states),
62 upload: async ({ environmentId, attachment }, signal, onProgress) => {
63 const release =
64 attachment.type === "file"
65 ? retainComposerAttachmentFileForPreview(attachment)
66 : undefined;
67 try {
68 const result = await prepareTurnAttachments({
69 environmentId,
70 attachments: [attachment],
71 supportsImageUploads: true,
72 signal,
73 onUploadProgress: (_, progress) => onProgress(progress),
74 persistUploadedReferences: async ([uploaded]) => {
75 if (signal.aborted || !uploaded) return "abandon";
76 const queued = flattenQueuedThreadMessages(
77 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
78 );
79 let retained = false;
80 for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) {
81 if (
82 composerDraftEnvironmentId(key, queued) === environmentId &&
83 draft.attachments.some((candidate) => candidate.id === attachment.id)
84 ) {
85 retained = setComposerDraftAttachmentUpload(key, uploaded) || retained;
86 }
87 }
88 if (!retained) return "abandon";
89 await flushComposerDrafts();
90 return "persisted";
91 },
92 });
93 return result.status === "ready";
94 } finally {
95 release?.();
96 }
97 },
98 });
99 queueRef.current = queue;
100 uploadQueue = queue;
101 return () => {
102 queue.dispose();
103 if (uploadQueue === queue) uploadQueue = null;
104 queueRef.current = null;
105 };
106 }, []);
107
108 useEffect(() => {
109 const queued = flattenQueuedThreadMessages(queuedMessages);
110 const connected = new Set(
111 connectedEnvironments
112 .filter((environment) => environment.connectionState === "connected")
113 .map((environment) => environment.environmentId),
114 );
115 const requests = Object.entries(drafts).flatMap(([key, draft]) => {
116 const environmentId = composerDraftEnvironmentId(key, queued);
117 if (environmentId === null || !connected.has(environmentId)) return [];
118 return draft.attachments
119 .filter((attachment) =>
120 canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)),
121 )
122 .map((attachment) => ({ environmentId, attachment }));
123 });
124 queueRef.current?.sync(requests);
125 }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]);
126}
apps/mobile/src/state/use-composer-drafts.test.ts+150
...@@ -118,10 +118,12 @@ import { appAtomRegistry } from "./atom-registry";...@@ -118,10 +118,12 @@ import { appAtomRegistry } from "./atom-registry";
118import { threadOutboxManager } from "./thread-outbox";118import { threadOutboxManager } from "./thread-outbox";
119import {119import {
120 appendComposerDraftAttachments,120 appendComposerDraftAttachments,
121 archiveCloudComposerDrafts,
121 clearComposerDraftContentState,122 clearComposerDraftContentState,
122 clearComposerDraftsEnvironment,123 clearComposerDraftsEnvironment,
123 ComposerDraftPersistenceError,124 ComposerDraftPersistenceError,
124 composerDraftsAtom,125 composerDraftsAtom,
126 composerCloudDraftsAtom,
125 copyComposerDraftContentIfEmpty,127 copyComposerDraftContentIfEmpty,
126 copyComposerDraftContentState,128 copyComposerDraftContentState,
127 decodePersistedComposerState,129 decodePersistedComposerState,
...@@ -136,7 +138,10 @@ import {...@@ -136,7 +138,10 @@ import {
136 resetComposerDraftsLoadState,138 resetComposerDraftsLoadState,
137 retainComposerAttachmentFileForPreview,139 retainComposerAttachmentFileForPreview,
138 restoreComposerDraftSnapshotState,140 restoreComposerDraftSnapshotState,
141 restoreCloudComposerDrafts,
139 setComposerDraftText,142 setComposerDraftText,
143 setComposerDraftAttachmentUpload,
144 waitForComposerDraftsLoaded,
140 setStickyComposerModelSelection,145 setStickyComposerModelSelection,
141 stickyComposerModelSelectionAtom,146 stickyComposerModelSelectionAtom,
142 undoComposerDraftMerge,147 undoComposerDraftMerge,
...@@ -157,6 +162,7 @@ afterEach(() => {...@@ -157,6 +162,7 @@ afterEach(() => {
157 composerDraftFileMocks.setOnWrite(null);162 composerDraftFileMocks.setOnWrite(null);
158 composerDraftFileMocks.resetWrites();163 composerDraftFileMocks.resetWrites();
159 appAtomRegistry.set(composerDraftsAtom, {});164 appAtomRegistry.set(composerDraftsAtom, {});
165 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
160 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);166 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
161 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});167 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
162 composerAttachmentCleanupMocks.remove.mockClear();168 composerAttachmentCleanupMocks.remove.mockClear();
...@@ -320,6 +326,150 @@ describe("mobile composer drafts", () => {...@@ -320,6 +326,150 @@ describe("mobile composer drafts", () => {
320 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();326 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
321 });327 });
322328
329 it("retains offline image bytes and newer edits when an early upload finishes", async () => {
330 const key = "environment-1:thread-1";
331 const image = {
332 id: "photo",
333 type: "image" as const,
334 name: "photo.png",
335 mimeType: "image/png",
336 sizeBytes: 3,
337 dataUrl: "data:image/png;base64,YWJj",
338 previewUri: "file:///photo.png",
339 };
340 const second = { ...image, id: "second", name: "second.png" };
341 const uploaded = {
342 ...image,
343 uploadedAttachmentId: "pending-photo",
344 uploadEnvironmentId: EnvironmentId.make("environment-1"),
345 };
346 composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} });
347 appendComposerDraftAttachments(key, [image]);
348 setComposerDraftText(key, "Edited while uploading");
349 appendComposerDraftAttachments(key, [second]);
350 expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true);
351 await flushComposerDrafts();
352
353 appAtomRegistry.set(composerDraftsAtom, {});
354 resetComposerDraftsLoadState();
355 await waitForComposerDraftsLoaded();
356 expect(getComposerDraftSnapshot(key)).toMatchObject({
357 text: "Edited while uploading",
358 attachments: [uploaded, second],
359 });
360 expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false);
361 expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2);
362 });
363
364 it("cleans up an unreferenced image upload even when there is no local file URI", async () => {
365 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
366 onTestFinished(() => outboxLoad.mockRestore());
367 const environmentId = EnvironmentId.make("environment-1");
368 await releaseUnusedComposerAttachmentFiles([
369 {
370 id: "photo",
371 type: "image",
372 name: "photo.png",
373 mimeType: "image/png",
374 sizeBytes: 3,
375 dataUrl: "data:image/png;base64,YWJj",
376 previewUri: "file:///photo.png",
377 uploadedAttachmentId: "pending-photo",
378 uploadEnvironmentId: environmentId,
379 },
380 ]);
381 expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [
382 "pending-photo",
383 ]);
384 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
385 });
386
387 it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => {
388 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
389 onTestFinished(() => load.mockRestore());
390 await waitForComposerDraftsLoaded();
391 const environmentId = EnvironmentId.make("cloud-environment");
392 const key = `${environmentId}:thread-1`;
393 const file = {
394 id: "local-pdf",
395 type: "file" as const,
396 name: "notes.pdf",
397 mimeType: "application/pdf",
398 sizeBytes: 42,
399 fileUri: "file:///documents/t3-composer-attachments/notes.pdf",
400 uploadEnvironmentId: environmentId,
401 uploadedAttachmentId: "pending-pdf",
402 };
403 const queued = {
404 environmentId,
405 threadId: ThreadId.make("thread-2"),
406 messageId: MessageId.make("queued-1"),
407 commandId: CommandId.make("command-1"),
408 text: "Send later",
409 attachments: [file],
410 createdAt: "2026-08-31T12:00:00.000Z",
411 };
412 appAtomRegistry.set(composerDraftsAtom, {
413 [key]: { text: "Unsent notes", attachments: [file] },
414 "direct-environment:thread-1": DRAFT,
415 "pending-task:queued-1": { text: "Edited queued task", attachments: [file] },
416 });
417 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] });
418 await archiveCloudComposerDrafts("account-a", new Set([environmentId]));
419 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
420 "direct-environment:thread-1": DRAFT,
421 });
422 // The registry can remove the active outbox and drafts after the backup lands.
423 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
424 await clearComposerDraftsEnvironment(environmentId);
425 await releaseUnusedComposerAttachmentFiles([file]);
426 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
427 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
428
429 appAtomRegistry.set(composerDraftsAtom, {});
430 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
431 resetComposerDraftsLoadState();
432 await waitForComposerDraftsLoaded();
433 await restoreCloudComposerDrafts("account-b");
434 expect(getComposerDraftSnapshot(key).attachments).toEqual([]);
435 expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({});
436 const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue();
437 onTestFinished(() => enqueue.mockRestore());
438 await restoreCloudComposerDrafts("account-a");
439 expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] });
440 expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task");
441 expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued);
442 expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({});
443 const persisted = decodePersistedComposerState(
444 JSON.parse(composerDraftFileMocks.getDocument()),
445 );
446 expect(persisted.drafts[key]?.attachments).toEqual([file]);
447 expect(persisted.cloudDrafts.accountId).toBe("account-a");
448 });
449
450 it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => {
451 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
452 onTestFinished(() => load.mockRestore());
453 await waitForComposerDraftsLoaded();
454 appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": DRAFT });
455 composerDraftFileMocks.setWriteError(new Error("Storage is full"));
456 await expect(
457 archiveCloudComposerDrafts("account-a", new Set([EnvironmentId.make("environment-1")])),
458 ).rejects.toThrow();
459 expect(
460 appAtomRegistry.get(composerCloudDraftsAtom).signedOut["account-a"]?.drafts[
461 "environment-1:thread-1"
462 ],
463 ).toEqual(DRAFT);
464 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
465 composerDraftFileMocks.setWriteError(null);
466 await archiveCloudComposerDrafts(null, new Set([EnvironmentId.make("environment-1")]));
467 expect(
468 decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).cloudDrafts
469 .signedOut["account-a"]?.drafts["environment-1:thread-1"],
470 ).toEqual(DRAFT);
471 });
472
323 it("keeps a removed file until both playback and a share copy finish", async () => {473 it("keeps a removed file until both playback and a share copy finish", async () => {
324 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);474 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
325 onTestFinished(() => outboxLoad.mockRestore());475 onTestFinished(() => outboxLoad.mockRestore());
apps/mobile/src/state/use-composer-drafts.ts+311-13
...@@ -23,7 +23,14 @@ import {...@@ -23,7 +23,14 @@ import {
23import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";23import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
24import { SerializedAsyncQueue } from "../lib/serialized-async-queue";24import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
25import { appAtomRegistry } from "./atom-registry";25import { appAtomRegistry } from "./atom-registry";
26import {
27 decodeQueuedThreadMessage,
28 encodeQueuedThreadMessage,
29 QueuedThreadMessageSchema,
30 type QueuedThreadMessage,
31} from "./thread-outbox-model";
26import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox";32import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox";
33import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue";
2734
28const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;35const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;
29const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";36const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";
...@@ -93,6 +100,16 @@ const PersistedComposerDraftsSchema = Schema.Struct({...@@ -93,6 +100,16 @@ const PersistedComposerDraftsSchema = Schema.Struct({
93 schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),100 schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),
94 drafts: Schema.Record(Schema.String, ComposerDraftSchema),101 drafts: Schema.Record(Schema.String, ComposerDraftSchema),
95 stickyModelSelection: Schema.optional(ModelSelectionSchema),102 stickyModelSelection: Schema.optional(ModelSelectionSchema),
103 cloudAccountId: Schema.optional(Schema.String),
104 signedOutDrafts: Schema.optional(
105 Schema.Record(
106 Schema.String,
107 Schema.Struct({
108 drafts: Schema.Record(Schema.String, ComposerDraftSchema),
109 queuedMessages: Schema.Array(QueuedThreadMessageSchema),
110 }),
111 ),
112 ),
96});113});
97114
98const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(115const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(
...@@ -114,6 +131,21 @@ export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>...@@ -114,6 +131,21 @@ export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>
114 Atom.withLabel("mobile:sticky-composer-model-selection"),131 Atom.withLabel("mobile:sticky-composer-model-selection"),
115);132);
116133
134interface SignedOutDrafts {
135 readonly drafts: Record<string, ComposerDraft>;
136 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
137}
138
139interface ComposerCloudDraftState {
140 readonly accountId: string | null;
141 readonly signedOut: Record<string, SignedOutDrafts>;
142}
143
144export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({
145 accountId: null,
146 signedOut: {},
147}).pipe(Atom.keepAlive);
148
117let loadPromise: Promise<void> | null = null;149let loadPromise: Promise<void> | null = null;
118let persistTimer: ReturnType<typeof setTimeout> | null = null;150let persistTimer: ReturnType<typeof setTimeout> | null = null;
119const persistenceQueue = new SerializedAsyncQueue();151const persistenceQueue = new SerializedAsyncQueue();
...@@ -156,6 +188,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean {...@@ -156,6 +188,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
156export function decodePersistedComposerState(value: unknown): {188export function decodePersistedComposerState(value: unknown): {
157 readonly drafts: Record<string, ComposerDraft>;189 readonly drafts: Record<string, ComposerDraft>;
158 readonly stickyModelSelection: ModelSelection | null;190 readonly stickyModelSelection: ModelSelection | null;
191 readonly cloudDrafts: ComposerCloudDraftState;
159} {192} {
160 const parsed = decodePersistedComposerDraftsDocument(value);193 const parsed = decodePersistedComposerDraftsDocument(value);
161 return {194 return {
...@@ -188,6 +221,18 @@ export function decodePersistedComposerState(value: unknown): {...@@ -188,6 +221,18 @@ export function decodePersistedComposerState(value: unknown): {
188 .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0),221 .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0),
189 ),222 ),
190 stickyModelSelection: parsed.stickyModelSelection ?? null,223 stickyModelSelection: parsed.stickyModelSelection ?? null,
224 cloudDrafts: {
225 accountId: parsed.cloudAccountId ?? null,
226 signedOut: Object.fromEntries(
227 Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [
228 id,
229 {
230 drafts: saved.drafts,
231 queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage),
232 },
233 ]),
234 ),
235 },
191 };236 };
192}237}
193238
...@@ -202,15 +247,18 @@ async function getComposerDraftsFile() {...@@ -202,15 +247,18 @@ async function getComposerDraftsFile() {
202 return new File(directory, COMPOSER_DRAFTS_FILE);247 return new File(directory, COMPOSER_DRAFTS_FILE);
203}248}
204249
205async function loadPersistedComposerState(): Promise<{250async function loadPersistedComposerState(): Promise<
206 readonly drafts: Record<string, ComposerDraft>;251 ReturnType<typeof decodePersistedComposerState>
207 readonly stickyModelSelection: ModelSelection | null;252> {
208}> {
209 let operation: ComposerDraftPersistenceError["operation"] = "open";253 let operation: ComposerDraftPersistenceError["operation"] = "open";
210 try {254 try {
211 const file = await getComposerDraftsFile();255 const file = await getComposerDraftsFile();
212 if (!file.exists) {256 if (!file.exists) {
213 return { drafts: {}, stickyModelSelection: null };257 return {
258 drafts: {},
259 stickyModelSelection: null,
260 cloudDrafts: { accountId: null, signedOut: {} },
261 };
214 }262 }
215 operation = "read";263 operation = "read";
216 const raw = await file.text();264 const raw = await file.text();
...@@ -226,13 +274,18 @@ async function loadPersistedComposerState(): Promise<{...@@ -226,13 +274,18 @@ async function loadPersistedComposerState(): Promise<{
226 cause,274 cause,
227 }),275 }),
228 );276 );
229 return { drafts: {}, stickyModelSelection: null };277 return {
278 drafts: {},
279 stickyModelSelection: null,
280 cloudDrafts: { accountId: null, signedOut: {} },
281 };
230 }282 }
231}283}
232284
233async function writePersistedComposerState(285async function writePersistedComposerState(
234 drafts: Record<string, ComposerDraft>,286 drafts: Record<string, ComposerDraft>,
235 stickyModelSelection: ModelSelection | null,287 stickyModelSelection: ModelSelection | null,
288 cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom),
236): Promise<void> {289): Promise<void> {
237 let operation: ComposerDraftPersistenceError["operation"] = "open";290 let operation: ComposerDraftPersistenceError["operation"] = "open";
238 try {291 try {
...@@ -245,6 +298,20 @@ async function writePersistedComposerState(...@@ -245,6 +298,20 @@ async function writePersistedComposerState(
245 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,298 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
246 drafts: nonEmptyDrafts,299 drafts: nonEmptyDrafts,
247 ...(stickyModelSelection ? { stickyModelSelection } : {}),300 ...(stickyModelSelection ? { stickyModelSelection } : {}),
301 ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}),
302 ...(Object.keys(cloudDrafts.signedOut).length > 0
303 ? {
304 signedOutDrafts: Object.fromEntries(
305 Object.entries(cloudDrafts.signedOut).map(([id, saved]) => [
306 id,
307 {
308 drafts: saved.drafts,
309 queuedMessages: saved.queuedMessages.map(encodeQueuedThreadMessage),
310 },
311 ]),
312 ),
313 }
314 : {}),
248 } as const;315 } as const;
249 const encoded = JSON.stringify(document);316 const encoded = JSON.stringify(document);
250 operation = "write";317 operation = "write";
...@@ -290,6 +357,13 @@ export async function flushComposerDrafts(): Promise<void> {...@@ -290,6 +357,13 @@ export async function flushComposerDrafts(): Promise<void> {
290 } while (persistTimer !== null);357 } while (persistTimer !== null);
291}358}
292359
360function signedOutAttachmentOwners() {
361 return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [
362 ...Object.values(saved.drafts),
363 ...saved.queuedMessages,
364 ]);
365}
366
293function isComposerAttachmentFileReferenced(fileUri: string): boolean {367function isComposerAttachmentFileReferenced(fileUri: string): boolean {
294 if (isComposerAttachmentFileRetained(fileUri)) {368 if (isComposerAttachmentFileRetained(fileUri)) {
295 return true;369 return true;
...@@ -299,7 +373,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean {...@@ -299,7 +373,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean {
299 const queuedMessages = Object.values(373 const queuedMessages = Object.values(
300 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),374 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
301 ).flat();375 ).flat();
302 return [...drafts, ...queuedMessages].some((owner) =>376 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>
303 owner.attachments.some(377 owner.attachments.some(
304 (attachment) =>378 (attachment) =>
305 attachment.type === "file" &&379 attachment.type === "file" &&
...@@ -316,10 +390,9 @@ function isComposerAttachmentUploadReferenced(...@@ -316,10 +390,9 @@ function isComposerAttachmentUploadReferenced(
316 const queuedMessages = Object.values(390 const queuedMessages = Object.values(
317 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),391 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
318 ).flat();392 ).flat();
319 return [...drafts, ...queuedMessages].some((owner) =>393 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>
320 owner.attachments.some(394 owner.attachments.some(
321 (attachment) =>395 (attachment) =>
322 attachment.type === "file" &&
323 attachment.uploadEnvironmentId === environmentId &&396 attachment.uploadEnvironmentId === environmentId &&
324 attachment.uploadedAttachmentId === attachmentId,397 attachment.uploadedAttachmentId === attachmentId,
325 ),398 ),
...@@ -337,7 +410,6 @@ export async function releaseUnusedComposerAttachmentFiles(...@@ -337,7 +410,6 @@ export async function releaseUnusedComposerAttachmentFiles(
337 const uploadCandidates = new Map<EnvironmentId, Set<string>>();410 const uploadCandidates = new Map<EnvironmentId, Set<string>>();
338 for (const attachment of attachments) {411 for (const attachment of attachments) {
339 if (412 if (
340 attachment.type !== "file" ||
341 attachment.uploadEnvironmentId === undefined ||413 attachment.uploadEnvironmentId === undefined ||
342 attachment.uploadedAttachmentId === undefined414 attachment.uploadedAttachmentId === undefined
343 ) {415 ) {
...@@ -347,7 +419,7 @@ export async function releaseUnusedComposerAttachmentFiles(...@@ -347,7 +419,7 @@ export async function releaseUnusedComposerAttachmentFiles(
347 ids.add(attachment.uploadedAttachmentId);419 ids.add(attachment.uploadedAttachmentId);
348 uploadCandidates.set(attachment.uploadEnvironmentId, ids);420 uploadCandidates.set(attachment.uploadEnvironmentId, ids);
349 }421 }
350 if (candidates.size === 0) {422 if (candidates.size === 0 && uploadCandidates.size === 0) {
351 return;423 return;
352 }424 }
353425
...@@ -435,7 +507,11 @@ export async function releaseUnusedComposerAttachmentFiles(...@@ -435,7 +507,11 @@ export async function releaseUnusedComposerAttachmentFiles(
435export function scheduleUnusedComposerAttachmentCleanup(507export function scheduleUnusedComposerAttachmentCleanup(
436 attachments: ReadonlyArray<DraftComposerAttachment>,508 attachments: ReadonlyArray<DraftComposerAttachment>,
437): void {509): void {
438 if (!attachments.some((attachment) => attachment.type === "file")) {510 if (
511 !attachments.some(
512 (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined,
513 )
514 ) {
439 return;515 return;
440 }516 }
441 void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => {517 void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => {
...@@ -443,7 +519,7 @@ export function scheduleUnusedComposerAttachmentCleanup(...@@ -443,7 +519,7 @@ export function scheduleUnusedComposerAttachmentCleanup(
443 });519 });
444}520}
445521
446/** Keeps previews usable after send/removal, then retries the normal ownership cleanup. */522/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */
447export function retainComposerAttachmentFileForPreview(523export function retainComposerAttachmentFileForPreview(
448 attachment: DraftComposerFileAttachment,524 attachment: DraftComposerFileAttachment,
449): () => void {525): () => void {
...@@ -484,6 +560,7 @@ export function ensureComposerDraftsLoaded(): void {...@@ -484,6 +560,7 @@ export function ensureComposerDraftsLoaded(): void {
484 }560 }
485 loadPromise = loadPersistedComposerState()561 loadPromise = loadPersistedComposerState()
486 .then((persisted) => {562 .then((persisted) => {
563 appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts);
487 if (Object.keys(persisted.drafts).length > 0) {564 if (Object.keys(persisted.drafts).length > 0) {
488 const current = appAtomRegistry.get(composerDraftsAtom);565 const current = appAtomRegistry.get(composerDraftsAtom);
489 appAtomRegistry.set(composerDraftsAtom, {566 appAtomRegistry.set(composerDraftsAtom, {
...@@ -520,6 +597,192 @@ export async function waitForComposerDraftsLoaded(): Promise<void> {...@@ -520,6 +597,192 @@ export async function waitForComposerDraftsLoaded(): Promise<void> {
520 }597 }
521}598}
522599
600export async function getComposerCloudAccountId(): Promise<string | null> {
601 await waitForComposerDraftsLoaded();
602 return appAtomRegistry.get(composerCloudDraftsAtom).accountId;
603}
604
605/** Save an account's local work before its relay environments are removed. */
606export async function archiveCloudComposerDrafts(
607 accountId: string | null,
608 environmentIds: ReadonlySet<EnvironmentId>,
609): Promise<void> {
610 await waitForComposerDraftsLoaded();
611 if (!(await threadOutboxManager.load())) throw new Error("Could not preserve queued messages.");
612 await flushThreadOutbox();
613 const cloud = appAtomRegistry.get(composerCloudDraftsAtom);
614 const owner = accountId ?? cloud.accountId;
615 if (owner === null) return;
616 const queued = Object.values(
617 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
618 ).flat();
619 const current = appAtomRegistry.get(composerDraftsAtom);
620 const remaining = { ...current };
621 const savedDrafts = { ...cloud.signedOut[owner]?.drafts };
622 for (const [key, draft] of Object.entries(current)) {
623 const environmentId = composerDraftEnvironmentId(key, queued);
624 if (environmentId !== null && environmentIds.has(environmentId)) {
625 savedDrafts[key] = draft;
626 delete remaining[key];
627 }
628 }
629 const savedMessages = new Map(
630 (cloud.signedOut[owner]?.queuedMessages ?? []).map((message) => [message.messageId, message]),
631 );
632 for (const message of queued) {
633 if (environmentIds.has(message.environmentId)) savedMessages.set(message.messageId, message);
634 }
635 appAtomRegistry.set(composerDraftsAtom, remaining);
636 appAtomRegistry.set(composerCloudDraftsAtom, {
637 // Keep the owner through removal. A crash or failed cleanup can retry it
638 // on cold start before a different account activates.
639 accountId: owner,
640 signedOut: {
641 ...cloud.signedOut,
642 [owner]: { drafts: savedDrafts, queuedMessages: [...savedMessages.values()] },
643 },
644 });
645 schedulePersistComposerState();
646 await flushComposerDrafts();
647}
648
649function sameDraftAttachmentIds(
650 left: ReadonlyArray<DraftComposerAttachment>,
651 right: ReadonlyArray<DraftComposerAttachment>,
652): boolean {
653 return (
654 left.length === right.length &&
655 left.every((attachment, index) => attachment.id === right[index]?.id)
656 );
657}
658
659/** An in-flight delivery can finish after sign-out took its snapshot. */
660export async function removeDeliveredCloudQueuedMessage(
661 message: QueuedThreadMessage,
662): Promise<void> {
663 await waitForComposerDraftsLoaded();
664 const cloud = appAtomRegistry.get(composerCloudDraftsAtom);
665 const signedOut = { ...cloud.signedOut };
666 let changed = false;
667 for (const [accountId, saved] of Object.entries(signedOut)) {
668 const archived = saved.queuedMessages.find(
669 (candidate) =>
670 candidate.environmentId === message.environmentId &&
671 candidate.messageId === message.messageId,
672 );
673 if (
674 !archived ||
675 archived.commandId !== message.commandId ||
676 archived.threadId !== message.threadId ||
677 archived.text !== message.text ||
678 !sameDraftAttachmentIds(archived.attachments, message.attachments)
679 )
680 continue;
681 // Upload ids may change during preparation; user edits must remain recoverable.
682 if (
683 JSON.stringify([
684 archived.modelSelection,
685 archived.runtimeMode,
686 archived.interactionMode,
687 archived.creation,
688 ]) !==
689 JSON.stringify([
690 message.modelSelection,
691 message.runtimeMode,
692 message.interactionMode,
693 message.creation,
694 ])
695 )
696 continue;
697 const editorKey = `pending-task:${message.messageId}`;
698 const editor = saved.drafts[editorKey];
699 if (
700 editor &&
701 (editor.text !== message.text ||
702 !sameDraftAttachmentIds(editor.attachments, message.attachments) ||
703 (editor.modelSelection !== undefined &&
704 JSON.stringify(editor.modelSelection) !== JSON.stringify(message.modelSelection)) ||
705 (editor.runtimeMode !== undefined && editor.runtimeMode !== message.runtimeMode) ||
706 (editor.interactionMode !== undefined &&
707 editor.interactionMode !== message.interactionMode) ||
708 (editor.workspaceSelection !== undefined &&
709 (editor.workspaceSelection.mode !== message.creation?.workspaceMode ||
710 editor.workspaceSelection.branch !== message.creation?.branch ||
711 editor.workspaceSelection.worktreePath !== message.creation?.worktreePath ||
712 (editor.workspaceSelection.startFromOrigin ?? false) !==
713 (message.creation?.startFromOrigin ?? false))))
714 )
715 continue;
716 const drafts = { ...saved.drafts };
717 delete drafts[editorKey];
718 signedOut[accountId] = {
719 drafts,
720 queuedMessages: saved.queuedMessages.filter((candidate) => candidate !== archived),
721 };
722 changed = true;
723 }
724 if (!changed) return;
725 appAtomRegistry.set(composerCloudDraftsAtom, { ...cloud, signedOut });
726 schedulePersistComposerState();
727 try {
728 await flushComposerDrafts();
729 } catch (error) {
730 // The live outbox can still remove this acknowledged message. Keep the
731 // archive update pending so a later successful flush lands it too.
732 schedulePersistComposerState();
733 throw error;
734 }
735}
736
737/** Restores only this account, before its connections can deliver queued turns. */
738export async function restoreCloudComposerDrafts(accountId: string): Promise<void> {
739 await waitForComposerDraftsLoaded();
740 const cloud = appAtomRegistry.get(composerCloudDraftsAtom);
741 const saved = cloud.signedOut[accountId];
742 if (saved) {
743 if (!(await threadOutboxManager.load())) throw new Error("Could not restore queued messages.");
744 for (const message of saved.queuedMessages) {
745 const alreadyQueued = Object.values(
746 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
747 )
748 .flat()
749 .some((current) => current.messageId === message.messageId);
750 if (!alreadyQueued) await threadOutboxManager.enqueue(message);
751 }
752 updateComposerDrafts((current) => {
753 const restored = { ...current };
754 for (const [key, draft] of Object.entries(saved.drafts)) {
755 const existing = current[key];
756 const attachmentIds = new Set(existing?.attachments.map((attachment) => attachment.id));
757 restored[key] = existing
758 ? {
759 ...draft,
760 ...existing,
761 text: mergeComposerDraftText(existing.text, draft.text),
762 // A concurrent import must not lose files, even above the send limit.
763 attachments: [
764 ...existing.attachments,
765 ...draft.attachments.filter((attachment) => !attachmentIds.has(attachment.id)),
766 ],
767 importedShareIds: [
768 ...new Set([
769 ...(existing.importedShareIds ?? []),
770 ...(draft.importedShareIds ?? []),
771 ]),
772 ],
773 }
774 : draft;
775 }
776 return restored;
777 });
778 }
779 const signedOut = { ...cloud.signedOut };
780 delete signedOut[accountId];
781 appAtomRegistry.set(composerCloudDraftsAtom, { accountId, signedOut });
782 schedulePersistComposerState();
783 await flushComposerDrafts();
784}
785
523function updateComposerDrafts(786function updateComposerDrafts(
524 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,787 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,
525): void {788): void {
...@@ -655,6 +918,41 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)...@@ -655,6 +918,41 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
655 );918 );
656}919}
657920
921/** Stamps a finished upload without overwriting text, removals, or newer attachments. */
922export function setComposerDraftAttachmentUpload(
923 draftKey: string,
924 attachment: DraftComposerAttachment,
925): boolean {
926 let previous: DraftComposerAttachment | undefined;
927 updateComposerDrafts((current) => {
928 const draft = current[draftKey];
929 previous = draft?.attachments.find((candidate) => candidate.id === attachment.id);
930 if (!draft || !previous) return current;
931 if (
932 previous.uploadedAttachmentId === attachment.uploadedAttachmentId &&
933 previous.uploadEnvironmentId === attachment.uploadEnvironmentId
934 )
935 return current;
936 return {
937 ...current,
938 [draftKey]: {
939 ...draft,
940 attachments: draft.attachments.map((candidate) =>
941 candidate.id === attachment.id
942 ? {
943 ...candidate,
944 uploadedAttachmentId: attachment.uploadedAttachmentId,
945 uploadEnvironmentId: attachment.uploadEnvironmentId,
946 }
947 : candidate,
948 ),
949 },
950 };
951 });
952 if (previous) scheduleUnusedComposerAttachmentCleanup([previous]);
953 return previous !== undefined;
954}
955
658export function updateComposerDraftSettings(956export function updateComposerDraftSettings(
659 draftKey: string,957 draftKey: string,
660 settings: Partial<ComposerDraftSettingsUpdate>,958 settings: Partial<ComposerDraftSettingsUpdate>,
apps/mobile/src/state/use-thread-composer-state.ts+16-1
...@@ -56,6 +56,10 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox";...@@ -56,6 +56,10 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox";
56import { useThreadOutboxMessages } from "./use-thread-outbox";56import { useThreadOutboxMessages } from "./use-thread-outbox";
57import { threadEnvironment } from "./threads";57import { threadEnvironment } from "./threads";
58import { useAtomCommand } from "./use-atom-command";58import { useAtomCommand } from "./use-atom-command";
59import {
60 composerAttachmentUploadBlockReason,
61 composerAttachmentUploadsAtom,
62} from "./composer-attachment-uploads";
5963
60export function appendReviewCommentToDraft(input: {64export function appendReviewCommentToDraft(input: {
61 readonly environmentId: EnvironmentId;65 readonly environmentId: EnvironmentId;
...@@ -178,6 +182,16 @@ export function useThreadComposerState() {...@@ -178,6 +182,16 @@ export function useThreadComposerState() {
178 const thread = selectedThreadDetail ?? selectedThreadShell;182 const thread = selectedThreadDetail ?? selectedThreadShell;
179 const text = draft.text.trim();183 const text = draft.text.trim();
180 const attachments = draft.attachments;184 const attachments = draft.attachments;
185 if (
186 composerAttachmentUploadBlockReason({
187 environmentId: selectedThreadShell.environmentId,
188 attachments,
189 connected: selectedEnvironmentRuntime?.connectionState === "connected",
190 serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null,
191 states: appAtomRegistry.get(composerAttachmentUploadsAtom),
192 }) !== null
193 )
194 return null;
181 if (text.length === 0 && attachments.length === 0) {195 if (text.length === 0 && attachments.length === 0) {
182 return null;196 return null;
183 }197 }
...@@ -298,7 +312,8 @@ export function useThreadComposerState() {...@@ -298,7 +312,8 @@ export function useThreadComposerState() {
298 );312 );
299 return messageId;313 return messageId;
300 }, [314 }, [
301 selectedEnvironmentRuntime?.serverConfig?.providers,315 selectedEnvironmentRuntime?.connectionState,
316 selectedEnvironmentRuntime?.serverConfig,
302 selectedThreadDetail,317 selectedThreadDetail,
303 selectedThreadShell,318 selectedThreadShell,
304 uploadThreadFeedback,319 uploadThreadFeedback,
apps/mobile/src/state/use-thread-outbox-drain.test.ts+67
...@@ -193,6 +193,7 @@ beforeEach(() => {...@@ -193,6 +193,7 @@ beforeEach(() => {
193afterEach(() => {193afterEach(() => {
194 appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {});194 appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {});
195 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {});195 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {});
196 appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} });
196 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});197 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});
197 harness.draftFile.setWriteError(null);198 harness.draftFile.setWriteError(null);
198 harness.removePersistedFile.mockClear();199 harness.removePersistedFile.mockClear();
...@@ -321,6 +322,72 @@ describe("thread outbox attachment preparation", () => {...@@ -321,6 +322,72 @@ describe("thread outbox attachment preparation", () => {
321});322});
322323
323describe("thread outbox drain delivery cleanup", () => {324describe("thread outbox drain delivery cleanup", () => {
325 it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => {
326 const message = queuedMessage({ messageId: "archive-write-failure", text: "Delivered" });
327 await harness.manager.enqueue(message);
328 await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId]));
329 harness.draftFile.setWriteError(new Error("Draft storage unavailable"));
330
331 await expect(
332 completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId)),
333 ).resolves.toBe("removed");
334 expect(remainingMessages()).toEqual([]);
335
336 harness.draftFile.setWriteError(null);
337 await composerDrafts.flushComposerDrafts();
338 appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} });
339 composerDrafts.resetComposerDraftsLoadState();
340 await composerDrafts.restoreCloudComposerDrafts("account-a");
341 expect(remainingMessages()).toEqual([]);
342 });
343
344 it.each([false, true])(
345 "does not restore a message delivered after the sign-out snapshot (outbox already cleared: %s)",
346 async (cleared) => {
347 const message = queuedMessage({
348 messageId: "delivered-during-sign-out",
349 text: "Already delivered",
350 });
351 await harness.manager.enqueue(message);
352 const deliveryRevision = harness.manager.revisionOf(message.messageId);
353 await composerDrafts.archiveCloudComposerDrafts(
354 "account-a",
355 new Set([message.environmentId]),
356 );
357 expect(
358 appAtomRegistry.get(composerDrafts.composerCloudDraftsAtom).signedOut["account-a"]
359 ?.queuedMessages,
360 ).toEqual([message]);
361
362 if (cleared) await harness.manager.clearEnvironment(message.environmentId);
363 await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe(
364 cleared ? "edited" : "removed",
365 );
366
367 // Restart before signing back in: the archived copy must be removed on disk too.
368 appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, {
369 accountId: null,
370 signedOut: {},
371 });
372 composerDrafts.resetComposerDraftsLoadState();
373 await composerDrafts.restoreCloudComposerDrafts("account-a");
374 expect(remainingMessages()).toEqual([]);
375 },
376 );
377
378 it("preserves an archived edit when an older payload finishes delivery", async () => {
379 const message = queuedMessage({ messageId: "edited-during-sign-out", text: "Original" });
380 await harness.manager.enqueue(message);
381 const deliveryRevision = harness.manager.revisionOf(message.messageId);
382 const edited = { ...message, text: "Keep this edit" };
383 await harness.manager.update(edited);
384 await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId]));
385 await harness.manager.clearEnvironment(message.environmentId);
386 await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited");
387 await composerDrafts.restoreCloudComposerDrafts("account-a");
388 expect(remainingMessages()).toEqual([edited]);
389 });
390
324 it("retries only cleanup after an acknowledged send removal fails", async () => {391 it("retries only cleanup after an acknowledged send removal fails", async () => {
325 const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" });392 const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" });
326 const acknowledged = new Set([message.messageId]);393 const acknowledged = new Set([message.messageId]);
apps/mobile/src/state/use-thread-outbox-drain.ts+37-16
...@@ -52,6 +52,7 @@ import {...@@ -52,6 +52,7 @@ import {
52 getComposerDraftSnapshot,52 getComposerDraftSnapshot,
53 mergeComposerDraftContent,53 mergeComposerDraftContent,
54 replaceComposerDraftAttachments,54 replaceComposerDraftAttachments,
55 removeDeliveredCloudQueuedMessage,
55 undoComposerDraftMerge,56 undoComposerDraftMerge,
56 updateComposerDraftSettings,57 updateComposerDraftSettings,
57 waitForComposerDraftsLoaded,58 waitForComposerDraftsLoaded,
...@@ -114,7 +115,10 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma...@@ -114,7 +115,10 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma
114 * `deliveryRevision` is the revision of the payload this attempt will send,115 * `deliveryRevision` is the revision of the payload this attempt will send,
115 * used for the delivery removal's compare-and-set.116 * used for the delivery removal's compare-and-set.
116 */117 */
117export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThreadMessage): Promise<118export async function prepareQueuedMessageAttachments(
119 queuedMessage: QueuedThreadMessage,
120 supportsImageUploads = false,
121): Promise<
118 | {122 | {
119 readonly status: "ready";123 readonly status: "ready";
120 readonly prepared: PreparedTurnAttachments;124 readonly prepared: PreparedTurnAttachments;
...@@ -135,6 +139,7 @@ export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThrea...@@ -135,6 +139,7 @@ export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThrea
135 const result = await prepareTurnAttachments({139 const result = await prepareTurnAttachments({
136 environmentId: queuedMessage.environmentId,140 environmentId: queuedMessage.environmentId,
137 attachments: queuedMessage.attachments,141 attachments: queuedMessage.attachments,
142 supportsImageUploads,
138 persistUploadedReferences: async (draftAttachments) => {143 persistUploadedReferences: async (draftAttachments) => {
139 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {144 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
140 return "abandon";145 return "abandon";
...@@ -179,13 +184,19 @@ export async function completeQueuedMessageDelivery(...@@ -179,13 +184,19 @@ export async function completeQueuedMessageDelivery(
179 queuedMessage: QueuedThreadMessage,184 queuedMessage: QueuedThreadMessage,
180 deliveryRevision: number,185 deliveryRevision: number,
181): Promise<"removed" | "edited" | "failed"> {186): 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 {187 try {
188 await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => {
189 console.warn("[thread-outbox] could not update sign-out snapshot after delivery", {
190 messageId: queuedMessage.messageId,
191 error,
192 });
193 });
194 // The editor may have taken the entry while startTurn was in flight; its
195 // unsaved edits have not bumped the revision yet, so the CAS alone would
196 // let removal win and the editor would lose them once it saves.
197 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
198 return "edited";
199 }
189 // Removal also releases the message's local attachment files.200 // Removal also releases the message's local attachment files.
190 const removed = await removeThreadOutboxMessage(201 const removed = await removeThreadOutboxMessage(
191 queuedMessage,202 queuedMessage,
...@@ -221,6 +232,12 @@ export async function removeAcknowledgedExistingThreadMessage(...@@ -221,6 +232,12 @@ export async function removeAcknowledgedExistingThreadMessage(
221 acknowledgedMessageIds: Set<MessageId>,232 acknowledgedMessageIds: Set<MessageId>,
222): Promise<boolean> {233): Promise<boolean> {
223 try {234 try {
235 await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => {
236 console.warn("[thread-outbox] could not update sign-out snapshot after delivery", {
237 messageId: queuedMessage.messageId,
238 error,
239 });
240 });
224 const removed = await removeThreadOutboxMessage(queuedMessage);241 const removed = await removeThreadOutboxMessage(queuedMessage);
225 if (removed) {242 if (removed) {
226 acknowledgedMessageIds.delete(queuedMessage.messageId);243 acknowledgedMessageIds.delete(queuedMessage.messageId);
...@@ -453,15 +470,10 @@ async function preserveUploadedAttachmentsForEditor(...@@ -453,15 +470,10 @@ async function preserveUploadedAttachmentsForEditor(
453 const draftKey = `pending-task:${originalMessage.messageId}`;470 const draftKey = `pending-task:${originalMessage.messageId}`;
454 const draft = getComposerDraftSnapshot(draftKey);471 const draft = getComposerDraftSnapshot(draftKey);
455 const uploadedById = new Map(472 const uploadedById = new Map(
456 uploadedMessage.attachments473 uploadedMessage.attachments.map((attachment) => [attachment.id, attachment] as const),
457 .filter((attachment) => attachment.type === "file")
458 .map((attachment) => [attachment.id, attachment] as const),
459 );474 );
460 let changed = false;475 let changed = false;
461 const nextAttachments = draft.attachments.map((attachment) => {476 const nextAttachments = draft.attachments.map((attachment) => {
462 if (attachment.type !== "file") {
463 return attachment;
464 }
465 const uploaded = uploadedById.get(attachment.id);477 const uploaded = uploadedById.get(attachment.id);
466 if (478 if (
467 !uploaded?.uploadedAttachmentId ||479 !uploaded?.uploadedAttachmentId ||
...@@ -672,7 +684,11 @@ export function useThreadOutboxDrain(): void {...@@ -672,7 +684,11 @@ export function useThreadOutboxDrain(): void {
672 let persistedMessage: QueuedThreadMessage;684 let persistedMessage: QueuedThreadMessage;
673 let deliveryRevision: number;685 let deliveryRevision: number;
674 try {686 try {
675 const preparedResult = await prepareQueuedMessageAttachments(queuedMessage);687 const preparedResult = await prepareQueuedMessageAttachments(
688 queuedMessage,
689 serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities
690 .attachmentUploads === true,
691 );
676 if (preparedResult.status === "abandoned") {692 if (preparedResult.status === "abandoned") {
677 return true;693 return true;
678 }694 }
...@@ -744,6 +760,7 @@ export function useThreadOutboxDrain(): void {...@@ -744,6 +760,7 @@ export function useThreadOutboxDrain(): void {
744 startTurn,760 startTurn,
745 updateThreadMetadata,761 updateThreadMetadata,
746 restoreQueuedMessage,762 restoreQueuedMessage,
763 serverConfigs,
747 ],764 ],
748 );765 );
749766
...@@ -761,7 +778,11 @@ export function useThreadOutboxDrain(): void {...@@ -761,7 +778,11 @@ export function useThreadOutboxDrain(): void {
761 let persistedMessage: QueuedThreadMessage;778 let persistedMessage: QueuedThreadMessage;
762 let deliveryRevision: number;779 let deliveryRevision: number;
763 try {780 try {
764 const preparedResult = await prepareQueuedMessageAttachments(queuedMessage);781 const preparedResult = await prepareQueuedMessageAttachments(
782 queuedMessage,
783 serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities
784 .attachmentUploads === true,
785 );
765 if (preparedResult.status === "abandoned") {786 if (preparedResult.status === "abandoned") {
766 return true;787 return true;
767 }788 }
...@@ -838,7 +859,7 @@ export function useThreadOutboxDrain(): void {...@@ -838,7 +859,7 @@ export function useThreadOutboxDrain(): void {
838 }859 }
839 return false;860 return false;
840 },861 },
841 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],862 [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn],
842 );863 );
843864
844 useEffect(() => {865 useEffect(() => {
docs/internals/connection-runtime.md+13
...@@ -138,6 +138,19 @@ connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on...@@ -138,6 +138,19 @@ connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on
138removal the registry clears its cache and calls the platform implementation, so138removal the registry clears its cache and calls the platform implementation, so
139web clears composer drafts and mobile clears drafts plus the thread outbox.139web clears composer drafts and mobile clears drafts plus the thread outbox.
140140
141Mobile cloud sign-out first saves relay drafts and queued messages in the local
142composer store under the owning account. These saved copies retain attachment
143files during cleanup and remain outside the active composer and upload queue.
144Signing back into that account restores them before relay credentials activate.
145Directly paired environments keep their drafts and outbox when cloud sign-out runs.
146
147Mobile composer attachments upload over HTTP while their environment is connected,
148with at most three concurrent transfers. Drafts retain local image data or an owned
149file URI alongside the pending upload ID. Sending verifies and reuses that ID, or
150uploads the local bytes again if it expired. Disconnecting cancels active transfers
151without discarding drafts; reconnecting resumes preparation. Older servers without
152attachment-upload support continue to receive inline images.
153
141## Source Boundaries154## Source Boundaries
142155
143Applications must import explicit package subpaths; the package intentionally156Applications must import explicit package subpaths; the package intentionally
docs/user/composer.md+7-4
...@@ -13,13 +13,16 @@ attach videos, text files, PDFs, ZIP archives, and other files. Each file can be...@@ -13,13 +13,16 @@ attach videos, text files, PDFs, ZIP archives, and other files. Each file can be
13by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files13by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files
14upload directly to the environment, where your agent can read, copy, or edit them by their file path.14upload directly to the environment, where your agent can read, copy, or edit them by their file path.
1515
16On web and desktop, attachments upload as soon as you add them. The send button becomes available16Attachments upload as soon as you add them while connected to a server that supports uploads.
17after every upload finishes. Failed uploads can be retried or removed. On mobile, tap **+** to open17The send button becomes available after every upload finishes. Failed uploads can be retried or
18removed. On mobile, tap **+** to open
18the photo library from either the compact or expanded composer. When the connected server supports19the photo library from either the compact or expanded composer. When the connected server supports
19file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**.20file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**.
20Videos use the server's file upload limit. You can also share photos, videos, and files into21Videos use the server's file upload limit. You can also share photos, videos, and files into
21T3 Code from other apps through the system share sheet. Mobile uploads happen when the message22T3 Code from other apps through the system share sheet. Mobile keeps a local copy of each draft
22sends, so queued messages keep their files until they deliver. Select a received file on mobile23attachment, so you can still preview it and queue messages while offline. Uploads resume when
24you reconnect. Drafts and queued messages survive app restarts; signing out of T3 Connect keeps
25them on your device until you sign back into the same account. Select a received file on mobile
23to preview it or open the system share options.26to preview it or open the system share options.
2427
25Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail28Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail