97140168d8
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
import type { ExecutorProfile } from "@/lib/types/http";
|
|
import type {
|
|
DialogComputedArgs,
|
|
DialogComputedValues,
|
|
DialogFormState,
|
|
AgentCompatState,
|
|
} 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,
|
|
resolveEffectiveTaskCreateWorkflowId,
|
|
} from "@/components/task-create-dialog-defaults";
|
|
import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs";
|
|
import { useFeature } from "@/hooks/domains/features/use-feature";
|
|
import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat";
|
|
import type { RemoteAuthSpec } from "@/lib/api/domains/settings-api";
|
|
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
|
|
import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types";
|
|
import { getMultiRepoExecutorDisabledReason } from "@/components/task-create-dialog-multi-repo-guard";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
/**
|
|
* 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 t("task:worktreeExecutorRequiresRepository");
|
|
}
|
|
|
|
/**
|
|
* Combines the two executor-disable rules into a single resolver:
|
|
* - no-repository mode → disable worktree (it needs a repo)
|
|
* - multi-repo selection → disable runtimes without sibling-repository launch support
|
|
* - 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 (profile) => getMultiRepoExecutorDisabledReason(profile.executor_type);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveDialogWorkflowSelection({
|
|
workspaceId,
|
|
workflowId,
|
|
selectedWorkflowId,
|
|
lockedWorkflow,
|
|
lastUsedWorkflowIdsByWorkspace,
|
|
userSettingsLoaded,
|
|
workflows,
|
|
}: Pick<
|
|
DialogComputedArgs,
|
|
| "workspaceId"
|
|
| "workflowId"
|
|
| "lockedWorkflow"
|
|
| "lastUsedWorkflowIdsByWorkspace"
|
|
| "userSettingsLoaded"
|
|
| "workflows"
|
|
> & { selectedWorkflowId: string | null }) {
|
|
const effectiveWorkflowId = resolveEffectiveTaskCreateWorkflowId({
|
|
workspaceId,
|
|
lockedWorkflowId: lockedWorkflow ? workflowId : null,
|
|
manualWorkflowId: selectedWorkflowId,
|
|
lastUsedWorkflowId:
|
|
userSettingsLoaded === false
|
|
? null
|
|
: (lastUsedWorkflowIdsByWorkspace[workspaceId ?? ""] ?? null),
|
|
contextWorkflowId: lockedWorkflow ? null : workflowId,
|
|
workflows,
|
|
});
|
|
const workflowAgentProfileId = effectiveWorkflowId
|
|
? (workflows.find((workflow) => workflow.id === effectiveWorkflowId)?.agent_profile_id ?? "")
|
|
: "";
|
|
return { effectiveWorkflowId, workflowAgentProfileId };
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/** Filter raw store profiles before executor compatibility or autopick runs. */
|
|
export function filterCompatibleAgentProfiles(
|
|
agentProfiles: AgentProfileOption[],
|
|
selectedExecutorProfile: ExecutorProfile | null,
|
|
authLoaded: boolean,
|
|
authSpecs: RemoteAuthSpec[],
|
|
dynamicRoutingEnabled = true,
|
|
): AgentProfileOption[] {
|
|
const selectable = agentProfiles.filter((profile) =>
|
|
isSelectableAgentProfile(profile, dynamicRoutingEnabled),
|
|
);
|
|
if (!selectedExecutorProfile || !authLoaded) return selectable;
|
|
return selectable.filter((profile) =>
|
|
isAgentConfiguredOnExecutor(profile, selectedExecutorProfile, authSpecs),
|
|
);
|
|
}
|
|
|
|
export type AgentCompatInput = {
|
|
selectedExecutorProfile: ExecutorProfile | null;
|
|
compatibleAgentProfiles: AgentProfileOption[];
|
|
/** Effective selection: the user's pick or the workflow override. */
|
|
selectedAgentProfileId: string;
|
|
/** The selected profile's store row, or null when the id is unknown. */
|
|
selectedAgentProfile: AgentProfileOption | null;
|
|
workflowAgentLocked: boolean;
|
|
dynamicRoutingEnabled: boolean;
|
|
};
|
|
|
|
/**
|
|
* Compatibility of the effective agent selection with the selected
|
|
* executor profile. Pure so the dialog's empty state, note, and footer reason
|
|
* can be tested without rendering.
|
|
*
|
|
* A workflow-locked profile that fails only the credential check keeps the
|
|
* `selected-incompatible` state even when nothing else is compatible: the
|
|
* user cannot change the agent, so the note has to name the workflow and the
|
|
* agent rather than the generic empty state. A locked profile that is not
|
|
* selectable at all (disabled) is not a credential problem and stays on the
|
|
* empty state when locked. An unlocked unavailable selection is distinct while
|
|
* an enabled alternative exists, so the dialog does not claim that no profile
|
|
* is compatible during automatic replacement.
|
|
*/
|
|
export function computeAgentCompatState(input: AgentCompatInput): AgentCompatState {
|
|
const { selectedExecutorProfile, compatibleAgentProfiles, selectedAgentProfileId } = input;
|
|
if (!selectedExecutorProfile) return "compatible";
|
|
if (!selectedAgentProfileId) {
|
|
return compatibleAgentProfiles.length === 0 ? "none-compatible" : "compatible";
|
|
}
|
|
if (compatibleAgentProfiles.some((ap) => ap.id === selectedAgentProfileId)) return "compatible";
|
|
const selectable = input.selectedAgentProfile
|
|
? isSelectableAgentProfile(input.selectedAgentProfile, input.dynamicRoutingEnabled)
|
|
: true;
|
|
if (!selectable) {
|
|
if (input.workflowAgentLocked || compatibleAgentProfiles.length === 0) {
|
|
return "none-compatible";
|
|
}
|
|
return "selected-unavailable";
|
|
}
|
|
if (input.workflowAgentLocked) return "selected-incompatible";
|
|
return compatibleAgentProfiles.length === 0 ? "none-compatible" : "selected-incompatible";
|
|
}
|
|
|
|
type AgentSelectionInput = {
|
|
/** Effective selection: the user's pick or the workflow override. */
|
|
agentProfileId: string;
|
|
agentProfiles: DialogComputedArgs["agentProfiles"];
|
|
workflowAgentLocked: boolean;
|
|
};
|
|
|
|
function useExecutorProfileCompat(
|
|
allExecutorProfiles: ExecutorProfile[],
|
|
selectedProfileId: string,
|
|
selection: AgentSelectionInput,
|
|
disabledReasonFor?: (profile: ExecutorProfile) => string | null,
|
|
) {
|
|
const { agentProfileId: selectedAgentProfileId, agentProfiles, workflowAgentLocked } = selection;
|
|
const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, {
|
|
disabledReasonFor,
|
|
});
|
|
const selectedExecutorProfile = useMemo(
|
|
() => allExecutorProfiles.find((p) => p.id === selectedProfileId) ?? null,
|
|
[allExecutorProfiles, selectedProfileId],
|
|
);
|
|
const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs();
|
|
const dynamicRoutingEnabled = useFeature("dynamicAgentRouting");
|
|
const compatibleAgentProfiles = useMemo(() => {
|
|
return filterCompatibleAgentProfiles(
|
|
agentProfiles,
|
|
selectedExecutorProfile,
|
|
authLoaded,
|
|
authSpecs,
|
|
dynamicRoutingEnabled,
|
|
);
|
|
}, [agentProfiles, selectedExecutorProfile, authSpecs, authLoaded, dynamicRoutingEnabled]);
|
|
// The agent column renders per state and the submit gate reads the derived
|
|
// boolean, so both "no compatible agent at all" and "selected agent is not
|
|
// compatible" block submission while only the former hides the selector.
|
|
const selectedAgentProfile = useMemo(
|
|
() => agentProfiles.find((ap) => ap.id === selectedAgentProfileId) ?? null,
|
|
[agentProfiles, selectedAgentProfileId],
|
|
);
|
|
const agentCompatState = useMemo(
|
|
() =>
|
|
computeAgentCompatState({
|
|
selectedExecutorProfile,
|
|
compatibleAgentProfiles,
|
|
selectedAgentProfileId,
|
|
selectedAgentProfile,
|
|
workflowAgentLocked,
|
|
dynamicRoutingEnabled,
|
|
}),
|
|
[
|
|
selectedExecutorProfile,
|
|
compatibleAgentProfiles,
|
|
selectedAgentProfileId,
|
|
selectedAgentProfile,
|
|
workflowAgentLocked,
|
|
dynamicRoutingEnabled,
|
|
],
|
|
);
|
|
const noCompatibleAgent = agentCompatState !== "compatible";
|
|
const selectedAgentProfileName = selectedAgentProfile?.label ?? null;
|
|
return {
|
|
selectedExecutorProfile,
|
|
compatibleAgentProfiles,
|
|
authLoaded,
|
|
executorProfileOptions,
|
|
noCompatibleAgent,
|
|
agentCompatState,
|
|
selectedAgentProfileName,
|
|
};
|
|
}
|
|
|
|
export function useDialogComputed({
|
|
fs,
|
|
open,
|
|
workspaceId,
|
|
workflowId,
|
|
defaultStepId,
|
|
settingsData,
|
|
agentProfiles,
|
|
workspaces,
|
|
executors,
|
|
repositories,
|
|
workflows,
|
|
snapshots,
|
|
lockedWorkflow,
|
|
lastUsedWorkflowIdsByWorkspace,
|
|
userSettingsLoaded,
|
|
agentProfileRecentUseContext,
|
|
}: DialogComputedArgs): DialogComputedValues {
|
|
const { effectiveWorkflowId, workflowAgentProfileId } = resolveDialogWorkflowSelection({
|
|
selectedWorkflowId: fs.selectedWorkflowId,
|
|
workspaceId,
|
|
workflowId,
|
|
lockedWorkflow,
|
|
lastUsedWorkflowIdsByWorkspace,
|
|
userSettingsLoaded,
|
|
workflows,
|
|
});
|
|
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<ExecutorProfile[]>(() => {
|
|
return executors.flatMap((executor) =>
|
|
(executor.profiles ?? []).map((p) => ({
|
|
...p,
|
|
executor_type: p.executor_type ?? executor.type,
|
|
executor_name: p.executor_name ?? executor.name,
|
|
})),
|
|
);
|
|
}, [executors]);
|
|
// Gate only runtimes whose launch path cannot project sibling repositories.
|
|
// 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
|
|
// applies the same capability check.
|
|
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,
|
|
{ agentProfileId: effectiveAgentProfileId, agentProfiles, workflowAgentLocked },
|
|
pickExecutorDisabledReason(fs.noRepository, isMultiRepoSelection),
|
|
);
|
|
const agentProfileOptions = useAgentProfileOptions(
|
|
exec.compatibleAgentProfiles,
|
|
agentProfileRecentUseContext,
|
|
);
|
|
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,
|
|
agentCompatState: exec.agentCompatState,
|
|
selectedAgentProfileName: exec.selectedAgentProfileName,
|
|
};
|
|
}
|