Add onboarding wizard and revamp agent creation flow

Add OnboardingWizard component for first-time company setup. Rework
NewAgentDialog into a multi-step wizard with adapter selection and
config. Add server route for agent connection string generation.
Wire onboarding into Dashboard and Layout. Update DialogContext with
onboarding state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 13:24:33 -06:00
parent 8f17b6fb52
commit 0975907121
8 changed files with 1111 additions and 278 deletions

View File

@@ -15,6 +15,28 @@ export function agentRoutes(db: Db) {
const svc = agentService(db); const svc = agentService(db);
const heartbeat = heartbeatService(db); const heartbeat = heartbeatService(db);
// Static model lists for adapters — can be extended to query CLIs dynamically
const adapterModels: Record<string, { id: string; label: string }[]> = {
claude_local: [
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
],
codex_local: [
{ id: "o4-mini", label: "o4-mini" },
{ id: "o3", label: "o3" },
{ id: "codex-mini-latest", label: "Codex Mini" },
],
process: [],
http: [],
};
router.get("/adapters/:type/models", (req, res) => {
const type = req.params.type as string;
const models = adapterModels[type] ?? [];
res.json(models);
});
router.get("/companies/:companyId/agents", async (req, res) => { router.get("/companies/:companyId/agents", async (req, res) => {
const companyId = req.params.companyId as string; const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);

View File

@@ -1,6 +1,11 @@
import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared"; import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared";
import { api } from "./client"; import { api } from "./client";
export interface AdapterModel {
id: string;
label: string;
}
export interface OrgNode { export interface OrgNode {
id: string; id: string;
name: string; name: string;
@@ -22,6 +27,7 @@ export const agentsApi = {
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }), createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
runtimeState: (id: string) => api.get<AgentRuntimeState>(`/agents/${id}/runtime-state`), runtimeState: (id: string) => api.get<AgentRuntimeState>(`/agents/${id}/runtime-state`),
resetSession: (id: string) => api.post<void>(`/agents/${id}/runtime-state/reset-session`, {}), resetSession: (id: string) => api.post<void>(`/agents/${id}/runtime-state/reset-session`, {}),
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}), invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}),
wakeup: ( wakeup: (
id: string, id: string,

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { BreadcrumbBar } from "./BreadcrumbBar"; import { BreadcrumbBar } from "./BreadcrumbBar";
@@ -7,15 +7,27 @@ import { CommandPalette } from "./CommandPalette";
import { NewIssueDialog } from "./NewIssueDialog"; import { NewIssueDialog } from "./NewIssueDialog";
import { NewProjectDialog } from "./NewProjectDialog"; import { NewProjectDialog } from "./NewProjectDialog";
import { NewAgentDialog } from "./NewAgentDialog"; import { NewAgentDialog } from "./NewAgentDialog";
import { OnboardingWizard } from "./OnboardingWizard";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
export function Layout() { export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const { openNewIssue } = useDialog(); const { openNewIssue, openOnboarding } = useDialog();
const { panelContent, closePanel } = usePanel(); const { panelContent, closePanel } = usePanel();
const { companies, loading: companiesLoading } = useCompany();
const onboardingTriggered = useRef(false);
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
if (companies.length === 0) {
onboardingTriggered.current = true;
openOnboarding();
}
}, [companies, companiesLoading, openOnboarding]);
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []); const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
const togglePanel = useCallback(() => { const togglePanel = useCallback(() => {
@@ -51,6 +63,7 @@ export function Layout() {
<NewIssueDialog /> <NewIssueDialog />
<NewProjectDialog /> <NewProjectDialog />
<NewAgentDialog /> <NewAgentDialog />
<OnboardingWizard />
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
@@ -17,28 +17,27 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { import {
Maximize2, Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import {
Minimize2, Minimize2,
Bot, Maximize2,
User,
Shield, Shield,
User,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Heart,
HelpCircle,
FolderOpen,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
const roleLabels: Record<string, string> = { const roleLabels: Record<string, string> = {
ceo: "CEO", ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO",
cto: "CTO", engineer: "Engineer", designer: "Designer", pm: "PM",
cmo: "CMO", qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General",
cfo: "CFO",
engineer: "Engineer",
designer: "Designer",
pm: "PM",
qa: "QA",
devops: "DevOps",
researcher: "Researcher",
general: "General",
}; };
const adapterLabels: Record<string, string> = { const adapterLabels: Record<string, string> = {
@@ -48,34 +47,53 @@ const adapterLabels: Record<string, string> = {
http: "HTTP", http: "HTTP",
}; };
/* ---- Help text for (?) tooltips ---- */
const help: Record<string, string> = {
name: "Display name for this agent.",
title: "Job title shown in the org chart.",
role: "Organizational role. Determines position and capabilities.",
reportsTo: "The agent this one reports to in the org hierarchy.",
adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.",
cwd: "The working directory where the agent operates. Should be an absolute path on the server.",
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
model: "Override the default model used by the adapter.",
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.",
bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.",
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
command: "The command to execute (e.g. node, python).",
args: "Command-line arguments, comma-separated.",
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
intervalSec: "Seconds between automatic heartbeat invocations.",
};
export function NewAgentDialog() { export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent } = useDialog(); const { newAgentOpen, closeNewAgent } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(true);
// Identity // Identity
const [name, setName] = useState(""); const [name, setName] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [role, setRole] = useState("general"); const [role, setRole] = useState("general");
const [reportsTo, setReportsTo] = useState(""); const [reportsTo, setReportsTo] = useState("");
const [capabilities, setCapabilities] = useState("");
// Adapter // Adapter
const [adapterType, setAdapterType] = useState<string>("claude_local"); const [adapterType, setAdapterType] = useState<string>("claude_local");
const [cwd, setCwd] = useState(""); const [cwd, setCwd] = useState("");
const [promptTemplate, setPromptTemplate] = useState(""); const [promptTemplate, setPromptTemplate] = useState("");
const [bootstrapPrompt, setBootstrapPrompt] = useState("");
const [model, setModel] = useState(""); const [model, setModel] = useState("");
// claude_local specific // claude_local specific
const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80); const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(false);
const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(true);
// codex_local specific // codex_local specific
const [search, setSearch] = useState(false); const [search, setSearch] = useState(false);
const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(true); const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(false);
// process specific // process specific
const [command, setCommand] = useState(""); const [command, setCommand] = useState("");
@@ -84,29 +102,22 @@ export function NewAgentDialog() {
// http specific // http specific
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
// Heartbeat // Advanced adapter fields
const [heartbeatEnabled, setHeartbeatEnabled] = useState(true); const [bootstrapPrompt, setBootstrapPrompt] = useState("");
const [intervalSec, setIntervalSec] = useState(300); const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80);
const [wakeOnAssignment, setWakeOnAssignment] = useState(true);
const [wakeOnOnDemand, setWakeOnOnDemand] = useState(true);
const [wakeOnAutomation, setWakeOnAutomation] = useState(true);
const [cooldownSec, setCooldownSec] = useState(10);
// Runtime // Heartbeat
const [contextMode, setContextMode] = useState("thin"); const [heartbeatEnabled, setHeartbeatEnabled] = useState(false);
const [budgetMonthlyCents, setBudgetMonthlyCents] = useState(0); const [intervalSec, setIntervalSec] = useState(300);
const [timeoutSec, setTimeoutSec] = useState(900);
const [graceSec, setGraceSec] = useState(15);
// Sections // Sections
const [adapterOpen, setAdapterOpen] = useState(true); const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
const [heartbeatOpen, setHeartbeatOpen] = useState(false); const [heartbeatOpen, setHeartbeatOpen] = useState(false);
const [runtimeOpen, setRuntimeOpen] = useState(false);
// Popover states // Popover states
const [roleOpen, setRoleOpen] = useState(false); const [roleOpen, setRoleOpen] = useState(false);
const [reportsToOpen, setReportsToOpen] = useState(false); const [reportsToOpen, setReportsToOpen] = useState(false);
const [adapterTypeOpen, setAdapterTypeOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false);
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -114,9 +125,23 @@ export function NewAgentDialog() {
enabled: !!selectedCompanyId && newAgentOpen, enabled: !!selectedCompanyId && newAgentOpen,
}); });
const { data: adapterModels } = useQuery({
queryKey: ["adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(adapterType),
enabled: newAgentOpen,
});
const isFirstAgent = !agents || agents.length === 0; const isFirstAgent = !agents || agents.length === 0;
const effectiveRole = isFirstAgent ? "ceo" : role; const effectiveRole = isFirstAgent ? "ceo" : role;
// Auto-fill for CEO
useEffect(() => {
if (newAgentOpen && isFirstAgent) {
if (!name) setName("CEO");
if (!title) setTitle("CEO");
}
}, [newAgentOpen, isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
const createAgent = useMutation({ const createAgent = useMutation({
mutationFn: (data: Record<string, unknown>) => mutationFn: (data: Record<string, unknown>) =>
agentsApi.create(selectedCompanyId!, data), agentsApi.create(selectedCompanyId!, data),
@@ -133,33 +158,23 @@ export function NewAgentDialog() {
setTitle(""); setTitle("");
setRole("general"); setRole("general");
setReportsTo(""); setReportsTo("");
setCapabilities("");
setAdapterType("claude_local"); setAdapterType("claude_local");
setCwd(""); setCwd("");
setPromptTemplate(""); setPromptTemplate("");
setBootstrapPrompt("");
setModel(""); setModel("");
setMaxTurnsPerRun(80); setDangerouslySkipPermissions(false);
setDangerouslySkipPermissions(true);
setSearch(false); setSearch(false);
setDangerouslyBypassSandbox(true); setDangerouslyBypassSandbox(false);
setCommand(""); setCommand("");
setArgs(""); setArgs("");
setUrl(""); setUrl("");
setHeartbeatEnabled(true); setBootstrapPrompt("");
setMaxTurnsPerRun(80);
setHeartbeatEnabled(false);
setIntervalSec(300); setIntervalSec(300);
setWakeOnAssignment(true); setExpanded(true);
setWakeOnOnDemand(true); setAdapterAdvancedOpen(false);
setWakeOnAutomation(true);
setCooldownSec(10);
setContextMode("thin");
setBudgetMonthlyCents(0);
setTimeoutSec(900);
setGraceSec(15);
setExpanded(false);
setAdapterOpen(true);
setHeartbeatOpen(false); setHeartbeatOpen(false);
setRuntimeOpen(false);
} }
function buildAdapterConfig() { function buildAdapterConfig() {
@@ -168,8 +183,8 @@ export function NewAgentDialog() {
if (promptTemplate) config.promptTemplate = promptTemplate; if (promptTemplate) config.promptTemplate = promptTemplate;
if (bootstrapPrompt) config.bootstrapPromptTemplate = bootstrapPrompt; if (bootstrapPrompt) config.bootstrapPromptTemplate = bootstrapPrompt;
if (model) config.model = model; if (model) config.model = model;
config.timeoutSec = timeoutSec; config.timeoutSec = 0;
config.graceSec = graceSec; config.graceSec = 15;
if (adapterType === "claude_local") { if (adapterType === "claude_local") {
config.maxTurnsPerRun = maxTurnsPerRun; config.maxTurnsPerRun = maxTurnsPerRun;
@@ -193,21 +208,20 @@ export function NewAgentDialog() {
role: effectiveRole, role: effectiveRole,
...(title.trim() ? { title: title.trim() } : {}), ...(title.trim() ? { title: title.trim() } : {}),
...(reportsTo ? { reportsTo } : {}), ...(reportsTo ? { reportsTo } : {}),
...(capabilities.trim() ? { capabilities: capabilities.trim() } : {}),
adapterType, adapterType,
adapterConfig: buildAdapterConfig(), adapterConfig: buildAdapterConfig(),
runtimeConfig: { runtimeConfig: {
heartbeat: { heartbeat: {
enabled: heartbeatEnabled, enabled: heartbeatEnabled,
intervalSec, intervalSec,
wakeOnAssignment, wakeOnAssignment: true,
wakeOnOnDemand, wakeOnOnDemand: true,
wakeOnAutomation, wakeOnAutomation: true,
cooldownSec, cooldownSec: 10,
}, },
}, },
contextMode, contextMode: "thin",
budgetMonthlyCents, budgetMonthlyCents: 0,
}); });
} }
@@ -218,16 +232,14 @@ export function NewAgentDialog() {
} }
} }
const currentAgent = (agents ?? []).find((a) => a.id === reportsTo); const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
return ( return (
<Dialog <Dialog
open={newAgentOpen} open={newAgentOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) { reset(); closeNewAgent(); }
reset();
closeNewAgent();
}
}} }}
> >
<DialogContent <DialogContent
@@ -247,26 +259,16 @@ export function NewAgentDialog() {
<span>New agent</span> <span>New agent</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => setExpanded(!expanded)}>
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />} {expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button> </Button>
<Button <Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => { reset(); closeNewAgent(); }}>
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => { reset(); closeNewAgent(); }}
>
<span className="text-lg leading-none">&times;</span> <span className="text-lg leading-none">&times;</span>
</Button> </Button>
</div> </div>
</div> </div>
<div className={cn("overflow-y-auto", expanded ? "max-h-[70vh]" : "max-h-[50vh]")}> <div className="overflow-y-auto max-h-[70vh]">
{/* Name */} {/* Name */}
<div className="px-4 pt-3"> <div className="px-4 pt-3">
<input <input
@@ -288,7 +290,7 @@ export function NewAgentDialog() {
/> />
</div> </div>
{/* Property chips */} {/* Property chips: Role + Reports To */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap"> <div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
{/* Role */} {/* Role */}
<Popover open={roleOpen} onOpenChange={setRoleOpen}> <Popover open={roleOpen} onOpenChange={setRoleOpen}>
@@ -331,7 +333,12 @@ export function NewAgentDialog() {
disabled={isFirstAgent} disabled={isFirstAgent}
> >
<User className="h-3 w-3 text-muted-foreground" /> <User className="h-3 w-3 text-muted-foreground" />
{currentAgent ? currentAgent.name : isFirstAgent ? "N/A (CEO)" : "Reports to"} {currentReportsTo
? `Reports to ${currentReportsTo.name}`
: isFirstAgent
? "Reports to: N/A (CEO)"
: "Reports to..."
}
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start"> <PopoverContent className="w-48 p-1" align="start">
@@ -359,124 +366,126 @@ export function NewAgentDialog() {
))} ))}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div>
{/* Adapter type */} {/* Adapter type dropdown (above config section) */}
<Popover open={adapterTypeOpen} onOpenChange={setAdapterTypeOpen}> <div className="px-4 py-2.5 border-t border-border">
<PopoverTrigger asChild> <Field label="Adapter" hint={help.adapterType}>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors"> <Popover>
<Bot className="h-3 w-3 text-muted-foreground" /> <PopoverTrigger asChild>
{adapterLabels[adapterType] ?? adapterType} <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">
</button> <span>{adapterLabels[adapterType] ?? adapterType}</span>
</PopoverTrigger> <ChevronDown className="h-3 w-3 text-muted-foreground" />
<PopoverContent className="w-40 p-1" align="start">
{AGENT_ADAPTER_TYPES.map((t) => (
<button
key={t}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
t === adapterType && "bg-accent"
)}
onClick={() => { setAdapterType(t); setAdapterTypeOpen(false); }}
>
{adapterLabels[t] ?? t}
</button> </button>
))} </PopoverTrigger>
</PopoverContent> <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
</Popover> {AGENT_ADAPTER_TYPES.map((t) => (
<button
key={t}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
t === adapterType && "bg-accent"
)}
onClick={() => setAdapterType(t)}
>
{adapterLabels[t] ?? t}
</button>
))}
</PopoverContent>
</Popover>
</Field>
</div> </div>
{/* Capabilities */} {/* Adapter Configuration (always open) */}
<div className="px-4 py-2 border-t border-border"> <div className="border-t border-border">
<input <div className="px-4 py-2 text-xs font-medium text-muted-foreground">
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40" Adapter Configuration
placeholder="Capabilities (what can this agent do?)" </div>
value={capabilities} <div className="px-4 pb-3 space-y-3">
onChange={(e) => setCapabilities(e.target.value)} {/* Working directory — basic, shown for local adapters */}
/> {(adapterType === "claude_local" || adapterType === "codex_local") && (
</div> <Field label="Working directory" hint={help.cwd}>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
{/* Adapter Config Section */} <FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<CollapsibleSection
title="Adapter Configuration"
open={adapterOpen}
onToggle={() => setAdapterOpen(!adapterOpen)}
>
<div className="space-y-3">
<Field label="Working directory">
<input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
placeholder="/path/to/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
</Field>
<Field label="Prompt template">
<textarea
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none min-h-[60px]"
placeholder="You are agent {{ agent.name }}..."
value={promptTemplate}
onChange={(e) => setPromptTemplate(e.target.value)}
/>
</Field>
<Field label="Bootstrap prompt (first run)">
<textarea
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none min-h-[40px]"
placeholder="Optional initial setup prompt"
value={bootstrapPrompt}
onChange={(e) => setBootstrapPrompt(e.target.value)}
/>
</Field>
<Field label="Model">
<input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
placeholder="e.g. claude-sonnet-4-5-20250929"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</Field>
{adapterType === "claude_local" && (
<>
<Field label="Max turns per run">
<input <input
type="number" className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
className="w-full bg-transparent outline-none text-sm font-mono" placeholder="/path/to/project"
value={maxTurnsPerRun} value={cwd}
onChange={(e) => setMaxTurnsPerRun(Number(e.target.value))} onChange={(e) => setCwd(e.target.value)}
/> />
</Field> <button
<ToggleField type="button"
label="Skip permissions" className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
checked={dangerouslySkipPermissions} onClick={async () => {
onChange={setDangerouslySkipPermissions} try {
/> // @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
</> const handle = await window.showDirectoryPicker({ mode: "read" });
setCwd(handle.name);
} catch {
// user cancelled or API unsupported
}
}}
>
Choose
</button>
</div>
</Field>
)} )}
{/* Prompt template — basic, auto-expanding */}
{(adapterType === "claude_local" || adapterType === "codex_local") && (
<Field label="Prompt template" hint={help.promptTemplate}>
<AutoExpandTextarea
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
value={promptTemplate}
onChange={setPromptTemplate}
minRows={4}
/>
</Field>
)}
{/* Skip permissions — basic for claude */}
{adapterType === "claude_local" && (
<ToggleField
label="Skip permissions"
hint={help.dangerouslySkipPermissions}
checked={dangerouslySkipPermissions}
onChange={setDangerouslySkipPermissions}
/>
)}
{/* Bypass sandbox + search — basic for codex */}
{adapterType === "codex_local" && ( {adapterType === "codex_local" && (
<> <>
<ToggleField label="Enable search" checked={search} onChange={setSearch} />
<ToggleField <ToggleField
label="Bypass sandbox" label="Bypass sandbox"
hint={help.dangerouslyBypassSandbox}
checked={dangerouslyBypassSandbox} checked={dangerouslyBypassSandbox}
onChange={setDangerouslyBypassSandbox} onChange={setDangerouslyBypassSandbox}
/> />
<ToggleField
label="Enable search"
hint={help.search}
checked={search}
onChange={setSearch}
/>
</> </>
)} )}
{/* Process-specific fields */}
{adapterType === "process" && ( {adapterType === "process" && (
<> <>
<Field label="Command"> <Field label="Command" hint={help.command}>
<input <input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
placeholder="e.g. node, python" placeholder="e.g. node, python"
value={command} value={command}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => setCommand(e.target.value)}
/> />
</Field> </Field>
<Field label="Args (comma-separated)"> <Field label="Args (comma-separated)" hint={help.args}>
<input <input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
placeholder="e.g. script.js, --flag" placeholder="e.g. script.js, --flag"
value={args} value={args}
onChange={(e) => setArgs(e.target.value)} onChange={(e) => setArgs(e.target.value)}
@@ -485,98 +494,111 @@ export function NewAgentDialog() {
</> </>
)} )}
{/* HTTP-specific fields */}
{adapterType === "http" && ( {adapterType === "http" && (
<Field label="Webhook URL"> <Field label="Webhook URL" hint={help.webhookUrl}>
<input <input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
placeholder="https://..." placeholder="https://..."
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
/> />
</Field> </Field>
)} )}
</div>
</CollapsibleSection>
{/* Heartbeat Policy Section */} {/* Advanced section for local adapters */}
{(adapterType === "claude_local" || adapterType === "codex_local") && (
<CollapsibleSection
title="Advanced Adapter Configuration"
open={adapterAdvancedOpen}
onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)}
>
<div className="space-y-3">
{/* Model dropdown */}
<Field label="Model" hint={help.model}>
<Popover open={modelOpen} onOpenChange={setModelOpen}>
<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 || "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">
<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>
{(adapterModels ?? []).map((m) => (
<button
key={m.id}
className={cn(
"flex items-center justify-between 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>{m.label}</span>
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
</button>
))}
</PopoverContent>
</Popover>
</Field>
{/* Bootstrap prompt */}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
<AutoExpandTextarea
placeholder="Optional initial setup prompt for the first run"
value={bootstrapPrompt}
onChange={setBootstrapPrompt}
minRows={2}
/>
</Field>
{/* Max turns — claude only */}
{adapterType === "claude_local" && (
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
<input
type="number"
className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono"
value={maxTurnsPerRun}
onChange={(e) => setMaxTurnsPerRun(Number(e.target.value))}
/>
</Field>
)}
</div>
</CollapsibleSection>
)}
</div>
</div>
{/* Heartbeat Policy */}
<CollapsibleSection <CollapsibleSection
title="Heartbeat Policy" title="Heartbeat Policy"
icon={<Heart className="h-3 w-3" />}
open={heartbeatOpen} open={heartbeatOpen}
onToggle={() => setHeartbeatOpen(!heartbeatOpen)} onToggle={() => setHeartbeatOpen(!heartbeatOpen)}
bordered
> >
<div className="space-y-3"> <div className="space-y-3">
<ToggleField label="Enabled" checked={heartbeatEnabled} onChange={setHeartbeatEnabled} /> <ToggleWithNumber
<Field label="Interval (seconds)"> label="Heartbeat on interval"
<input hint={help.heartbeatInterval}
type="number" checked={heartbeatEnabled}
className="w-full bg-transparent outline-none text-sm font-mono" onCheckedChange={setHeartbeatEnabled}
value={intervalSec} number={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value))} onNumberChange={setIntervalSec}
/> numberLabel="sec"
</Field> numberHint={help.intervalSec}
<ToggleField label="Wake on assignment" checked={wakeOnAssignment} onChange={setWakeOnAssignment} /> showNumber={heartbeatEnabled}
<ToggleField label="Wake on on-demand" checked={wakeOnOnDemand} onChange={setWakeOnOnDemand} /> />
<ToggleField label="Wake on automation" checked={wakeOnAutomation} onChange={setWakeOnAutomation} />
<Field label="Cooldown (seconds)">
<input
type="number"
className="w-full bg-transparent outline-none text-sm font-mono"
value={cooldownSec}
onChange={(e) => setCooldownSec(Number(e.target.value))}
/>
</Field>
</div>
</CollapsibleSection>
{/* Runtime Section */}
<CollapsibleSection
title="Runtime"
open={runtimeOpen}
onToggle={() => setRuntimeOpen(!runtimeOpen)}
>
<div className="space-y-3">
<Field label="Context mode">
<div className="flex gap-2">
{(["thin", "fat"] as const).map((m) => (
<button
key={m}
className={cn(
"px-2 py-1 text-xs rounded border",
m === contextMode
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => setContextMode(m)}
>
{m}
</button>
))}
</div>
</Field>
<Field label="Monthly budget (cents)">
<input
type="number"
className="w-full bg-transparent outline-none text-sm font-mono"
value={budgetMonthlyCents}
onChange={(e) => setBudgetMonthlyCents(Number(e.target.value))}
/>
</Field>
<Field label="Timeout (seconds)">
<input
type="number"
className="w-full bg-transparent outline-none text-sm font-mono"
value={timeoutSec}
onChange={(e) => setTimeoutSec(Number(e.target.value))}
/>
</Field>
<Field label="Grace period (seconds)">
<input
type="number"
className="w-full bg-transparent outline-none text-sm font-mono"
value={graceSec}
onChange={(e) => setGraceSec(Number(e.target.value))}
/>
</Field>
</div> </div>
</CollapsibleSection> </CollapsibleSection>
</div> </div>
@@ -599,35 +621,30 @@ export function NewAgentDialog() {
); );
} }
function CollapsibleSection({ /* ---- Reusable components ---- */
title,
open, function HintIcon({ text }: { text: string }) {
onToggle,
children,
}: {
title: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return ( return (
<div className="border-t border-border"> <Tooltip>
<button <TooltipTrigger asChild>
className="flex items-center gap-2 w-full px-4 py-2 text-xs font-medium text-muted-foreground hover:bg-accent/30 transition-colors" <button type="button" className="inline-flex text-muted-foreground/50 hover:text-muted-foreground transition-colors">
onClick={onToggle} <HelpCircle className="h-3 w-3" />
> </button>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} </TooltipTrigger>
{title} <TooltipContent side="top" className="max-w-xs">
</button> {text}
{open && <div className="px-4 pb-3">{children}</div>} </TooltipContent>
</div> </Tooltip>
); );
} }
function Field({ label, children }: { label: string; children: React.ReactNode }) { function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return ( return (
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{label}</label> <div className="flex items-center gap-1.5 mb-1">
<label className="text-xs text-muted-foreground">{label}</label>
{hint && <HintIcon text={hint} />}
</div>
{children} {children}
</div> </div>
); );
@@ -635,16 +652,21 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
function ToggleField({ function ToggleField({
label, label,
hint,
checked, checked,
onChange, onChange,
}: { }: {
label: string; label: string;
hint?: string;
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
}) { }) {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{label}</span> <div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />}
</div>
<button <button
className={cn( className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors", "relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
@@ -662,3 +684,130 @@ function ToggleField({
</div> </div>
); );
} }
function ToggleWithNumber({
label,
hint,
checked,
onCheckedChange,
number,
onNumberChange,
numberLabel,
numberHint,
showNumber,
}: {
label: string;
hint?: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
number: number;
onNumberChange: (v: number) => void;
numberLabel: string;
numberHint?: string;
showNumber: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />}
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
checked ? "bg-green-600" : "bg-muted"
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
{showNumber && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Run heartbeat every</span>
<input
type="number"
className="w-16 rounded-md border border-border px-2 py-0.5 bg-transparent outline-none text-xs font-mono text-center"
value={number}
onChange={(e) => onNumberChange(Number(e.target.value))}
/>
<span>{numberLabel}</span>
{numberHint && <HintIcon text={numberHint} />}
</div>
)}
</div>
);
}
function CollapsibleSection({
title,
icon,
open,
onToggle,
bordered,
children,
}: {
title: string;
icon?: React.ReactNode;
open: boolean;
onToggle: () => void;
bordered?: boolean;
children: React.ReactNode;
}) {
return (
<div className={cn(bordered && "border-t border-border")}>
<button
className="flex items-center gap-2 w-full px-4 py-2 text-xs font-medium text-muted-foreground hover:bg-accent/30 transition-colors"
onClick={onToggle}
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{icon}
{title}
</button>
{open && <div className="px-4 pb-3">{children}</div>}
</div>
);
}
function AutoExpandTextarea({
value,
onChange,
placeholder,
minRows,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
minRows?: number;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const rows = minRows ?? 3;
const lineHeight = 20; // approx line height in px for text-sm mono
const minHeight = rows * lineHeight;
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
}, [minHeight]);
useEffect(() => { adjustHeight(); }, [value, adjustHeight]);
return (
<textarea
ref={textareaRef}
className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none overflow-hidden"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ minHeight }}
/>
);
}

