authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 01:54:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 01:54:59-07:00
logf0f16e4f6e8e923dcb17fc1c25d4fb1e442f80a5
tree183b284a2439a7e88397427bbd171aef056e62f4
parent00da6b57a43aef39162ff9e8205b5441c24298d0
signature Signed by PGP key B5690EEEBB952194

perf(mobile): sends respond instantly, thread opens stop freezing (#4882)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

12 files changed, 283 insertions(+), 61 deletions(-)

apps/mobile/src/Stack.tsx+10-1
...@@ -284,6 +284,15 @@ function workspacePathFromState(state: NavigationState): string {...@@ -284,6 +284,15 @@ function workspacePathFromState(state: NavigationState): string {
284 return path.startsWith("/") ? path : `/${path}`;284 return path.startsWith("/") ? path : `/${path}`;
285}285}
286286
287// The drain hook subscribes to the outbox, all thread shells, projects, and
288// connection statuses. Hosting it in a null-rendering leaf keeps those
289// updates from re-rendering RootStackLayout (and with it every screen) on
290// each enqueue, shell change, or reconnect.
291function ThreadOutboxDrainWorker() {
292 useThreadOutboxDrain();
293 return null;
294}
295
287function RootStackLayout(props: {296function RootStackLayout(props: {
288 readonly children: React.ReactNode;297 readonly children: React.ReactNode;
289 readonly state: NavigationState;298 readonly state: NavigationState;
...@@ -292,7 +301,6 @@ function RootStackLayout(props: {...@@ -292,7 +301,6 @@ function RootStackLayout(props: {
292 const { pendingShare } = useIncomingShare();301 const { pendingShare } = useIncomingShare();
293 const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);302 const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
294 useAgentNotificationNavigation();303 useAgentNotificationNavigation();
295 useThreadOutboxDrain();
296 // Presents the T3 Connect onboarding sheet after an in-session sign-in.304 // Presents the T3 Connect onboarding sheet after an in-session sign-in.
297 useConnectOnboardingNavigation();305 useConnectOnboardingNavigation();
298 // Launcher app shortcuts: routes shortcut taps and tracks opened threads.306 // Launcher app shortcuts: routes shortcut taps and tracks opened threads.
...@@ -320,6 +328,7 @@ function RootStackLayout(props: {...@@ -320,6 +328,7 @@ function RootStackLayout(props: {
320328
321 return (329 return (
322 <HardwareKeyboardCommandProvider pathname={pathname}>330 <HardwareKeyboardCommandProvider pathname={pathname}>
331 <ThreadOutboxDrainWorker />
323 <ShowcaseCaptureCoordinator pathname={pathname} />332 <ShowcaseCaptureCoordinator pathname={pathname} />
324 <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>333 <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
325 <AdaptiveWorkspaceLayout pathname={workspacePathname}>334 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
apps/mobile/src/features/home/HomeScreen.tsx+21-7
...@@ -665,6 +665,26 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -665,6 +665,26 @@ export function HomeScreen(props: HomeScreenProps) {
665 );665 );
666 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);666 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
667667
668 // FlatList treats a changed extraData identity as "re-render every visible
669 // row", so an inline object literal would invalidate all rows on every
670 // HomeScreen render.
671 const v2ExtraData = useMemo(
672 () => ({
673 projectByKey,
674 projectCwdByKey,
675 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
676 serverConfigs,
677 savedConnectionsById: props.savedConnectionsById,
678 }),
679 [
680 projectByKey,
681 projectCwdByKey,
682 props.savedConnectionsById,
683 serverConfigs,
684 v2ProjectTitleByProjectKey,
685 ],
686 );
687
668 const extraData = useMemo(688 const extraData = useMemo(
669 () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),689 () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),
670 [props.savedConnectionsById, projectCwdByKey],690 [props.savedConnectionsById, projectCwdByKey],
...@@ -900,13 +920,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -900,13 +920,7 @@ export function HomeScreen(props: HomeScreenProps) {
900 data={threadListV2Items}920 data={threadListV2Items}
901 renderItem={renderV2Item}921 renderItem={renderV2Item}
902 keyExtractor={v2KeyExtractor}922 keyExtractor={v2KeyExtractor}
903 extraData={{923 extraData={v2ExtraData}
904 projectByKey,
905 projectCwdByKey,
906 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
907 serverConfigs,
908 savedConnectionsById: props.savedConnectionsById,
909 }}
910 ListHeaderComponent={v2ListHeader}924 ListHeaderComponent={v2ListHeader}
911 ListFooterComponent={925 ListFooterComponent={
912 threadListV2Layout.hiddenSettledCount > 0 ? (926 threadListV2Layout.hiddenSettledCount > 0 ? (
apps/mobile/src/features/shortcuts/useAppShortcuts.ts+8-1
...@@ -54,7 +54,14 @@ function useShortcutNavigation(): void {...@@ -54,7 +54,14 @@ function useShortcutNavigation(): void {
54}54}
5555
56function useRecentThreadShortcutSync(state: NavigationState): void {56function useRecentThreadShortcutSync(state: NavigationState): void {
57 const threadRef = useMemo(() => activeThreadRef(state), [state]);57 // Launcher shortcuts are Android-only. A null ref on iOS keeps this hook
58 // (mounted in the root stack layout) from subscribing the root to the
59 // active thread's shell, which would re-render every screen on each
60 // title/status/session change.
61 const threadRef = useMemo(
62 () => (Platform.OS === "android" ? activeThreadRef(state) : null),
63 [state],
64 );
58 const threadShell = useThreadShell(threadRef);65 const threadShell = useThreadShell(threadRef);
59 // null until the persisted list loads; recording waits on it so the first66 // null until the persisted list loads; recording waits on it so the first
60 // thread opened after a cold start cannot clobber older entries.67 // thread opened after a cold start cannot clobber older entries.
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+5-1
...@@ -946,7 +946,11 @@ export function NewTaskDraftScreen(props: {...@@ -946,7 +946,11 @@ export function NewTaskDraftScreen(props: {
946 const promptEditor = (946 const promptEditor = (
947 <ComposerEditor947 <ComposerEditor
948 ref={promptInputRef}948 ref={promptInputRef}
949 autoFocus={!isAndroid}949 // Native autoFocus fires becomeFirstResponder in didMoveToWindow, which
950 // forces the iOS keyboard bring-up during the formSheet present
951 // animation and stalls it. The runAfterInteractions effect above focuses
952 // the editor once the transition settles instead.
953 autoFocus={false}
950 editable={!isIncomingShareTransferPending}954 editable={!isIncomingShareTransferPending}
951 multiline955 multiline
952 scrollEnabled={isExpanded}956 scrollEnabled={isExpanded}
apps/mobile/src/features/threads/ThreadComposer.tsx+8-6
...@@ -518,14 +518,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -518,14 +518,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
518 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);518 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
519 if (inFlightThreadIdsRef.current.has(threadKey)) return;519 if (inFlightThreadIdsRef.current.has(threadKey)) return;
520 inFlightThreadIdsRef.current.add(threadKey);520 inFlightThreadIdsRef.current.add(threadKey);
521 // Sending a prompt starts agent work: arm the lock-screen card now, while
522 // the app is foregrounded and the activity token can be registered.
523 armAgentAwarenessLiveActivityForLocalWork({
524 threadTitle: props.selectedThread.title,
525 projectTitle: props.environmentLabel ?? "T3 Code",
526 });
527 try {521 try {
528 await onSendMessage();522 await onSendMessage();
523 // Sending a prompt starts agent work: arm the lock-screen card while the
524 // app is foregrounded and the activity token can be registered. Armed
525 // after the send so its preference read and native Activity start don't
526 // contend with the queued-message feedback on the tap frame.
527 armAgentAwarenessLiveActivityForLocalWork({
528 threadTitle: props.selectedThread.title,
529 projectTitle: props.environmentLabel ?? "T3 Code",
530 });
529 } finally {531 } finally {
530 inFlightThreadIdsRef.current.delete(threadKey);532 inFlightThreadIdsRef.current.delete(threadKey);
531 }533 }
apps/mobile/src/lib/threadActivity.ts+27-13
...@@ -957,6 +957,11 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean {...@@ -957,6 +957,11 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean {
957957
958function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {958function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {
959 const grouped: ThreadFeedEntry[] = [];959 const grouped: ThreadFeedEntry[] = [];
960 // Mutable backing array for the trailing group so appending an activity is
961 // O(1) instead of re-copying the group (which made this loop quadratic on
962 // long tool runs). The array is only mutated while it is the trailing group.
963 let openGroupActivities: ThreadFeedActivity[] | null = null;
964 let openGroupTurnId: TurnId | null = null;
960965
961 for (const entry of entries) {966 for (const entry of entries) {
962 // Skip empty messages so they don't break activity grouping.967 // Skip empty messages so they don't break activity grouping.
...@@ -966,24 +971,23 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th...@@ -966,24 +971,23 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
966971
967 if (entry.type !== "activity") {972 if (entry.type !== "activity") {
968 grouped.push(entry);973 grouped.push(entry);
974 openGroupActivities = null;
969 continue;975 continue;
970 }976 }
971977
972 const previous = grouped.at(-1);978 if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {
973 if (previous?.type === "activity-group" && previous.turnId === entry.turnId) {979 openGroupActivities.push(entry.activity);
974 grouped[grouped.length - 1] = {
975 ...previous,
976 activities: [...previous.activities, entry.activity],
977 };
978 continue;980 continue;
979 }981 }
980982
983 openGroupActivities = [entry.activity];
984 openGroupTurnId = entry.turnId;
981 grouped.push({985 grouped.push({
982 type: "activity-group",986 type: "activity-group",
983 id: entry.id,987 id: entry.id,
984 createdAt: entry.createdAt,988 createdAt: entry.createdAt,
985 turnId: entry.turnId,989 turnId: entry.turnId,
986 activities: [entry.activity],990 activities: openGroupActivities,
987 });991 });
988 }992 }
989993
...@@ -1225,13 +1229,24 @@ function appendPresentedFeedEntry(...@@ -1225,13 +1229,24 @@ function appendPresentedFeedEntry(
1225 });1229 });
1226}1230}
12271231
1228export function derivePendingApprovals(1232/**
1233 * Sorts activities into lifecycle order. `derivePendingApprovals` and
1234 * `derivePendingUserInputs` both expect this ordering; sorting once and
1235 * passing the result to both avoids re-sorting the full activity history
1236 * per derivation.
1237 */
1238export function sortThreadActivities(
1229 activities: ReadonlyArray<OrchestrationThreadActivity>,1239 activities: ReadonlyArray<OrchestrationThreadActivity>,
1240): ReadonlyArray<OrchestrationThreadActivity> {
1241 return Arr.sort(activities, activityOrder);
1242}
1243
1244export function derivePendingApprovals(
1245 sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
1230): PendingApproval[] {1246): PendingApproval[] {
1231 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();1247 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
1232 const ordered = Arr.sort(activities, activityOrder);
12331248
1234 for (const activity of ordered) {1249 for (const activity of sortedActivities) {
1235 const payload =1250 const payload =
1236 activity.payload && typeof activity.payload === "object"1251 activity.payload && typeof activity.payload === "object"
1237 ? (activity.payload as Record<string, unknown>)1252 ? (activity.payload as Record<string, unknown>)
...@@ -1273,12 +1288,11 @@ export function derivePendingApprovals(...@@ -1273,12 +1288,11 @@ export function derivePendingApprovals(
1273}1288}
12741289
1275export function derivePendingUserInputs(1290export function derivePendingUserInputs(
1276 activities: ReadonlyArray<OrchestrationThreadActivity>,1291 sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
1277): PendingUserInput[] {1292): PendingUserInput[] {
1278 const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();1293 const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();
1279 const ordered = Arr.sort(activities, activityOrder);
12801294
1281 for (const activity of ordered) {1295 for (const activity of sortedActivities) {
1282 const payload =1296 const payload =
1283 activity.payload && typeof activity.payload === "object"1297 activity.payload && typeof activity.payload === "object"
1284 ? (activity.payload as Record<string, unknown>)1298 ? (activity.payload as Record<string, unknown>)
apps/mobile/src/state/thread-outbox-manager.ts+23-6
...@@ -88,11 +88,23 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -88,11 +88,23 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
88 return loadPromise;88 return loadPromise;
89 };89 };
9090
91 const enqueue = (message: QueuedThreadMessage): Promise<void> =>91 // The queued atom drives the composer's immediate "queued" feedback, so it
92 serialize(async () => {92 // is published synchronously; the durable write happens behind it and rolls
93 // the message back out if it fails (durability only matters for crash
94 // recovery, not for the in-session queue).
95 const enqueue = (message: QueuedThreadMessage): Promise<void> => {
96 setMessages([
97 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
98 message,
99 ]);
100 return serialize(async () => {
93 try {101 try {
94 await options.storage.write(message);102 await options.storage.write(message);
95 } catch (cause) {103 } catch (cause) {
104 // Roll back by reference, not messageId: a retry enqueue with the same
105 // id may have optimistically replaced this attempt while the write was
106 // in flight, and its entry must survive this attempt's failure.
107 setMessages(currentMessages().filter((candidate) => candidate !== message));
96 throw new ThreadOutboxManagerError({108 throw new ThreadOutboxManagerError({
97 operation: "enqueue",109 operation: "enqueue",
98 environmentId: message.environmentId,110 environmentId: message.environmentId,
...@@ -101,11 +113,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -101,11 +113,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
101 cause,113 cause,
102 });114 });
103 }115 }
104 setMessages([
105 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
106 message,
107 ]);
108 });116 });
117 };
118
119 // Resolves once all pending mutations (including any in-flight enqueue
120 // write) have settled, reporting whether the message is still queued. The
121 // drain awaits this before dispatching so a message whose durable write
122 // later fails can never have been delivered first.
123 const confirmQueued = (message: QueuedThreadMessage): Promise<boolean> =>
124 serialize(async () => currentMessages().some((candidate) => candidate === message));
109125
110 // Rewrites an already-queued message. A no-op when the message has been126 // Rewrites an already-queued message. A no-op when the message has been
111 // removed in the meantime (e.g. deleted or delivered), so a trailing editor127 // removed in the meantime (e.g. deleted or delivered), so a trailing editor
...@@ -204,6 +220,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -204,6 +220,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
204 serialize,220 serialize,
205 load,221 load,
206 enqueue,222 enqueue,
223 confirmQueued,
207 update,224 update,
208 remove,225 remove,
209 clearEnvironment,226 clearEnvironment,
apps/mobile/src/state/thread-outbox.test.ts+105
...@@ -294,6 +294,111 @@ describe("thread outbox", () => {...@@ -294,6 +294,111 @@ describe("thread outbox", () => {
294 registry.dispose();294 registry.dispose();
295 });295 });
296296
297 it("publishes an enqueued message before the durable write resolves", async () => {
298 const registry = AtomRegistry.make();
299 let releaseWrite!: () => void;
300 const writeBlocked = new Promise<void>((resolve) => {
301 releaseWrite = resolve;
302 });
303 const manager = createThreadOutboxManager({
304 registry,
305 storage: {
306 load: async () => [],
307 write: async () => writeBlocked,
308 remove: async () => undefined,
309 },
310 });
311 const message = queuedMessage({
312 messageId: "message-1",
313 createdAt: "2026-06-08T10:00:01.000Z",
314 });
315
316 const enqueueing = manager.enqueue(message);
317 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
318 "environment-1:thread-1": [message],
319 });
320
321 releaseWrite();
322 await enqueueing;
323 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
324 "environment-1:thread-1": [message],
325 });
326 registry.dispose();
327 });
328
329 it("rolls an enqueued message back out when the durable write fails", async () => {
330 const registry = AtomRegistry.make();
331 const writeCause = new Error("disk full");
332 const manager = createThreadOutboxManager({
333 registry,
334 storage: {
335 load: async () => [],
336 write: async () => {
337 throw writeCause;
338 },
339 remove: async () => undefined,
340 },
341 });
342 const message = queuedMessage({
343 messageId: "message-1",
344 createdAt: "2026-06-08T10:00:01.000Z",
345 });
346
347 await expect(manager.enqueue(message)).rejects.toEqual(
348 new ThreadOutboxManagerError({
349 operation: "enqueue",
350 environmentId: message.environmentId,
351 threadId: message.threadId,
352 messageId: message.messageId,
353 cause: writeCause,
354 }),
355 );
356 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({});
357 registry.dispose();
358 });
359
360 it("keeps a same-id retry queued when the first attempt's write fails", async () => {
361 const registry = AtomRegistry.make();
362 let failNextWrite = true;
363 let releaseFirstWrite!: () => void;
364 const firstWriteBlocked = new Promise<void>((resolve) => {
365 releaseFirstWrite = resolve;
366 });
367 const manager = createThreadOutboxManager({
368 registry,
369 storage: {
370 load: async () => [],
371 write: async () => {
372 if (failNextWrite) {
373 failNextWrite = false;
374 await firstWriteBlocked;
375 throw new Error("disk full");
376 }
377 },
378 remove: async () => undefined,
379 },
380 });
381 const message = queuedMessage({
382 messageId: "message-1",
383 createdAt: "2026-06-08T10:00:01.000Z",
384 });
385 const retried = { ...message, text: "retried" };
386
387 const first = manager.enqueue(message);
388 const second = manager.enqueue(retried);
389 releaseFirstWrite();
390 await expect(first).rejects.toBeInstanceOf(ThreadOutboxManagerError);
391 await second;
392
393 // The failed first attempt must not roll back the retry that replaced it.
394 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
395 "environment-1:thread-1": [retried],
396 });
397 await expect(manager.confirmQueued(retried)).resolves.toBe(true);
398 await expect(manager.confirmQueued(message)).resolves.toBe(false);
399 registry.dispose();
400 });
401
297 it("replaces an existing message when an enqueue retry uses the same id", async () => {402 it("replaces an existing message when an enqueue retry uses the same id", async () => {
298 const registry = AtomRegistry.make();403 const registry = AtomRegistry.make();
299 const manager = createThreadOutboxManager({404 const manager = createThreadOutboxManager({
apps/mobile/src/state/thread-outbox.ts+5
...@@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis...@@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis
20 return threadOutboxManager.enqueue(message);20 return threadOutboxManager.enqueue(message);
21}21}
2222
23/** Waits for pending writes to settle; false if the message was rolled back. */
24export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage): Promise<boolean> {
25 return threadOutboxManager.confirmQueued(message);
26}
27
23/** Rewrite a queued message; no-op (false) if it was removed in the meantime. */28/** Rewrite a queued message; no-op (false) if it was removed in the meantime. */
24export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> {29export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> {
25 return threadOutboxManager.update(message);30 return threadOutboxManager.update(message);
apps/mobile/src/state/use-selected-thread-requests.ts+10-4
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 derivePendingApprovals,11 derivePendingApprovals,
12 derivePendingUserInputs,12 derivePendingUserInputs,
13 setPendingUserInputCustomAnswer,13 setPendingUserInputCustomAnswer,
14 sortThreadActivities,
14 type PendingUserInputDraftAnswer,15 type PendingUserInputDraftAnswer,
15} from "../lib/threadActivity";16} from "../lib/threadActivity";
16import { appAtomRegistry } from "./atom-registry";17import { appAtomRegistry } from "./atom-registry";
...@@ -70,14 +71,19 @@ export function useSelectedThreadRequests() {...@@ -70,14 +71,19 @@ export function useSelectedThreadRequests() {
70 null,71 null,
71 );72 );
7273
73 const activePendingApprovals = useMemo(74 // Sort once; both derivations expect the same lifecycle ordering.
74 () => (selectedThread ? derivePendingApprovals(selectedThread.activities) : []),75 const sortedActivities = useMemo(
76 () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []),
75 [selectedThread],77 [selectedThread],
76 );78 );
79 const activePendingApprovals = useMemo(
80 () => derivePendingApprovals(sortedActivities),
81 [sortedActivities],
82 );
77 const activePendingApproval = activePendingApprovals[0] ?? null;83 const activePendingApproval = activePendingApprovals[0] ?? null;
78 const activePendingUserInputs = useMemo(84 const activePendingUserInputs = useMemo(
79 () => (selectedThread ? derivePendingUserInputs(selectedThread.activities) : []),85 () => derivePendingUserInputs(sortedActivities),
80 [selectedThread],86 [sortedActivities],
81 );87 );
82 const activePendingUserInput = activePendingUserInputs[0] ?? null;88 const activePendingUserInput = activePendingUserInputs[0] ?? null;
83 const activePendingUserInputDrafts =89 const activePendingUserInputDrafts =
apps/mobile/src/state/use-thread-composer-state.ts+28-18
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30 composerDraftsAtom,30 composerDraftsAtom,
31 ensureComposerDraftsLoaded,31 ensureComposerDraftsLoaded,
32 getComposerDraftSnapshot,32 getComposerDraftSnapshot,
33 mergeComposerDraftContent,
33 removeComposerDraftAttachment,34 removeComposerDraftAttachment,
34 setComposerDraftText,35 setComposerDraftText,
35 updateComposerDraftSettings,36 updateComposerDraftSettings,
...@@ -148,27 +149,36 @@ export function useThreadComposerState() {...@@ -148,27 +149,36 @@ export function useThreadComposerState() {
148149
149 const metadata = makeQueuedMessageMetadata();150 const metadata = makeQueuedMessageMetadata();
150 const messageId = MessageId.make(metadata.messageId);151 const messageId = MessageId.make(metadata.messageId);
151 try {152 // Enqueue publishes the queued atom synchronously (the durable write
152 await enqueueThreadOutboxMessage({153 // happens behind it), so clearing the draft here gives send feedback on
153 environmentId: selectedThreadShell.environmentId,154 // the tap frame instead of after file I/O. If the write fails the message
154 threadId: selectedThreadShell.id,155 // is rolled out of the queue and the content is merged back into the
155 messageId,156 // draft, preserving anything typed since.
156 commandId: CommandId.make(metadata.commandId),157 const enqueuePromise = enqueueThreadOutboxMessage({
157 text,158 environmentId: selectedThreadShell.environmentId,
158 attachments,159 threadId: selectedThreadShell.id,
159 modelSelection: draft.modelSelection ?? thread.modelSelection,160 messageId,
160 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,161 commandId: CommandId.make(metadata.commandId),
161 interactionMode: draft.interactionMode ?? thread.interactionMode,162 text,
162 createdAt: metadata.createdAt,163 attachments,
163 });164 modelSelection: draft.modelSelection ?? thread.modelSelection,
164 clearComposerDraftContent(threadKey);165 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
165 return messageId;166 interactionMode: draft.interactionMode ?? thread.interactionMode,
166 } catch (error) {167 createdAt: metadata.createdAt,
168 });
169 clearComposerDraftContent(threadKey);
170 enqueuePromise.catch((error: unknown) => {
171 // Restore text via merge (idempotent) but attachments via the uncapped
172 // append: the merge path slots existing attachments first and truncates
173 // at the send limit, which would silently drop this message's images if
174 // the user attached new ones while the write was in flight.
175 void mergeComposerDraftContent(threadKey, { text, attachments: [] });
176 appendComposerDraftAttachments(threadKey, attachments);
167 setPendingConnectionError(177 setPendingConnectionError(
168 error instanceof Error ? error.message : "Failed to save the queued message.",178 error instanceof Error ? error.message : "Failed to save the queued message.",
169 );179 );
170 return null;180 });
171 }181 return messageId;
172 }, [selectedThreadDetail, selectedThreadShell]);182 }, [selectedThreadDetail, selectedThreadShell]);
173183
174 const onChangeDraftMessage = useCallback(184 const onChangeDraftMessage = useCallback(
apps/mobile/src/state/use-thread-outbox-drain.ts+33-4
...@@ -21,7 +21,11 @@ import { toUploadChatImageAttachments } from "../lib/composerImages";...@@ -21,7 +21,11 @@ import { toUploadChatImageAttachments } from "../lib/composerImages";
21import { randomHex } from "../lib/uuid";21import { randomHex } from "../lib/uuid";
22import { appAtomRegistry } from "./atom-registry";22import { appAtomRegistry } from "./atom-registry";
23import { useProjects, useThreadShells } from "./entities";23import { useProjects, useThreadShells } from "./entities";
24import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";24import {
25 confirmThreadOutboxMessageQueued,
26 ensureThreadOutboxLoaded,
27 removeThreadOutboxMessage,
28} from "./thread-outbox";
25import {29import {
26 isQueuedThreadCreationSendable,30 isQueuedThreadCreationSendable,
27 modelSelectionsEqual,31 modelSelectionsEqual,
...@@ -33,7 +37,7 @@ import {...@@ -33,7 +37,7 @@ import {
33 type QueuedThreadMessage,37 type QueuedThreadMessage,
34 type ThreadOutboxCommandStage,38 type ThreadOutboxCommandStage,
35} from "./thread-outbox-model";39} from "./thread-outbox-model";
36import { threadEnvironment } from "./threads";40import { environmentThreadShells, threadEnvironment } from "./threads";
37import { useAtomCommand } from "./use-atom-command";41import { useAtomCommand } from "./use-atom-command";
38import {42import {
39 editingQueuedMessageIdsAtom,43 editingQueuedMessageIdsAtom,
...@@ -349,8 +353,32 @@ export function useThreadOutboxDrain(): void {...@@ -349,8 +353,32 @@ export function useThreadOutboxDrain(): void {
349 return false;353 return false;
350 },354 },
351 );355 );
352 const delivery =356 // Enqueues publish optimistically before their durable write settles.
353 deliveryAction === "remove"357 // Confirm the write landed (and the message wasn't rolled back) before
358 // sending, so a failed write can never chase an already-delivered turn.
359 const delivery = confirmThreadOutboxMessageQueued(nextQueuedMessage).then((queued) => {
360 if (!queued) {
361 // Rolled back by a failed write; nothing to deliver or retry.
362 return true;
363 }
364 // The guards evaluated before the confirmation await are stale by now:
365 // the thread may have gone busy, or the user may have opened this
366 // message in the editor. Re-read both and defer to the next drain pass
367 // (returning true skips the failure/backoff path) rather than sending
368 // a payload the user is editing or racing an active turn.
369 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) {
370 return true;
371 }
372 const freshThread = findThread(
373 appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
374 nextQueuedMessage,
375 );
376 const freshThreadBusy =
377 freshThread?.session?.status === "running" || freshThread?.session?.status === "starting";
378 if (deliveryAction === "send" && creation === undefined && freshThreadBusy) {
379 return true;
380 }
381 return deliveryAction === "remove"
354 ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")382 ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
355 : creation !== undefined383 : creation !== undefined
356 ? creationProjectCwd !== null384 ? creationProjectCwd !== null
...@@ -359,6 +387,7 @@ export function useThreadOutboxDrain(): void {...@@ -359,6 +387,7 @@ export function useThreadOutboxDrain(): void {
359 : thread !== undefined387 : thread !== undefined
360 ? sendQueuedMessage(nextQueuedMessage, thread)388 ? sendQueuedMessage(nextQueuedMessage, thread)
361 : Promise.resolve(false);389 : Promise.resolve(false);
390 });
362 void delivery391 void delivery
363 .then((sent) => {392 .then((sent) => {
364 if (sent) {393 if (sent) {