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 {
284284 return path.startsWith("/") ? path : `/${path}`;
285285}
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
287296function RootStackLayout(props: {
288297 readonly children: React.ReactNode;
289298 readonly state: NavigationState;
......@@ -292,7 +301,6 @@ function RootStackLayout(props: {
292301 const { pendingShare } = useIncomingShare();
293302 const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
294303 useAgentNotificationNavigation();
295 useThreadOutboxDrain();
296304 // Presents the T3 Connect onboarding sheet after an in-session sign-in.
297305 useConnectOnboardingNavigation();
298306 // Launcher app shortcuts: routes shortcut taps and tracks opened threads.
......@@ -320,6 +328,7 @@ function RootStackLayout(props: {
320328
321329 return (
322330 <HardwareKeyboardCommandProvider pathname={pathname}>
331 <ThreadOutboxDrainWorker />
323332 <ShowcaseCaptureCoordinator pathname={pathname} />
324333 <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
325334 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
apps/mobile/src/features/home/HomeScreen.tsx+21-7
......@@ -665,6 +665,26 @@ export function HomeScreen(props: HomeScreenProps) {
665665 );
666666 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
668688 const extraData = useMemo(
669689 () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),
670690 [props.savedConnectionsById, projectCwdByKey],
......@@ -900,13 +920,7 @@ export function HomeScreen(props: HomeScreenProps) {
900920 data={threadListV2Items}
901921 renderItem={renderV2Item}
902922 keyExtractor={v2KeyExtractor}
903 extraData={{
904 projectByKey,
905 projectCwdByKey,
906 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
907 serverConfigs,
908 savedConnectionsById: props.savedConnectionsById,
909 }}
923 extraData={v2ExtraData}
910924 ListHeaderComponent={v2ListHeader}
911925 ListFooterComponent={
912926 threadListV2Layout.hiddenSettledCount > 0 ? (
apps/mobile/src/features/shortcuts/useAppShortcuts.ts+8-1
......@@ -54,7 +54,14 @@ function useShortcutNavigation(): void {
5454}
5555
5656function 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 );
5865 const threadShell = useThreadShell(threadRef);
5966 // null until the persisted list loads; recording waits on it so the first
6067 // 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: {
946946 const promptEditor = (
947947 <ComposerEditor
948948 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}
950954 editable={!isIncomingShareTransferPending}
951955 multiline
952956 scrollEnabled={isExpanded}
apps/mobile/src/features/threads/ThreadComposer.tsx+8-6
......@@ -518,14 +518,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
518518 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
519519 if (inFlightThreadIdsRef.current.has(threadKey)) return;
520520 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 });
527521 try {
528522 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 });
529531 } finally {
530532 inFlightThreadIdsRef.current.delete(threadKey);
531533 }
apps/mobile/src/lib/threadActivity.ts+27-13
......@@ -957,6 +957,11 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean {
957957
958958function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {
959959 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
961966 for (const entry of entries) {
962967 // Skip empty messages so they don't break activity grouping.
......@@ -966,24 +971,23 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
966971
967972 if (entry.type !== "activity") {
968973 grouped.push(entry);
974 openGroupActivities = null;
969975 continue;
970976 }
971977
972 const previous = grouped.at(-1);
973 if (previous?.type === "activity-group" && previous.turnId === entry.turnId) {
974 grouped[grouped.length - 1] = {
975 ...previous,
976 activities: [...previous.activities, entry.activity],
977 };
978 if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {
979 openGroupActivities.push(entry.activity);
978980 continue;
979981 }
980982
983 openGroupActivities = [entry.activity];
984 openGroupTurnId = entry.turnId;
981985 grouped.push({
982986 type: "activity-group",
983987 id: entry.id,
984988 createdAt: entry.createdAt,
985989 turnId: entry.turnId,
986 activities: [entry.activity],
990 activities: openGroupActivities,
987991 });
988992 }
989993
......@@ -1225,13 +1229,24 @@ function appendPresentedFeedEntry(
12251229 });
12261230}
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(
12291239 activities: ReadonlyArray<OrchestrationThreadActivity>,
1240): ReadonlyArray<OrchestrationThreadActivity> {
1241 return Arr.sort(activities, activityOrder);
1242}
1243
1244export function derivePendingApprovals(
1245 sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
12301246): PendingApproval[] {
12311247 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) {
12351250 const payload =
12361251 activity.payload && typeof activity.payload === "object"
12371252 ? (activity.payload as Record<string, unknown>)
......@@ -1273,12 +1288,11 @@ export function derivePendingApprovals(
12731288}
12741289
12751290export function derivePendingUserInputs(
1276 activities: ReadonlyArray<OrchestrationThreadActivity>,
1291 sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
12771292): PendingUserInput[] {
12781293 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) {
12821296 const payload =
12831297 activity.payload && typeof activity.payload === "object"
12841298 ? (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) {
8888 return loadPromise;
8989 };
9090
91 const enqueue = (message: QueuedThreadMessage): Promise<void> =>
92 serialize(async () => {
91 // The queued atom drives the composer's immediate "queued" feedback, so it
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 () => {
93101 try {
94102 await options.storage.write(message);
95103 } 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));
96108 throw new ThreadOutboxManagerError({
97109 operation: "enqueue",
98110 environmentId: message.environmentId,
......@@ -101,11 +113,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
101113 cause,
102114 });
103115 }
104 setMessages([
105 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
106 message,
107 ]);
108116 });
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
110126 // Rewrites an already-queued message. A no-op when the message has been
111127 // removed in the meantime (e.g. deleted or delivered), so a trailing editor
......@@ -204,6 +220,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
204220 serialize,
205221 load,
206222 enqueue,
223 confirmQueued,
207224 update,
208225 remove,
209226 clearEnvironment,
apps/mobile/src/state/thread-outbox.test.ts+105
......@@ -294,6 +294,111 @@ describe("thread outbox", () => {
294294 registry.dispose();
295295 });
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
297402 it("replaces an existing message when an enqueue retry uses the same id", async () => {
298403 const registry = AtomRegistry.make();
299404 const manager = createThreadOutboxManager({
apps/mobile/src/state/thread-outbox.ts+5
......@@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis
2020 return threadOutboxManager.enqueue(message);
2121}
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
2328/** Rewrite a queued message; no-op (false) if it was removed in the meantime. */
2429export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> {
2530 return threadOutboxManager.update(message);
apps/mobile/src/state/use-selected-thread-requests.ts+10-4
......@@ -11,6 +11,7 @@ import {
1111 derivePendingApprovals,
1212 derivePendingUserInputs,
1313 setPendingUserInputCustomAnswer,
14 sortThreadActivities,
1415 type PendingUserInputDraftAnswer,
1516} from "../lib/threadActivity";
1617import { appAtomRegistry } from "./atom-registry";
......@@ -70,14 +71,19 @@ export function useSelectedThreadRequests() {
7071 null,
7172 );
7273
73 const activePendingApprovals = useMemo(
74 () => (selectedThread ? derivePendingApprovals(selectedThread.activities) : []),
74 // Sort once; both derivations expect the same lifecycle ordering.
75 const sortedActivities = useMemo(
76 () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []),
7577 [selectedThread],
7678 );
79 const activePendingApprovals = useMemo(
80 () => derivePendingApprovals(sortedActivities),
81 [sortedActivities],
82 );
7783 const activePendingApproval = activePendingApprovals[0] ?? null;
7884 const activePendingUserInputs = useMemo(
79 () => (selectedThread ? derivePendingUserInputs(selectedThread.activities) : []),
80 [selectedThread],
85 () => derivePendingUserInputs(sortedActivities),
86 [sortedActivities],
8187 );
8288 const activePendingUserInput = activePendingUserInputs[0] ?? null;
8389 const activePendingUserInputDrafts =
apps/mobile/src/state/use-thread-composer-state.ts+28-18
......@@ -30,6 +30,7 @@ import {
3030 composerDraftsAtom,
3131 ensureComposerDraftsLoaded,
3232 getComposerDraftSnapshot,
33 mergeComposerDraftContent,
3334 removeComposerDraftAttachment,
3435 setComposerDraftText,
3536 updateComposerDraftSettings,
......@@ -148,27 +149,36 @@ export function useThreadComposerState() {
148149
149150 const metadata = makeQueuedMessageMetadata();
150151 const messageId = MessageId.make(metadata.messageId);
151 try {
152 await enqueueThreadOutboxMessage({
153 environmentId: selectedThreadShell.environmentId,
154 threadId: selectedThreadShell.id,
155 messageId,
156 commandId: CommandId.make(metadata.commandId),
157 text,
158 attachments,
159 modelSelection: draft.modelSelection ?? thread.modelSelection,
160 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
161 interactionMode: draft.interactionMode ?? thread.interactionMode,
162 createdAt: metadata.createdAt,
163 });
164 clearComposerDraftContent(threadKey);
165 return messageId;
166 } catch (error) {
152 // Enqueue publishes the queued atom synchronously (the durable write
153 // happens behind it), so clearing the draft here gives send feedback on
154 // the tap frame instead of after file I/O. If the write fails the message
155 // is rolled out of the queue and the content is merged back into the
156 // draft, preserving anything typed since.
157 const enqueuePromise = enqueueThreadOutboxMessage({
158 environmentId: selectedThreadShell.environmentId,
159 threadId: selectedThreadShell.id,
160 messageId,
161 commandId: CommandId.make(metadata.commandId),
162 text,
163 attachments,
164 modelSelection: draft.modelSelection ?? thread.modelSelection,
165 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
166 interactionMode: draft.interactionMode ?? thread.interactionMode,
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);
167177 setPendingConnectionError(
168178 error instanceof Error ? error.message : "Failed to save the queued message.",
169179 );
170 return null;
171 }
180 });
181 return messageId;
172182 }, [selectedThreadDetail, selectedThreadShell]);
173183
174184 const onChangeDraftMessage = useCallback(
apps/mobile/src/state/use-thread-outbox-drain.ts+33-4
......@@ -21,7 +21,11 @@ import { toUploadChatImageAttachments } from "../lib/composerImages";
2121import { randomHex } from "../lib/uuid";
2222import { appAtomRegistry } from "./atom-registry";
2323import { useProjects, useThreadShells } from "./entities";
24import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";
24import {
25 confirmThreadOutboxMessageQueued,
26 ensureThreadOutboxLoaded,
27 removeThreadOutboxMessage,
28} from "./thread-outbox";
2529import {
2630 isQueuedThreadCreationSendable,
2731 modelSelectionsEqual,
......@@ -33,7 +37,7 @@ import {
3337 type QueuedThreadMessage,
3438 type ThreadOutboxCommandStage,
3539} from "./thread-outbox-model";
36import { threadEnvironment } from "./threads";
40import { environmentThreadShells, threadEnvironment } from "./threads";
3741import { useAtomCommand } from "./use-atom-command";
3842import {
3943 editingQueuedMessageIdsAtom,
......@@ -349,8 +353,32 @@ export function useThreadOutboxDrain(): void {
349353 return false;
350354 },
351355 );
352 const delivery =
353 deliveryAction === "remove"
356 // Enqueues publish optimistically before their durable write settles.
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"
354382 ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
355383 : creation !== undefined
356384 ? creationProjectCwd !== null
......@@ -359,6 +387,7 @@ export function useThreadOutboxDrain(): void {
359387 : thread !== undefined
360388 ? sendQueuedMessage(nextQueuedMessage, thread)
361389 : Promise.resolve(false);
390 });
362391 void delivery
363392 .then((sent) => {
364393 if (sent) {