From 4ff1a0bde7e596b2bcd0f5a20da9f2e1bbfb5160 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 19:50:49 +0200 Subject: [PATCH 1/5] fix(mobile): hand connected follow-ups to server queue --- apps/mobile/src/state/thread-outbox-model.ts | 6 ++++-- apps/mobile/src/state/thread-outbox.test.ts | 17 +++++++++-------- .../mobile/src/state/use-thread-outbox-drain.ts | 1 - 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be3872..57720970bb2 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -153,7 +153,6 @@ export function resolveThreadOutboxDeliveryAction(input: { readonly threadExists: boolean; readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; - readonly threadBusy: boolean; }): ThreadOutboxDeliveryAction { if (input.isCreation) { // A pending task creates its thread on delivery. If the thread already @@ -169,7 +168,10 @@ export function resolveThreadOutboxDeliveryAction(input: { if (!input.threadExists) { return input.shellStatus === "live" ? "remove" : "wait"; } - return input.environmentConnected && !input.threadBusy ? "send" : "wait"; + // Once connected, hand ownership to the server immediately. The server + // persists follow-ups that arrive during an active turn; this local outbox + // is only the offline/transport safety boundary. + return input.environmentConnected ? "send" : "wait"; } /** diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 6c665c432f4..db4ebd4ef7c 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -352,14 +352,13 @@ describe("thread outbox", () => { registry.dispose(); }); - it("only removes a missing-thread message after shell synchronization is live", () => { + it("keeps offline messages local and hands connected messages to the server", () => { expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: false, shellStatus: "synchronizing", environmentConnected: true, - threadBusy: false, }), ).toBe("wait"); expect( @@ -368,16 +367,22 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "live", environmentConnected: true, - threadBusy: false, }), ).toBe("remove"); + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: false, + threadExists: true, + shellStatus: "live", + environmentConnected: false, + }), + ).toBe("wait"); expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: false, }), ).toBe("send"); }); @@ -389,7 +394,6 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "cached", environmentConnected: false, - threadBusy: false, }), ).toBe("wait"); // Connected but not yet synchronized: a previously delivered creation may @@ -400,7 +404,6 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "synchronizing", environmentConnected: true, - threadBusy: false, }), ).toBe("wait"); expect( @@ -409,7 +412,6 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "live", environmentConnected: true, - threadBusy: false, }), ).toBe("send"); expect( @@ -418,7 +420,6 @@ describe("thread outbox", () => { threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: true, }), ).toBe("remove"); }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 3559fa140fe..9ebf3808f94 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -310,7 +310,6 @@ export function useThreadOutboxDrain(): void { threadExists: thread !== undefined, shellStatus, environmentConnected: environment?.connectionState === "connected", - threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", }); if (deliveryAction === "wait") { continue; From 2ecc328a1db0c874e4260d304e547c4f1556d826 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 19:56:46 +0200 Subject: [PATCH 2/5] feat(mobile): render optimistic pending messages --- .../src/features/threads/ThreadComposer.tsx | 16 +--- .../features/threads/ThreadDetailScreen.tsx | 2 - .../src/features/threads/ThreadFeed.tsx | 27 ++++++ .../features/threads/ThreadRouteScreen.tsx | 1 - apps/mobile/src/lib/threadActivity.ts | 4 + .../src/state/use-thread-composer-state.ts | 88 ++++++++++++++++--- 6 files changed, 110 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6bc60598c11..8dd4af33e04 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -103,7 +103,6 @@ export interface ThreadComposerProps { readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; - readonly queueCount: number; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; @@ -314,10 +313,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; - const sendLabel = - props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 - ? "Queue" - : "Send"; + const sendLabel = "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; @@ -991,16 +987,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - - {/* Queue count */} - {props.queueCount > 0 ? ( - - - {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send - automatically. - - - ) : null} ); })} + {previewAttachments.map((attachment) => ( + + ))} + {deliveryState === "sending" ? ( + <> + + + Sending + + + ) : deliveryState === "waiting" ? ( + + Waiting for connection + + ) : deliveryState === "queued" ? ( + + Queued on server + + ) : null} {timestampLabel} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index ca42e97dab8..9b5e2eeb06f 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -793,7 +793,6 @@ function ThreadRouteContent( environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} - selectedThreadQueueCount={composer.selectedThreadQueueCount} layoutVariant={layout.variant} usesAutomaticContentInsets={usesNativeHeaderGlass} onOpenConnectionEditor={handleOpenConnectionEditor} diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9be3b6c1a60..f32300838fd 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,8 @@ import { deriveResolvedUserInputTranscripts } from "@t3tools/shared/userInputTra import * as Arr from "effect/Array"; import * as Order from "effect/Order"; +import type { DraftComposerImageAttachment } from "./composerImages"; + export interface PendingApproval { readonly requestId: ApprovalRequestId; readonly requestKind: "command" | "file-read" | "file-change"; @@ -94,6 +96,8 @@ type RawThreadFeedEntry = readonly id: string; readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; + readonly deliveryState?: "waiting" | "sending" | "queued"; + readonly previewAttachments?: ReadonlyArray; } | { readonly type: "activity"; diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index be83fe3ea31..b17d46c7f8a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -41,7 +41,10 @@ import { updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; -import { setPendingConnectionError } from "../state/use-remote-environment-registry"; +import { + setPendingConnectionError, + useRemoteConnectionStatus, +} from "../state/use-remote-environment-registry"; import { orchestrationEnvironment } from "../state/orchestration"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; @@ -87,6 +90,7 @@ export function useThreadComposerState() { const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const { connectedEnvironments } = useRemoteConnectionStatus(); useEffect(() => { ensureComposerDraftsLoaded(); @@ -143,18 +147,83 @@ export function useThreadComposerState() { loadPage: loadOlderActivitiesPage, }); - const selectedThreadFeed = useMemo( - () => - selectedThreadDetail - ? buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities }) - : [], - [selectedThreadDetail, mergedActivities], - ); + const selectedThreadFeed = useMemo(() => { + if (!selectedThreadDetail) { + return []; + } + + const feed = buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities }); + const timelineMessageIds = new Set(selectedThreadDetail.messages.map((message) => message.id)); + const optimisticByMessageId = new Map< + MessageId, + (typeof feed)[number] & { readonly type: "message" } + >(); + + for (const message of selectedThreadDetail.queuedMessages) { + if (timelineMessageIds.has(message.messageId)) { + continue; + } + optimisticByMessageId.set(message.messageId, { + type: "message", + id: message.messageId, + createdAt: message.queuedAt, + deliveryState: "queued", + message: { + id: message.messageId, + role: "user", + text: message.text, + attachments: message.attachments, + turnId: null, + streaming: false, + createdAt: message.queuedAt, + updatedAt: message.queuedAt, + }, + }); + } + + // A local outbox entry wins over the matching server projection until the + // command acknowledgement removes it. That makes the bubble transition + // from "Sending" to "Queued" without rendering twice. + for (const message of selectedThreadQueuedMessages) { + if (timelineMessageIds.has(message.messageId)) { + continue; + } + optimisticByMessageId.set(message.messageId, { + type: "message", + id: message.messageId, + createdAt: message.createdAt, + deliveryState: connectedEnvironments.some( + (environment) => + environment.environmentId === message.environmentId && + environment.connectionState === "connected", + ) + ? "sending" + : "waiting", + previewAttachments: message.attachments, + message: { + id: message.messageId, + role: "user", + text: message.text, + attachments: [], + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }, + }); + } + + return [ + ...feed, + ...Array.from(optimisticByMessageId.values()).sort((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + ]; + }, [connectedEnvironments, selectedThreadDetail, selectedThreadQueuedMessages, mergedActivities]); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; - const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadDetail ?? selectedThreadShell; const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; @@ -348,7 +417,6 @@ export function useThreadComposerState() { return { selectedThreadFeed, - selectedThreadQueueCount, activeWorkStartedAt, draftMessage, draftAttachments, From 0d7e2a2c39796b35fd3ce77bde518d80856afe3c Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 20:17:05 +0200 Subject: [PATCH 3/5] feat(mobile): control queued follow-up messages --- .../features/threads/ThreadDetailScreen.tsx | 7 +++ .../src/features/threads/ThreadFeed.tsx | 49 ++++++++++++++++++- .../features/threads/ThreadRouteScreen.tsx | 2 + apps/mobile/src/lib/threadActivity.test.ts | 24 +++++++++ apps/mobile/src/lib/threadActivity.ts | 13 +++++ .../src/state/use-thread-composer-state.ts | 48 +++++++++++++++++- 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 1b32aded4b2..eec5959a1ac 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -79,6 +79,11 @@ export interface ThreadDetailScreenProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + readonly onSteerQueuedMessage: (messageId: MessageId) => Promise; + readonly onRemoveQueuedMessage: ( + messageId: MessageId, + source: "local" | "server", + ) => Promise; readonly onStartNewThread: () => void; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; @@ -381,6 +386,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread hasMoreOlder={props.hasMoreOlderActivities} loadingOlder={props.loadingOlderActivities} onLoadOlder={props.onLoadOlderActivities} + onSteerQueuedMessage={props.onSteerQueuedMessage} + onRemoveQueuedMessage={props.onRemoveQueuedMessage} /> ) : ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 9e8c97cb392..065d4b91208 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,7 +1,7 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; -import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { MessageId, type EnvironmentId, type ThreadId, type TurnId } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import { SymbolView } from "../../components/AppSymbol"; @@ -86,6 +86,7 @@ import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCo import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import { + deriveQueuedMessageControls, deriveThreadFeedPresentation, type ThreadFeedEntry, type ThreadFeedLatestTurn, @@ -146,6 +147,11 @@ export interface ThreadFeedProps { readonly hasMoreOlder?: boolean; readonly loadingOlder?: boolean; readonly onLoadOlder?: () => void; + readonly onSteerQueuedMessage: (messageId: MessageId) => Promise; + readonly onRemoveQueuedMessage: ( + messageId: MessageId, + source: "local" | "server", + ) => Promise; } function MessageAttachmentImage(props: { @@ -801,7 +807,10 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick< + ThreadFeedProps, + "environmentId" | "skills" | "onSteerQueuedMessage" | "onRemoveQueuedMessage" + > & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; readonly terminalAssistantMessageIds: ReadonlySet; @@ -869,6 +878,8 @@ function renderFeedEntry( const attachments = message.attachments ?? []; const previewAttachments = entry.previewAttachments ?? []; const deliveryState = entry.deliveryState; + const queueSource = entry.queueSource; + const queueControls = deriveQueuedMessageControls(deliveryState, queueSource); const hasReviewCommentContext = message.text.includes(" ) : null} + {queueControls.canSteer ? ( + void props.onSteerQueuedMessage(MessageId.make(message.id))} + className="min-h-8 justify-center rounded-full px-2" + > + Send now + + ) : null} + {queueControls.canRemove && queueSource ? ( + + void props.onRemoveQueuedMessage(MessageId.make(message.id), queueSource) + } + className="min-h-8 justify-center rounded-full px-2" + > + + {queueSource === "local" ? "Discard" : "Remove"} + + + ) : null} {timestampLabel} @@ -1749,6 +1790,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, + onSteerQueuedMessage: props.onSteerQueuedMessage, + onRemoveQueuedMessage: props.onRemoveQueuedMessage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1770,6 +1813,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, + props.onSteerQueuedMessage, + props.onRemoveQueuedMessage, onPressImage, onToggleTurnFold, onToggleWorkGroup, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 9b5e2eeb06f..1c1b6d903b9 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -803,6 +803,8 @@ function ThreadRouteContent( serverConfig={serverConfig} onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} + onSteerQueuedMessage={composer.onSteerQueuedMessage} + onRemoveQueuedMessage={composer.onRemoveQueuedMessage} onStartNewThread={handleStartNewThread} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index c191cd108f7..36c7f8d14df 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -13,11 +13,35 @@ import { import { buildThreadFeed, + deriveQueuedMessageControls, deriveThreadFeedPresentation, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; +describe("deriveQueuedMessageControls", () => { + it("allows steering or removing server-queued messages", () => { + expect(deriveQueuedMessageControls("queued", "server")).toEqual({ + canSteer: true, + canRemove: true, + }); + }); + + it("allows discarding an offline local-outbox message", () => { + expect(deriveQueuedMessageControls("waiting", "local")).toEqual({ + canSteer: false, + canRemove: true, + }); + }); + + it("does not claim an in-flight local send can still be cancelled", () => { + expect(deriveQueuedMessageControls("sending", "local")).toEqual({ + canSteer: false, + canRemove: false, + }); + }); +}); + function makeActivity( input: Partial & Pick, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f32300838fd..9487e94391b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -97,6 +97,7 @@ type RawThreadFeedEntry = readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; readonly deliveryState?: "waiting" | "sending" | "queued"; + readonly queueSource?: "local" | "server"; readonly previewAttachments?: ReadonlyArray; } | { @@ -140,6 +141,18 @@ export type ThreadFeedEntry = readonly expanded: boolean; }; +export function deriveQueuedMessageControls( + deliveryState: "waiting" | "sending" | "queued" | undefined, + queueSource: "local" | "server" | undefined, +): { readonly canSteer: boolean; readonly canRemove: boolean } { + return { + canSteer: deliveryState === "queued" && queueSource === "server", + canRemove: + (deliveryState === "queued" && queueSource === "server") || + (deliveryState === "waiting" && queueSource === "local"), + }; +} + export type ThreadFeedLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt" diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b17d46c7f8a..1a4467cae03 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -49,7 +49,8 @@ import { orchestrationEnvironment } from "../state/orchestration"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { useAtomCommand } from "./use-atom-command"; -import { enqueueThreadOutboxMessage } from "./thread-outbox"; +import { threadEnvironment } from "./threads"; +import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; const EMPTY_ACTIVITIES: ReadonlyArray = []; @@ -110,6 +111,12 @@ export function useThreadComposerState() { const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, { reportFailure: false, }); + const steerQueuedMessage = useAtomCommand(threadEnvironment.steerQueuedMessage, { + label: "steer queued message", + }); + const removeServerQueuedMessage = useAtomCommand(threadEnvironment.removeQueuedMessage, { + label: "remove queued message", + }); const selectedEnvironmentIdForActivities = selectedThreadShell?.environmentId ?? null; const selectedThreadIdForActivities = selectedThreadShell?.id ?? null; const loadOlderActivitiesPage = useCallback( @@ -168,6 +175,7 @@ export function useThreadComposerState() { id: message.messageId, createdAt: message.queuedAt, deliveryState: "queued", + queueSource: "server", message: { id: message.messageId, role: "user", @@ -192,6 +200,7 @@ export function useThreadComposerState() { type: "message", id: message.messageId, createdAt: message.createdAt, + queueSource: "local", deliveryState: connectedEnvironments.some( (environment) => environment.environmentId === message.environmentId && @@ -297,6 +306,41 @@ export function useThreadComposerState() { } }, [selectedThreadDetail, selectedThreadShell]); + const onSteerQueuedMessage = useCallback( + async (messageId: MessageId) => { + if (!selectedThreadShell) { + return; + } + await steerQueuedMessage({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, messageId }, + }); + }, + [selectedThreadShell, steerQueuedMessage], + ); + + const onRemoveQueuedMessage = useCallback( + async (messageId: MessageId, source: "local" | "server") => { + if (!selectedThreadShell) { + return; + } + if (source === "local") { + const message = selectedThreadQueuedMessages.find( + (candidate) => candidate.messageId === messageId, + ); + if (message) { + await removeThreadOutboxMessage(message); + } + return; + } + await removeServerQueuedMessage({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, messageId }, + }); + }, + [removeServerQueuedMessage, selectedThreadQueuedMessages, selectedThreadShell], + ); + const onChangeDraftMessage = useCallback( (value: string) => { if (!selectedThreadShell) { @@ -437,6 +481,8 @@ export function useThreadComposerState() { onNativePasteImages, onRemoveDraftImage, onSendMessage, + onSteerQueuedMessage, + onRemoveQueuedMessage, onUpdateModelSelection, onUpdateRuntimeMode, onUpdateInteractionMode, From 397d9b26bf2b2383a15849497b2a03d329bb7334 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 20:21:17 +0200 Subject: [PATCH 4/5] fix(mobile): make send optimistic on the outbox critical path Update the in-memory outbox before disk I/O, clear the draft immediately, and paint local pending bubbles even while thread detail is still loading so send no longer waits on durability or snapshot hydration. --- .../features/threads/ThreadDetailScreen.tsx | 12 +--- .../mobile/src/state/thread-outbox-manager.ts | 22 +++++-- apps/mobile/src/state/thread-outbox.test.ts | 63 +++++++++++++++++++ .../src/state/use-thread-composer-state.ts | 61 ++++++++++-------- 4 files changed, 118 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index eec5959a1ac..6524e2b6d1d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -251,10 +251,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, [freeze, selectedThreadKey]); useEffect(() => { + // Anchor as soon as the target row exists in the feed — including local + // outbox "Sending" bubbles painted before thread detail has finished loading. if ( anchorMessageId === null || lastScrolledAnchorMessageIdRef.current === anchorMessageId || - contentPresentationKind !== "ready" || !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) ) { return; @@ -293,14 +294,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }); }); return () => cancelAnimationFrame(frame); - }, [ - anchorMessageId, - freeze, - contentPresentationKind, - selectedThreadFeed, - scrollMessageToEnd, - selectedThreadKey, - ]); + }, [anchorMessageId, freeze, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey]); const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index 19f89d13c51..bfa25549bdd 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -88,11 +88,24 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { return loadPromise; }; - const enqueue = (message: QueuedThreadMessage): Promise => - serialize(async () => { + const enqueue = (message: QueuedThreadMessage): Promise => { + // Paint the optimistic bubble immediately. Disk durability trails the + // in-memory queue so send UX never waits on filesystem latency. + setMessages([ + ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + message, + ]); + return serialize(async () => { + // Dropped while the write was queued (e.g. user discarded / delivered). + if (!currentMessages().some((candidate) => candidate.messageId === message.messageId)) { + return; + } try { await options.storage.write(message); } catch (cause) { + setMessages( + currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + ); throw new ThreadOutboxManagerError({ operation: "enqueue", environmentId: message.environmentId, @@ -101,11 +114,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }); } - setMessages([ - ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), - 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 diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index db4ebd4ef7c..6c1ab440a2d 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -247,6 +247,69 @@ describe("thread outbox", () => { registry.dispose(); }); + it("surfaces enqueue in memory before the durable write resolves", async () => { + const registry = AtomRegistry.make(); + let releaseWrite: (() => void) | null = null; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + const storage: ThreadOutboxStorage = { + load: async () => [], + write: async () => { + await writeGate; + }, + remove: async () => undefined, + }; + const manager = createThreadOutboxManager({ registry, storage }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + const enqueuePromise = manager.enqueue(message); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [message], + }); + + releaseWrite?.(); + await enqueuePromise; + registry.dispose(); + }); + + it("rolls back the in-memory queue when the durable write fails", async () => { + const registry = AtomRegistry.make(); + const writeCause = new Error("write failed"); + const storage: ThreadOutboxStorage = { + load: async () => [], + write: async () => { + throw writeCause; + }, + remove: async () => undefined, + }; + const manager = createThreadOutboxManager({ registry, storage }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + const enqueuePromise = manager.enqueue(message); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [message], + }); + await expect(enqueuePromise).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 atom state aligned with durable writes and removals", async () => { const registry = AtomRegistry.make(); const stored = new Map(); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 1a4467cae03..e8eb8d85adc 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -155,18 +155,20 @@ export function useThreadComposerState() { }); const selectedThreadFeed = useMemo(() => { - if (!selectedThreadDetail) { - return []; - } - - const feed = buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities }); - const timelineMessageIds = new Set(selectedThreadDetail.messages.map((message) => message.id)); + // Local outbox rows must still paint while detail is hydrating — otherwise + // send during "Loading messages…" produces no bubble until the snapshot lands. + const feed = selectedThreadDetail + ? buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities }) + : []; + const timelineMessageIds = new Set( + selectedThreadDetail?.messages.map((message) => message.id) ?? [], + ); const optimisticByMessageId = new Map< MessageId, (typeof feed)[number] & { readonly type: "message" } >(); - for (const message of selectedThreadDetail.queuedMessages) { + for (const message of selectedThreadDetail?.queuedMessages ?? []) { if (timelineMessageIds.has(message.messageId)) { continue; } @@ -222,6 +224,10 @@ export function useThreadComposerState() { }); } + if (optimisticByMessageId.size === 0) { + return feed; + } + return [ ...feed, ...Array.from(optimisticByMessageId.values()).sort((left, right) => @@ -283,27 +289,32 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); - try { - await enqueueThreadOutboxMessage({ - environmentId: selectedThreadShell.environmentId, - threadId: selectedThreadShell.id, - messageId, - commandId: CommandId.make(metadata.commandId), - text, - attachments, - modelSelection: draft.modelSelection ?? thread.modelSelection, - runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: draft.interactionMode ?? thread.interactionMode, - createdAt: metadata.createdAt, - }); - clearComposerDraftContent(threadKey); - return messageId; - } catch (error) { + // Enqueue updates the in-memory outbox synchronously so the feed can paint + // "Sending" before disk I/O finishes. Clear the draft in the same turn and + // return immediately — durability trails off the critical path. + void enqueueThreadOutboxMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId, + commandId: CommandId.make(metadata.commandId), + text, + attachments, + modelSelection: draft.modelSelection ?? thread.modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: draft.interactionMode ?? thread.interactionMode, + createdAt: metadata.createdAt, + }).catch((error) => { + // Memory outbox already rolled back on write failure; restore the draft. + setComposerDraftText(threadKey, draft.text); + if (draft.attachments.length > 0) { + appendComposerDraftAttachments(threadKey, draft.attachments); + } setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); - return null; - } + }); + clearComposerDraftContent(threadKey); + return messageId; }, [selectedThreadDetail, selectedThreadShell]); const onSteerQueuedMessage = useCallback( From 1ce8c76162f017c86f1f75c63c23dc17e74d4668 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 20:21:46 +0200 Subject: [PATCH 5/5] fix(web): reconcile queued send path after candidate replay --- apps/mobile/src/state/thread-outbox.test.ts | 4 +- .../web/src/components/ChatView.logic.test.ts | 1 + apps/web/src/components/ChatView.tsx | 146 +++++++++--------- 3 files changed, 77 insertions(+), 74 deletions(-) diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 6c1ab440a2d..1782a71b369 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -249,7 +249,7 @@ describe("thread outbox", () => { it("surfaces enqueue in memory before the durable write resolves", async () => { const registry = AtomRegistry.make(); - let releaseWrite: (() => void) | null = null; + let releaseWrite!: () => void; const writeGate = new Promise((resolve) => { releaseWrite = resolve; }); @@ -271,7 +271,7 @@ describe("thread outbox", () => { "environment-1:thread-1": [message], }); - releaseWrite?.(); + releaseWrite(); await enqueuePromise; registry.dispose(); }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 49ae73007d2..e1156b9cd32 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -717,6 +717,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "running", latestTurn: runningTurn, latestUserMessageId: localDispatch.latestUserMessageId, + projectedMessageIds: new Set(), session: runningSession, hasPendingApproval: false, hasPendingUserInput: false, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3f1566345e2..dfa8dd059be 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4844,34 +4844,44 @@ function ChatViewContent(props: ChatViewProps) { preparingWorktree: Boolean(baseBranchForWorktree), messageId: messageIdForSend, }); + let turnStartSucceeded = false; - const composerImagesSnapshot = [...composerImages]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const composerElementContextsSnapshot = [...composerElementContexts]; - const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; - const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; - const messageTextWithContexts = appendElementContextsToPrompt( - appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), - composerElementContextsSnapshot, - ); - const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( - (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), - messageTextWithContexts, - ); - const messageTextForSend = appendReviewCommentsToPrompt( - messageTextWithPreviewAnnotations, - composerReviewCommentsSnapshot, - ); - const messageCreatedAt = new Date().toISOString(); - const outgoingMessageText = formatOutgoingPrompt({ - provider: ctxSelectedProvider, - model: ctxSelectedModel, - models: ctxSelectedProviderModels, - effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, - }); - const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ + try { + const composerImagesSnapshot = [...composerImages]; + const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; + const composerElementContextsSnapshot = [...composerElementContexts]; + const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + const messageTextWithContexts = appendElementContextsToPrompt( + appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + composerElementContextsSnapshot, + ); + const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( + (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), + messageTextWithContexts, + ); + const messageTextForSend = appendReviewCommentsToPrompt( + messageTextWithPreviewAnnotations, + composerReviewCommentsSnapshot, + ); + const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); + const turnAttachmentsPromise = Promise.all( + composerImagesSnapshot.map(async (image) => ({ + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ); + const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, id: image.id, name: image.name, @@ -4992,51 +5002,43 @@ function ChatViewContent(props: ChatViewProps) { failure = turnAttachmentsResult; } - const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise); - if (failure === null && turnAttachmentsResult._tag === "Failure") { - failure = turnAttachmentsResult; - } - - let turnStartSucceeded = false; - if (failure === null && turnAttachmentsResult._tag === "Success") { - const bootstrap = - isLocalDraftThread || baseBranchForWorktree - ? { - ...(isLocalDraftThread - ? { - createThread: { - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, - createdAt: activeThread.createdAt, - }, - } - : {}), - ...(baseBranchForWorktree - ? { - prepareWorktree: { - projectCwd: activeProject.workspaceRoot, - baseBranch: baseBranchForWorktree, - ...(reuseBaseBranch - ? { reuseBaseBranch: true } - : { - branch: buildTemporaryWorktreeBranchName(randomHex), - ...(startFromOrigin ? { startFromOrigin: true } : {}), - }), - }, - runSetupScript: true, - } - : {}), - } - : undefined; - beginLocalDispatch({ preparingWorktree: false, messageId: messageIdForSend }); - const startResult = await startThreadTurn({ - environmentId, - input: { + if (failure === null && turnAttachmentsResult._tag === "Success") { + const bootstrap = + isLocalDraftThread || baseBranchForWorktree + ? { + ...(isLocalDraftThread + ? { + createThread: { + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + } + : {}), + ...(baseBranchForWorktree + ? { + prepareWorktree: { + projectCwd: activeProject.workspaceRoot, + baseBranch: baseBranchForWorktree, + ...(reuseBaseBranch + ? { reuseBaseBranch: true } + : { + branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), + }), + }, + runSetupScript: true, + } + : {}), + } + : undefined; + const queuedTurnInput = { + commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend,