diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7067f05fd010..f3378925c421 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,122 +980,77 @@ 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, + }); + } + // 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); - // 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); } - + const draftSnapshot = getComposerDraftSnapshot(draftKey); 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. + // 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( - 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), + }), ); + scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments); } if (!selectedProject) { @@ -1269,50 +1204,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 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", { + screen: "NewTaskDraft", + params: { + draftId: restoredNewTaskDraftKey(creation.messageId), + 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 +820,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 +851,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..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 @@ -279,6 +280,32 @@ function FloatingStatusLabel(props: { ); } + if (props.status.kind === "preparing") { + return ( + + + + + ); + } 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/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)} > ; - 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/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 new file mode 100644 index 000000000000..772b7cc91141 --- /dev/null +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -0,0 +1,247 @@ +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isPendingThreadCreationVisible, + pendingThreadCreationMessage, + pendingThreadCreationShell, + resolvePendingThreadCreation, + type PendingThreadCreation, +} 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("resolvePendingThreadCreation", () => { + 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({ + 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("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({ + id: creation.messageId, + role: "user", + text: creation.text, + 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 new file mode 100644 index 000000000000..100391cbfe10 --- /dev/null +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -0,0 +1,163 @@ +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 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")); + +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); +} + +/** + * 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] { + return { + id: message.messageId, + role: "user", + text: message.text, + // 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, + 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/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 7207b1d46a5c..8f10bc92acf8 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -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,12 +153,22 @@ export function useThreadComposerState() { ); const selectedThreadMessages = selectedThreadDetail?.messages; const selectedThreadActivities = selectedThreadDetail?.activities; + // 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; const selectedThreadFeed = useMemo(() => { + const loadedMessages = selectedThreadMessages ?? []; const feed = - selectedThreadMessages && selectedThreadActivities + (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null ? buildThreadFeed({ - messages: selectedThreadMessages, - activities: selectedThreadActivities, + messages: + pendingCreationMessage !== null && + !loadedMessages.some((message) => message.id === pendingCreationMessage.messageId) + ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)] + : loadedMessages, + activities: selectedThreadActivities ?? [], }) : []; const pendingAcknowledgments = acknowledgedMessages.filter( @@ -161,6 +183,7 @@ export function useThreadComposerState() { }, [ selectedThreadActivities, selectedThreadMessages, + pendingCreationMessage, selectedThreadKey, selectedThreadQueuedMessages, acknowledgedMessages, @@ -265,6 +288,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 (selectedThreadCreation !== null) { + return null; + } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); @@ -399,6 +429,7 @@ export function useThreadComposerState() { }, [ selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, + selectedThreadCreation, selectedThreadDetail, selectedThreadShell, uploadThreadFeedback, 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..3d0a0fcc0ecd 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -133,8 +133,13 @@ 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 { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { completeQueuedMessageDelivery, @@ -203,6 +208,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(); @@ -585,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" }), @@ -618,6 +673,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..7388037a5593 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -27,7 +27,13 @@ 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, + pendingThreadCreationOutcomesAtom, + recordPendingThreadCreationOutcome, +} from "./pending-thread-creation"; import { serverEnvironment } from "./server"; import { confirmThreadOutboxMessageQueued, @@ -57,7 +63,6 @@ import { type ComposerDraft, getComposerDraftSnapshot, mergeComposerDraftContent, - newTaskDraftKey, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, @@ -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) { @@ -468,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); } @@ -538,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(); @@ -927,6 +942,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 +962,30 @@ export function useThreadOutboxDrain(): void { [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); + // 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 + // recorded, and a non-reactive read would leave that entry uncollected + // because `threads` never changes again. + useEffect(() => { + for (const [threadKey, outcome] of Object.entries(creationOutcomes)) { + if ( + outcome.kind === "delivered" && + 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); + } + } + }, [creationOutcomes, 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..85f7fb3c3c92 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,5 +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, @@ -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 { + resolvePendingThreadCreation, + pendingThreadCreationOutcomesAtom, + pendingThreadCreationShell, + type PendingThreadCreation, +} from "./pending-thread-creation"; 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,36 @@ 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(() => { + 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 +142,21 @@ 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], ); + 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 @@ -128,6 +176,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin () => ({ selectedThreadRef, selectedThread, + selectedThreadCreation, + selectedThreadDetailState, selectedThreadProject, selectedEnvironmentConnection, selectedEnvironmentRuntime, @@ -136,6 +186,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin selectedEnvironmentConnection, selectedEnvironmentRuntime, selectedThread, + selectedThreadCreation, + selectedThreadDetailState, selectedThreadProject, selectedThreadRef, ], diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index dab35ad3e08c..74e90d45a5e7 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,110 @@ describe("formatDuration", () => { expect(formatDuration(durationMs)).toBe("0ms"); }); }); + +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 + // 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( + { + 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( + { + 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, + ), + ).toBe("2026-09-06T23:33:05.000Z"); + }); + + // requestedAt must not leak past the end of the work. + it("stops counting once the turn has settled", () => { + expect( + deriveActiveWorkStartedAt( + { + 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: "idle", activeTurnId: null }, + null, + ), + ).toBeNull(); + }); + + // 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", + 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, + ), + ).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 2ae82c22a691..b87fa9547473 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; }; @@ -32,20 +34,33 @@ 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; return true; } +/** + * When the working indicator should be counting, and from when. + * + * `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, ): string | null { + if (session?.activeTurnId && session.activeTurnId !== latestTurn?.turnId) { + return sendStartedAt; + } if (!isLatestTurnSettled(latestTurn, session)) { - return latestTurn?.startedAt ?? sendStartedAt; + return latestTurn?.startedAt ?? latestTurn?.requestedAt ?? sendStartedAt; } return sendStartedAt; }