diff --git a/docs/guides/board-operator/org-structure.md b/docs/guides/board-operator/org-structure.md index b074d312c..43e36b614 100644 --- a/docs/guides/board-operator/org-structure.md +++ b/docs/guides/board-operator/org-structure.md @@ -9,6 +9,7 @@ Paperclip enforces a strict organizational hierarchy. Every agent reports to exa - The **CEO** has no manager (reports to the board/human operator) - Every other agent has a `reportsTo` field pointing to their manager +- You can change an agent’s manager after creation from **Agent → Configuration → Reports to** (or via `PATCH /api/agents/{id}` with `reportsTo`) - Managers can create subtasks and delegate to their reports - Agents escalate blockers up the chain of command diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 0b515dca4..1810e9a84 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -44,6 +44,7 @@ import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-field import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; +import { ReportsToPicker } from "./ReportsToPicker"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; /* ---- Create mode values ---- */ @@ -315,6 +316,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); const models = fetchedModels ?? externalModels ?? []; + const { data: companyAgents = [] } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: Boolean(!isCreate && selectedCompanyId), + }); + /** Props passed to adapter-specific config field components */ const adapterFieldProps = { mode, @@ -462,6 +469,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) { placeholder="e.g. VP of Engineering" /> + + mark("identity", "reportsTo", id)} + excludeAgentIds={[props.agent.id]} + chooseLabel="Choose manager…" + /> + void; + disabled?: boolean; + excludeAgentIds?: string[]; + disabledEmptyLabel?: string; + chooseLabel?: string; +}) { + const [open, setOpen] = useState(false); + const exclude = new Set(excludeAgentIds); + const rows = agents.filter( + (a) => a.status !== "terminated" && !exclude.has(a.id), + ); + const current = value ? agents.find((a) => a.id === value) : null; + const terminatedManager = current?.status === "terminated"; + const unknownManager = Boolean(value && !current); + + return ( + + + + {unknownManager ? ( + <> + + Unknown manager (stale ID) + > + ) : current ? ( + <> + + + {`Reports to ${current.name}${terminatedManager ? " (terminated)" : ""}`} + + > + ) : ( + <> + + + {disabled ? disabledEmptyLabel : chooseLabel} + + > + )} + + + + { + onChange(null); + setOpen(false); + }} + > + No manager + + {terminatedManager && ( + + + + Current: {current.name} (terminated) + + + )} + {unknownManager && ( + + Saved manager is missing from this company. Choose a new manager or clear. + + )} + {rows.map((a) => ( + { + onChange(a.id); + setOpen(false); + }} + > + + {a.name} + {roleLabels[a.role] ?? a.role} + + ))} + + + ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 0a933f8e0..2c5297d0c 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -18,6 +18,7 @@ import { issuesApi } from "../api/issues"; import { usePanel } from "../context/PanelContext"; import { useSidebar } from "../context/SidebarContext"; import { useCompany } from "../context/CompanyContext"; +import { useToast } from "../context/ToastContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; @@ -1420,6 +1421,7 @@ function ConfigurationTab({ hideInstructionsFile?: boolean; }) { const queryClient = useQueryClient(); + const { pushToast } = useToast(); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); const lastAgentRef = useRef(agent); @@ -1441,9 +1443,17 @@ function ConfigurationTab({ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) }); }, - onError: () => { + onError: (err) => { setAwaitingRefreshAfterSave(false); + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Could not save agent"; + pushToast({ title: "Save failed", body: message, tone: "error" }); }, }); diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index 697bf6492..b8787be2f 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -14,13 +14,13 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { Shield, User } from "lucide-react"; +import { Shield } 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"; +import { ReportsToPicker } from "../components/ReportsToPicker"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, @@ -68,11 +68,10 @@ export function NewAgent() { const [name, setName] = useState(""); const [title, setTitle] = useState(""); const [role, setRole] = useState("general"); - const [reportsTo, setReportsTo] = useState(""); + const [reportsTo, setReportsTo] = useState(null); const [configValues, setConfigValues] = useState(defaultCreateValues); const [selectedSkillKeys, setSelectedSkillKeys] = useState([]); const [roleOpen, setRoleOpen] = useState(false); - const [reportsToOpen, setReportsToOpen] = useState(false); const [formError, setFormError] = useState(null); const { data: agents } = useQuery({ @@ -199,7 +198,6 @@ export function NewAgent() { }); } - const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); const availableSkills = (companySkills ?? []).filter((skill) => !skill.key.startsWith("paperclipai/paperclip/")); function toggleSkill(key: string, checked: boolean) { @@ -273,54 +271,12 @@ export function NewAgent() { - - - - {currentReportsTo ? ( - <> - - {`Reports to ${currentReportsTo.name}`} - > - ) : ( - <> - - {isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."} - > - )} - - - - { setReportsTo(""); setReportsToOpen(false); }} - > - No manager - - {(agents ?? []).map((a) => ( - { setReportsTo(a.id); setReportsToOpen(false); }} - > - - {a.name} - {roleLabels[a.role] ?? a.role} - - ))} - - + {/* Shared config form */}