PR #62 uses strict model selection with no default — the merge conflict resolution incorrectly kept HEAD's references to this removed constant. Also remove dead opencode_local branch in OnboardingWizard (already handled by prior condition). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1235 lines
50 KiB
TypeScript
1235 lines
50 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 {
|
|
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 { AsciiArtAnimation } from "./AsciiArtAnimation";
|
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
|
import { HintIcon } from "./agent-config-primitives";
|
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
|
import {
|
|
Building2,
|
|
Bot,
|
|
Code,
|
|
ListTodo,
|
|
Rocket,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Terminal,
|
|
Globe,
|
|
Sparkles,
|
|
MousePointer2,
|
|
Check,
|
|
Loader2,
|
|
FolderOpen,
|
|
ChevronDown,
|
|
X
|
|
} from "lucide-react";
|
|
|
|
type Step = 1 | 2 | 3 | 4;
|
|
type AdapterType =
|
|
| "claude_local"
|
|
| "codex_local"
|
|
| "opencode_local"
|
|
| "cursor"
|
|
| "process"
|
|
| "http"
|
|
| "openclaw";
|
|
|
|
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](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md)
|
|
|
|
Ensure you have a folder agents/ceo and then download this AGENTS.md as well as the sibling HEARTBEAT.md, SOUL.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file
|
|
|
|
And after you've finished that, hire yourself a Founding Engineer agent`;
|
|
|
|
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);
|
|
|
|
// 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 === "opencode_local" || adapterType === "cursor";
|
|
const effectiveAdapterCommand =
|
|
command.trim() ||
|
|
(adapterType === "codex_local"
|
|
? "codex"
|
|
: 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 === "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()) {
|
|
await goalsApi.create(company.id, {
|
|
title: companyGoal.trim(),
|
|
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;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const issue = await issuesApi.create(createdCompanyId, {
|
|
title: taskTitle.trim(),
|
|
...(taskDescription.trim()
|
|
? { description: taskDescription.trim() }
|
|
: {}),
|
|
assigneeAgentId: createdAgentId,
|
|
status: "todo"
|
|
});
|
|
setCreatedIssueRef(issue.identifier ?? issue.id);
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.issues.list(createdCompanyId)
|
|
});
|
|
setStep(4);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to create task");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleLaunch() {
|
|
if (!createdAgentId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
setLoading(false);
|
|
reset();
|
|
closeOnboarding();
|
|
if (createdCompanyPrefix && createdIssueRef) {
|
|
navigate(`/${createdCompanyPrefix}/issues/${createdIssueRef}`);
|
|
return;
|
|
}
|
|
if (createdCompanyPrefix) {
|
|
navigate(`/${createdCompanyPrefix}/dashboard`);
|
|
return;
|
|
}
|
|
navigate("/dashboard");
|
|
}
|
|
|
|
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="w-full md:w-1/2 flex flex-col overflow-y-auto">
|
|
<div className="w-full max-w-md mx-auto my-auto px-8 py-12 shrink-0">
|
|
{/* Progress indicators */}
|
|
<div className="flex items-center gap-2 mb-8">
|
|
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">Get Started</span>
|
|
<span className="text-sm text-muted-foreground/60">
|
|
Step {step} of 4
|
|
</span>
|
|
<div className="flex items-center gap-1.5 ml-auto">
|
|
{[1, 2, 3, 4].map((s) => (
|
|
<div
|
|
key={s}
|
|
className={cn(
|
|
"h-1.5 w-6 rounded-full transition-colors",
|
|
s < step
|
|
? "bg-green-500"
|
|
: s === step
|
|
? "bg-foreground"
|
|
: "bg-muted"
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</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>
|
|
<label className="text-xs text-muted-foreground mb-1 block">
|
|
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>
|
|
<label className="text-xs text-muted-foreground mb-1 block">
|
|
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
|
|
},
|
|
{
|
|
value: "opencode_local" as const,
|
|
label: "OpenCode",
|
|
icon: Code,
|
|
desc: "Local OpenCode agent"
|
|
},
|
|
{
|
|
value: "opencode_local" as const,
|
|
label: "OpenCode",
|
|
icon: OpenCodeLogoIcon,
|
|
desc: "Local multi-provider agent"
|
|
},
|
|
{
|
|
value: "openclaw" as const,
|
|
label: "OpenClaw",
|
|
icon: Bot,
|
|
desc: "Notify OpenClaw webhook",
|
|
comingSoon: true
|
|
},
|
|
{
|
|
value: "cursor" as const,
|
|
label: "Cursor",
|
|
icon: MousePointer2,
|
|
desc: "Local Cursor agent"
|
|
},
|
|
{
|
|
value: "process" as const,
|
|
label: "Shell Command",
|
|
icon: Terminal,
|
|
desc: "Run a process",
|
|
comingSoon: true
|
|
},
|
|
{
|
|
value: "http" as const,
|
|
label: "HTTP Webhook",
|
|
icon: Globe,
|
|
desc: "Call an endpoint",
|
|
comingSoon: true
|
|
}
|
|
].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 === "codex_local" && !model) {
|
|
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
|
} else if (nextType === "cursor" && !model) {
|
|
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
|
|
}
|
|
if (nextType === "opencode_local") {
|
|
if (!model.includes("/")) {
|
|
setModel("");
|
|
}
|
|
return;
|
|
}
|
|
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.comingSoon ? "Coming soon" : opt.desc}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Conditional adapter fields */}
|
|
{(adapterType === "claude_local" ||
|
|
adapterType === "codex_local" ||
|
|
adapterType === "opencode_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 && (
|
|
<AdapterEnvironmentResult result={adapterEnvResult} />
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
<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 === "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 === "opencode_local" ? (
|
|
<p className="text-muted-foreground">
|
|
If auth fails, set{" "}
|
|
<span className="font-mono">
|
|
{adapterType === "cursor" ? "CURSOR_API_KEY" : "OPENAI_API_KEY"}
|
|
</span>{" "}
|
|
in
|
|
env or run{" "}
|
|
<span className="font-mono">
|
|
{adapterType === "cursor"
|
|
? "agent login"
|
|
: adapterType === "codex_local"
|
|
? "codex login"
|
|
: "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") && (
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">
|
|
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="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. Your assigned task already woke
|
|
the agent, so you can jump straight to 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 ? "Opening..." : "Open Issue"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right half — ASCII art (hidden on mobile) */}
|
|
<div className="hidden md:block w-1/2 overflow-hidden">
|
|
<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>
|
|
);
|
|
}
|