authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 00:12:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 00:12:40-07:00
log5b68b2c8e1e971e4ee6956157a1698646ef52a55
tree073c1baaaeac67c59458442bae8c901ccc420ddc
parent08c715ed93f8a6bfd85d42f5a8b7dbe12404554d
signature Signed by PGP key B5690EEEBB952194

feat(mobile): open the thread screen as soon as a new task is submitted (#10435)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

21 files changed, 1106 insertions(+), 377 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+79-163
......@@ -20,10 +20,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
2020import { useUniwindTheme } from "../../lib/useUniwindTheme";
2121import { useFontFamily } from "../../lib/useFontFamily";
2222
23import {
24 isAtomCommandInterrupted,
25 squashAtomCommandFailure,
26} from "@t3tools/client-runtime/state/runtime";
2723import {
2824 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
2925 resolveEnvironmentMachineKind,
......@@ -52,7 +48,6 @@ import { SymbolView } from "../../components/AppSymbol";
5248import { AppText as Text } from "../../components/AppText";
5349import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
5450import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
55import { ShimmeringWorkContent } from "./thread-work-log";
5651import { ComposerCommandPopover } from "./ComposerCommandPopover";
5752import { useComposerCommandMenu } from "./use-composer-command-menu";
5853import {
......@@ -78,7 +73,6 @@ import {
7873import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
7974import {
8075 clearComposerDraftContent,
81 flushComposerDrafts,
8276 getComposerDraftSnapshot,
8377 mergeComposerDraftContent,
8478 restoreComposerDraftSnapshot,
......@@ -91,15 +85,12 @@ import {
9185 isModelSelectionUnavailable,
9286 resolveSelectableModelSelection,
9387} from "../../lib/modelOptions";
94import { resolveProviderInteractionMode } from "./legacy-plan-mode";
9588import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
9689import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
9790import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
98import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
9991import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
10092import { useNewTaskFlow } from "./new-task-flow-provider";
10193import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
102import { useCreateProjectThread } from "./use-project-actions";
10394import { resolveDraftProjectSelection } from "./new-task-project-selection";
10495import {
10596 resolveNewTaskBranchLabel,
......@@ -158,7 +149,6 @@ export function NewTaskDraftScreen(props: {
158149 readonly incomingShareId?: string;
159150}) {
160151 const projects = useProjects();
161 const createProjectThread = useCreateProjectThread();
162152 const flow = useNewTaskFlow();
163153 const navigation = useNavigation();
164154 const {
......@@ -942,16 +932,6 @@ export function NewTaskDraftScreen(props: {
942932 ) ?? flow.selectedModel;
943933 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
944934 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
945 const selectedWorktreePath =
946 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
947 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
948 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
949 const interactionMode = resolveProviderInteractionMode(
950 selectedEnvironmentServerConfig?.providers.find(
951 (provider) => provider.instanceId === modelSelection?.instanceId,
952 ),
953 flow.planModeEnabled ? (draft.interactionMode ?? flow.interactionMode) : "default",
954 );
955935 const initialMessageText = draft.text.trim();
956936
957937 if (
......@@ -1000,122 +980,77 @@ export function NewTaskDraftScreen(props: {
1000980
1001981 const editingPendingTask = flow.editingPendingTask;
1002982
1003 if (queuesInsteadOfStarting) {
1004 // Offline, or an attachment is still uploading: park the task in the
1005 // outbox and let the drain send it once the environment is reachable
1006 // and the bytes are on the server. Editing an existing pending task
1007 // re-queues it under its original identifiers.
1008 const metadata = editingPendingTask
1009 ? {
1010 threadId: editingPendingTask.threadId,
1011 commandId: editingPendingTask.commandId,
1012 messageId: editingPendingTask.messageId,
1013 createdAt: editingPendingTask.createdAt,
1014 }
1015 : makeTurnCommandMetadata();
1016 const message = flow.buildPendingTaskMessage(metadata);
1017 if (!message) {
1018 return;
1019 }
1020 flow.setSubmitting(true);
1021 try {
1022 await enqueueThreadOutboxMessage(message);
1023 } catch (error) {
1024 Alert.alert(
1025 "Could not queue task",
1026 error instanceof Error ? error.message : "The task could not be saved to the outbox.",
1027 );
1028 return;
1029 } finally {
1030 flow.setSubmitting(false);
1031 }
1032 if (editingPendingTask) {
1033 flow.finishEditingPendingTask();
1034 } else {
1035 // Drop draft-local model/workspace selections with the content. The
1036 // next task re-resolves project defaults before sticky app defaults.
1037 clearComposerDraftContent(draftKey, {
1038 clearModelSelection: true,
1039 clearWorkspaceSelection: true,
1040 });
1041 }
1042 setSubmitNavigationAction(CommonActions.goBack());
983 // Every submission goes through the outbox: the drain uploads the
984 // attachments and delivers the creation, retrying across reconnects.
985 // When it can send now the thread screen opens immediately with the
986 // queued prompt and reports setup progress there, like the web draft
987 // does. Offline, or with uploads still in flight, the task stays a
988 // pending task and the sheet closes. Editing an existing pending task
989 // re-queues it under its original identifiers.
990 const metadata = editingPendingTask
991 ? {
992 threadId: editingPendingTask.threadId,
993 commandId: editingPendingTask.commandId,
994 messageId: editingPendingTask.messageId,
995 createdAt: editingPendingTask.createdAt,
996 }
997 : makeTurnCommandMetadata();
998 const message = flow.buildPendingTaskMessage(metadata, {
999 // A task that waits in the outbox cannot know the checkout it will
1000 // drain against; one that sends now runs against the live one.
1001 currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName,
1002 });
1003 if (!message) {
10431004 return;
10441005 }
1045
1006 if (!queuesInsteadOfStarting) {
1007 // Arm the lock-screen card before the async thread creation: backgrounding
1008 // the app right after tapping submit would otherwise reject the foreground
1009 // -only Activity start. If creation fails, the token registration's replay
1010 // finds no work and ends the card within seconds.
1011 armAgentAwarenessLiveActivityForLocalWork({
1012 environmentId: selectedProject.environmentId,
1013 threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
1014 projectTitle: selectedProject.title,
1015 });
1016 }
1017 // Persist before clearing the draft or leaving its editor. This only waits
1018 // for the local outbox write; server and worktree setup run on the thread.
10461019 flow.setSubmitting(true);
1047 // Arm the lock-screen card before the async thread creation: backgrounding
1048 // the app right after tapping submit would otherwise reject the foreground
1049 // -only Activity start. If creation fails, the token registration's replay
1050 // finds no work and ends the card within seconds.
1051 armAgentAwarenessLiveActivityForLocalWork({
1052 environmentId: selectedProject.environmentId,
1053 threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
1054 projectTitle: selectedProject.title,
1055 });
1056 const creationBranch = resolveProjectThreadCreationBranch({
1057 workspaceMode,
1058 selectedBranch: selectedBranchName,
1059 currentCheckoutBranch: flow.currentCheckoutBranchName,
1060 });
1061 const result = await createProjectThread({
1062 project: selectedProject,
1063 modelSelection,
1064 envMode: workspaceMode,
1065 branch: creationBranch,
1066 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
1067 startFromOrigin,
1068 runtimeMode,
1069 interactionMode,
1070 initialMessageText,
1071 initialAttachments: draft.attachments,
1072 onAttachmentsUploaded: async (attachments) => {
1073 flow.replaceAttachments(attachments);
1074 await flushComposerDrafts();
1075 },
1076 ...(editingPendingTask
1077 ? {
1078 turnMetadata: {
1079 threadId: editingPendingTask.threadId,
1080 commandId: editingPendingTask.commandId,
1081 messageId: editingPendingTask.messageId,
1082 createdAt: editingPendingTask.createdAt,
1083 },
1084 }
1085 : {}),
1086 });
1087 flow.setSubmitting(false);
1088
1089 if (result._tag === "Failure") {
1090 if (!isAtomCommandInterrupted(result)) {
1091 const error = squashAtomCommandFailure(result);
1092 Alert.alert(
1093 "Could not start task",
1094 error instanceof Error ? error.message : "The task could not be started.",
1095 );
1096 }
1020 try {
1021 await enqueueThreadOutboxMessage(message);
1022 } catch (error) {
1023 Alert.alert(
1024 "Could not queue task",
1025 error instanceof Error ? error.message : "The task could not be saved to the outbox.",
1026 );
10971027 return;
1028 } finally {
1029 flow.setSubmitting(false);
10981030 }
1099
1031 const draftSnapshot = getComposerDraftSnapshot(draftKey);
11001032 if (editingPendingTask) {
1101 try {
1102 await removeThreadOutboxMessage(editingPendingTask);
1103 } catch (error) {
1104 console.warn("[new-task] failed to remove delivered pending task", error);
1105 }
11061033 flow.finishEditingPendingTask();
11071034 } else {
1035 // Drop draft-local model/workspace selections with the content. The
1036 // next task re-resolves project defaults before sticky app defaults.
1037 // The queued message owns the attachments now, so the sweep is deferred
1038 // until the write confirms it.
11081039 clearComposerDraftContent(draftKey, {
11091040 clearModelSelection: true,
11101041 clearWorkspaceSelection: true,
1042 deferAttachmentCleanup: true,
11111043 });
11121044 }
11131045 setSubmitNavigationAction(
1114 StackActions.replace("Thread", {
1115 environmentId: String(result.value.environmentId),
1116 threadId: String(result.value.threadId),
1117 }),
1046 queuesInsteadOfStarting
1047 ? CommonActions.goBack()
1048 : StackActions.replace("Thread", {
1049 environmentId: String(message.environmentId),
1050 threadId: String(message.threadId),
1051 }),
11181052 );
1053 scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments);
11191054 }
11201055
11211056 if (!selectedProject) {
......@@ -1269,50 +1204,31 @@ export function NewTaskDraftScreen(props: {
12691204
12701205 const workspaceControls = (
12711206 <View className="flex-row items-center gap-1 px-2">
1272 {flow.submitting && !queuesInsteadOfStarting && flow.workspaceMode === "worktree" ? (
1273 <View
1274 accessible
1275 accessibilityLabel="Setting up worktree…"
1276 className="h-11 w-full max-w-[260px] flex-row items-center px-2"
1277 >
1278 <ShimmeringWorkContent
1279 icon="arrow.triangle.branch"
1280 iconSubtleColor={theme["--color-icon-subtle"]}
1281 label="Setting up worktree…"
1282 showIcon
1283 />
1284 </View>
1285 ) : (
1286 <>
1287 <ComposerInlineControl
1288 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
1289 accessibilityLabel={workspaceLabel}
1290 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1291 iconNode={
1292 <NewTaskWorkspaceIcon
1293 workspaceMode={flow.workspaceMode}
1294 worktreePath={flow.selectedWorktreePath}
1295 />
1296 }
1297 label={workspaceLabel}
1298 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
1299 onPress={() =>
1300 flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")
1301 }
1302 showChevron={false}
1207 <ComposerInlineControl
1208 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
1209 accessibilityLabel={workspaceLabel}
1210 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1211 iconNode={
1212 <NewTaskWorkspaceIcon
1213 workspaceMode={flow.workspaceMode}
1214 worktreePath={flow.selectedWorktreePath}
13031215 />
1216 }
1217 label={workspaceLabel}
1218 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
1219 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
1220 showChevron={false}
1221 />
13041222
1305 <ComposerInlineControl
1306 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
1307 chevronDirection="right"
1308 disabled={isComposerInteractionLocked}
1309 icon="arrow.triangle.branch"
1310 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
1311 maxWidth={190}
1312 onPress={() => openContextPicker("NewTaskBranch")}
1313 />
1314 </>
1315 )}
1223 <ComposerInlineControl
1224 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
1225 chevronDirection="right"
1226 disabled={isComposerInteractionLocked}
1227 icon="arrow.triangle.branch"
1228 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
1229 maxWidth={190}
1230 onPress={() => openContextPicker("NewTaskBranch")}
1231 />
13161232 </View>
13171233 );
13181234
apps/mobile/src/features/threads/ThreadComposer.tsx+6-6
......@@ -120,6 +120,8 @@ export interface ThreadComposerProps {
120120 readonly queueCount: number;
121121 readonly environmentId: EnvironmentId;
122122 readonly projectCwd: string | null;
123 /** Why sending is blocked right now (shown as the send button's label), or null. */
124 readonly sendBlockedReason?: string | null;
123125 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
124126 readonly onChangeDraftMessage: (value: string) => void;
125127 readonly onPickDraftMedia: () => Promise<void>;
......@@ -344,11 +346,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
344346 serverConfig: props.serverConfig,
345347 states: uploadStates,
346348 });
349 const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason;
347350 const canSend =
348 hasContent &&
349 !voiceInput.blocksSubmission &&
350 attachmentBlockReason === null &&
351 !modelUnavailable;
351 hasContent && !voiceInput.blocksSubmission && sendBlockedReason === null && !modelUnavailable;
352352
353353 // Keep the feed inset aligned with the card or compact dictation strip.
354354 useEffect(() => {
......@@ -701,7 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
701701 />
702702 ) : (
703703 <ComposerActionButton
704 accessibilityLabel={attachmentBlockReason ?? sendLabel}
704 accessibilityLabel={sendBlockedReason ?? sendLabel}
705705 icon="arrow.up"
706706 variant="primary"
707707 disabled={!canSend}
......@@ -792,7 +792,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
792792 />
793793 ) : voicePresentation.showsSend ? (
794794 <ComposerActionButton
795 accessibilityLabel={attachmentBlockReason ?? sendLabel}
795 accessibilityLabel={sendBlockedReason ?? sendLabel}
796796 icon="arrow.up"
797797 variant="primary"
798798 disabled={!canSend}
apps/mobile/src/features/threads/ThreadCreationFailedCard.tsx created+36
......@@ -0,0 +1,36 @@
1import { Pressable, View } from "react-native";
2
3import { AppText as Text } from "../../components/AppText";
4
5/**
6 * Shown in place of the composer when the server rejected a new task. The
7 * prompt and attachments are already back in the project draft, so the only
8 * action is reopening it.
9 */
10export function ThreadCreationFailedCard(props: {
11 readonly reason: string;
12 readonly onEditTask: () => void;
13}) {
14 return (
15 <View className="gap-2.5 rounded-[20px] border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 p-4">
16 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-adaptive-rose-700-300">
17 Could not start task
18 </Text>
19 <Text className="font-sans text-sm leading-normal text-adaptive-neutral-600-400">
20 {props.reason}
21 </Text>
22 <Text className="font-sans text-xs leading-normal text-adaptive-neutral-600-400">
23 Your prompt was kept in the project draft.
24 </Text>
25 <View className="flex-row">
26 <Pressable
27 accessibilityRole="button"
28 className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3"
29 onPress={props.onEditTask}
30 >
31 <Text className="text-sm font-t3-extrabold text-white">Edit task</Text>
32 </Pressable>
33 </View>
34 </View>
35 );
36}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+48-2
......@@ -81,6 +81,7 @@ import { PendingApprovalCard } from "./PendingApprovalCard";
8181import { ComposerFeedback } from "./ComposerFeedback";
8282import { ComposerUsageLimits } from "./ComposerUsageLimits";
8383import { PendingUserInputCard } from "./PendingUserInputCard";
84import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
8485import {
8586 FLOATING_WORKING_CONTROL_COVERAGE,
8687 FloatingWorkingControl,
......@@ -113,6 +114,15 @@ export interface ThreadDetailScreenProps {
113114 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
114115 readonly activeWorkStartedAt: string | null;
115116 readonly isCompacting: boolean;
117 /**
118 * The server has not created this thread yet. "preparing" runs while the
119 * queued creation is delivered (a worktree may be checking out); "failed"
120 * is a rejected creation whose content went back to the project draft.
121 */
122 readonly creationState:
123 | { readonly kind: "preparing"; readonly preparingWorktree: boolean }
124 | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void }
125 | null;
116126 readonly activePendingApproval: PendingApproval | null;
117127 readonly respondingApprovalId: ApprovalRequestId | null;
118128 readonly activePendingUserInput: PendingUserInput | null;
......@@ -347,6 +357,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
347357 if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {
348358 return null;
349359 }
360 if (props.creationState?.kind === "preparing") {
361 return {
362 kind: "preparing",
363 label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…",
364 };
365 }
366 if (props.creationState?.kind === "failed") {
367 return null;
368 }
350369 if (threadSyncLabel !== null) {
351370 return { kind: "syncing", label: threadSyncLabel };
352371 }
......@@ -917,6 +936,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
917936 />
918937 </Animated.View>
919938 ) : null}
939 {props.creationState?.kind === "failed" ? (
940 <Animated.View
941 className="shrink-0 px-4"
942 style={{ paddingBottom: composerBottomInset }}
943 entering={FadeInDown.duration(220)}
944 exiting={FadeOut.duration(140)}
945 >
946 <ThreadCreationFailedCard
947 reason={props.creationState.reason}
948 onEditTask={props.creationState.onEditTask}
949 />
950 </Animated.View>
951 ) : null}
920952 {props.activePendingApproval || props.activePendingUserInput ? (
921953 <Animated.View
922954 className="shrink-0 gap-3 px-4 pb-3"
......@@ -961,8 +993,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
961993 </View>
962994
963995 {/* Hidden (not unmounted) while a user-input request owns the
964 composer slot, so composer drafts and editor state survive. */}
965 <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}>
996 composer slot, so composer drafts and editor state survive.
997 A rejected creation has no thread to send to; the failure card
998 owns the slot instead. */}
999 <View
1000 style={
1001 activeUserInputRequestId !== null || props.creationState?.kind === "failed"
1002 ? { display: "none" }
1003 : undefined
1004 }
1005 >
9661006 <ThreadComposer
9671007 editorRef={composerEditorRef}
9681008 draftMessage={props.draftMessage}
......@@ -977,6 +1017,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
9771017 queueCount={props.selectedThreadQueueCount}
9781018 environmentId={props.environmentId}
9791019 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1020 // Follow-ups typed during setup wait in the draft: queueing
1021 // them against a thread id the server may still reject
1022 // would strand them in the outbox.
1023 sendBlockedReason={
1024 props.creationState?.kind === "preparing" ? "Starting the task…" : null
1025 }
9801026 bottomInset={composerBottomInset}
9811027 onChangeDraftMessage={props.onChangeDraftMessage}
9821028 onPickDraftMedia={props.onPickDraftMedia}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+70-10
......@@ -22,9 +22,12 @@ import {
2222 projectScriptRuntimeEnv,
2323 resolveProjectScripts,
2424} from "@t3tools/shared/projectScripts";
25import { Platform, ScrollView, View } from "react-native";
25import { Alert, Platform, ScrollView, View } from "react-native";
2626import { useSafeAreaInsets } from "react-native-safe-area-context";
2727import { useWorkspaceState } from "../../state/workspace";
28import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key";
29import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation";
30import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft";
2831import { useEnvironmentQuery } from "../../state/query";
2932import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
3033import { vcsEnvironment } from "../../state/vcs";
......@@ -58,7 +61,7 @@ import {
5861 stagePendingTerminalLaunch,
5962} from "../terminal/terminalLaunchContext";
6063import { terminalDebugLog } from "../terminal/terminalDebugLog";
61import { ThreadDetailScreen } from "./ThreadDetailScreen";
64import { ThreadDetailScreen, type ThreadDetailScreenProps } from "./ThreadDetailScreen";
6265import {
6366 ThreadGitControls,
6467 useThreadGitCenterHeaderItems,
......@@ -200,8 +203,12 @@ function ThreadRouteContent(
200203 } = useAdaptiveWorkspaceLayout();
201204 const { connectionState } = useRemoteConnectionStatus();
202205 const { onReconnectEnvironment } = useRemoteConnections();
203 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
204 useThreadSelection();
206 const {
207 selectedThread,
208 selectedThreadCreation,
209 selectedThreadProject,
210 selectedEnvironmentConnection,
211 } = useThreadSelection();
205212 const selectedThreadDetailState = props.selectedThreadDetailState;
206213 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
207214 // "Load earlier turns" header state for windowed (paginated) thread loads.
......@@ -742,6 +749,52 @@ function ThreadRouteContent(
742749 selectedThreadProject?.workspaceRoot,
743750 ]);
744751
752 const handleEditFailedCreation = useCallback(async () => {
753 const creation = selectedThreadCreation?.message;
754 if (!creation?.creation || routeThreadIdentity === null) {
755 return;
756 }
757 // The drain restored the prompt and attachments into the recovery draft
758 // the rejected creation owns. Open that draft by id: without it the sheet
759 // mints a fresh empty one and the restored content is unreachable.
760 try {
761 await recoverFailedThreadDraft(creation);
762 } catch (error) {
763 Alert.alert(
764 "Could not restore draft",
765 error instanceof Error ? error.message : String(error),
766 );
767 return;
768 }
769 clearPendingThreadCreationOutcome(routeThreadIdentity);
770 navigation.dispatch(
771 StackActions.replace("NewTaskSheet", {
772 screen: "NewTaskDraft",
773 params: {
774 draftId: restoredNewTaskDraftKey(creation.messageId),
775 environmentId: String(creation.environmentId),
776 projectId: String(creation.creation.projectId),
777 ...(selectedThreadProject ? { title: selectedThreadProject.title } : {}),
778 },
779 }),
780 );
781 }, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]);
782 const creationState = ((): ThreadDetailScreenProps["creationState"] => {
783 if (selectedThreadCreation === null) {
784 return null;
785 }
786 if (selectedThreadCreation.outcome?.kind === "failed") {
787 return {
788 kind: "failed",
789 reason: selectedThreadCreation.outcome.reason,
790 onEditTask: handleEditFailedCreation,
791 };
792 }
793 return {
794 kind: "preparing",
795 preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree",
796 };
797 })();
745798 // Deep links / cold starts land with Thread as the ONLY route, where the
746799 // native back button does not render. Provide an explicit Home escape for
747800 // that case; when history exists the native back button is used instead.
......@@ -767,12 +820,18 @@ function ThreadRouteContent(
767820 return <OpeningThreadLoadingScreen />;
768821 }
769822
770 const contentPresentation = projectThreadContentPresentation({
771 hasDetail: selectedThreadDetail !== null,
772 detailError: Option.getOrNull(selectedThreadDetailState.error),
773 detailDeleted: selectedThreadDetailState.status === "deleted",
774 connectionState: routeConnectionState,
775 });
823 // A queued creation renders as ready content: its prompt is the whole
824 // conversation until the server creates the thread. The subscription's
825 // not-found error for that window is expected, not a load failure.
826 const contentPresentation =
827 creationState !== null
828 ? { kind: "ready" as const }
829 : projectThreadContentPresentation({
830 hasDetail: selectedThreadDetail !== null,
831 detailError: Option.getOrNull(selectedThreadDetailState.error),
832 detailDeleted: selectedThreadDetailState.status === "deleted",
833 connectionState: routeConnectionState,
834 });
776835 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
777836 const renderThreadRouteBody = (showActionControls: boolean) => (
778837 <>
......@@ -792,6 +851,7 @@ function ThreadRouteContent(
792851 selectedThreadFeed={composer.selectedThreadFeed}
793852 activeWorkStartedAt={composer.activeWorkStartedAt}
794853 isCompacting={composer.isCompacting}
854 creationState={creationState}
795855 activePendingApproval={requests.activePendingApproval}
796856 respondingApprovalId={requests.respondingApprovalId}
797857 activePendingUserInput={requests.activePendingUserInput}
apps/mobile/src/features/threads/floating-working-control.tsx+27
......@@ -24,6 +24,7 @@ import { SymbolView } from "../../components/AppSymbol";
2424import { ControlPill } from "../../components/ControlPill";
2525import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
2626import type { FloatingWorkingStatus } from "./floating-working-status";
27import { ShimmeringWorkContent } from "./thread-work-log";
2728
2829const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem
2930// The collapsed composer capsule starts 6 below its overlay's top edge, so
......@@ -279,6 +280,32 @@ function FloatingStatusLabel(props: {
279280 </StatusLabelRow>
280281 );
281282 }
283 if (props.status.kind === "preparing") {
284 return (
285 <StatusLabelRow
286 key="preparing"
287 accessibilityLabel={props.status.label}
288 className="gap-1.5"
289 onLayout={props.onLayout}
290 >
291 <SymbolView
292 name="arrow.triangle.branch"
293 size={13}
294 tintColorClassName="foreground"
295 type="monochrome"
296 />
297 <ShimmeringWorkContent
298 className="flex-none"
299 textClassName="font-t3-medium"
300 compact
301 icon="arrow.triangle.branch"
302 iconSubtleColor="transparent"
303 label={props.status.label}
304 showIcon={false}
305 />
306 </StatusLabelRow>
307 );
308 }
282309 return (
283310 <WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} />
284311 );
apps/mobile/src/features/threads/floating-working-status.ts+3
......@@ -9,6 +9,9 @@ export type FloatingWorkingStatus =
99 | { readonly kind: "working"; readonly startedAt: string }
1010 | { readonly kind: "syncing"; readonly label: string }
1111 | { readonly kind: "compacting" }
12 // A task whose thread the server has not created yet: the worktree may
13 // still be checking out, so there is no turn to time.
14 | { readonly kind: "preparing"; readonly label: string }
1215 | {
1316 readonly kind: "connection";
1417 readonly tone: "reconnecting" | "unavailable";
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-7
......@@ -96,6 +96,7 @@ import {
9696 resolveNewTaskLocalWorkspaceSelection,
9797} from "./new-task-context-presentation";
9898import { resolveEnvironmentProjectMatch } from "./new-task-project-selection";
99import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
99100
100101type WorkspaceMode = "local" | "worktree";
101102
......@@ -189,7 +190,13 @@ type NewTaskFlowContextValue = {
189190 readonly beginEditingPendingTask: (messageId: string) => boolean;
190191 readonly finishEditingPendingTask: () => void;
191192 readonly cancelEditingPendingTask: () => void;
192 readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null;
193 readonly buildPendingTaskMessage: (
194 metadata: TurnCommandMetadata,
195 options?: {
196 /** The live checkout, recorded as a local task's branch when it sends now. */
197 readonly currentCheckoutBranch?: string | null;
198 },
199 ) => QueuedThreadMessage | null;
193200 readonly setPrompt: (value: string) => void;
194201 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void;
195202 /** Appends draft attachments; returns how many the live cap rejected. */
......@@ -916,7 +923,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
916923 }, []);
917924
918925 const buildPendingTaskMessage = useCallback(
919 (metadata: TurnCommandMetadata): QueuedThreadMessage | null => {
926 (
927 metadata: TurnCommandMetadata,
928 options?: { readonly currentCheckoutBranch?: string | null },
929 ): QueuedThreadMessage | null => {
920930 if (!selectedProject || !selectedProjectDraftKey) {
921931 return null;
922932 }
......@@ -970,11 +980,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
970980 ...(projectTitle !== undefined ? { projectTitle } : {}),
971981 ...(projectCwd !== undefined ? { projectCwd } : {}),
972982 workspaceMode: mode,
973 // Only an explicit picker choice, never the current checkout: a
974 // queued local task drains days later against whatever is checked
975 // out then, so recording a queue-time guess would pin a stale label
976 // to a thread that ran somewhere else.
977 branch: workspaceSelection?.branch ?? null,
983 // An explicit picker choice wins. Otherwise only a task sending now
984 // records the current checkout: a queued local task drains days
985 // later against whatever is checked out then, so a queue-time
986 // guess would pin a stale label to a thread that ran somewhere else.
987 branch: resolveProjectThreadCreationBranch({
988 workspaceMode: mode,
989 selectedBranch: workspaceSelection?.branch ?? null,
990 currentCheckoutBranch: options?.currentCheckoutBranch ?? null,
991 }),
978992 worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
979993 // The draft only carries the flag when the user touched it; fall
980994 // back to the resolved default (server settings) so queued tasks
apps/mobile/src/features/threads/thread-work-log.tsx+7-1
......@@ -139,6 +139,7 @@ export function ThreadDisclosureChevron(props: {
139139}
140140
141141function ShimmerWorkContent(props: {
142 readonly textClassName?: string;
142143 readonly compact?: boolean;
143144 readonly environmentId?: EnvironmentId;
144145 readonly highlighted: boolean;
......@@ -176,6 +177,7 @@ function ShimmerWorkContent(props: {
176177 "min-w-0 shrink",
177178 props.compact ? "text-xs" : "text-sm",
178179 props.highlighted ? "text-foreground" : "text-foreground-muted",
180 props.textClassName,
179181 )}
180182 numberOfLines={1}
181183 onTextLayout={props.onTextLayout}
......@@ -187,6 +189,8 @@ function ShimmerWorkContent(props: {
187189}
188190
189191export function ShimmeringWorkContent(props: {
192 readonly className?: string;
193 readonly textClassName?: string;
190194 /** Secondary line: no icon slot, caption size. */
191195 readonly compact?: boolean;
192196 readonly environmentId?: EnvironmentId;
......@@ -259,10 +263,11 @@ export function ShimmeringWorkContent(props: {
259263
260264 return (
261265 <View
262 className="min-w-0 flex-1 overflow-hidden"
266 className={cn("min-w-0 flex-1 overflow-hidden", props.className)}
263267 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
264268 >
265269 <ShimmerWorkContent
270 textClassName={props.textClassName}
266271 compact={props.compact}
267272 environmentId={props.environmentId}
268273 highlighted={false}
......@@ -304,6 +309,7 @@ export function ShimmeringWorkContent(props: {
304309 >
305310 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>
306311 <ShimmerWorkContent
312 textClassName={props.textClassName}
307313 compact={props.compact}
308314 environmentId={props.environmentId}
309315 highlighted
apps/mobile/src/features/threads/use-project-actions.ts deleted-168
......@@ -1,168 +0,0 @@
1import { useCallback } from "react";
2
3import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime";
6import {
7 ThreadId,
8 type ModelSelection,
9 type ProviderInteractionMode,
10 type RuntimeMode,
11} from "@t3tools/contracts";
12import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/unstable/reactivity";
15
16import { threadEnvironment } from "../../state/threads";
17import type { DraftComposerAttachment } from "../../lib/composerImages";
18import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/attachmentUpload";
19import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
20import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
21import { randomHex } from "../../lib/uuid";
22import { isModelSelectionUnavailable } from "../../lib/modelOptions";
23import { useAtomCommand } from "../../state/use-atom-command";
24import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts";
25import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
26import { validateProjectThreadCreation } from "./projectThreadCreationValidation";
27import { appAtomRegistry } from "../../state/atom-registry";
28import { serverEnvironment } from "../../state/server";
29import { resolveProviderInteractionMode } from "./legacy-plan-mode";
30
31export function useCreateProjectThread() {
32 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
33
34 return useCallback(
35 async (input: {
36 readonly project: EnvironmentProject;
37 readonly modelSelection: ModelSelection;
38 readonly envMode: "local" | "worktree";
39 readonly branch: string | null;
40 readonly worktreePath: string | null;
41 readonly startFromOrigin?: boolean;
42 readonly runtimeMode: RuntimeMode;
43 readonly interactionMode: ProviderInteractionMode;
44 readonly initialMessageText: string;
45 readonly initialAttachments: ReadonlyArray<DraftComposerAttachment>;
46 readonly onAttachmentsUploaded: (
47 attachments: ReadonlyArray<DraftComposerAttachment>,
48 ) => Promise<void>;
49 /** Reuse identifiers from a queued pending task instead of minting new ones. */
50 readonly turnMetadata?: TurnCommandMetadata;
51 }) => {
52 const metadata = input.turnMetadata ?? makeTurnCommandMetadata();
53 const threadId = ThreadId.make(metadata.threadId);
54 const initialMessageText = input.initialMessageText.trim();
55
56 const validationError = validateProjectThreadCreation({
57 environmentId: input.project.environmentId,
58 projectId: input.project.id,
59 environmentMode: input.envMode,
60 branch: input.branch,
61 initialMessageText,
62 });
63 if (validationError !== null) {
64 setPendingConnectionError(validationError.message);
65 return AsyncResult.failure(Cause.fail(validationError));
66 }
67
68 const validateLiveFileAttachments = (
69 attachments: ReadonlyArray<DraftComposerAttachment>,
70 ): string | null =>
71 validateDraftFileAttachments({
72 attachments,
73 serverConfig: appAtomRegistry.get(
74 serverEnvironment.configValueAtom(input.project.environmentId),
75 ),
76 });
77 const initialAttachmentError = validateLiveFileAttachments(input.initialAttachments);
78 if (initialAttachmentError !== null) {
79 setPendingConnectionError(initialAttachmentError);
80 return AsyncResult.failure(Cause.fail(new Error(initialAttachmentError)));
81 }
82
83 let prepared: Awaited<ReturnType<typeof prepareTurnAttachments>>;
84 try {
85 // If persisting the references into the draft throws, the owner call
86 // deletes the pending uploads it minted before rethrowing.
87 prepared = await prepareTurnAttachments({
88 environmentId: input.project.environmentId,
89 attachments: input.initialAttachments,
90 supportsImageUploads:
91 appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId))
92 ?.environment.capabilities.attachmentUploads === true,
93 persistUploadedReferences: async (draftAttachments) => {
94 await input.onAttachmentsUploaded(draftAttachments);
95 return "persisted";
96 },
97 });
98 } catch (error) {
99 const message = error instanceof Error ? error.message : "An attachment could not upload.";
100 setPendingConnectionError(message);
101 return AsyncResult.failure(Cause.fail(new Error(message)));
102 }
103 if (prepared.status !== "ready") {
104 const message = "The attachments are no longer available.";
105 setPendingConnectionError(message);
106 return AsyncResult.failure(Cause.fail(new Error(message)));
107 }
108
109 const preparedAttachmentError = validateLiveFileAttachments(prepared.draftAttachments);
110 if (preparedAttachmentError !== null) {
111 setPendingConnectionError(preparedAttachmentError);
112 return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError)));
113 }
114
115 const serverConfig = appAtomRegistry.get(
116 serverEnvironment.configValueAtom(input.project.environmentId),
117 );
118 const providerError = !serverConfig
119 ? "Provider settings are still loading. Try again."
120 : isModelSelectionUnavailable(serverConfig, input.modelSelection)
121 ? "Antigravity model unavailable. Set it up on web or desktop, or choose another model."
122 : null;
123 if (providerError !== null) {
124 setPendingConnectionError(providerError);
125 return AsyncResult.failure(Cause.fail(new Error(providerError)));
126 }
127 const provider = serverConfig?.providers.find(
128 (candidate) => candidate.instanceId === input.modelSelection.instanceId,
129 );
130
131 const result = await startTurn({
132 environmentId: input.project.environmentId,
133 input: buildProjectThreadStartTurnInput({
134 projectId: input.project.id,
135 projectCwd: input.project.workspaceRoot,
136 threadId: metadata.threadId,
137 commandId: metadata.commandId,
138 messageId: metadata.messageId,
139 createdAt: metadata.createdAt,
140 text: initialMessageText,
141 uploadedAttachments: prepared.attachments,
142 modelSelection: input.modelSelection,
143 runtimeMode: input.runtimeMode,
144 interactionMode: resolveProviderInteractionMode(provider, input.interactionMode),
145 workspaceMode: input.envMode,
146 branch: input.branch,
147 worktreePath: input.worktreePath,
148 startFromOrigin: input.startFromOrigin ?? false,
149 worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
150 }),
151 });
152 if (AsyncResult.isFailure(result)) {
153 const error = Cause.squash(result.cause);
154 setPendingConnectionError(
155 error instanceof Error ? error.message : "The task could not be started.",
156 );
157 return AsyncResult.failure(result.cause);
158 }
159 setPendingConnectionError(null);
160 scheduleUnusedComposerAttachmentCleanup(prepared.draftAttachments);
161
162 return mapAtomCommandResult(result, () =>
163 scopeThreadRef(input.project.environmentId, threadId),
164 );
165 },
166 [startTurn],
167 );
168}
apps/mobile/src/state/new-task-draft-key.ts+9
......@@ -9,6 +9,15 @@ export function isNewTaskDraftKey(draftKey: string): boolean {
99 return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX);
1010}
1111
12/**
13 * The draft a rejected queued task's content is restored into. The outbox
14 * drain writes it and the thread screen's "Edit task" action opens it, so both
15 * sides derive the key here rather than rebuilding the string.
16 */
17export function restoredNewTaskDraftKey(messageId: string): string {
18 return newTaskDraftKey(`restored-${messageId}`);
19}
20
1221/**
1322 * Builds before drafts were id-keyed used `new-task:<environmentId>:<projectId>`,
1423 * one slot per project. Ids are UUIDs and never contain a colon, so a colon
apps/mobile/src/state/pending-thread-creation.test.ts created+247
......@@ -0,0 +1,247 @@
1import {
2 CommandId,
3 EnvironmentId,
4 MessageId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";
10
11import {
12 isPendingThreadCreationVisible,
13 pendingThreadCreationMessage,
14 pendingThreadCreationShell,
15 resolvePendingThreadCreation,
16 type PendingThreadCreation,
17} from "./pending-thread-creation";
18import type { QueuedThreadMessage } from "./thread-outbox-model";
19
20const creation: QueuedThreadMessage = {
21 environmentId: EnvironmentId.make("environment-1"),
22 threadId: ThreadId.make("thread-1"),
23 messageId: MessageId.make("message-1"),
24 commandId: CommandId.make("command-1"),
25 text: "Fix the flaky login test",
26 attachments: [
27 {
28 id: "draft-image",
29 type: "image",
30 name: "screen.png",
31 mimeType: "image/png",
32 sizeBytes: 10,
33 previewUri: "data:image/png;base64,AAAA",
34 },
35 ],
36 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
37 runtimeMode: "full-access",
38 creation: {
39 projectId: ProjectId.make("project-1"),
40 workspaceMode: "worktree",
41 branch: "main",
42 worktreePath: null,
43 },
44 createdAt: "2026-08-24T12:00:00.000Z",
45};
46
47describe("resolvePendingThreadCreation", () => {
48 const threadKey = `${creation.environmentId}:${creation.threadId}`;
49 const pending: PendingThreadCreation = { message: creation, outcome: null };
50 const prompt = { id: creation.messageId };
51
52 it("keeps setup visible through the prompt echo and shell cleanup until detail has a turn", () => {
53 let previous = resolvePendingThreadCreation({
54 threadKey,
55 pending,
56 previous: null,
57 detail: null,
58 });
59 expect(previous).toBe(pending);
60
61 previous = resolvePendingThreadCreation({
62 threadKey,
63 pending,
64 previous,
65 detail: { messages: [], latestTurn: null, session: null },
66 });
67 expect(previous).toBe(pending);
68
69 // The user message arrives before the provider publishes a timed turn.
70 previous = resolvePendingThreadCreation({
71 threadKey,
72 pending,
73 previous,
74 detail: { messages: [prompt], latestTurn: null, session: { status: "starting" } },
75 });
76 expect(previous).toBe(pending);
77
78 // The shell stream may observe the turn and collect the global outcome
79 // before this screen's detail stream catches up.
80 previous = resolvePendingThreadCreation({
81 threadKey,
82 pending: null,
83 previous,
84 detail: { messages: [prompt], latestTurn: null, session: { status: "starting" } },
85 });
86 expect(previous).toBe(pending);
87
88 expect(
89 resolvePendingThreadCreation({
90 threadKey,
91 pending: null,
92 previous,
93 detail: {
94 messages: [prompt],
95 latestTurn: { turnId: "turn-1" },
96 session: { status: "running" },
97 },
98 }),
99 ).toBeNull();
100 });
101
102 it("keeps the prompt until both the turn and its message have arrived", () => {
103 expect(
104 resolvePendingThreadCreation({
105 threadKey,
106 pending,
107 previous: null,
108 detail: { messages: [], latestTurn: { turnId: "turn-1" }, session: { status: "running" } },
109 }),
110 ).toBe(pending);
111 });
112
113 it.each(["error", "stopped", "interrupted"])("ends setup when startup is %s", (status) => {
114 expect(
115 resolvePendingThreadCreation({
116 threadKey,
117 pending: null,
118 previous: pending,
119 detail: { messages: [prompt], latestTurn: null, session: { status } },
120 }),
121 ).toBeNull();
122 });
123
124 it("preserves rejected task recovery", () => {
125 const failed: PendingThreadCreation = {
126 message: creation,
127 outcome: { kind: "failed", message: creation, reason: "Checkout failed" },
128 };
129 expect(
130 resolvePendingThreadCreation({
131 threadKey,
132 pending: failed,
133 previous: pending,
134 detail: { messages: [], latestTurn: null, session: { status: "error" } },
135 }),
136 ).toBe(failed);
137 });
138
139 it("does not carry setup into another thread or invent it for existing threads", () => {
140 expect(
141 resolvePendingThreadCreation({
142 threadKey: "another-thread",
143 pending: null,
144 previous: pending,
145 detail: null,
146 }),
147 ).toBeNull();
148 expect(
149 resolvePendingThreadCreation({
150 threadKey,
151 pending: null,
152 previous: null,
153 detail: null,
154 }),
155 ).toBeNull();
156 });
157});
158
159describe("pendingThreadCreationShell", () => {
160 it("shapes a queued creation as the thread shell the screen renders before creation", () => {
161 expect(pendingThreadCreationShell(creation)).toMatchObject({
162 environmentId: creation.environmentId,
163 id: creation.threadId,
164 projectId: creation.creation!.projectId,
165 title: "Fix the flaky login test",
166 modelSelection: creation.modelSelection,
167 runtimeMode: "full-access",
168 interactionMode: "default",
169 branch: "main",
170 worktreePath: null,
171 latestTurn: null,
172 session: null,
173 latestUserMessageAt: creation.createdAt,
174 });
175 });
176
177 it("keeps a local task's explicit worktree path", () => {
178 expect(
179 pendingThreadCreationShell({
180 ...creation,
181 creation: {
182 ...creation.creation!,
183 workspaceMode: "local",
184 worktreePath: "/repo/.worktrees/feature",
185 },
186 })?.worktreePath,
187 ).toBe("/repo/.worktrees/feature");
188 });
189
190 it("returns null for a follow-up message or a creation without a model", () => {
191 expect(pendingThreadCreationShell({ ...creation, creation: undefined })).toBeNull();
192 expect(pendingThreadCreationShell({ ...creation, modelSelection: undefined })).toBeNull();
193 });
194});
195
196describe("isPendingThreadCreationVisible", () => {
197 const creationMessageId = String(creation.messageId);
198
199 it("stands in before any detail has loaded", () => {
200 expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: null })).toBe(
201 true,
202 );
203 });
204
205 // The regression: the server creates the thread, THEN builds the worktree,
206 // then starts the turn. The shell and an empty detail arrive seconds before
207 // the prompt, and keying on the shell left the thread empty for that whole
208 // window.
209 it("keeps standing in while the created thread has no messages yet", () => {
210 expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: [] })).toBe(true);
211 });
212
213 it("keeps standing in when the thread holds only unrelated messages", () => {
214 expect(
215 isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: ["someone-else"] }),
216 ).toBe(true);
217 });
218
219 it("stands down once the delivered prompt lands under the same id", () => {
220 expect(
221 isPendingThreadCreationVisible({
222 creationMessageId,
223 loadedMessageIds: ["someone-else", creationMessageId],
224 }),
225 ).toBe(false);
226 });
227});
228
229describe("pendingThreadCreationMessage", () => {
230 it("renders the queued prompt as the first user message", () => {
231 expect(pendingThreadCreationMessage(creation)).toEqual({
232 id: creation.messageId,
233 role: "user",
234 text: creation.text,
235 turnId: null,
236 streaming: false,
237 createdAt: creation.createdAt,
238 updatedAt: creation.createdAt,
239 });
240 });
241
242 // Draft attachment ids are local; the feed resolves attachment rows against
243 // the server and would spin forever on them.
244 it("omits the queued attachments rather than passing local draft ids to the feed", () => {
245 expect(pendingThreadCreationMessage(creation)).not.toHaveProperty("attachments");
246 });
247});
apps/mobile/src/state/pending-thread-creation.ts created+163
......@@ -0,0 +1,163 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import type { OrchestrationThread } from "@t3tools/contracts";
3import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";
4import { Atom } from "effect/unstable/reactivity";
5
6import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
7import { scopedThreadKey } from "../lib/scopedEntities";
8import { appAtomRegistry } from "./atom-registry";
9import type { QueuedThreadMessage } from "./thread-outbox-model";
10
11/**
12 * A new task navigates to its thread screen the moment it is queued, before the
13 * server has created the thread. Until the shell arrives the screen renders a
14 * stand-in built from the queued creation. The outcome recorded by the outbox
15 * drain covers the two windows that stand-in cannot: the gap between delivery
16 * and the first turn (keep showing setup) and a rejected creation
17 * (the drain restored the content into the project draft; offer to reopen it).
18 */
19export type PendingThreadCreationOutcome =
20 | { readonly kind: "delivered"; readonly message: QueuedThreadMessage }
21 | { readonly kind: "failed"; readonly message: QueuedThreadMessage; readonly reason: string };
22
23export type PendingThreadCreation = {
24 readonly message: QueuedThreadMessage;
25 readonly outcome: PendingThreadCreationOutcome | null;
26};
27
28/** Keep the screen's creation state until its detail can take over the pill. */
29export function resolvePendingThreadCreation(input: {
30 readonly threadKey: string | null;
31 readonly pending: PendingThreadCreation | null;
32 readonly previous: PendingThreadCreation | null;
33 readonly detail: {
34 readonly messages: ReadonlyArray<{ readonly id: string }>;
35 readonly latestTurn: { readonly turnId: string } | null;
36 readonly session: { readonly status: string } | null;
37 } | null;
38}): PendingThreadCreation | null {
39 const creation = input.pending ?? input.previous;
40 if (
41 creation === null ||
42 scopedThreadKey(creation.message.environmentId, creation.message.threadId) !== input.threadKey
43 ) {
44 return null;
45 }
46 if (creation.outcome?.kind === "failed") return creation;
47 const detail = input.detail;
48 if (
49 detail?.session?.status === "error" ||
50 detail?.session?.status === "stopped" ||
51 detail?.session?.status === "interrupted"
52 )
53 return null;
54 // Message delivery and turn startup are separate events. The prompt alone
55 // cannot replace the preparing pill; wait for the turn's timing too. Retain
56 // the local creation if the outbox has already collected its shell outcome.
57 if (
58 detail !== null &&
59 detail.latestTurn !== null &&
60 !isPendingThreadCreationVisible({
61 creationMessageId: creation.message.messageId,
62 loadedMessageIds: detail.messages.map((message) => message.id),
63 })
64 ) {
65 return null;
66 }
67 return creation;
68}
69
70export const pendingThreadCreationOutcomesAtom = Atom.make<
71 Readonly<Record<string, PendingThreadCreationOutcome>>
72>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:pending-thread-creation:outcomes"));
73
74export function recordPendingThreadCreationOutcome(outcome: PendingThreadCreationOutcome): void {
75 const key = scopedThreadKey(outcome.message.environmentId, outcome.message.threadId);
76 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {
77 ...appAtomRegistry.get(pendingThreadCreationOutcomesAtom),
78 [key]: outcome,
79 });
80}
81
82export function clearPendingThreadCreationOutcome(threadKey: string): void {
83 const current = appAtomRegistry.get(pendingThreadCreationOutcomesAtom);
84 if (!current[threadKey]) {
85 return;
86 }
87 const next = { ...current };
88 delete next[threadKey];
89 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, next);
90}
91
92/**
93 * Whether the queued prompt still has to stand in for the real message.
94 *
95 * The server creates the thread, then builds the worktree, and only then
96 * starts the turn, so the thread shell and an empty detail arrive seconds
97 * ahead of the prompt. Keying this on the shell's arrival left the thread
98 * showing "No conversation yet" for that whole window. The queued message id
99 * is reused as the delivered message id, so its presence is the exact signal.
100 */
101export function isPendingThreadCreationVisible(input: {
102 readonly creationMessageId: string;
103 /** Null while no detail has loaded; empty during a worktree checkout. */
104 readonly loadedMessageIds: ReadonlyArray<string> | null;
105}): boolean {
106 return !input.loadedMessageIds?.includes(input.creationMessageId);
107}
108
109export function pendingThreadCreationMessage(
110 message: QueuedThreadMessage,
111): OrchestrationThread["messages"][number] {
112 return {
113 id: message.messageId,
114 role: "user",
115 text: message.text,
116 // Deliberately no attachments. Their ids are local draft ids the server
117 // cannot resolve, so the feed's attachment rows would sit on a spinner
118 // that only ends when the real message arrives — and never, if the
119 // creation is rejected. The delivered message renders them moments later.
120 turnId: null,
121 streaming: false,
122 createdAt: message.createdAt,
123 updatedAt: message.createdAt,
124 };
125}
126
127/**
128 * Thread shell shaped from a queued creation. `modelSelection` is required on
129 * the shell; a creation is only sendable with one, so the fallback never sends.
130 */
131export function pendingThreadCreationShell(
132 message: QueuedThreadMessage,
133): EnvironmentThreadShell | null {
134 const creation = message.creation;
135 if (!creation || !message.modelSelection) {
136 return null;
137 }
138 return {
139 environmentId: message.environmentId,
140 id: message.threadId,
141 projectId: creation.projectId,
142 title: deriveThreadTitleFromPrompt(message.text),
143 modelSelection: message.modelSelection,
144 runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE,
145 interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
146 branch: creation.branch,
147 worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath,
148 linkedPullRequest: null,
149 latestTurn: null,
150 createdAt: message.createdAt,
151 updatedAt: message.createdAt,
152 archivedAt: null,
153 settledOverride: null,
154 settledAt: null,
155 snoozedUntil: null,
156 snoozedAt: null,
157 session: null,
158 latestUserMessageAt: message.createdAt,
159 hasPendingApprovals: false,
160 hasPendingUserInput: false,
161 hasActionableProposedPlan: false,
162 };
163}
apps/mobile/src/state/recover-failed-thread-draft.ts created+32
......@@ -0,0 +1,32 @@
1import type { QueuedThreadMessage } from "./thread-outbox-model";
2import { scopedThreadKey } from "../lib/scopedEntities";
3import { restoredNewTaskDraftKey } from "./new-task-draft-key";
4import {
5 appendComposerDraftAttachments,
6 clearComposerDraftContent,
7 flushComposerDrafts,
8 getComposerDraftSnapshot,
9 mergeComposerDraftContent,
10} from "./use-composer-drafts";
11
12/** Move unsent setup edits into the restored task before reopening its editor. */
13export async function recoverFailedThreadDraft(message: QueuedThreadMessage): Promise<void> {
14 const sourceKey = scopedThreadKey(message.environmentId, message.threadId);
15 const targetKey = restoredNewTaskDraftKey(message.messageId);
16 const source = getComposerDraftSnapshot(sourceKey);
17 if (source.text.length === 0 && source.attachments.length === 0) return;
18
19 await mergeComposerDraftContent(targetKey, { text: source.text, attachments: [] });
20 const existingIds = new Set(
21 getComposerDraftSnapshot(targetKey).attachments.map((attachment) => attachment.id),
22 );
23 appendComposerDraftAttachments(
24 targetKey,
25 source.attachments.filter((attachment) => !existingIds.has(attachment.id)),
26 { allowOverflow: true },
27 );
28 // Recovery may exceed the send cap. Preserve every file and let the editor
29 // ask the user to remove extras; never discard them during a failed send.
30 await flushComposerDrafts();
31 clearComposerDraftContent(sourceKey);
32}
apps/mobile/src/state/use-thread-composer-state.ts+36-5
......@@ -36,6 +36,7 @@ import { buildThreadFeed } from "../lib/threadActivity";
3636import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";
3737import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed";
3838import { appAtomRegistry } from "../state/atom-registry";
39import { pendingThreadCreationMessage } from "./pending-thread-creation";
3940import {
4041 appendComposerDraftAttachments,
4142 appendComposerDraftText,
......@@ -101,7 +102,11 @@ export function useThreadDraftForThread(input: {
101102}
102103
103104export function useThreadComposerState() {
104 const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection();
105 const {
106 selectedThread: selectedThreadShell,
107 selectedThreadCreation,
108 selectedEnvironmentRuntime,
109 } = useThreadSelection();
105110 const selectedThreadDetail = useSelectedThreadDetail();
106111 const composerDrafts = useAtomValue(composerDraftsAtom);
107112 const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom);
......@@ -121,8 +126,15 @@ export function useThreadComposerState() {
121126 const selectedThreadKey = selectedThreadShell
122127 ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id)
123128 : null;
129 // The creation entry is the thread itself (rendered as the first message),
130 // not a follow-up waiting behind it.
124131 const selectedThreadQueuedMessages = useMemo(
125 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
132 () =>
133 selectedThreadKey
134 ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []).filter(
135 (message) => message.creation === undefined,
136 )
137 : [],
126138 [queuedMessagesByThreadKey, selectedThreadKey],
127139 );
128140 const feedbackSubmissions = useMemo(
......@@ -141,12 +153,22 @@ export function useThreadComposerState() {
141153 );
142154 const selectedThreadMessages = selectedThreadDetail?.messages;
143155 const selectedThreadActivities = selectedThreadDetail?.activities;
156 // A thread whose creation has not delivered its turn yet: the prompt only
157 // exists in the outbox, so it is appended to whatever the server has. The
158 // detail is usually present but empty during a worktree checkout, so this
159 // cannot be an either/or with the loaded messages.
160 const pendingCreationMessage = selectedThreadCreation?.message ?? null;
144161 const selectedThreadFeed = useMemo(() => {
162 const loadedMessages = selectedThreadMessages ?? [];
145163 const feed =
146 selectedThreadMessages && selectedThreadActivities
164 (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null
147165 ? buildThreadFeed({
148 messages: selectedThreadMessages,
149 activities: selectedThreadActivities,
166 messages:
167 pendingCreationMessage !== null &&
168 !loadedMessages.some((message) => message.id === pendingCreationMessage.messageId)
169 ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)]
170 : loadedMessages,
171 activities: selectedThreadActivities ?? [],
150172 })
151173 : [];
152174 const pendingAcknowledgments = acknowledgedMessages.filter(
......@@ -161,6 +183,7 @@ export function useThreadComposerState() {
161183 }, [
162184 selectedThreadActivities,
163185 selectedThreadMessages,
186 pendingCreationMessage,
164187 selectedThreadKey,
165188 selectedThreadQueuedMessages,
166189 acknowledgedMessages,
......@@ -265,6 +288,13 @@ export function useThreadComposerState() {
265288 if (!selectedThreadShell) {
266289 return null;
267290 }
291 // The server has not created this thread yet. Queuing a follow-up against
292 // its id would strand the message: if the creation is rejected the thread
293 // never appears and the drain drops the orphan. The composer disables its
294 // send button too; this guard also covers the editor's submit key.
295 if (selectedThreadCreation !== null) {
296 return null;
297 }
268298
269299 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
270300 const draft = getComposerDraftSnapshot(threadKey);
......@@ -399,6 +429,7 @@ export function useThreadComposerState() {
399429 }, [
400430 selectedEnvironmentRuntime?.connectionState,
401431 selectedEnvironmentRuntime?.serverConfig,
432 selectedThreadCreation,
402433 selectedThreadDetail,
403434 selectedThreadShell,
404435 uploadThreadFeedback,
apps/mobile/src/state/use-thread-detail.ts+5-5
......@@ -13,12 +13,12 @@ export function useThreadDetail(target: ThreadDetailTarget) {
1313 return useEnvironmentThread(target.environmentId, target.threadId);
1414}
1515
16/**
17 * The selection owns the subscription so it can hold it back while a queued
18 * creation has not reached the server yet.
19 */
1620export function useSelectedThreadDetailState() {
17 const { selectedThread } = useThreadSelection();
18 return useThreadDetail({
19 environmentId: selectedThread?.environmentId ?? null,
20 threadId: selectedThread?.id ?? null,
21 });
21 return useThreadSelection().selectedThreadDetailState;
2222}
2323
2424export function useSelectedThreadDetail() {
apps/mobile/src/state/use-thread-outbox-drain.test.ts+91
......@@ -133,8 +133,13 @@ vi.mock("./thread-outbox", async () => {
133133});
134134
135135import { appAtomRegistry } from "./atom-registry";
136import {
137 clearPendingThreadCreationOutcome,
138 pendingThreadCreationOutcomesAtom,
139} from "./pending-thread-creation";
136140import type { QueuedThreadMessage } from "./thread-outbox-model";
137141import * as composerDrafts from "./use-composer-drafts";
142import { recoverFailedThreadDraft } from "./recover-failed-thread-draft";
138143import { editingQueuedMessageIdsAtom } from "./use-thread-outbox";
139144import {
140145 completeQueuedMessageDelivery,
......@@ -203,6 +208,7 @@ afterEach(() => {
203208 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {});
204209 appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} });
205210 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});
211 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {});
206212 harness.draftFile.setWriteError(null);
207213 harness.removePersistedFile.mockClear();
208214 harness.removeOutboxMessage.mockClear();
......@@ -585,6 +591,55 @@ describe("thread outbox delivered creation recovery", () => {
585591});
586592
587593describe("thread outbox recovery rollback", () => {
594 it("reopens a rejected task with setup edits and every attachment, even above the send cap", async () => {
595 const message = queuedMessage({ messageId: "failed-setup", text: "Original prompt" });
596 const sourceKey = `${message.environmentId}:${message.threadId}`;
597 const targetKey = "new-task:restored-failed-setup";
598 const files = Array.from(
599 { length: 10 },
600 (_, index) =>
601 queuedMessage({
602 messageId: `attachment-${index}`,
603 text: "",
604 fileUri: `file:///file-${index}`,
605 }).attachments[0]!,
606 );
607 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {
608 [targetKey]: { text: message.text, attachments: files.slice(0, 8) },
609 [sourceKey]: { text: "Please include tests", attachments: files.slice(8) },
610 });
611 await recoverFailedThreadDraft(message);
612 expect(composerDrafts.getComposerDraftSnapshot(targetKey)).toMatchObject({
613 text: "Original prompt\n\nPlease include tests",
614 attachments: files,
615 });
616 expect(composerDrafts.getComposerDraftSnapshot(sourceKey)).toMatchObject({
617 text: "",
618 attachments: [],
619 });
620 await recoverFailedThreadDraft(message);
621 expect(composerDrafts.getComposerDraftSnapshot(targetKey).text).toBe(
622 "Original prompt\n\nPlease include tests",
623 );
624 });
625
626 it("keeps setup edits recoverable when saving their recovery draft fails", async () => {
627 const message = queuedMessage({ messageId: "failed-save", text: "Original prompt" });
628 const sourceKey = `${message.environmentId}:${message.threadId}`;
629 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {
630 "new-task:restored-failed-save": { text: message.text, attachments: [] },
631 [sourceKey]: { text: "Follow-up", attachments: [] },
632 });
633 harness.draftFile.setWriteError(new Error("disk full"));
634 await expect(recoverFailedThreadDraft(message)).rejects.toThrow("Composer draft persistence");
635 expect(composerDrafts.getComposerDraftSnapshot(sourceKey).text).toBe("Follow-up");
636 harness.draftFile.setWriteError(null);
637 await recoverFailedThreadDraft(message);
638 expect(composerDrafts.getComposerDraftSnapshot("new-task:restored-failed-save").text).toBe(
639 "Original prompt\n\nFollow-up",
640 );
641 });
642
588643 it("restores a rejected new task as its own draft for the project", async () => {
589644 const message: QueuedThreadMessage = {
590645 ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),
......@@ -618,6 +673,42 @@ describe("thread outbox recovery rollback", () => {
618673 });
619674 expect(remainingMessages()).toEqual([]);
620675 expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server");
676 // The thread screen opened for this creation reads the failure from here.
677 expect(
678 appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[
679 `${message.environmentId}:${message.threadId}`
680 ],
681 ).toEqual({ kind: "failed", message, reason: "rejected by server" });
682 });
683
684 it("keeps a failed outcome until its thread screen consumes it", async () => {
685 const message: QueuedThreadMessage = {
686 ...queuedMessage({ messageId: "message-creation-kept", text: "new task text" }),
687 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
688 creation: {
689 projectId: ProjectId.make("project-1"),
690 workspaceMode: "local",
691 branch: null,
692 worktreePath: null,
693 },
694 };
695 await harness.manager.enqueue(message);
696 await restoreRejectedQueuedMessage(message, "rejected by server");
697
698 const key = `${message.environmentId}:${message.threadId}`;
699 expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[key]?.kind).toBe("failed");
700
701 clearPendingThreadCreationOutcome(key);
702 expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[key]).toBeUndefined();
703 });
704
705 it("does not record a creation outcome for a rejected follow-up message", async () => {
706 const message = queuedMessage({ messageId: "message-followup-restore", text: "follow up" });
707 await harness.manager.enqueue(message);
708
709 await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored");
710
711 expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)).toEqual({});
621712 });
622713
623714 it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => {
apps/mobile/src/state/use-thread-outbox-drain.ts+44-2
......@@ -27,7 +27,13 @@ import {
2727 forgetAcknowledgedThreadMessage,
2828} from "./acknowledged-thread-messages";
2929import { appAtomRegistry } from "./atom-registry";
30import { restoredNewTaskDraftKey } from "./new-task-draft-key";
3031import { useProjects, useServerConfigs, useThreadShells } from "./entities";
32import {
33 clearPendingThreadCreationOutcome,
34 pendingThreadCreationOutcomesAtom,
35 recordPendingThreadCreationOutcome,
36} from "./pending-thread-creation";
3137import { serverEnvironment } from "./server";
3238import {
3339 confirmThreadOutboxMessageQueued,
......@@ -57,7 +63,6 @@ import {
5763 type ComposerDraft,
5864 getComposerDraftSnapshot,
5965 mergeComposerDraftContent,
60 newTaskDraftKey,
6166 replaceComposerDraftAttachments,
6267 removeDeliveredCloudQueuedMessage,
6368 undoComposerDraftMerge,
......@@ -439,6 +444,15 @@ export async function restoreRejectedQueuedMessage(
439444 // The queued message is gone; from here the draft owns the content and
440445 // must never be rolled back.
441446 rollback = null;
447 if (queuedMessage.creation) {
448 // The thread screen for this creation is likely open; it reads the
449 // outcome to offer reopening the restored draft.
450 recordPendingThreadCreationOutcome({
451 kind: "failed",
452 message: queuedMessage,
453 reason: message,
454 });
455 }
442456 setPendingConnectionError(message);
443457 return "restored";
444458 } catch (error) {
......@@ -468,7 +482,7 @@ export async function restoreRejectedQueuedMessage(
468482 */
469483function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {
470484 return queuedMessage.creation
471 ? newTaskDraftKey(`restored-${queuedMessage.messageId}`)
485 ? restoredNewTaskDraftKey(queuedMessage.messageId)
472486 : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);
473487}
474488
......@@ -538,6 +552,7 @@ export function useThreadOutboxDrain(): void {
538552 const queuedMessagesByThreadKey = useThreadOutboxMessages();
539553 const shellStatuses = useThreadOutboxShellStatuses();
540554 const threads = useThreadShells();
555 const creationOutcomes = useAtomValue(pendingThreadCreationOutcomesAtom);
541556 const projects = useProjects();
542557 const serverConfigs = useServerConfigs();
543558 const { connectedEnvironments } = useRemoteConnectionStatus();
......@@ -927,6 +942,9 @@ export function useThreadOutboxDrain(): void {
927942 if (failure?.action === "restore") {
928943 return restoreQueuedMessage(persistedMessage, failure.message);
929944 }
945 // Recorded before the queue entry goes so the thread screen never sees a
946 // gap between the queued creation and the server's shell.
947 recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage });
930948 const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision);
931949 if (outcome === "edited") {
932950 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
......@@ -944,6 +962,30 @@ export function useThreadOutboxDrain(): void {
944962 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],
945963 );
946964
965 // A creation outcome bridges setup until the server's shell has a turn.
966 // Drop it once that happens so the map cannot grow for a whole session; a
967 // failed outcome stays until its thread screen consumes it.
968 // Subscribed, not read once: the shell often lands before the outcome is
969 // recorded, and a non-reactive read would leave that entry uncollected
970 // because `threads` never changes again.
971 useEffect(() => {
972 for (const [threadKey, outcome] of Object.entries(creationOutcomes)) {
973 if (
974 outcome.kind === "delivered" &&
975 threads.some(
976 (thread) =>
977 scopedThreadKey(thread.environmentId, thread.id) === threadKey &&
978 (thread.latestTurn !== null ||
979 thread.session?.status === "error" ||
980 thread.session?.status === "stopped" ||
981 thread.session?.status === "interrupted"),
982 )
983 ) {
984 clearPendingThreadCreationOutcome(threadKey);
985 }
986 }
987 }, [creationOutcomes, threads]);
988
947989 useEffect(() => {
948990 if (dispatchingQueuedMessageId !== null) {
949991 return;
apps/mobile/src/state/use-thread-selection.ts+57-5
......@@ -1,5 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";
12import { useRoute, type RouteProp } from "@react-navigation/native";
2import { useMemo, useRef } from "react";
3import { useMemo, useRef, useState } from "react";
34import {
45 EnvironmentId,
56 type OrchestrationThread,
......@@ -10,12 +11,20 @@ import {
1011import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1112import * as Option from "effect/Option";
1213
14import { scopedThreadKey } from "../lib/scopedEntities";
1315import { useProject, useThreadShell } from "../state/entities";
1416import { useEnvironmentThread } from "../state/threads";
17import {
18 resolvePendingThreadCreation,
19 pendingThreadCreationOutcomesAtom,
20 pendingThreadCreationShell,
21 type PendingThreadCreation,
22} from "./pending-thread-creation";
1523import {
1624 useRemoteEnvironmentRuntime,
1725 useSavedRemoteConnection,
1826} from "./use-remote-environment-registry";
27import { useThreadOutboxMessages } from "./use-thread-outbox";
1928type ThreadSelectionRouteParams = {
2029 readonly environmentId?: string | string[];
2130 readonly threadId?: string | string[];
......@@ -96,9 +105,36 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin
96105 }
97106 const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current;
98107 const selectedThreadShell = useThreadShell(selectedThreadRef);
108 const selectedThreadKey =
109 selectedThreadRef === null
110 ? null
111 : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId);
112 const queuedMessagesByThreadKey = useThreadOutboxMessages();
113 const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom);
114 // A creation the outbox still holds or just delivered: the thread screen
115 // opened before the server made the thread, so present a stand-in shell.
116 const pendingCreation = useMemo<PendingThreadCreation | null>(() => {
117 if (selectedThreadKey === null) {
118 return null;
119 }
120 const queued = queuedMessagesByThreadKey[selectedThreadKey]?.find(
121 (message) => message.creation !== undefined,
122 );
123 const outcome = creationOutcome[selectedThreadKey] ?? null;
124 const message = queued ?? outcome?.message ?? null;
125 return message === null ? null : { message, outcome };
126 }, [creationOutcome, queuedMessagesByThreadKey, selectedThreadKey]);
127 // Until the creation is delivered the server has no thread to subscribe
128 // to; subscribing anyway would retry "not found" for the whole setup.
129 const selectedThreadDetailRef =
130 selectedThreadShell !== null ||
131 pendingCreation === null ||
132 pendingCreation.outcome?.kind === "delivered"
133 ? selectedThreadRef
134 : null;
99135 const selectedThreadDetailState = useEnvironmentThread(
100 selectedThreadRef?.environmentId ?? null,
101 selectedThreadRef?.threadId ?? null,
136 selectedThreadDetailRef?.environmentId ?? null,
137 selectedThreadDetailRef?.threadId ?? null,
102138 );
103139 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
104140 const selectedThread = useMemo(
......@@ -106,9 +142,21 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin
106142 selectedThreadShell ??
107143 (selectedThreadRef !== null && selectedThreadDetail !== null
108144 ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail)
109 : null),
110 [selectedThreadDetail, selectedThreadRef, selectedThreadShell],
145 : pendingCreation !== null
146 ? pendingThreadCreationShell(pendingCreation.message)
147 : null),
148 [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell],
111149 );
150 const [previousCreation, setPreviousCreation] = useState<PendingThreadCreation | null>(null);
151 const selectedThreadCreation = resolvePendingThreadCreation({
152 threadKey: selectedThreadKey,
153 pending: pendingCreation,
154 previous: previousCreation,
155 detail: selectedThreadDetail,
156 });
157 if (previousCreation !== selectedThreadCreation) {
158 setPreviousCreation(selectedThreadCreation);
159 }
112160 const selectedProjectRef = useMemo<ScopedProjectRef | null>(
113161 () =>
114162 selectedThread === null
......@@ -128,6 +176,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin
128176 () => ({
129177 selectedThreadRef,
130178 selectedThread,
179 selectedThreadCreation,
180 selectedThreadDetailState,
131181 selectedThreadProject,
132182 selectedEnvironmentConnection,
133183 selectedEnvironmentRuntime,
......@@ -136,6 +186,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin
136186 selectedEnvironmentConnection,
137187 selectedEnvironmentRuntime,
138188 selectedThread,
189 selectedThreadCreation,
190 selectedThreadDetailState,
139191 selectedThreadProject,
140192 selectedThreadRef,
141193 ],
packages/shared/src/orchestrationTiming.test.ts+108-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { formatDuration } from "./orchestrationTiming.ts";
3import { formatDuration, deriveActiveWorkStartedAt } from "./orchestrationTiming.ts";
44
55describe("formatDuration", () => {
66 it.each([
......@@ -29,3 +29,110 @@ describe("formatDuration", () => {
2929 expect(formatDuration(durationMs)).toBe("0ms");
3030 });
3131});
32
33describe("deriveActiveWorkStartedAt", () => {
34 it.each([null, "2026-09-06T23:34:00.000Z"])(
35 "does not time a superseded turn when the active turn differs",
36 (sendStartedAt) => {
37 expect(
38 deriveActiveWorkStartedAt(
39 {
40 turnId: "old",
41 requestedAt: "2026-09-06T23:33:00.000Z",
42 startedAt: null,
43 completedAt: null,
44 },
45 { orchestrationStatus: "running", activeTurnId: "new" },
46 sendStartedAt,
47 ),
48 ).toBe(sendStartedAt);
49 },
50 );
51
52 it("stops timing a turn that failed before its provider started", () => {
53 expect(
54 deriveActiveWorkStartedAt(
55 {
56 turnId: "turn-1",
57 requestedAt: "2026-09-06T23:33:00.000Z",
58 startedAt: null,
59 completedAt: "2026-09-06T23:33:05.000Z",
60 },
61 { orchestrationStatus: "error", activeTurnId: null },
62 null,
63 ),
64 ).toBeNull();
65 });
66 // The gap this closes. The projector stamps startedAt in the same update
67 // that moves the session to "running", so during provider spin-up the turn
68 // is requested with no startedAt and the session is "starting". Returning
69 // null there blinks the working indicator out between "Setting up
70 // worktree..." and "Working for 0s".
71 it("counts from requestedAt while the provider is still starting", () => {
72 expect(
73 deriveActiveWorkStartedAt(
74 {
75 turnId: "turn-1",
76 requestedAt: "2026-09-06T23:33:00.000Z",
77 startedAt: null,
78 completedAt: null,
79 },
80 { orchestrationStatus: "starting", activeTurnId: null },
81 null,
82 ),
83 ).toBe("2026-09-06T23:33:00.000Z");
84 });
85
86 it("prefers the turn's own startedAt once the provider reports it", () => {
87 expect(
88 deriveActiveWorkStartedAt(
89 {
90 turnId: "turn-1",
91 requestedAt: "2026-09-06T23:33:00.000Z",
92 startedAt: "2026-09-06T23:33:05.000Z",
93 completedAt: null,
94 },
95 { orchestrationStatus: "running", activeTurnId: "turn-1" },
96 null,
97 ),
98 ).toBe("2026-09-06T23:33:05.000Z");
99 });
100
101 // requestedAt must not leak past the end of the work.
102 it("stops counting once the turn has settled", () => {
103 expect(
104 deriveActiveWorkStartedAt(
105 {
106 turnId: "turn-1",
107 requestedAt: "2026-09-06T23:33:00.000Z",
108 startedAt: "2026-09-06T23:33:05.000Z",
109 completedAt: "2026-09-06T23:33:09.000Z",
110 },
111 { orchestrationStatus: "idle", activeTurnId: null },
112 null,
113 ),
114 ).toBeNull();
115 });
116
117 // A session restarting with no new turn must not resurrect the old one.
118 it("does not count a settled turn while a session is starting again", () => {
119 expect(
120 deriveActiveWorkStartedAt(
121 {
122 turnId: "turn-1",
123 requestedAt: "2026-09-06T23:33:00.000Z",
124 startedAt: "2026-09-06T23:33:05.000Z",
125 completedAt: "2026-09-06T23:33:09.000Z",
126 },
127 { orchestrationStatus: "starting", activeTurnId: null },
128 null,
129 ),
130 ).toBeNull();
131 });
132
133 it("falls back to the caller's send timestamp when there is no turn yet", () => {
134 expect(deriveActiveWorkStartedAt(null, null, "2026-09-06T23:33:00.000Z")).toBe(
135 "2026-09-06T23:33:00.000Z",
136 );
137 });
138});
packages/shared/src/orchestrationTiming.ts+17-2
......@@ -1,5 +1,7 @@
11type LatestTurnTiming = {
22 readonly turnId: string | null;
3 /** Set when the turn is created; `startedAt` waits for the provider. */
4 readonly requestedAt?: string | null;
35 readonly startedAt: string | null;
46 readonly completedAt: string | null;
57};
......@@ -32,20 +34,33 @@ function isLatestTurnSettled(
3234 latestTurn: LatestTurnTiming | null,
3335 session: SessionActivityState | null,
3436): boolean {
35 if (!latestTurn?.startedAt) return false;
37 if (!latestTurn) return false;
3638 if (!latestTurn.completedAt) return false;
3739 if (!session) return true;
3840 if (session.orchestrationStatus === "running") return false;
3941 return true;
4042}
4143
44/**
45 * When the working indicator should be counting, and from when.
46 *
47 * `requestedAt` is the floor for an unsettled turn. The projector only stamps
48 * `startedAt` in the same update that moves the session to "running", so while
49 * the provider spins up (session "starting") a requested turn has no
50 * `startedAt` at all — and returning null there blinks the indicator out for
51 * the whole spin-up. A settled turn still falls through to `sendStartedAt`, so
52 * this cannot leave the indicator counting after the work is done.
53 */
4254export function deriveActiveWorkStartedAt(
4355 latestTurn: LatestTurnTiming | null,
4456 session: SessionActivityState | null,
4557 sendStartedAt: string | null,
4658): string | null {
59 if (session?.activeTurnId && session.activeTurnId !== latestTurn?.turnId) {
60 return sendStartedAt;
61 }
4762 if (!isLatestTurnSettled(latestTurn, session)) {
48 return latestTurn?.startedAt ?? sendStartedAt;
63 return latestTurn?.startedAt ?? latestTurn?.requestedAt ?? sendStartedAt;
4964 }
5065 return sendStartedAt;
5166}