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:
@@ -15,6 +15,28 @@ export function agentRoutes(db: Db) {
|
||||
const svc = agentService(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) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface AdapterModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface OrgNode {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -22,6 +27,7 @@ export const agentsApi = {
|
||||
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
|
||||
runtimeState: (id: string) => api.get<AgentRuntimeState>(`/agents/${id}/runtime-state`),
|
||||
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`, {}),
|
||||
wakeup: (
|
||||
id: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
@@ -7,15 +7,27 @@ import { CommandPalette } from "./CommandPalette";
|
||||
import { NewIssueDialog } from "./NewIssueDialog";
|
||||
import { NewProjectDialog } from "./NewProjectDialog";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
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 togglePanel = useCallback(() => {
|
||||
@@ -51,6 +63,7 @@ export function Layout() {
|
||||
<NewIssueDialog />
|
||||
<NewProjectDialog />
|
||||
<NewAgentDialog />
|
||||
<OnboardingWizard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
@@ -17,28 +17,27 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Maximize2,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Minimize2,
|
||||
Bot,
|
||||
User,
|
||||
Maximize2,
|
||||
Shield,
|
||||
User,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
ceo: "CEO",
|
||||
cto: "CTO",
|
||||
cmo: "CMO",
|
||||
cfo: "CFO",
|
||||
engineer: "Engineer",
|
||||
designer: "Designer",
|
||||
pm: "PM",
|
||||
qa: "QA",
|
||||
devops: "DevOps",
|
||||
researcher: "Researcher",
|
||||
general: "General",
|
||||
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO",
|
||||
engineer: "Engineer", designer: "Designer", pm: "PM",
|
||||
qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General",
|
||||
};
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
@@ -48,34 +47,53 @@ const adapterLabels: Record<string, string> = {
|
||||
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() {
|
||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// Identity
|
||||
const [name, setName] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [role, setRole] = useState("general");
|
||||
const [reportsTo, setReportsTo] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
|
||||
// Adapter
|
||||
const [adapterType, setAdapterType] = useState<string>("claude_local");
|
||||
const [cwd, setCwd] = useState("");
|
||||
const [promptTemplate, setPromptTemplate] = useState("");
|
||||
const [bootstrapPrompt, setBootstrapPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
|
||||
// claude_local specific
|
||||
const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80);
|
||||
const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(true);
|
||||
const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(false);
|
||||
|
||||
// codex_local specific
|
||||
const [search, setSearch] = useState(false);
|
||||
const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(true);
|
||||
const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(false);
|
||||
|
||||
// process specific
|
||||
const [command, setCommand] = useState("");
|
||||
@@ -84,29 +102,22 @@ export function NewAgentDialog() {
|
||||
// http specific
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
// Heartbeat
|
||||
const [heartbeatEnabled, setHeartbeatEnabled] = useState(true);
|
||||
const [intervalSec, setIntervalSec] = useState(300);
|
||||
const [wakeOnAssignment, setWakeOnAssignment] = useState(true);
|
||||
const [wakeOnOnDemand, setWakeOnOnDemand] = useState(true);
|
||||
const [wakeOnAutomation, setWakeOnAutomation] = useState(true);
|
||||
const [cooldownSec, setCooldownSec] = useState(10);
|
||||
// Advanced adapter fields
|
||||
const [bootstrapPrompt, setBootstrapPrompt] = useState("");
|
||||
const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80);
|
||||
|
||||
// Runtime
|
||||
const [contextMode, setContextMode] = useState("thin");
|
||||
const [budgetMonthlyCents, setBudgetMonthlyCents] = useState(0);
|
||||
const [timeoutSec, setTimeoutSec] = useState(900);
|
||||
const [graceSec, setGraceSec] = useState(15);
|
||||
// Heartbeat
|
||||
const [heartbeatEnabled, setHeartbeatEnabled] = useState(false);
|
||||
const [intervalSec, setIntervalSec] = useState(300);
|
||||
|
||||
// Sections
|
||||
const [adapterOpen, setAdapterOpen] = useState(true);
|
||||
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
|
||||
const [heartbeatOpen, setHeartbeatOpen] = useState(false);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
|
||||
// Popover states
|
||||
const [roleOpen, setRoleOpen] = useState(false);
|
||||
const [reportsToOpen, setReportsToOpen] = useState(false);
|
||||
const [adapterTypeOpen, setAdapterTypeOpen] = useState(false);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -114,9 +125,23 @@ export function NewAgentDialog() {
|
||||
enabled: !!selectedCompanyId && newAgentOpen,
|
||||
});
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
||||
enabled: newAgentOpen,
|
||||
});
|
||||
|
||||
const isFirstAgent = !agents || agents.length === 0;
|
||||
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({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
agentsApi.create(selectedCompanyId!, data),
|
||||
@@ -133,33 +158,23 @@ export function NewAgentDialog() {
|
||||
setTitle("");
|
||||
setRole("general");
|
||||
setReportsTo("");
|
||||
setCapabilities("");
|
||||
setAdapterType("claude_local");
|
||||
setCwd("");
|
||||
setPromptTemplate("");
|
||||
setBootstrapPrompt("");
|
||||
setModel("");
|
||||
setMaxTurnsPerRun(80);
|
||||
setDangerouslySkipPermissions(true);
|
||||
setDangerouslySkipPermissions(false);
|
||||
setSearch(false);
|
||||
setDangerouslyBypassSandbox(true);
|
||||
setDangerouslyBypassSandbox(false);
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
setUrl("");
|
||||
setHeartbeatEnabled(true);
|
||||
setBootstrapPrompt("");
|
||||
setMaxTurnsPerRun(80);
|
||||
setHeartbeatEnabled(false);
|
||||
setIntervalSec(300);
|
||||
setWakeOnAssignment(true);
|
||||
setWakeOnOnDemand(true);
|
||||
setWakeOnAutomation(true);
|
||||
setCooldownSec(10);
|
||||
setContextMode("thin");
|
||||
setBudgetMonthlyCents(0);
|
||||
setTimeoutSec(900);
|
||||
setGraceSec(15);
|
||||
setExpanded(false);
|
||||
setAdapterOpen(true);
|
||||
setExpanded(true);
|
||||
setAdapterAdvancedOpen(false);
|
||||
setHeartbeatOpen(false);
|
||||
setRuntimeOpen(false);
|
||||
}
|
||||
|
||||
function buildAdapterConfig() {
|
||||
@@ -168,8 +183,8 @@ export function NewAgentDialog() {
|
||||
if (promptTemplate) config.promptTemplate = promptTemplate;
|
||||
if (bootstrapPrompt) config.bootstrapPromptTemplate = bootstrapPrompt;
|
||||
if (model) config.model = model;
|
||||
config.timeoutSec = timeoutSec;
|
||||
config.graceSec = graceSec;
|
||||
config.timeoutSec = 0;
|
||||
config.graceSec = 15;
|
||||
|
||||
if (adapterType === "claude_local") {
|
||||
config.maxTurnsPerRun = maxTurnsPerRun;
|
||||
@@ -193,21 +208,20 @@ export function NewAgentDialog() {
|
||||
role: effectiveRole,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(reportsTo ? { reportsTo } : {}),
|
||||
...(capabilities.trim() ? { capabilities: capabilities.trim() } : {}),
|
||||
adapterType,
|
||||
adapterConfig: buildAdapterConfig(),
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: heartbeatEnabled,
|
||||
intervalSec,
|
||||
wakeOnAssignment,
|
||||
wakeOnOnDemand,
|
||||
wakeOnAutomation,
|
||||
cooldownSec,
|
||||
wakeOnAssignment: true,
|
||||
wakeOnOnDemand: true,
|
||||
wakeOnAutomation: true,
|
||||
cooldownSec: 10,
|
||||
},
|
||||
},
|
||||
contextMode,
|
||||
budgetMonthlyCents,
|
||||
contextMode: "thin",
|
||||
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 (
|
||||
<Dialog
|
||||
open={newAgentOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
closeNewAgent();
|
||||
}
|
||||
if (!open) { reset(); closeNewAgent(); }
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
@@ -247,26 +259,16 @@ export function NewAgentDialog() {
|
||||
<span>New agent</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Button 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" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => { reset(); closeNewAgent(); }}
|
||||
>
|
||||
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => { reset(); closeNewAgent(); }}>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("overflow-y-auto", expanded ? "max-h-[70vh]" : "max-h-[50vh]")}>
|
||||
<div className="overflow-y-auto max-h-[70vh]">
|
||||
{/* Name */}
|
||||
<div className="px-4 pt-3">
|
||||
<input
|
||||
@@ -288,7 +290,7 @@ export function NewAgentDialog() {
|
||||
/>
|
||||
</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">
|
||||
{/* Role */}
|
||||
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
|
||||
@@ -331,7 +333,12 @@ export function NewAgentDialog() {
|
||||
disabled={isFirstAgent}
|
||||
>
|
||||
<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>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="start">
|
||||
@@ -359,124 +366,126 @@ export function NewAgentDialog() {
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Adapter type */}
|
||||
<Popover open={adapterTypeOpen} onOpenChange={setAdapterTypeOpen}>
|
||||
{/* Adapter type dropdown (above config section) */}
|
||||
<div className="px-4 py-2.5 border-t border-border">
|
||||
<Field label="Adapter" hint={help.adapterType}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<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">
|
||||
<Bot className="h-3 w-3 text-muted-foreground" />
|
||||
{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">
|
||||
<span>{adapterLabels[adapterType] ?? adapterType}</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] 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",
|
||||
"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); setAdapterTypeOpen(false); }}
|
||||
onClick={() => setAdapterType(t)}
|
||||
>
|
||||
{adapterLabels[t] ?? t}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="px-4 py-2 border-t border-border">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
|
||||
placeholder="Capabilities (what can this agent do?)"
|
||||
value={capabilities}
|
||||
onChange={(e) => setCapabilities(e.target.value)}
|
||||
/>
|
||||
{/* Adapter Configuration (always open) */}
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
Adapter Configuration
|
||||
</div>
|
||||
|
||||
{/* Adapter Config Section */}
|
||||
<CollapsibleSection
|
||||
title="Adapter Configuration"
|
||||
open={adapterOpen}
|
||||
onToggle={() => setAdapterOpen(!adapterOpen)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Field label="Working directory">
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
{/* Working directory — basic, shown for local adapters */}
|
||||
{(adapterType === "claude_local" || adapterType === "codex_local") && (
|
||||
<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">
|
||||
<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/40"
|
||||
placeholder="/path/to/project"
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={async () => {
|
||||
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>
|
||||
<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
|
||||
type="number"
|
||||
className="w-full bg-transparent outline-none text-sm font-mono"
|
||||
value={maxTurnsPerRun}
|
||||
onChange={(e) => setMaxTurnsPerRun(Number(e.target.value))}
|
||||
{/* 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" && (
|
||||
<>
|
||||
<ToggleField label="Enable search" checked={search} onChange={setSearch} />
|
||||
<ToggleField
|
||||
label="Bypass sandbox"
|
||||
hint={help.dangerouslyBypassSandbox}
|
||||
checked={dangerouslyBypassSandbox}
|
||||
onChange={setDangerouslyBypassSandbox}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Enable search"
|
||||
hint={help.search}
|
||||
checked={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Process-specific fields */}
|
||||
{adapterType === "process" && (
|
||||
<>
|
||||
<Field label="Command">
|
||||
<Field label="Command" hint={help.command}>
|
||||
<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"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Args (comma-separated)">
|
||||
<Field label="Args (comma-separated)" hint={help.args}>
|
||||
<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"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
@@ -485,98 +494,111 @@ export function NewAgentDialog() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* HTTP-specific fields */}
|
||||
{adapterType === "http" && (
|
||||
<Field label="Webhook URL">
|
||||
<Field label="Webhook URL" hint={help.webhookUrl}>
|
||||
<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://..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Heartbeat Policy Section */}
|
||||
{/* Advanced section for local adapters */}
|
||||
{(adapterType === "claude_local" || adapterType === "codex_local") && (
|
||||
<CollapsibleSection
|
||||
title="Heartbeat Policy"
|
||||
open={heartbeatOpen}
|
||||
onToggle={() => setHeartbeatOpen(!heartbeatOpen)}
|
||||
title="Advanced Adapter Configuration"
|
||||
open={adapterAdvancedOpen}
|
||||
onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ToggleField label="Enabled" checked={heartbeatEnabled} onChange={setHeartbeatEnabled} />
|
||||
<Field label="Interval (seconds)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent outline-none text-sm font-mono"
|
||||
value={intervalSec}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
<ToggleField label="Wake on assignment" checked={wakeOnAssignment} onChange={setWakeOnAssignment} />
|
||||
<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) => (
|
||||
{/* 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
|
||||
key={m}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs rounded border",
|
||||
m === contextMode
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!model && "bg-accent"
|
||||
)}
|
||||
onClick={() => setContextMode(m)}
|
||||
onClick={() => { setModel(""); setModelOpen(false); }}
|
||||
>
|
||||
{m}
|
||||
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>
|
||||
</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))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Policy */}
|
||||
<CollapsibleSection
|
||||
title="Heartbeat Policy"
|
||||
icon={<Heart className="h-3 w-3" />}
|
||||
open={heartbeatOpen}
|
||||
onToggle={() => setHeartbeatOpen(!heartbeatOpen)}
|
||||
bordered
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ToggleWithNumber
|
||||
label="Heartbeat on interval"
|
||||
hint={help.heartbeatInterval}
|
||||
checked={heartbeatEnabled}
|
||||
onCheckedChange={setHeartbeatEnabled}
|
||||
number={intervalSec}
|
||||
onNumberChange={setIntervalSec}
|
||||
numberLabel="sec"
|
||||
numberHint={help.intervalSec}
|
||||
showNumber={heartbeatEnabled}
|
||||
/>
|
||||
</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>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
@@ -599,35 +621,30 @@ export function NewAgentDialog() {
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
/* ---- Reusable components ---- */
|
||||
|
||||
function HintIcon({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="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" />}
|
||||
{title}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="inline-flex text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
</button>
|
||||
{open && <div className="px-4 pb-3">{children}</div>}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
@@ -635,16 +652,21 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
|
||||
function ToggleField({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<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",
|
||||
@@ -662,3 +684,130 @@ function ToggleField({
|
||||
</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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
616
ui/src/components/OnboardingWizard.tsx
Normal file
616
ui/src/components/OnboardingWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,9 @@ interface DialogContextValue {
|
||||
newAgentOpen: boolean;
|
||||
openNewAgent: () => void;
|
||||
closeNewAgent: () => void;
|
||||
onboardingOpen: boolean;
|
||||
openOnboarding: () => void;
|
||||
closeOnboarding: () => void;
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
@@ -26,6 +29,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [newIssueDefaults, setNewIssueDefaults] = useState<NewIssueDefaults>({});
|
||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||
const [newAgentOpen, setNewAgentOpen] = useState(false);
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||
|
||||
const openNewIssue = useCallback((defaults: NewIssueDefaults = {}) => {
|
||||
setNewIssueDefaults(defaults);
|
||||
@@ -53,6 +57,14 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
setNewAgentOpen(false);
|
||||
}, []);
|
||||
|
||||
const openOnboarding = useCallback(() => {
|
||||
setOnboardingOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeOnboarding = useCallback(() => {
|
||||
setOnboardingOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider
|
||||
value={{
|
||||
@@ -66,6 +78,9 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
newAgentOpen,
|
||||
openNewAgent,
|
||||
closeNewAgent,
|
||||
onboardingOpen,
|
||||
openOnboarding,
|
||||
closeOnboarding,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -118,7 +118,7 @@ export function Agents() {
|
||||
{agents && agents.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Bot}
|
||||
message="No agents yet."
|
||||
message="Create your first agent to get started."
|
||||
action="New Agent"
|
||||
onAction={openNewAgent}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { activityApi } from "../api/activity";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
@@ -54,7 +55,8 @@ function getStaleIssues(issues: Issue[]): Issue[] {
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { selectedCompanyId, selectedCompany, companies } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -94,6 +96,16 @@ export function Dashboard() {
|
||||
};
|
||||
|
||||
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 (
|
||||
<EmptyState icon={LayoutDashboard} message="Create or select a company to view the dashboard." />
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user