From 5a1f5940da75a408aeecd50d76b70a8210fed4b8 Mon Sep 17 00:00:00 2001 From: T3 Code Test Date: Mon, 5 Oct 2026 04:59:44 +0530 Subject: [PATCH 1/4] fix(mobile): show why a message failed to send in the thread When the server rejected a send for a reason other than the network, mobile put the text back in the composer and wrote the reason to a global value that only the Add Environment screen renders. The thread showed nothing, and the error later surfaced as if pairing had failed. Send failures now go to a per-thread error shown above that thread's composer, the way web shows its thread error. It clears when the user dismisses it or sends again. A rejected new task keeps its own failure card. Closes #15801 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/ComposerErrorNotice.tsx | 47 +++++++++++++++++++ .../features/threads/ThreadDetailScreen.tsx | 16 +++++++ .../mobile/src/state/thread-composer-error.ts | 20 ++++++++ .../src/state/use-thread-composer-state.ts | 9 +++- .../src/state/use-thread-outbox-drain.test.ts | 16 +++++-- .../src/state/use-thread-outbox-drain.ts | 18 +++---- 6 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/features/threads/ComposerErrorNotice.tsx create mode 100644 apps/mobile/src/state/thread-composer-error.ts diff --git a/apps/mobile/src/features/threads/ComposerErrorNotice.tsx b/apps/mobile/src/features/threads/ComposerErrorNotice.tsx new file mode 100644 index 000000000000..0875f1ef6591 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerErrorNotice.tsx @@ -0,0 +1,47 @@ +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; + +/** Why the thread's last message did not send, above the composer until dismissed. */ +export function ComposerErrorNotice({ + message, + onDismiss, +}: { + readonly message: string; + readonly onDismiss: () => void; +}) { + return ( + + + + + {message} + + + + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 013bf900912d..9a7438f88b80 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; @@ -93,6 +94,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { setThreadComposerError, threadComposerErrorAtom } from "../../state/thread-composer-error"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; @@ -104,6 +106,7 @@ import type { ThreadFeedLatestRun, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; +import { ComposerErrorNotice } from "./ComposerErrorNotice"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; @@ -362,6 +365,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const composerError = useAtomValue(threadComposerErrorAtom(selectedThreadKey)); const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, threadId: props.selectedThread.id, @@ -1190,6 +1194,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onDismiss={() => props.onDismissFeedback(submission.id)} /> ))} + {composerError !== null ? ( + + setThreadComposerError(selectedThreadKey, null)} + /> + + ) : null} {usageLimitsReport && activeUserInputRequestId === null ? ( + Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:thread-composer-error:${threadKey}`), + ), +); + +export function setThreadComposerError(threadKey: string, message: string | null): void { + appAtomRegistry.set(threadComposerErrorAtom(threadKey), message); +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 1f9eed3d675c..88e56871bde2 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -96,6 +96,7 @@ import { useQueuedRunEdit, } from "./queued-run-edit"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; +import { setThreadComposerError } from "./thread-composer-error"; import { useSelectedThreadProjection, useSelectedThreadVisibleTurnItems, @@ -452,7 +453,8 @@ export function useThreadComposerState() { }); } endQueuedRunEdit(selectedThreadKey, { deferAttachmentCleanup: keepable }); - setPendingConnectionError( + setThreadComposerError( + selectedThreadKey, keepable ? "That message already started. Your edit is back in the composer." : "That message already started, so the edit was discarded.", @@ -670,6 +672,8 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); + // A new send supersedes the reason the previous one bounced back. + setThreadComposerError(threadKey, null); // Enqueue publishes the queued atom synchronously (the durable write // happens behind it), so clearing the draft here gives send feedback on // the tap frame instead of after file I/O. If the write fails the message @@ -706,7 +710,8 @@ export function useThreadComposerState() { attachments: [], }); appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); - setPendingConnectionError( + setThreadComposerError( + threadKey, error instanceof Error ? error.message : "Failed to save the queued message.", ); }, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 890b29f9c3e8..3d8fc0fac84f 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -19,7 +19,6 @@ const harness = vi.hoisted(() => ({ removePersistedFile: vi.fn(async () => undefined), removeOutboxMessage: vi.fn(async (_message: QueuedThreadMessage) => undefined), prepareTurnAttachments: vi.fn(), - setPendingConnectionError: vi.fn(), draftFile: (() => { let document = ""; let writeError: Error | null = null; @@ -106,7 +105,6 @@ vi.mock("./use-thread-outbox", async () => { }); vi.mock("./use-remote-environment-registry", () => ({ - setPendingConnectionError: harness.setPendingConnectionError, useRemoteConnectionStatus: () => ({ connectedEnvironments: [] }), })); @@ -137,6 +135,7 @@ import { clearPendingThreadCreationOutcome, pendingThreadCreationOutcomesAtom, } from "./pending-thread-creation"; +import { threadComposerErrorAtom } from "./thread-composer-error"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; @@ -209,11 +208,11 @@ afterEach(() => { appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {}); + appAtomRegistry.set(threadComposerErrorAtom("environment-1:thread-1"), null); harness.draftFile.setWriteError(null); harness.removePersistedFile.mockClear(); harness.removeOutboxMessage.mockClear(); harness.prepareTurnAttachments.mockReset(); - harness.setPendingConnectionError.mockClear(); }); describe("thread outbox attachment preparation", () => { @@ -697,7 +696,10 @@ describe("thread outbox recovery rollback", () => { }, }); expect(remainingMessages()).toEqual([]); - expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server"); + // The creation's failure card shows the reason; the composer is hidden. + expect( + appAtomRegistry.get(threadComposerErrorAtom(`${message.environmentId}:${message.threadId}`)), + ).toBeNull(); // The thread screen opened for this creation reads the failure from here. expect( appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[ @@ -734,6 +736,10 @@ describe("thread outbox recovery rollback", () => { await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored"); expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)).toEqual({}); + // The thread screen shows why the message came back into the composer. + expect( + appAtomRegistry.get(threadComposerErrorAtom(`${message.environmentId}:${message.threadId}`)), + ).toBe("rejected"); }); it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => { @@ -759,6 +765,6 @@ describe("thread outbox recovery rollback", () => { "typed offline\n\nqueued text", ); expect(remainingMessages()).toEqual([]); - expect(harness.setPendingConnectionError).toHaveBeenCalledWith("too large"); + expect(appAtomRegistry.get(threadComposerErrorAtom(draftKey))).toBe("too large"); }); }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 454980d404a5..1bf51e72ffa9 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -80,10 +80,8 @@ import { useThreadOutboxMessages, useThreadOutboxShellStatuses, } from "./use-thread-outbox"; -import { - setPendingConnectionError, - useRemoteConnectionStatus, -} from "./use-remote-environment-registry"; +import { setThreadComposerError } from "./thread-composer-error"; +import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; // Ordinary offline behavior (a socket dropping mid-request, a retryable // attachment upload failure) must not spam `console.warn` on every backoff @@ -438,6 +436,7 @@ export async function restoreRejectedQueuedMessage( message: string, ): Promise<"restored" | "deferred" | "blocked" | "retry"> { const draftKey = recoveryDraftKey(queuedMessage); + const threadKey = scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); // Set once the merge publishes, cleared once the queued message is removed. // The catch below uses it to take the merged content back out, so a retry // after a mid-recovery failure cannot append the recovered text again. @@ -467,7 +466,8 @@ export async function restoreRejectedQueuedMessage( (attachment) => !existingAttachmentIds.has(attachment.id), ).length; if (existingAttachmentIds.size + addedAttachmentCount > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - setPendingConnectionError( + setThreadComposerError( + threadKey, `Remove attachments from the draft before restoring this message. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, ); return "blocked"; @@ -540,14 +540,15 @@ export async function restoreRejectedQueuedMessage( rollback = null; if (queuedMessage.creation) { // The thread screen for this creation is likely open; it reads the - // outcome to offer reopening the restored draft. + // outcome to offer reopening the restored draft, and shows the reason. recordPendingThreadCreationOutcome({ kind: "failed", message: queuedMessage, reason: message, }); + } else { + setThreadComposerError(threadKey, message); } - setPendingConnectionError(message); return "restored"; } catch (error) { if (rollback !== null) { @@ -561,7 +562,8 @@ export async function restoreRejectedQueuedMessage( ); } console.warn("[thread-outbox] failed to restore an undeliverable message", error); - setPendingConnectionError( + setThreadComposerError( + threadKey, error instanceof Error ? error.message : "The unsent message could not be restored.", ); return "retry"; From 6dac48de4cf3f4c6024909df14b5e2d3bbfe8c7d Mon Sep 17 00:00:00 2001 From: T3 Code Test Date: Mon, 5 Oct 2026 05:13:22 +0530 Subject: [PATCH 2/4] fix(mobile): keep send errors in one map and clear them when they stop applying - One keep-alive map instead of an atom per opened thread, so dismissed errors don't accumulate. - Errors from the outbox record which message they describe. If that message is delivered on a later retry, its error clears. An error about a different message stays. - Removing an environment clears its threads' errors with its drafts and outbox. - Announce the error to VoiceOver; accessibilityLiveRegion only reaches TalkBack. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/connection/platform.ts | 2 + .../features/threads/ComposerErrorNotice.tsx | 10 +++- .../features/threads/ThreadDetailScreen.tsx | 9 ++- .../src/state/thread-composer-error.test.ts | 39 +++++++++++++ .../mobile/src/state/thread-composer-error.ts | 56 +++++++++++++++---- .../src/state/use-thread-composer-state.ts | 4 +- .../src/state/use-thread-outbox-drain.test.ts | 33 ++++++++--- .../src/state/use-thread-outbox-drain.ts | 12 +++- 8 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 apps/mobile/src/state/thread-composer-error.test.ts diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 251264c9c9ea..03a21a9d1c7c 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -27,6 +27,7 @@ import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; +import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; import { connectionStorageLayer } from "./storage"; @@ -218,6 +219,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( [ Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), + Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), ], { concurrency: "unbounded", discard: true }, ).pipe( diff --git a/apps/mobile/src/features/threads/ComposerErrorNotice.tsx b/apps/mobile/src/features/threads/ComposerErrorNotice.tsx index 0875f1ef6591..37ea6078f2ab 100644 --- a/apps/mobile/src/features/threads/ComposerErrorNotice.tsx +++ b/apps/mobile/src/features/threads/ComposerErrorNotice.tsx @@ -1,4 +1,5 @@ -import { Pressable, View } from "react-native"; +import { useEffect } from "react"; +import { AccessibilityInfo, Platform, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; @@ -11,6 +12,13 @@ export function ComposerErrorNotice({ readonly message: string; readonly onDismiss: () => void; }) { + // accessibilityLiveRegion below only reaches TalkBack; VoiceOver needs an + // explicit announcement. + useEffect(() => { + if (Platform.OS === "ios") { + AccessibilityInfo.announceForAccessibility(message); + } + }, [message]); return ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9a7438f88b80..886c717fabc8 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -94,7 +94,10 @@ import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { setThreadComposerError, threadComposerErrorAtom } from "../../state/thread-composer-error"; +import { + clearThreadComposerError, + threadComposerErrorsAtom, +} from "../../state/thread-composer-error"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; @@ -365,7 +368,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); - const composerError = useAtomValue(threadComposerErrorAtom(selectedThreadKey)); + const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, threadId: props.selectedThread.id, @@ -1202,7 +1205,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread > setThreadComposerError(selectedThreadKey, null)} + onDismiss={() => clearThreadComposerError(selectedThreadKey)} /> ) : null} diff --git a/apps/mobile/src/state/thread-composer-error.test.ts b/apps/mobile/src/state/thread-composer-error.test.ts new file mode 100644 index 000000000000..7c3ef653ebba --- /dev/null +++ b/apps/mobile/src/state/thread-composer-error.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { appAtomRegistry } from "./atom-registry"; +import { + clearThreadComposerError, + clearThreadComposerErrorsForEnvironment, + setThreadComposerError, + threadComposerErrorsAtom, +} from "./thread-composer-error"; + +afterEach(() => { + appAtomRegistry.set(threadComposerErrorsAtom, {}); +}); + +describe("thread composer errors", () => { + it("clears a message-scoped error only for that message", () => { + setThreadComposerError("environment-1:thread-1", "rejected", "message-1"); + + clearThreadComposerError("environment-1:thread-1", "message-2"); + expect(appAtomRegistry.get(threadComposerErrorsAtom)["environment-1:thread-1"]?.message).toBe( + "rejected", + ); + + clearThreadComposerError("environment-1:thread-1"); + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); + }); + + it("clears every thread's error for a removed environment and no others", () => { + setThreadComposerError("environment-1:thread-1", "one"); + setThreadComposerError("environment-1:thread-2", "two"); + setThreadComposerError("environment-10:thread-1", "other environment"); + + clearThreadComposerErrorsForEnvironment("environment-1"); + + expect(Object.keys(appAtomRegistry.get(threadComposerErrorsAtom))).toEqual([ + "environment-10:thread-1", + ]); + }); +}); diff --git a/apps/mobile/src/state/thread-composer-error.ts b/apps/mobile/src/state/thread-composer-error.ts index db0769a3a89a..bf51139ede97 100644 --- a/apps/mobile/src/state/thread-composer-error.ts +++ b/apps/mobile/src/state/thread-composer-error.ts @@ -2,19 +2,55 @@ import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atom-registry"; +interface ThreadComposerError { + readonly message: string; + /** The queued message this error is about, when it is about one. */ + readonly messageId: string | null; +} + /** * Why a thread's last message did not go out, shown above that thread's * composer. The outbox drain can reject a message after the user has left the - * thread, so the reason is kept per thread until they dismiss it or send again. - * Keyed by `scopedThreadKey`. + * thread, so the reason is kept per thread until they dismiss it, send again, + * or the message it describes is delivered after all. Keyed by `scopedThreadKey`. */ -export const threadComposerErrorAtom = Atom.family((threadKey: string) => - Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel(`mobile:thread-composer-error:${threadKey}`), - ), -); +export const threadComposerErrorsAtom = Atom.make>>( + {}, +).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer-errors")); + +export function setThreadComposerError( + threadKey: string, + message: string, + messageId: string | null = null, +): void { + appAtomRegistry.set(threadComposerErrorsAtom, { + ...appAtomRegistry.get(threadComposerErrorsAtom), + [threadKey]: { message, messageId }, + }); +} + +/** With `messageId`, clears only an error that describes that message. */ +export function clearThreadComposerError(threadKey: string, messageId?: string): void { + const current = appAtomRegistry.get(threadComposerErrorsAtom); + const error = current[threadKey]; + if (!error || (messageId !== undefined && error.messageId !== messageId)) { + return; + } + const next = { ...current }; + delete next[threadKey]; + appAtomRegistry.set(threadComposerErrorsAtom, next); +} -export function setThreadComposerError(threadKey: string, message: string | null): void { - appAtomRegistry.set(threadComposerErrorAtom(threadKey), message); +export function clearThreadComposerErrorsForEnvironment(environmentId: string): void { + const current = appAtomRegistry.get(threadComposerErrorsAtom); + const prefix = `${environmentId}:`; + const keys = Object.keys(current).filter((key) => key.startsWith(prefix)); + if (keys.length === 0) { + return; + } + const next = { ...current }; + for (const key of keys) { + delete next[key]; + } + appAtomRegistry.set(threadComposerErrorsAtom, next); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 88e56871bde2..9688eddc65a4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -96,7 +96,7 @@ import { useQueuedRunEdit, } from "./queued-run-edit"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; -import { setThreadComposerError } from "./thread-composer-error"; +import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error"; import { useSelectedThreadProjection, useSelectedThreadVisibleTurnItems, @@ -673,7 +673,7 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // A new send supersedes the reason the previous one bounced back. - setThreadComposerError(threadKey, null); + clearThreadComposerError(threadKey); // Enqueue publishes the queued atom synchronously (the durable write // happens behind it), so clearing the draft here gives send feedback on // the tap frame instead of after file I/O. If the write fails the message diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 3d8fc0fac84f..f1b851dac2a5 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -135,7 +135,7 @@ import { clearPendingThreadCreationOutcome, pendingThreadCreationOutcomesAtom, } from "./pending-thread-creation"; -import { threadComposerErrorAtom } from "./thread-composer-error"; +import { setThreadComposerError, threadComposerErrorsAtom } from "./thread-composer-error"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; @@ -208,7 +208,7 @@ afterEach(() => { appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {}); - appAtomRegistry.set(threadComposerErrorAtom("environment-1:thread-1"), null); + appAtomRegistry.set(threadComposerErrorsAtom, {}); harness.draftFile.setWriteError(null); harness.removePersistedFile.mockClear(); harness.removeOutboxMessage.mockClear(); @@ -441,6 +441,25 @@ describe("thread outbox drain delivery cleanup", () => { expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); }); + it("clears an error about the delivered message but keeps one about another message", async () => { + const threadKey = "environment-1:thread-1"; + const retried = queuedMessage({ messageId: "message-retried", text: "retried" }); + await harness.manager.enqueue(retried); + // A failed recovery left this message queued with an error about it. + setThreadComposerError(threadKey, "could not be restored", retried.messageId); + + await completeQueuedMessageDelivery(retried, harness.manager.revisionOf(retried.messageId)); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]).toBeUndefined(); + + const other = queuedMessage({ messageId: "message-other", text: "other" }); + await harness.manager.enqueue(other); + // The thread's error explains a different, rejected message still in the draft. + setThreadComposerError(threadKey, "rejected", "message-rejected"); + + await completeQueuedMessageDelivery(other, harness.manager.revisionOf(other.messageId)); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe("rejected"); + }); + it("keeps a delivered message when its editor opens during storage removal", async () => { const message = queuedMessage({ messageId: "message-editor-removal-race", @@ -697,9 +716,7 @@ describe("thread outbox recovery rollback", () => { }); expect(remainingMessages()).toEqual([]); // The creation's failure card shows the reason; the composer is hidden. - expect( - appAtomRegistry.get(threadComposerErrorAtom(`${message.environmentId}:${message.threadId}`)), - ).toBeNull(); + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); // The thread screen opened for this creation reads the failure from here. expect( appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[ @@ -738,8 +755,8 @@ describe("thread outbox recovery rollback", () => { expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)).toEqual({}); // The thread screen shows why the message came back into the composer. expect( - appAtomRegistry.get(threadComposerErrorAtom(`${message.environmentId}:${message.threadId}`)), - ).toBe("rejected"); + appAtomRegistry.get(threadComposerErrorsAtom)[`${message.environmentId}:${message.threadId}`], + ).toEqual({ message: "rejected", messageId: message.messageId }); }); it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => { @@ -765,6 +782,6 @@ describe("thread outbox recovery rollback", () => { "typed offline\n\nqueued text", ); expect(remainingMessages()).toEqual([]); - expect(appAtomRegistry.get(threadComposerErrorAtom(draftKey))).toBe("too large"); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[draftKey]?.message).toBe("too large"); }); }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1bf51e72ffa9..5d5bcc9d6a06 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -80,7 +80,7 @@ import { useThreadOutboxMessages, useThreadOutboxShellStatuses, } from "./use-thread-outbox"; -import { setThreadComposerError } from "./thread-composer-error"; +import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; // Ordinary offline behavior (a socket dropping mid-request, a retryable @@ -280,6 +280,12 @@ export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, ): Promise<"removed" | "edited" | "failed"> { + // The server took it after all: an error left by an earlier failed recovery + // of this same message no longer applies. + clearThreadComposerError( + scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), + queuedMessage.messageId, + ); try { await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { @@ -469,6 +475,7 @@ export async function restoreRejectedQueuedMessage( setThreadComposerError( threadKey, `Remove attachments from the draft before restoring this message. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, + queuedMessage.messageId, ); return "blocked"; } @@ -547,7 +554,7 @@ export async function restoreRejectedQueuedMessage( reason: message, }); } else { - setThreadComposerError(threadKey, message); + setThreadComposerError(threadKey, message, queuedMessage.messageId); } return "restored"; } catch (error) { @@ -565,6 +572,7 @@ export async function restoreRejectedQueuedMessage( setThreadComposerError( threadKey, error instanceof Error ? error.message : "The unsent message could not be restored.", + queuedMessage.messageId, ); return "retry"; } From 23a1eea42e1c3a608e7144a4708300718f6614aa Mon Sep 17 00:00:00 2001 From: T3 Code Test Date: Mon, 5 Oct 2026 05:23:00 +0530 Subject: [PATCH 3/4] fix(mobile): a resend during send-failure recovery no longer leaves a stale error Recovery used to set the error only after it finished persisting the restored draft. The restored text is visible before that, so a user could resend it in the gap, clearing the error, and recovery would then set it again even though the resend went through. The error is now set before the merge publishes the text, so a resend always comes after it and clears it. Each path where recovery backs out withdraws the error it set. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/state/use-thread-outbox-drain.test.ts | 42 ++++++++++++++++++- .../src/state/use-thread-outbox-drain.ts | 12 +++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index f1b851dac2a5..c3f0171fc442 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -135,7 +135,11 @@ import { clearPendingThreadCreationOutcome, pendingThreadCreationOutcomesAtom, } from "./pending-thread-creation"; -import { setThreadComposerError, threadComposerErrorsAtom } from "./thread-composer-error"; +import { + clearThreadComposerError, + setThreadComposerError, + threadComposerErrorsAtom, +} from "./thread-composer-error"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; @@ -759,6 +763,42 @@ describe("thread outbox recovery rollback", () => { ).toEqual({ message: "rejected", messageId: message.messageId }); }); + it("leaves no error when the restored text is resent before recovery finishes", async () => { + const message = queuedMessage({ messageId: "message-resent", text: "resend me" }); + const threadKey = `${message.environmentId}:${message.threadId}`; + await harness.manager.enqueue(message); + // The user sees the restored text as soon as the merge publishes it and + // sends it again while the recovery is still awaiting persistence. + const unsubscribe = appAtomRegistry.subscribe(composerDrafts.composerDraftsAtom, (drafts) => { + if (drafts[threadKey]?.text === "resend me") { + unsubscribe(); + clearThreadComposerError(threadKey); + void composerDrafts.clearComposerDraftContent(threadKey); + } + }); + + await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored"); + + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); + }); + + it("withdraws the error when an edit makes the recovery back out", async () => { + const message = queuedMessage({ messageId: "message-edited-mid-recovery", text: "edit me" }); + const threadKey = `${message.environmentId}:${message.threadId}`; + await harness.manager.enqueue(message); + const unsubscribe = appAtomRegistry.subscribe(composerDrafts.composerDraftsAtom, (drafts) => { + if (drafts[threadKey]?.text === "edit me") { + unsubscribe(); + appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); + } + }); + + await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("deferred"); + + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); + expect(remainingMessages()).toEqual([message]); + }); + it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => { const message = queuedMessage({ messageId: "message-restore", text: "queued text" }); const draftKey = `${message.environmentId}:${message.threadId}`; diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 5d5bcc9d6a06..760e524070ce 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -480,6 +480,13 @@ export async function restoreRejectedQueuedMessage( return "blocked"; } + // Shown before the merge publishes the text, so a resend of that text, + // which can happen while this recovery still awaits persistence, clears + // it. Withdrawn below wherever the recovery backs out. + const withdrawError = () => clearThreadComposerError(threadKey, queuedMessage.messageId); + if (!queuedMessage.creation) { + setThreadComposerError(threadKey, message, queuedMessage.messageId); + } let mergedDraft: ComposerDraft; try { stampRecoveryDraftProject(queuedMessage, draftKey); @@ -499,6 +506,7 @@ export async function restoreRejectedQueuedMessage( rollback = { snapshot: originalDraft, merged: mergedDraft }; } if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + withdrawError(); await undoComposerDraftMerge(draftKey, originalDraft, mergedDraft); return "deferred"; } @@ -527,6 +535,7 @@ export async function restoreRejectedQueuedMessage( !(await confirmThreadOutboxMessageQueued(queuedMessage)) || appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] ) { + withdrawError(); await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft); return "deferred"; } @@ -539,6 +548,7 @@ export async function restoreRejectedQueuedMessage( () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId], )) ) { + withdrawError(); await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft); return "deferred"; } @@ -553,8 +563,6 @@ export async function restoreRejectedQueuedMessage( message: queuedMessage, reason: message, }); - } else { - setThreadComposerError(threadKey, message, queuedMessage.messageId); } return "restored"; } catch (error) { From 237bb20d817e3cd207da735ea5d1b1c272bc02cc Mon Sep 17 00:00:00 2001 From: T3 Code Test Date: Mon, 5 Oct 2026 05:25:15 +0530 Subject: [PATCH 4/4] fix(mobile): drop a stale recovery error once a rejected new task is restored If restoring a rejected new task failed once, the retry's success recorded the failure card but left that earlier error above it. Successful creation recovery now withdraws any error tagged with its message. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/state/use-thread-outbox-drain.test.ts | 31 +++++++++++++++++++ .../src/state/use-thread-outbox-drain.ts | 3 ++ 2 files changed, 34 insertions(+) diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index c3f0171fc442..a7b807e6267a 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -729,6 +729,37 @@ describe("thread outbox recovery rollback", () => { ).toEqual({ kind: "failed", message, reason: "rejected by server" }); }); + it("drops an earlier recovery error once a rejected new task is restored", async () => { + const message: QueuedThreadMessage = { + ...queuedMessage({ messageId: "message-creation-retried", text: "new task text" }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + }; + await harness.manager.enqueue(message); + harness.draftFile.setWriteError(new Error("disk full")); + await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe( + "retry", + ); + const threadKey = `${message.environmentId}:${message.threadId}`; + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.messageId).toBe( + message.messageId, + ); + + harness.draftFile.setWriteError(null); + await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe( + "restored", + ); + + // The failure card carries the reason now; nothing stale sits above it. + expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); + expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[threadKey]?.kind).toBe("failed"); + }); + it("keeps a failed outcome until its thread screen consumes it", async () => { const message: QueuedThreadMessage = { ...queuedMessage({ messageId: "message-creation-kept", text: "new task text" }), diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 760e524070ce..db4d5b14f590 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -556,6 +556,9 @@ export async function restoreRejectedQueuedMessage( // must never be rolled back. rollback = null; if (queuedMessage.creation) { + // The failure card shows the reason, so an error left by an earlier + // failed attempt at this recovery no longer applies. + withdrawError(); // The thread screen for this creation is likely open; it reads the // outcome to offer reopening the restored draft, and shows the reason. recordPendingThreadCreationOutcome({