diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 02baefb6..18df83d8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,6 +24,7 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; import { OrgChart } from "./pages/OrgChart"; +import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; @@ -101,6 +102,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -214,6 +216,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index a5392716..b3ab9233 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,53 +1,20 @@ -import { useState, useEffect } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { agentsApi } from "../api/agents"; import { queryKeys } from "../lib/queryKeys"; -import { AGENT_ROLES } from "@paperclipai/shared"; import { Dialog, DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Minimize2, - Maximize2, - Shield, - User, -} from "lucide-react"; -import { cn, agentUrl } from "../lib/utils"; -import { roleLabels } from "./agent-config-primitives"; -import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm"; -import { defaultCreateValues } from "./agent-config-defaults"; -import { getUIAdapter } from "../adapters"; -import { AgentIcon } from "./AgentIconPicker"; +import { Bot, Sparkles } from "lucide-react"; export function NewAgentDialog() { - const { newAgentOpen, closeNewAgent } = useDialog(); - const { selectedCompanyId, selectedCompany } = useCompany(); - const queryClient = useQueryClient(); + const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); + const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); - const [expanded, setExpanded] = useState(true); - - // Identity - const [name, setName] = useState(""); - const [title, setTitle] = useState(""); - const [role, setRole] = useState("general"); - const [reportsTo, setReportsTo] = useState(""); - - // Config values (managed by AgentConfigForm) - const [configValues, setConfigValues] = useState(defaultCreateValues); - - // Popover states - const [roleOpen, setRoleOpen] = useState(false); - const [reportsToOpen, setReportsToOpen] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -55,287 +22,74 @@ export function NewAgentDialog() { enabled: !!selectedCompanyId && newAgentOpen, }); - const { - data: adapterModels, - error: adapterModelsError, - isLoading: adapterModelsLoading, - isFetching: adapterModelsFetching, - } = useQuery({ - queryKey: - selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) - : ["agents", "none", "adapter-models", configValues.adapterType], - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), - enabled: Boolean(selectedCompanyId) && newAgentOpen, - }); + const ceoAgent = (agents ?? []).find((a) => a.role === "ceo"); - const isFirstAgent = !agents || agents.length === 0; - const effectiveRole = isFirstAgent ? "ceo" : role; - const [formError, setFormError] = useState(null); - - // 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.hire(selectedCompanyId!, data), - onSuccess: (result) => { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); - reset(); - closeNewAgent(); - navigate(agentUrl(result.agent)); - }, - onError: (error) => { - setFormError(error instanceof Error ? error.message : "Failed to create agent"); - }, - }); - - function reset() { - setName(""); - setTitle(""); - setRole("general"); - setReportsTo(""); - setConfigValues(defaultCreateValues); - setExpanded(true); - setFormError(null); - } - - function buildAdapterConfig() { - const adapter = getUIAdapter(configValues.adapterType); - return adapter.buildAdapterConfig(configValues); - } - - function handleSubmit() { - if (!selectedCompanyId || !name.trim()) return; - setFormError(null); - if (configValues.adapterType === "opencode_local") { - const selectedModel = configValues.model.trim(); - if (!selectedModel) { - setFormError("OpenCode requires an explicit model in provider/model format."); - return; - } - if (adapterModelsError) { - setFormError( - adapterModelsError instanceof Error - ? adapterModelsError.message - : "Failed to load OpenCode models.", - ); - return; - } - if (adapterModelsLoading || adapterModelsFetching) { - setFormError("OpenCode models are still loading. Please wait and try again."); - return; - } - const discovered = adapterModels ?? []; - if (!discovered.some((entry) => entry.id === selectedModel)) { - setFormError( - discovered.length === 0 - ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." - : `Configured OpenCode model is unavailable: ${selectedModel}`, - ); - return; - } - } - createAgent.mutate({ - name: name.trim(), - role: effectiveRole, - ...(title.trim() ? { title: title.trim() } : {}), - ...(reportsTo ? { reportsTo } : {}), - adapterType: configValues.adapterType, - adapterConfig: buildAdapterConfig(), - runtimeConfig: { - heartbeat: { - enabled: configValues.heartbeatEnabled, - intervalSec: configValues.intervalSec, - wakeOnDemand: true, - cooldownSec: 10, - maxConcurrentRuns: 1, - }, - }, - budgetMonthlyCents: 0, + function handleAskCeo() { + closeNewAgent(); + openNewIssue({ + assigneeAgentId: ceoAgent?.id, + title: "Create a new agent", + description: "(type in what kind of agent you want here)", }); } - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(); - } + function handleAdvancedConfig() { + closeNewAgent(); + navigate("/agents/new"); } - const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); - return ( { - if (!open) { reset(); closeNewAgent(); } + if (!open) closeNewAgent(); }} > {/* Header */}
-
- {selectedCompany && ( - - {selectedCompany.name.slice(0, 3).toUpperCase()} - - )} - - New agent -
-
- - -
-
- -
- {/* Name */} -
- setName(e.target.value)} - autoFocus - /> -
- - {/* Title */} -
- setTitle(e.target.value)} - /> -
- - {/* Property chips: Role + Reports To */} -
- {/* Role */} - - - - - - {AGENT_ROLES.map((r) => ( - - ))} - - - - {/* Reports To */} - - - - - - - {(agents ?? []).map((a) => ( - - ))} - - -
- - {/* Shared config form (adapter + heartbeat) */} - setConfigValues((prev) => ({ ...prev, ...patch }))} - adapterModels={adapterModels} - /> -
- - {/* Footer */} -
- - {isFirstAgent ? "This will be the CEO" : ""} - -
- {formError && ( -
{formError}
- )} -
+ Add a new agent
+ +
+ {/* Recommendation */} +
+
+ +
+

