Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,15 @@ function workspacePathFromState(state: NavigationState): string {
return path.startsWith("/") ? path : `/${path}`;
}

// The drain hook subscribes to the outbox, all thread shells, projects, and
// connection statuses. Hosting it in a null-rendering leaf keeps those
// updates from re-rendering RootStackLayout (and with it every screen) on
// each enqueue, shell change, or reconnect.
function ThreadOutboxDrainWorker() {
useThreadOutboxDrain();
return null;
}

function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
Expand All @@ -292,7 +301,6 @@ function RootStackLayout(props: {
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
useAgentNotificationNavigation();
useThreadOutboxDrain();
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
useConnectOnboardingNavigation();
// Launcher app shortcuts: routes shortcut taps and tracks opened threads.
Expand Down Expand Up @@ -320,6 +328,7 @@ function RootStackLayout(props: {

return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
Expand Down
28 changes: 21 additions & 7 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,26 @@ export function HomeScreen(props: HomeScreenProps) {
);
const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);

// FlatList treats a changed extraData identity as "re-render every visible
// row", so an inline object literal would invalidate all rows on every
// HomeScreen render.
const v2ExtraData = useMemo(
() => ({
projectByKey,
projectCwdByKey,
projectTitleByProjectKey: v2ProjectTitleByProjectKey,
serverConfigs,
savedConnectionsById: props.savedConnectionsById,
}),
[
projectByKey,
projectCwdByKey,
props.savedConnectionsById,
serverConfigs,
v2ProjectTitleByProjectKey,
],
);

const extraData = useMemo(
() => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),
[props.savedConnectionsById, projectCwdByKey],
Expand Down Expand Up @@ -900,13 +920,7 @@ export function HomeScreen(props: HomeScreenProps) {
data={threadListV2Items}
renderItem={renderV2Item}
keyExtractor={v2KeyExtractor}
extraData={{
projectByKey,
projectCwdByKey,
projectTitleByProjectKey: v2ProjectTitleByProjectKey,
serverConfigs,
savedConnectionsById: props.savedConnectionsById,
}}
extraData={v2ExtraData}
ListHeaderComponent={v2ListHeader}
ListFooterComponent={
threadListV2Layout.hiddenSettledCount > 0 ? (
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/shortcuts/useAppShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ function useShortcutNavigation(): void {
}

function useRecentThreadShortcutSync(state: NavigationState): void {
const threadRef = useMemo(() => activeThreadRef(state), [state]);
// Launcher shortcuts are Android-only. A null ref on iOS keeps this hook
// (mounted in the root stack layout) from subscribing the root to the
// active thread's shell, which would re-render every screen on each
// title/status/session change.
const threadRef = useMemo(
() => (Platform.OS === "android" ? activeThreadRef(state) : null),
[state],
);
const threadShell = useThreadShell(threadRef);
// null until the persisted list loads; recording waits on it so the first
// thread opened after a cold start cannot clobber older entries.
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,11 @@ export function NewTaskDraftScreen(props: {
const promptEditor = (
<ComposerEditor
ref={promptInputRef}
autoFocus={!isAndroid}
// Native autoFocus fires becomeFirstResponder in didMoveToWindow, which
// forces the iOS keyboard bring-up during the formSheet present
// animation and stalls it. The runAfterInteractions effect above focuses
// the editor once the transition settles instead.
autoFocus={false}
editable={!isIncomingShareTransferPending}
multiline
scrollEnabled={isExpanded}
Expand Down
14 changes: 8 additions & 6 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,14 +518,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
if (inFlightThreadIdsRef.current.has(threadKey)) return;
inFlightThreadIdsRef.current.add(threadKey);
// Sending a prompt starts agent work: arm the lock-screen card now, while
// the app is foregrounded and the activity token can be registered.
armAgentAwarenessLiveActivityForLocalWork({
threadTitle: props.selectedThread.title,
projectTitle: props.environmentLabel ?? "T3 Code",
});
try {
await onSendMessage();
// Sending a prompt starts agent work: arm the lock-screen card while the
// app is foregrounded and the activity token can be registered. Armed
// after the send so its preference read and native Activity start don't
// contend with the queued-message feedback on the tap frame.
armAgentAwarenessLiveActivityForLocalWork({
threadTitle: props.selectedThread.title,
projectTitle: props.environmentLabel ?? "T3 Code",
});
} finally {
inFlightThreadIdsRef.current.delete(threadKey);
}
Expand Down
40 changes: 27 additions & 13 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,11 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean {

function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {
const grouped: ThreadFeedEntry[] = [];
// Mutable backing array for the trailing group so appending an activity is
// O(1) instead of re-copying the group (which made this loop quadratic on
// long tool runs). The array is only mutated while it is the trailing group.
let openGroupActivities: ThreadFeedActivity[] | null = null;
let openGroupTurnId: TurnId | null = null;

for (const entry of entries) {
// Skip empty messages so they don't break activity grouping.
Expand All @@ -966,24 +971,23 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th

if (entry.type !== "activity") {
grouped.push(entry);
openGroupActivities = null;
continue;
}

const previous = grouped.at(-1);
if (previous?.type === "activity-group" && previous.turnId === entry.turnId) {
grouped[grouped.length - 1] = {
...previous,
activities: [...previous.activities, entry.activity],
};
if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {
openGroupActivities.push(entry.activity);
continue;
}

openGroupActivities = [entry.activity];
openGroupTurnId = entry.turnId;
grouped.push({
type: "activity-group",
id: entry.id,
createdAt: entry.createdAt,
turnId: entry.turnId,
activities: [entry.activity],
activities: openGroupActivities,
});
}

Expand Down Expand Up @@ -1225,13 +1229,24 @@ function appendPresentedFeedEntry(
});
}

export function derivePendingApprovals(
/**
* Sorts activities into lifecycle order. `derivePendingApprovals` and
* `derivePendingUserInputs` both expect this ordering; sorting once and
* passing the result to both avoids re-sorting the full activity history
* per derivation.
*/
export function sortThreadActivities(
activities: ReadonlyArray<OrchestrationThreadActivity>,
): ReadonlyArray<OrchestrationThreadActivity> {
return Arr.sort(activities, activityOrder);
}

export function derivePendingApprovals(
sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
): PendingApproval[] {
const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
const ordered = Arr.sort(activities, activityOrder);

for (const activity of ordered) {
for (const activity of sortedActivities) {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
Expand Down Expand Up @@ -1273,12 +1288,11 @@ export function derivePendingApprovals(
}

export function derivePendingUserInputs(
activities: ReadonlyArray<OrchestrationThreadActivity>,
sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
): PendingUserInput[] {
const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();
const ordered = Arr.sort(activities, activityOrder);

for (const activity of ordered) {
for (const activity of sortedActivities) {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
Expand Down
29 changes: 23 additions & 6 deletions apps/mobile/src/state/thread-outbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,23 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
return loadPromise;
};

const enqueue = (message: QueuedThreadMessage): Promise<void> =>
serialize(async () => {
// The queued atom drives the composer's immediate "queued" feedback, so it
// is published synchronously; the durable write happens behind it and rolls
// the message back out if it fails (durability only matters for crash
// recovery, not for the in-session queue).
const enqueue = (message: QueuedThreadMessage): Promise<void> => {
setMessages([
...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
message,
]);
return serialize(async () => {
try {
await options.storage.write(message);
} catch (cause) {
// Roll back by reference, not messageId: a retry enqueue with the same
// id may have optimistically replaced this attempt while the write was
// in flight, and its entry must survive this attempt's failure.
setMessages(currentMessages().filter((candidate) => candidate !== message));
throw new ThreadOutboxManagerError({
operation: "enqueue",
environmentId: message.environmentId,
Expand All @@ -101,11 +113,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
cause,
});
}
setMessages([
...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
message,
]);
});
};

// Resolves once all pending mutations (including any in-flight enqueue
// write) have settled, reporting whether the message is still queued. The
// drain awaits this before dispatching so a message whose durable write
// later fails can never have been delivered first.
const confirmQueued = (message: QueuedThreadMessage): Promise<boolean> =>
serialize(async () => currentMessages().some((candidate) => candidate === message));

// Rewrites an already-queued message. A no-op when the message has been
// removed in the meantime (e.g. deleted or delivered), so a trailing editor
Expand Down Expand Up @@ -204,6 +220,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
serialize,
load,
enqueue,
confirmQueued,
update,
remove,
clearEnvironment,
Expand Down
105 changes: 105 additions & 0 deletions apps/mobile/src/state/thread-outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,111 @@ describe("thread outbox", () => {
registry.dispose();
});

it("publishes an enqueued message before the durable write resolves", async () => {
const registry = AtomRegistry.make();
let releaseWrite!: () => void;
const writeBlocked = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
const manager = createThreadOutboxManager({
registry,
storage: {
load: async () => [],
write: async () => writeBlocked,
remove: async () => undefined,
},
});
const message = queuedMessage({
messageId: "message-1",
createdAt: "2026-06-08T10:00:01.000Z",
});

const enqueueing = manager.enqueue(message);
expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
"environment-1:thread-1": [message],
});

releaseWrite();
await enqueueing;
expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
"environment-1:thread-1": [message],
});
registry.dispose();
});

it("rolls an enqueued message back out when the durable write fails", async () => {
const registry = AtomRegistry.make();
const writeCause = new Error("disk full");
const manager = createThreadOutboxManager({
registry,
storage: {
load: async () => [],
write: async () => {
throw writeCause;
},
remove: async () => undefined,
},
});
const message = queuedMessage({
messageId: "message-1",
createdAt: "2026-06-08T10:00:01.000Z",
});

await expect(manager.enqueue(message)).rejects.toEqual(
new ThreadOutboxManagerError({
operation: "enqueue",
environmentId: message.environmentId,
threadId: message.threadId,
messageId: message.messageId,
cause: writeCause,
}),
);
expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({});
registry.dispose();
});

it("keeps a same-id retry queued when the first attempt's write fails", async () => {
const registry = AtomRegistry.make();
let failNextWrite = true;
let releaseFirstWrite!: () => void;
const firstWriteBlocked = new Promise<void>((resolve) => {
releaseFirstWrite = resolve;
});
const manager = createThreadOutboxManager({
registry,
storage: {
load: async () => [],
write: async () => {
if (failNextWrite) {
failNextWrite = false;
await firstWriteBlocked;
throw new Error("disk full");
}
},
remove: async () => undefined,
},
});
const message = queuedMessage({
messageId: "message-1",
createdAt: "2026-06-08T10:00:01.000Z",
});
const retried = { ...message, text: "retried" };

const first = manager.enqueue(message);
const second = manager.enqueue(retried);
releaseFirstWrite();
await expect(first).rejects.toBeInstanceOf(ThreadOutboxManagerError);
await second;

// The failed first attempt must not roll back the retry that replaced it.
expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
"environment-1:thread-1": [retried],
});
await expect(manager.confirmQueued(retried)).resolves.toBe(true);
await expect(manager.confirmQueued(message)).resolves.toBe(false);
registry.dispose();
});

it("replaces an existing message when an enqueue retry uses the same id", async () => {
const registry = AtomRegistry.make();
const manager = createThreadOutboxManager({
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/state/thread-outbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis
return threadOutboxManager.enqueue(message);
}

/** Waits for pending writes to settle; false if the message was rolled back. */
export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage): Promise<boolean> {
return threadOutboxManager.confirmQueued(message);
}

/** Rewrite a queued message; no-op (false) if it was removed in the meantime. */
export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> {
return threadOutboxManager.update(message);
Expand Down
Loading
Loading