Files
PaperClipAI/ui/src/components/ConfigureBuiltInAgentModal.tsx
T
Devin FoleyandPaperclip fd4b197f83 fix(ui): hide runner selection during agent setup (#19517)
## Thinking Path

> - Paperclip lets people manage AI agents for work.
> - Agent setup connects a harness, model, account, and computer.
> - The system already selects a qualified runner for a new agent.
> - Setup still asks users to select that internal runner.
> - The runner dropdown can be the only field in Advanced.
> - This PR removes the dropdown and hides an empty Advanced section.
> - Users keep their saved execution settings and see fewer setup controls.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Agent creation, onboarding, built-in agent setup, company import, and agent configuration.

**Subsystem affected**

The UI, setup error messages, tests, and Storybook fixtures.

**Current behavior**

Users can select a runner from a dropdown. Some setup screens place only that dropdown in Advanced. Errors can direct users to that control.

**Proposed behavior**

Remove runner dropdowns from setup and configuration. Use the existing automatic selection for new agents. Retain saved runner settings. Show Advanced only when other fields remain.

**Reason and benefit**

Users can configure agents without an extra internal execution choice. Setup also avoids an empty Advanced section.

**Breaking changes**

Users can no longer select a runner through these UI dropdowns. The API and CLI retain their runner options. Existing agents keep their saved execution settings.

**Additional context**

Related: [Default new agents to the qualified runner for their harness](https://github.com/paperclipai/paperclip/pull/15422). This PR changes the existing setup UI. It adds no harness or runtime default. The master merge retains the custom gateway placement from [#19510](https://github.com/paperclipai/paperclip/pull/19510).

## What Changed

- Remove the shared runner dropdown and its setup/configuration callers.
- Hide Advanced when it has no remaining fields. Keep command and argument settings where supported. Preserve custom gateway setup under Advanced on the Claude and OpenAI API key paths.
- Keep setup failures visible and keep the retry action outside the removed control.
- Update errors and documentation that referred to the removed dropdown.
- Update component tests and Storybook fixtures for the new UI states.
- Make Legacy first-task evals resume an API-configured setup record. Record that path separately from fresh automatic onboarding.

## Verification

- Latest master integration (`187c0f6d6e5`): 276 onboarding/provider-connection tests passed across six files. UI typecheck and build passed. Token gates, query-error rendering checks, and the Storybook catalog check passed. The conflict resolution retains master's bounded connection tests and cancellation while removing the obsolete runner instruction. The first test command used the wrong working directory and failed before collecting tests; the corrected invocation from `ui/` passed.
- Earlier branch verification passed: full workspace build and typecheck, 1,037 focused UI/service tests, 49 heartbeat provider contract tests, eval typecheck, and 30 map/catalog tests.
- The full local `pnpm test:run` run passed all six chat integration shards (1,931 tests). It was stopped for the master merge before the complete suite finished. The [complete CI run](https://github.com/paperclipai/paperclip/actions/runs/42264662642) passed on the previous head `72e1699`.
- Current head `a097c8369d079c839e78661a2206cfa62c5210e0`: [full CI](https://github.com/paperclipai/paperclip/actions/runs/42273168268) passed. PR checks total 55 passed and 3 skipped, with none failed or pending. Greptile reviewed this head at 5/5 with no actionable findings or unresolved review threads.
- Live browser/provider evals have not run. The Legacy eval change has no live qualification claim.

## Risks

- Removing the dropdown changes the setup UI. Existing API and CLI runner choices remain available.
- Conditional Advanced sections must retain valid command and argument fields.
- Legacy first-task evals now prove setup resumption. They do not prove the fresh-agent automatic default.
- No schema change or data migration is included.

## Model Used

OpenAI `gpt-6.1-sol`, through Codex in T3 Code. Reasoning effort: `xhigh`. The model used file editing, shell commands, and test tools. The harness does not expose the context window size.

## Checklist

- [x] I have included a thinking path that traces from project context to this change
- [x] I have specified the model used (with version and capability details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before requesting merge

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-11-06 10:58:42 -08:00

260 lines
9.9 KiB
TypeScript

import { describeError } from "@/api/errors";
import { useMemo, useState } from "react";
import { agentHarnessType } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/agent-config-primitives";
import { AdapterTypeDropdown, ModelDropdown } from "@/components/AgentConfigForm";
import { InlineBanner } from "@/components/InlineBanner";
import { listAdapterOptions } from "@/adapters/metadata";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { ApiError } from "@/api/client";
import {
builtInAgentsApi,
type BuiltInAgentState,
} from "@/api/builtInAgents";
import { adapterCuratesModelOrder } from "../lib/model-utils";
/** Adapters whose config completeness is keyed on a non-empty `model`. */
function isModelBasedAdapter(adapterType: string): boolean {
return !["process", "command", "http", "openclaw_gateway", "hermes_gateway"].includes(adapterType);
}
function defaultAdapterType(state: BuiltInAgentState): string {
return state.definition.defaultAdapterType ?? state.definition.allowedAdapterTypes?.[0] ?? "codex_local";
}
function parseBudgetMonthlyCents(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
const cents = Math.round(Number(trimmed) * 100);
return Number.isFinite(cents) && cents >= 0 ? cents : undefined;
}
export interface ConfigureBuiltInAgentModalProps {
companyId: string;
state: BuiltInAgentState;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful provision (e.g. to navigate to the agent). */
onConfigured?: (result: BuiltInAgentState) => void;
}
/**
* Configure-on-first-use modal for a built-in agent. Reuses the shared
* `AdapterTypeDropdown` + `ModelDropdown` (ux-spec D6 — no second model picker),
* plus an optional monthly budget, and submits to the provision endpoint.
*/
export function ConfigureBuiltInAgentModal({
companyId,
state,
open,
onOpenChange,
onConfigured,
}: ConfigureBuiltInAgentModalProps) {
const queryClient = useQueryClient();
const { definition } = state;
const [adapterType, setAdapterType] = useState<string>(
() => state.agent && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state),
);
const [model, setModel] = useState<string>(() => {
const config = state.agent?.adapterConfig;
const configuredModel = typeof config === "object" && config !== null
? (config as Record<string, unknown>).model
: null;
if (typeof configuredModel === "string") return configuredModel;
const defaultModel = state.definition.defaultAdapterConfig?.model;
return typeof defaultModel === "string" ? defaultModel : "";
});
const [modelOpen, setModelOpen] = useState(false);
const [budgetDollars, setBudgetDollars] = useState<string>(() => {
const cents = definition.defaultBudgetMonthlyCents ?? 0;
return cents > 0 ? String(cents / 100) : "";
});
const [error, setError] = useState<string | null>(null);
// Restrict adapter choices to the registry's allow-list. Non-model adapters
// are still selectable: provisioning creates the row, then full agent config
// collects command/endpoint fields while the built-in remains `needs_setup`.
const disabledTypes = useMemo(() => {
const allowed = new Set(definition.allowedAdapterTypes ?? []);
return new Set(
listAdapterOptions()
.map((option) => option.value)
.filter((value) => allowed.size > 0 && !allowed.has(value)),
);
}, [definition.allowedAdapterTypes]);
const setupSupportedInModal = isModelBasedAdapter(adapterType);
const { data: fetchedModels } = useQuery({
queryKey: queryKeys.agents.adapterModels(companyId, adapterType, null),
queryFn: () => agentsApi.adapterModels(companyId, adapterType, {}),
enabled: open && Boolean(companyId) && setupSupportedInModal,
});
const models = fetchedModels ?? [];
const modelRequired = setupSupportedInModal;
const normalizedModel = model.trim();
const modelKnown =
!normalizedModel ||
models.length === 0 ||
models.some((candidate) => candidate.id === normalizedModel);
const modelError = modelKnown
? null
: `Model “${normalizedModel}” is not available for ${adapterType}. Choose a known model.`;
const budgetMonthlyCents = parseBudgetMonthlyCents(budgetDollars);
const budgetValid = !budgetDollars.trim() || budgetMonthlyCents !== undefined;
const canSubmit =
budgetValid &&
modelKnown &&
(setupSupportedInModal ? !modelRequired || normalizedModel.length > 0 : true);
const submitLabel = setupSupportedInModal
? `Configure & enable ${definition.displayName}`
: `Provision ${definition.displayName}`;
const provision = useMutation({
mutationFn: async () => {
const adapterConfig: Record<string, unknown> = {};
if (model.trim()) adapterConfig.model = model.trim();
const existing = state.agent;
const sameHarness = existing && agentHarnessType(existing.adapterType, existing.adapterConfig) === adapterType;
const savedModel = typeof existing?.adapterConfig.model === "string" ? existing.adapterConfig.model.trim() : "";
const alreadyConfigured = state.status === "ready" || state.status === "paused";
const adapterInput = alreadyConfigured && sameHarness && normalizedModel === savedModel ? {}
: sameHarness ? { adapterType: existing.adapterType, adapterConfig: { ...existing.adapterConfig, ...adapterConfig } }
: { adapterType, adapterConfig };
const result = await builtInAgentsApi.provision(companyId, definition.key, {
...adapterInput,
...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}),
});
return result;
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
if (result.agentId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(result.agentId) });
}
onConfigured?.(result);
onOpenChange(false);
},
onError: (err) => {
setError(err instanceof ApiError ? err.message : "Failed to configure the built-in agent.");
},
});
return (
<Dialog open={open} onOpenChange={(next) => (provision.isPending ? undefined : onOpenChange(next))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Set up the {definition.displayName}</DialogTitle>
<DialogDescription>{definition.shortPurpose}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<InlineBanner tone="info" compact>
Creates <strong>{definition.displayName}</strong> in your roster, badged{" "}
<strong>Built-in</strong>. Organizations that require hire approval will queue this for the
board.
</InlineBanner>
<Field label="Harness">
<AdapterTypeDropdown
value={adapterType}
onChange={(next) => {
setAdapterType(next);
setModel("");
}}
disabledTypes={disabledTypes}
/>
</Field>
{modelRequired && (
// ModelDropdown supplies its own "Model" Field label + hint.
<ModelDropdown
models={models}
value={model}
onChange={setModel}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}
required
groupByProvider={false}
preserveOrder={adapterCuratesModelOrder(adapterType)}
creatable
/>
)}
{modelError && (
<p className="text-sm text-destructive" role="alert">
{modelError}
</p>
)}
{!setupSupportedInModal && (
<InlineBanner tone="warning" compact>
This adapter needs command or endpoint fields before it can run. Provision the
built-in row now, then finish those fields from the full agent configuration.
</InlineBanner>
)}
<Field label="Monthly budget (optional)" hint="Leave blank for no cap.">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">$</span>
<Input
type="number"
min="0"
step="1"
inputMode="decimal"
placeholder="0"
value={budgetDollars}
onChange={(event) => setBudgetDollars(event.target.value)}
className="w-32"
/>
<span className="text-sm text-muted-foreground">/ month</span>
</div>
</Field>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={provision.isPending}
>
Not now
</Button>
<Button
onClick={() => {
setError(null);
provision.mutate();
}}
disabled={!canSubmit || provision.isPending}
>
{provision.isPending ? "Configuring…" : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}