import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EnvironmentId, ModelSelection, ProjectReadFileResult, ProviderInteractionMode, ProviderOptionSelection, RuntimeMode, ServerProviderSkill,} from "@t3tools/contracts";import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, MessageId, T3_PROJECT_FILE_NAME, ThreadId,} from "@t3tools/contracts";import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode,} from "@t3tools/shared/threadEnvMode";import * as Arr from "effect/Array";import { pipe } from "effect/Function"; import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";import type { TurnCommandMetadata } from "../../lib/commandMetadata";import type { DraftComposerImageAttachment } from "../../lib/composerImages";import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";import { buildModelOptions, groupByProvider, resolveDefaultableModelSelection, resolveSelectableModelSelection,} from "../../lib/modelOptions";import { scopedProjectKey } from "../../lib/scopedEntities";import { appAtomRegistry } from "../../state/atom-registry";import { projectEnvironment } from "../../state/projects";import { useEnvironmentQuery } from "../../state/query";import { appendComposerDraftAttachments, clearComposerDraft, copyComposerDraftContentIfEmpty, getComposerDraftSnapshot, isComposerDraftEmpty, removeComposerDraftAttachment, replaceComposerDraftAttachments, setComposerDraftText, updateComposerDraftSettings, useComposerDraft,} from "../../state/use-composer-drafts";import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";import { vcsEnvironment } from "../../state/vcs";import { flattenQueuedThreadMessages, threadOutboxManager, updateThreadOutboxMessage, type QueuedThreadMessage,} from "../../state/thread-outbox";import { holdEditingQueuedMessage, releaseEditingQueuedMessage, useThreadOutboxMessages,} from "../../state/use-thread-outbox";import { setPendingConnectionError, useSavedRemoteConnections,} from "../../state/use-remote-environment-registry";import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";import { type VcsRef } from "@t3tools/client-runtime/state/vcs";import { buildHomeProjectScopes, sortHomeProjectScopes, type HomeProjectScope,} from "../home/homeThreadList";import { useMobileProjectGroupingSettings } from "../../state/project-grouping";import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection,} from "./new-task-context-presentation"; type WorkspaceMode = "local" | "worktree"; const BRANCH_SEARCH_DEBOUNCE_MS = 150; function pendingTaskDraftKey(messageId: string): string { return `pending-task:${messageId}`;} // The message id owned by the currently active editing session, tracked// across provider instances. An in-flight flush from a dismissed session// consults it so it never drops the draft or releases the drain lock out from// under a newer session editing the same task.let activeEditingMessageId: string | null = null; function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { const message = flattenQueuedThreadMessages( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).find((candidate) => candidate.messageId === messageId); return message?.creation !== undefined ? message : null;} export function branchBadgeLabel(input: { readonly branch: VcsRef; readonly project: EnvironmentProject | null;}): string | null { if (input.branch.current) { return "current"; } if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { return "worktree"; } if (input.branch.isDefault) { return "default"; } return null;} type NewTaskFlowContextValue = { readonly projectScopes: ReadonlyArray<HomeProjectScope>; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly selectedModelKey: string | null; readonly workspaceMode: WorkspaceMode; readonly selectedBranchName: string | null; readonly selectedWorktreePath: string | null; readonly startFromOrigin: boolean; readonly draftKey: string | null; readonly editingPendingTask: QueuedThreadMessage | null; readonly prompt: string; readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; readonly submitting: boolean; readonly branchQuery: string; readonly branchesLoading: boolean; readonly branchesError: string | null; readonly branchesFetchingNextPage: boolean; readonly hasMoreBranches: boolean; readonly availableBranches: ReadonlyArray<VcsRef>; readonly currentCheckoutBranchName: string | null; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly planModeEnabled: boolean; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; }>; readonly selectedProject: EnvironmentProject | null; readonly modelOptions: ReadonlyArray<ModelOption>; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>; readonly providerGroups: ReadonlyArray<ProviderGroup>; readonly filteredBranches: ReadonlyArray<VcsRef>; readonly reset: () => void; readonly setProject: (project: EnvironmentProject) => void; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly setSelectedModelKey: ( key: string | null, options?: ReadonlyArray<ProviderOptionSelection>, ) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; readonly selectBranch: (branch: VcsRef) => void; readonly setStartFromOrigin: (value: boolean) => void; readonly beginEditingPendingTask: (messageId: string) => boolean; readonly finishEditingPendingTask: () => void; readonly cancelEditingPendingTask: () => void; readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; readonly setPrompt: (value: string) => void; readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; readonly removeAttachment: (imageId: string) => void; readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; readonly setBranchQuery: (value: string) => void; readonly loadBranches: () => void; readonly loadMoreBranches: () => void; readonly setRuntimeMode: (value: RuntimeMode) => void; readonly setInteractionMode: (value: ProviderInteractionMode) => void; readonly setSelectedModelOptions: ( value: ReadonlyArray<ProviderOptionSelection> | undefined, ) => void; readonly setExpandedProvider: (value: string | null) => void;}; const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null); export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projects = useProjects(); const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); const projectScopes = useMemo( () => sortHomeProjectScopes({ scopes: buildHomeProjectScopes({ projects, environmentId: null, projectGroupingMode: groupingSettings.sidebarProjectGroupingMode, }), threads, pendingTasks: [], projectSortOrder: "updated_at", }), [groupingSettings.sidebarProjectGroupingMode, projects, threads], ); const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState<EnvironmentId | null>( null, ); const selectedEnvironmentId = selectedEnvironmentIdOverride !== null && projects.some((project) => project.environmentId === selectedEnvironmentIdOverride) ? selectedEnvironmentIdOverride : (projects[0]?.environmentId ?? null); const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const [expandedProvider, setExpandedProvider] = useState<string | null>(null); const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null); const pendingLocalBranchSyncDraftKeysRef = useRef(new Set<string>()); // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act // on a task whose editing session already ended this render. const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); setSubmitting(false); setBranchQuery(""); setExpandedProvider(null); pendingLocalBranchSyncDraftKeysRef.current.clear(); const editing = editingPendingTaskRef.current; editingPendingTaskRef.current = null; setEditingPendingTask(null); if (editing) { if (activeEditingMessageId === editing.messageId) { activeEditingMessageId = null; } releaseEditingQueuedMessage(editing.messageId); } }, []); const projectsForEnvironment = useMemo( () => pipe( projects, Arr.filter((project) => project.environmentId === selectedEnvironmentId), ), [projects, selectedEnvironmentId], ); // Stand-in for the edited task's project while its shell is not loaded // (environment offline / still synchronizing), built from the metadata // snapshotted at enqueue time. const editingPendingProject = useMemo<EnvironmentProject | null>(() => { const creation = editingPendingTask?.creation; if (!editingPendingTask || !creation) { return null; } return { environmentId: editingPendingTask.environmentId, id: creation.projectId, title: creation.projectTitle ?? "Unknown project", // Deliberately empty when the snapshot has no cwd — downstream consumers // (branch queries, worktree bootstrap) must skip it, not receive a // fabricated path. workspaceRoot: creation.projectCwd ?? "", repositoryIdentity: null, defaultModelSelection: editingPendingTask.modelSelection ?? null, scripts: [], createdAt: editingPendingTask.createdAt, updatedAt: editingPendingTask.createdAt, }; }, [editingPendingTask]); const selectedProject = projectsForEnvironment.find( (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, ) ?? // While editing a queued task whose project shell is absent, keep the task // pinned to its own project — falling through to an arbitrary first // project would silently retarget it (and its reused turn identifiers). (editingPendingProject !== null && selectedProjectKey === scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) ? editingPendingProject : (projectsForEnvironment[0] ?? null)); // Only offer machines that actually host the currently selected repository, so // switching computers moves the same repo across machines instead of jumping to // whatever unrelated project happens to be first on the other machine. Repository // identity is the primary signal; projects that haven't reported one yet (still // indexing) fall back to workspace basename / title so a valid host isn't hidden. const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; // `|| null` (not `??`): a pending-task placeholder project can have an empty // workspaceRoot, and an "" basename would reject every real host below. const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; const selectedProjectTitle = selectedProject?.title ?? null; const environments = useMemo(() => { const seen = new Set<EnvironmentId>(); const result: Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; }> = []; const hostsSelectedRepository = (project: EnvironmentProject) => { if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { return true; } const projectKey = project.repositoryIdentity?.canonicalKey ?? null; if (selectedRepositoryKey !== null && projectKey !== null) { return projectKey === selectedRepositoryKey; } return ( project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || (selectedProjectTitle !== null && project.title === selectedProjectTitle) ); }; for (const project of projects) { if (!hostsSelectedRepository(project)) { continue; } if (seen.has(project.environmentId)) { continue; } const environment = savedConnectionsById[project.environmentId]; if (!environment) { continue; } seen.add(project.environmentId); result.push({ environmentId: project.environmentId, environmentLabel: environment.environmentLabel, }); } return result; }, [ projects, savedConnectionsById, selectedRepositoryKey, selectedWorkspaceBasename, selectedProjectTitle, ]); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, ); // While a queued pending task is being edited its draft lives under a key // scoped to the queued message, so per-project new-task drafts stay intact. const selectedProjectDraftKey = editingPendingTask ? pendingTaskDraftKey(editingPendingTask.messageId) : selectedProject ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` : null; const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; // Default mode until the user picks one explicitly — same resolution web // uses for new draft threads: per-project setting, then the repo's // checked-in t3.json, then the server's configured default. const t3ProjectFileQuery = useEnvironmentQuery( selectedProject !== null && selectedProject.workspaceRoot !== "" ? projectEnvironment.readFile({ environmentId: selectedProject.environmentId, input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, }) : null, ); const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; const t3ProjectFileDefaultMode = useMemo(() => { if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; }, [t3ProjectFileData]); const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ projectSetting: selectedProject?.defaultThreadEnvMode, projectFile: t3ProjectFileDefaultMode, globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", }); // While unsettled the resolved default is provisional. Nothing may write // it into the draft during that window (the auto-branch effect does), or // the frozen interim value beats the t3.json default once it loads. const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ explicitMode: selectedProjectDraft.workspaceSelection?.mode, projectSetting: selectedProject?.defaultThreadEnvMode, projectFilePending: t3ProjectFileQuery.isPending, }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; // Keep the user's explicit choice separate from the resolved display value: // only the explicit flag is ever written back to the draft, so the resolved // value keeps tracking the server setting when the config loads late. const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = draftStartFromOrigin ?? selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = planModeEnabled ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) : DEFAULT_PROVIDER_INTERACTION_MODE; // Stored selections only count while their provider is usable on the // server; otherwise the server's default model wins instead of silently // targeting a disabled provider. The draft selection is an explicit pick // and passes through as-is; the project default (last used, possibly from // desktop) is implicit and additionally never resolves to a legacy model. const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, selectedProjectDraft.modelSelection ?? null, ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ); const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, draftModelSelection ?? projectDefaultModelSelection, ), [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], ); const selectedModel = draftModelSelection ?? projectDefaultModelSelection ?? modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; const selectedModelOption = modelOptions.find( (option) => selectedModel && option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; const selectedProviderSkills = useMemo( () => selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, )?.skills ?? [], [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); const setSelectedModelKey = useCallback( // Options ride along in the same write: a follow-up setSelectedModelOptions // call would rebuild the selection from the stale pre-switch model. (key: string | null, options?: ReadonlyArray<ProviderOptionSelection>) => { if (!key || !selectedProjectDraftKey) { return; } const option = modelOptions.find((candidate) => candidate.key === key); if (!option) { return; } updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: options ? { ...option.selection, options } : option.selection, }); }, [modelOptions, selectedProjectDraftKey], ); const setSelectedModelOptions = useCallback( (options: ReadonlyArray<ProviderOptionSelection> | undefined) => { if (!selectedModel || !selectedProjectDraftKey) { return; } const nextSelection: ModelSelection = options ? { ...selectedModel, options } : { instanceId: selectedModel.instanceId, model: selectedModel.model, }; updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: nextSelection, }); }, [selectedModel, selectedProjectDraftKey], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const setPrompt = useCallback( (value: string) => { if (!selectedProjectDraftKey) { return; } setComposerDraftText(selectedProjectDraftKey, value); }, [selectedProjectDraftKey], ); const replaceAttachments = useCallback( (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {Lines 1–500 of 1125. Load more
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EnvironmentId, ModelSelection, ProjectReadFileResult, ProviderInteractionMode, ProviderOptionSelection, RuntimeMode, ServerProviderSkill, } from "@t3tools/contracts"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, MessageId, T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode, } from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; import type { TurnCommandMetadata } from "../../lib/commandMetadata"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, groupByProvider, resolveDefaultableModelSelection, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, clearComposerDraft, copyComposerDraftContentIfEmpty, getComposerDraftSnapshot, isComposerDraftEmpty, removeComposerDraftAttachment, replaceComposerDraftAttachments, setComposerDraftText, updateComposerDraftSettings, useComposerDraft, } from "../../state/use-composer-drafts"; import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; import { flattenQueuedThreadMessages, threadOutboxManager, updateThreadOutboxMessage, type QueuedThreadMessage, } from "../../state/thread-outbox"; import { holdEditingQueuedMessage, releaseEditingQueuedMessage, useThreadOutboxMessages, } from "../../state/use-thread-outbox"; import { setPendingConnectionError, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; import { buildHomeProjectScopes, sortHomeProjectScopes, type HomeProjectScope, } from "../home/homeThreadList"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; type WorkspaceMode = "local" | "worktree"; const BRANCH_SEARCH_DEBOUNCE_MS = 150; function pendingTaskDraftKey(messageId: string): string { return `pending-task:${messageId}`; } // The message id owned by the currently active editing session, tracked // across provider instances. An in-flight flush from a dismissed session // consults it so it never drops the draft or releases the drain lock out from // under a newer session editing the same task. let activeEditingMessageId: string | null = null; function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { const message = flattenQueuedThreadMessages( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).find((candidate) => candidate.messageId === messageId); return message?.creation !== undefined ? message : null; } export function branchBadgeLabel(input: { readonly branch: VcsRef; readonly project: EnvironmentProject | null; }): string | null { if (input.branch.current) { return "current"; } if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { return "worktree"; } if (input.branch.isDefault) { return "default"; } return null; } type NewTaskFlowContextValue = { readonly projectScopes: ReadonlyArray<HomeProjectScope>; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly selectedModelKey: string | null; readonly workspaceMode: WorkspaceMode; readonly selectedBranchName: string | null; readonly selectedWorktreePath: string | null; readonly startFromOrigin: boolean; readonly draftKey: string | null; readonly editingPendingTask: QueuedThreadMessage | null; readonly prompt: string; readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; readonly submitting: boolean; readonly branchQuery: string; readonly branchesLoading: boolean; readonly branchesError: string | null; readonly branchesFetchingNextPage: boolean; readonly hasMoreBranches: boolean; readonly availableBranches: ReadonlyArray<VcsRef>; readonly currentCheckoutBranchName: string | null; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly planModeEnabled: boolean; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; }>; readonly selectedProject: EnvironmentProject | null; readonly modelOptions: ReadonlyArray<ModelOption>; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>; readonly providerGroups: ReadonlyArray<ProviderGroup>; readonly filteredBranches: ReadonlyArray<VcsRef>; readonly reset: () => void; readonly setProject: (project: EnvironmentProject) => void; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly setSelectedModelKey: ( key: string | null, options?: ReadonlyArray<ProviderOptionSelection>, ) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; readonly selectBranch: (branch: VcsRef) => void; readonly setStartFromOrigin: (value: boolean) => void; readonly beginEditingPendingTask: (messageId: string) => boolean; readonly finishEditingPendingTask: () => void; readonly cancelEditingPendingTask: () => void; readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; readonly setPrompt: (value: string) => void; readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; readonly removeAttachment: (imageId: string) => void; readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; readonly setBranchQuery: (value: string) => void; readonly loadBranches: () => void; readonly loadMoreBranches: () => void; readonly setRuntimeMode: (value: RuntimeMode) => void; readonly setInteractionMode: (value: ProviderInteractionMode) => void; readonly setSelectedModelOptions: ( value: ReadonlyArray<ProviderOptionSelection> | undefined, ) => void; readonly setExpandedProvider: (value: string | null) => void; }; const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null); export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projects = useProjects(); const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); const projectScopes = useMemo( () => sortHomeProjectScopes({ scopes: buildHomeProjectScopes({ projects, environmentId: null, projectGroupingMode: groupingSettings.sidebarProjectGroupingMode, }), threads, pendingTasks: [], projectSortOrder: "updated_at", }), [groupingSettings.sidebarProjectGroupingMode, projects, threads], ); const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState<EnvironmentId | null>( null, ); const selectedEnvironmentId = selectedEnvironmentIdOverride !== null && projects.some((project) => project.environmentId === selectedEnvironmentIdOverride) ? selectedEnvironmentIdOverride : (projects[0]?.environmentId ?? null); const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const [expandedProvider, setExpandedProvider] = useState<string | null>(null); const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null); const pendingLocalBranchSyncDraftKeysRef = useRef(new Set<string>()); // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act // on a task whose editing session already ended this render. const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); setSubmitting(false); setBranchQuery(""); setExpandedProvider(null); pendingLocalBranchSyncDraftKeysRef.current.clear(); const editing = editingPendingTaskRef.current; editingPendingTaskRef.current = null; setEditingPendingTask(null); if (editing) { if (activeEditingMessageId === editing.messageId) { activeEditingMessageId = null; } releaseEditingQueuedMessage(editing.messageId); } }, []); const projectsForEnvironment = useMemo( () => pipe( projects, Arr.filter((project) => project.environmentId === selectedEnvironmentId), ), [projects, selectedEnvironmentId], ); // Stand-in for the edited task's project while its shell is not loaded // (environment offline / still synchronizing), built from the metadata // snapshotted at enqueue time. const editingPendingProject = useMemo<EnvironmentProject | null>(() => { const creation = editingPendingTask?.creation; if (!editingPendingTask || !creation) { return null; } return { environmentId: editingPendingTask.environmentId, id: creation.projectId, title: creation.projectTitle ?? "Unknown project", // Deliberately empty when the snapshot has no cwd — downstream consumers // (branch queries, worktree bootstrap) must skip it, not receive a // fabricated path. workspaceRoot: creation.projectCwd ?? "", repositoryIdentity: null, defaultModelSelection: editingPendingTask.modelSelection ?? null, scripts: [], createdAt: editingPendingTask.createdAt, updatedAt: editingPendingTask.createdAt, }; }, [editingPendingTask]); const selectedProject = projectsForEnvironment.find( (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, ) ?? // While editing a queued task whose project shell is absent, keep the task // pinned to its own project — falling through to an arbitrary first // project would silently retarget it (and its reused turn identifiers). (editingPendingProject !== null && selectedProjectKey === scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) ? editingPendingProject : (projectsForEnvironment[0] ?? null)); // Only offer machines that actually host the currently selected repository, so // switching computers moves the same repo across machines instead of jumping to // whatever unrelated project happens to be first on the other machine. Repository // identity is the primary signal; projects that haven't reported one yet (still // indexing) fall back to workspace basename / title so a valid host isn't hidden. const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; // `|| null` (not `??`): a pending-task placeholder project can have an empty // workspaceRoot, and an "" basename would reject every real host below. const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; const selectedProjectTitle = selectedProject?.title ?? null; const environments = useMemo(() => { const seen = new Set<EnvironmentId>(); const result: Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; }> = []; const hostsSelectedRepository = (project: EnvironmentProject) => { if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { return true; } const projectKey = project.repositoryIdentity?.canonicalKey ?? null; if (selectedRepositoryKey !== null && projectKey !== null) { return projectKey === selectedRepositoryKey; } return ( project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || (selectedProjectTitle !== null && project.title === selectedProjectTitle) ); }; for (const project of projects) { if (!hostsSelectedRepository(project)) { continue; } if (seen.has(project.environmentId)) { continue; } const environment = savedConnectionsById[project.environmentId]; if (!environment) { continue; } seen.add(project.environmentId); result.push({ environmentId: project.environmentId, environmentLabel: environment.environmentLabel, }); } return result; }, [ projects, savedConnectionsById, selectedRepositoryKey, selectedWorkspaceBasename, selectedProjectTitle, ]); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, ); // While a queued pending task is being edited its draft lives under a key // scoped to the queued message, so per-project new-task drafts stay intact. const selectedProjectDraftKey = editingPendingTask ? pendingTaskDraftKey(editingPendingTask.messageId) : selectedProject ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` : null; const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; // Default mode until the user picks one explicitly — same resolution web // uses for new draft threads: per-project setting, then the repo's // checked-in t3.json, then the server's configured default. const t3ProjectFileQuery = useEnvironmentQuery( selectedProject !== null && selectedProject.workspaceRoot !== "" ? projectEnvironment.readFile({ environmentId: selectedProject.environmentId, input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, }) : null, ); const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; const t3ProjectFileDefaultMode = useMemo(() => { if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; }, [t3ProjectFileData]); const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ projectSetting: selectedProject?.defaultThreadEnvMode, projectFile: t3ProjectFileDefaultMode, globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", }); // While unsettled the resolved default is provisional. Nothing may write // it into the draft during that window (the auto-branch effect does), or // the frozen interim value beats the t3.json default once it loads. const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ explicitMode: selectedProjectDraft.workspaceSelection?.mode, projectSetting: selectedProject?.defaultThreadEnvMode, projectFilePending: t3ProjectFileQuery.isPending, }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; // Keep the user's explicit choice separate from the resolved display value: // only the explicit flag is ever written back to the draft, so the resolved // value keeps tracking the server setting when the config loads late. const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = draftStartFromOrigin ?? selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = planModeEnabled ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) : DEFAULT_PROVIDER_INTERACTION_MODE; // Stored selections only count while their provider is usable on the // server; otherwise the server's default model wins instead of silently // targeting a disabled provider. The draft selection is an explicit pick // and passes through as-is; the project default (last used, possibly from // desktop) is implicit and additionally never resolves to a legacy model. const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, selectedProjectDraft.modelSelection ?? null, ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ); const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, draftModelSelection ?? projectDefaultModelSelection, ), [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], ); const selectedModel = draftModelSelection ?? projectDefaultModelSelection ?? modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; const selectedModelOption = modelOptions.find( (option) => selectedModel && option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; const selectedProviderSkills = useMemo( () => selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, )?.skills ?? [], [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); const setSelectedModelKey = useCallback( // Options ride along in the same write: a follow-up setSelectedModelOptions // call would rebuild the selection from the stale pre-switch model. (key: string | null, options?: ReadonlyArray<ProviderOptionSelection>) => { if (!key || !selectedProjectDraftKey) { return; } const option = modelOptions.find((candidate) => candidate.key === key); if (!option) { return; } updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: options ? { ...option.selection, options } : option.selection, }); }, [modelOptions, selectedProjectDraftKey], ); const setSelectedModelOptions = useCallback( (options: ReadonlyArray<ProviderOptionSelection> | undefined) => { if (!selectedModel || !selectedProjectDraftKey) { return; } const nextSelection: ModelSelection = options ? { ...selectedModel, options } : { instanceId: selectedModel.instanceId, model: selectedModel.model, }; updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: nextSelection, }); }, [selectedModel, selectedProjectDraftKey], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const setPrompt = useCallback( (value: string) => { if (!selectedProjectDraftKey) { return; } setComposerDraftText(selectedProjectDraftKey, value); }, [selectedProjectDraftKey], ); const replaceAttachments = useCallback( (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {