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