From 6453ac5d3cc49ce4fedf77ea799132af70012e9f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:25:26 -0700 Subject: [PATCH 1/6] feat(mobile): open the thread screen as soon as a new task is submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submitting a new task kept the user on the new-task sheet until the server created the thread, which for worktree mode meant staring at a locked composer for the whole checkout. Web transforms the draft into the thread immediately and reports setup progress there. Mobile now routes every submission through the outbox and replaces to the Thread route right away. Until the server's shell arrives the screen renders a stand-in built from the queued creation: the prompt as the first message, a "Setting up worktree…" pill, and a composer that blocks sending. The outbox drain records the creation outcome; a rejected creation shows a "Could not start task" card whose Edit task action reopens the restored project draft. The thread detail subscription is held back until delivery so the not-found retry loop never runs during setup. Made with Claude Fable 5 in T3 Code (Claude Code harness). Co-Authored-By: Claude Fable 5 --- .../features/threads/NewTaskDraftScreen.tsx | 235 ++++++------------ .../src/features/threads/ThreadComposer.tsx | 12 +- .../threads/ThreadCreationFailedCard.tsx | 36 +++ .../features/threads/ThreadDetailScreen.tsx | 50 +++- .../features/threads/ThreadRouteScreen.tsx | 65 ++++- .../threads/floating-working-control.tsx | 18 ++ .../threads/floating-working-status.ts | 3 + .../threads/new-task-flow-provider.tsx | 28 ++- .../features/threads/use-project-actions.ts | 168 ------------- .../src/state/pending-thread-creation.test.ts | 102 ++++++++ .../src/state/pending-thread-creation.ts | 108 ++++++++ .../src/state/use-thread-composer-state.ts | 39 ++- apps/mobile/src/state/use-thread-detail.ts | 10 +- .../src/state/use-thread-outbox-drain.test.ts | 41 +++ .../src/state/use-thread-outbox-drain.ts | 32 +++ apps/mobile/src/state/use-thread-selection.ts | 56 ++++- 16 files changed, 635 insertions(+), 368 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadCreationFailedCard.tsx delete mode 100644 apps/mobile/src/features/threads/use-project-actions.ts create mode 100644 apps/mobile/src/state/pending-thread-creation.test.ts create mode 100644 apps/mobile/src/state/pending-thread-creation.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7067f05fd010..2ae997c7c611 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -20,10 +20,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useFontFamily } from "../../lib/useFontFamily"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, resolveEnvironmentMachineKind, @@ -52,7 +48,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; -import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { @@ -78,7 +73,6 @@ import { import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, - flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, @@ -91,15 +85,12 @@ import { isModelSelectionUnavailable, resolveSelectableModelSelection, } from "../../lib/modelOptions"; -import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; -import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; -import { useCreateProjectThread } from "./use-project-actions"; import { resolveDraftProjectSelection } from "./new-task-project-selection"; import { resolveNewTaskBranchLabel, @@ -158,7 +149,6 @@ export function NewTaskDraftScreen(props: { readonly incomingShareId?: string; }) { const projects = useProjects(); - const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const { @@ -942,16 +932,6 @@ export function NewTaskDraftScreen(props: { ) ?? flow.selectedModel; const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; - const selectedWorktreePath = - draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; - const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; - const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; - const interactionMode = resolveProviderInteractionMode( - selectedEnvironmentServerConfig?.providers.find( - (provider) => provider.instanceId === modelSelection?.instanceId, - ), - flow.planModeEnabled ? (draft.interactionMode ?? flow.interactionMode) : "default", - ); const initialMessageText = draft.text.trim(); if ( @@ -1000,121 +980,69 @@ export function NewTaskDraftScreen(props: { const editingPendingTask = flow.editingPendingTask; - if (queuesInsteadOfStarting) { - // Offline, or an attachment is still uploading: park the task in the - // outbox and let the drain send it once the environment is reachable - // and the bytes are on the server. Editing an existing pending task - // re-queues it under its original identifiers. - const metadata = editingPendingTask - ? { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - } - : makeTurnCommandMetadata(); - const message = flow.buildPendingTaskMessage(metadata); - if (!message) { - return; - } - flow.setSubmitting(true); - try { - await enqueueThreadOutboxMessage(message); - } catch (error) { - Alert.alert( - "Could not queue task", - error instanceof Error ? error.message : "The task could not be saved to the outbox.", - ); - return; - } finally { - flow.setSubmitting(false); - } - if (editingPendingTask) { - flow.finishEditingPendingTask(); - } else { - // Drop draft-local model/workspace selections with the content. The - // next task re-resolves project defaults before sticky app defaults. - clearComposerDraftContent(draftKey, { - clearModelSelection: true, - clearWorkspaceSelection: true, - }); - } - setSubmitNavigationAction(CommonActions.goBack()); + // Every submission goes through the outbox: the drain uploads the + // attachments and delivers the creation, retrying across reconnects. + // When it can send now the thread screen opens immediately with the + // queued prompt and reports setup progress there, like the web draft + // does. Offline, or with uploads still in flight, the task stays a + // pending task and the sheet closes. Editing an existing pending task + // re-queues it under its original identifiers. + const metadata = editingPendingTask + ? { + threadId: editingPendingTask.threadId, + commandId: editingPendingTask.commandId, + messageId: editingPendingTask.messageId, + createdAt: editingPendingTask.createdAt, + } + : makeTurnCommandMetadata(); + const message = flow.buildPendingTaskMessage(metadata, { + // A task that waits in the outbox cannot know the checkout it will + // drain against; one that sends now runs against the live one. + currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName, + }); + if (!message) { return; } - + if (!queuesInsteadOfStarting) { + // Arm the lock-screen card before the async thread creation: backgrounding + // the app right after tapping submit would otherwise reject the foreground + // -only Activity start. If creation fails, the token registration's replay + // finds no work and ends the card within seconds. + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: selectedProject.environmentId, + threadTitle: deriveThreadTitleFromPrompt(initialMessageText), + projectTitle: selectedProject.title, + }); + } flow.setSubmitting(true); - // Arm the lock-screen card before the async thread creation: backgrounding - // the app right after tapping submit would otherwise reject the foreground - // -only Activity start. If creation fails, the token registration's replay - // finds no work and ends the card within seconds. - armAgentAwarenessLiveActivityForLocalWork({ - environmentId: selectedProject.environmentId, - threadTitle: deriveThreadTitleFromPrompt(initialMessageText), - projectTitle: selectedProject.title, - }); - const creationBranch = resolveProjectThreadCreationBranch({ - workspaceMode, - selectedBranch: selectedBranchName, - currentCheckoutBranch: flow.currentCheckoutBranchName, - }); - const result = await createProjectThread({ - project: selectedProject, - modelSelection, - envMode: workspaceMode, - branch: creationBranch, - worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, - startFromOrigin, - runtimeMode, - interactionMode, - initialMessageText, - initialAttachments: draft.attachments, - onAttachmentsUploaded: async (attachments) => { - flow.replaceAttachments(attachments); - await flushComposerDrafts(); - }, - ...(editingPendingTask - ? { - turnMetadata: { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - }, - } - : {}), - }); - flow.setSubmitting(false); - - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Alert.alert( - "Could not start task", - error instanceof Error ? error.message : "The task could not be started.", - ); - } + try { + await enqueueThreadOutboxMessage(message); + } catch (error) { + Alert.alert( + "Could not queue task", + error instanceof Error ? error.message : "The task could not be saved to the outbox.", + ); return; + } finally { + flow.setSubmitting(false); } - if (editingPendingTask) { - try { - await removeThreadOutboxMessage(editingPendingTask); - } catch (error) { - console.warn("[new-task] failed to remove delivered pending task", error); - } flow.finishEditingPendingTask(); } else { + // Drop draft-local model/workspace selections with the content. The + // next task re-resolves project defaults before sticky app defaults. clearComposerDraftContent(draftKey, { clearModelSelection: true, clearWorkspaceSelection: true, }); } setSubmitNavigationAction( - StackActions.replace("Thread", { - environmentId: String(result.value.environmentId), - threadId: String(result.value.threadId), - }), + queuesInsteadOfStarting + ? CommonActions.goBack() + : StackActions.replace("Thread", { + environmentId: String(message.environmentId), + threadId: String(message.threadId), + }), ); } @@ -1269,50 +1197,31 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( - {flow.submitting && !queuesInsteadOfStarting && flow.workspaceMode === "worktree" ? ( - - - - ) : ( - <> - - } - label={workspaceLabel} - maxWidth={flow.workspaceMode === "local" ? 220 : 148} - onPress={() => - flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local") - } - showChevron={false} + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} + showChevron={false} + /> - openContextPicker("NewTaskBranch")} - /> - - )} + openContextPicker("NewTaskBranch")} + /> ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 57d2740b53d3..684b69c843a5 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -120,6 +120,8 @@ export interface ThreadComposerProps { readonly queueCount: number; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; + /** Why sending is blocked right now (shown as the send button's label), or null. */ + readonly sendBlockedReason?: string | null; readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftMedia: () => Promise; @@ -344,11 +346,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer serverConfig: props.serverConfig, states: uploadStates, }); + const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; const canSend = - hasContent && - !voiceInput.blocksSubmission && - attachmentBlockReason === null && - !modelUnavailable; + hasContent && !voiceInput.blocksSubmission && sendBlockedReason === null && !modelUnavailable; // Keep the feed inset aligned with the card or compact dictation strip. useEffect(() => { @@ -701,7 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : ( ) : voicePresentation.showsSend ? ( void; +}) { + return ( + + + Could not start task + + + {props.reason} + + + Your prompt was kept in the project draft. + + + + Edit task + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 57a93e0cff22..3310edc2b3e5 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -81,6 +81,7 @@ import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; +import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, @@ -113,6 +114,15 @@ export interface ThreadDetailScreenProps { readonly selectedThreadFeed: ReadonlyArray; readonly activeWorkStartedAt: string | null; readonly isCompacting: boolean; + /** + * The server has not created this thread yet. "preparing" runs while the + * queued creation is delivered (a worktree may be checking out); "failed" + * is a rejected creation whose content went back to the project draft. + */ + readonly creationState: + | { readonly kind: "preparing"; readonly preparingWorktree: boolean } + | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void } + | null; readonly activePendingApproval: PendingApproval | null; readonly respondingApprovalId: ApprovalRequestId | null; readonly activePendingUserInput: PendingUserInput | null; @@ -347,6 +357,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (props.activePendingApproval !== null || props.activePendingUserInput !== null) { return null; } + if (props.creationState?.kind === "preparing") { + return { + kind: "preparing", + label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", + }; + } + if (props.creationState?.kind === "failed") { + return null; + } if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } @@ -917,6 +936,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread /> ) : null} + {props.creationState?.kind === "failed" ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( {/* Hidden (not unmounted) while a user-input request owns the - composer slot, so composer drafts and editor state survive. */} - + composer slot, so composer drafts and editor state survive. + A rejected creation has no thread to send to; the failure card + owns the slot instead. */} + { + const creation = selectedThreadCreation?.message; + if (!creation?.creation || routeThreadIdentity === null) { + return; + } + // The drain already restored the prompt into the project draft; the + // outcome is consumed here so this screen does not keep offering it. + clearPendingThreadCreationOutcome(routeThreadIdentity); + navigation.dispatch( + StackActions.replace("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(creation.environmentId), + projectId: String(creation.creation.projectId), + ...(selectedThreadProject ? { title: selectedThreadProject.title } : {}), + }, + }), + ); + }, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]); + const creationState = ((): ThreadDetailScreenProps["creationState"] => { + if (selectedThreadCreation === null) { + return null; + } + if (selectedThreadCreation.outcome?.kind === "failed") { + return { + kind: "failed", + reason: selectedThreadCreation.outcome.reason, + onEditTask: handleEditFailedCreation, + }; + } + return { + kind: "preparing", + preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree", + }; + })(); // Deep links / cold starts land with Thread as the ONLY route, where the // native back button does not render. Provide an explicit Home escape for // that case; when history exists the native back button is used instead. @@ -767,12 +807,18 @@ function ThreadRouteContent( return ; } - const contentPresentation = projectThreadContentPresentation({ - hasDetail: selectedThreadDetail !== null, - detailError: Option.getOrNull(selectedThreadDetailState.error), - detailDeleted: selectedThreadDetailState.status === "deleted", - connectionState: routeConnectionState, - }); + // A queued creation renders as ready content: its prompt is the whole + // conversation until the server creates the thread. The subscription's + // not-found error for that window is expected, not a load failure. + const contentPresentation = + creationState !== null + ? { kind: "ready" as const } + : projectThreadContentPresentation({ + hasDetail: selectedThreadDetail !== null, + detailError: Option.getOrNull(selectedThreadDetailState.error), + detailDeleted: selectedThreadDetailState.status === "deleted", + connectionState: routeConnectionState, + }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const renderThreadRouteBody = (showActionControls: boolean) => ( <> @@ -792,6 +838,7 @@ function ThreadRouteContent( selectedThreadFeed={composer.selectedThreadFeed} activeWorkStartedAt={composer.activeWorkStartedAt} isCompacting={composer.isCompacting} + creationState={creationState} activePendingApproval={requests.activePendingApproval} respondingApprovalId={requests.respondingApprovalId} activePendingUserInput={requests.activePendingUserInput} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 26f321517baa..7d231155a4a3 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -279,6 +279,24 @@ function FloatingStatusLabel(props: { ); } + if (props.status.kind === "preparing") { + return ( + + + {props.status.label} + + ); + } return ( ); diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index 71d0f3bd9e17..515e0eb81b72 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -9,6 +9,9 @@ export type FloatingWorkingStatus = | { readonly kind: "working"; readonly startedAt: string } | { readonly kind: "syncing"; readonly label: string } | { readonly kind: "compacting" } + // A task whose thread the server has not created yet: the worktree may + // still be checking out, so there is no turn to time. + | { readonly kind: "preparing"; readonly label: string } | { readonly kind: "connection"; readonly tone: "reconnecting" | "unavailable"; diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5df507ea671b..535581b58e75 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -96,6 +96,7 @@ import { resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; +import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; type WorkspaceMode = "local" | "worktree"; @@ -189,7 +190,13 @@ type NewTaskFlowContextValue = { readonly beginEditingPendingTask: (messageId: string) => boolean; readonly finishEditingPendingTask: () => void; readonly cancelEditingPendingTask: () => void; - readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; + readonly buildPendingTaskMessage: ( + metadata: TurnCommandMetadata, + options?: { + /** The live checkout, recorded as a local task's branch when it sends now. */ + readonly currentCheckoutBranch?: string | null; + }, + ) => QueuedThreadMessage | null; readonly setPrompt: (value: string) => void; readonly replaceAttachments: (attachments: ReadonlyArray) => void; /** Appends draft attachments; returns how many the live cap rejected. */ @@ -916,7 +923,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, []); const buildPendingTaskMessage = useCallback( - (metadata: TurnCommandMetadata): QueuedThreadMessage | null => { + ( + metadata: TurnCommandMetadata, + options?: { readonly currentCheckoutBranch?: string | null }, + ): QueuedThreadMessage | null => { if (!selectedProject || !selectedProjectDraftKey) { return null; } @@ -970,11 +980,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ...(projectTitle !== undefined ? { projectTitle } : {}), ...(projectCwd !== undefined ? { projectCwd } : {}), workspaceMode: mode, - // Only an explicit picker choice, never the current checkout: a - // queued local task drains days later against whatever is checked - // out then, so recording a queue-time guess would pin a stale label - // to a thread that ran somewhere else. - branch: workspaceSelection?.branch ?? null, + // An explicit picker choice wins. Otherwise only a task sending now + // records the current checkout: a queued local task drains days + // later against whatever is checked out then, so a queue-time + // guess would pin a stale label to a thread that ran somewhere else. + branch: resolveProjectThreadCreationBranch({ + workspaceMode: mode, + selectedBranch: workspaceSelection?.branch ?? null, + currentCheckoutBranch: options?.currentCheckoutBranch ?? null, + }), worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), // The draft only carries the flag when the user touched it; fall // back to the resolved default (server settings) so queued tasks diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts deleted file mode 100644 index bb0dce57ff71..000000000000 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { useCallback } from "react"; - -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import { - ThreadId, - type ModelSelection, - type ProviderInteractionMode, - type RuntimeMode, -} from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; - -import { threadEnvironment } from "../../state/threads"; -import type { DraftComposerAttachment } from "../../lib/composerImages"; -import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/attachmentUpload"; -import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; -import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; -import { randomHex } from "../../lib/uuid"; -import { isModelSelectionUnavailable } from "../../lib/modelOptions"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts"; -import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; -import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { serverEnvironment } from "../../state/server"; -import { resolveProviderInteractionMode } from "./legacy-plan-mode"; - -export function useCreateProjectThread() { - const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); - - return useCallback( - async (input: { - readonly project: EnvironmentProject; - readonly modelSelection: ModelSelection; - readonly envMode: "local" | "worktree"; - readonly branch: string | null; - readonly worktreePath: string | null; - readonly startFromOrigin?: boolean; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode; - readonly initialMessageText: string; - readonly initialAttachments: ReadonlyArray; - readonly onAttachmentsUploaded: ( - attachments: ReadonlyArray, - ) => Promise; - /** Reuse identifiers from a queued pending task instead of minting new ones. */ - readonly turnMetadata?: TurnCommandMetadata; - }) => { - const metadata = input.turnMetadata ?? makeTurnCommandMetadata(); - const threadId = ThreadId.make(metadata.threadId); - const initialMessageText = input.initialMessageText.trim(); - - const validationError = validateProjectThreadCreation({ - environmentId: input.project.environmentId, - projectId: input.project.id, - environmentMode: input.envMode, - branch: input.branch, - initialMessageText, - }); - if (validationError !== null) { - setPendingConnectionError(validationError.message); - return AsyncResult.failure(Cause.fail(validationError)); - } - - const validateLiveFileAttachments = ( - attachments: ReadonlyArray, - ): string | null => - validateDraftFileAttachments({ - attachments, - serverConfig: appAtomRegistry.get( - serverEnvironment.configValueAtom(input.project.environmentId), - ), - }); - const initialAttachmentError = validateLiveFileAttachments(input.initialAttachments); - if (initialAttachmentError !== null) { - setPendingConnectionError(initialAttachmentError); - return AsyncResult.failure(Cause.fail(new Error(initialAttachmentError))); - } - - let prepared: Awaited>; - try { - // If persisting the references into the draft throws, the owner call - // deletes the pending uploads it minted before rethrowing. - prepared = await prepareTurnAttachments({ - environmentId: input.project.environmentId, - attachments: input.initialAttachments, - supportsImageUploads: - appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId)) - ?.environment.capabilities.attachmentUploads === true, - persistUploadedReferences: async (draftAttachments) => { - await input.onAttachmentsUploaded(draftAttachments); - return "persisted"; - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "An attachment could not upload."; - setPendingConnectionError(message); - return AsyncResult.failure(Cause.fail(new Error(message))); - } - if (prepared.status !== "ready") { - const message = "The attachments are no longer available."; - setPendingConnectionError(message); - return AsyncResult.failure(Cause.fail(new Error(message))); - } - - const preparedAttachmentError = validateLiveFileAttachments(prepared.draftAttachments); - if (preparedAttachmentError !== null) { - setPendingConnectionError(preparedAttachmentError); - return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError))); - } - - const serverConfig = appAtomRegistry.get( - serverEnvironment.configValueAtom(input.project.environmentId), - ); - const providerError = !serverConfig - ? "Provider settings are still loading. Try again." - : isModelSelectionUnavailable(serverConfig, input.modelSelection) - ? "Antigravity model unavailable. Set it up on web or desktop, or choose another model." - : null; - if (providerError !== null) { - setPendingConnectionError(providerError); - return AsyncResult.failure(Cause.fail(new Error(providerError))); - } - const provider = serverConfig?.providers.find( - (candidate) => candidate.instanceId === input.modelSelection.instanceId, - ); - - const result = await startTurn({ - environmentId: input.project.environmentId, - input: buildProjectThreadStartTurnInput({ - projectId: input.project.id, - projectCwd: input.project.workspaceRoot, - threadId: metadata.threadId, - commandId: metadata.commandId, - messageId: metadata.messageId, - createdAt: metadata.createdAt, - text: initialMessageText, - uploadedAttachments: prepared.attachments, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: resolveProviderInteractionMode(provider, input.interactionMode), - workspaceMode: input.envMode, - branch: input.branch, - worktreePath: input.worktreePath, - startFromOrigin: input.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), - }), - }); - if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - setPendingConnectionError( - error instanceof Error ? error.message : "The task could not be started.", - ); - return AsyncResult.failure(result.cause); - } - setPendingConnectionError(null); - scheduleUnusedComposerAttachmentCleanup(prepared.draftAttachments); - - return mapAtomCommandResult(result, () => - scopeThreadRef(input.project.environmentId, threadId), - ); - }, - [startTurn], - ); -} diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts new file mode 100644 index 000000000000..c2505f251183 --- /dev/null +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -0,0 +1,102 @@ +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + pendingThreadCreationMessage, + pendingThreadCreationShell, +} from "./pending-thread-creation"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +const creation: QueuedThreadMessage = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + commandId: CommandId.make("command-1"), + text: "Fix the flaky login test", + attachments: [ + { + id: "draft-image", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 10, + previewUri: "data:image/png;base64,AAAA", + }, + ], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "worktree", + branch: "main", + worktreePath: null, + }, + createdAt: "2026-08-24T12:00:00.000Z", +}; + +describe("pendingThreadCreationShell", () => { + it("shapes a queued creation as the thread shell the screen renders before creation", () => { + expect(pendingThreadCreationShell(creation)).toMatchObject({ + environmentId: creation.environmentId, + id: creation.threadId, + projectId: creation.creation!.projectId, + title: "Fix the flaky login test", + modelSelection: creation.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + latestUserMessageAt: creation.createdAt, + }); + }); + + it("keeps a local task's explicit worktree path", () => { + expect( + pendingThreadCreationShell({ + ...creation, + creation: { + ...creation.creation!, + workspaceMode: "local", + worktreePath: "/repo/.worktrees/feature", + }, + })?.worktreePath, + ).toBe("/repo/.worktrees/feature"); + }); + + it("returns null for a follow-up message or a creation without a model", () => { + expect(pendingThreadCreationShell({ ...creation, creation: undefined })).toBeNull(); + expect(pendingThreadCreationShell({ ...creation, modelSelection: undefined })).toBeNull(); + }); +}); + +describe("pendingThreadCreationMessage", () => { + it("renders the queued prompt as the first user message with its attachments named", () => { + expect(pendingThreadCreationMessage(creation)).toEqual({ + id: creation.messageId, + role: "user", + text: creation.text, + attachments: [ + { + type: "image", + id: "draft-image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ], + turnId: null, + streaming: false, + createdAt: creation.createdAt, + updatedAt: creation.createdAt, + }); + }); +}); diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts new file mode 100644 index 000000000000..b36b28e16fa3 --- /dev/null +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -0,0 +1,108 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { OrchestrationThread } from "@t3tools/contracts"; +import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { appAtomRegistry } from "./atom-registry"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +/** + * A new task navigates to its thread screen the moment it is queued, before the + * server has created the thread. Until the shell arrives the screen renders a + * stand-in built from the queued creation. The outcome recorded by the outbox + * drain covers the two windows that stand-in cannot: the gap between delivery + * and the shell snapshot (keep showing the stand-in) and a rejected creation + * (the drain restored the content into the project draft; offer to reopen it). + */ +export type PendingThreadCreationOutcome = + | { readonly kind: "delivered"; readonly message: QueuedThreadMessage } + | { readonly kind: "failed"; readonly message: QueuedThreadMessage; readonly reason: string }; + +export const pendingThreadCreationOutcomesAtom = Atom.make< + Readonly> +>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:pending-thread-creation:outcomes")); + +export function recordPendingThreadCreationOutcome(outcome: PendingThreadCreationOutcome): void { + const key = scopedThreadKey(outcome.message.environmentId, outcome.message.threadId); + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, { + ...appAtomRegistry.get(pendingThreadCreationOutcomesAtom), + [key]: outcome, + }); +} + +export function clearPendingThreadCreationOutcome(threadKey: string): void { + const current = appAtomRegistry.get(pendingThreadCreationOutcomesAtom); + if (!current[threadKey]) { + return; + } + const next = { ...current }; + delete next[threadKey]; + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, next); +} + +export function pendingThreadCreationMessage( + message: QueuedThreadMessage, +): OrchestrationThread["messages"][number] { + return { + id: message.messageId, + role: "user", + text: message.text, + // Local attachments have no server id yet; the row only needs to + // reserve the space and name them. + ...(message.attachments.length > 0 + ? { + attachments: message.attachments.map((attachment) => ({ + type: attachment.type, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + })), + } + : {}), + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }; +} + +/** + * Thread shell shaped from a queued creation. `modelSelection` is required on + * the shell; a creation is only sendable with one, so the fallback never sends. + */ +export function pendingThreadCreationShell( + message: QueuedThreadMessage, +): EnvironmentThreadShell | null { + const creation = message.creation; + if (!creation || !message.modelSelection) { + return null; + } + return { + environmentId: message.environmentId, + id: message.threadId, + projectId: creation.projectId, + title: deriveThreadTitleFromPrompt(message.text), + modelSelection: message.modelSelection, + runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + branch: creation.branch, + worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath, + linkedPullRequest: null, + latestTurn: null, + createdAt: message.createdAt, + updatedAt: message.createdAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + session: null, + latestUserMessageAt: message.createdAt, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 7207b1d46a5c..7ac9cf9d8ef7 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import { @@ -36,6 +36,7 @@ import { buildThreadFeed } from "../lib/threadActivity"; import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; import { appAtomRegistry } from "../state/atom-registry"; +import { pendingThreadCreationMessage } from "./pending-thread-creation"; import { appendComposerDraftAttachments, appendComposerDraftText, @@ -101,7 +102,11 @@ export function useThreadDraftForThread(input: { } export function useThreadComposerState() { - const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection(); + const { + selectedThread: selectedThreadShell, + selectedThreadCreation, + selectedEnvironmentRuntime, + } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom); @@ -121,8 +126,15 @@ export function useThreadComposerState() { const selectedThreadKey = selectedThreadShell ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) : null; + // The creation entry is the thread itself (rendered as the first message), + // not a follow-up waiting behind it. const selectedThreadQueuedMessages = useMemo( - () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), + () => + selectedThreadKey + ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []).filter( + (message) => message.creation === undefined, + ) + : [], [queuedMessagesByThreadKey, selectedThreadKey], ); const feedbackSubmissions = useMemo( @@ -141,6 +153,12 @@ export function useThreadComposerState() { ); const selectedThreadMessages = selectedThreadDetail?.messages; const selectedThreadActivities = selectedThreadDetail?.activities; + // A thread the server has not created yet only has the queued prompt; the + // stand-in shell is the "detail" until the real snapshot lands. + const pendingCreationMessage = selectedThreadCreation?.message ?? null; + // Read inside the send callback, which must not be rebuilt per keystroke. + const selectedThreadCreationRef = useRef(selectedThreadCreation); + selectedThreadCreationRef.current = selectedThreadCreation; const selectedThreadFeed = useMemo(() => { const feed = selectedThreadMessages && selectedThreadActivities @@ -148,7 +166,12 @@ export function useThreadComposerState() { messages: selectedThreadMessages, activities: selectedThreadActivities, }) - : []; + : pendingCreationMessage !== null + ? buildThreadFeed({ + messages: [pendingThreadCreationMessage(pendingCreationMessage)], + activities: [], + }) + : []; const pendingAcknowledgments = acknowledgedMessages.filter( (message) => scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey && @@ -161,6 +184,7 @@ export function useThreadComposerState() { }, [ selectedThreadActivities, selectedThreadMessages, + pendingCreationMessage, selectedThreadKey, selectedThreadQueuedMessages, acknowledgedMessages, @@ -265,6 +289,13 @@ export function useThreadComposerState() { if (!selectedThreadShell) { return null; } + // The server has not created this thread yet. Queuing a follow-up against + // its id would strand the message: if the creation is rejected the thread + // never appears and the drain drops the orphan. The composer disables its + // send button too; this guard also covers the editor's submit key. + if (selectedThreadCreationRef.current !== null) { + return null; + } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 388b4d9afcb9..c071f2aad938 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -13,12 +13,12 @@ export function useThreadDetail(target: ThreadDetailTarget) { return useEnvironmentThread(target.environmentId, target.threadId); } +/** + * The selection owns the subscription so it can hold it back while a queued + * creation has not reached the server yet. + */ export function useSelectedThreadDetailState() { - const { selectedThread } = useThreadSelection(); - return useThreadDetail({ - environmentId: selectedThread?.environmentId ?? null, - threadId: selectedThread?.id ?? null, - }); + return useThreadSelection().selectedThreadDetailState; } export function useSelectedThreadDetail() { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 5e67fe57b840..082121d0aef9 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -133,6 +133,10 @@ vi.mock("./thread-outbox", async () => { }); import { appAtomRegistry } from "./atom-registry"; +import { + clearPendingThreadCreationOutcome, + pendingThreadCreationOutcomesAtom, +} from "./pending-thread-creation"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; @@ -203,6 +207,7 @@ afterEach(() => { appAtomRegistry.set(composerDrafts.composerDraftsAtom, {}); appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {}); harness.draftFile.setWriteError(null); harness.removePersistedFile.mockClear(); harness.removeOutboxMessage.mockClear(); @@ -618,6 +623,42 @@ describe("thread outbox recovery rollback", () => { }); expect(remainingMessages()).toEqual([]); expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server"); + // The thread screen opened for this creation reads the failure from here. + expect( + appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[ + `${message.environmentId}:${message.threadId}` + ], + ).toEqual({ kind: "failed", message, reason: "rejected by server" }); + }); + + it("keeps a failed outcome until its thread screen consumes it", async () => { + const message: QueuedThreadMessage = { + ...queuedMessage({ messageId: "message-creation-kept", text: "new task text" }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + }; + await harness.manager.enqueue(message); + await restoreRejectedQueuedMessage(message, "rejected by server"); + + const key = `${message.environmentId}:${message.threadId}`; + expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[key]?.kind).toBe("failed"); + + clearPendingThreadCreationOutcome(key); + expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[key]).toBeUndefined(); + }); + + it("does not record a creation outcome for a rejected follow-up message", async () => { + const message = queuedMessage({ messageId: "message-followup-restore", text: "follow up" }); + await harness.manager.enqueue(message); + + await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored"); + + expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)).toEqual({}); }); it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 0a197ceb8727..d0ae7ea6f1b5 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -28,6 +28,11 @@ import { } from "./acknowledged-thread-messages"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useServerConfigs, useThreadShells } from "./entities"; +import { + clearPendingThreadCreationOutcome, + pendingThreadCreationOutcomesAtom, + recordPendingThreadCreationOutcome, +} from "./pending-thread-creation"; import { serverEnvironment } from "./server"; import { confirmThreadOutboxMessageQueued, @@ -439,6 +444,15 @@ export async function restoreRejectedQueuedMessage( // The queued message is gone; from here the draft owns the content and // must never be rolled back. rollback = null; + if (queuedMessage.creation) { + // The thread screen for this creation is likely open; it reads the + // outcome to offer reopening the restored draft. + recordPendingThreadCreationOutcome({ + kind: "failed", + message: queuedMessage, + reason: message, + }); + } setPendingConnectionError(message); return "restored"; } catch (error) { @@ -927,6 +941,9 @@ export function useThreadOutboxDrain(): void { if (failure?.action === "restore") { return restoreQueuedMessage(persistedMessage, failure.message); } + // Recorded before the queue entry goes so the thread screen never sees a + // gap between the queued creation and the server's shell. + recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage }); const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); if (outcome === "edited") { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { @@ -944,6 +961,21 @@ export function useThreadOutboxDrain(): void { [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); + // A creation outcome only bridges the gap until the server's shell arrives. + // Drop it once that happens so the map cannot grow for a whole session; a + // failed outcome stays until its thread screen consumes it. + useEffect(() => { + const outcomes = appAtomRegistry.get(pendingThreadCreationOutcomesAtom); + for (const [threadKey, outcome] of Object.entries(outcomes)) { + if ( + outcome.kind === "delivered" && + threads.some((thread) => scopedThreadKey(thread.environmentId, thread.id) === threadKey) + ) { + clearPendingThreadCreationOutcome(threadKey); + } + } + }, [threads]); + useEffect(() => { if (dispatchingQueuedMessageId !== null) { return; diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 7e012cb78903..8231733a9bbd 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { useRoute, type RouteProp } from "@react-navigation/native"; import { useMemo, useRef } from "react"; import { @@ -10,12 +11,20 @@ import { import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import * as Option from "effect/Option"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentThread } from "../state/threads"; +import { + pendingThreadCreationOutcomesAtom, + pendingThreadCreationShell, + type PendingThreadCreationOutcome, +} from "./pending-thread-creation"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; import { useRemoteEnvironmentRuntime, useSavedRemoteConnection, } from "./use-remote-environment-registry"; +import { useThreadOutboxMessages } from "./use-thread-outbox"; type ThreadSelectionRouteParams = { readonly environmentId?: string | string[]; readonly threadId?: string | string[]; @@ -96,9 +105,39 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin } const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current; const selectedThreadShell = useThreadShell(selectedThreadRef); + const selectedThreadKey = + selectedThreadRef === null + ? null + : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); + const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom); + // A creation the outbox still holds or just delivered: the thread screen + // opened before the server made the thread, so present a stand-in shell. + const pendingCreation = useMemo<{ + readonly message: QueuedThreadMessage; + readonly outcome: PendingThreadCreationOutcome | null; + } | null>(() => { + if (selectedThreadKey === null) { + return null; + } + const queued = queuedMessagesByThreadKey[selectedThreadKey]?.find( + (message) => message.creation !== undefined, + ); + const outcome = creationOutcome[selectedThreadKey] ?? null; + const message = queued ?? outcome?.message ?? null; + return message === null ? null : { message, outcome }; + }, [creationOutcome, queuedMessagesByThreadKey, selectedThreadKey]); + // Until the creation is delivered the server has no thread to subscribe + // to; subscribing anyway would retry "not found" for the whole setup. + const selectedThreadDetailRef = + selectedThreadShell !== null || + pendingCreation === null || + pendingCreation.outcome?.kind === "delivered" + ? selectedThreadRef + : null; const selectedThreadDetailState = useEnvironmentThread( - selectedThreadRef?.environmentId ?? null, - selectedThreadRef?.threadId ?? null, + selectedThreadDetailRef?.environmentId ?? null, + selectedThreadDetailRef?.threadId ?? null, ); const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const selectedThread = useMemo( @@ -106,9 +145,14 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin selectedThreadShell ?? (selectedThreadRef !== null && selectedThreadDetail !== null ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail) - : null), - [selectedThreadDetail, selectedThreadRef, selectedThreadShell], + : pendingCreation !== null + ? pendingThreadCreationShell(pendingCreation.message) + : null), + [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell], ); + // The stand-in outlives the queue entry only until the real shell shows up. + const selectedThreadCreation = + selectedThreadShell === null && selectedThreadDetail === null ? pendingCreation : null; const selectedProjectRef = useMemo( () => selectedThread === null @@ -128,6 +172,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin () => ({ selectedThreadRef, selectedThread, + selectedThreadCreation, + selectedThreadDetailState, selectedThreadProject, selectedEnvironmentConnection, selectedEnvironmentRuntime, @@ -136,6 +182,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin selectedEnvironmentConnection, selectedEnvironmentRuntime, selectedThread, + selectedThreadCreation, + selectedThreadDetailState, selectedThreadProject, selectedThreadRef, ], From 091874fd238f8d4cc321cf1073513b4562a15b7e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:58:17 -0700 Subject: [PATCH 2/6] fix(mobile): open the restored draft after a rejected task and stop faking attachment ids Review follow-ups on the immediate-thread-navigation change: - Edit task navigated without a draftId, so the sheet minted a fresh empty draft and the restored prompt in new-task:restored- was unreachable. Both bots caught this; drafts became id-keyed on main while this branch was open. The key now comes from one shared helper. - The stand-in message passed local draft attachment ids as server ids, so the feed's attachment rows sat on a spinner that never resolved when the creation was rejected. It omits attachments instead; the delivered message renders them moments later. - The outcome-pruning effect read its atom non-reactively, leaking a delivered outcome whenever the shell landed before the outcome was recorded. It subscribes now. Made with Claude Opus 5 in T3 Code (Claude Code harness). Co-Authored-By: Claude Opus 5 --- .../src/features/threads/ThreadRouteScreen.tsx | 7 +++++-- apps/mobile/src/state/new-task-draft-key.ts | 9 +++++++++ .../src/state/pending-thread-creation.test.ts | 17 +++++++---------- .../mobile/src/state/pending-thread-creation.ts | 17 ++++------------- .../mobile/src/state/use-thread-outbox-drain.ts | 13 ++++++++----- 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index b57b7920b720..fa6356aa97b2 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -25,6 +25,7 @@ import { import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; +import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; @@ -752,13 +753,15 @@ function ThreadRouteContent( if (!creation?.creation || routeThreadIdentity === null) { return; } - // The drain already restored the prompt into the project draft; the - // outcome is consumed here so this screen does not keep offering it. + // The drain restored the prompt and attachments into the recovery draft + // the rejected creation owns. Open that draft by id: without it the sheet + // mints a fresh empty one and the restored content is unreachable. clearPendingThreadCreationOutcome(routeThreadIdentity); navigation.dispatch( StackActions.replace("NewTaskSheet", { screen: "NewTaskDraft", params: { + draftId: restoredNewTaskDraftKey(creation.messageId), environmentId: String(creation.environmentId), projectId: String(creation.creation.projectId), ...(selectedThreadProject ? { title: selectedThreadProject.title } : {}), diff --git a/apps/mobile/src/state/new-task-draft-key.ts b/apps/mobile/src/state/new-task-draft-key.ts index 942380dbaa3d..8ea11faaf35d 100644 --- a/apps/mobile/src/state/new-task-draft-key.ts +++ b/apps/mobile/src/state/new-task-draft-key.ts @@ -9,6 +9,15 @@ export function isNewTaskDraftKey(draftKey: string): boolean { return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX); } +/** + * The draft a rejected queued task's content is restored into. The outbox + * drain writes it and the thread screen's "Edit task" action opens it, so both + * sides derive the key here rather than rebuilding the string. + */ +export function restoredNewTaskDraftKey(messageId: string): string { + return newTaskDraftKey(`restored-${messageId}`); +} + /** * Builds before drafts were id-keyed used `new-task::`, * one slot per project. Ids are UUIDs and never contain a colon, so a colon diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts index c2505f251183..23ed4688d776 100644 --- a/apps/mobile/src/state/pending-thread-creation.test.ts +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -79,24 +79,21 @@ describe("pendingThreadCreationShell", () => { }); describe("pendingThreadCreationMessage", () => { - it("renders the queued prompt as the first user message with its attachments named", () => { + it("renders the queued prompt as the first user message", () => { expect(pendingThreadCreationMessage(creation)).toEqual({ id: creation.messageId, role: "user", text: creation.text, - attachments: [ - { - type: "image", - id: "draft-image", - name: "screen.png", - mimeType: "image/png", - sizeBytes: 10, - }, - ], turnId: null, streaming: false, createdAt: creation.createdAt, updatedAt: creation.createdAt, }); }); + + // Draft attachment ids are local; the feed resolves attachment rows against + // the server and would spin forever on them. + it("omits the queued attachments rather than passing local draft ids to the feed", () => { + expect(pendingThreadCreationMessage(creation)).not.toHaveProperty("attachments"); + }); }); diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index b36b28e16fa3..195535711b08 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -49,19 +49,10 @@ export function pendingThreadCreationMessage( id: message.messageId, role: "user", text: message.text, - // Local attachments have no server id yet; the row only needs to - // reserve the space and name them. - ...(message.attachments.length > 0 - ? { - attachments: message.attachments.map((attachment) => ({ - type: attachment.type, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - })), - } - : {}), + // Deliberately no attachments. Their ids are local draft ids the server + // cannot resolve, so the feed's attachment rows would sit on a spinner + // that only ends when the real message arrives — and never, if the + // creation is rejected. The delivered message renders them moments later. turnId: null, streaming: false, createdAt: message.createdAt, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index d0ae7ea6f1b5..69f74315ed16 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -27,6 +27,7 @@ import { forgetAcknowledgedThreadMessage, } from "./acknowledged-thread-messages"; import { appAtomRegistry } from "./atom-registry"; +import { restoredNewTaskDraftKey } from "./new-task-draft-key"; import { useProjects, useServerConfigs, useThreadShells } from "./entities"; import { clearPendingThreadCreationOutcome, @@ -62,7 +63,6 @@ import { type ComposerDraft, getComposerDraftSnapshot, mergeComposerDraftContent, - newTaskDraftKey, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, @@ -482,7 +482,7 @@ export async function restoreRejectedQueuedMessage( */ function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string { return queuedMessage.creation - ? newTaskDraftKey(`restored-${queuedMessage.messageId}`) + ? restoredNewTaskDraftKey(queuedMessage.messageId) : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); } @@ -552,6 +552,7 @@ export function useThreadOutboxDrain(): void { const queuedMessagesByThreadKey = useThreadOutboxMessages(); const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); + const creationOutcomes = useAtomValue(pendingThreadCreationOutcomesAtom); const projects = useProjects(); const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); @@ -964,9 +965,11 @@ export function useThreadOutboxDrain(): void { // A creation outcome only bridges the gap until the server's shell arrives. // Drop it once that happens so the map cannot grow for a whole session; a // failed outcome stays until its thread screen consumes it. + // Subscribed, not read once: the shell often lands before the outcome is + // recorded, and a non-reactive read would leave that entry uncollected + // because `threads` never changes again. useEffect(() => { - const outcomes = appAtomRegistry.get(pendingThreadCreationOutcomesAtom); - for (const [threadKey, outcome] of Object.entries(outcomes)) { + for (const [threadKey, outcome] of Object.entries(creationOutcomes)) { if ( outcome.kind === "delivered" && threads.some((thread) => scopedThreadKey(thread.environmentId, thread.id) === threadKey) @@ -974,7 +977,7 @@ export function useThreadOutboxDrain(): void { clearPendingThreadCreationOutcome(threadKey); } } - }, [threads]); + }, [creationOutcomes, threads]); useEffect(() => { if (dispatchingQueuedMessageId !== null) { From fd3fc8f607068dcfafdfaa894e9171454523d820 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:19:50 -0700 Subject: [PATCH 3/6] fix(mobile): keep the queued prompt on screen while the worktree builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread opened to "No conversation yet" for the whole worktree checkout, which is exactly what the immediate-navigation change was meant to prevent. The server creates the thread, then builds the worktree, and only starts the turn afterwards, so the thread shell and an empty detail arrive seconds ahead of the prompt. The stand-in was gated on the shell being absent, so it was dropped the moment that shell landed: no prompt bubble, no "Setting up worktree…" pill, and the empty-state placeholder instead. It is now gated on the delivered prompt itself, matched by message id — the queued id IS the delivered id, so the swap is exact — and the feed appends it to the loaded messages rather than replacing them, since the detail exists but is empty during that window. Submitting also held the sheet open for the outbox's disk write, leaving an emptied composer on screen while the sheet dismissed. Enqueue publishes to the queue atom synchronously, so navigation no longer waits on persistence; a failed write restores the draft and says so, like the thread composer's send. Made with Claude Opus 5 in T3 Code (Claude Code harness). Co-Authored-By: Claude Opus 5 --- .../features/threads/NewTaskDraftScreen.tsx | 37 +++++++++++++------ .../src/state/pending-thread-creation.test.ts | 34 +++++++++++++++++ .../src/state/pending-thread-creation.ts | 17 +++++++++ .../src/state/use-thread-composer-state.ts | 23 ++++++------ apps/mobile/src/state/use-thread-selection.ts | 12 +++++- 5 files changed, 98 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 2ae997c7c611..7df0f7adc669 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1014,26 +1014,24 @@ export function NewTaskDraftScreen(props: { projectTitle: selectedProject.title, }); } - flow.setSubmitting(true); - try { - await enqueueThreadOutboxMessage(message); - } catch (error) { - Alert.alert( - "Could not queue task", - error instanceof Error ? error.message : "The task could not be saved to the outbox.", - ); - return; - } finally { - flow.setSubmitting(false); - } + // Enqueue publishes to the queue atom synchronously and persists behind + // it, so leave on this frame instead of holding the sheet open — and the + // emptied composer on screen — for a disk write. A failed write rolls the + // message back out and restores the draft, exactly like the thread + // composer's own send. + const enqueued = enqueueThreadOutboxMessage(message); + const draftSnapshot = getComposerDraftSnapshot(draftKey); if (editingPendingTask) { flow.finishEditingPendingTask(); } else { // Drop draft-local model/workspace selections with the content. The // next task re-resolves project defaults before sticky app defaults. + // The queued message owns the attachments now, so the sweep is deferred + // until the write confirms it. clearComposerDraftContent(draftKey, { clearModelSelection: true, clearWorkspaceSelection: true, + deferAttachmentCleanup: true, }); } setSubmitNavigationAction( @@ -1044,6 +1042,21 @@ export function NewTaskDraftScreen(props: { threadId: String(message.threadId), }), ); + void enqueued.then( + () => { + scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments); + }, + (error: unknown) => { + // The message was rolled back out of the queue, so nothing will start + // the thread. Restore the draft and say so: the user has already been + // moved to a thread screen that is never going to fill in. + void restoreComposerDraftSnapshot(draftKey, draftSnapshot); + Alert.alert( + "Could not queue task", + error instanceof Error ? error.message : "The task could not be saved to the outbox.", + ); + }, + ); } if (!selectedProject) { diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts index 23ed4688d776..ef618668c334 100644 --- a/apps/mobile/src/state/pending-thread-creation.test.ts +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -9,6 +9,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + isPendingThreadCreationVisible, pendingThreadCreationMessage, pendingThreadCreationShell, } from "./pending-thread-creation"; @@ -78,6 +79,39 @@ describe("pendingThreadCreationShell", () => { }); }); +describe("isPendingThreadCreationVisible", () => { + const creationMessageId = String(creation.messageId); + + it("stands in before any detail has loaded", () => { + expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: null })).toBe( + true, + ); + }); + + // The regression: the server creates the thread, THEN builds the worktree, + // then starts the turn. The shell and an empty detail arrive seconds before + // the prompt, and keying on the shell left the thread empty for that whole + // window. + it("keeps standing in while the created thread has no messages yet", () => { + expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: [] })).toBe(true); + }); + + it("keeps standing in when the thread holds only unrelated messages", () => { + expect( + isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: ["someone-else"] }), + ).toBe(true); + }); + + it("stands down once the delivered prompt lands under the same id", () => { + expect( + isPendingThreadCreationVisible({ + creationMessageId, + loadedMessageIds: ["someone-else", creationMessageId], + }), + ).toBe(false); + }); +}); + describe("pendingThreadCreationMessage", () => { it("renders the queued prompt as the first user message", () => { expect(pendingThreadCreationMessage(creation)).toEqual({ diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index 195535711b08..f97de5fbba6d 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -42,6 +42,23 @@ export function clearPendingThreadCreationOutcome(threadKey: string): void { appAtomRegistry.set(pendingThreadCreationOutcomesAtom, next); } +/** + * Whether the queued prompt still has to stand in for the real message. + * + * The server creates the thread, then builds the worktree, and only then + * starts the turn, so the thread shell and an empty detail arrive seconds + * ahead of the prompt. Keying this on the shell's arrival left the thread + * showing "No conversation yet" for that whole window. The queued message id + * is reused as the delivered message id, so its presence is the exact signal. + */ +export function isPendingThreadCreationVisible(input: { + readonly creationMessageId: string; + /** Null while no detail has loaded; empty during a worktree checkout. */ + readonly loadedMessageIds: ReadonlyArray | null; +}): boolean { + return !input.loadedMessageIds?.includes(input.creationMessageId); +} + export function pendingThreadCreationMessage( message: QueuedThreadMessage, ): OrchestrationThread["messages"][number] { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 7ac9cf9d8ef7..b725b7569979 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -153,25 +153,26 @@ export function useThreadComposerState() { ); const selectedThreadMessages = selectedThreadDetail?.messages; const selectedThreadActivities = selectedThreadDetail?.activities; - // A thread the server has not created yet only has the queued prompt; the - // stand-in shell is the "detail" until the real snapshot lands. + // A thread whose creation has not delivered its turn yet: the prompt only + // exists in the outbox, so it is appended to whatever the server has. The + // detail is usually present but empty during a worktree checkout, so this + // cannot be an either/or with the loaded messages. const pendingCreationMessage = selectedThreadCreation?.message ?? null; // Read inside the send callback, which must not be rebuilt per keystroke. const selectedThreadCreationRef = useRef(selectedThreadCreation); selectedThreadCreationRef.current = selectedThreadCreation; const selectedThreadFeed = useMemo(() => { + const loadedMessages = selectedThreadMessages ?? []; const feed = - selectedThreadMessages && selectedThreadActivities + (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null ? buildThreadFeed({ - messages: selectedThreadMessages, - activities: selectedThreadActivities, + messages: + pendingCreationMessage !== null + ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)] + : loadedMessages, + activities: selectedThreadActivities ?? [], }) - : pendingCreationMessage !== null - ? buildThreadFeed({ - messages: [pendingThreadCreationMessage(pendingCreationMessage)], - activities: [], - }) - : []; + : []; const pendingAcknowledgments = acknowledgedMessages.filter( (message) => scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey && diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 8231733a9bbd..0b17e0a61b55 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -15,6 +15,7 @@ import { scopedThreadKey } from "../lib/scopedEntities"; import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentThread } from "../state/threads"; import { + isPendingThreadCreationVisible, pendingThreadCreationOutcomesAtom, pendingThreadCreationShell, type PendingThreadCreationOutcome, @@ -150,9 +151,16 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin : null), [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell], ); - // The stand-in outlives the queue entry only until the real shell shows up. + // The stand-in stands down when the delivered prompt lands, not when the + // shell does — see isPendingThreadCreationVisible. const selectedThreadCreation = - selectedThreadShell === null && selectedThreadDetail === null ? pendingCreation : null; + pendingCreation !== null && + isPendingThreadCreationVisible({ + creationMessageId: pendingCreation.message.messageId, + loadedMessageIds: selectedThreadDetail?.messages.map((message) => message.id) ?? null, + }) + ? pendingCreation + : null; const selectedProjectRef = useMemo( () => selectedThread === null From 30e4f1ba3f9e4e18880acdb507dfde5a9a523b1e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:27:17 -0700 Subject: [PATCH 4/6] fix(mobile): keep the working pill up between the prompt landing and the agent starting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating pill blinked out for about half a second after "Setting up worktree…" cleared and before "Working for 0s" faded in. Mobile derives the working timer from the shared helper, which only counts a turn once the provider stamps startedAt. A turn that is requested and whose session is already running — the window while the provider spins up — produced null, so nothing was shown. Web never had the gap because its own copy of the helper carries a running-session branch that falls back to the last user message. Bring the shared helper to parity with web's and pass the last user message from mobile. The fallback stays inside the running-session branch on purpose: using it once the turn settles would leave the pill counting forever. Made with Claude Opus 5 in T3 Code (Claude Code harness). Co-Authored-By: Claude Opus 5 --- .../src/state/use-thread-composer-state.ts | 8 +++ .../shared/src/orchestrationTiming.test.ts | 56 ++++++++++++++++++- packages/shared/src/orchestrationTiming.ts | 18 ++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b725b7569979..2f8f2b0491a2 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -279,10 +279,18 @@ export function useThreadComposerState() { return null; } + // The last user message is the floor for a running session whose turn has + // not reported startedAt yet — otherwise the pill blinks out between the + // prompt landing and the agent starting, which web never does. + const latestUserMessageAt = + selectedThreadDetail?.messages.findLast((message) => message.role === "user")?.createdAt ?? + selectedThreadShell?.latestUserMessageAt ?? + null; return deriveActiveWorkStartedAt( selectedThread.latestTurn, selectedThreadSessionActivity, null, + latestUserMessageAt, ); }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index dab35ad3e08c..87fae5bc880d 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDuration } from "./orchestrationTiming.ts"; +import { formatDuration, deriveActiveWorkStartedAt } from "./orchestrationTiming.ts"; describe("formatDuration", () => { it.each([ @@ -29,3 +29,57 @@ describe("formatDuration", () => { expect(formatDuration(durationMs)).toBe("0ms"); }); }); + +describe("deriveActiveWorkStartedAt", () => { + const running = { orchestrationStatus: "running", activeTurnId: "turn-1" } as const; + const requestedTurn = { + turnId: "turn-1", + startedAt: null, + completedAt: null, + }; + + // The gap this closes: a queued prompt lands and the session goes running, + // but the provider has not stamped startedAt yet. Returning null there blinks + // the working indicator out between "Setting up worktree…" and "Working for". + it("counts from the last user message while a running turn has no startedAt", () => { + expect( + deriveActiveWorkStartedAt(requestedTurn, running, null, "2026-09-06T23:21:00.000Z"), + ).toBe("2026-09-06T23:21:00.000Z"); + }); + + it("prefers the turn's own startedAt once the provider reports it", () => { + expect( + deriveActiveWorkStartedAt( + { ...requestedTurn, startedAt: "2026-09-06T23:21:05.000Z" }, + running, + null, + "2026-09-06T23:21:00.000Z", + ), + ).toBe("2026-09-06T23:21:05.000Z"); + }); + + it("stops counting once the turn settles, despite a user message being present", () => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "turn-1", + startedAt: "2026-09-06T23:21:05.000Z", + completedAt: "2026-09-06T23:21:09.000Z", + }, + { orchestrationStatus: "idle", activeTurnId: null }, + null, + "2026-09-06T23:21:00.000Z", + ), + ).toBeNull(); + }); + + it("keeps counting an unsettled turn when no session is running", () => { + expect( + deriveActiveWorkStartedAt( + { turnId: "turn-1", startedAt: "2026-09-06T23:21:05.000Z", completedAt: null }, + null, + null, + ), + ).toBe("2026-09-06T23:21:05.000Z"); + }); +}); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 2ae82c22a691..387c32328c32 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -39,11 +39,29 @@ function isLatestTurnSettled( return true; } +/** + * When the working indicator should be counting, and from when. + * + * A running session whose turn has no `startedAt` yet is still work: the turn + * is requested and the provider is spinning up. Without the running-session + * branch the indicator blinks out for that window — visible on mobile as a gap + * between a queued prompt landing and the agent starting. `latestUserMessageAt` + * is deliberately only a fallback inside that branch: using it once the turn + * has settled would leave the indicator running forever. + */ export function deriveActiveWorkStartedAt( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, sendStartedAt: string | null, + latestUserMessageAt: string | null = null, ): string | null { + const runningTurnId = session?.orchestrationStatus === "running" ? session.activeTurnId : null; + if (runningTurnId !== null && runningTurnId !== undefined) { + if (latestTurn?.turnId === runningTurnId) { + return latestTurn.startedAt ?? sendStartedAt ?? latestUserMessageAt; + } + return sendStartedAt ?? latestUserMessageAt; + } if (!isLatestTurnSettled(latestTurn, session)) { return latestTurn?.startedAt ?? sendStartedAt; } From e1db6b3df5a01e2dcecff41c761bf2cb8c305b0b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:37:26 -0700 Subject: [PATCH 5/6] fix(mobile): count a requested turn so the working pill never blinks out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt at this gap was wrong. It added a running-session branch, but the projector stamps latestTurn.startedAt in the same update that moves the session to "running" — so whenever the session is running, startedAt is already set and that branch could never fire. The real window is session "starting": the provider is spinning up, the turn exists with requestedAt, and startedAt is still null. deriveActiveWorkStartedAt returned null for it, so the pill went out between "Setting up worktree…" and "Working for 0s". An unsettled turn now falls back to requestedAt. A settled turn still falls through to the caller's send timestamp, so this cannot leave the indicator counting after the work is done, and a session restarting with no new turn does not resurrect the old one — both covered by tests. Reverts the speculative running-branch and latestUserMessageAt parameter added in 30e4f1ba3. Made with Claude Opus 5 in T3 Code (Claude Code harness). Co-Authored-By: Claude Opus 5 --- .../src/state/use-thread-composer-state.ts | 8 --- .../shared/src/orchestrationTiming.test.ts | 71 ++++++++++++------- packages/shared/src/orchestrationTiming.ts | 24 +++---- 3 files changed, 55 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 2f8f2b0491a2..b725b7569979 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -279,18 +279,10 @@ export function useThreadComposerState() { return null; } - // The last user message is the floor for a running session whose turn has - // not reported startedAt yet — otherwise the pill blinks out between the - // prompt landing and the agent starting, which web never does. - const latestUserMessageAt = - selectedThreadDetail?.messages.findLast((message) => message.role === "user")?.createdAt ?? - selectedThreadShell?.latestUserMessageAt ?? - null; return deriveActiveWorkStartedAt( selectedThread.latestTurn, selectedThreadSessionActivity, null, - latestUserMessageAt, ); }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index 87fae5bc880d..91fdcf57aeb6 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -31,55 +31,76 @@ describe("formatDuration", () => { }); describe("deriveActiveWorkStartedAt", () => { - const running = { orchestrationStatus: "running", activeTurnId: "turn-1" } as const; - const requestedTurn = { - turnId: "turn-1", - startedAt: null, - completedAt: null, - }; - - // The gap this closes: a queued prompt lands and the session goes running, - // but the provider has not stamped startedAt yet. Returning null there blinks - // the working indicator out between "Setting up worktree…" and "Working for". - it("counts from the last user message while a running turn has no startedAt", () => { + // The gap this closes. The projector stamps startedAt in the same update + // that moves the session to "running", so during provider spin-up the turn + // is requested with no startedAt and the session is "starting". Returning + // null there blinks the working indicator out between "Setting up + // worktree..." and "Working for 0s". + it("counts from requestedAt while the provider is still starting", () => { expect( - deriveActiveWorkStartedAt(requestedTurn, running, null, "2026-09-06T23:21:00.000Z"), - ).toBe("2026-09-06T23:21:00.000Z"); + deriveActiveWorkStartedAt( + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: null, + completedAt: null, + }, + { orchestrationStatus: "starting", activeTurnId: null }, + null, + ), + ).toBe("2026-09-06T23:33:00.000Z"); }); it("prefers the turn's own startedAt once the provider reports it", () => { expect( deriveActiveWorkStartedAt( - { ...requestedTurn, startedAt: "2026-09-06T23:21:05.000Z" }, - running, + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: "2026-09-06T23:33:05.000Z", + completedAt: null, + }, + { orchestrationStatus: "running", activeTurnId: "turn-1" }, null, - "2026-09-06T23:21:00.000Z", ), - ).toBe("2026-09-06T23:21:05.000Z"); + ).toBe("2026-09-06T23:33:05.000Z"); }); - it("stops counting once the turn settles, despite a user message being present", () => { + // requestedAt must not leak past the end of the work. + it("stops counting once the turn has settled", () => { expect( deriveActiveWorkStartedAt( { turnId: "turn-1", - startedAt: "2026-09-06T23:21:05.000Z", - completedAt: "2026-09-06T23:21:09.000Z", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: "2026-09-06T23:33:05.000Z", + completedAt: "2026-09-06T23:33:09.000Z", }, { orchestrationStatus: "idle", activeTurnId: null }, null, - "2026-09-06T23:21:00.000Z", ), ).toBeNull(); }); - it("keeps counting an unsettled turn when no session is running", () => { + // A session restarting with no new turn must not resurrect the old one. + it("does not count a settled turn while a session is starting again", () => { expect( deriveActiveWorkStartedAt( - { turnId: "turn-1", startedAt: "2026-09-06T23:21:05.000Z", completedAt: null }, - null, + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: "2026-09-06T23:33:05.000Z", + completedAt: "2026-09-06T23:33:09.000Z", + }, + { orchestrationStatus: "starting", activeTurnId: null }, null, ), - ).toBe("2026-09-06T23:21:05.000Z"); + ).toBeNull(); + }); + + it("falls back to the caller's send timestamp when there is no turn yet", () => { + expect(deriveActiveWorkStartedAt(null, null, "2026-09-06T23:33:00.000Z")).toBe( + "2026-09-06T23:33:00.000Z", + ); }); }); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 387c32328c32..3e719a58a085 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -1,5 +1,7 @@ type LatestTurnTiming = { readonly turnId: string | null; + /** Set when the turn is created; `startedAt` waits for the provider. */ + readonly requestedAt?: string | null; readonly startedAt: string | null; readonly completedAt: string | null; }; @@ -42,28 +44,20 @@ function isLatestTurnSettled( /** * When the working indicator should be counting, and from when. * - * A running session whose turn has no `startedAt` yet is still work: the turn - * is requested and the provider is spinning up. Without the running-session - * branch the indicator blinks out for that window — visible on mobile as a gap - * between a queued prompt landing and the agent starting. `latestUserMessageAt` - * is deliberately only a fallback inside that branch: using it once the turn - * has settled would leave the indicator running forever. + * `requestedAt` is the floor for an unsettled turn. The projector only stamps + * `startedAt` in the same update that moves the session to "running", so while + * the provider spins up (session "starting") a requested turn has no + * `startedAt` at all — and returning null there blinks the indicator out for + * the whole spin-up. A settled turn still falls through to `sendStartedAt`, so + * this cannot leave the indicator counting after the work is done. */ export function deriveActiveWorkStartedAt( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, sendStartedAt: string | null, - latestUserMessageAt: string | null = null, ): string | null { - const runningTurnId = session?.orchestrationStatus === "running" ? session.activeTurnId : null; - if (runningTurnId !== null && runningTurnId !== undefined) { - if (latestTurn?.turnId === runningTurnId) { - return latestTurn.startedAt ?? sendStartedAt ?? latestUserMessageAt; - } - return sendStartedAt ?? latestUserMessageAt; - } if (!isLatestTurnSettled(latestTurn, session)) { - return latestTurn?.startedAt ?? sendStartedAt; + return latestTurn?.startedAt ?? latestTurn?.requestedAt ?? sendStartedAt; } return sendStartedAt; } From 436856933260d3fc11485ad0655ade6f19d41b5a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 00:07:53 -0700 Subject: [PATCH 6/6] fix(mobile): preserve setup status and recover failed task drafts --- .../features/threads/NewTaskDraftScreen.tsx | 36 +++--- .../features/threads/ThreadRouteScreen.tsx | 14 ++- .../threads/floating-working-control.tsx | 11 +- .../src/features/threads/thread-work-log.tsx | 8 +- .../src/state/pending-thread-creation.test.ts | 114 ++++++++++++++++++ .../src/state/pending-thread-creation.ts | 49 +++++++- .../src/state/recover-failed-thread-draft.ts | 32 +++++ .../src/state/use-thread-composer-state.ts | 11 +- .../src/state/use-thread-outbox-drain.test.ts | 50 ++++++++ .../src/state/use-thread-outbox-drain.ts | 11 +- apps/mobile/src/state/use-thread-selection.ts | 32 +++-- .../shared/src/orchestrationTiming.test.ts | 32 +++++ packages/shared/src/orchestrationTiming.ts | 5 +- 13 files changed, 352 insertions(+), 53 deletions(-) create mode 100644 apps/mobile/src/state/recover-failed-thread-draft.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7df0f7adc669..f3378925c421 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1014,12 +1014,20 @@ export function NewTaskDraftScreen(props: { projectTitle: selectedProject.title, }); } - // Enqueue publishes to the queue atom synchronously and persists behind - // it, so leave on this frame instead of holding the sheet open — and the - // emptied composer on screen — for a disk write. A failed write rolls the - // message back out and restores the draft, exactly like the thread - // composer's own send. - const enqueued = enqueueThreadOutboxMessage(message); + // Persist before clearing the draft or leaving its editor. This only waits + // for the local outbox write; server and worktree setup run on the thread. + flow.setSubmitting(true); + try { + await enqueueThreadOutboxMessage(message); + } catch (error) { + Alert.alert( + "Could not queue task", + error instanceof Error ? error.message : "The task could not be saved to the outbox.", + ); + return; + } finally { + flow.setSubmitting(false); + } const draftSnapshot = getComposerDraftSnapshot(draftKey); if (editingPendingTask) { flow.finishEditingPendingTask(); @@ -1042,21 +1050,7 @@ export function NewTaskDraftScreen(props: { threadId: String(message.threadId), }), ); - void enqueued.then( - () => { - scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments); - }, - (error: unknown) => { - // The message was rolled back out of the queue, so nothing will start - // the thread. Restore the draft and say so: the user has already been - // moved to a thread screen that is never going to fill in. - void restoreComposerDraftSnapshot(draftKey, draftSnapshot); - Alert.alert( - "Could not queue task", - error instanceof Error ? error.message : "The task could not be saved to the outbox.", - ); - }, - ); + scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments); } if (!selectedProject) { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fa6356aa97b2..55f122d6389a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -22,11 +22,12 @@ import { projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; -import { Platform, ScrollView, View } from "react-native"; +import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; +import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; import { vcsEnvironment } from "../../state/vcs"; @@ -748,7 +749,7 @@ function ThreadRouteContent( selectedThreadProject?.workspaceRoot, ]); - const handleEditFailedCreation = useCallback(() => { + const handleEditFailedCreation = useCallback(async () => { const creation = selectedThreadCreation?.message; if (!creation?.creation || routeThreadIdentity === null) { return; @@ -756,6 +757,15 @@ function ThreadRouteContent( // The drain restored the prompt and attachments into the recovery draft // the rejected creation owns. Open that draft by id: without it the sheet // mints a fresh empty one and the restored content is unreachable. + try { + await recoverFailedThreadDraft(creation); + } catch (error) { + Alert.alert( + "Could not restore draft", + error instanceof Error ? error.message : String(error), + ); + return; + } clearPendingThreadCreationOutcome(routeThreadIdentity); navigation.dispatch( StackActions.replace("NewTaskSheet", { diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 7d231155a4a3..88454206af19 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -24,6 +24,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { FloatingWorkingStatus } from "./floating-working-status"; +import { ShimmeringWorkContent } from "./thread-work-log"; const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem // The collapsed composer capsule starts 6 below its overlay's top edge, so @@ -293,7 +294,15 @@ function FloatingStatusLabel(props: { tintColorClassName="foreground" type="monochrome" /> - {props.status.label} + ); } diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index c6043a27bcda..c866dffc5624 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -139,6 +139,7 @@ export function ThreadDisclosureChevron(props: { } function ShimmerWorkContent(props: { + readonly textClassName?: string; readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly highlighted: boolean; @@ -176,6 +177,7 @@ function ShimmerWorkContent(props: { "min-w-0 shrink", props.compact ? "text-xs" : "text-sm", props.highlighted ? "text-foreground" : "text-foreground-muted", + props.textClassName, )} numberOfLines={1} onTextLayout={props.onTextLayout} @@ -187,6 +189,8 @@ function ShimmerWorkContent(props: { } export function ShimmeringWorkContent(props: { + readonly className?: string; + readonly textClassName?: string; /** Secondary line: no icon slot, caption size. */ readonly compact?: boolean; readonly environmentId?: EnvironmentId; @@ -259,10 +263,11 @@ export function ShimmeringWorkContent(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} > { + const threadKey = `${creation.environmentId}:${creation.threadId}`; + const pending: PendingThreadCreation = { message: creation, outcome: null }; + const prompt = { id: creation.messageId }; + + it("keeps setup visible through the prompt echo and shell cleanup until detail has a turn", () => { + let previous = resolvePendingThreadCreation({ + threadKey, + pending, + previous: null, + detail: null, + }); + expect(previous).toBe(pending); + + previous = resolvePendingThreadCreation({ + threadKey, + pending, + previous, + detail: { messages: [], latestTurn: null, session: null }, + }); + expect(previous).toBe(pending); + + // The user message arrives before the provider publishes a timed turn. + previous = resolvePendingThreadCreation({ + threadKey, + pending, + previous, + detail: { messages: [prompt], latestTurn: null, session: { status: "starting" } }, + }); + expect(previous).toBe(pending); + + // The shell stream may observe the turn and collect the global outcome + // before this screen's detail stream catches up. + previous = resolvePendingThreadCreation({ + threadKey, + pending: null, + previous, + detail: { messages: [prompt], latestTurn: null, session: { status: "starting" } }, + }); + expect(previous).toBe(pending); + + expect( + resolvePendingThreadCreation({ + threadKey, + pending: null, + previous, + detail: { + messages: [prompt], + latestTurn: { turnId: "turn-1" }, + session: { status: "running" }, + }, + }), + ).toBeNull(); + }); + + it("keeps the prompt until both the turn and its message have arrived", () => { + expect( + resolvePendingThreadCreation({ + threadKey, + pending, + previous: null, + detail: { messages: [], latestTurn: { turnId: "turn-1" }, session: { status: "running" } }, + }), + ).toBe(pending); + }); + + it.each(["error", "stopped", "interrupted"])("ends setup when startup is %s", (status) => { + expect( + resolvePendingThreadCreation({ + threadKey, + pending: null, + previous: pending, + detail: { messages: [prompt], latestTurn: null, session: { status } }, + }), + ).toBeNull(); + }); + + it("preserves rejected task recovery", () => { + const failed: PendingThreadCreation = { + message: creation, + outcome: { kind: "failed", message: creation, reason: "Checkout failed" }, + }; + expect( + resolvePendingThreadCreation({ + threadKey, + pending: failed, + previous: pending, + detail: { messages: [], latestTurn: null, session: { status: "error" } }, + }), + ).toBe(failed); + }); + + it("does not carry setup into another thread or invent it for existing threads", () => { + expect( + resolvePendingThreadCreation({ + threadKey: "another-thread", + pending: null, + previous: pending, + detail: null, + }), + ).toBeNull(); + expect( + resolvePendingThreadCreation({ + threadKey, + pending: null, + previous: null, + detail: null, + }), + ).toBeNull(); + }); +}); + describe("pendingThreadCreationShell", () => { it("shapes a queued creation as the thread shell the screen renders before creation", () => { expect(pendingThreadCreationShell(creation)).toMatchObject({ diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index f97de5fbba6d..100391cbfe10 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -13,13 +13,60 @@ import type { QueuedThreadMessage } from "./thread-outbox-model"; * server has created the thread. Until the shell arrives the screen renders a * stand-in built from the queued creation. The outcome recorded by the outbox * drain covers the two windows that stand-in cannot: the gap between delivery - * and the shell snapshot (keep showing the stand-in) and a rejected creation + * and the first turn (keep showing setup) and a rejected creation * (the drain restored the content into the project draft; offer to reopen it). */ export type PendingThreadCreationOutcome = | { readonly kind: "delivered"; readonly message: QueuedThreadMessage } | { readonly kind: "failed"; readonly message: QueuedThreadMessage; readonly reason: string }; +export type PendingThreadCreation = { + readonly message: QueuedThreadMessage; + readonly outcome: PendingThreadCreationOutcome | null; +}; + +/** Keep the screen's creation state until its detail can take over the pill. */ +export function resolvePendingThreadCreation(input: { + readonly threadKey: string | null; + readonly pending: PendingThreadCreation | null; + readonly previous: PendingThreadCreation | null; + readonly detail: { + readonly messages: ReadonlyArray<{ readonly id: string }>; + readonly latestTurn: { readonly turnId: string } | null; + readonly session: { readonly status: string } | null; + } | null; +}): PendingThreadCreation | null { + const creation = input.pending ?? input.previous; + if ( + creation === null || + scopedThreadKey(creation.message.environmentId, creation.message.threadId) !== input.threadKey + ) { + return null; + } + if (creation.outcome?.kind === "failed") return creation; + const detail = input.detail; + if ( + detail?.session?.status === "error" || + detail?.session?.status === "stopped" || + detail?.session?.status === "interrupted" + ) + return null; + // Message delivery and turn startup are separate events. The prompt alone + // cannot replace the preparing pill; wait for the turn's timing too. Retain + // the local creation if the outbox has already collected its shell outcome. + if ( + detail !== null && + detail.latestTurn !== null && + !isPendingThreadCreationVisible({ + creationMessageId: creation.message.messageId, + loadedMessageIds: detail.messages.map((message) => message.id), + }) + ) { + return null; + } + return creation; +} + export const pendingThreadCreationOutcomesAtom = Atom.make< Readonly> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:pending-thread-creation:outcomes")); diff --git a/apps/mobile/src/state/recover-failed-thread-draft.ts b/apps/mobile/src/state/recover-failed-thread-draft.ts new file mode 100644 index 000000000000..2eb172921634 --- /dev/null +++ b/apps/mobile/src/state/recover-failed-thread-draft.ts @@ -0,0 +1,32 @@ +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { restoredNewTaskDraftKey } from "./new-task-draft-key"; +import { + appendComposerDraftAttachments, + clearComposerDraftContent, + flushComposerDrafts, + getComposerDraftSnapshot, + mergeComposerDraftContent, +} from "./use-composer-drafts"; + +/** Move unsent setup edits into the restored task before reopening its editor. */ +export async function recoverFailedThreadDraft(message: QueuedThreadMessage): Promise { + const sourceKey = scopedThreadKey(message.environmentId, message.threadId); + const targetKey = restoredNewTaskDraftKey(message.messageId); + const source = getComposerDraftSnapshot(sourceKey); + if (source.text.length === 0 && source.attachments.length === 0) return; + + await mergeComposerDraftContent(targetKey, { text: source.text, attachments: [] }); + const existingIds = new Set( + getComposerDraftSnapshot(targetKey).attachments.map((attachment) => attachment.id), + ); + appendComposerDraftAttachments( + targetKey, + source.attachments.filter((attachment) => !existingIds.has(attachment.id)), + { allowOverflow: true }, + ); + // Recovery may exceed the send cap. Preserve every file and let the editor + // ask the user to remove extras; never discard them during a failed send. + await flushComposerDrafts(); + clearComposerDraftContent(sourceKey); +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b725b7569979..8f10bc92acf8 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert } from "react-native"; import { @@ -158,16 +158,14 @@ export function useThreadComposerState() { // detail is usually present but empty during a worktree checkout, so this // cannot be an either/or with the loaded messages. const pendingCreationMessage = selectedThreadCreation?.message ?? null; - // Read inside the send callback, which must not be rebuilt per keystroke. - const selectedThreadCreationRef = useRef(selectedThreadCreation); - selectedThreadCreationRef.current = selectedThreadCreation; const selectedThreadFeed = useMemo(() => { const loadedMessages = selectedThreadMessages ?? []; const feed = (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null ? buildThreadFeed({ messages: - pendingCreationMessage !== null + pendingCreationMessage !== null && + !loadedMessages.some((message) => message.id === pendingCreationMessage.messageId) ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)] : loadedMessages, activities: selectedThreadActivities ?? [], @@ -294,7 +292,7 @@ export function useThreadComposerState() { // its id would strand the message: if the creation is rejected the thread // never appears and the drain drops the orphan. The composer disables its // send button too; this guard also covers the editor's submit key. - if (selectedThreadCreationRef.current !== null) { + if (selectedThreadCreation !== null) { return null; } @@ -431,6 +429,7 @@ export function useThreadComposerState() { }, [ selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, + selectedThreadCreation, selectedThreadDetail, selectedThreadShell, uploadThreadFeedback, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 082121d0aef9..3d0a0fcc0ecd 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -139,6 +139,7 @@ import { } from "./pending-thread-creation"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; +import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { completeQueuedMessageDelivery, @@ -590,6 +591,55 @@ describe("thread outbox delivered creation recovery", () => { }); describe("thread outbox recovery rollback", () => { + it("reopens a rejected task with setup edits and every attachment, even above the send cap", async () => { + const message = queuedMessage({ messageId: "failed-setup", text: "Original prompt" }); + const sourceKey = `${message.environmentId}:${message.threadId}`; + const targetKey = "new-task:restored-failed-setup"; + const files = Array.from( + { length: 10 }, + (_, index) => + queuedMessage({ + messageId: `attachment-${index}`, + text: "", + fileUri: `file:///file-${index}`, + }).attachments[0]!, + ); + appAtomRegistry.set(composerDrafts.composerDraftsAtom, { + [targetKey]: { text: message.text, attachments: files.slice(0, 8) }, + [sourceKey]: { text: "Please include tests", attachments: files.slice(8) }, + }); + await recoverFailedThreadDraft(message); + expect(composerDrafts.getComposerDraftSnapshot(targetKey)).toMatchObject({ + text: "Original prompt\n\nPlease include tests", + attachments: files, + }); + expect(composerDrafts.getComposerDraftSnapshot(sourceKey)).toMatchObject({ + text: "", + attachments: [], + }); + await recoverFailedThreadDraft(message); + expect(composerDrafts.getComposerDraftSnapshot(targetKey).text).toBe( + "Original prompt\n\nPlease include tests", + ); + }); + + it("keeps setup edits recoverable when saving their recovery draft fails", async () => { + const message = queuedMessage({ messageId: "failed-save", text: "Original prompt" }); + const sourceKey = `${message.environmentId}:${message.threadId}`; + appAtomRegistry.set(composerDrafts.composerDraftsAtom, { + "new-task:restored-failed-save": { text: message.text, attachments: [] }, + [sourceKey]: { text: "Follow-up", attachments: [] }, + }); + harness.draftFile.setWriteError(new Error("disk full")); + await expect(recoverFailedThreadDraft(message)).rejects.toThrow("Composer draft persistence"); + expect(composerDrafts.getComposerDraftSnapshot(sourceKey).text).toBe("Follow-up"); + harness.draftFile.setWriteError(null); + await recoverFailedThreadDraft(message); + expect(composerDrafts.getComposerDraftSnapshot("new-task:restored-failed-save").text).toBe( + "Original prompt\n\nFollow-up", + ); + }); + it("restores a rejected new task as its own draft for the project", async () => { const message: QueuedThreadMessage = { ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }), diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 69f74315ed16..7388037a5593 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -962,7 +962,7 @@ export function useThreadOutboxDrain(): void { [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); - // A creation outcome only bridges the gap until the server's shell arrives. + // A creation outcome bridges setup until the server's shell has a turn. // Drop it once that happens so the map cannot grow for a whole session; a // failed outcome stays until its thread screen consumes it. // Subscribed, not read once: the shell often lands before the outcome is @@ -972,7 +972,14 @@ export function useThreadOutboxDrain(): void { for (const [threadKey, outcome] of Object.entries(creationOutcomes)) { if ( outcome.kind === "delivered" && - threads.some((thread) => scopedThreadKey(thread.environmentId, thread.id) === threadKey) + threads.some( + (thread) => + scopedThreadKey(thread.environmentId, thread.id) === threadKey && + (thread.latestTurn !== null || + thread.session?.status === "error" || + thread.session?.status === "stopped" || + thread.session?.status === "interrupted"), + ) ) { clearPendingThreadCreationOutcome(threadKey); } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 0b17e0a61b55..85f7fb3c3c92 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useRoute, type RouteProp } from "@react-navigation/native"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState } from "react"; import { EnvironmentId, type OrchestrationThread, @@ -15,12 +15,11 @@ import { scopedThreadKey } from "../lib/scopedEntities"; import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentThread } from "../state/threads"; import { - isPendingThreadCreationVisible, + resolvePendingThreadCreation, pendingThreadCreationOutcomesAtom, pendingThreadCreationShell, - type PendingThreadCreationOutcome, + type PendingThreadCreation, } from "./pending-thread-creation"; -import type { QueuedThreadMessage } from "./thread-outbox-model"; import { useRemoteEnvironmentRuntime, useSavedRemoteConnection, @@ -114,10 +113,7 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom); // A creation the outbox still holds or just delivered: the thread screen // opened before the server made the thread, so present a stand-in shell. - const pendingCreation = useMemo<{ - readonly message: QueuedThreadMessage; - readonly outcome: PendingThreadCreationOutcome | null; - } | null>(() => { + const pendingCreation = useMemo(() => { if (selectedThreadKey === null) { return null; } @@ -151,16 +147,16 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin : null), [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell], ); - // The stand-in stands down when the delivered prompt lands, not when the - // shell does — see isPendingThreadCreationVisible. - const selectedThreadCreation = - pendingCreation !== null && - isPendingThreadCreationVisible({ - creationMessageId: pendingCreation.message.messageId, - loadedMessageIds: selectedThreadDetail?.messages.map((message) => message.id) ?? null, - }) - ? pendingCreation - : null; + const [previousCreation, setPreviousCreation] = useState(null); + const selectedThreadCreation = resolvePendingThreadCreation({ + threadKey: selectedThreadKey, + pending: pendingCreation, + previous: previousCreation, + detail: selectedThreadDetail, + }); + if (previousCreation !== selectedThreadCreation) { + setPreviousCreation(selectedThreadCreation); + } const selectedProjectRef = useMemo( () => selectedThread === null diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index 91fdcf57aeb6..74e90d45a5e7 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -31,6 +31,38 @@ describe("formatDuration", () => { }); describe("deriveActiveWorkStartedAt", () => { + it.each([null, "2026-09-06T23:34:00.000Z"])( + "does not time a superseded turn when the active turn differs", + (sendStartedAt) => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "old", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: null, + completedAt: null, + }, + { orchestrationStatus: "running", activeTurnId: "new" }, + sendStartedAt, + ), + ).toBe(sendStartedAt); + }, + ); + + it("stops timing a turn that failed before its provider started", () => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: null, + completedAt: "2026-09-06T23:33:05.000Z", + }, + { orchestrationStatus: "error", activeTurnId: null }, + null, + ), + ).toBeNull(); + }); // The gap this closes. The projector stamps startedAt in the same update // that moves the session to "running", so during provider spin-up the turn // is requested with no startedAt and the session is "starting". Returning diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 3e719a58a085..b87fa9547473 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -34,7 +34,7 @@ function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, ): boolean { - if (!latestTurn?.startedAt) return false; + if (!latestTurn) return false; if (!latestTurn.completedAt) return false; if (!session) return true; if (session.orchestrationStatus === "running") return false; @@ -56,6 +56,9 @@ export function deriveActiveWorkStartedAt( session: SessionActivityState | null, sendStartedAt: string | null, ): string | null { + if (session?.activeTurnId && session.activeTurnId !== latestTurn?.turnId) { + return sendStartedAt; + } if (!isLatestTurnSettled(latestTurn, session)) { return latestTurn?.startedAt ?? latestTurn?.requestedAt ?? sendStartedAt; }