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";...@@ -20,10 +20,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
20import { useUniwindTheme } from "../../lib/useUniwindTheme";20import { useUniwindTheme } from "../../lib/useUniwindTheme";
21import { useFontFamily } from "../../lib/useFontFamily";21import { useFontFamily } from "../../lib/useFontFamily";
2222
23import {
24 isAtomCommandInterrupted,
25 squashAtomCommandFailure,
26} from "@t3tools/client-runtime/state/runtime";
27import {23import {
28 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,24 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
29 resolveEnvironmentMachineKind,25 resolveEnvironmentMachineKind,
...@@ -52,7 +48,6 @@ import { SymbolView } from "../../components/AppSymbol";...@@ -52,7 +48,6 @@ import { SymbolView } from "../../components/AppSymbol";
52import { AppText as Text } from "../../components/AppText";48import { AppText as Text } from "../../components/AppText";
53import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";49import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
54import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";50import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
55import { ShimmeringWorkContent } from "./thread-work-log";
56import { ComposerCommandPopover } from "./ComposerCommandPopover";51import { ComposerCommandPopover } from "./ComposerCommandPopover";
57import { useComposerCommandMenu } from "./use-composer-command-menu";52import { useComposerCommandMenu } from "./use-composer-command-menu";
58import {53import {
...@@ -78,7 +73,6 @@ import {...@@ -78,7 +73,6 @@ import {
78import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";73import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
79import {74import {
80 clearComposerDraftContent,75 clearComposerDraftContent,
81 flushComposerDrafts,
82 getComposerDraftSnapshot,76 getComposerDraftSnapshot,
83 mergeComposerDraftContent,77 mergeComposerDraftContent,
84 restoreComposerDraftSnapshot,78 restoreComposerDraftSnapshot,
...@@ -91,15 +85,12 @@ import {...@@ -91,15 +85,12 @@ import {
91 isModelSelectionUnavailable,85 isModelSelectionUnavailable,
92 resolveSelectableModelSelection,86 resolveSelectableModelSelection,
93} from "../../lib/modelOptions";87} from "../../lib/modelOptions";
94import { resolveProviderInteractionMode } from "./legacy-plan-mode";
95import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";88import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
96import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";89import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
97import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";90import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
98import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
99import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";91import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
100import { useNewTaskFlow } from "./new-task-flow-provider";92import { useNewTaskFlow } from "./new-task-flow-provider";
101import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";93import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
102import { useCreateProjectThread } from "./use-project-actions";
103import { resolveDraftProjectSelection } from "./new-task-project-selection";94import { resolveDraftProjectSelection } from "./new-task-project-selection";
104import {95import {
105 resolveNewTaskBranchLabel,96 resolveNewTaskBranchLabel,
...@@ -158,7 +149,6 @@ export function NewTaskDraftScreen(props: {...@@ -158,7 +149,6 @@ export function NewTaskDraftScreen(props: {
158 readonly incomingShareId?: string;149 readonly incomingShareId?: string;
159}) {150}) {
160 const projects = useProjects();151 const projects = useProjects();
161 const createProjectThread = useCreateProjectThread();
162 const flow = useNewTaskFlow();152 const flow = useNewTaskFlow();
163 const navigation = useNavigation();153 const navigation = useNavigation();
164 const {154 const {
...@@ -942,16 +932,6 @@ export function NewTaskDraftScreen(props: {...@@ -942,16 +932,6 @@ export function NewTaskDraftScreen(props: {
942 ) ?? flow.selectedModel;932 ) ?? flow.selectedModel;
943 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;933 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
944 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;934 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 );
955 const initialMessageText = draft.text.trim();935 const initialMessageText = draft.text.trim();
956936
957 if (937 if (
...@@ -1000,122 +980,77 @@ export function NewTaskDraftScreen(props: {...@@ -1000,122 +980,77 @@ export function NewTaskDraftScreen(props: {
1000980
1001 const editingPendingTask = flow.editingPendingTask;981 const editingPendingTask = flow.editingPendingTask;
1002982
1003 if (queuesInsteadOfStarting) {983 // Every submission goes through the outbox: the drain uploads the
1004 // Offline, or an attachment is still uploading: park the task in the984 // attachments and delivers the creation, retrying across reconnects.
1005 // outbox and let the drain send it once the environment is reachable985 // When it can send now the thread screen opens immediately with the
1006 // and the bytes are on the server. Editing an existing pending task986 // queued prompt and reports setup progress there, like the web draft
1007 // re-queues it under its original identifiers.987 // does. Offline, or with uploads still in flight, the task stays a
1008 const metadata = editingPendingTask988 // pending task and the sheet closes. Editing an existing pending task
1009 ? {989 // re-queues it under its original identifiers.
1010 threadId: editingPendingTask.threadId,990 const metadata = editingPendingTask
1011 commandId: editingPendingTask.commandId,991 ? {
1012 messageId: editingPendingTask.messageId,992 threadId: editingPendingTask.threadId,
1013 createdAt: editingPendingTask.createdAt,993 commandId: editingPendingTask.commandId,
1014 }994 messageId: editingPendingTask.messageId,
1015 : makeTurnCommandMetadata();995 createdAt: editingPendingTask.createdAt,
1016 const message = flow.buildPendingTaskMessage(metadata);996 }
1017 if (!message) {997 : makeTurnCommandMetadata();
1018 return;998 const message = flow.buildPendingTaskMessage(metadata, {
1019 }999 // A task that waits in the outbox cannot know the checkout it will
1020 flow.setSubmitting(true);1000 // drain against; one that sends now runs against the live one.
1021 try {1001 currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName,
1022 await enqueueThreadOutboxMessage(message);1002 });
1023 } catch (error) {1003 if (!message) {
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());
1043 return;1004 return;
1044 }1005 }
10451006 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.
1046 flow.setSubmitting(true);1019 flow.setSubmitting(true);
1047 // Arm the lock-screen card before the async thread creation: backgrounding1020 try {
1048 // the app right after tapping submit would otherwise reject the foreground1021 await enqueueThreadOutboxMessage(message);
1049 // -only Activity start. If creation fails, the token registration's replay1022 } catch (error) {
1050 // finds no work and ends the card within seconds.1023 Alert.alert(
1051 armAgentAwarenessLiveActivityForLocalWork({1024 "Could not queue task",
1052 environmentId: selectedProject.environmentId,1025 error instanceof Error ? error.message : "The task could not be saved to the outbox.",
1053 threadTitle: deriveThreadTitleFromPrompt(initialMessageText),1026 );
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 }
1097 return;1027 return;
1028 } finally {
1029 flow.setSubmitting(false);
1098 }1030 }
10991031 const draftSnapshot = getComposerDraftSnapshot(draftKey);
1100 if (editingPendingTask) {1032 if (editingPendingTask) {
1101 try {
1102 await removeThreadOutboxMessage(editingPendingTask);
1103 } catch (error) {
1104 console.warn("[new-task] failed to remove delivered pending task", error);
1105 }
1106 flow.finishEditingPendingTask();1033 flow.finishEditingPendingTask();
1107 } else {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 // The queued message owns the attachments now, so the sweep is deferred
1038 // until the write confirms it.
1108 clearComposerDraftContent(draftKey, {1039 clearComposerDraftContent(draftKey, {
1109 clearModelSelection: true,1040 clearModelSelection: true,
1110 clearWorkspaceSelection: true,1041 clearWorkspaceSelection: true,
1042 deferAttachmentCleanup: true,
1111 });1043 });
1112 }1044 }
1113 setSubmitNavigationAction(1045 setSubmitNavigationAction(
1114 StackActions.replace("Thread", {1046 queuesInsteadOfStarting
1115 environmentId: String(result.value.environmentId),1047 ? CommonActions.goBack()
1116 threadId: String(result.value.threadId),1048 : StackActions.replace("Thread", {
1117 }),1049 environmentId: String(message.environmentId),
1050 threadId: String(message.threadId),
1051 }),
1118 );1052 );
1053 scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments);
1119 }1054 }
11201055
1121 if (!selectedProject) {1056 if (!selectedProject) {
...@@ -1269,50 +1204,31 @@ export function NewTaskDraftScreen(props: {...@@ -1269,50 +1204,31 @@ export function NewTaskDraftScreen(props: {
12691204
1270 const workspaceControls = (1205 const workspaceControls = (
1271 <View className="flex-row items-center gap-1 px-2">1206 <View className="flex-row items-center gap-1 px-2">
1272 {flow.submitting && !queuesInsteadOfStarting && flow.workspaceMode === "worktree" ? (1207 <ComposerInlineControl
1273 <View1208 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
1274 accessible1209 accessibilityLabel={workspaceLabel}
1275 accessibilityLabel="Setting up worktree…"1210 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1276 className="h-11 w-full max-w-[260px] flex-row items-center px-2"1211 iconNode={
1277 >1212 <NewTaskWorkspaceIcon
1278 <ShimmeringWorkContent1213 workspaceMode={flow.workspaceMode}
1279 icon="arrow.triangle.branch"1214 worktreePath={flow.selectedWorktreePath}
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}
1303 />1215 />
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 <ComposerInlineControl1223 <ComposerInlineControl
1306 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}1224 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
1307 chevronDirection="right"1225 chevronDirection="right"
1308 disabled={isComposerInteractionLocked}1226 disabled={isComposerInteractionLocked}
1309 icon="arrow.triangle.branch"1227 icon="arrow.triangle.branch"
1310 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}1228 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
1311 maxWidth={190}1229 maxWidth={190}
1312 onPress={() => openContextPicker("NewTaskBranch")}1230 onPress={() => openContextPicker("NewTaskBranch")}
1313 />1231 />
1314 </>
1315 )}
1316 </View>1232 </View>
1317 );1233 );
13181234
apps/mobile/src/features/threads/ThreadComposer.tsx+6-6
...@@ -120,6 +120,8 @@ export interface ThreadComposerProps {...@@ -120,6 +120,8 @@ export interface ThreadComposerProps {
120 readonly queueCount: number;120 readonly queueCount: number;
121 readonly environmentId: EnvironmentId;121 readonly environmentId: EnvironmentId;
122 readonly projectCwd: string | null;122 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;
123 readonly editorRef?: RefObject<ComposerEditorHandle | null>;125 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
124 readonly onChangeDraftMessage: (value: string) => void;126 readonly onChangeDraftMessage: (value: string) => void;
125 readonly onPickDraftMedia: () => Promise<void>;127 readonly onPickDraftMedia: () => Promise<void>;
...@@ -344,11 +346,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -344,11 +346,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
344 serverConfig: props.serverConfig,346 serverConfig: props.serverConfig,
345 states: uploadStates,347 states: uploadStates,
346 });348 });
349 const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason;
347 const canSend =350 const canSend =
348 hasContent &&351 hasContent && !voiceInput.blocksSubmission && sendBlockedReason === null && !modelUnavailable;
349 !voiceInput.blocksSubmission &&
350 attachmentBlockReason === null &&
351 !modelUnavailable;
352352
353 // Keep the feed inset aligned with the card or compact dictation strip.353 // Keep the feed inset aligned with the card or compact dictation strip.
354 useEffect(() => {354 useEffect(() => {
...@@ -701,7 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -701,7 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
701 />701 />
702 ) : (702 ) : (
703 <ComposerActionButton703 <ComposerActionButton
704 accessibilityLabel={attachmentBlockReason ?? sendLabel}704 accessibilityLabel={sendBlockedReason ?? sendLabel}
705 icon="arrow.up"705 icon="arrow.up"
706 variant="primary"706 variant="primary"
707 disabled={!canSend}707 disabled={!canSend}
...@@ -792,7 +792,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -792,7 +792,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
792 />792 />
793 ) : voicePresentation.showsSend ? (793 ) : voicePresentation.showsSend ? (
794 <ComposerActionButton794 <ComposerActionButton
795 accessibilityLabel={attachmentBlockReason ?? sendLabel}795 accessibilityLabel={sendBlockedReason ?? sendLabel}
796 icon="arrow.up"796 icon="arrow.up"
797 variant="primary"797 variant="primary"
798 disabled={!canSend}798 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";...@@ -81,6 +81,7 @@ import { PendingApprovalCard } from "./PendingApprovalCard";
81import { ComposerFeedback } from "./ComposerFeedback";81import { ComposerFeedback } from "./ComposerFeedback";
82import { ComposerUsageLimits } from "./ComposerUsageLimits";82import { ComposerUsageLimits } from "./ComposerUsageLimits";
83import { PendingUserInputCard } from "./PendingUserInputCard";83import { PendingUserInputCard } from "./PendingUserInputCard";
84import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
84import {85import {
85 FLOATING_WORKING_CONTROL_COVERAGE,86 FLOATING_WORKING_CONTROL_COVERAGE,
86 FloatingWorkingControl,87 FloatingWorkingControl,
...@@ -113,6 +114,15 @@ export interface ThreadDetailScreenProps {...@@ -113,6 +114,15 @@ export interface ThreadDetailScreenProps {
113 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;114 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
114 readonly activeWorkStartedAt: string | null;115 readonly activeWorkStartedAt: string | null;
115 readonly isCompacting: boolean;116 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;
116 readonly activePendingApproval: PendingApproval | null;126 readonly activePendingApproval: PendingApproval | null;
117 readonly respondingApprovalId: ApprovalRequestId | null;127 readonly respondingApprovalId: ApprovalRequestId | null;
118 readonly activePendingUserInput: PendingUserInput | null;128 readonly activePendingUserInput: PendingUserInput | null;
...@@ -347,6 +357,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -347,6 +357,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
347 if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {357 if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {
348 return null;358 return null;
349 }359 }
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 }
350 if (threadSyncLabel !== null) {369 if (threadSyncLabel !== null) {
351 return { kind: "syncing", label: threadSyncLabel };370 return { kind: "syncing", label: threadSyncLabel };
352 }371 }
...@@ -917,6 +936,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -917,6 +936,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
917 />936 />
918 </Animated.View>937 </Animated.View>
919 ) : null}938 ) : 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}
920 {props.activePendingApproval || props.activePendingUserInput ? (952 {props.activePendingApproval || props.activePendingUserInput ? (
921 <Animated.View953 <Animated.View
922 className="shrink-0 gap-3 px-4 pb-3"954 className="shrink-0 gap-3 px-4 pb-3"
...@@ -961,8 +993,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -961,8 +993,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
961 </View>993 </View>
962994
963 {/* Hidden (not unmounted) while a user-input request owns the995 {/* Hidden (not unmounted) while a user-input request owns the
964 composer slot, so composer drafts and editor state survive. */}996 composer slot, so composer drafts and editor state survive.
965 <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}>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 >
966 <ThreadComposer1006 <ThreadComposer
967 editorRef={composerEditorRef}1007 editorRef={composerEditorRef}
968 draftMessage={props.draftMessage}1008 draftMessage={props.draftMessage}
...@@ -977,6 +1017,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -977,6 +1017,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
977 queueCount={props.selectedThreadQueueCount}1017 queueCount={props.selectedThreadQueueCount}
978 environmentId={props.environmentId}1018 environmentId={props.environmentId}
979 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}1019 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 }
980 bottomInset={composerBottomInset}1026 bottomInset={composerBottomInset}
981 onChangeDraftMessage={props.onChangeDraftMessage}1027 onChangeDraftMessage={props.onChangeDraftMessage}
982 onPickDraftMedia={props.onPickDraftMedia}1028 onPickDraftMedia={props.onPickDraftMedia}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+70-10
...@@ -22,9 +22,12 @@ import {...@@ -22,9 +22,12 @@ import {
22 projectScriptRuntimeEnv,22 projectScriptRuntimeEnv,
23 resolveProjectScripts,23 resolveProjectScripts,
24} from "@t3tools/shared/projectScripts";24} from "@t3tools/shared/projectScripts";
25import { Platform, ScrollView, View } from "react-native";25import { Alert, Platform, ScrollView, View } from "react-native";
26import { useSafeAreaInsets } from "react-native-safe-area-context";26import { useSafeAreaInsets } from "react-native-safe-area-context";
27import { useWorkspaceState } from "../../state/workspace";27import { 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";
28import { useEnvironmentQuery } from "../../state/query";31import { useEnvironmentQuery } from "../../state/query";
29import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";32import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
30import { vcsEnvironment } from "../../state/vcs";33import { vcsEnvironment } from "../../state/vcs";
...@@ -58,7 +61,7 @@ import {...@@ -58,7 +61,7 @@ import {
58 stagePendingTerminalLaunch,61 stagePendingTerminalLaunch,
59} from "../terminal/terminalLaunchContext";62} from "../terminal/terminalLaunchContext";
60import { terminalDebugLog } from "../terminal/terminalDebugLog";63import { terminalDebugLog } from "../terminal/terminalDebugLog";
61import { ThreadDetailScreen } from "./ThreadDetailScreen";64import { ThreadDetailScreen, type ThreadDetailScreenProps } from "./ThreadDetailScreen";
62import {65import {
63 ThreadGitControls,66 ThreadGitControls,
64 useThreadGitCenterHeaderItems,67 useThreadGitCenterHeaderItems,
...@@ -200,8 +203,12 @@ function ThreadRouteContent(...@@ -200,8 +203,12 @@ function ThreadRouteContent(
200 } = useAdaptiveWorkspaceLayout();203 } = useAdaptiveWorkspaceLayout();
201 const { connectionState } = useRemoteConnectionStatus();204 const { connectionState } = useRemoteConnectionStatus();
202 const { onReconnectEnvironment } = useRemoteConnections();205 const { onReconnectEnvironment } = useRemoteConnections();
203 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =206 const {
204 useThreadSelection();207 selectedThread,
208 selectedThreadCreation,
209 selectedThreadProject,
210 selectedEnvironmentConnection,
211 } = useThreadSelection();
205 const selectedThreadDetailState = props.selectedThreadDetailState;212 const selectedThreadDetailState = props.selectedThreadDetailState;
206 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);213 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
207 // "Load earlier turns" header state for windowed (paginated) thread loads.214 // "Load earlier turns" header state for windowed (paginated) thread loads.
...@@ -742,6 +749,52 @@ function ThreadRouteContent(...@@ -742,6 +749,52 @@ function ThreadRouteContent(
742 selectedThreadProject?.workspaceRoot,749 selectedThreadProject?.workspaceRoot,
743 ]);750 ]);
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 })();
745 // Deep links / cold starts land with Thread as the ONLY route, where the798 // Deep links / cold starts land with Thread as the ONLY route, where the
746 // native back button does not render. Provide an explicit Home escape for799 // native back button does not render. Provide an explicit Home escape for
747 // that case; when history exists the native back button is used instead.800 // that case; when history exists the native back button is used instead.
...@@ -767,12 +820,18 @@ function ThreadRouteContent(...@@ -767,12 +820,18 @@ function ThreadRouteContent(
767 return <OpeningThreadLoadingScreen />;820 return <OpeningThreadLoadingScreen />;
768 }821 }
769822
770 const contentPresentation = projectThreadContentPresentation({823 // A queued creation renders as ready content: its prompt is the whole
771 hasDetail: selectedThreadDetail !== null,824 // conversation until the server creates the thread. The subscription's
772 detailError: Option.getOrNull(selectedThreadDetailState.error),825 // not-found error for that window is expected, not a load failure.
773 detailDeleted: selectedThreadDetailState.status === "deleted",826 const contentPresentation =
774 connectionState: routeConnectionState,827 creationState !== null
775 });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 });
776 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;835 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
777 const renderThreadRouteBody = (showActionControls: boolean) => (836 const renderThreadRouteBody = (showActionControls: boolean) => (
778 <>837 <>
...@@ -792,6 +851,7 @@ function ThreadRouteContent(...@@ -792,6 +851,7 @@ function ThreadRouteContent(
792 selectedThreadFeed={composer.selectedThreadFeed}851 selectedThreadFeed={composer.selectedThreadFeed}
793 activeWorkStartedAt={composer.activeWorkStartedAt}852 activeWorkStartedAt={composer.activeWorkStartedAt}
794 isCompacting={composer.isCompacting}853 isCompacting={composer.isCompacting}
854 creationState={creationState}
795 activePendingApproval={requests.activePendingApproval}855 activePendingApproval={requests.activePendingApproval}
796 respondingApprovalId={requests.respondingApprovalId}856 respondingApprovalId={requests.respondingApprovalId}
797 activePendingUserInput={requests.activePendingUserInput}857 activePendingUserInput={requests.activePendingUserInput}
apps/mobile/src/features/threads/floating-working-control.tsx+27
...@@ -24,6 +24,7 @@ import { SymbolView } from "../../components/AppSymbol";...@@ -24,6 +24,7 @@ import { SymbolView } from "../../components/AppSymbol";
24import { ControlPill } from "../../components/ControlPill";24import { ControlPill } from "../../components/ControlPill";
25import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";25import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
26import type { FloatingWorkingStatus } from "./floating-working-status";26import type { FloatingWorkingStatus } from "./floating-working-status";
27import { ShimmeringWorkContent } from "./thread-work-log";
2728
28const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem29const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem
29// The collapsed composer capsule starts 6 below its overlay's top edge, so30// The collapsed composer capsule starts 6 below its overlay's top edge, so
...@@ -279,6 +280,32 @@ function FloatingStatusLabel(props: {...@@ -279,6 +280,32 @@ function FloatingStatusLabel(props: {
279 </StatusLabelRow>280 </StatusLabelRow>
280 );281 );
281 }282 }
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 }
282 return (309 return (
283 <WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} />310 <WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} />
284 );311 );
apps/mobile/src/features/threads/floating-working-status.ts+3
...@@ -9,6 +9,9 @@ export type FloatingWorkingStatus =...@@ -9,6 +9,9 @@ export type FloatingWorkingStatus =
9 | { readonly kind: "working"; readonly startedAt: string }9 | { readonly kind: "working"; readonly startedAt: string }
10 | { readonly kind: "syncing"; readonly label: string }10 | { readonly kind: "syncing"; readonly label: string }
11 | { readonly kind: "compacting" }11 | { 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 }
12 | {15 | {
13 readonly kind: "connection";16 readonly kind: "connection";
14 readonly tone: "reconnecting" | "unavailable";17 readonly tone: "reconnecting" | "unavailable";
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-7
...@@ -96,6 +96,7 @@ import {...@@ -96,6 +96,7 @@ import {
96 resolveNewTaskLocalWorkspaceSelection,96 resolveNewTaskLocalWorkspaceSelection,
97} from "./new-task-context-presentation";97} from "./new-task-context-presentation";
98import { resolveEnvironmentProjectMatch } from "./new-task-project-selection";98import { resolveEnvironmentProjectMatch } from "./new-task-project-selection";
99import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
99100
100type WorkspaceMode = "local" | "worktree";101type WorkspaceMode = "local" | "worktree";
101102
...@@ -189,7 +190,13 @@ type NewTaskFlowContextValue = {...@@ -189,7 +190,13 @@ type NewTaskFlowContextValue = {
189 readonly beginEditingPendingTask: (messageId: string) => boolean;190 readonly beginEditingPendingTask: (messageId: string) => boolean;
190 readonly finishEditingPendingTask: () => void;191 readonly finishEditingPendingTask: () => void;
191 readonly cancelEditingPendingTask: () => void;192 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;
193 readonly setPrompt: (value: string) => void;200 readonly setPrompt: (value: string) => void;
194 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void;201 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void;
195 /** Appends draft attachments; returns how many the live cap rejected. */202 /** Appends draft attachments; returns how many the live cap rejected. */
...@@ -916,7 +923,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -916,7 +923,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
916 }, []);923 }, []);
917924
918 const buildPendingTaskMessage = useCallback(925 const buildPendingTaskMessage = useCallback(
919 (metadata: TurnCommandMetadata): QueuedThreadMessage | null => {926 (
927 metadata: TurnCommandMetadata,
928 options?: { readonly currentCheckoutBranch?: string | null },
929 ): QueuedThreadMessage | null => {
920 if (!selectedProject || !selectedProjectDraftKey) {930 if (!selectedProject || !selectedProjectDraftKey) {
921 return null;931 return null;
922 }932 }
...@@ -970,11 +980,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -970,11 +980,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
970 ...(projectTitle !== undefined ? { projectTitle } : {}),980 ...(projectTitle !== undefined ? { projectTitle } : {}),
971 ...(projectCwd !== undefined ? { projectCwd } : {}),981 ...(projectCwd !== undefined ? { projectCwd } : {}),
972 workspaceMode: mode,982 workspaceMode: mode,
973 // Only an explicit picker choice, never the current checkout: a983 // An explicit picker choice wins. Otherwise only a task sending now
974 // queued local task drains days later against whatever is checked984 // records the current checkout: a queued local task drains days
975 // out then, so recording a queue-time guess would pin a stale label985 // later against whatever is checked out then, so a queue-time
976 // to a thread that ran somewhere else.986 // guess would pin a stale label to a thread that ran somewhere else.
977 branch: workspaceSelection?.branch ?? null,987 branch: resolveProjectThreadCreationBranch({
988 workspaceMode: mode,
989 selectedBranch: workspaceSelection?.branch ?? null,
990 currentCheckoutBranch: options?.currentCheckoutBranch ?? null,
991 }),
978 worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),992 worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
979 // The draft only carries the flag when the user touched it; fall993 // The draft only carries the flag when the user touched it; fall
980 // back to the resolved default (server settings) so queued tasks994 // 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: {...@@ -139,6 +139,7 @@ export function ThreadDisclosureChevron(props: {
139}139}
140140
141function ShimmerWorkContent(props: {141function ShimmerWorkContent(props: {
142 readonly textClassName?: string;
142 readonly compact?: boolean;143 readonly compact?: boolean;
143 readonly environmentId?: EnvironmentId;144 readonly environmentId?: EnvironmentId;
144 readonly highlighted: boolean;145 readonly highlighted: boolean;
...@@ -176,6 +177,7 @@ function ShimmerWorkContent(props: {...@@ -176,6 +177,7 @@ function ShimmerWorkContent(props: {
176 "min-w-0 shrink",177 "min-w-0 shrink",
177 props.compact ? "text-xs" : "text-sm",178 props.compact ? "text-xs" : "text-sm",
178 props.highlighted ? "text-foreground" : "text-foreground-muted",179 props.highlighted ? "text-foreground" : "text-foreground-muted",
180 props.textClassName,
179 )}181 )}
180 numberOfLines={1}182 numberOfLines={1}
181 onTextLayout={props.onTextLayout}183 onTextLayout={props.onTextLayout}
...@@ -187,6 +189,8 @@ function ShimmerWorkContent(props: {...@@ -187,6 +189,8 @@ function ShimmerWorkContent(props: {
187}189}
188190
189export function ShimmeringWorkContent(props: {191export function ShimmeringWorkContent(props: {
192 readonly className?: string;
193 readonly textClassName?: string;
190 /** Secondary line: no icon slot, caption size. */194 /** Secondary line: no icon slot, caption size. */
191 readonly compact?: boolean;195 readonly compact?: boolean;
192 readonly environmentId?: EnvironmentId;196 readonly environmentId?: EnvironmentId;
...@@ -259,10 +263,11 @@ export function ShimmeringWorkContent(props: {...@@ -259,10 +263,11 @@ export function ShimmeringWorkContent(props: {
259263
260 return (264 return (
261 <View265 <View
262 className="min-w-0 flex-1 overflow-hidden"266 className={cn("min-w-0 flex-1 overflow-hidden", props.className)}
263 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}267 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
264 >268 >
265 <ShimmerWorkContent269 <ShimmerWorkContent
270 textClassName={props.textClassName}
266 compact={props.compact}271 compact={props.compact}
267 environmentId={props.environmentId}272 environmentId={props.environmentId}
268 highlighted={false}273 highlighted={false}
...@@ -304,6 +309,7 @@ export function ShimmeringWorkContent(props: {...@@ -304,6 +309,7 @@ export function ShimmeringWorkContent(props: {
304 >309 >
305 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>310 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>
306 <ShimmerWorkContent311 <ShimmerWorkContent
312 textClassName={props.textClassName}
307 compact={props.compact}313 compact={props.compact}
308 environmentId={props.environmentId}314 environmentId={props.environmentId}
309 highlighted315 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 {...@@ -9,6 +9,15 @@ export function isNewTaskDraftKey(draftKey: string): boolean {
9 return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX);9 return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX);
10}10}
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
12/**21/**
13 * Builds before drafts were id-keyed used `new-task:<environmentId>:<projectId>`,22 * Builds before drafts were id-keyed used `new-task:<environmentId>:<projectId>`,
14 * one slot per project. Ids are UUIDs and never contain a colon, so a colon23 * 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";...@@ -36,6 +36,7 @@ import { buildThreadFeed } from "../lib/threadActivity";
36import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";36import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";
37import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed";37import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed";
38import { appAtomRegistry } from "../state/atom-registry";38import { appAtomRegistry } from "../state/atom-registry";
39import { pendingThreadCreationMessage } from "./pending-thread-creation";
39import {40import {
40 appendComposerDraftAttachments,41 appendComposerDraftAttachments,
41 appendComposerDraftText,42 appendComposerDraftText,
...@@ -101,7 +102,11 @@ export function useThreadDraftForThread(input: {...@@ -101,7 +102,11 @@ export function useThreadDraftForThread(input: {
101}102}
102103
103export function useThreadComposerState() {104export function useThreadComposerState() {
104 const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection();105 const {
106 selectedThread: selectedThreadShell,
107 selectedThreadCreation,
108 selectedEnvironmentRuntime,
109 } = useThreadSelection();
105 const selectedThreadDetail = useSelectedThreadDetail();110 const selectedThreadDetail = useSelectedThreadDetail();
106 const composerDrafts = useAtomValue(composerDraftsAtom);111 const composerDrafts = useAtomValue(composerDraftsAtom);
107 const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom);112 const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom);
...@@ -121,8 +126,15 @@ export function useThreadComposerState() {...@@ -121,8 +126,15 @@ export function useThreadComposerState() {
121 const selectedThreadKey = selectedThreadShell126 const selectedThreadKey = selectedThreadShell
122 ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id)127 ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id)
123 : null;128 : null;
129 // The creation entry is the thread itself (rendered as the first message),
130 // not a follow-up waiting behind it.
124 const selectedThreadQueuedMessages = useMemo(131 const selectedThreadQueuedMessages = useMemo(
125 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),132 () =>
133 selectedThreadKey
134 ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []).filter(
135 (message) => message.creation === undefined,
136 )
137 : [],
126 [queuedMessagesByThreadKey, selectedThreadKey],138 [queuedMessagesByThreadKey, selectedThreadKey],
127 );139 );
128 const feedbackSubmissions = useMemo(140 const feedbackSubmissions = useMemo(
...@@ -141,12 +153,22 @@ export function useThreadComposerState() {...@@ -141,12 +153,22 @@ export function useThreadComposerState() {
141 );153 );
142 const selectedThreadMessages = selectedThreadDetail?.messages;154 const selectedThreadMessages = selectedThreadDetail?.messages;
143 const selectedThreadActivities = selectedThreadDetail?.activities;155 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;
144 const selectedThreadFeed = useMemo(() => {161 const selectedThreadFeed = useMemo(() => {
162 const loadedMessages = selectedThreadMessages ?? [];
145 const feed =163 const feed =
146 selectedThreadMessages && selectedThreadActivities164 (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null
147 ? buildThreadFeed({165 ? buildThreadFeed({
148 messages: selectedThreadMessages,166 messages:
149 activities: selectedThreadActivities,167 pendingCreationMessage !== null &&
168 !loadedMessages.some((message) => message.id === pendingCreationMessage.messageId)
169 ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)]
170 : loadedMessages,
171 activities: selectedThreadActivities ?? [],
150 })172 })
151 : [];173 : [];
152 const pendingAcknowledgments = acknowledgedMessages.filter(174 const pendingAcknowledgments = acknowledgedMessages.filter(
...@@ -161,6 +183,7 @@ export function useThreadComposerState() {...@@ -161,6 +183,7 @@ export function useThreadComposerState() {
161 }, [183 }, [
162 selectedThreadActivities,184 selectedThreadActivities,
163 selectedThreadMessages,185 selectedThreadMessages,
186 pendingCreationMessage,
164 selectedThreadKey,187 selectedThreadKey,
165 selectedThreadQueuedMessages,188 selectedThreadQueuedMessages,
166 acknowledgedMessages,189 acknowledgedMessages,
...@@ -265,6 +288,13 @@ export function useThreadComposerState() {...@@ -265,6 +288,13 @@ export function useThreadComposerState() {
265 if (!selectedThreadShell) {288 if (!selectedThreadShell) {
266 return null;289 return null;
267 }290 }
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
269 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);299 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
270 const draft = getComposerDraftSnapshot(threadKey);300 const draft = getComposerDraftSnapshot(threadKey);
...@@ -399,6 +429,7 @@ export function useThreadComposerState() {...@@ -399,6 +429,7 @@ export function useThreadComposerState() {
399 }, [429 }, [
400 selectedEnvironmentRuntime?.connectionState,430 selectedEnvironmentRuntime?.connectionState,
401 selectedEnvironmentRuntime?.serverConfig,431 selectedEnvironmentRuntime?.serverConfig,
432 selectedThreadCreation,
402 selectedThreadDetail,433 selectedThreadDetail,
403 selectedThreadShell,434 selectedThreadShell,
404 uploadThreadFeedback,435 uploadThreadFeedback,
apps/mobile/src/state/use-thread-detail.ts+5-5
...@@ -13,12 +13,12 @@ export function useThreadDetail(target: ThreadDetailTarget) {...@@ -13,12 +13,12 @@ export function useThreadDetail(target: ThreadDetailTarget) {
13 return useEnvironmentThread(target.environmentId, target.threadId);13 return useEnvironmentThread(target.environmentId, target.threadId);
14}14}
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 */
16export function useSelectedThreadDetailState() {20export function useSelectedThreadDetailState() {
17 const { selectedThread } = useThreadSelection();21 return useThreadSelection().selectedThreadDetailState;
18 return useThreadDetail({
19 environmentId: selectedThread?.environmentId ?? null,
20 threadId: selectedThread?.id ?? null,
21 });
22}22}
2323
24export function useSelectedThreadDetail() {24export function useSelectedThreadDetail() {
apps/mobile/src/state/use-thread-outbox-drain.test.ts+91
...@@ -133,8 +133,13 @@ vi.mock("./thread-outbox", async () => {...@@ -133,8 +133,13 @@ vi.mock("./thread-outbox", async () => {
133});133});
134134
135import { appAtomRegistry } from "./atom-registry";135import { appAtomRegistry } from "./atom-registry";
136import {
137 clearPendingThreadCreationOutcome,
138 pendingThreadCreationOutcomesAtom,
139} from "./pending-thread-creation";
136import type { QueuedThreadMessage } from "./thread-outbox-model";140import type { QueuedThreadMessage } from "./thread-outbox-model";
137import * as composerDrafts from "./use-composer-drafts";141import * as composerDrafts from "./use-composer-drafts";
142import { recoverFailedThreadDraft } from "./recover-failed-thread-draft";
138import { editingQueuedMessageIdsAtom } from "./use-thread-outbox";143import { editingQueuedMessageIdsAtom } from "./use-thread-outbox";
139import {144import {
140 completeQueuedMessageDelivery,145 completeQueuedMessageDelivery,
...@@ -203,6 +208,7 @@ afterEach(() => {...@@ -203,6 +208,7 @@ afterEach(() => {
203 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {});208 appAtomRegistry.set(composerDrafts.composerDraftsAtom, {});
204 appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} });209 appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} });
205 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});210 appAtomRegistry.set(editingQueuedMessageIdsAtom, {});
211 appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {});
206 harness.draftFile.setWriteError(null);212 harness.draftFile.setWriteError(null);
207 harness.removePersistedFile.mockClear();213 harness.removePersistedFile.mockClear();
208 harness.removeOutboxMessage.mockClear();214 harness.removeOutboxMessage.mockClear();
...@@ -585,6 +591,55 @@ describe("thread outbox delivered creation recovery", () => {...@@ -585,6 +591,55 @@ describe("thread outbox delivered creation recovery", () => {
585});591});
586592
587describe("thread outbox recovery rollback", () => {593describe("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
588 it("restores a rejected new task as its own draft for the project", async () => {643 it("restores a rejected new task as its own draft for the project", async () => {
589 const message: QueuedThreadMessage = {644 const message: QueuedThreadMessage = {
590 ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),645 ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),
...@@ -618,6 +673,42 @@ describe("thread outbox recovery rollback", () => {...@@ -618,6 +673,42 @@ describe("thread outbox recovery rollback", () => {
618 });673 });
619 expect(remainingMessages()).toEqual([]);674 expect(remainingMessages()).toEqual([]);
620 expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server");675 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({});
621 });712 });
622713
623 it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => {714 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 {...@@ -27,7 +27,13 @@ import {
27 forgetAcknowledgedThreadMessage,27 forgetAcknowledgedThreadMessage,
28} from "./acknowledged-thread-messages";28} from "./acknowledged-thread-messages";
29import { appAtomRegistry } from "./atom-registry";29import { appAtomRegistry } from "./atom-registry";
30import { restoredNewTaskDraftKey } from "./new-task-draft-key";
30import { useProjects, useServerConfigs, useThreadShells } from "./entities";31import { useProjects, useServerConfigs, useThreadShells } from "./entities";
32import {
33 clearPendingThreadCreationOutcome,
34 pendingThreadCreationOutcomesAtom,
35 recordPendingThreadCreationOutcome,
36} from "./pending-thread-creation";
31import { serverEnvironment } from "./server";37import { serverEnvironment } from "./server";
32import {38import {
33 confirmThreadOutboxMessageQueued,39 confirmThreadOutboxMessageQueued,
...@@ -57,7 +63,6 @@ import {...@@ -57,7 +63,6 @@ import {
57 type ComposerDraft,63 type ComposerDraft,
58 getComposerDraftSnapshot,64 getComposerDraftSnapshot,
59 mergeComposerDraftContent,65 mergeComposerDraftContent,
60 newTaskDraftKey,
61 replaceComposerDraftAttachments,66 replaceComposerDraftAttachments,
62 removeDeliveredCloudQueuedMessage,67 removeDeliveredCloudQueuedMessage,
63 undoComposerDraftMerge,68 undoComposerDraftMerge,
...@@ -439,6 +444,15 @@ export async function restoreRejectedQueuedMessage(...@@ -439,6 +444,15 @@ export async function restoreRejectedQueuedMessage(
439 // The queued message is gone; from here the draft owns the content and444 // The queued message is gone; from here the draft owns the content and
440 // must never be rolled back.445 // must never be rolled back.
441 rollback = null;446 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 }
442 setPendingConnectionError(message);456 setPendingConnectionError(message);
443 return "restored";457 return "restored";
444 } catch (error) {458 } catch (error) {
...@@ -468,7 +482,7 @@ export async function restoreRejectedQueuedMessage(...@@ -468,7 +482,7 @@ export async function restoreRejectedQueuedMessage(
468 */482 */
469function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {483function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {
470 return queuedMessage.creation484 return queuedMessage.creation
471 ? newTaskDraftKey(`restored-${queuedMessage.messageId}`)485 ? restoredNewTaskDraftKey(queuedMessage.messageId)
472 : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);486 : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);
473}487}
474488
...@@ -538,6 +552,7 @@ export function useThreadOutboxDrain(): void {...@@ -538,6 +552,7 @@ export function useThreadOutboxDrain(): void {
538 const queuedMessagesByThreadKey = useThreadOutboxMessages();552 const queuedMessagesByThreadKey = useThreadOutboxMessages();
539 const shellStatuses = useThreadOutboxShellStatuses();553 const shellStatuses = useThreadOutboxShellStatuses();
540 const threads = useThreadShells();554 const threads = useThreadShells();
555 const creationOutcomes = useAtomValue(pendingThreadCreationOutcomesAtom);
541 const projects = useProjects();556 const projects = useProjects();
542 const serverConfigs = useServerConfigs();557 const serverConfigs = useServerConfigs();
543 const { connectedEnvironments } = useRemoteConnectionStatus();558 const { connectedEnvironments } = useRemoteConnectionStatus();
...@@ -927,6 +942,9 @@ export function useThreadOutboxDrain(): void {...@@ -927,6 +942,9 @@ export function useThreadOutboxDrain(): void {
927 if (failure?.action === "restore") {942 if (failure?.action === "restore") {
928 return restoreQueuedMessage(persistedMessage, failure.message);943 return restoreQueuedMessage(persistedMessage, failure.message);
929 }944 }
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 });
930 const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision);948 const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision);
931 if (outcome === "edited") {949 if (outcome === "edited") {
932 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {950 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
...@@ -944,6 +962,30 @@ export function useThreadOutboxDrain(): void {...@@ -944,6 +962,30 @@ export function useThreadOutboxDrain(): void {
944 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],962 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],
945 );963 );
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
947 useEffect(() => {989 useEffect(() => {
948 if (dispatchingQueuedMessageId !== null) {990 if (dispatchingQueuedMessageId !== null) {
949 return;991 return;
apps/mobile/src/state/use-thread-selection.ts+57-5
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";
1import { useRoute, type RouteProp } from "@react-navigation/native";2import { useRoute, type RouteProp } from "@react-navigation/native";
2import { useMemo, useRef } from "react";3import { useMemo, useRef, useState } from "react";
3import {4import {
4 EnvironmentId,5 EnvironmentId,
5 type OrchestrationThread,6 type OrchestrationThread,
...@@ -10,12 +11,20 @@ import {...@@ -10,12 +11,20 @@ import {
10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
11import * as Option from "effect/Option";12import * as Option from "effect/Option";
1213
14import { scopedThreadKey } from "../lib/scopedEntities";
13import { useProject, useThreadShell } from "../state/entities";15import { useProject, useThreadShell } from "../state/entities";
14import { useEnvironmentThread } from "../state/threads";16import { useEnvironmentThread } from "../state/threads";
17import {
18 resolvePendingThreadCreation,
19 pendingThreadCreationOutcomesAtom,
20 pendingThreadCreationShell,
21 type PendingThreadCreation,
22} from "./pending-thread-creation";
15import {23import {
16 useRemoteEnvironmentRuntime,24 useRemoteEnvironmentRuntime,
17 useSavedRemoteConnection,25 useSavedRemoteConnection,
18} from "./use-remote-environment-registry";26} from "./use-remote-environment-registry";
27import { useThreadOutboxMessages } from "./use-thread-outbox";
19type ThreadSelectionRouteParams = {28type ThreadSelectionRouteParams = {
20 readonly environmentId?: string | string[];29 readonly environmentId?: string | string[];
21 readonly threadId?: string | string[];30 readonly threadId?: string | string[];
...@@ -96,9 +105,36 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin...@@ -96,9 +105,36 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin
96 }105 }
97 const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current;106 const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current;
98 const selectedThreadShell = useThreadShell(selectedThreadRef);107 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;
99 const selectedThreadDetailState = useEnvironmentThread(135 const selectedThreadDetailState = useEnvironmentThread(
100 selectedThreadRef?.environmentId ?? null,136 selectedThreadDetailRef?.environmentId ?? null,
101 selectedThreadRef?.threadId ?? null,137 selectedThreadDetailRef?.threadId ?? null,
102 );138 );
103 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);139 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
104 const selectedThread = useMemo(140 const selectedThread = useMemo(
...@@ -106,9 +142,21 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin...@@ -106,9 +142,21 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin
106 selectedThreadShell ??142 selectedThreadShell ??
107 (selectedThreadRef !== null && selectedThreadDetail !== null143 (selectedThreadRef !== null && selectedThreadDetail !== null
108 ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail)144 ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail)
109 : null),145 : pendingCreation !== null
110 [selectedThreadDetail, selectedThreadRef, selectedThreadShell],146 ? pendingThreadCreationShell(pendingCreation.message)
147 : null),
148 [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell],
111 );149 );
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 }
112 const selectedProjectRef = useMemo<ScopedProjectRef | null>(160 const selectedProjectRef = useMemo<ScopedProjectRef | null>(
113 () =>161 () =>
114 selectedThread === null162 selectedThread === null
...@@ -128,6 +176,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin...@@ -128,6 +176,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin
128 () => ({176 () => ({
129 selectedThreadRef,177 selectedThreadRef,
130 selectedThread,178 selectedThread,
179 selectedThreadCreation,
180 selectedThreadDetailState,
131 selectedThreadProject,181 selectedThreadProject,
132 selectedEnvironmentConnection,182 selectedEnvironmentConnection,
133 selectedEnvironmentRuntime,183 selectedEnvironmentRuntime,
...@@ -136,6 +186,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin...@@ -136,6 +186,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin
136 selectedEnvironmentConnection,186 selectedEnvironmentConnection,
137 selectedEnvironmentRuntime,187 selectedEnvironmentRuntime,
138 selectedThread,188 selectedThread,
189 selectedThreadCreation,
190 selectedThreadDetailState,
139 selectedThreadProject,191 selectedThreadProject,
140 selectedThreadRef,192 selectedThreadRef,
141 ],193 ],
packages/shared/src/orchestrationTiming.test.ts+108-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { formatDuration } from "./orchestrationTiming.ts";3import { formatDuration, deriveActiveWorkStartedAt } from "./orchestrationTiming.ts";
44
5describe("formatDuration", () => {5describe("formatDuration", () => {
6 it.each([6 it.each([
...@@ -29,3 +29,110 @@ describe("formatDuration", () => {...@@ -29,3 +29,110 @@ describe("formatDuration", () => {
29 expect(formatDuration(durationMs)).toBe("0ms");29 expect(formatDuration(durationMs)).toBe("0ms");
30 });30 });
31});31});
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 @@...@@ -1,5 +1,7 @@
1type LatestTurnTiming = {1type LatestTurnTiming = {
2 readonly turnId: string | null;2 readonly turnId: string | null;
3 /** Set when the turn is created; `startedAt` waits for the provider. */
4 readonly requestedAt?: string | null;
3 readonly startedAt: string | null;5 readonly startedAt: string | null;
4 readonly completedAt: string | null;6 readonly completedAt: string | null;
5};7};
...@@ -32,20 +34,33 @@ function isLatestTurnSettled(...@@ -32,20 +34,33 @@ function isLatestTurnSettled(
32 latestTurn: LatestTurnTiming | null,34 latestTurn: LatestTurnTiming | null,
33 session: SessionActivityState | null,35 session: SessionActivityState | null,
34): boolean {36): boolean {
35 if (!latestTurn?.startedAt) return false;37 if (!latestTurn) return false;
36 if (!latestTurn.completedAt) return false;38 if (!latestTurn.completedAt) return false;
37 if (!session) return true;39 if (!session) return true;
38 if (session.orchestrationStatus === "running") return false;40 if (session.orchestrationStatus === "running") return false;
39 return true;41 return true;
40}42}
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 */
42export function deriveActiveWorkStartedAt(54export function deriveActiveWorkStartedAt(
43 latestTurn: LatestTurnTiming | null,55 latestTurn: LatestTurnTiming | null,
44 session: SessionActivityState | null,56 session: SessionActivityState | null,
45 sendStartedAt: string | null,57 sendStartedAt: string | null,
46): string | null {58): string | null {
59 if (session?.activeTurnId && session.activeTurnId !== latestTurn?.turnId) {
60 return sendStartedAt;
61 }
47 if (!isLatestTurnSettled(latestTurn, session)) {62 if (!isLatestTurnSettled(latestTurn, session)) {
48 return latestTurn?.startedAt ?? sendStartedAt;63 return latestTurn?.startedAt ?? latestTurn?.requestedAt ?? sendStartedAt;
49 }64 }
50 return sendStartedAt;65 return sendStartedAt;
51}66}