import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Plus, Trash2, Users } from "lucide-react"; import type { Agent, AgentDesiredSkillEntry, CompanySkillDetail, CompanySkillUsageAgent, CompanySkillVersionSummary, } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { agentsApi } from "@/api/agents"; import { companySkillsApi } from "@/api/companySkills"; import { queryKeys } from "@/lib/queryKeys"; import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities"; import { useOptionalToastActions } from "@/context/ToastContext"; import { AgentIcon } from "@/components/AgentIconPicker"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { SearchableSelect, type SearchableSelectGroup, type SearchableSelectOption, } from "@/components/SearchableSelect"; import { cn } from "@/lib/utils"; const LATEST_VALUE = "__latest__"; // A single change the operator can request against one agent's desired skill // set. The sync endpoint replaces the *entire* set, so every mutation is // applied against a freshly fetched snapshot (see `applyChange`). type SkillChange = | { kind: "add" } | { kind: "remove" } | { kind: "pin"; versionId: string | null }; /** * Header pill that shows how many agents have this skill in their desired set * and opens the management modal. Owns the dialog's open state so the header * stays declarative. */ export function AgentsUsingSkillBadge({ companyId, skill, canManage = true, }: { companyId: string; skill: CompanySkillDetail; canManage?: boolean; }) { const [open, setOpen] = useState(false); const count = skill.usedByAgents.length; const label = `${count} ${count === 1 ? "agent uses" : "agents use"} this skill`; return ( <> ); } /** * Modal listing the agents that have this skill assigned, with per-agent * version pinning and add/remove. All writes go through * `POST /api/agents/:id/skills/sync`, which *replaces* the agent's whole * desired set — so we always read the agent's current set first and send it * back with just the one change applied (never derive from skill-detail data). */ export function AgentsUsingSkillDialog({ open, onOpenChange, companyId, skill, canManage = true, }: { open: boolean; onOpenChange: (open: boolean) => void; companyId: string; skill: CompanySkillDetail; canManage?: boolean; }) { const queryClient = useQueryClient(); const toast = useOptionalToastActions(); const adapterCaps = useAdapterCapabilities(); const [pendingAgentId, setPendingAgentId] = useState(null); const [confirmRemoveId, setConfirmRemoveId] = useState(null); // Lazy: only fetch versions/agents once the modal is open. Skills with no // version history (currentVersion === null, e.g. read-only catalog/bundled) // never render a pin select, so their versions query stays disabled. const hasVersions = skill.currentVersion !== null; const versionsQuery = useQuery({ queryKey: queryKeys.companySkills.versions(companyId, skill.id), queryFn: () => companySkillsApi.versions(companyId, skill.id), enabled: open && hasVersions, }); const agentsQuery = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: open, }); useEffect(() => { if (!open) setConfirmRemoveId(null); }, [open]); const versions = useMemo( () => [...(versionsQuery.data ?? [])].sort((a, b) => b.revisionNumber - a.revisionNumber), [versionsQuery.data], ); const latestRevision = skill.currentVersion?.revisionNumber ?? null; const revisionByVersionId = useMemo( () => new Map(versions.map((version) => [version.id, version.revisionNumber])), [versions], ); const agentMetaById = useMemo( () => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent])), [agentsQuery.data], ); // Company agents that support skills and don't already have this one — the // footer add-agent picker. const attachedIds = useMemo( () => new Set(skill.usedByAgents.map((entry) => entry.id)), [skill.usedByAgents], ); const addableAgents = useMemo( () => (agentsQuery.data ?? []) .filter( (agent) => !attachedIds.has(agent.id) && agent.status !== "terminated" && adapterCaps(agent.adapterType).supportsSkills, ) .sort((a, b) => a.name.localeCompare(b.name)), [agentsQuery.data, attachedIds, adapterCaps], ); const syncMutation = useMutation({ mutationFn: async ({ agent, change, }: { agent: Pick | Agent; change: SkillChange; }) => { const skillKey = skill.key; const snapshot = await agentsApi.skills(agent.id, companyId); // Prefer the richer entries payload; fall back to bare keys (versionId // null = tracks latest) for older snapshots. const currentEntries: AgentDesiredSkillEntry[] = snapshot.desiredSkillEntries ?? snapshot.desiredSkills.map((key) => ({ key, versionId: null })); const others = currentEntries.filter((entry) => entry.key !== skillKey); const nextEntries = [...others]; if (change.kind !== "remove") { const versionId = change.kind === "pin" ? change.versionId : null; nextEntries.push({ key: skillKey, versionId }); } // Set-equality round-trip: skip the write if nothing actually changed so // we don't churn the agent's config (order-insensitive per b8b6f4446). if (desiredSetsEqual(currentEntries, nextEntries)) { return { agentId: agent.id, changed: false }; } await agentsApi.syncSkills(agent.id, nextEntries, "replace", companyId); return { agentId: agent.id, changed: true }; }, onSuccess: async ({ agentId }) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skill.id) }), queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) }), // Keep the agent's own Skills tab (PAP-13194) in sync. queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agentId) }), ]); }, onError: (error) => { const message = error instanceof Error ? error.message : "Failed to update agent skills."; toast?.pushToast({ tone: "error", title: "Update failed", body: message.includes("403") ? "You don't have permission to change this agent's skills." : message, }); }, }); async function applyChange( agent: Pick | Agent, change: SkillChange, ) { setPendingAgentId(agent.id); try { await syncMutation.mutateAsync({ agent, change }); setConfirmRemoveId(null); } catch { // Toast already surfaced by the mutation's onError. } finally { setPendingAgentId(null); } } const rows = skill.usedByAgents; const count = rows.length; return ( Agents using {skill.name} {count === 0 ? "No agents have this skill assigned yet." : `${count} ${count === 1 ? "agent has" : "agents have"} this skill in their desired set.`}
{count === 0 ? (
{canManage ? "Add an agent below to assign this skill." : "This skill isn't assigned to any agents."}
) : (
    {rows.map((agent) => ( setConfirmRemoveId(agent.id)} onCancelRemove={() => setConfirmRemoveId(null)} onRemove={() => applyChange(agent, { kind: "remove" })} onPin={(versionId) => applyChange(agent, { kind: "pin", versionId })} /> ))}
)}
{canManage ? (
applyChange(agent, { kind: "add" })} /> {syncMutation.isPending ? (
) : null}
); } function AgentRow({ agent, icon, canManage, hasVersions, versions, latestRevision, pinnedRevision, busy, confirmingRemove, onRequestRemove, onCancelRemove, onRemove, onPin, }: { agent: CompanySkillUsageAgent; icon: string | null; canManage: boolean; hasVersions: boolean; versions: CompanySkillVersionSummary[]; latestRevision: number | null; pinnedRevision: number | null; busy: boolean; confirmingRemove: boolean; onRequestRemove: () => void; onCancelRemove: () => void; onRemove: () => void; onPin: (versionId: string | null) => void; }) { const behindLatest = pinnedRevision !== null && latestRevision !== null && latestRevision > pinnedRevision ? latestRevision - pinnedRevision : 0; return (
  • {agent.name} {agent.adapterType}
    {!hasVersions ? ( — ) : canManage ? ( ) : ( {agent.versionId ? `v${pinnedRevision ?? "?"}` : `Latest${latestRevision !== null ? ` (v${latestRevision})` : ""}`} )} {behindLatest > 0 ? ( {behindLatest} version{behindLatest === 1 ? "" : "s"} behind latest ) : null}
    {canManage ? ( confirmingRemove ? (
    ) : ( ) ) : null}
  • ); } function AddAgentPicker({ agents, loading, disabled, onSelect, }: { agents: Agent[]; loading: boolean; disabled: boolean; onSelect: (agent: Agent) => void; }) { type AgentOption = SearchableSelectOption & { agent: Agent }; const groups = useMemo[]>(() => { const options: AgentOption[] = agents.map((agent) => ({ key: agent.id, value: agent.id, label: agent.name, title: agent.name, searchText: [agent.name, agent.adapterType].join(" "), agent, })); return [{ id: "agents", options }]; }, [agents]); return ( value="" groups={groups} loading={loading} loadingMessage="Loading agents..." placeholder="Add agent…" searchPlaceholder="Search agents..." emptyMessage="All eligible agents already have this skill." disabled={disabled} onValueChange={(_value, option) => { onSelect(option.agent); return { close: true }; }} triggerClassName="h-8 w-full justify-start" contentClassName="w-72" renderValue={() => ( )} renderOption={(option) => ( {option.label} {option.agent.adapterType} )} /> ); } /** Order-insensitive comparison of desired skill sets by (key, versionId). */ function desiredSetsEqual(a: AgentDesiredSkillEntry[], b: AgentDesiredSkillEntry[]): boolean { if (a.length !== b.length) return false; const encode = (entry: AgentDesiredSkillEntry) => `${entry.key}${entry.versionId ?? ""}`; const setA = new Set(a.map(encode)); return b.every((entry) => setA.has(encode(entry))); }