"use client"; import { useMemo } from "react"; import type { ExecutorProfile } from "@/lib/types/http"; import type { DialogComputedArgs, DialogComputedValues, DialogFormState, } from "@/components/task-create-dialog-types"; import { useRepositoryOptions, useBranchOptions, useAgentProfileOptions, useExecutorHint, useExecutorProfileOptions, useIsLocalExecutor, } from "@/components/task-create-dialog-options"; import { computePassthroughProfile } from "@/components/task-create-dialog-helpers"; import { computeDialogDefaultStepId, computeSingleWorkflowFallbackId, } from "@/components/task-create-dialog-defaults"; import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat"; /** * Multi-repo tasks currently only run on the git-worktree executor — * Docker/Sprites/etc. don't yet know how to provision N sibling repos under * one task root. Returning a non-null reason marks the option as disabled in * the executor selector and surfaces this string as a tooltip. The dialog * only applies this when 2+ repos are selected (see isMultiRepoSelection). */ function nonWorktreeDisabledReason(profile: ExecutorProfile): string | null { if ((profile.executor_type ?? "") === "worktree") return null; return "Multi-repo tasks only support the git-worktree executor."; } /** * Worktree executor needs a repository to create the worktree from. Disable * it when the task is in no-repository mode so the picker doesn't offer an * unworkable choice (the backend would silently fall back to local). */ function worktreeDisabledReason(profile: ExecutorProfile): string | null { if ((profile.executor_type ?? "") !== "worktree") return null; return "Worktree executor requires a repository."; } /** * Combines the two executor-disable rules into a single resolver: * - no-repository mode → disable worktree (it needs a repo) * - multi-repo selection → disable everything except worktree * - otherwise → no disabling * The two never co-occur (no-repository implies zero repos, so multi-repo * cannot be true at the same time), so a simple priority order is enough. */ function pickExecutorDisabledReason( noRepository: boolean, isMultiRepoSelection: boolean, ): ((profile: ExecutorProfile) => string | null) | undefined { if (noRepository) return worktreeDisabledReason; if (isMultiRepoSelection) return nonWorktreeDisabledReason; return undefined; } /** * The form has a repo selection when: * - noRepository is on (intentionally repo-less), OR * - useRemote is on and at least one remote-URL row has a non-empty URL, OR * - useRemote is off and any workspace/local row has a repo set. * * The mode (useRemote) gates which list is consulted — rows from the * inactive mode are hidden but not cleared (toggle-back is non-destructive), * and they must not influence the submit gate. * * Exported for unit-testing the repo-selection gate independently of the * full `useDialogComputed` React hook. */ export function computeHasRepositorySelection(fs: DialogFormState): boolean { if (fs.noRepository) return true; if (fs.useRemote) return fs.remoteRepos.some((r) => r.url.trim() !== ""); return fs.repositories.some((r) => r.repositoryId || r.localPath); } /** * Number of repositories the task will operate on. Mode-aware: when Remote * mode is on we count non-empty URL rows, otherwise we count workspace/local * rows with a repo set. Rows from the inactive mode are hidden in the UI and * must not influence the multi-repo executor gate. * * Exported for unit-testing the executor gate independently of the React hook. */ export function computeSelectedRepoCount(fs: DialogFormState): number { if (fs.noRepository) return 0; if (fs.useRemote) return fs.remoteRepos.filter((r) => r.url.trim() !== "").length; return fs.repositories.filter((r) => r.repositoryId || r.localPath).length; } function useExecutorProfileCompat( allExecutorProfiles: ExecutorProfile[], selectedProfileId: string, selectedAgentProfileId: string, agentProfiles: DialogComputedArgs["agentProfiles"], disabledReasonFor?: (profile: ExecutorProfile) => string | null, ) { const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, { disabledReasonFor, }); const selectedExecutorProfile = useMemo( () => allExecutorProfiles.find((p) => p.id === selectedProfileId) ?? null, [allExecutorProfiles, selectedProfileId], ); const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs(); const compatibleAgentProfiles = useMemo(() => { if (!selectedExecutorProfile || !authLoaded) return agentProfiles; return agentProfiles.filter((ap) => isAgentConfiguredOnExecutor(ap, selectedExecutorProfile, authSpecs), ); }, [agentProfiles, selectedExecutorProfile, authSpecs, authLoaded]); // `noCompatibleAgent` gates the submit button. It must catch BOTH cases: // 1. The selected executor has no compatible agents at all. // 2. The user picked an agent that isn't compatible with the executor // (e.g. switched executor after the agent was chosen). // Previously this only checked case 1, so case 2 silently let the user // submit with a known-incompatible combination. const noCompatibleAgent = useMemo(() => { if (!selectedExecutorProfile) return false; if (compatibleAgentProfiles.length === 0) return true; if (!selectedAgentProfileId) return false; return !compatibleAgentProfiles.some((ap) => ap.id === selectedAgentProfileId); }, [selectedExecutorProfile, compatibleAgentProfiles, selectedAgentProfileId]); return { selectedExecutorProfile, compatibleAgentProfiles, authLoaded, executorProfileOptions, noCompatibleAgent, }; } export function useDialogComputed({ fs, open, workspaceId, workflowId, defaultStepId, settingsData, agentProfiles, workspaces, executors, repositories, workflows, snapshots, }: DialogComputedArgs): DialogComputedValues { const singleWorkflowId = computeSingleWorkflowFallbackId( fs.selectedWorkflowId, workflowId, workflows, ); const effectiveWorkflowId = fs.selectedWorkflowId ?? workflowId ?? singleWorkflowId; // Compute workflow agent lock directly from data — avoids effect timing issues. const workflowAgentProfileId = (() => { const wfId = effectiveWorkflowId; if (!wfId) return ""; const wf = workflows.find((w) => w.id === wfId); return wf?.agent_profile_id ?? ""; })(); const workflowAgentLocked = Boolean(workflowAgentProfileId); // fs.agentProfileId lags behind the workflow override on dialog re-open // (effect deps don't change), so fall back to the synchronous value. const effectiveAgentProfileId = fs.agentProfileId || workflowAgentProfileId; const isPassthroughProfile = useMemo( () => computePassthroughProfile(effectiveAgentProfileId, agentProfiles), [effectiveAgentProfileId, agentProfiles], ); const effectiveDefaultStepId = computeDialogDefaultStepId({ selectedWorkflowId: fs.selectedWorkflowId, workflowId, fetchedSteps: fs.fetchedSteps, defaultStepId, effectiveWorkflowId, snapshots, }); const workspaceDefaults = workspaceId ? workspaces.find((ws) => ws.id === workspaceId) : null; const firstRemoteUrl = fs.remoteRepos[0]?.url.trim() ?? ""; const hasRepositorySelection = computeHasRepositorySelection(fs); // Branch options are only used by the URL-mode flow now (the chip's branch // pill loads branches per-repo). Keep the computed value but always feed it // the URL branches when in URL mode — sourced from the per-URL hook cache. const branchOptions = useBranchOptions( fs.useRemote ? fs.branchesByUrl.branches(firstRemoteUrl) : [], ); const allExecutorProfiles = useMemo(() => { return executors.flatMap((executor) => (executor.profiles ?? []).map((p) => ({ ...p, executor_type: p.executor_type ?? executor.type, executor_name: p.executor_name ?? executor.name, })), ); }, [executors]); // Multi-repo tasks only run on the git-worktree executor today — Docker / // Sprites / standalone don't yet know how to provision N sibling repos. Gate // non-worktree options only when 2+ repos are selected; single-repo tasks // keep the full executor catalogue. Count BOTH workspace/local rows AND // remote-URL rows (each non-empty row is a distinct repo the task will // operate on) so a 2-row Remote selection trips the same restriction. const selectedRepoCount = computeSelectedRepoCount(fs); const isMultiRepoSelection = selectedRepoCount > 1; // Use the effective agent ID (form value OR the workflow-locked override) // so the compatibility gate catches the override case too — passing the // raw fs.agentProfileId would let workflow-locked sessions slip past with // an empty selection. const exec = useExecutorProfileCompat( allExecutorProfiles, fs.executorProfileId, effectiveAgentProfileId, agentProfiles, pickExecutorDisabledReason(fs.noRepository, isMultiRepoSelection), ); const agentProfileOptions = useAgentProfileOptions(exec.compatibleAgentProfiles); const executorHint = useExecutorHint(executors, fs.executorId, selectedRepoCount); const isLocalExecutor = useIsLocalExecutor(executors, fs.executorId); const { headerRepositoryOptions } = useRepositoryOptions(repositories, fs.discoveredRepositories); // Treat the dialog as still loading agents until BOTH the agent profiles // (DB rows) AND the host-utility capability probe have resolved. The // backend reconciler renames profiles ("Claude" → "Claude Sonnet 4.6") only // after the probe lands, so showing the selector before then surfaces stale // labels missing the model badge. const agentProfilesLoading = open && (!settingsData.agentsLoaded || !settingsData.capabilitiesLoaded); const executorsLoading = open && !settingsData.executorsLoaded; return { isPassthroughProfile, effectiveWorkflowId, effectiveDefaultStepId, workspaceDefaults, hasRepositorySelection, branchOptions, agentProfileOptions, executorProfileOptions: exec.executorProfileOptions, executorHint, isLocalExecutor, headerRepositoryOptions, agentProfilesLoading, executorsLoading, workflowAgentLocked, workflowAgentProfileId, effectiveAgentProfileId, selectedExecutorProfileName: exec.selectedExecutorProfile?.name ?? null, compatibleAgentProfiles: exec.compatibleAgentProfiles, authLoaded: exec.authLoaded, noCompatibleAgent: exec.noCompatibleAgent, }; }