Merge pull request #1380 from DanielSousa/feature/change-reports-to

feat(ui): edit and hire with Reports to picker
This commit is contained in:
Dotta
2026-03-23 15:08:54 -05:00
committed by GitHub
5 changed files with 163 additions and 54 deletions

View File

@@ -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 agents 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

View File

@@ -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"
/>
</Field>
<Field label="Reports to" hint={help.reportsTo}>
<ReportsToPicker
agents={companyAgents}
value={eff("identity", "reportsTo", props.agent.reportsTo ?? null)}
onChange={(id) => mark("identity", "reportsTo", id)}
excludeAgentIds={[props.agent.id]}
chooseLabel="Choose manager…"
/>
</Field>
<Field label="Capabilities" hint={help.capabilities}>
<MarkdownEditor
value={eff("identity", "capabilities", props.agent.capabilities ?? "")}

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import type { Agent } from "@paperclipai/shared";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { User } from "lucide-react";
import { cn } from "../lib/utils";
import { roleLabels } from "./agent-config-primitives";
import { AgentIcon } from "./AgentIconPicker";
export function ReportsToPicker({
agents,
value,
onChange,
disabled = false,
excludeAgentIds = [],
disabledEmptyLabel = "Reports to: N/A (CEO)",
chooseLabel = "Reports to...",
}: {
agents: Agent[];
value: string | null;
onChange: (id: string | null) => 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden 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}
>
{unknownManager ? (
<>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">Unknown manager (stale ID)</span>
</>
) : current ? (
<>
<AgentIcon icon={current.icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
<span
className={cn(
"min-w-0 truncate",
terminatedManager && "text-amber-900 dark:text-amber-200",
)}
>
{`Reports to ${current.name}${terminatedManager ? " (terminated)" : ""}`}
</span>
</>
) : (
<>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">
{disabled ? disabledEmptyLabel : chooseLabel}
</span>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<button
type="button"
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
value === null && "bg-accent",
)}
onClick={() => {
onChange(null);
setOpen(false);
}}
>
No manager
</button>
{terminatedManager && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
<AgentIcon icon={current.icon} className="shrink-0 h-3 w-3" />
<span className="min-w-0 truncate">
Current: {current.name} (terminated)
</span>
</div>
)}
{unknownManager && (
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
Saved manager is missing from this company. Choose a new manager or clear.
</div>
)}
{rows.map((a) => (
<button
type="button"
key={a.id}
className={cn(
"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={() => {
onChange(a.id);
setOpen(false);
}}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
<span className="min-w-0 truncate">{a.name}</span>
<span className="text-muted-foreground ml-auto shrink-0">{roleLabels[a.role] ?? a.role}</span>
</button>
))}
</PopoverContent>
</Popover>
);
}

View File

@@ -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" });
},
});

View File

@@ -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<string | null>(null);
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
const [selectedSkillKeys, setSelectedSkillKeys] = useState<string[]>([]);
const [roleOpen, setRoleOpen] = useState(false);
const [reportsToOpen, setReportsToOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(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() {
</PopoverContent>
</Popover>
<Popover open={reportsToOpen} onOpenChange={setReportsToOpen}>
<PopoverTrigger asChild>
<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",
isFirstAgent && "opacity-60 cursor-not-allowed"
)}
disabled={isFirstAgent}
>
{currentReportsTo ? (
<>
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
{`Reports to ${currentReportsTo.name}`}
</>
) : (
<>
<User className="h-3 w-3 text-muted-foreground" />
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!reportsTo && "bg-accent"
)}
onClick={() => { setReportsTo(""); setReportsToOpen(false); }}
>
No manager
</button>
{(agents ?? []).map((a) => (
<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",
a.id === reportsTo && "bg-accent"
)}
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
</button>
))}
</PopoverContent>
</Popover>
<ReportsToPicker
agents={agents ?? []}
value={reportsTo}
onChange={setReportsTo}
disabled={isFirstAgent}
/>
</div>
{/* Shared config form */}