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;
}