authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-05 09:03:40+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 09:03:40+05:30
loga1d9d72aefc2a388ea3915956e9d84d279c87da7
treea463619c83247d2a43ec49815a99216ae35605d8
parentde09c7566c71a9c01a554b2f163fa50e3e15bfe5
signature Signed by PGP key B5690EEEBB952194

fix(mobile): a message that fails to send now says why in the thread (#15807)

Co-authored-by: T3 Code Test <t3code-test@example.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

8 files changed, 306 insertions(+), 15 deletions(-)

apps/mobile/src/connection/platform.ts+2
......@@ -27,6 +27,7 @@ import * as MobileStorage from "../persistence/mobile-storage";
2727import { appAtomRegistry } from "../state/atom-registry";
2828import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal";
2929import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
30import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error";
3031import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
3132import { connectionStorageLayer } from "./storage";
3233
......@@ -264,6 +265,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed(
264265 [
265266 Effect.promise(() => clearThreadOutboxEnvironment(environmentId)),
266267 Effect.promise(() => clearComposerDraftsEnvironment(environmentId)),
268 Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)),
267269 ],
268270 { concurrency: "unbounded", discard: true },
269271 ).pipe(
apps/mobile/src/features/threads/ComposerErrorNotice.tsx created+55
......@@ -0,0 +1,55 @@
1import { useEffect } from "react";
2import { AccessibilityInfo, Platform, Pressable, View } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { SymbolView } from "../../components/AppSymbol";
6
7/** Why the thread's last message did not send, above the composer until dismissed. */
8export function ComposerErrorNotice({
9 message,
10 onDismiss,
11}: {
12 readonly message: string;
13 readonly onDismiss: () => void;
14}) {
15 // accessibilityLiveRegion below only reaches TalkBack; VoiceOver needs an
16 // explicit announcement.
17 useEffect(() => {
18 if (Platform.OS === "ios") {
19 AccessibilityInfo.announceForAccessibility(message);
20 }
21 }, [message]);
22 return (
23 <View className="px-4 pb-3">
24 <View className="flex-row items-start gap-3 rounded-[20px] border-continuous bg-card p-4">
25 <SymbolView
26 name="exclamationmark.circle"
27 size={16}
28 tintColorClassName="accent-danger-foreground"
29 type="monochrome"
30 />
31 <Text
32 selectable
33 accessibilityLiveRegion="polite"
34 className="min-w-0 flex-1 text-sm text-foreground"
35 >
36 {message}
37 </Text>
38 <Pressable
39 accessibilityLabel="Dismiss error"
40 accessibilityRole="button"
41 hitSlop={12}
42 onPress={onDismiss}
43 className="p-1 active:opacity-60"
44 >
45 <SymbolView
46 name="xmark"
47 size={14}
48 tintColorClassName="accent-icon-muted"
49 type="monochrome"
50 />
51 </Pressable>
52 </View>
53 </View>
54 );
55}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+19
......@@ -1,3 +1,4 @@
1import { useAtomValue } from "@effect/atom-react";
12import { useThreadReportedModelSelection } from "../../state/entities";
23import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
34import { useNavigation } from "@react-navigation/native";
......@@ -93,6 +94,10 @@ import { useEnvironmentQuery } from "../../state/query";
9394import { threadDevicePreviews } from "../devices/threadDevicePreviews";
9495import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
9596import { scopedThreadKey } from "../../lib/scopedEntities";
97import {
98 clearThreadComposerError,
99 threadComposerErrorsAtom,
100} from "../../state/thread-composer-error";
96101import { threadEnvironment } from "../../state/threads";
97102import { useAtomCommand } from "../../state/use-atom-command";
98103import { useDelayedStatus } from "../../lib/useDelayedStatus";
......@@ -104,6 +109,7 @@ import type {
104109 ThreadFeedLatestRun,
105110} from "../../lib/threadActivity";
106111import { PendingApprovalCard } from "./PendingApprovalCard";
112import { ComposerErrorNotice } from "./ComposerErrorNotice";
107113import { ComposerFeedback } from "./ComposerFeedback";
108114import { ComposerUsageLimits } from "./ComposerUsageLimits";
109115import { PendingUserInputCard } from "./PendingUserInputCard";
......@@ -362,6 +368,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
362368 const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44;
363369 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
364370 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
371 const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;
365372 const queuedCount = useThreadQueuedCount({
366373 environmentId: props.environmentId,
367374 threadId: props.selectedThread.id,
......@@ -1190,6 +1197,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
11901197 onDismiss={() => props.onDismissFeedback(submission.id)}
11911198 />
11921199 ))}
1200 {composerError !== null ? (
1201 <Animated.View
1202 className="shrink-0"
1203 entering={FadeInDown.duration(180)}
1204 exiting={FadeOut.duration(120)}
1205 >
1206 <ComposerErrorNotice
1207 message={composerError}
1208 onDismiss={() => clearThreadComposerError(selectedThreadKey)}
1209 />
1210 </Animated.View>
1211 ) : null}
11931212 {usageLimitsReport && activeUserInputRequestId === null ? (
11941213 <Animated.View
11951214 className="shrink-0 px-4 pb-3"
apps/mobile/src/state/thread-composer-error.test.ts created+39
......@@ -0,0 +1,39 @@
1import { afterEach, describe, expect, it } from "vite-plus/test";
2
3import { appAtomRegistry } from "./atom-registry";
4import {
5 clearThreadComposerError,
6 clearThreadComposerErrorsForEnvironment,
7 setThreadComposerError,
8 threadComposerErrorsAtom,
9} from "./thread-composer-error";
10
11afterEach(() => {
12 appAtomRegistry.set(threadComposerErrorsAtom, {});
13});
14
15describe("thread composer errors", () => {
16 it("clears a message-scoped error only for that message", () => {
17 setThreadComposerError("environment-1:thread-1", "rejected", "message-1");
18
19 clearThreadComposerError("environment-1:thread-1", "message-2");
20 expect(appAtomRegistry.get(threadComposerErrorsAtom)["environment-1:thread-1"]?.message).toBe(
21 "rejected",
22 );
23
24 clearThreadComposerError("environment-1:thread-1");
25 expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({});
26 });
27
28 it("clears every thread's error for a removed environment and no others", () => {
29 setThreadComposerError("environment-1:thread-1", "one");
30 setThreadComposerError("environment-1:thread-2", "two");
31 setThreadComposerError("environment-10:thread-1", "other environment");
32
33 clearThreadComposerErrorsForEnvironment("environment-1");
34
35 expect(Object.keys(appAtomRegistry.get(threadComposerErrorsAtom))).toEqual([
36 "environment-10:thread-1",
37 ]);
38 });
39});
apps/mobile/src/state/thread-composer-error.ts created+56
......@@ -0,0 +1,56 @@
1import { Atom } from "effect/unstable/reactivity";
2
3import { appAtomRegistry } from "./atom-registry";
4
5interface ThreadComposerError {
6 readonly message: string;
7 /** The queued message this error is about, when it is about one. */
8 readonly messageId: string | null;
9}
10
11/**
12 * Why a thread's last message did not go out, shown above that thread's
13 * composer. The outbox drain can reject a message after the user has left the
14 * thread, so the reason is kept per thread until they dismiss it, send again,
15 * or the message it describes is delivered after all. Keyed by `scopedThreadKey`.
16 */
17export const threadComposerErrorsAtom = Atom.make<Readonly<Record<string, ThreadComposerError>>>(
18 {},
19).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer-errors"));
20
21export function setThreadComposerError(
22 threadKey: string,
23 message: string,
24 messageId: string | null = null,
25): void {
26 appAtomRegistry.set(threadComposerErrorsAtom, {
27 ...appAtomRegistry.get(threadComposerErrorsAtom),
28 [threadKey]: { message, messageId },
29 });
30}
31
32/** With `messageId`, clears only an error that describes that message. */
33export function clearThreadComposerError(threadKey: string, messageId?: string): void {
34 const current = appAtomRegistry.get(threadComposerErrorsAtom);
35 const error = current[threadKey];
36 if (!error || (messageId !== undefined && error.messageId !== messageId)) {
37 return;
38 }
39 const next = { ...current };
40 delete next[threadKey];
41 appAtomRegistry.set(threadComposerErrorsAtom, next);
42}
43
44export function clearThreadComposerErrorsForEnvironment(environmentId: string): void {
45 const current = appAtomRegistry.get(threadComposerErrorsAtom);
46 const prefix = `${environmentId}:`;
47 const keys = Object.keys(current).filter((key) => key.startsWith(prefix));
48 if (keys.length === 0) {
49 return;
50 }
51 const next = { ...current };
52 for (const key of keys) {
53 delete next[key];
54 }
55 appAtomRegistry.set(threadComposerErrorsAtom, next);
56}
apps/mobile/src/state/use-thread-composer-state.ts+7-2
......@@ -96,6 +96,7 @@ import {
9696 useQueuedRunEdit,
9797} from "./queued-run-edit";
9898import { setPendingConnectionError } from "../state/use-remote-environment-registry";
99import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error";
99100import {
100101 useSelectedThreadProjection,
101102 useSelectedThreadVisibleTurnItems,
......@@ -452,7 +453,8 @@ export function useThreadComposerState() {
452453 });
453454 }
454455 endQueuedRunEdit(selectedThreadKey, { deferAttachmentCleanup: keepable });
455 setPendingConnectionError(
456 setThreadComposerError(
457 selectedThreadKey,
456458 keepable
457459 ? "That message already started. Your edit is back in the composer."
458460 : "That message already started, so the edit was discarded.",
......@@ -670,6 +672,8 @@ export function useThreadComposerState() {
670672
671673 const metadata = makeQueuedMessageMetadata();
672674 const messageId = MessageId.make(metadata.messageId);
675 // A new send supersedes the reason the previous one bounced back.
676 clearThreadComposerError(threadKey);
673677 // Enqueue publishes the queued atom synchronously (the durable write
674678 // happens behind it), so clearing the draft here gives send feedback on
675679 // the tap frame instead of after file I/O. If the write fails the message
......@@ -706,7 +710,8 @@ export function useThreadComposerState() {
706710 attachments: [],
707711 });
708712 appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true });
709 setPendingConnectionError(
713 setThreadComposerError(
714 threadKey,
710715 error instanceof Error ? error.message : "Failed to save the queued message.",
711716 );
712717 },
apps/mobile/src/state/use-thread-outbox-drain.test.ts+99-5
......@@ -19,7 +19,6 @@ const harness = vi.hoisted(() => ({
1919 removePersistedFile: vi.fn(async () => undefined),
2020 removeOutboxMessage: vi.fn(async (_message: QueuedThreadMessage) => undefined),
2121 prepareTurnAttachments: vi.fn<typeof import("../lib/attachmentUpload").prepareTurnAttachments>(),
22 setPendingConnectionError: vi.fn(),
2322 draftFile: (() => {
2423 let document = "";
2524 let writeError: Error | null = null;
......@@ -106,7 +105,6 @@ vi.mock("./use-thread-outbox", async () => {
106105});
107106
108107vi.mock("./use-remote-environment-registry", () => ({
109 setPendingConnectionError: harness.setPendingConnectionError,
110108 useRemoteConnectionStatus: () => ({ connectedEnvironments: [] }),
111109}));
112110
......@@ -137,6 +135,11 @@ import {
137135 clearPendingThreadCreationOutcome,
138136 pendingThreadCreationOutcomesAtom,
139137} from "./pending-thread-creation";
138import {
139 clearThreadComposerError,
140 setThreadComposerError,
141 threadComposerErrorsAtom,
142} from "./thread-composer-error";
140143import type { QueuedThreadMessage } from "./thread-outbox-model";
141144import * as composerDrafts from "./use-composer-drafts";
142145import { recoverFailedThreadDraft } from "./recover-failed-thread-draft";
......@@ -209,11 +212,11 @@ afterEach(() => {
209212 appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} });
210213 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});
211214 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {});
215 appAtomRegistry.set(threadComposerErrorsAtom, {});
212216 harness.draftFile.setWriteError(null);
213217 harness.removePersistedFile.mockClear();
214218 harness.removeOutboxMessage.mockClear();
215219 harness.prepareTurnAttachments.mockReset();
216 harness.setPendingConnectionError.mockClear();
217220});
218221
219222describe("thread outbox attachment preparation", () => {
......@@ -442,6 +445,25 @@ describe("thread outbox drain delivery cleanup", () => {
442445 expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]);
443446 });
444447
448 it("clears an error about the delivered message but keeps one about another message", async () => {
449 const threadKey = "environment-1:thread-1";
450 const retried = queuedMessage({ messageId: "message-retried", text: "retried" });
451 await harness.manager.enqueue(retried);
452 // A failed recovery left this message queued with an error about it.
453 setThreadComposerError(threadKey, "could not be restored", retried.messageId);
454
455 await completeQueuedMessageDelivery(retried, harness.manager.revisionOf(retried.messageId));
456 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]).toBeUndefined();
457
458 const other = queuedMessage({ messageId: "message-other", text: "other" });
459 await harness.manager.enqueue(other);
460 // The thread's error explains a different, rejected message still in the draft.
461 setThreadComposerError(threadKey, "rejected", "message-rejected");
462
463 await completeQueuedMessageDelivery(other, harness.manager.revisionOf(other.messageId));
464 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe("rejected");
465 });
466
445467 it("keeps a delivered message when its editor opens during storage removal", async () => {
446468 const message = queuedMessage({
447469 messageId: "message-editor-removal-race",
......@@ -697,7 +719,8 @@ describe("thread outbox recovery rollback", () => {
697719 },
698720 });
699721 expect(remainingMessages()).toEqual([]);
700 expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server");
722 // The creation's failure card shows the reason; the composer is hidden.
723 expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({});
701724 // The thread screen opened for this creation reads the failure from here.
702725 expect(
703726 appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[
......@@ -706,6 +729,37 @@ describe("thread outbox recovery rollback", () => {
706729 ).toEqual({ kind: "failed", message, reason: "rejected by server" });
707730 });
708731
732 it("drops an earlier recovery error once a rejected new task is restored", async () => {
733 const message: QueuedThreadMessage = {
734 ...queuedMessage({ messageId: "message-creation-retried", text: "new task text" }),
735 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
736 creation: {
737 projectId: ProjectId.make("project-1"),
738 workspaceMode: "local",
739 branch: null,
740 worktreePath: null,
741 },
742 };
743 await harness.manager.enqueue(message);
744 harness.draftFile.setWriteError(new Error("disk full"));
745 await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe(
746 "retry",
747 );
748 const threadKey = `${message.environmentId}:${message.threadId}`;
749 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.messageId).toBe(
750 message.messageId,
751 );
752
753 harness.draftFile.setWriteError(null);
754 await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe(
755 "restored",
756 );
757
758 // The failure card carries the reason now; nothing stale sits above it.
759 expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({});
760 expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[threadKey]?.kind).toBe("failed");
761 });
762
709763 it("keeps a failed outcome until its thread screen consumes it", async () => {
710764 const message: QueuedThreadMessage = {
711765 ...queuedMessage({ messageId: "message-creation-kept", text: "new task text" }),
......@@ -734,6 +788,46 @@ describe("thread outbox recovery rollback", () => {
734788 await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored");
735789
736790 expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)).toEqual({});
791 // The thread screen shows why the message came back into the composer.
792 expect(
793 appAtomRegistry.get(threadComposerErrorsAtom)[`${message.environmentId}:${message.threadId}`],
794 ).toEqual({ message: "rejected", messageId: message.messageId });
795 });
796
797 it("leaves no error when the restored text is resent before recovery finishes", async () => {
798 const message = queuedMessage({ messageId: "message-resent", text: "resend me" });
799 const threadKey = `${message.environmentId}:${message.threadId}`;
800 await harness.manager.enqueue(message);
801 // The user sees the restored text as soon as the merge publishes it and
802 // sends it again while the recovery is still awaiting persistence.
803 const unsubscribe = appAtomRegistry.subscribe(composerDrafts.composerDraftsAtom, (drafts) => {
804 if (drafts[threadKey]?.text === "resend me") {
805 unsubscribe();
806 clearThreadComposerError(threadKey);
807 void composerDrafts.clearComposerDraftContent(threadKey);
808 }
809 });
810
811 await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored");
812
813 expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({});
814 });
815
816 it("withdraws the error when an edit makes the recovery back out", async () => {
817 const message = queuedMessage({ messageId: "message-edited-mid-recovery", text: "edit me" });
818 const threadKey = `${message.environmentId}:${message.threadId}`;
819 await harness.manager.enqueue(message);
820 const unsubscribe = appAtomRegistry.subscribe(composerDrafts.composerDraftsAtom, (drafts) => {
821 if (drafts[threadKey]?.text === "edit me") {
822 unsubscribe();
823 appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true });
824 }
825 });
826
827 await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("deferred");
828
829 expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({});
830 expect(remainingMessages()).toEqual([message]);
737831 });
738832
739833 it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => {
......@@ -759,6 +853,6 @@ describe("thread outbox recovery rollback", () => {
759853 "typed offline\n\nqueued text",
760854 );
761855 expect(remainingMessages()).toEqual([]);
762 expect(harness.setPendingConnectionError).toHaveBeenCalledWith("too large");
856 expect(appAtomRegistry.get(threadComposerErrorsAtom)[draftKey]?.message).toBe("too large");
763857 });
764858});
apps/mobile/src/state/use-thread-outbox-drain.ts+29-8
......@@ -80,10 +80,8 @@ import {
8080 useThreadOutboxMessages,
8181 useThreadOutboxShellStatuses,
8282} from "./use-thread-outbox";
83import {
84 setPendingConnectionError,
85 useRemoteConnectionStatus,
86} from "./use-remote-environment-registry";
83import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error";
84import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
8785
8886// Ordinary offline behavior (a socket dropping mid-request, a retryable
8987// attachment upload failure) must not spam `console.warn` on every backoff
......@@ -282,6 +280,12 @@ export async function completeQueuedMessageDelivery(
282280 queuedMessage: QueuedThreadMessage,
283281 deliveryRevision: number,
284282): Promise<"removed" | "edited" | "failed"> {
283 // The server took it after all: an error left by an earlier failed recovery
284 // of this same message no longer applies.
285 clearThreadComposerError(
286 scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId),
287 queuedMessage.messageId,
288 );
285289 try {
286290 await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => {
287291 console.warn("[thread-outbox] could not update sign-out snapshot after delivery", {
......@@ -438,6 +442,7 @@ export async function restoreRejectedQueuedMessage(
438442 message: string,
439443): Promise<"restored" | "deferred" | "blocked" | "retry"> {
440444 const draftKey = recoveryDraftKey(queuedMessage);
445 const threadKey = scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);
441446 // Set once the merge publishes, cleared once the queued message is removed.
442447 // The catch below uses it to take the merged content back out, so a retry
443448 // after a mid-recovery failure cannot append the recovered text again.
......@@ -467,12 +472,21 @@ export async function restoreRejectedQueuedMessage(
467472 (attachment) => !existingAttachmentIds.has(attachment.id),
468473 ).length;
469474 if (existingAttachmentIds.size + addedAttachmentCount > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
470 setPendingConnectionError(
475 setThreadComposerError(
476 threadKey,
471477 `Remove attachments from the draft before restoring this message. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`,
478 queuedMessage.messageId,
472479 );
473480 return "blocked";
474481 }
475482
483 // Shown before the merge publishes the text, so a resend of that text,
484 // which can happen while this recovery still awaits persistence, clears
485 // it. Withdrawn below wherever the recovery backs out.
486 const withdrawError = () => clearThreadComposerError(threadKey, queuedMessage.messageId);
487 if (!queuedMessage.creation) {
488 setThreadComposerError(threadKey, message, queuedMessage.messageId);
489 }
476490 let mergedDraft: ComposerDraft;
477491 try {
478492 stampRecoveryDraftProject(queuedMessage, draftKey);
......@@ -492,6 +506,7 @@ export async function restoreRejectedQueuedMessage(
492506 rollback = { snapshot: originalDraft, merged: mergedDraft };
493507 }
494508 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
509 withdrawError();
495510 await undoComposerDraftMerge(draftKey, originalDraft, mergedDraft);
496511 return "deferred";
497512 }
......@@ -520,6 +535,7 @@ export async function restoreRejectedQueuedMessage(
520535 !(await confirmThreadOutboxMessageQueued(queuedMessage)) ||
521536 appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]
522537 ) {
538 withdrawError();
523539 await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft);
524540 return "deferred";
525541 }
......@@ -532,6 +548,7 @@ export async function restoreRejectedQueuedMessage(
532548 () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId],
533549 ))
534550 ) {
551 withdrawError();
535552 await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft);
536553 return "deferred";
537554 }
......@@ -539,15 +556,17 @@ export async function restoreRejectedQueuedMessage(
539556 // must never be rolled back.
540557 rollback = null;
541558 if (queuedMessage.creation) {
559 // The failure card shows the reason, so an error left by an earlier
560 // failed attempt at this recovery no longer applies.
561 withdrawError();
542562 // The thread screen for this creation is likely open; it reads the
543 // outcome to offer reopening the restored draft.
563 // outcome to offer reopening the restored draft, and shows the reason.
544564 recordPendingThreadCreationOutcome({
545565 kind: "failed",
546566 message: queuedMessage,
547567 reason: message,
548568 });
549569 }
550 setPendingConnectionError(message);
551570 return "restored";
552571 } catch (error) {
553572 if (rollback !== null) {
......@@ -561,8 +580,10 @@ export async function restoreRejectedQueuedMessage(
561580 );
562581 }
563582 console.warn("[thread-outbox] failed to restore an undeliverable message", error);
564 setPendingConnectionError(
583 setThreadComposerError(
584 threadKey,
565585 error instanceof Error ? error.message : "The unsent message could not be restored.",
586 queuedMessage.messageId,
566587 );
567588 return "retry";
568589 }