+ We recommend letting your CEO handle agent setup — they know the + org structure and can configure reporting, permissions, and + adapters. +

+
+ + + + {/* Advanced link */} +
+ +
+
); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 35fab02e..de0ea73f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -332,7 +332,18 @@ export function NewIssueDialog() { setDialogCompanyId(selectedCompanyId); const draft = loadDraft(); - if (draft && draft.title.trim()) { + if (newIssueDefaults.title) { + setTitle(newIssueDefaults.title); + setDescription(newIssueDefaults.description ?? ""); + setStatus(newIssueDefaults.status ?? "todo"); + setPriority(newIssueDefaults.priority ?? ""); + setProjectId(newIssueDefaults.projectId ?? ""); + setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeModelOverride(""); + setAssigneeThinkingEffort(""); + setAssigneeChrome(false); + setAssigneeUseProjectWorkspace(true); + } else if (draft && draft.title.trim()) { setTitle(draft.title); setDescription(draft.description); setStatus(draft.status || "todo"); diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index b21e6b8a..ef7b12b8 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,8 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + title?: string; + description?: string; } interface NewGoalDefaults { diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx new file mode 100644 index 00000000..a583ece2 --- /dev/null +++ b/ui/src/pages/NewAgent.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@/lib/router"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { agentsApi } from "../api/agents"; +import { queryKeys } from "../lib/queryKeys"; +import { AGENT_ROLES } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Shield, User } from "lucide-react"; +import { cn, agentUrl } from "../lib/utils"; +import { roleLabels } from "../components/agent-config-primitives"; +import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm"; +import { defaultCreateValues } from "../components/agent-config-defaults"; +import { getUIAdapter } from "../adapters"; +import { AgentIcon } from "../components/AgentIconPicker"; + +export function NewAgent() { + const { selectedCompanyId, selectedCompany } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const [name, setName] = useState(""); + const [title, setTitle] = useState(""); + const [role, setRole] = useState("general"); + const [reportsTo, setReportsTo] = useState(""); + const [configValues, setConfigValues] = useState(defaultCreateValues); + const [roleOpen, setRoleOpen] = useState(false); + const [reportsToOpen, setReportsToOpen] = useState(false); + const [formError, setFormError] = useState(null); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { + data: adapterModels, + error: adapterModelsError, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) + : ["agents", "none", "adapter-models", configValues.adapterType], + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), + enabled: Boolean(selectedCompanyId), + }); + + const isFirstAgent = !agents || agents.length === 0; + const effectiveRole = isFirstAgent ? "ceo" : role; + + useEffect(() => { + setBreadcrumbs([ + { label: "Agents", href: "/agents" }, + { label: "New Agent" }, + ]); + }, [setBreadcrumbs]); + + useEffect(() => { + if (isFirstAgent) { + if (!name) setName("CEO"); + if (!title) setTitle("CEO"); + } + }, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps + + const createAgent = useMutation({ + mutationFn: (data: Record) => + agentsApi.hire(selectedCompanyId!, data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); + navigate(agentUrl(result.agent)); + }, + onError: (error) => { + setFormError(error instanceof Error ? error.message : "Failed to create agent"); + }, + }); + + function buildAdapterConfig() { + const adapter = getUIAdapter(configValues.adapterType); + return adapter.buildAdapterConfig(configValues); + } + + function handleSubmit() { + if (!selectedCompanyId || !name.trim()) return; + setFormError(null); + if (configValues.adapterType === "opencode_local") { + const selectedModel = configValues.model.trim(); + if (!selectedModel) { + setFormError("OpenCode requires an explicit model in provider/model format."); + return; + } + if (adapterModelsError) { + setFormError( + adapterModelsError instanceof Error + ? adapterModelsError.message + : "Failed to load OpenCode models.", + ); + return; + } + if (adapterModelsLoading || adapterModelsFetching) { + setFormError("OpenCode models are still loading. Please wait and try again."); + return; + } + const discovered = adapterModels ?? []; + if (!discovered.some((entry) => entry.id === selectedModel)) { + setFormError( + discovered.length === 0 + ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." + : `Configured OpenCode model is unavailable: ${selectedModel}`, + ); + return; + } + } + createAgent.mutate({ + name: name.trim(), + role: effectiveRole, + ...(title.trim() ? { title: title.trim() } : {}), + ...(reportsTo ? { reportsTo } : {}), + adapterType: configValues.adapterType, + adapterConfig: buildAdapterConfig(), + runtimeConfig: { + heartbeat: { + enabled: configValues.heartbeatEnabled, + intervalSec: configValues.intervalSec, + wakeOnDemand: true, + cooldownSec: 10, + maxConcurrentRuns: 1, + }, + }, + budgetMonthlyCents: 0, + }); + } + + const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); + + return ( +
+
+

New Agent

+

+ Advanced agent configuration +

+
+ +
+ {/* Name */} +
+ setName(e.target.value)} + autoFocus + /> +
+ + {/* Title */} +
+ setTitle(e.target.value)} + /> +
+ + {/* Property chips: Role + Reports To */} +
+ + + + + + {AGENT_ROLES.map((r) => ( + + ))} + + + + + + + + + + {(agents ?? []).map((a) => ( + + ))} + + +
+ + {/* Shared config form */} + setConfigValues((prev) => ({ ...prev, ...patch }))} + adapterModels={adapterModels} + /> + + {/* Footer */} +
+ {isFirstAgent && ( +

This will be the CEO

+ )} + {formError && ( +

{formError}

+ )} +
+ + +
+
+
+
+ ); +}