From 61f53b6471e46297e3d8b7474b767b9b6ced4ce6 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Fri, 20 Mar 2026 20:06:19 +0000 Subject: [PATCH 1/4] feat: add ReportsToPicker for agent management - Introduced ReportsToPicker component in AgentConfigForm and NewAgent pages to allow selection of an agent's manager. - Updated organizational structure documentation to reflect the ability to change an agent's manager post-creation. - Enhanced error handling in ConfigurationTab to provide user feedback on save failures. --- docs/guides/board-operator/org-structure.md | 1 + ui/src/components/AgentConfigForm.tsx | 16 ++++ ui/src/components/ReportsToPicker.tsx | 96 +++++++++++++++++++++ ui/src/pages/AgentDetail.tsx | 12 ++- ui/src/pages/NewAgent.tsx | 65 +++----------- 5 files changed, 134 insertions(+), 56 deletions(-) create mode 100644 ui/src/components/ReportsToPicker.tsx diff --git a/docs/guides/board-operator/org-structure.md b/docs/guides/board-operator/org-structure.md index b074d312..43e36b61 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 69c31919..bbfe5414 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"; /* ---- Create mode values ---- */ @@ -301,6 +302,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, @@ -447,6 +454,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; + + return ( + + + + + + + {rows.map((a) => ( + + ))} + + + ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 3e238e5e..e922b79b 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -17,6 +17,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"; @@ -1356,6 +1357,7 @@ function ConfigurationTab({ updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); + const { pushToast } = useToast(); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); const lastAgentRef = useRef(agent); @@ -1377,9 +1379,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 364e35a0..e9488ba2 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -12,13 +12,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, @@ -66,10 +66,9 @@ 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 [roleOpen, setRoleOpen] = useState(false); - const [reportsToOpen, setReportsToOpen] = useState(false); const [formError, setFormError] = useState(null); const { data: agents } = useQuery({ @@ -173,7 +172,7 @@ export function NewAgent() { name: name.trim(), role: effectiveRole, ...(title.trim() ? { title: title.trim() } : {}), - ...(reportsTo ? { reportsTo } : {}), + ...(reportsTo != null ? { reportsTo } : {}), adapterType: configValues.adapterType, adapterConfig: buildAdapterConfig(), runtimeConfig: { @@ -189,8 +188,6 @@ export function NewAgent() { }); } - const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); - return (
@@ -253,54 +250,12 @@ export function NewAgent() { - - - - - - - {(agents ?? []).map((a) => ( - - ))} - - +
{/* Shared config form */} From de10269d10ed7e66cff65db6b844db26d58fabff Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Fri, 20 Mar 2026 20:30:07 +0000 Subject: [PATCH 2/4] fix: update ReportsToPicker to display terminated status and improve layout - Modified the display of the current agent's name to include a "(terminated)" suffix if the agent's status is terminated. - Adjusted button layout to ensure proper text truncation and overflow handling for agent names and roles. --- ui/src/components/ReportsToPicker.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/components/ReportsToPicker.tsx b/ui/src/components/ReportsToPicker.tsx index 5c9fe50d..6ce5a40b 100644 --- a/ui/src/components/ReportsToPicker.tsx +++ b/ui/src/components/ReportsToPicker.tsx @@ -48,7 +48,7 @@ export function ReportsToPicker({ {current ? ( <> - {`Reports to ${current.name}`} + {`Reports to ${current.name}${current.status === "terminated" ? " (terminated)" : ""}`} ) : ( <> @@ -77,7 +77,7 @@ export function ReportsToPicker({ type="button" key={a.id} className={cn( - "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate", + "flex items-center gap-2 w-full min-w-0 px-2 py-1.5 text-xs rounded hover:bg-accent/50 overflow-hidden", a.id === value && "bg-accent", )} onClick={() => { @@ -86,8 +86,8 @@ export function ReportsToPicker({ }} > - {a.name} - {roleLabels[a.role] ?? a.role} + {a.name} + {roleLabels[a.role] ?? a.role} ))} From 17b6f6c8f72df59abcfea7543f120db27c1abd9d Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Fri, 20 Mar 2026 20:32:03 +0000 Subject: [PATCH 3/4] fix: enhance ReportsToPicker to handle unknown and terminated managers - Added handling for cases where the selected manager is terminated, displaying a distinct style and message. - Introduced a new state for unknown managers, providing user feedback when the saved manager is missing. - Improved layout for displaying current manager status, ensuring clarity in the UI. --- ui/src/components/ReportsToPicker.tsx | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/ui/src/components/ReportsToPicker.tsx b/ui/src/components/ReportsToPicker.tsx index 6ce5a40b..0de901b4 100644 --- a/ui/src/components/ReportsToPicker.tsx +++ b/ui/src/components/ReportsToPicker.tsx @@ -33,6 +33,8 @@ export function ReportsToPicker({ (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 ( @@ -41,14 +43,22 @@ export function ReportsToPicker({ type="button" className={cn( "inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors", + terminatedManager && "border-amber-600/45 bg-amber-500/5", disabled && "opacity-60 cursor-not-allowed", )} disabled={disabled} > - {current ? ( + {unknownManager ? ( + <> + + Unknown manager (stale ID) + + ) : current ? ( <> - {`Reports to ${current.name}${current.status === "terminated" ? " (terminated)" : ""}`} + + {`Reports to ${current.name}${terminatedManager ? " (terminated)" : ""}`} + ) : ( <> @@ -72,6 +82,19 @@ export function ReportsToPicker({ > No manager + {terminatedManager && ( +
+ + + Current: {current.name} (terminated) + +
+ )} + {unknownManager && ( +
+ Saved manager is missing from this company. Choose a new manager or clear. +
+ )} {rows.map((a) => ( @@ -83,9 +90,9 @@ export function ReportsToPicker({ No manager {terminatedManager && ( -
+
- + Current: {current.name} (terminated)
@@ -109,7 +116,7 @@ export function ReportsToPicker({ }} > - {a.name} + {a.name} {roleLabels[a.role] ?? a.role} ))}