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

Commit f632bfb

Browse files
authored
fix(mobile): restore the composer message when a send fails (port #3785) (#3787)
1 parent dc502fc commit f632bfb

4 files changed

Lines changed: 243 additions & 31 deletions

File tree

apps/mobile/src/app/task/[id].tsx

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,11 @@ export default function TaskDetailScreen() {
285285
// creates a fresh run that resumes from the previous one and queues the
286286
// message as pending_user_message.
287287
const handleSendAfterTerminal = useCallback(
288-
async (text: string, attachments: PendingAttachment[]) => {
289-
if (!taskId || !task) return;
288+
async (
289+
text: string,
290+
attachments: PendingAttachment[],
291+
): Promise<boolean> => {
292+
if (!taskId || !task) return false;
290293
// Optimistically echo into the chat before tearing down the old session
291294
// and waiting for the resume run's SSE stream to come up.
292295
const echoAttachments = attachments.map((a) => ({
@@ -324,6 +327,7 @@ export default function TaskDetailScreen() {
324327
setTask(updatedTask);
325328
await connectToTask(updatedTask);
326329
updateTaskInCache(updatedTask);
330+
return true;
327331
} catch (err) {
328332
log.error("Failed to send after terminal", err);
329333
pendingTaskPromptStoreApi.clear(taskId);
@@ -332,6 +336,7 @@ export default function TaskDetailScreen() {
332336
"Failed to send",
333337
"Could not continue this task. Please try again.",
334338
);
339+
return false;
335340
}
336341
},
337342
[
@@ -361,8 +366,11 @@ export default function TaskDetailScreen() {
361366
);
362367

363368
const handleSendPrompt = useCallback(
364-
(text: string, attachments: PendingAttachment[]) => {
365-
if (!taskId) return;
369+
async (
370+
text: string,
371+
attachments: PendingAttachment[],
372+
): Promise<boolean> => {
373+
if (!taskId) return false;
366374
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
367375

368376
// Saving an in-place edit: overwrite the queued message and release the
@@ -374,38 +382,37 @@ export default function TaskDetailScreen() {
374382
queue.update(taskId, editingId, { content: text, attachments });
375383
queue.clearEditing(taskId);
376384
flushQueuedMessagesIfIdle(taskId);
377-
return;
385+
return true;
378386
}
379387

380388
if (session?.terminalStatus) {
381-
handleSendAfterTerminal(text, attachments);
382-
return;
389+
return handleSendAfterTerminal(text, attachments);
383390
}
384391

385-
const onSendFailed = (err: unknown) => {
392+
// A turn is running. Queue holds the message locally until it ends;
393+
// Steer interrupts the turn and resends right away.
394+
const isSteer = !!session?.isPromptPending;
395+
if (isSteer && messagingMode === "queue") {
396+
useMessageQueueStore.getState().enqueue(taskId, text, attachments);
397+
return true;
398+
}
399+
400+
try {
401+
if (isSteer) {
402+
await sendInterrupting(taskId, text, attachments);
403+
} else {
404+
await sendPrompt(taskId, text, attachments);
405+
}
406+
trackPromptSent(text, isSteer);
407+
return true;
408+
} catch (err) {
386409
log.error("Failed to send prompt", err);
387410
Alert.alert(
388411
"Failed to send",
389412
"Your message could not be delivered. Please try again.",
390413
);
391-
};
392-
393-
// A turn is running. Queue holds the message locally until it ends;
394-
// Steer interrupts the turn and resends right away.
395-
if (session?.isPromptPending) {
396-
if (messagingMode === "queue") {
397-
useMessageQueueStore.getState().enqueue(taskId, text, attachments);
398-
return;
399-
}
400-
sendInterrupting(taskId, text, attachments)
401-
.then(() => trackPromptSent(text, true))
402-
.catch(onSendFailed);
403-
return;
414+
return false;
404415
}
405-
406-
sendPrompt(taskId, text, attachments)
407-
.then(() => trackPromptSent(text, false))
408-
.catch(onSendFailed);
409416
},
410417
[
411418
taskId,

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

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,19 @@ import {
5959
} from "./options";
6060
import { Pill } from "./Pill";
6161
import { SelectSheet } from "./SelectSheet";
62+
import {
63+
type ComposerContent,
64+
isComposerEmpty,
65+
submitComposerMessage,
66+
} from "./submitComposerMessage";
6267

6368
const log = logger.scope("task-chat-composer");
6469

6570
interface TaskChatComposerProps {
66-
onSend: (message: string, attachments: PendingAttachment[]) => void;
71+
onSend: (
72+
message: string,
73+
attachments: PendingAttachment[],
74+
) => Promise<boolean>;
6775
onStop?: () => void;
6876
disabled?: boolean;
6977
placeholder?: string;
@@ -179,6 +187,14 @@ export function TaskChatComposer({
179187
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
180188
const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false);
181189

190+
// Mirror composer state into refs so a failed send can read the current
191+
// value after awaiting, rather than the value captured when it was sent.
192+
const messageRef = useRef(message);
193+
messageRef.current = message;
194+
const attachmentsRef = useRef(attachments);
195+
attachmentsRef.current = attachments;
196+
const submissionRef = useRef(0);
197+
182198
useEffect(() => {
183199
if (!initialMessage) return;
184200
setMessage(initialMessage);
@@ -206,18 +222,33 @@ export function TaskChatComposer({
206222

207223
const showReasoningPill = modelSupportsReasoning(model);
208224

209-
const hasContent = message.trim().length > 0 || attachments.length > 0;
225+
const hasContent = !isComposerEmpty({ text: message, attachments });
210226
const canSend = hasContent && !disabled && !isRecording;
211227
const showStop =
212228
!isUserTurn && !canSend && !isRecording && !isTranscribing && !!onStop;
213229

230+
const applyContent = (content: ComposerContent) => {
231+
setMessage(content.text);
232+
setAttachments(content.attachments);
233+
};
234+
214235
const handleSend = () => {
215-
const trimmed = message.trim();
216236
if (!hasContent || disabled) return;
217-
setMessage("");
218-
setAttachments([]);
237+
const submitted: ComposerContent = { text: message.trim(), attachments };
238+
const submissionId = ++submissionRef.current;
219239
Keyboard.dismiss();
220-
onSend(trimmed, attachments);
240+
void submitComposerMessage({
241+
submitted,
242+
clear: () => applyContent({ text: "", attachments: [] }),
243+
send: () => onSend(submitted.text, submitted.attachments),
244+
isLatestSubmission: () => submissionId === submissionRef.current,
245+
isEmpty: () =>
246+
isComposerEmpty({
247+
text: messageRef.current,
248+
attachments: attachmentsRef.current,
249+
}),
250+
restore: applyContent,
251+
});
221252
};
222253

223254
const addAttachment = async (
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { PendingAttachment } from "./attachments/types";
3+
import {
4+
type ComposerContent,
5+
isComposerEmpty,
6+
submitComposerMessage,
7+
} from "./submitComposerMessage";
8+
9+
const attachment: PendingAttachment = {
10+
kind: "image",
11+
id: "a1",
12+
uri: "file://x.png",
13+
fileName: "x.png",
14+
mimeType: "image/png",
15+
};
16+
17+
const submitted: ComposerContent = {
18+
text: "hello there",
19+
attachments: [attachment],
20+
};
21+
22+
function createComposer(
23+
initial: ComposerContent = { text: "", attachments: [] },
24+
) {
25+
let content = initial;
26+
return {
27+
get content() {
28+
return content;
29+
},
30+
clear: vi.fn(() => {
31+
content = { text: "", attachments: [] };
32+
}),
33+
restore: vi.fn((next: ComposerContent) => {
34+
content = next;
35+
}),
36+
isEmpty: () => isComposerEmpty(content),
37+
};
38+
}
39+
40+
describe("isComposerEmpty", () => {
41+
const cases: Array<[ComposerContent, boolean]> = [
42+
[{ text: "", attachments: [] }, true],
43+
[{ text: " ", attachments: [] }, true],
44+
[{ text: "hi", attachments: [] }, false],
45+
[{ text: "", attachments: [attachment] }, false],
46+
];
47+
it.each(cases)("%o -> %s", (content, expected) => {
48+
expect(isComposerEmpty(content)).toBe(expected);
49+
});
50+
});
51+
52+
describe("submitComposerMessage", () => {
53+
it("clears and stays cleared on a successful send", async () => {
54+
const composer = createComposer();
55+
56+
await submitComposerMessage({
57+
submitted,
58+
clear: composer.clear,
59+
send: async () => true,
60+
isLatestSubmission: () => true,
61+
isEmpty: composer.isEmpty,
62+
restore: composer.restore,
63+
});
64+
65+
expect(composer.clear).toHaveBeenCalledOnce();
66+
expect(composer.restore).not.toHaveBeenCalled();
67+
expect(composer.content).toEqual({ text: "", attachments: [] });
68+
});
69+
70+
it("restores text and attachments when a send fails", async () => {
71+
const composer = createComposer();
72+
73+
await submitComposerMessage({
74+
submitted,
75+
clear: composer.clear,
76+
send: async () => false,
77+
isLatestSubmission: () => true,
78+
isEmpty: composer.isEmpty,
79+
restore: composer.restore,
80+
});
81+
82+
expect(composer.restore).toHaveBeenCalledWith(submitted);
83+
expect(composer.content).toEqual(submitted);
84+
});
85+
86+
it("treats a thrown send as a failure and restores", async () => {
87+
const composer = createComposer();
88+
89+
await submitComposerMessage({
90+
submitted,
91+
clear: composer.clear,
92+
send: async () => {
93+
throw new Error("network");
94+
},
95+
isLatestSubmission: () => true,
96+
isEmpty: composer.isEmpty,
97+
restore: composer.restore,
98+
});
99+
100+
expect(composer.content).toEqual(submitted);
101+
});
102+
103+
it("does not restore when the user has typed a new draft", async () => {
104+
const composer = createComposer();
105+
composer.clear.mockImplementation(() => {});
106+
107+
await submitComposerMessage({
108+
submitted,
109+
clear: composer.clear,
110+
send: async () => false,
111+
isLatestSubmission: () => true,
112+
isEmpty: () => false,
113+
restore: composer.restore,
114+
});
115+
116+
expect(composer.restore).not.toHaveBeenCalled();
117+
});
118+
119+
it("does not restore a stale failure over a newer submission", async () => {
120+
const composer = createComposer();
121+
122+
await submitComposerMessage({
123+
submitted,
124+
clear: composer.clear,
125+
send: async () => false,
126+
isLatestSubmission: () => false,
127+
isEmpty: composer.isEmpty,
128+
restore: composer.restore,
129+
});
130+
131+
expect(composer.restore).not.toHaveBeenCalled();
132+
});
133+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { PendingAttachment } from "./attachments/types";
2+
3+
export interface ComposerContent {
4+
text: string;
5+
attachments: PendingAttachment[];
6+
}
7+
8+
export function isComposerEmpty(content: ComposerContent): boolean {
9+
return content.text.trim().length === 0 && content.attachments.length === 0;
10+
}
11+
12+
interface SubmitComposerMessageOptions {
13+
submitted: ComposerContent;
14+
clear: () => void;
15+
send: () => Promise<boolean>;
16+
isLatestSubmission: () => boolean;
17+
isEmpty: () => boolean;
18+
restore: (content: ComposerContent) => void;
19+
}
20+
21+
export async function submitComposerMessage({
22+
submitted,
23+
clear,
24+
send,
25+
isLatestSubmission,
26+
isEmpty,
27+
restore,
28+
}: SubmitComposerMessageOptions): Promise<void> {
29+
clear();
30+
31+
let sent = false;
32+
try {
33+
sent = await send();
34+
} catch {
35+
sent = false;
36+
}
37+
38+
if (!sent && isLatestSubmission() && isEmpty()) {
39+
restore(submitted);
40+
}
41+
}

0 commit comments

Comments
 (0)