diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d87bcb9e..b0a3f9b7 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 = { + 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); diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index b79f2b0e..5a348a33 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -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(`/agents/${id}/keys`, { name }), runtimeState: (id: string) => api.get(`/agents/${id}/runtime-state`), resetSession: (id: string) => api.post(`/agents/${id}/runtime-state/reset-session`, {}), + adapterModels: (type: string) => api.get(`/adapters/${type}/models`), invoke: (id: string) => api.post(`/agents/${id}/heartbeat/invoke`, {}), wakeup: ( id: string, diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 39934abe..fbaafb45 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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() { + ); } diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index f05c5175..2ab3d21d 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -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 = { - 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 = { @@ -48,34 +47,53 @@ const adapterLabels: Record = { http: "HTTP", }; +/* ---- Help text for (?) tooltips ---- */ +const help: Record = { + 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("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) => 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 ( { - if (!open) { - reset(); - closeNewAgent(); - } + if (!open) { reset(); closeNewAgent(); } }} > New agent
- -
-
+
{/* Name */}
- {/* Property chips */} + {/* Property chips: Role + Reports To */}
{/* Role */} @@ -331,7 +333,12 @@ export function NewAgentDialog() { disabled={isFirstAgent} > - {currentAgent ? currentAgent.name : isFirstAgent ? "N/A (CEO)" : "Reports to"} + {currentReportsTo + ? `Reports to ${currentReportsTo.name}` + : isFirstAgent + ? "Reports to: N/A (CEO)" + : "Reports to..." + } @@ -359,124 +366,126 @@ export function NewAgentDialog() { ))} +
- {/* Adapter type */} - - - - - - {AGENT_ADAPTER_TYPES.map((t) => ( - - ))} - - + + + {AGENT_ADAPTER_TYPES.map((t) => ( + + ))} + + +
- {/* Capabilities */} -
- setCapabilities(e.target.value)} - /> -
- - {/* Adapter Config Section */} - setAdapterOpen(!adapterOpen)} - > -
- - setCwd(e.target.value)} - /> - - -