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