1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { useEnvironmentPresentation } from "../../state/presentation";
3import { environmentSession } from "../../state/session";
4import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
5import { Alert } from "react-native";
6
7import type {
8 EnvironmentId,
9 ModelSelection,
10 ProjectReadFileResult,
11 ProviderInteractionMode,
12 ProviderOptionSelection,
13 RuntimeMode,
14 ServerProvider,
15} from "@t3tools/contracts";
16import {
17 CommandId,
18 DEFAULT_PROVIDER_INTERACTION_MODE,
19 DEFAULT_RUNTIME_MODE,
20 DEFAULT_SERVER_SETTINGS,
21 MessageId,
22 repositoryGroupingKeyOf,
23 T3_PROJECT_FILE_NAME,
24 ThreadId,
25} from "@t3tools/contracts";
26import { sanitizeNewRefName } from "@t3tools/shared/git";
27import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
28import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
29import * as Arr from "effect/Array";
30import { pipe } from "effect/Function";
31
32import {
33 useEnvironmentServerConfig,
34 useProjects,
35 useServerConfigs,
36 useThreadShells,
37} from "../../state/entities";
38import type { TurnCommandMetadata } from "../../lib/commandMetadata";
39import type { DraftComposerAttachment } from "../../lib/composerImages";
40import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
41import {
42 buildModelOptions,
43 groupByProvider,
44 resolveDefaultableModelSelection,
45 resolveNewTaskModelSelection,
46 resolveSelectableModelSelection,
47} from "../../lib/modelOptions";
48import { scopedProjectKey } from "../../lib/scopedEntities";
49import { appAtomRegistry } from "../../state/atom-registry";
50import { projectEnvironment } from "../../state/projects";
51import { useEnvironmentQuery } from "../../state/query";
52import { useAtomCommand } from "../../state/use-atom-command";
53import {
54 appendComposerDraftAttachments,
55 type ComposerDraftInsertion,
56 clearComposerDraft,
57 composerDraftsAtom,
58 createNewTaskDraft,
59 getComposerDraftSnapshot,
60 isComposerDraftEmpty,
61 isNewTaskDraftKey,
62 removeComposerDraftAttachment,
63 replaceComposerDraftAttachments,
64 retargetNewTaskDraft,
65 scheduleUnusedComposerAttachmentCleanup,
66 setComposerDraftText,
67 setComposerDraftContext,
68 setStickyComposerModelSelection,
69 updateComposerDraftSettings,
70 useComposerDraft,
71 useStickyComposerModelSelection,
72} from "../../state/use-composer-drafts";
73import {
74 capturePendingTaskEditorWriteBaseline,
75 flushPendingTaskEditorWrite,
76} from "../../state/pending-task-editor-writes";
77import {
78 rememberModelOptions,
79 withRememberedModelOptions,
80} from "../../state/use-model-option-memory";
81import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
82import { vcsEnvironment } from "../../state/vcs";
83import {
84 flattenQueuedThreadMessages,
85 threadOutboxManager,
86 type QueuedThreadMessage,
87} from "../../state/thread-outbox";
88import {
89 holdEditingQueuedMessage,
90 releaseEditingQueuedMessage,
91 useThreadOutboxMessages,
92} from "../../state/use-thread-outbox";
93import {
94 setPendingConnectionError,
95 useRemoteConnectionStatus,
96 useSavedRemoteConnections,
97} from "../../state/use-remote-environment-registry";
98import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
99import {
100 isAtomCommandInterrupted,
101 squashAtomCommandFailure,
102} from "@t3tools/client-runtime/state/runtime";
103import { isScratchProject } from "@t3tools/client-runtime/state/projects";
104import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
105import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
106import {
107 buildHomeProjectScopes,
108 sortHomeProjectScopes,
109 type HomeProjectScope,
110} from "../home/homeThreadList";
111import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
112import {
113 resolvePendingTaskInteractionMode,
114 resolveProviderInteractionMode,
115} from "../../state/legacy-plan-mode";
116import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
117import {
118 filterNewTaskBranches,
119 resolveNewTaskBranchWorktreePath,
120 resolveNewTaskLocalWorkspaceSelection,
121} from "./new-task-context-presentation";
122import { resolveEnvironmentProjectMatch } from "./new-task-project-selection";
123import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
124
125type WorkspaceMode = "local" | "worktree";
126
127const BRANCH_SEARCH_DEBOUNCE_MS = 150;
128
129function pendingTaskDraftKey(messageId: string): string {
130 return `pending-task:${messageId}`;
131}
132
133// The message id owned by the currently active editing session, tracked
134// across provider instances. An in-flight flush from a dismissed session
135// consults it so it never drops the draft or releases the drain lock out from
136// under a newer session editing the same task.
137let activeEditingMessageId: string | null = null;
138
139function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null {
140 const message = flattenQueuedThreadMessages(
141 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
142 ).find((candidate) => candidate.messageId === messageId);
143 return message?.creation !== undefined ? message : null;
144}
145
146export function branchBadgeLabel(input: {
147 readonly branch: VcsRef;
148 readonly project: EnvironmentProject | null;
149}): string | null {
150 if (input.branch.current) {
151 return "current";
152 }
153 if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) {
154 return "worktree";
155 }
156 if (input.branch.isRemote) {
157 return "remote";
158 }
159 if (input.branch.isDefault) {
160 return "default";
161 }
162 return null;
163}
164
165type NewTaskFlowContextValue = {
166 readonly projectScopes: ReadonlyArray<HomeProjectScope>;
167 readonly selectedEnvironmentId: EnvironmentId | null;
168 readonly selectedProjectKey: string | null;
169 readonly selectedModelKey: string | null;
170 readonly workspaceMode: WorkspaceMode;
171 /** False for threads without a project: their folder has no branch or worktree. */
172 readonly canChooseWorkspace: boolean;
173 readonly selectedBranchName: string | null;
174 readonly selectedWorktreePath: string | null;
175 readonly startFromOrigin: boolean;
176 readonly pullRequestReference: string | undefined;
177 readonly draftKey: string | null;
178 readonly editingPendingTask: QueuedThreadMessage | null;
179 readonly prompt: string;
180 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
181 readonly submitting: boolean;
182 readonly branchQuery: string;
183 readonly branchesLoading: boolean;
184 readonly branchesError: string | null;
185 readonly branchesFetchingNextPage: boolean;
186 readonly hasMoreBranches: boolean;
187 readonly availableBranches: ReadonlyArray<VcsRef>;
188 readonly currentCheckoutBranchName: string | null;
189 readonly runtimeMode: RuntimeMode;
190 readonly interactionMode: ProviderInteractionMode;
191 readonly planModeEnabled: boolean;
192 readonly expandedProvider: string | null;
193 readonly environments: ReadonlyArray<{
194 readonly environmentId: EnvironmentId;
195 readonly environmentLabel: string;
196 }>;
197 readonly selectedProject: EnvironmentProject | null;
198 /** True when the draft is a thread without a project (its machine's Scratch project). */
199 readonly isScratchDraft: boolean;
200 readonly modelOptions: ReadonlyArray<ModelOption>;
201 readonly selectedModel: ModelSelection | null;
202 readonly selectedModelOption: ModelOption | null;
203 readonly selectedProviderStatus: ServerProvider | null;
204 readonly providerGroups: ReadonlyArray<ProviderGroup>;
205 readonly filteredBranches: ReadonlyArray<VcsRef>;
206 readonly reset: () => void;
207 readonly setProject: (project: EnvironmentProject) => void;
208 /**
209 * Binds the composer to an existing new-task draft (a row in the thread
210 * list). Returns false when the draft is gone, so the caller can fall back
211 * to a fresh one.
212 */
213 readonly openDraft: (draftKey: string) => boolean;
214 readonly selectEnvironment: (environmentId: EnvironmentId) => void;
215 /**
216 * Moves the draft to another machine. A draft without a project resolves to
217 * that machine's "No project" folder first, creating it when needed.
218 */
219 readonly switchEnvironment: (environmentId: EnvironmentId) => Promise<boolean>;
220 /** The machine a switch in progress is heading to. */
221 readonly switchingToEnvironmentId: EnvironmentId | null;
222 readonly setSelectedModelKey: (
223 key: string | null,
224 options?: ReadonlyArray<ProviderOptionSelection>,
225 ) => void;
226 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
227 readonly selectBranch: (branch: VcsRef) => void;
228 readonly setStartFromOrigin: (value: boolean) => void;
229 readonly beginEditingPendingTask: (messageId: string) => boolean;
230 readonly finishEditingPendingTask: () => void;
231 readonly cancelEditingPendingTask: () => void;
232 readonly buildPendingTaskMessage: (
233 metadata: TurnCommandMetadata,
234 options?: {
235 /** The live checkout, recorded as a local task's branch when it sends now. */
236 readonly currentCheckoutBranch?: string | null;
237 },
238 ) => QueuedThreadMessage | null;
239 readonly setPrompt: (value: string) => void;
240 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void;
241 /** Appends draft attachments; returns how many the live cap rejected. */
242 readonly appendAttachments: (
243 attachments: ReadonlyArray<DraftComposerAttachment>,
244 insertion?: ComposerDraftInsertion,
245 ) => number;
246 readonly removeAttachment: (imageId: string) => void;
247 readonly clearAttachments: () => void;
248 readonly setSubmitting: (value: boolean) => void;
249 readonly setBranchQuery: (value: string) => void;
250 readonly loadBranches: () => void;
251 readonly loadMoreBranches: () => void;
252 readonly setRuntimeMode: (value: RuntimeMode) => void;
253 readonly setInteractionMode: (value: ProviderInteractionMode) => void;
254 readonly setSelectedModelOptions: (
255 value: ReadonlyArray<ProviderOptionSelection> | undefined,
256 ) => void;
257 readonly setExpandedProvider: (value: string | null) => void;
258};
259
260const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null);
261
262export function NewTaskFlowProvider(props: React.PropsWithChildren) {
263 const projects = useProjects();
264 const threads = useThreadShells();
265 const { savedConnectionsById } = useSavedRemoteConnections();
266 const groupingSettings = useMobileProjectGroupingSettings();
267 const { enabled: legacyPlanModeEnabled, loaded: planModePreferenceLoaded } =
268 useLegacyPlanModeState();
269
270 const projectScopes = useMemo(
271 () =>
272 sortHomeProjectScopes({
273 scopes: buildHomeProjectScopes({
274 projects,
275 environmentId: null,
276 projectGroupingMode: groupingSettings.sidebarProjectGroupingMode,
277 }),
278 threads,
279 pendingTasks: [],
280 projectSortOrder: "updated_at",
281 }),
282 [groupingSettings.sidebarProjectGroupingMode, projects, threads],
283 );
284
285 const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState<EnvironmentId | null>(
286 null,
287 );
288 const selectedEnvironmentId =
289 selectedEnvironmentIdOverride !== null &&
290 projects.some((project) => project.environmentId === selectedEnvironmentIdOverride)
291 ? selectedEnvironmentIdOverride
292 : (projects[0]?.environmentId ?? null);
293 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
294 // The new-task draft the composer is bound to. Null until a project is
295 // chosen; each New Task entry mints its own, so a project can hold several.
296 const [activeDraftKey, setActiveDraftKey] = useState<string | null>(null);
297 const [submitting, setSubmitting] = useState(false);
298 const [branchQuery, setBranchQuery] = useState("");
299 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
300 const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null);
301 const pendingLocalBranchSyncDraftKeysRef = useRef(new Set<string>());
302 // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act
303 // on a task whose editing session already ended this render.
304 const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null);
305 // Outbox revision this editor session may write after its predecessor save.
306 // Unrelated accepted writes still beat the dismissed session's CAS.
307 const editingRevisionRef = useRef(Promise.resolve(0));
308
309 const reset = useCallback(() => {
310 setSelectedEnvironmentId(null);
311 setSelectedProjectKey(null);
312 setActiveDraftKey(null);
313 setSubmitting(false);
314 setBranchQuery("");
315 setExpandedProvider(null);
316 pendingLocalBranchSyncDraftKeysRef.current.clear();
317 const editing = editingPendingTaskRef.current;
318 editingPendingTaskRef.current = null;
319 setEditingPendingTask(null);
320 if (editing) {
321 if (activeEditingMessageId === editing.messageId) {
322 activeEditingMessageId = null;
323 }
324 releaseEditingQueuedMessage(editing.messageId);
325 }
326 }, []);
327
328 const projectsForEnvironment = useMemo(
329 () =>
330 pipe(
331 projects,
332 Arr.filter((project) => project.environmentId === selectedEnvironmentId),
333 ),
334 [projects, selectedEnvironmentId],
335 );
336
337 // Stand-in for the edited task's project while its shell is not loaded
338 // (environment offline / still synchronizing), built from the metadata
339 // snapshotted at enqueue time.
340 const editingPendingProject = useMemo<EnvironmentProject | null>(() => {
341 const creation = editingPendingTask?.creation;
342 if (!editingPendingTask || !creation) {
343 return null;
344 }
345 return {
346 environmentId: editingPendingTask.environmentId,
347 id: creation.projectId,
348 title: creation.projectTitle ?? "Unknown project",
349 // Deliberately empty when the snapshot has no cwd — downstream consumers
350 // (branch queries, worktree bootstrap) must skip it, not receive a
351 // fabricated path.
352 workspaceRoot: creation.projectCwd ?? "",
353 repositoryIdentity: null,
354 defaultModelSelection: editingPendingTask.modelSelection ?? null,
355 scripts: [],
356 createdAt: editingPendingTask.createdAt,
357 updatedAt: editingPendingTask.createdAt,
358 };
359 }, [editingPendingTask]);
360
361 const selectedProject =
362 projectsForEnvironment.find(
363 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
364 ) ??
365 // While editing a queued task whose project shell is absent, keep the task
366 // pinned to its own project — falling through to an arbitrary first
367 // project would silently retarget it (and its reused turn identifiers).
368 (editingPendingProject !== null &&
369 selectedProjectKey ===
370 scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id)
371 ? editingPendingProject
372 : (projectsForEnvironment[0] ?? null));
373
374 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
375 selectedProject?.environmentId ?? null,
376 );
377 const isScratchDraft =
378 selectedProject !== null &&
379 isScratchProject(selectedProject, selectedEnvironmentServerConfig?.scratchWorkspaceRoot);
380 const serverConfigs = useServerConfigs();
381 const { connectedEnvironments } = useRemoteConnectionStatus();
382 // A thread without a project can move to any connected machine that offers
383 // one; its Scratch project there is created on the switch if it is missing.
384 const scratchEnvironments = useMemo(
385 () =>
386 connectedEnvironments.filter(
387 (environment) =>
388 availableScratchWorkspaceRoot(
389 environment.connectionState,
390 serverConfigs.get(environment.environmentId),
391 ) !== null,
392 ),
393 [connectedEnvironments, serverConfigs],
394 );
395
396 // Only offer machines that actually host the currently selected repository, so
397 // switching computers moves the same repo across machines instead of jumping to
398 // whatever unrelated project happens to be first on the other machine. Repository
399 // identity is the primary signal; projects that haven't reported one yet (still
400 // indexing) fall back to workspace basename / title so a valid host isn't hidden.
401 const selectedRepositoryKey = selectedProject?.repositoryIdentity
402 ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
403 : null;
404 // `|| null` (not `??`): a pending-task placeholder project can have an empty
405 // workspaceRoot, and an "" basename would reject every real host below.
406 const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
407 const selectedProjectTitle = selectedProject?.title ?? null;
408 const environments = useMemo(() => {
409 if (isScratchDraft) {
410 return scratchEnvironments.map((environment) => ({
411 environmentId: environment.environmentId,
412 environmentLabel: environment.environmentLabel,
413 }));
414 }
415 const seen = new Set<EnvironmentId>();
416 const result: Array<{
417 readonly environmentId: EnvironmentId;
418 readonly environmentLabel: string;
419 }> = [];
420 const hostsSelectedRepository = (project: EnvironmentProject) => {
421 if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {
422 return true;
423 }
424 const projectKey = project.repositoryIdentity
425 ? repositoryGroupingKeyOf(project.repositoryIdentity)
426 : null;
427 if (selectedRepositoryKey !== null && projectKey !== null) {
428 return projectKey === selectedRepositoryKey;
429 }
430 return (
431 project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename ||
432 (selectedProjectTitle !== null && project.title === selectedProjectTitle)
433 );
434 };
435 for (const project of projects) {
436 if (!hostsSelectedRepository(project)) {
437 continue;
438 }
439 if (seen.has(project.environmentId)) {
440 continue;
441 }
442 const environment = savedConnectionsById[project.environmentId];
443 if (!environment) {
444 continue;
445 }
446 seen.add(project.environmentId);
447 result.push({
448 environmentId: project.environmentId,
449 environmentLabel: environment.environmentLabel,
450 });
451 }
452 return result;
453 }, [
454 isScratchDraft,
455 projects,
456 savedConnectionsById,
457 scratchEnvironments,
458 selectedRepositoryKey,
459 selectedWorkspaceBasename,
460 selectedProjectTitle,
461 ]);
462
463 // While a queued pending task is being edited its draft lives under a key
464 // scoped to the queued message, so new-task drafts stay intact.
465 const selectedProjectDraftKey = editingPendingTask
466 ? pendingTaskDraftKey(editingPendingTask.messageId)
467 : selectedProject
468 ? activeDraftKey
469 : null;
470 // selectedProject can resolve without setProject ever running (the
471 // environment's first project is the fallback, and the draft screen skips
472 // setProject when the route's project already matches it). The composer
473 // still needs a draft to write into, so bind one the moment a project is
474 // in view and nothing else owns the key.
475 useEffect(() => {
476 if (activeDraftKey !== null || editingPendingTask !== null || selectedProject === null) {
477 return;
478 }
479 setActiveDraftKey(
480 createNewTaskDraft({
481 environmentId: selectedProject.environmentId,
482 projectId: selectedProject.id,
483 }),
484 );
485 }, [activeDraftKey, editingPendingTask, selectedProject]);
486 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
487 const prompt = selectedProjectDraft.text;
488 const attachments = selectedProjectDraft.attachments;
489 // Default mode until the user picks one explicitly — same resolution web
490 // uses for new draft threads: per-project setting, then the repo's
491 // checked-in t3.json, then the server's configured default (which a new
492 // project with no threads yet replaces with its checkout).
493 const fileAccessSession = useEnvironmentQuery(
494 selectedProject !== null && selectedProject.workspaceRoot !== ""
495 ? environmentSession.sessionStateAtom(selectedProject.environmentId)
496 : null,
497 );
498 const fileEnvironment = useEnvironmentPresentation(selectedProject?.environmentId ?? null);
499 const fileAccess = resolveFilesystemReadAccess({
500 isCatalogReady: fileEnvironment.isReady,
501 connection: fileEnvironment.presentation?.connection ?? null,
502 session: fileAccessSession.data,
503 sessionError: fileAccessSession.error,
504 });
505 const { canReadFiles } = fileAccess;
506 const fileAccessPending =
507 selectedProject !== null && selectedProject.workspaceRoot !== "" && fileAccess.isPending;
508 const t3ProjectFileQuery = useEnvironmentQuery(
509 canReadFiles && selectedProject !== null && selectedProject.workspaceRoot !== ""
510 ? projectEnvironment.readFile({
511 environmentId: selectedProject.environmentId,
512 input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME },
513 })
514 : null,
515 );
516 const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null;
517 const t3ProjectFile = useMemo(
518 () =>
519 t3ProjectFileData === null || t3ProjectFileData.truncated
520 ? null
521 : parseT3ProjectFile(t3ProjectFileData.contents),
522 [t3ProjectFileData],
523 );
524 // Environment settings with the project's overrides and its t3.json
525 // applied; the aggregate's own legacy fields still count until the server
526 // folds them.
527 const projectSettings = useMemo(
528 () =>
529 resolveProjectSettings(
530 selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
531 selectedProject?.id ?? null,
532 selectedProject,
533 t3ProjectFile,
534 ),
535 [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile],
536 );
537 // A thread without a project runs in a plain folder, so worktree mode
538 // would leave it unsendable: it is always local and offers no choice.
539 const canChooseWorkspace = !isScratchDraft;
540 const selectedProjectHasThreads =
541 selectedProject !== null &&
542 threads.some(
543 (thread) =>
544 thread.environmentId === selectedProject.environmentId &&
545 thread.projectId === selectedProject.id,
546 );
547 const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace
548 ? resolveNewThreadEnvMode({
549 projectSettings,
550 workspaceRoot: selectedProject?.workspaceRoot ?? null,
551 newProjectsRoot: selectedEnvironmentServerConfig?.newProjectsRoot,
552 projectHasThreads: selectedProjectHasThreads,
553 })
554 : "local";
555 // While the file read is pending and nothing above it decided, the
556 // resolved default is provisional. Nothing may write it into the draft
557 // during that window (the auto-branch effect does), or the frozen interim
558 // value beats the t3.json default once it loads.
559 const defaultWorkspaceModeSettled =
560 selectedProjectDraft.workspaceSelection?.mode !== undefined ||
561 projectSettings.sources.defaultThreadEnvMode !== "environment" ||
562 (!t3ProjectFileQuery.isPending && !fileAccessPending);
563 const workspaceMode = canChooseWorkspace
564 ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode)
565 : "local";
566 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
567 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
568 // Keep the user's explicit choice separate from the resolved display value:
569 // only the explicit flag is ever written back to the draft, so the resolved
570 // value keeps tracking the server setting when the config loads late.
571 const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;
572 const startFromOrigin =
573 draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin;
574 const defaultRuntimeMode = editingPendingTask
575 ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE)
576 : projectSettings.settings.defaultRuntimeMode;
577 const runtimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode;
578
579 // Antigravity keeps unavailable selections so sign-out or a catalog change
580 // cannot switch the user's model. Other providers retain their fallback
581 // rules. Implicit defaults also exclude legacy models for those providers.
582 const draftModelSelection = resolveSelectableModelSelection(
583 selectedEnvironmentServerConfig,
584 selectedProjectDraft.modelSelection ?? null,
585 selectedProject?.id ?? null,
586 );
587 const projectDefaultModelSelection = resolveDefaultableModelSelection(
588 selectedEnvironmentServerConfig,
589 projectSettings.settings.defaultModelSelection,
590 selectedProject?.id ?? null,
591 );
592 const storedStickyModelSelection = useStickyComposerModelSelection();
593 const stickyModelSelection = resolveDefaultableModelSelection(
594 selectedEnvironmentServerConfig,
595 storedStickyModelSelection,
596 selectedProject?.id ?? null,
597 );
598 const modelOptions = useMemo(
599 () =>
600 buildModelOptions(
601 selectedEnvironmentServerConfig,
602 draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
603 undefined,
604 selectedProject?.id ?? null,
605 ),
606 [
607 selectedEnvironmentServerConfig,
608 draftModelSelection,
609 projectDefaultModelSelection,
610 stickyModelSelection,
611 selectedProject?.id,
612 ],
613 );
614
615 // An unsent draft keeps its explicit pick. Fresh drafts resolve the project
616 // default before the last manual app-wide selection and provider default.
617 const selectedModel = resolveNewTaskModelSelection({
618 draftSelection: draftModelSelection,
619 projectDefaultSelection: projectDefaultModelSelection,
620 stickySelection: stickyModelSelection,
621 modelOptions,
622 });
623 const selectedModelKey = selectedModel
624 ? `${selectedModel.instanceId}:${selectedModel.model}`
625 : null;
626
627 const selectedModelOption =
628 modelOptions.find(
629 (option) =>
630 selectedModel &&
631 option.selection.instanceId === selectedModel.instanceId &&
632 option.selection.model === selectedModel.model,
633 ) ?? null;
634 const selectedProviderStatus = useMemo(
635 () =>
636 selectedEnvironmentServerConfig?.providers.find(
637 (provider) => provider.instanceId === selectedModel?.instanceId,
638 ) ?? null,
639 [selectedEnvironmentServerConfig, selectedModel?.instanceId],
640 );
641 const planModeEnabled =
642 legacyPlanModeEnabled && selectedProviderStatus?.showInteractionModeToggle !== false;
643 const interactionMode = planModeEnabled
644 ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
645 : DEFAULT_PROVIDER_INTERACTION_MODE;
646 const setSelectedModelKey = useCallback(
647 // Options ride along in the same write: a follow-up setSelectedModelOptions
648 // call would rebuild the selection from the stale pre-switch model.
649 (key: string | null, options?: ReadonlyArray<ProviderOptionSelection>) => {
650 if (!key || !selectedProjectDraftKey) {
651 return;
652 }
653 const option = modelOptions.find((candidate) => candidate.key === key);
654 if (!option) {
655 return;
656 }
657 const selection = withRememberedModelOptions(
658 options ? { ...option.selection, options } : option.selection,
659 );
660 const provider = selectedEnvironmentServerConfig?.providers.find(
661 (candidate) => candidate.instanceId === selection.instanceId,
662 );
663 updateComposerDraftSettings(selectedProjectDraftKey, {
664 modelSelection: selection,
665 ...(provider?.showInteractionModeToggle === false
666 ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE }
667 : {}),
668 });
669 setStickyComposerModelSelection(selection);
670 },
671 [modelOptions, selectedEnvironmentServerConfig, selectedProjectDraftKey],
672 );
673 const setSelectedModelOptions = useCallback(
674 (options: ReadonlyArray<ProviderOptionSelection> | undefined) => {
675 if (!selectedModel || !selectedProjectDraftKey) {
676 return;
677 }
678 rememberModelOptions(selectedModel.instanceId, selectedModel.model, options ?? []);
679 const nextSelection: ModelSelection = options
680 ? { ...selectedModel, options }
681 : {
682 instanceId: selectedModel.instanceId,
683 model: selectedModel.model,
684 };
685 updateComposerDraftSettings(selectedProjectDraftKey, {
686 modelSelection: nextSelection,
687 });
688 setStickyComposerModelSelection(nextSelection);
689 },
690 [selectedModel, selectedProjectDraftKey],
691 );
692
693 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
694 const setPrompt = useCallback(
695 (value: string) => {
696 if (!selectedProjectDraftKey) {
697 return;
698 }
699 setComposerDraftText(selectedProjectDraftKey, value);
700 },
701 [selectedProjectDraftKey],
702 );
703 const replaceAttachments = useCallback(
704 (nextAttachments: ReadonlyArray<DraftComposerAttachment>) => {
705 if (!selectedProjectDraftKey) {
706 return;
707 }
708 replaceComposerDraftAttachments(selectedProjectDraftKey, nextAttachments);
709 },
710 [selectedProjectDraftKey],
711 );
712 // Returns how many attachments the live cap rejected so the caller can
713 // tell the user (a concurrent add can fill the draft mid-pick).
714 const appendAttachments = useCallback(
715 (
716 nextAttachments: ReadonlyArray<DraftComposerAttachment>,
717 insertion?: ComposerDraftInsertion,
718 ): number => {
719 if (!selectedProjectDraftKey) {
720 return 0;
721 }
722 return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, {
723 appendReference: true,
724 insertion,
725 });
726 },
727 [selectedProjectDraftKey],
728 );
729 const removeAttachment = useCallback(
730 (imageId: string) => {
731 if (!selectedProjectDraftKey) {
732 return;
733 }
734 removeComposerDraftAttachment(selectedProjectDraftKey, imageId);
735 },
736 [selectedProjectDraftKey],
737 );
738 const clearAttachments = useCallback(() => {
739 if (!selectedProjectDraftKey) {
740 return;
741 }
742 replaceComposerDraftAttachments(selectedProjectDraftKey, []);
743 }, [selectedProjectDraftKey]);
744 const branchSearchQuery = sanitizeNewRefName(branchQuery);
745 const debouncedBranchQuery = useDebouncedValue(branchSearchQuery, BRANCH_SEARCH_DEBOUNCE_MS);
746 const branchTarget = useMemo(
747 () => ({
748 environmentId: selectedProject?.environmentId ?? null,
749 // `|| null` also skips the stand-in project's empty workspaceRoot.
750 cwd: selectedProject?.workspaceRoot || null,
751 query: debouncedBranchQuery,
752 }),
753 [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot],
754 );
755 const branchState = usePaginatedBranches(branchTarget);
756 const branchSearchIsDebouncing = branchSearchQuery !== debouncedBranchQuery;
757 const branchesLoading =
758 branchSearchIsDebouncing || (branchState.isPending && branchState.data === null);
759 const branchesFetchingNextPage = branchState.isFetchingNextPage;
760 const hasMoreBranches =
761 branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined;
762 const allBranchRefs = branchState.refs;
763 const availableBranches = useMemo(
764 () =>
765 pipe(
766 allBranchRefs,
767 Arr.filter((branch) => !branch.isRemote),
768 ),
769 [allBranchRefs],
770 );
771 // The ref actually checked out in the project root, serialized onto new
772 // local threads. It comes from the live status stream rather than listRefs'
773 // `current` flag, which is served from a cache that can lag an out-of-band
774 // `git switch` by minutes — and from the same value the PR badge compares
775 // against. Detached HEAD and non-repository projects report no ref, so this
776 // stays null instead of fabricating a branch. The status family is
777 // deduplicated per (environmentId, cwd) with the thread rows.
778 const projectGitStatus = useEnvironmentQuery(
779 branchTarget.environmentId !== null && branchTarget.cwd !== null
780 ? vcsEnvironment.status({
781 environmentId: branchTarget.environmentId,
782 input: { cwd: branchTarget.cwd },
783 })
784 : null,
785 );
786 const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null;
787
788 const filteredBranches = useMemo(
789 () => filterNewTaskBranches(allBranchRefs, branchQuery),
790 [allBranchRefs, branchQuery],
791 );
792
793 // The composer's draft follows the project it will be sent to: switching
794 // mid-compose keeps the same draft and moves it, so typed text follows the
795 // user. A pending-task edit owns its own key and is untouched here.
796 const carryDraftContentTo = useCallback(
797 (project: EnvironmentProject) => {
798 const target = { environmentId: project.environmentId, projectId: project.id };
799 if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
800 retargetNewTaskDraft(activeDraftKey, target);
801 } else if (!editingPendingTaskRef.current) {
802 setActiveDraftKey(createNewTaskDraft(target));
803 }
804 },
805 [activeDraftKey],
806 );
807
808 const setProject = useCallback(
809 (project: EnvironmentProject) => {
810 carryDraftContentTo(project);
811 setSelectedEnvironmentId(project.environmentId);
812 setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id));
813 },
814 [carryDraftContentTo],
815 );
816
817 const openDraft = useCallback(
818 (draftKey: string): boolean => {
819 const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
820 const stamp = draft?.project;
821 if (!isNewTaskDraftKey(draftKey) || !stamp) {
822 return false;
823 }
824 // The stamped project must be loaded: selectedProject falls back to
825 // the environment's first project otherwise, and the draft would be
826 // sent somewhere the user never chose.
827 const projectLoaded = projects.some(
828 (project) =>
829 project.environmentId === stamp.environmentId && project.id === stamp.projectId,
830 );
831 if (!projectLoaded) {
832 return false;
833 }
834 setActiveDraftKey(draftKey);
835 setSelectedEnvironmentId(stamp.environmentId);
836 setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId));
837 return true;
838 },
839 [projects],
840 );
841
842 const selectEnvironment = useCallback(
843 (environmentId: EnvironmentId) => {
844 const match = resolveEnvironmentProjectMatch(
845 projects.filter((project) => project.environmentId === environmentId),
846 selectedProject,
847 );
848 if (match) {
849 carryDraftContentTo(match);
850 }
851 setSelectedEnvironmentId(environmentId);
852 setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null);
853 },
854 [projects, selectedProject, carryDraftContentTo],
855 );
856
857 const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
858 const [switchingToEnvironmentId, setSwitchingToEnvironmentId] = useState<EnvironmentId | null>(
859 null,
860 );
861 // The latest switch wins: a slower, earlier one must not retarget the draft.
862 const latestSwitchRef = useRef<object | null>(null);
863 const switchEnvironment = useCallback(
864 async (environmentId: EnvironmentId): Promise<boolean> => {
865 if (environmentId === selectedEnvironmentId) {
866 latestSwitchRef.current = null;
867 setSwitchingToEnvironmentId(null);
868 return true;
869 }
870 if (!isScratchDraft) {
871 selectEnvironment(environmentId);
872 return true;
873 }
874 const request = {};
875 latestSwitchRef.current = request;
876 setSwitchingToEnvironmentId(environmentId);
877 try {
878 const result = await openScratch({ environmentId, input: {} });
879 if (latestSwitchRef.current !== request) return false;
880 if (result._tag === "Success") {
881 setProject(result.value);
882 return true;
883 }
884 if (!isAtomCommandInterrupted(result)) {
885 const error = squashAtomCommandFailure(result);
886 Alert.alert(
887 "Could not switch machine",
888 error instanceof Error
889 ? error.message
890 : "The folder for threads without a project could not be created.",
891 );
892 }
893 return false;
894 } finally {
895 if (latestSwitchRef.current === request) {
896 latestSwitchRef.current = null;
897 setSwitchingToEnvironmentId(null);
898 }
899 }
900 },
901 [isScratchDraft, openScratch, selectEnvironment, selectedEnvironmentId, setProject],
902 );
903
904 const setWorkspaceMode = useCallback(
905 (mode: WorkspaceMode) => {
906 if (!selectedProjectDraftKey) {
907 return;
908 }
909 if (!selectedProject) {
910 return;
911 }
912 const localSelection = resolveNewTaskLocalWorkspaceSelection({
913 branches: availableBranches,
914 projectCwd: selectedProject.workspaceRoot,
915 });
916 if (mode === "local" && localSelection.awaitsCurrentBranch) {
917 pendingLocalBranchSyncDraftKeysRef.current.add(selectedProjectDraftKey);
918 } else {
919 pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
920 }
921 updateComposerDraftSettings(selectedProjectDraftKey, {
922 workspaceSelection: {
923 mode,
924 branch: mode === "local" ? localSelection.branch : selectedBranchName,
925 worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath,
926 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
927 },
928 });
929 },
930 [
931 availableBranches,
932 draftStartFromOrigin,
933 selectedBranchName,
934 selectedProject,
935 selectedProjectDraftKey,
936 selectedWorktreePath,
937 ],
938 );
939
940 useEffect(() => {
941 if (
942 workspaceMode !== "local" ||
943 !selectedProject ||
944 !selectedProjectDraftKey ||
945 !pendingLocalBranchSyncDraftKeysRef.current.has(selectedProjectDraftKey)
946 ) {
947 return;
948 }
949 const localSelection = resolveNewTaskLocalWorkspaceSelection({
950 branches: availableBranches,
951 projectCwd: selectedProject.workspaceRoot,
952 });
953 if (localSelection.awaitsCurrentBranch) {
954 return;
955 }
956
957 pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
958 updateComposerDraftSettings(selectedProjectDraftKey, {
959 workspaceSelection: {
960 mode: "local",
961 branch: localSelection.branch,
962 worktreePath: localSelection.worktreePath,
963 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
964 },
965 });
966 }, [
967 availableBranches,
968 draftStartFromOrigin,
969 selectedProject,
970 selectedProjectDraftKey,
971 workspaceMode,
972 ]);
973
974 const selectBranch = useCallback(
975 (branch: VcsRef) => {
976 if (!selectedProject || !selectedProjectDraftKey) {
977 return;
978 }
979 pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
980 updateComposerDraftSettings(selectedProjectDraftKey, {
981 workspaceSelection: {
982 mode: workspaceMode,
983 branch: branch.name,
984 worktreePath: resolveNewTaskBranchWorktreePath({
985 workspaceMode,
986 projectCwd: selectedProject.workspaceRoot,
987 branchWorktreePath: branch.worktreePath,
988 }),
989 ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
990 },
991 });
992 },
993 [draftStartFromOrigin, selectedProject, selectedProjectDraftKey, workspaceMode],
994 );
995
996 const setStartFromOrigin = useCallback(
997 (value: boolean) => {
998 if (!selectedProjectDraftKey) {
999 return;
1000 }
1001 updateComposerDraftSettings(selectedProjectDraftKey, {
1002 workspaceSelection: {
1003 mode: workspaceMode,
1004 branch: selectedBranchName,
1005 worktreePath: selectedWorktreePath,
1006 startFromOrigin: value,
1007 },
1008 });
1009 },
1010 [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, workspaceMode],
1011 );
1012
1013 const refreshBranches = branchState.refresh;
1014 const loadMoreBranches = branchState.loadNext;
1015 const loadBranches = useCallback(() => {
1016 if (!selectedProject) {
1017 return;
1018 }
1019 setPendingConnectionError(null);
1020 refreshBranches();
1021 }, [refreshBranches, selectedProject]);
1022
1023 useEffect(() => {
1024 if (
1025 !selectedProjectDraftKey ||
1026 !defaultWorkspaceModeSettled ||
1027 workspaceMode !== "worktree" ||
1028 selectedBranchName !== null
1029 ) {
1030 return;
1031 }
1032 // The draft screen writes a thread's branch and worktree into the draft in
1033 // the same commit this effect runs, so the rendered selection above can be
1034 // stale. Re-read the draft before replacing it.
1035 const live = getComposerDraftSnapshot(selectedProjectDraftKey).workspaceSelection;
1036 if (live && (live.mode !== "worktree" || live.branch !== null)) {
1037 return;
1038 }
1039 // The default may only exist as origin/<default> (isRemote), which
1040 // availableBranches filters out — search the unfiltered refs for it.
1041 const preferredBranch =
1042 allBranchRefs.find((branch) => branch.isDefault) ??
1043 availableBranches.find((branch) => branch.current) ??
1044 null;
1045 if (preferredBranch) {
1046 selectBranch(preferredBranch);
1047 }
1048 }, [
1049 allBranchRefs,
1050 availableBranches,
1051 defaultWorkspaceModeSettled,
1052 selectBranch,
1053 selectedBranchName,
1054 selectedProjectDraftKey,
1055 workspaceMode,
1056 ]);
1057
1058 const setRuntimeMode = useCallback(
1059 (value: RuntimeMode) => {
1060 if (selectedProjectDraftKey) {
1061 updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value });
1062 }
1063 },
1064 [selectedProjectDraftKey],
1065 );
1066 const setInteractionMode = useCallback(
1067 (value: ProviderInteractionMode) => {
1068 if (selectedProjectDraftKey) {
1069 updateComposerDraftSettings(selectedProjectDraftKey, {
1070 interactionMode: resolveProviderInteractionMode(selectedProviderStatus, value),
1071 });
1072 }
1073 },
1074 [selectedProjectDraftKey, selectedProviderStatus],
1075 );
1076
1077 const beginEditingPendingTask = useCallback((messageId: string): boolean => {
1078 const message = findQueuedPendingTask(messageId);
1079 if (!message?.creation) {
1080 return false;
1081 }
1082 const draftKey = pendingTaskDraftKey(message.messageId);
1083 // Only hydrate a fresh editing draft; reopening mid-edit keeps newer edits.
1084 if (isComposerDraftEmpty(getComposerDraftSnapshot(draftKey))) {
1085 setComposerDraftText(draftKey, message.text);
1086 setComposerDraftContext(draftKey, message.context);
1087 replaceComposerDraftAttachments(draftKey, message.attachments);
1088 updateComposerDraftSettings(draftKey, {
1089 modelSelection: message.modelSelection,
1090 runtimeMode: message.runtimeMode,
1091 interactionMode: message.interactionMode,
1092 workspaceSelection: {
1093 mode: message.creation.workspaceMode,
1094 branch: message.creation.branch,
1095 worktreePath: message.creation.worktreePath,
1096 startFromOrigin: message.creation.startFromOrigin ?? false,
1097 pullRequestReference: message.creation.pullRequestReference,
1098 },
1099 });
1100 }
1101 setSelectedEnvironmentId(message.environmentId);
1102 setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId));
1103 activeEditingMessageId = message.messageId;
1104 editingPendingTaskRef.current = message;
1105 editingRevisionRef.current = capturePendingTaskEditorWriteBaseline(message.messageId);
1106 setEditingPendingTask(message);
1107 // Hold the outbox drain off this task while it is open in the editor.
1108 holdEditingQueuedMessage(message.messageId);
1109 return true;
1110 }, []);
1111
1112 const buildPendingTaskMessage = useCallback(
1113 (
1114 metadata: TurnCommandMetadata,
1115 options?: { readonly currentCheckoutBranch?: string | null },
1116 ): QueuedThreadMessage | null => {
1117 if (!selectedProject || !selectedProjectDraftKey) {
1118 return null;
1119 }
1120 const draft = getComposerDraftSnapshot(selectedProjectDraftKey);
1121 const text = draft.text.trim();
1122 // Use the displayed selection rules without substituting an unavailable
1123 // Antigravity model while the task is queued.
1124 const draftModelSelection =
1125 resolveSelectableModelSelection(
1126 selectedEnvironmentServerConfig,
1127 draft.modelSelection ?? null,
1128 selectedProject.id,
1129 ) ?? selectedModel;
1130 // A shared image or file is a task on its own; text is optional.
1131 if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) {
1132 return null;
1133 }
1134 // A saved choice from before the project went no-project must not
1135 // survive: those threads always run locally in their own folder.
1136 const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined;
1137 // Fall back to the resolved mode (server default) so queued tasks drain
1138 // with the same mode the composer displayed.
1139 const mode = workspaceSelection?.mode ?? workspaceMode;
1140 // When the selection is the stand-in built from the queued snapshot,
1141 // persist the original (possibly absent) snapshot values — the
1142 // stand-in's placeholder title/workspaceRoot must never be written back
1143 // as if they were real project metadata.
1144 const usingPendingSnapshot = selectedProject === editingPendingProject;
1145 const projectTitle = usingPendingSnapshot
1146 ? editingPendingTask?.creation?.projectTitle
1147 : selectedProject.title;
1148 const projectCwd = usingPendingSnapshot
1149 ? editingPendingTask?.creation?.projectCwd
1150 : selectedProject.workspaceRoot;
1151 return {
1152 environmentId: selectedProject.environmentId,
1153 threadId: ThreadId.make(metadata.threadId),
1154 messageId: MessageId.make(metadata.messageId),
1155 commandId: CommandId.make(metadata.commandId),
1156 text,
1157 attachments: draft.attachments,
1158 context: draft.context,
1159 modelSelection: draftModelSelection,
1160 runtimeMode: draft.runtimeMode ?? defaultRuntimeMode,
1161 interactionMode: resolvePendingTaskInteractionMode({
1162 preferenceLoaded: planModePreferenceLoaded,
1163 planModeEnabled: legacyPlanModeEnabled,
1164 draftInteractionMode: draft.interactionMode,
1165 queuedInteractionMode: editingPendingTask?.interactionMode,
1166 provider: selectedEnvironmentServerConfig?.providers.find(
1167 (candidate) => candidate.instanceId === draftModelSelection.instanceId,
1168 ),
1169 }),
1170 creation: {
1171 projectId: selectedProject.id,
1172 ...(projectTitle !== undefined ? { projectTitle } : {}),
1173 ...(projectCwd !== undefined ? { projectCwd } : {}),
1174 workspaceMode: mode,
1175 ...(mode === "worktree" && workspaceSelection?.pullRequestReference
1176 ? { pullRequestReference: workspaceSelection.pullRequestReference }
1177 : {}),
1178 // An explicit picker choice wins. Otherwise only a task sending now
1179 // records the current checkout: a queued local task drains days
1180 // later against whatever is checked out then, so a queue-time
1181 // guess would pin a stale label to a thread that ran somewhere else.
1182 branch: resolveProjectThreadCreationBranch({
1183 workspaceMode: mode,
1184 selectedBranch: workspaceSelection?.branch ?? null,
1185 currentCheckoutBranch: options?.currentCheckoutBranch ?? null,
1186 }),
1187 worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
1188 // The draft only carries the flag when the user touched it; fall
1189 // back to the resolved default (server settings) so queued tasks
1190 // drain with the same origin mode the composer displayed.
1191 ...((workspaceSelection?.startFromOrigin ?? startFromOrigin)
1192 ? { startFromOrigin: true }
1193 : {}),
1194 },
1195 createdAt: metadata.createdAt,
1196 };
1197 },
1198 [
1199 canChooseWorkspace,
1200 defaultRuntimeMode,
1201 editingPendingProject,
1202 editingPendingTask,
1203 selectedEnvironmentServerConfig,
1204 selectedModel,
1205 selectedProject,
1206 selectedProjectDraftKey,
1207 legacyPlanModeEnabled,
1208 planModePreferenceLoaded,
1209 startFromOrigin,
1210 workspaceMode,
1211 ],
1212 );
1213
1214 const finishEditingPendingTask = useCallback(() => {
1215 const editing = editingPendingTaskRef.current;
1216 editingPendingTaskRef.current = null;
1217 if (editing) {
1218 if (activeEditingMessageId === editing.messageId) {
1219 activeEditingMessageId = null;
1220 }
1221 clearComposerDraft(pendingTaskDraftKey(editing.messageId));
1222 releaseEditingQueuedMessage(editing.messageId);
1223 scheduleUnusedComposerAttachmentCleanup(editing.attachments);
1224 }
1225 setEditingPendingTask(null);
1226 }, []);
1227
1228 // If the queued task disappears mid-edit (deleted from the list, or
1229 // delivered), end the editing session immediately without saving — a later
1230 // flush must not resurrect it, and the composer should fall back to the
1231 // regular per-project draft.
1232 const queuedMessagesByThreadKey = useThreadOutboxMessages();
1233 useEffect(() => {
1234 const editing = editingPendingTaskRef.current;
1235 if (!editing) {
1236 return;
1237 }
1238 const stillQueued = flattenQueuedThreadMessages(queuedMessagesByThreadKey).some(
1239 (candidate) => candidate.messageId === editing.messageId,
1240 );
1241 if (!stillQueued) {
1242 finishEditingPendingTask();
1243 }
1244 }, [finishEditingPendingTask, queuedMessagesByThreadKey]);
1245
1246 // Leaving the flow mid-edit (sheet dismissed or draft screen popped) saves
1247 // the current edits back into the queued task so nothing typed here is lost.
1248 const editingFlushRef = useRef<(() => void) | null>(null);
1249 useEffect(() => {
1250 editingFlushRef.current = () => {
1251 const editing = editingPendingTaskRef.current;
1252 if (!editing) {
1253 return;
1254 }
1255 editingPendingTaskRef.current = null;
1256 setEditingPendingTask(null);
1257 if (activeEditingMessageId === editing.messageId) {
1258 activeEditingMessageId = null;
1259 }
1260
1261 const message = buildPendingTaskMessage({
1262 threadId: editing.threadId,
1263 commandId: editing.commandId,
1264 messageId: editing.messageId,
1265 createdAt: editing.createdAt,
1266 });
1267
1268 if (!message) {
1269 // The edits are currently unsendable (e.g. the prompt was cleared).
1270 // Keep both the draft and the drain lock: the stale queued payload
1271 // must not auto-send content the user just removed, and reopening the
1272 // task resumes from the saved draft.
1273 return;
1274 }
1275
1276 // The write handoff lets a reopened editor follow this editor's pending
1277 // save. Its CAS still rejects unrelated queue edits, deletes, and
1278 // deliveries, so the flush cannot resurrect or overwrite them.
1279 void flushPendingTaskEditorWrite({
1280 message,
1281 baseline: editingRevisionRef.current,
1282 draftKey: pendingTaskDraftKey(editing.messageId),
1283 })
1284 .then((savedDraftStillCurrent) => {
1285 // If this task was reopened (possibly in a fresh provider) while
1286 // the save was in flight, that session owns the draft and the lock.
1287 if (activeEditingMessageId === editing.messageId) {
1288 return;
1289 }
1290 if (!savedDraftStillCurrent) {
1291 // A newer queue write won the CAS, or a newer editor changed this
1292 // draft. Keep the draft and drain lock so reopening can retry it.
1293 return;
1294 }
1295 clearComposerDraft(pendingTaskDraftKey(editing.messageId));
1296 releaseEditingQueuedMessage(editing.messageId);
1297 scheduleUnusedComposerAttachmentCleanup(editing.attachments);
1298 })
1299 .catch((error) => {
1300 // Keep the drain lock and the draft: delivering the stale payload
1301 // would silently drop the newer edits. Reopening the task retries.
1302 console.warn("[new-task] failed to save edited pending task", error);
1303 });
1304 };
1305 }, [buildPendingTaskMessage]);
1306 const cancelEditingPendingTask = useCallback(() => {
1307 editingFlushRef.current?.();
1308 }, []);
1309 useEffect(
1310 () => () => {
1311 editingFlushRef.current?.();
1312 },
1313 [],
1314 );
1315
1316 const value = useMemo<NewTaskFlowContextValue>(
1317 () => ({
1318 projectScopes,
1319 selectedEnvironmentId,
1320 selectedProjectKey,
1321 selectedModelKey,
1322 workspaceMode,
1323 canChooseWorkspace,
1324 selectedBranchName,
1325 selectedWorktreePath,
1326 startFromOrigin,
1327 draftKey: selectedProjectDraftKey,
1328 pullRequestReference: selectedProjectDraft.workspaceSelection?.pullRequestReference,
1329 editingPendingTask,
1330 prompt,
1331 attachments,
1332 submitting,
1333 branchQuery,
1334 branchesLoading,
1335 branchesError: branchState.error,
1336 branchesFetchingNextPage,
1337 hasMoreBranches,
1338 availableBranches,
1339 currentCheckoutBranchName,
1340 runtimeMode,
1341 interactionMode,
1342 planModeEnabled,
1343 expandedProvider,
1344 environments,
1345 selectedProject,
1346 isScratchDraft,
1347 modelOptions,
1348 selectedModel,
1349 selectedModelOption,
1350 selectedProviderStatus,
1351 providerGroups,
1352 filteredBranches,
1353 reset,
1354 setProject,
1355 openDraft,
1356 selectEnvironment,
1357 switchEnvironment,
1358 switchingToEnvironmentId,
1359 setSelectedModelKey,
1360 setWorkspaceMode,
1361 selectBranch,
1362 setStartFromOrigin,
1363 beginEditingPendingTask,
1364 finishEditingPendingTask,
1365 cancelEditingPendingTask,
1366 buildPendingTaskMessage,
1367 setPrompt,
1368 replaceAttachments,
1369 appendAttachments,
1370 removeAttachment,
1371 clearAttachments,
1372 setSubmitting,
1373 setBranchQuery,
1374 loadBranches,
1375 loadMoreBranches,
1376 setRuntimeMode,
1377 setInteractionMode,
1378 setSelectedModelOptions,
1379 setExpandedProvider,
1380 }),
1381 [
1382 attachments,
1383 availableBranches,
1384 beginEditingPendingTask,
1385 branchQuery,
1386 branchesLoading,
1387 branchState.error,
1388 branchesFetchingNextPage,
1389 buildPendingTaskMessage,
1390 cancelEditingPendingTask,
1391 currentCheckoutBranchName,
1392 editingPendingTask,
1393 environments,
1394 expandedProvider,
1395 filteredBranches,
1396 finishEditingPendingTask,
1397 interactionMode,
1398 isScratchDraft,
1399 planModeEnabled,
1400 loadBranches,
1401 loadMoreBranches,
1402 projectScopes,
1403 modelOptions,
1404 prompt,
1405 providerGroups,
1406 replaceAttachments,
1407 reset,
1408 runtimeMode,
1409 selectedBranchName,
1410 hasMoreBranches,
1411 selectedEnvironmentId,
1412 selectedModel,
1413 selectedModelKey,
1414 selectedModelOption,
1415 selectedProjectDraftKey,
1416 selectedProviderStatus,
1417 setSelectedModelOptions,
1418 selectedProject,
1419 selectedProjectDraft.workspaceSelection?.pullRequestReference,
1420 selectedProjectKey,
1421 selectedWorktreePath,
1422 setProject,
1423 openDraft,
1424 selectBranch,
1425 selectEnvironment,
1426 switchEnvironment,
1427 switchingToEnvironmentId,
1428 setInteractionMode,
1429 setPrompt,
1430 setRuntimeMode,
1431 setSelectedModelKey,
1432 setStartFromOrigin,
1433 setWorkspaceMode,
1434 startFromOrigin,
1435 submitting,
1436 workspaceMode,
1437 canChooseWorkspace,
1438 appendAttachments,
1439 clearAttachments,
1440 removeAttachment,
1441 ],
1442 );
1443
1444 return <NewTaskFlowContext.Provider value={value}>{props.children}</NewTaskFlowContext.Provider>;
1445}
1446
1447export function useNewTaskFlow() {
1448 const value = React.use(NewTaskFlowContext);
1449 if (value === null) {
1450 throw new Error("useNewTaskFlow must be used within NewTaskFlowProvider.");
1451 }
1452 return value;
1453}