diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index ef381ae1bc..ff565fdf9a 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -3,11 +3,13 @@ import type { ComponentProps, ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; -import type { CompanySkillDetail, CompanySkillVersion, FolderListResult } from "@paperclipai/shared"; +import type { CatalogSkill, CompanySkillDetail, CompanySkillVersion, FolderListResult } from "@paperclipai/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DiscoveryGrid, + InstallPreviewDialog, SkillDetailPage, + defaultInstallAgentSelection, getSkillVersionDiffSelection, resolveDiscoveryTab, withDiscoveryTab, @@ -837,3 +839,161 @@ describe("SkillDetailPage settings", () => { expect((node.querySelector('[role="dialog"] input') as HTMLInputElement).value).toBe("memory"); }); }); + +describe("install-time agent enablement", () => { + const agentOptions = [ + { id: "agent-ceo", name: "CEO", adapterType: "claude_local", supportsSkills: true, required: false, icon: null, paused: false }, + { id: "agent-designer", name: "Designer", adapterType: "claude_local", supportsSkills: true, required: false, icon: null, paused: true }, + { id: "agent-gateway", name: "Gateway", adapterType: "openclaw_gateway", supportsSkills: false, required: false, icon: null, paused: false }, + { id: "agent-builtin", name: "Summarizer", adapterType: "claude_local", supportsSkills: true, required: true, icon: null, paused: false }, + ]; + + function makeCatalogSkill(): CatalogSkill { + return { + id: "catalog-1", + key: "paperclipai/bundled/product/wireframe", + kind: "bundled", + category: "product", + slug: "wireframe", + name: "wireframe", + description: "Draw wireframes.", + path: "catalog/bundled/product/wireframe", + entrypoint: "SKILL.md", + trustLevel: "markdown_only", + compatibility: "compatible", + defaultInstall: false, + recommendedForRoles: [], + requires: [], + tags: [], + files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 128, sha256: "abc" }], + contentHash: "sha256:abc", + }; + } + + it("defaults to every skills-capable, non-required agent", () => { + expect(defaultInstallAgentSelection(agentOptions)).toEqual(new Set(["agent-ceo", "agent-designer"])); + }); + + it("passes the default agent selection through onConfirm for fresh installs", async () => { + const onConfirm = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + // The dialog seeds its slug/agent state in passive effects; give them a + // macrotask to flush before interacting. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const node = container as ParentNode; + expect(node.textContent).toContain("Enable for agents"); + + await click(buttonsNamed(node, "Install skill")[0] as HTMLButtonElement); + + expect(onConfirm).toHaveBeenCalledWith({ + slug: "wireframe", + force: false, + agentIds: expect.arrayContaining(["agent-ceo", "agent-designer"]), + }); + expect(onConfirm.mock.calls[0][0].agentIds).toHaveLength(2); + }); + + it("keeps tracking the agent default when agents load after the dialog opens", async () => { + const onConfirm = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + const renderDialog = (agents: typeof agentOptions) => + root?.render( + , + ); + + // Dialog opens before the agents query resolves: nothing to select yet. + await act(async () => renderDialog([])); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // The agents arrive later; the untouched selection must pick them up. + await act(async () => renderDialog(agentOptions)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await click(buttonsNamed(container as ParentNode, "Install skill")[0] as HTMLButtonElement); + + expect(onConfirm.mock.calls[0][0].agentIds).toHaveLength(2); + }); + + it("skips agent enablement for updates and replacements", async () => { + const onConfirm = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const node = container as ParentNode; + expect(node.textContent).not.toContain("Enable for agents"); + + await click(buttonsNamed(node, "Install update")[0] as HTMLButtonElement); + + expect(onConfirm).toHaveBeenCalledWith({ slug: "wireframe", force: false, agentIds: [] }); + }); +}); diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 5ef66a9197..0f9e67ddb7 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -2018,7 +2018,17 @@ function CatalogDetailPane({ ); } -function InstallPreviewDialog({ +// Installing only adds a skill to the company library; an agent can use it only +// once it is also enabled for that agent. Pre-select every agent that can +// receive the skill so "install" defaults to a state where the skill is +// actually usable, instead of a library row no agent has. +export function defaultInstallAgentSelection( + agents: Array>, +): Set { + return new Set(agents.filter((agent) => agent.supportsSkills && !agent.required).map((agent) => agent.id)); +} + +export function InstallPreviewDialog({ open, onOpenChange, skill, @@ -2028,6 +2038,7 @@ function InstallPreviewDialog({ defaultSlug, defaultForce, defaultAction, + agents, isPending, error, onConfirm, @@ -2041,13 +2052,21 @@ function InstallPreviewDialog({ defaultSlug: string | null; defaultForce: boolean; defaultAction: "install" | "update" | "replace"; + agents: AttachAgentOption[]; isPending: boolean; error: string | null; - onConfirm: (input: { slug: string | null; force: boolean }) => void; + onConfirm: (input: { slug: string | null; force: boolean; agentIds: string[] }) => void; }) { const [slug, setSlug] = useState(""); const [force, setForce] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); + const [selectedAgentIds, setSelectedAgentIds] = useState>(new Set()); + // Whether the user changed the agent selection this open. Until then the + // selection keeps tracking the default: the agents query may resolve after + // the dialog opens, and a one-shot seed would freeze an empty selection and + // install the skill for nobody. + const [selectionTouched, setSelectionTouched] = useState(false); + const wasOpenRef = useRef(false); useEffect(() => { if (!open) return; @@ -2056,6 +2075,18 @@ function InstallPreviewDialog({ setAdvancedOpen(defaultAction === "replace" || defaultForce); }, [open, defaultSlug, defaultForce, defaultAction]); + useEffect(() => { + if (open && !wasOpenRef.current) setSelectionTouched(false); + wasOpenRef.current = open; + }, [open]); + + // Track the default selection while the dialog is open and untouched; the + // user's first change takes over and background refetches never clobber it. + useEffect(() => { + if (!open || selectionTouched) return; + setSelectedAgentIds(defaultAction === "install" ? defaultInstallAgentSelection(agents) : new Set()); + }, [open, selectionTouched, defaultAction, agents]); + if (!skill) return null; let confirmLabel = "Install skill"; @@ -2140,6 +2171,33 @@ function InstallPreviewDialog({ ) : null} + {defaultAction === "install" ? ( +
+
Enable for agents
+

+ Installing adds the skill to the company library. Agents can only use it once it is enabled for them. +

+ { + setSelectionTouched(true); + setSelectedAgentIds(next); + }} + showSelectionPreview={false} + emptyMessage="No agents in this company support skills yet." + isAgentDisabled={(agent) => { + const option = agent as AttachAgentOption; + return option.required || !option.supportsSkills; + }} + getDescription={(agent) => { + const option = agent as AttachAgentOption; + return `${option.adapterType}${option.required ? " · required" : ""}${!option.supportsSkills ? " · skills not supported" : ""}`; + }} + /> +
+ ) : null} +