mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Merge pull request #1380 from DanielSousa/feature/change-reports-to
feat(ui): edit and hire with Reports to picker
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 ?? "")}
|
||||
|
||||
126
ui/src/components/ReportsToPicker.tsx
Normal file
126
ui/src/components/ReportsToPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user