Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 135745b

Browse files
authored
fix(mobile): prepare cloud attachments before sending (port #3838) (#3866)
1 parent 0d1590a commit 135745b

5 files changed

Lines changed: 240 additions & 16 deletions

File tree

apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import {
2121
Stop,
2222
} from "phosphor-react-native";
2323
import { useFeatureFlag } from "posthog-react-native";
24-
import { useCallback, useEffect, useMemo, useState } from "react";
24+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2525
import {
2626
ActivityIndicator,
27+
Alert,
2728
Keyboard,
2829
Pressable,
2930
ScrollView,
@@ -37,7 +38,11 @@ import { useThemeColors } from "@/lib/theme";
3738
import type { MessagingMode } from "../stores/messagingModeStore";
3839
import { AgentConfigControls } from "./AgentConfigControls";
3940
import { AttachmentSheet } from "./attachments/AttachmentSheet";
40-
import { AttachmentsBar } from "./attachments/AttachmentsBar";
41+
import {
42+
type AttachmentStatus,
43+
AttachmentsBar,
44+
} from "./attachments/AttachmentsBar";
45+
import { attachmentPreparer } from "./attachments/buildCloudPrompt";
4146
import {
4247
captureFromCamera,
4348
pickDocument,
@@ -122,6 +127,9 @@ export function TaskChatComposer({
122127
const modelConfigOption = getModelConfigOption(configOptions);
123128
const [message, setMessage] = useState(() => initialMessage ?? "");
124129
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
130+
const [attachmentStatus, setAttachmentStatus] = useState<
131+
Record<string, AttachmentStatus>
132+
>({});
125133
const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false);
126134

127135
// Mirror composer state into refs so a failed send can read the current
@@ -132,6 +140,48 @@ export function TaskChatComposer({
132140
attachmentsRef.current = attachments;
133141
const submissionRef = useRef(0);
134142

143+
const clearStatus = useCallback((id: string) => {
144+
setAttachmentStatus((prev) => {
145+
if (!(id in prev)) return prev;
146+
const { [id]: _dropped, ...rest } = prev;
147+
return rest;
148+
});
149+
}, []);
150+
151+
// Encode eagerly so oversized/unsupported files fail at attach, not send.
152+
const beginPreparing = useCallback(
153+
(att: PendingAttachment) => {
154+
setAttachmentStatus((prev) => ({ ...prev, [att.id]: "preparing" }));
155+
attachmentPreparer.prepare(att).then(
156+
() => {
157+
if (attachmentsRef.current.some((a) => a.id === att.id)) {
158+
clearStatus(att.id);
159+
}
160+
},
161+
(error: unknown) => {
162+
if (!attachmentsRef.current.some((a) => a.id === att.id)) return;
163+
setAttachmentStatus((prev) => ({ ...prev, [att.id]: "error" }));
164+
Alert.alert(
165+
"Attachment can't be sent",
166+
error instanceof Error
167+
? error.message
168+
: "This file couldn't be prepared. Remove it and try another.",
169+
);
170+
},
171+
);
172+
},
173+
[clearStatus],
174+
);
175+
176+
const loadAttachments = useCallback(
177+
(next: PendingAttachment[]) => {
178+
setAttachments(next);
179+
setAttachmentStatus({});
180+
for (const att of next) beginPreparing(att);
181+
},
182+
[beginPreparing],
183+
);
184+
135185
useEffect(() => {
136186
if (!initialMessage) return;
137187
setMessage(initialMessage);
@@ -140,8 +190,17 @@ export function TaskChatComposer({
140190
useEffect(() => {
141191
if (!restoredDraft) return;
142192
setMessage(restoredDraft.text);
143-
setAttachments(restoredDraft.attachments);
144-
}, [restoredDraft]);
193+
loadAttachments(restoredDraft.attachments);
194+
}, [restoredDraft, loadAttachments]);
195+
196+
useEffect(
197+
() => () => {
198+
for (const att of attachmentsRef.current) {
199+
attachmentPreparer.forget(att.id);
200+
}
201+
},
202+
[],
203+
);
145204

146205
useEffect(() => {
147206
if (!hasLiveConfig) return;
@@ -174,9 +233,12 @@ export function TaskChatComposer({
174233
const isTranscribing = status === "transcribing";
175234

176235
const hasContent = !isComposerEmpty({ text: message, attachments });
236+
const statuses = Object.values(attachmentStatus);
237+
const attachmentsPreparing = statuses.includes("preparing");
238+
const sendBlocked = attachmentsPreparing || statuses.includes("error");
177239
const primaryAction = resolveComposerPrimaryAction({
178240
hasContent,
179-
disabled,
241+
disabled: disabled || sendBlocked,
180242
isRecording,
181243
isTranscribing,
182244
canStop: !isUserTurn && !!onStop,
@@ -187,7 +249,7 @@ export function TaskChatComposer({
187249

188250
const applyContent = (content: ComposerContent) => {
189251
setMessage(content.text);
190-
setAttachments(content.attachments);
252+
loadAttachments(content.attachments);
191253
};
192254

193255
const handleSend = () => {
@@ -214,14 +276,19 @@ export function TaskChatComposer({
214276
) => {
215277
try {
216278
const att = await picker();
217-
if (att) setAttachments((prev) => [...prev, att]);
279+
if (att) {
280+
setAttachments((prev) => [...prev, att]);
281+
beginPreparing(att);
282+
}
218283
} catch (err) {
219284
log.error("Failed to pick attachment", err);
220285
}
221286
};
222287

223288
const removeAttachment = (id: string) => {
224289
setAttachments((prev) => prev.filter((a) => a.id !== id));
290+
attachmentPreparer.forget(id);
291+
clearStatus(id);
225292
};
226293

227294
const handleMicPress = async () => {
@@ -282,6 +349,7 @@ export function TaskChatComposer({
282349
<AttachmentsBar
283350
attachments={attachments}
284351
onRemove={removeAttachment}
352+
statuses={attachmentStatus}
285353
/>
286354
<TextInput
287355
className="px-4 pt-3.5 pb-3 text-[15px] text-gray-12"
@@ -368,20 +436,22 @@ export function TaskChatComposer({
368436
canSend ? handleSend : showStop ? handleStop : handleMicPress
369437
}
370438
onLongPress={handleMicLongPress}
371-
disabled={isTranscribing || disabled}
439+
disabled={isTranscribing || disabled || sendBlocked}
372440
className={`h-9 w-9 items-center justify-center rounded-lg ${
373441
canSend ? "bg-gray-12" : "bg-gray-3"
374442
}`}
375443
>
376-
{isTranscribing ? (
444+
{isTranscribing || attachmentsPreparing ? (
377445
<ActivityIndicator
378446
size="small"
379447
color={themeColors.gray[12]}
380448
/>
381-
) : canSend ? (
449+
) : canSend || sendBlocked ? (
382450
<ArrowUp
383451
size={18}
384-
color={themeColors.background}
452+
color={
453+
canSend ? themeColors.background : themeColors.gray[9]
454+
}
385455
weight="bold"
386456
/>
387457
) : isRecording || showStop ? (

apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import { Text } from "@components/text";
2-
import { FileText, X } from "phosphor-react-native";
3-
import { Image, Pressable, ScrollView, View } from "react-native";
2+
import { FileText, WarningCircle, X } from "phosphor-react-native";
3+
import {
4+
ActivityIndicator,
5+
Image,
6+
Pressable,
7+
ScrollView,
8+
View,
9+
} from "react-native";
410
import { useThemeColors } from "@/lib/theme";
511
import type { PendingAttachment } from "./types";
612

13+
export type AttachmentStatus = "preparing" | "error";
14+
715
interface AttachmentsBarProps {
816
attachments: PendingAttachment[];
917
onRemove: (id: string) => void;
18+
statuses?: Record<string, AttachmentStatus>;
1019
}
1120

1221
function truncate(name: string, max = 18): string {
@@ -18,7 +27,36 @@ function truncate(name: string, max = 18): string {
1827
return `${name.slice(0, max - 1)}…`;
1928
}
2029

21-
export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) {
30+
function StatusOverlay({ status }: { status?: AttachmentStatus }) {
31+
const themeColors = useThemeColors();
32+
if (!status) return null;
33+
return (
34+
<View
35+
className="absolute inset-0 items-center justify-center rounded-lg bg-gray-1/70"
36+
accessibilityLabel={
37+
status === "preparing"
38+
? "Preparing attachment"
39+
: "Attachment failed to prepare"
40+
}
41+
>
42+
{status === "preparing" ? (
43+
<ActivityIndicator size="small" color={themeColors.gray[12]} />
44+
) : (
45+
<WarningCircle
46+
size={20}
47+
color={themeColors.status.error}
48+
weight="fill"
49+
/>
50+
)}
51+
</View>
52+
);
53+
}
54+
55+
export function AttachmentsBar({
56+
attachments,
57+
onRemove,
58+
statuses,
59+
}: AttachmentsBarProps) {
2260
const themeColors = useThemeColors();
2361
if (attachments.length === 0) return null;
2462

@@ -60,6 +98,7 @@ export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) {
6098
</Text>
6199
</View>
62100
)}
101+
<StatusOverlay status={statuses?.[att.id]} />
63102
<Pressable
64103
onPress={() => onRemove(att.id)}
65104
hitSlop={8}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createAttachmentPreparer } from "./attachmentPreparer";
3+
import type { CloudPromptBlock, PendingAttachment } from "./types";
4+
5+
function attachment(id: string): PendingAttachment {
6+
return {
7+
kind: "document",
8+
id,
9+
uri: `file://${id}.txt`,
10+
fileName: `${id}.txt`,
11+
mimeType: "text/plain",
12+
};
13+
}
14+
15+
function block(id: string): CloudPromptBlock {
16+
return { type: "text", text: id };
17+
}
18+
19+
describe("createAttachmentPreparer", () => {
20+
it("caches a resolved block and reuses it across prepares", async () => {
21+
const build = vi.fn(async (att: PendingAttachment) => block(att.id));
22+
const preparer = createAttachmentPreparer(build);
23+
24+
const first = await preparer.prepare(attachment("a"));
25+
const second = await preparer.prepare(attachment("a"));
26+
27+
expect(first).toEqual(block("a"));
28+
expect(second).toBe(first);
29+
expect(build).toHaveBeenCalledTimes(1);
30+
});
31+
32+
it("dedupes concurrent preparation of the same attachment", async () => {
33+
const build = vi.fn(async (att: PendingAttachment) => block(att.id));
34+
const preparer = createAttachmentPreparer(build);
35+
36+
const [first, second] = await Promise.all([
37+
preparer.prepare(attachment("a")),
38+
preparer.prepare(attachment("a")),
39+
]);
40+
41+
expect(first).toBe(second);
42+
expect(build).toHaveBeenCalledTimes(1);
43+
});
44+
45+
it("prepares distinct attachments independently", async () => {
46+
const build = vi.fn(async (att: PendingAttachment) => block(att.id));
47+
const preparer = createAttachmentPreparer(build);
48+
49+
expect(await preparer.prepare(attachment("a"))).toEqual(block("a"));
50+
expect(await preparer.prepare(attachment("b"))).toEqual(block("b"));
51+
expect(build).toHaveBeenCalledTimes(2);
52+
});
53+
54+
it("evicts a failed preparation so it can be retried", async () => {
55+
const build = vi
56+
.fn<(att: PendingAttachment) => Promise<CloudPromptBlock>>()
57+
.mockRejectedValueOnce(new Error("too large"))
58+
.mockImplementation(async (att) => block(att.id));
59+
const preparer = createAttachmentPreparer(build);
60+
61+
await expect(preparer.prepare(attachment("a"))).rejects.toThrow(
62+
"too large",
63+
);
64+
expect(await preparer.prepare(attachment("a"))).toEqual(block("a"));
65+
expect(build).toHaveBeenCalledTimes(2);
66+
});
67+
68+
it("re-reads after forget", async () => {
69+
const build = vi.fn(async (att: PendingAttachment) => block(att.id));
70+
const preparer = createAttachmentPreparer(build);
71+
72+
await preparer.prepare(attachment("a"));
73+
preparer.forget("a");
74+
await preparer.prepare(attachment("a"));
75+
76+
expect(build).toHaveBeenCalledTimes(2);
77+
});
78+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { CloudPromptBlock, PendingAttachment } from "./types";
2+
3+
export interface AttachmentPreparer {
4+
prepare(attachment: PendingAttachment): Promise<CloudPromptBlock>;
5+
forget(id: string): void;
6+
}
7+
8+
export function createAttachmentPreparer(
9+
build: (attachment: PendingAttachment) => Promise<CloudPromptBlock>,
10+
): AttachmentPreparer {
11+
const cache = new Map<string, Promise<CloudPromptBlock>>();
12+
13+
return {
14+
prepare(attachment) {
15+
const existing = cache.get(attachment.id);
16+
if (existing) return existing;
17+
18+
const pending = build(attachment).catch((error) => {
19+
cache.delete(attachment.id);
20+
throw error;
21+
});
22+
cache.set(attachment.id, pending);
23+
return pending;
24+
},
25+
forget(id) {
26+
cache.delete(id);
27+
},
28+
};
29+
}

apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as FileSystem from "expo-file-system/legacy";
2+
import { createAttachmentPreparer } from "./attachmentPreparer";
23
import type { CloudPromptBlock, PendingAttachment } from "./types";
34

45
const MAX_EMBEDDED_TEXT_CHARS = 100_000;
@@ -92,7 +93,9 @@ function estimateBase64Bytes(base64: string): number {
9293
return Math.floor((base64.length * 3) / 4) - padding;
9394
}
9495

95-
async function buildBlock(att: PendingAttachment): Promise<CloudPromptBlock> {
96+
export async function buildAttachmentBlock(
97+
att: PendingAttachment,
98+
): Promise<CloudPromptBlock> {
9699
if (att.kind === "image") {
97100
const base64 = await FileSystem.readAsStringAsync(att.uri, {
98101
encoding: FileSystem.EncodingType.Base64,
@@ -129,6 +132,9 @@ async function buildBlock(att: PendingAttachment): Promise<CloudPromptBlock> {
129132
};
130133
}
131134

135+
export const attachmentPreparer =
136+
createAttachmentPreparer(buildAttachmentBlock);
137+
132138
/**
133139
* Reads each attachment from disk and assembles the cloud-prompt block array
134140
* the agent server expects. Throws if any individual attachment fails so the
@@ -142,7 +148,9 @@ export async function buildCloudPromptBlocks(
142148
const trimmed = text.trim();
143149
if (trimmed) blocks.push({ type: "text", text: trimmed });
144150
for (const attachment of attachments) {
145-
blocks.push(await buildBlock(attachment));
151+
blocks.push(await attachmentPreparer.prepare(attachment));
152+
// Base64/text payloads are large; release once folded into the prompt.
153+
attachmentPreparer.forget(attachment.id);
146154
}
147155
if (blocks.length === 0) {
148156
throw new Error("Cloud prompt cannot be empty");

0 commit comments

Comments
 (0)