Files
paperclip/ui/src/components/OnboardingWizard.tsx
2026-03-13 09:46:36 -05:00

1374 lines
56 KiB
TypeScript

import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { companiesApi } from "../api/companies";
import { goalsApi } from "../api/goals";
import { agentsApi } from "../api/agents";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { Dialog, DialogPortal } from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
import {
extractModelName,
extractProviderIdWithFallback
} from "../lib/model-utils";
import { getUIAdapter } from "../adapters";
import { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import {
Building2,
Bot,
Code,
Gem,
ListTodo,
Rocket,
ArrowLeft,
ArrowRight,
Terminal,
Sparkles,
MousePointer2,
Check,
Loader2,
FolderOpen,
ChevronDown,
X
} from "lucide-react";
type Step = 1 | 2 | 3 | 4;
type AdapterType =
| "claude_local"
| "codex_local"
| "gemini_local"
| "opencode_local"
| "pi_local"
| "cursor"
| "process"
| "http"
| "openclaw_gateway";
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here:
https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md
Ensure you have a folder agents/ceo and then download this AGENTS.md, and sibling HEARTBEAT.md, SOUL.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file
After that, hire yourself a Founding Engineer agent and then plan the roadmap and tasks for your new company.`;
export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
const initialStep = onboardingOptions.initialStep ?? 1;
const existingCompanyId = onboardingOptions.companyId;
const [step, setStep] = useState<Step>(initialStep);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modelOpen, setModelOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
// Step 1
const [companyName, setCompanyName] = useState("");
const [companyGoal, setCompanyGoal] = useState("");
// Step 2
const [agentName, setAgentName] = useState("CEO");
const [adapterType, setAdapterType] = useState<AdapterType>("claude_local");
const [cwd, setCwd] = useState("");
const [model, setModel] = useState("");
const [command, setCommand] = useState("");
const [args, setArgs] = useState("");
const [url, setUrl] = useState("");
const [adapterEnvResult, setAdapterEnvResult] =
useState<AdapterEnvironmentTestResult | null>(null);
const [adapterEnvError, setAdapterEnvError] = useState<string | null>(null);
const [adapterEnvLoading, setAdapterEnvLoading] = useState(false);
const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] =
useState(false);
const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
const [showMoreAdapters, setShowMoreAdapters] = useState(false);
// Step 3
const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md");
const [taskDescription, setTaskDescription] = useState(
DEFAULT_TASK_DESCRIPTION
);
// Auto-grow textarea for task description
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResizeTextarea = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, []);
// Created entity IDs — pre-populate from existing company when skipping step 1
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
existingCompanyId ?? null
);
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
string | null
>(null);
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
// Sync step and company when onboarding opens with options.
// Keep this independent from company-list refreshes so Step 1 completion
// doesn't get reset after creating a company.
useEffect(() => {
if (!onboardingOpen) return;
const cId = onboardingOptions.companyId ?? null;
setStep(onboardingOptions.initialStep ?? 1);
setCreatedCompanyId(cId);
setCreatedCompanyPrefix(null);
}, [
onboardingOpen,
onboardingOptions.companyId,
onboardingOptions.initialStep
]);
// Backfill issue prefix for an existing company once companies are loaded.
useEffect(() => {
if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
const company = companies.find((c) => c.id === createdCompanyId);
if (company) setCreatedCompanyPrefix(company.issuePrefix);
}, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
// Resize textarea when step 3 is shown or description changes
useEffect(() => {
if (step === 3) autoResizeTextarea();
}, [step, taskDescription, autoResizeTextarea]);
const {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching
} = useQuery({
queryKey: createdCompanyId
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
});
const isLocalAdapter =
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" ||
adapterType === "cursor";
const effectiveAdapterCommand =
command.trim() ||
(adapterType === "codex_local"
? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
? "opencode"
: "claude");
useEffect(() => {
if (step !== 2) return;
setAdapterEnvResult(null);
setAdapterEnvError(null);
}, [step, adapterType, cwd, model, command, args, url]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const hasAnthropicApiKeyOverrideCheck =
adapterEnvResult?.checks.some(
(check) =>
check.code === "claude_anthropic_api_key_overrides_subscription"
) ?? false;
const shouldSuggestUnsetAnthropicApiKey =
adapterType === "claude_local" &&
adapterEnvResult?.status === "fail" &&
hasAnthropicApiKeyOverrideCheck;
const filteredModels = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
return (adapterModels ?? []).filter((entry) => {
if (!query) return true;
const provider = extractProviderIdWithFallback(entry.id, "");
return (
entry.id.toLowerCase().includes(query) ||
entry.label.toLowerCase().includes(query) ||
provider.toLowerCase().includes(query)
);
});
}, [adapterModels, modelSearch]);
const groupedModels = useMemo(() => {
if (adapterType !== "opencode_local") {
return [
{
provider: "models",
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id))
}
];
}
const groups = new Map<string, Array<{ id: string; label: string }>>();
for (const entry of filteredModels) {
const provider = extractProviderIdWithFallback(entry.id);
const bucket = groups.get(provider) ?? [];
bucket.push(entry);
groups.set(provider, bucket);
}
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, entries]) => ({
provider,
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id))
}));
}, [filteredModels, adapterType]);
function reset() {
setStep(1);
setLoading(false);
setError(null);
setCompanyName("");
setCompanyGoal("");
setAgentName("CEO");
setAdapterType("claude_local");
setCwd("");
setModel("");
setCommand("");
setArgs("");
setUrl("");
setAdapterEnvResult(null);
setAdapterEnvError(null);
setAdapterEnvLoading(false);
setForceUnsetAnthropicApiKey(false);
setUnsetAnthropicLoading(false);
setTaskTitle("Create your CEO HEARTBEAT.md");
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedAgentId(null);
setCreatedIssueRef(null);
}
function handleClose() {
reset();
closeOnboarding();
}
function buildAdapterConfig(): Record<string, unknown> {
const adapter = getUIAdapter(adapterType);
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
cwd,
model:
adapterType === "codex_local"
? model || DEFAULT_CODEX_LOCAL_MODEL
: adapterType === "gemini_local"
? model || DEFAULT_GEMINI_LOCAL_MODEL
: adapterType === "cursor"
? model || DEFAULT_CURSOR_LOCAL_MODEL
: model,
command,
args,
url,
dangerouslySkipPermissions: adapterType === "claude_local",
dangerouslyBypassSandbox:
adapterType === "codex_local"
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
: defaultCreateValues.dangerouslyBypassSandbox
});
if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) {
const env =
typeof config.env === "object" &&
config.env !== null &&
!Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {};
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
config.env = env;
}
return config;
}
async function runAdapterEnvironmentTest(
adapterConfigOverride?: Record<string, unknown>
): Promise<AdapterEnvironmentTestResult | null> {
if (!createdCompanyId) {
setAdapterEnvError(
"Create or select a company before testing adapter environment."
);
return null;
}
setAdapterEnvLoading(true);
setAdapterEnvError(null);
try {
const result = await agentsApi.testEnvironment(
createdCompanyId,
adapterType,
{
adapterConfig: adapterConfigOverride ?? buildAdapterConfig()
}
);
setAdapterEnvResult(result);
return result;
} catch (err) {
setAdapterEnvError(
err instanceof Error ? err.message : "Adapter environment test failed"
);
return null;
} finally {
setAdapterEnvLoading(false);
}
}
async function handleStep1Next() {
setLoading(true);
setError(null);
try {
const company = await companiesApi.create({ name: companyName.trim() });
setCreatedCompanyId(company.id);
setCreatedCompanyPrefix(company.issuePrefix);
setSelectedCompanyId(company.id);
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
if (companyGoal.trim()) {
const parsedGoal = parseOnboardingGoalInput(companyGoal);
await goalsApi.create(company.id, {
title: parsedGoal.title,
...(parsedGoal.description
? { description: parsedGoal.description }
: {}),
level: "company",
status: "active"
});
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(company.id)
});
}
setStep(2);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create company");
} finally {
setLoading(false);
}
}
async function handleStep2Next() {
if (!createdCompanyId) return;
setLoading(true);
setError(null);
try {
if (adapterType === "opencode_local") {
const selectedModelId = model.trim();
if (!selectedModelId) {
setError(
"OpenCode requires an explicit model in provider/model format."
);
return;
}
if (adapterModelsError) {
setError(
adapterModelsError instanceof Error
? adapterModelsError.message
: "Failed to load OpenCode models."
);
return;
}
if (adapterModelsLoading || adapterModelsFetching) {
setError(
"OpenCode models are still loading. Please wait and try again."
);
return;
}
const discoveredModels = adapterModels ?? [];
if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
setError(
discoveredModels.length === 0
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
: `Configured OpenCode model is unavailable: ${selectedModelId}`
);
return;
}
}
if (isLocalAdapter) {
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
if (!result) return;
}
const agent = await agentsApi.create(createdCompanyId, {
name: agentName.trim(),
role: "ceo",
adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: {
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1
}
}
});
setCreatedAgentId(agent.id);
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(createdCompanyId)
});
setStep(3);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create agent");
} finally {
setLoading(false);
}
}
async function handleUnsetAnthropicApiKey() {
if (!createdCompanyId || unsetAnthropicLoading) return;
setUnsetAnthropicLoading(true);
setError(null);
setAdapterEnvError(null);
setForceUnsetAnthropicApiKey(true);
const configWithUnset = (() => {
const config = buildAdapterConfig();
const env =
typeof config.env === "object" &&
config.env !== null &&
!Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {};
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
config.env = env;
return config;
})();
try {
if (createdAgentId) {
await agentsApi.update(
createdAgentId,
{ adapterConfig: configWithUnset },
createdCompanyId
);
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(createdCompanyId)
});
}
const result = await runAdapterEnvironmentTest(configWithUnset);
if (result?.status === "fail") {
setError(
"Retried with ANTHROPIC_API_KEY unset in adapter config, but the environment test is still failing."
);
}
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to unset ANTHROPIC_API_KEY and retry."
);
} finally {
setUnsetAnthropicLoading(false);
}
}
async function handleStep3Next() {
if (!createdCompanyId || !createdAgentId) return;
setError(null);
setStep(4);
}
async function handleLaunch() {
if (!createdCompanyId || !createdAgentId) return;
setLoading(true);
setError(null);
try {
let issueRef = createdIssueRef;
if (!issueRef) {
const issue = await issuesApi.create(createdCompanyId, {
title: taskTitle.trim(),
...(taskDescription.trim()
? { description: taskDescription.trim() }
: {}),
assigneeAgentId: createdAgentId,
status: "todo"
});
issueRef = issue.identifier ?? issue.id;
setCreatedIssueRef(issueRef);
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(createdCompanyId)
});
}
setSelectedCompanyId(createdCompanyId);
reset();
closeOnboarding();
navigate(
createdCompanyPrefix
? `/${createdCompanyPrefix}/issues/${issueRef}`
: `/issues/${issueRef}`
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create task");
} finally {
setLoading(false);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (step === 1 && companyName.trim()) handleStep1Next();
else if (step === 2 && agentName.trim()) handleStep2Next();
else if (step === 3 && taskTitle.trim()) handleStep3Next();
else if (step === 4) handleLaunch();
}
}
if (!onboardingOpen) return null;
return (
<Dialog
open={onboardingOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogPortal>
{/* Plain div instead of DialogOverlay — Radix's overlay wraps in
RemoveScroll which blocks wheel events on our custom (non-DialogContent)
scroll container. A plain div preserves the background without scroll-locking. */}
<div className="fixed inset-0 z-50 bg-background" />
<div className="fixed inset-0 z-50 flex" onKeyDown={handleKeyDown}>
{/* Close button */}
<button
onClick={handleClose}
className="absolute top-4 left-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</button>
{/* Left half — form */}
<div
className={cn(
"w-full flex flex-col overflow-y-auto transition-[width] duration-500 ease-in-out",
step === 1 ? "md:w-1/2" : "md:w-full"
)}
>
<div className="w-full max-w-md mx-auto my-auto px-8 py-12 shrink-0">
{/* Progress tabs */}
<div className="flex items-center gap-0 mb-8 border-b border-border">
{(
[
{ step: 1 as Step, label: "Company", icon: Building2 },
{ step: 2 as Step, label: "Agent", icon: Bot },
{ step: 3 as Step, label: "Task", icon: ListTodo },
{ step: 4 as Step, label: "Launch", icon: Rocket }
] as const
).map(({ step: s, label, icon: Icon }) => (
<button
key={s}
type="button"
onClick={() => setStep(s)}
className={cn(
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors cursor-pointer",
s === step
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground/70 hover:border-border"
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
{/* Step content */}
{step === 1 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Name your company</h3>
<p className="text-xs text-muted-foreground">
This is the organization your agents will work for.
</p>
</div>
</div>
<div className="mt-3 group">
<label
className={cn(
"text-xs mb-1 block transition-colors",
companyName.trim()
? "text-foreground"
: "text-muted-foreground group-focus-within:text-foreground"
)}
>
Company name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="Acme Corp"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
autoFocus
/>
</div>
<div className="group">
<label
className={cn(
"text-xs mb-1 block transition-colors",
companyGoal.trim()
? "text-foreground"
: "text-muted-foreground group-focus-within:text-foreground"
)}
>
Mission / goal (optional)
</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
placeholder="What is this company trying to achieve?"
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Bot className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Create your first agent</h3>
<p className="text-xs text-muted-foreground">
Choose how this agent will run tasks.
</p>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Agent name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="CEO"
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
autoFocus
/>
</div>
{/* Adapter type radio cards */}
<div>
<label className="text-xs text-muted-foreground mb-2 block">
Adapter type
</label>
<div className="grid grid-cols-2 gap-2">
{[
{
value: "claude_local" as const,
label: "Claude Code",
icon: Sparkles,
desc: "Local Claude agent",
recommended: true
},
{
value: "codex_local" as const,
label: "Codex",
icon: Code,
desc: "Local Codex agent",
recommended: true
}
].map((opt) => (
<button
key={opt.value}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
adapterType === opt.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
const nextType = opt.value as AdapterType;
setAdapterType(nextType);
if (nextType === "codex_local" && !model) {
setModel(DEFAULT_CODEX_LOCAL_MODEL);
}
if (nextType !== "codex_local") {
setModel("");
}
}}
>
{opt.recommended && (
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
Recommended
</span>
)}
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.desc}
</span>
</button>
))}
</div>
<button
className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowMoreAdapters((v) => !v)}
>
<ChevronDown
className={cn(
"h-3 w-3 transition-transform",
showMoreAdapters ? "rotate-0" : "-rotate-90"
)}
/>
More Agent Adapter Types
</button>
{showMoreAdapters && (
<div className="grid grid-cols-2 gap-2 mt-2">
{[
{
value: "gemini_local" as const,
label: "Gemini CLI",
icon: Gem,
desc: "Local Gemini agent"
},
{
value: "opencode_local" as const,
label: "OpenCode",
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent"
},
{
value: "pi_local" as const,
label: "Pi",
icon: Terminal,
desc: "Local Pi agent"
},
{
value: "cursor" as const,
label: "Cursor",
icon: MousePointer2,
desc: "Local Cursor agent"
},
{
value: "openclaw_gateway" as const,
label: "OpenClaw Gateway",
icon: Bot,
desc: "Invoke OpenClaw via gateway protocol",
comingSoon: true,
disabledLabel: "Configure OpenClaw within the App"
}
].map((opt) => (
<button
key={opt.value}
disabled={!!opt.comingSoon}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
opt.comingSoon
? "border-border opacity-40 cursor-not-allowed"
: adapterType === opt.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
if (opt.comingSoon) return;
const nextType = opt.value as AdapterType;
setAdapterType(nextType);
if (nextType === "gemini_local" && !model) {
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
return;
}
if (nextType === "cursor" && !model) {
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
return;
}
if (nextType === "opencode_local") {
if (!model.includes("/")) {
setModel("");
}
return;
}
setModel("");
}}
>
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.comingSoon
? (opt as { disabledLabel?: string })
.disabledLabel ?? "Coming soon"
: opt.desc}
</span>
</button>
))}
</div>
)}
</div>
{/* Conditional adapter fields */}
{(adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor") && (
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1">
<label className="text-xs text-muted-foreground">
Working directory
</label>
<HintIcon text="Paperclip works best if you create a new folder for your agents to keep their memories and stay organized. Create a new folder and put the path here." />
</div>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/50"
placeholder="/path/to/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
<ChoosePathButton />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Model
</label>
<Popover
open={modelOpen}
onOpenChange={(next) => {
setModelOpen(next);
if (!next) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span
className={cn(
!model && "text-muted-foreground"
)}
>
{selectedModel
? selectedModel.label
: model ||
(adapterType === "opencode_local"
? "Select model (required)"
: "Default")}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-1"
align="start"
>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
{adapterType !== "opencode_local" && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!model && "bg-accent"
)}
onClick={() => {
setModel("");
setModelOpen(false);
}}
>
Default
</button>
)}
<div className="max-h-[240px] overflow-y-auto">
{groupedModels.map((group) => (
<div
key={group.provider}
className="mb-1 last:mb-0"
>
{adapterType === "opencode_local" && (
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{group.provider} ({group.entries.length})
</div>
)}
{group.entries.map((m) => (
<button
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === model && "bg-accent"
)}
onClick={() => {
setModel(m.id);
setModelOpen(false);
}}
>
<span
className="block w-full text-left truncate"
title={m.id}
>
{adapterType === "opencode_local"
? extractModelName(m.id)
: m.label}
</span>
</button>
))}
</div>
))}
</div>
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">
No models discovered.
</p>
)}
</PopoverContent>
</Popover>
</div>
</div>
)}
{isLocalAdapter && (
<div className="space-y-2 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-medium">
Adapter environment check
</p>
<p className="text-[11px] text-muted-foreground">
Runs a live probe that asks the adapter CLI to
respond with hello.
</p>
</div>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
disabled={adapterEnvLoading}
onClick={() => void runAdapterEnvironmentTest()}
>
{adapterEnvLoading ? "Testing..." : "Test now"}
</Button>
</div>
{adapterEnvError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-[11px] text-destructive">
{adapterEnvError}
</div>
)}
{adapterEnvResult &&
adapterEnvResult.status === "pass" ? (
<div className="flex items-center gap-2 rounded-md border border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-xs text-green-700 dark:text-green-300 animate-in fade-in slide-in-from-bottom-1 duration-300">
<Check className="h-3.5 w-3.5 shrink-0" />
<span className="font-medium">Passed</span>
</div>
) : adapterEnvResult ? (
<AdapterEnvironmentResult result={adapterEnvResult} />
) : null}
{shouldSuggestUnsetAnthropicApiKey && (
<div className="rounded-md border border-amber-300/60 bg-amber-50/40 px-2.5 py-2 space-y-2">
<p className="text-[11px] text-amber-900/90 leading-relaxed">
Claude failed while{" "}
<span className="font-mono">ANTHROPIC_API_KEY</span>{" "}
is set. You can clear it in this CEO adapter config
and retry the probe.
</p>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
disabled={
adapterEnvLoading || unsetAnthropicLoading
}
onClick={() => void handleUnsetAnthropicApiKey()}
>
{unsetAnthropicLoading
? "Retrying..."
: "Unset ANTHROPIC_API_KEY"}
</Button>
</div>
)}
{adapterEnvResult && adapterEnvResult.status === "fail" && (
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px] space-y-1.5">
<p className="font-medium">Manual debug</p>
<p className="text-muted-foreground font-mono break-all">
{adapterType === "cursor"
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
: adapterType === "codex_local"
? `${effectiveAdapterCommand} exec --json -`
: adapterType === "gemini_local"
? `${effectiveAdapterCommand} --output-format json "Respond with hello."`
: adapterType === "opencode_local"
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
</p>
<p className="text-muted-foreground">
Prompt:{" "}
<span className="font-mono">Respond with hello.</span>
</p>
{adapterType === "cursor" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" ? (
<p className="text-muted-foreground">
If auth fails, set{" "}
<span className="font-mono">
{adapterType === "cursor"
? "CURSOR_API_KEY"
: adapterType === "gemini_local"
? "GEMINI_API_KEY"
: "OPENAI_API_KEY"}
</span>{" "}
in env or run{" "}
<span className="font-mono">
{adapterType === "cursor"
? "agent login"
: adapterType === "codex_local"
? "codex login"
: adapterType === "gemini_local"
? "gemini auth"
: "opencode auth login"}
</span>
.
</p>
) : (
<p className="text-muted-foreground">
If login is required, run{" "}
<span className="font-mono">claude login</span>{" "}
and retry.
</p>
)}
</div>
)}
</div>
)}
{adapterType === "process" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Command
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. node, python"
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Args (comma-separated)
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. script.js, --flag"
value={args}
onChange={(e) => setArgs(e.target.value)}
/>
</div>
</div>
)}
{(adapterType === "http" ||
adapterType === "openclaw_gateway") && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{adapterType === "openclaw_gateway"
? "Gateway URL"
: "Webhook URL"}
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder={
adapterType === "openclaw_gateway"
? "ws://127.0.0.1:18789"
: "https://..."
}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
)}
</div>
)}
{step === 3 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<ListTodo className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Give it something to do</h3>
<p className="text-xs text-muted-foreground">
Give your agent a small task to start with a bug fix,
a research question, writing a script.
</p>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Task title
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. Research competitor pricing"
value={taskTitle}
onChange={(e) => setTaskTitle(e.target.value)}
autoFocus
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Description (optional)
</label>
<textarea
ref={textareaRef}
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[120px] max-h-[300px] overflow-y-auto"
placeholder="Add more detail about what the agent should do..."
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
/>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Rocket className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Ready to launch</h3>
<p className="text-xs text-muted-foreground">
Everything is set up. Launching now will create the
starter task, wake the agent, and open the issue.
</p>
</div>
</div>
<div className="border border-border divide-y divide-border">
<div className="flex items-center gap-3 px-3 py-2.5">
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{companyName}
</p>
<p className="text-xs text-muted-foreground">Company</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
</div>
<div className="flex items-center gap-3 px-3 py-2.5">
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{agentName}
</p>
<p className="text-xs text-muted-foreground">
{getUIAdapter(adapterType).label}
</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
</div>
<div className="flex items-center gap-3 px-3 py-2.5">
<ListTodo className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{taskTitle}
</p>
<p className="text-xs text-muted-foreground">Task</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mt-3">
<p className="text-xs text-destructive">{error}</p>
</div>
)}
{/* Footer navigation */}
<div className="flex items-center justify-between mt-8">
<div>
{step > 1 && step > (onboardingOptions.initialStep ?? 1) && (
<Button
variant="ghost"
size="sm"
onClick={() => setStep((step - 1) as Step)}
disabled={loading}
>
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
Back
</Button>
)}
</div>
<div className="flex items-center gap-2">
{step === 1 && (
<Button
size="sm"
disabled={!companyName.trim() || loading}
onClick={handleStep1Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 2 && (
<Button
size="sm"
disabled={
!agentName.trim() || loading || adapterEnvLoading
}
onClick={handleStep2Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 3 && (
<Button
size="sm"
disabled={!taskTitle.trim() || loading}
onClick={handleStep3Next}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Next"}
</Button>
)}
{step === 4 && (
<Button size="sm" disabled={loading} onClick={handleLaunch}>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Create & Open Issue"}
</Button>
)}
</div>
</div>
</div>
</div>
{/* Right half — ASCII art (hidden on mobile) */}
<div
className={cn(
"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out",
step === 1 ? "w-1/2 opacity-100" : "w-0 opacity-0"
)}
>
<AsciiArtAnimation />
</div>
</div>
</DialogPortal>
</Dialog>
);
}
function AdapterEnvironmentResult({
result
}: {
result: AdapterEnvironmentTestResult;
}) {
const statusLabel =
result.status === "pass"
? "Passed"
: result.status === "warn"
? "Warnings"
: "Failed";
const statusClass =
result.status === "pass"
? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10"
: result.status === "warn"
? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
: "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
return (
<div className={`rounded-md border px-2.5 py-2 text-[11px] ${statusClass}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{statusLabel}</span>
<span className="opacity-80">
{new Date(result.testedAt).toLocaleTimeString()}
</span>
</div>
<div className="mt-1.5 space-y-1">
{result.checks.map((check, idx) => (
<div
key={`${check.code}-${idx}`}
className="leading-relaxed break-words"
>
<span className="font-medium uppercase tracking-wide opacity-80">
{check.level}
</span>
<span className="mx-1 opacity-60">·</span>
<span>{check.message}</span>
{check.detail && (
<span className="block opacity-75 break-all">
({check.detail})
</span>
)}
{check.hint && (
<span className="block opacity-90 break-words">
Hint: {check.hint}
</span>
)}
</div>
))}
</div>
</div>
);
}