View File

@@ -0,0 +1,616 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
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, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
import {
Building2,
Bot,
ListTodo,
Rocket,
ArrowLeft,
ArrowRight,
Terminal,
Globe,
Sparkles,
Check,
Loader2,
} from "lucide-react";
type Step = 1 | 2 | 3 | 4;
type AdapterType = "claude_local" | "process" | "http";
export function OnboardingWizard() {
const { onboardingOpen, closeOnboarding } = useDialog();
const { setSelectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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("");
// Step 3
const [taskTitle, setTaskTitle] = useState("");
const [taskDescription, setTaskDescription] = useState("");
// Created entity IDs
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(null);
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
function reset() {
setStep(1);
setLoading(false);
setError(null);
setCompanyName("");
setCompanyGoal("");
setAgentName("CEO");
setAdapterType("claude_local");
setCwd("");
setModel("");
setCommand("");
setArgs("");
setUrl("");
setTaskTitle("");
setTaskDescription("");
setCreatedCompanyId(null);
setCreatedAgentId(null);
}
function buildAdapterConfig(): Record<string, unknown> {
if (adapterType === "claude_local") {
return {
...(cwd ? { cwd } : {}),
...(model ? { model } : {}),
timeoutSec: 900,
graceSec: 15,
maxTurnsPerRun: 80,
dangerouslySkipPermissions: true,
};
}
if (adapterType === "process") {
return {
...(command ? { command } : {}),
args: args
.split(",")
.map((s) => s.trim())
.filter(Boolean),
timeoutSec: 900,
graceSec: 15,
};
}
// http
return {
...(url ? { url } : {}),
method: "POST",
timeoutMs: 15000,
};
}
async function handleStep1Next() {
setLoading(true);
setError(null);
try {
const company = await companiesApi.create({ name: companyName.trim() });
setCreatedCompanyId(company.id);
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 {
const agent = await agentsApi.create(createdCompanyId, {
name: agentName.trim(),
role: "ceo",
adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: {
heartbeat: {
enabled: true,
intervalSec: 300,
wakeOnAssignment: true,
wakeOnOnDemand: true,
wakeOnAutomation: true,
cooldownSec: 10,
},
},
});
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 handleStep3Next() {
if (!createdCompanyId || !createdAgentId) return;
setLoading(true);
setError(null);
try {
await issuesApi.create(createdCompanyId, {
title: taskTitle.trim(),
...(taskDescription.trim() ? { description: taskDescription.trim() } : {}),
assigneeAgentId: createdAgentId,
status: "todo",
});
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);
try {
await agentsApi.invoke(createdAgentId);
} catch {
// Agent may already be running from auto-wake — that's fine
}
setLoading(false);
reset();
closeOnboarding();
navigate(`/agents/${createdAgentId}`);
}
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();
}
}
const stepIcons = [Building2, Bot, ListTodo, Rocket];
return (
<Dialog
open={onboardingOpen}
onOpenChange={(open) => {
if (!open) {
reset();
closeOnboarding();
}
}}
>
<DialogContent
showCloseButton={false}
className="p-0 gap-0 overflow-hidden sm:max-w-lg"
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-2 text-sm">
<Sparkles className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Get Started</span>
<span className="text-muted-foreground/60">
Step {step} of 4
</span>
</div>
<div className="flex items-center gap-1.5">
{[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>
{/* Content */}
<div className="overflow-y-auto max-h-[60vh]">
{step === 1 && (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="rounded-lg 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="p-4 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="rounded-lg 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-3 gap-2">
{([
{
value: "claude_local" as const,
label: "Claude Code",
icon: Sparkles,
desc: "Local Claude agent",
},
{
value: "process" as const,
label: "Shell Command",
icon: Terminal,
desc: "Run a process",
},
{
value: "http" as const,
label: "HTTP Webhook",
icon: Globe,
desc: "Call an endpoint",
},
] as const).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",
adapterType === opt.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => setAdapterType(opt.value)}
>
<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>
</div>
{/* Conditional adapter fields */}
{adapterType === "claude_local" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Working directory
</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="/path/to/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Model
</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="claude-sonnet-4-5-20250929"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</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" && (
<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="p-4 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="rounded-lg 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
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-[80px]"
placeholder="Add more detail about what the agent should do..."
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
/>
</div>
</div>
)}
{step === 4 && (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="rounded-lg 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. Launch your agent and watch it work.
</p>
</div>
</div>
<div className="rounded-md 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">
{adapterType === "claude_local"
? "Claude Code"
: adapterType === "process"
? "Shell Command"
: "HTTP Webhook"}
</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>
)}
</div>
{/* Error */}
{error && (
<div className="px-4 pb-2">
<p className="text-xs text-destructive">{error}</p>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
<div>
{step > 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">
<span className="text-xs text-muted-foreground">
{step < 4 && "Cmd+Enter to continue"}
</span>
{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}
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" />
) : (
<Rocket className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Launching..." : "Launch Agent"}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -17,6 +17,9 @@ interface DialogContextValue {
newAgentOpen: boolean; newAgentOpen: boolean;
openNewAgent: () => void; openNewAgent: () => void;
closeNewAgent: () => void; closeNewAgent: () => void;
onboardingOpen: boolean;
openOnboarding: () => void;
closeOnboarding: () => void;
} }
const DialogContext = createContext<DialogContextValue | null>(null); const DialogContext = createContext<DialogContextValue | null>(null);
@@ -26,6 +29,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
const [newIssueDefaults, setNewIssueDefaults] = useState<NewIssueDefaults>({}); const [newIssueDefaults, setNewIssueDefaults] = useState<NewIssueDefaults>({});
const [newProjectOpen, setNewProjectOpen] = useState(false); const [newProjectOpen, setNewProjectOpen] = useState(false);
const [newAgentOpen, setNewAgentOpen] = useState(false); const [newAgentOpen, setNewAgentOpen] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
const openNewIssue = useCallback((defaults: NewIssueDefaults = {}) => { const openNewIssue = useCallback((defaults: NewIssueDefaults = {}) => {
setNewIssueDefaults(defaults); setNewIssueDefaults(defaults);
@@ -53,6 +57,14 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setNewAgentOpen(false); setNewAgentOpen(false);
}, []); }, []);
const openOnboarding = useCallback(() => {
setOnboardingOpen(true);
}, []);
const closeOnboarding = useCallback(() => {
setOnboardingOpen(false);
}, []);
return ( return (
<DialogContext.Provider <DialogContext.Provider
value={{ value={{
@@ -66,6 +78,9 @@ export function DialogProvider({ children }: { children: ReactNode }) {
newAgentOpen, newAgentOpen,
openNewAgent, openNewAgent,
closeNewAgent, closeNewAgent,
onboardingOpen,
openOnboarding,
closeOnboarding,
}} }}
> >
{children} {children}

View File

@@ -118,7 +118,7 @@ export function Agents() {
{agents && agents.length === 0 && ( {agents && agents.length === 0 && (
<EmptyState <EmptyState
icon={Bot} icon={Bot}
message="No agents yet." message="Create your first agent to get started."
action="New Agent" action="New Agent"
onAction={openNewAgent} onAction={openNewAgent}
/> />

View File

@@ -6,6 +6,7 @@ import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { MetricCard } from "../components/MetricCard"; import { MetricCard } from "../components/MetricCard";
@@ -54,7 +55,8 @@ function getStaleIssues(issues: Issue[]): Issue[] {
} }
export function Dashboard() { export function Dashboard() {
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId, selectedCompany, companies } = useCompany();
const { openOnboarding } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -94,6 +96,16 @@ export function Dashboard() {
}; };
if (!selectedCompanyId) { if (!selectedCompanyId) {
if (companies.length === 0) {
return (
<EmptyState
icon={LayoutDashboard}
message="Welcome to Paperclip. Set up your first company and agent to get started."
action="Get Started"
onAction={openOnboarding}
/>
);
}
return ( return (
<EmptyState icon={LayoutDashboard} message="Create or select a company to view the dashboard." /> <EmptyState icon={LayoutDashboard} message="Create or select a company to view the dashboard." />
); );