diff --git a/scripts/screenshot-pap-10242.mjs b/scripts/screenshot-pap-10242.mjs new file mode 100644 index 00000000000..ae641f451b4 --- /dev/null +++ b/scripts/screenshot-pap-10242.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const playwrightPkgRoot = path.join( + repoRoot, + "node_modules/.pnpm/playwright@1.58.2/node_modules/playwright", +); +const { chromium } = await import(path.join(playwrightPkgRoot, "index.mjs")); + +const HOST = process.env.PAP_HOST ?? "100.123.243.20"; +const BASE = `http://${HOST}:3100`; +const outDir = path.join(repoRoot, "screenshots/PAP-10242"); +await fs.mkdir(outDir, { recursive: true }); + +// Reuse the PAP-9704 local board session token, re-hosted onto the reachable +// tailscale interface with a fresh client-side expiry (the server validates the +// token value, not the cookie expiry). +const authState = JSON.parse( + await fs.readFile(path.join(repoRoot, "screenshots/PAP-9704-local-auth-state.json"), "utf8"), +); +const sessionCookie = authState.cookies.find((c) => c.name.endsWith("session_token")); + +const browser = await chromium.launch({ + executablePath: "/srv/paperclip/home/.cache/ms-playwright/chromium-1223/chrome-linux/chrome", + args: ["--no-sandbox"], +}); + +const results = []; +try { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + if (sessionCookie) { + await context.addCookies([ + { + name: sessionCookie.name, + value: sessionCookie.value, + domain: HOST, + path: "/", + expires: Math.floor(Date.now() / 1000) + 3600, + httpOnly: sessionCookie.httpOnly ?? true, + secure: false, + sameSite: "Lax", + }, + ]); + } + const page = await context.newPage(); + page.on("console", (m) => { + if (m.type() === "error") console.log(" [browser console error]", m.text().slice(0, 200)); + }); + + async function shot(slug, urlPath, prep) { + const url = `${BASE}${urlPath}`; + await page.goto(url, { waitUntil: "networkidle" }).catch(() => {}); + await page.waitForTimeout(1500); + if (prep) await prep(page); + const target = path.join(outDir, `${slug}.png`); + await page.screenshot({ path: target, fullPage: false }); + const title = await page.title().catch(() => ""); + const bodyText = (await page.evaluate(() => document.body.innerText).catch(() => "")) || ""; + results.push({ slug, url, title, hasTeams: bodyText.includes("Teams"), sample: bodyText.slice(0, 120).replace(/\n/g, " ") }); + console.log(`captured ${target} :: title="${title}" :: ${bodyText.slice(0, 80).replace(/\n/g, " ")}`); + } + + // 01 browse + await shot("01-browse", "/PAP/teams"); + + // 02 detail (click first team row) + await shot("02-detail", "/PAP/teams", async (p) => { + const row = p.locator('button:has-text("Team")').first(); + if (await row.count()) await row.click().catch(() => {}); + await p.waitForTimeout(800); + }); + + // 03 install wizard (click Install team) + await shot("03-install-wizard", "/PAP/teams", async (p) => { + const cta = p.locator('button:has-text("Install team")').first(); + if (await cta.count()) await cta.click().catch(() => {}); + await p.waitForTimeout(1500); + }); +} finally { + await browser.close(); +} + +await fs.writeFile(path.join(outDir, "_capture-summary.json"), JSON.stringify(results, null, 2)); +console.log("\nSummary:", JSON.stringify(results, null, 2)); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 94522182c9b..9f3c4ffb88e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -37,6 +37,7 @@ import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; +import { TeamCatalog } from "./pages/TeamCatalog"; import { Secrets } from "./pages/Secrets"; import { CompanyExport } from "./pages/CompanyExport"; import { CompanyImport } from "./pages/CompanyImport"; @@ -83,6 +84,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/teamCatalog.ts b/ui/src/api/teamCatalog.ts new file mode 100644 index 00000000000..0b0d312187b --- /dev/null +++ b/ui/src/api/teamCatalog.ts @@ -0,0 +1,52 @@ +import type { + CatalogTeam, + CatalogTeamFileDetail, + CatalogTeamImportOptions, + CatalogTeamImportPreviewResult, + CatalogTeamInstallOptions, + CatalogTeamInstallResult, + CatalogTeamKind, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export interface TeamCatalogListQuery { + kind?: CatalogTeamKind; + category?: string; + q?: string; +} + +/** + * Client for the Phase E teams-catalog API (server/src/routes/teams-catalog.ts). + * + * The preview/install bodies mirror `catalogTeamPreviewSchema` / + * `catalogTeamInstallSchema` exactly. Several richer fields the Phase F design + * imagined (per-source policy maps, secret values, skill-plan overrides) are not + * accepted by the shipped strict schema, so the UI derives those affordances + * client-side and degrades gracefully — see TeamCatalog.tsx. + */ +export const teamCatalogApi = { + catalogList: (query: TeamCatalogListQuery = {}) => { + const params = new URLSearchParams(); + if (query.kind) params.set("kind", query.kind); + if (query.category) params.set("category", query.category); + if (query.q) params.set("q", query.q); + const search = params.toString(); + return api.get(`/teams/catalog${search ? `?${search}` : ""}`); + }, + catalogDetail: (catalogRef: string) => + api.get(`/teams/catalog/${encodeURIComponent(catalogRef)}`), + catalogFile: (catalogRef: string, relativePath = "TEAM.md") => + api.get( + `/teams/catalog/${encodeURIComponent(catalogRef)}/files?path=${encodeURIComponent(relativePath)}`, + ), + preview: (companyId: string, catalogRef: string, options: CatalogTeamImportOptions = {}) => + api.post( + `/companies/${encodeURIComponent(companyId)}/teams/catalog/${encodeURIComponent(catalogRef)}/preview`, + options, + ), + install: (companyId: string, catalogRef: string, options: CatalogTeamInstallOptions = {}) => + api.post( + `/companies/${encodeURIComponent(companyId)}/teams/catalog/${encodeURIComponent(catalogRef)}/install`, + options, + ), +}; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index bce1f64aecf..c98f017cfb0 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { SquarePen, Network, Boxes, + Users2, Repeat, GitBranch, Settings, @@ -121,6 +122,7 @@ export function Sidebar() { + diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index a5517f341c3..f6361e1d31c 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -17,6 +17,13 @@ export const queryKeys = { catalogFile: (catalogRef: string, relativePath: string) => ["company-skills", "catalog", "file", catalogRef, relativePath] as const, }, + teamCatalog: { + catalog: (filters: { kind?: string; category?: string; q?: string } = {}) => + ["team-catalog", "catalog", filters.kind ?? "__all-kinds__", filters.category ?? "__all-categories__", filters.q ?? ""] as const, + catalogDetail: (catalogRef: string) => ["team-catalog", "catalog", "detail", catalogRef] as const, + catalogFile: (catalogRef: string, relativePath: string) => + ["team-catalog", "catalog", "file", catalogRef, relativePath] as const, + }, agents: { list: (companyId: string) => ["agents", companyId] as const, detail: (id: string) => ["agents", "detail", id] as const, diff --git a/ui/src/pages/TeamCatalog.test.tsx b/ui/src/pages/TeamCatalog.test.tsx new file mode 100644 index 00000000000..3906d45c0e5 --- /dev/null +++ b/ui/src/pages/TeamCatalog.test.tsx @@ -0,0 +1,317 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { + CatalogTeam, + CatalogTeamImportPreviewResult, +} from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamCatalog } from "./TeamCatalog"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +const mockTeamCatalogApi = vi.hoisted(() => ({ + catalogList: vi.fn(), + catalogDetail: vi.fn(), + catalogFile: vi.fn(), + preview: vi.fn(), + install: vi.fn(), +})); + +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockPushToast = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock("../api/teamCatalog", () => ({ teamCatalogApi: mockTeamCatalogApi })); +vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi })); + +vi.mock("../components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>
{children}
, +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: mockPushToast }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +// Drive the route deterministically: the team is preselected so the detail pane +// renders without depending on the auto-select navigation effect. +let currentRoute = "team-no-deps"; +const mockSearchParams = new URLSearchParams(); +vi.mock("@/lib/router", () => ({ + useParams: () => ({ "*": currentRoute }), + useNavigate: () => mockNavigate, + useSearchParams: () => [mockSearchParams, vi.fn()], +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// cmdk (Command) and Radix rely on browser APIs jsdom does not implement. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub; +if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function makeTeam(overrides: Partial = {}): CatalogTeam { + return { + id: "team-no-deps", + key: "paperclipai/bundled/company-defaults/team-no-deps", + kind: "bundled", + category: "company-defaults", + slug: "team-no-deps", + name: "Core Exec Team", + description: "A starter executive team.", + path: "catalog/bundled/company-defaults/team-no-deps", + entrypoint: "TEAM.md", + schema: "agentcompanies/v1", + defaultInstall: true, + recommendedForCompanyTypes: [], + tags: ["exec"], + counts: { + agents: 2, + projects: 1, + tasks: 0, + routines: 1, + localSkills: 0, + catalogSkills: 0, + externalSkillSources: 0, + }, + rootAgentSlugs: [], + agentSlugs: ["ceo", "cto"], + projectSlugs: ["launch"], + requiredSkills: [], + envInputs: [], + sourceRefs: [], + files: [{ path: "TEAM.md", kind: "team", sizeBytes: 100, sha256: "abc" }], + trustLevel: "markdown_only", + compatibility: "compatible", + contentHash: "sha256:deadbeefdeadbeefdeadbeef", + ...overrides, + }; +} + +function makePreview(): CatalogTeamImportPreviewResult { + return { + team: makeTeam(), + portabilityPreview: { + include: { company: false, agents: true, projects: true, issues: false, skills: true }, + targetCompanyId: "company-1", + targetCompanyName: "Paperclip", + collisionStrategy: "rename", + selectedAgentSlugs: ["ceo", "cto"], + plan: { + companyAction: "none", + agentPlans: [ + { slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null }, + { slug: "cto", action: "create", plannedName: "CTO", existingAgentId: null, reason: null }, + ], + projectPlans: [ + { slug: "launch", action: "create", plannedName: "Launch", existingProjectId: null, reason: null }, + ], + issuePlans: [], + }, + manifest: { + schemaVersion: 1, + generatedAt: "2026-06-03T00:00:00.000Z", + source: null, + includes: { company: false, agents: true, projects: true, issues: false, skills: true }, + company: null, + sidebar: null, + agents: [], + skills: [], + projects: [], + issues: [], + envInputs: [], + }, + files: {}, + envInputs: [], + warnings: [], + errors: [], + }, + skillPreparations: [], + warnings: [], + errors: [], + }; +} + +function findButton(label: string): HTMLButtonElement | undefined { + return Array.from(document.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes(label), + ) as HTMLButtonElement | undefined; +} + +describe("TeamCatalog install preview path", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + currentRoute = "team-no-deps"; + mockAgentsApi.list.mockResolvedValue([]); + mockTeamCatalogApi.catalogList.mockResolvedValue([makeTeam()]); + mockTeamCatalogApi.preview.mockResolvedValue(makePreview()); + mockTeamCatalogApi.install.mockResolvedValue({ + team: makeTeam(), + portabilityImport: { + company: { id: "company-1", name: "Paperclip", action: "unchanged" }, + agents: [], + projects: [], + envInputs: [], + warnings: [], + }, + skillPreparations: [], + warnings: [], + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function renderPage() { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + return root; + } + + it("renders the detail pane for the selected team", async () => { + await renderPage(); + expect(document.body.textContent).toContain("Core Exec Team"); + expect(document.body.textContent).toContain("A starter executive team."); + // summary grid counts + expect(document.body.textContent).toContain("Agents"); + expect(document.body.textContent).toContain("Projects"); + }); + + it("opens the installer, fetches the preview, and submits the install", async () => { + await renderPage(); + + // Open the installer from the detail CTA. + const installCta = findButton("Install team"); + expect(installCta).toBeTruthy(); + await act(async () => { + installCta!.click(); + }); + await flushReact(); + + // The wizard is single-step (no deps) → lands on the preview step and + // auto-loads the categorized preview. + expect(mockTeamCatalogApi.preview).toHaveBeenCalledTimes(1); + expect(mockTeamCatalogApi.preview).toHaveBeenCalledWith( + "company-1", + "team-no-deps", + expect.objectContaining({ collisionStrategy: "rename" }), + ); + expect(document.body.textContent).toContain("Summary"); + // categorized plan rows + expect(document.body.textContent?.toLowerCase()).toContain("ceo"); + expect(document.body.textContent?.toLowerCase()).toContain("launch"); + + // Submit install from the footer. + const submit = Array.from(document.querySelectorAll("button")).filter((b) => + (b.textContent ?? "").includes("Install team"), + ); + // Last "Install team" is the wizard footer submit. + await act(async () => { + submit[submit.length - 1].click(); + }); + await flushReact(); + + expect(mockTeamCatalogApi.install).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("Team installed"); + }); + + it("requires a target manager before continuing when the team has root agents", async () => { + currentRoute = "team-with-root"; + mockTeamCatalogApi.catalogList.mockResolvedValue([ + makeTeam({ id: "team-with-root", slug: "team-with-root", rootAgentSlugs: ["ceo"], agentSlugs: ["ceo", "cto"] }), + ]); + mockAgentsApi.list.mockResolvedValue([ + { + id: "agent-1", + companyId: "company-1", + name: "Founder", + urlKey: "founder", + role: "ceo", + title: null, + icon: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: {}, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + await renderPage(); + + const installCta = findButton("Install team"); + await act(async () => { + installCta!.click(); + }); + await flushReact(); + + // First step is target manager; Continue is disabled until a manager is chosen. + expect(document.body.textContent).toContain("root agents need a manager"); + const continueBtn = findButton("Continue"); + expect(continueBtn).toBeTruthy(); + expect(continueBtn!.disabled).toBe(true); + // Preview has not been requested yet (still on step 1). + expect(mockTeamCatalogApi.preview).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/pages/TeamCatalog.tsx b/ui/src/pages/TeamCatalog.tsx new file mode 100644 index 00000000000..f6ccbb04dea --- /dev/null +++ b/ui/src/pages/TeamCatalog.tsx @@ -0,0 +1,1946 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "@/lib/router"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + Agent, + CatalogTeam, + CatalogTeamEnvInputSummary, + CatalogTeamImportPreviewResult, + CatalogTeamSkillPreparation, + CatalogTeamSkillRequirement, + CatalogTeamSourceRef, + CatalogTeamTrustLevel, + CatalogTeamCompatibility, + CompanyPortabilityCollisionStrategy, +} from "@paperclipai/shared"; +import { teamCatalogApi } from "../api/teamCatalog"; +import { agentsApi } from "../api/agents"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useToastActions } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { EmptyState } from "../components/EmptyState"; +import { MarkdownBody } from "../components/MarkdownBody"; +import { cn } from "../lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertTriangle, + ArrowRight, + Boxes, + Check, + CheckCircle2, + ChevronDown, + ChevronRight, + Crown, + Download, + FileText, + Filter, + Folder, + FolderKanban, + FolderOpen, + Github, + KeyRound, + Link2, + Loader2, + Package, + Repeat, + RotateCcw, + Search, + ShieldCheck, + Users2, + XCircle, + XOctagon, +} from "lucide-react"; + +// --------------------------------------------------------------------------- +// Risk model — derived client-side from the team's source refs (design §8). +// --------------------------------------------------------------------------- + +type TeamRisk = "safe" | "has_warnings" | "blocked"; + +const UI_UNSUPPORTED_SOURCE_TYPES = new Set([ + "local_path", + "agent_package", +]); + +function sourceWarningCode( + source: CatalogTeamSourceRef, +): "ok" | "unpinned" | "unsupported_in_ui" { + if (UI_UNSUPPORTED_SOURCE_TYPES.has(source.type)) return "unsupported_in_ui"; + if (!source.pinned) return "unpinned"; + return "ok"; +} + +function teamRisk(team: CatalogTeam): TeamRisk { + let risk: TeamRisk = "safe"; + for (const source of team.sourceRefs) { + const code = sourceWarningCode(source); + if (code === "unsupported_in_ui") return "blocked"; + if (code === "unpinned") risk = "has_warnings"; + } + return risk; +} + +function externalSourceCount(team: CatalogTeam): number { + return team.sourceRefs.filter((s) => s.type !== "include").length; +} + +function skillCount(team: CatalogTeam): number { + return ( + team.counts.localSkills + team.counts.catalogSkills + team.counts.externalSkillSources + ); +} + +function titleCase(slug: string): string { + return slug + .replace(/[-_/]+/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); +} + +function encodeTeamFilePath(filePath: string): string { + return filePath.split("/").map(encodeURIComponent).join("~"); +} + +function decodeTeamFilePath(encoded: string | undefined): string | null { + if (!encoded) return null; + return encoded.split("~").map(decodeURIComponent).join("/"); +} + +type ParsedRoute = { catalogRef: string | null; filePath: string | null }; + +function parseTeamRoute(routePath: string | undefined): ParsedRoute { + if (!routePath) return { catalogRef: null, filePath: null }; + const segments = routePath.split("/").filter(Boolean); + if (segments.length === 0) return { catalogRef: null, filePath: null }; + const catalogRef = decodeURIComponent(segments[0]); + if (segments[1] === "files" && segments[2]) { + return { catalogRef, filePath: decodeTeamFilePath(segments[2]) }; + } + return { catalogRef, filePath: null }; +} + +function teamRoute(catalogRef: string, filePath?: string | null): string { + const base = `/teams/${encodeURIComponent(catalogRef)}`; + if (filePath) return `${base}/files/${encodeTeamFilePath(filePath)}`; + return base; +} + +// --------------------------------------------------------------------------- +// Small presentational components (siblings of the Skills catalog chips). +// --------------------------------------------------------------------------- + +const TRUST_META: Record< + CatalogTeamTrustLevel, + { label: string; tip: string; tone: string; Icon: typeof ShieldCheck } +> = { + markdown_only: { + label: "Markdown only", + tip: "Contains only markdown and references. No executable content.", + tone: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30", + Icon: ShieldCheck, + }, + assets: { + label: "Assets", + tip: "Includes static assets (images, fixtures). No executable content.", + tone: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30", + Icon: ShieldCheck, + }, + scripts_executables: { + label: "Scripts", + tip: "Includes executable scripts that were security-reviewed before bundling.", + tone: "text-amber-600 dark:text-amber-300 border-amber-500/30", + Icon: AlertTriangle, + }, + external_sources: { + label: "External sources", + tip: "References external sources resolved at install time.", + tone: "text-amber-600 dark:text-amber-300 border-amber-500/30", + Icon: AlertTriangle, + }, +}; + +function TrustChip({ level, iconOnly = false }: { level: CatalogTeamTrustLevel; iconOnly?: boolean }) { + const meta = TRUST_META[level]; + const { Icon } = meta; + return ( + + + + + {!iconOnly && meta.label} + + + {meta.tip} + + ); +} + +const COMPAT_META: Record< + CatalogTeamCompatibility, + { label: string; tone: string } +> = { + compatible: { label: "Compatible", tone: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30" }, + unknown: { label: "Unknown compat", tone: "text-muted-foreground border-border" }, + invalid: { label: "Invalid", tone: "text-rose-600 dark:text-rose-300 border-rose-500/30" }, +}; + +function CompatChip({ compatibility }: { compatibility: CatalogTeamCompatibility }) { + const meta = COMPAT_META[compatibility]; + return ( + + {meta.label} + + ); +} + +function ProvenanceBadge({ team }: { team: CatalogTeam }) { + if (!team.packageName) return null; + return ( + + + + + {team.packageName} + {team.packageVersion ? `@${team.packageVersion}` : ""} + + + Catalog package provenance + + ); +} + +function RiskBanner({ team }: { team: CatalogTeam }) { + const unsafe = team.sourceRefs.filter( + (s) => sourceWarningCode(s) !== "ok", + ); + if (unsafe.length === 0) return null; + return ( +
+
+ + This team references {unsafe.length} external source + {unsafe.length === 1 ? "" : "s"} +
+
    + {unsafe.map((s) => ( +
  • + {s.ref}{" "} + + ({sourceWarningCode(s) === "unsupported_in_ui" ? "unsupported in browser install" : "unpinned"}) + +
  • + ))} +
+
+ ); +} + +function sourceKindIcon(type: CatalogTeamSourceRef["type"]) { + switch (type) { + case "github": + return Github; + case "url": + return Link2; + case "local_path": + return Folder; + case "agent_package": + return Package; + case "skills_sh": + return Boxes; + default: + return Link2; + } +} + +// --------------------------------------------------------------------------- +// File tree +// --------------------------------------------------------------------------- + +type TreeNode = { + name: string; + path: string | null; + kind: "dir" | "file"; + children: TreeNode[]; +}; + +function buildTree(files: CatalogTeam["files"]): TreeNode[] { + const root: TreeNode = { name: "", path: null, kind: "dir", children: [] }; + for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) { + const parts = file.path.split("/"); + let cursor = root; + parts.forEach((part, idx) => { + const isLeaf = idx === parts.length - 1; + let child = cursor.children.find((c) => c.name === part); + if (!child) { + child = { + name: part, + path: isLeaf ? file.path : null, + kind: isLeaf ? "file" : "dir", + children: [], + }; + cursor.children.push(child); + } + cursor = child; + }); + } + const sort = (node: TreeNode) => { + node.children.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + node.children.forEach(sort); + }; + sort(root); + return root.children; +} + +function TeamFileTree({ + nodes, + depth = 0, + selectedPath, + expanded, + onToggleDir, + onSelectFile, +}: { + nodes: TreeNode[]; + depth?: number; + selectedPath: string | null; + expanded: Set; + onToggleDir: (name: string) => void; + onSelectFile: (path: string) => void; +}) { + return ( +
    + {nodes.map((node) => { + const key = node.path ?? `dir:${depth}:${node.name}`; + if (node.kind === "dir") { + const open = expanded.has(node.name); + const DirIcon = open ? FolderOpen : Folder; + return ( +
  • + + {open && ( + + )} +
  • + ); + } + const active = node.path === selectedPath; + return ( +
  • + +
  • + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Agent hierarchy preview (manifest exposes agentSlugs + rootAgentSlugs only; +// per-agent reportsTo is not in the list manifest, so we render roots distinctly +// and group the remaining members — graceful degradation per design §7). +// --------------------------------------------------------------------------- + +function TeamHierarchyPreview({ team }: { team: CatalogTeam }) { + const roots = new Set(team.rootAgentSlugs); + const members = team.agentSlugs.filter((slug) => !roots.has(slug)); + const requiresManager = team.rootAgentSlugs.length > 0; + return ( +
+
    + {team.rootAgentSlugs.map((slug) => ( +
  • + + {titleCase(slug)} + root agent +
  • + ))} + {members.map((slug) => ( +
  • + + {titleCase(slug)} +
  • + ))} + {team.agentSlugs.length === 0 && ( +
  • No agents in this team.
  • + )} +
+
+ ); +} + +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// --------------------------------------------------------------------------- +// Detail pane +// --------------------------------------------------------------------------- + +function MetricTile({ + label, + value, + Icon, +}: { + label: string; + value: number; + Icon: typeof Users2; +}) { + return ( +
+
+ {value} + +
+ {label} +
+ ); +} + +function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] }) { + if (skills.length === 0) return

No required skills.

; + return ( +
    + {skills.map((skill) => ( +
  • + + {skill.ref} + + {skill.type} + + {skill.resolved ? ( + + resolved + + ) : ( + + external + + )} +
  • + ))} +
+ ); +} + +function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) { + if (inputs.length === 0) return null; + return ( +
+ Secrets & env inputs +
    + {inputs.map((input) => ( +
  • + + {input.key} + + {input.kind} + + {input.requirement === "required" && ( + required + )} +
  • + ))} +
+
+ ); +} + +function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) { + const external = sources.filter((s) => s.type !== "include"); + const [open, setOpen] = useState(false); + if (external.length === 0) return null; + return ( +
+ + {open && ( +
    + {external.map((source) => { + const Icon = sourceKindIcon(source.type); + const code = sourceWarningCode(source); + return ( +
  • + + {source.ref} + + {code === "ok" && ( + Pinned + )} + {code === "unpinned" && ( + Unpinned + )} + {code === "unsupported_in_ui" && ( + Unsupported in browser install + )} + +
  • + ); + })} +
+ )} +
+ ); +} + +function TeamDetailPane({ + team, + selectedPath, + onSelectFile, + onInstall, + canInstall, + fileContent, +}: { + team: CatalogTeam; + selectedPath: string | null; + onSelectFile: (path: string | null) => void; + onInstall: () => void; + canInstall: boolean; + fileContent: string | null; +}) { + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const tree = useMemo(() => buildTree(team.files), [team.files]); + const invalid = team.compatibility === "invalid"; + const unsafe = team.trustLevel === "scripts_executables"; + + const toggleDir = (name: string) => + setExpandedDirs((current) => { + const next = new Set(current); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + + const installButton = ( + + ); + + return ( +
+
+ {/* Header */} +
+
+

{team.name}

+
+ + {team.kind} + + {team.category} + + + +
+
+ {invalid ? ( + + + {installButton} + + This team cannot be installed — the package manifest is invalid. + + ) : !canInstall ? ( + + + {installButton} + + Requires board operator or agent-create permissions. + + ) : ( + installButton + )} +
+ + + + {/* Description */} + {team.description && ( +
+ {team.description} +
+ )} + + {/* Summary grid */} +
+ + + + +
+ + {/* Agent hierarchy */} +
+ Agent hierarchy + +
+ + {/* Projects */} + {team.projectSlugs.length > 0 && ( +
+ Projects +
    + {team.projectSlugs.map((slug) => ( +
  • + + {titleCase(slug)} + {slug} +
  • + ))} +
+
+ )} + + {/* Required skills */} +
+ Required skills + +
+ + {/* Env inputs */} + + + {/* External sources */} + + + {/* File inventory */} +
+ Files +
+ onSelectFile(path)} + /> +
+ {selectedPath && ( +
+
+ {selectedPath} + +
+
+ {fileContent === null ? ( + + ) : selectedPath.endsWith(".md") ? ( +
+ {fileContent} +
+ ) : ( +
{fileContent}
+ )} +
+
+ )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Install wizard +// --------------------------------------------------------------------------- + +type WizardStep = "target_manager" | "source_policy" | "skill_plan" | "preview"; + +const STEP_LABELS: Record = { + target_manager: "Target manager", + source_policy: "Source policy", + skill_plan: "Prerequisite skills", + preview: "Preview", +}; + +function computeSteps(team: CatalogTeam): WizardStep[] { + const steps: WizardStep[] = []; + if (team.rootAgentSlugs.length > 0) steps.push("target_manager"); + if (team.sourceRefs.some((s) => sourceWarningCode(s) !== "ok")) steps.push("source_policy"); + if (team.requiredSkills.length > 0) steps.push("skill_plan"); + steps.push("preview"); + return steps; +} + +type ApplyPhase = "form" | "applying" | "done" | "error"; + +function TeamInstallerDialog({ + team, + companyId, + agents, + open, + onClose, + onInstalled, +}: { + team: CatalogTeam; + companyId: string; + agents: Agent[]; + open: boolean; + onClose: () => void; + onInstalled: () => void; +}) { + const steps = useMemo(() => computeSteps(team), [team]); + const [stepIndex, setStepIndex] = useState(0); + const [phase, setPhase] = useState("form"); + + // Step 1 — target manager + const [targetManagerAgentId, setTargetManagerAgentId] = useState(null); + const [fullCompany, setFullCompany] = useState(false); + const canBypassManager = team.recommendedForCompanyTypes.includes("company-root"); + + // Step 2 — source policy (the strict API exposes 3 booleans) + const [allowExternalSources, setAllowExternalSources] = useState(false); + const [allowUnpinnedOptionalSources, setAllowUnpinnedOptionalSources] = useState(false); + const [allowLocalPathSources, setAllowLocalPathSources] = useState(false); + + // Step 4 — preview controls + const [collisionStrategy, setCollisionStrategy] = useState("rename"); + const [nameOverrides, setNameOverrides] = useState>({}); + const [confirmScripts, setConfirmScripts] = useState(false); + + const [previewResult, setPreviewResult] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [applyError, setApplyError] = useState(null); + + // Reset on open + useEffect(() => { + if (open) { + setStepIndex(0); + setPhase("form"); + setTargetManagerAgentId(null); + setFullCompany(false); + setAllowExternalSources(false); + setAllowUnpinnedOptionalSources(false); + setAllowLocalPathSources(false); + setCollisionStrategy("rename"); + setNameOverrides({}); + setConfirmScripts(false); + setPreviewResult(null); + setPreviewError(null); + setApplyError(null); + } + }, [open]); + + const currentStep = steps[stepIndex]; + + const buildOptions = () => ({ + targetManagerAgentId: fullCompany ? null : targetManagerAgentId, + collisionStrategy, + nameOverrides: Object.keys(nameOverrides).length > 0 ? nameOverrides : undefined, + sourcePolicy: { + allowExternalSources, + allowUnpinnedOptionalSources, + allowLocalPathSources, + }, + }); + + const previewMutation = useMutation({ + mutationFn: () => teamCatalogApi.preview(companyId, team.id, buildOptions()), + onSuccess: (result) => { + setPreviewResult(result); + setPreviewError(null); + }, + onError: (error) => { + setPreviewError(error instanceof Error ? error.message : "Failed to load install preview."); + }, + }); + + const installMutation = useMutation({ + mutationFn: () => teamCatalogApi.install(companyId, team.id, buildOptions()), + onMutate: () => { + setPhase("applying"); + setApplyError(null); + }, + onSuccess: () => { + setPhase("done"); + onInstalled(); + }, + onError: (error) => { + setPhase("error"); + setApplyError(error instanceof Error ? error.message : "Install failed."); + }, + }); + + // Auto-load preview when reaching the preview step. + const previewRequested = useRef(false); + useEffect(() => { + if (currentStep === "preview" && !previewRequested.current && !previewMutation.isPending) { + previewRequested.current = true; + previewMutation.mutate(); + } + if (currentStep !== "preview") previewRequested.current = false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStep]); + + const targetManagerResolved = fullCompany || Boolean(targetManagerAgentId); + + function canContinue(step: WizardStep): boolean { + if (step === "target_manager") return targetManagerResolved; + if (step === "source_policy") { + // Block forward when an unsupported source is present and not (cannot be) allowed. + const hasUnsupported = team.sourceRefs.some((s) => sourceWarningCode(s) === "unsupported_in_ui"); + if (hasUnsupported && !allowLocalPathSources) return false; + return true; + } + return true; + } + + const hasErrors = (previewResult?.errors.length ?? 0) > 0; + const blockedCount = previewResult?.errors.length ?? 0; + const needsScriptsConfirm = team.trustLevel === "scripts_executables"; + + function goNext() { + if (stepIndex < steps.length - 1) setStepIndex((i) => i + 1); + } + function goBack() { + if (stepIndex > 0) setStepIndex((i) => i - 1); + } + + function submitInstall() { + if (needsScriptsConfirm && !confirmScripts) { + setConfirmScripts(true); + return; + } + installMutation.mutate(); + } + + const totalSteps = steps.length; + + return ( + { if (!next && phase !== "applying") onClose(); }}> + + + + + Install {team.name} + + {phase === "form" && ( + + Step {stepIndex + 1} of {totalSteps} · {STEP_LABELS[currentStep]} + + )} + + + {phase === "form" && ( +
+ {currentStep === "target_manager" && ( + { setTargetManagerAgentId(id); setFullCompany(false); }} + fullCompany={fullCompany} + onToggleFullCompany={(v) => { setFullCompany(v); if (v) setTargetManagerAgentId(null); }} + canBypassManager={canBypassManager} + /> + )} + + {currentStep === "source_policy" && ( + { + if (key === "external") setAllowExternalSources(value); + if (key === "unpinned") setAllowUnpinnedOptionalSources(value); + if (key === "localPath") setAllowLocalPathSources(value); + }} + /> + )} + + {currentStep === "skill_plan" && ( + + )} + + {currentStep === "preview" && ( + { setCollisionStrategy(s); previewRequested.current = false; previewMutation.mutate(); }} + nameOverrides={nameOverrides} + onRename={(slug, name) => setNameOverrides((cur) => ({ ...cur, [slug]: name }))} + onRetry={() => previewMutation.mutate()} + /> + )} +
+ )} + + {phase === "applying" && } + {phase === "done" && } + {phase === "error" && ( +
+
+ +
+

Install failed

+

{applyError}

+

+ Partial state is not rolled back. Review the company activity log before retrying. +

+
+
+
+ )} + + {phase === "form" && ( + +
+ {stepIndex > 0 ? ( + + ) : ( + + )} +
+
+ {currentStep === "preview" && hasErrors && ( + + Install blocked: {blockedCount} error{blockedCount === 1 ? "" : "s"} + + )} + {currentStep === "preview" ? ( + needsScriptsConfirm && confirmScripts ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ )} + + {phase === "error" && ( + + + + )} +
+
+ ); +} + +function StepTargetManager({ + team, + agents, + targetManagerAgentId, + onPickManager, + fullCompany, + onToggleFullCompany, + canBypassManager, +}: { + team: CatalogTeam; + agents: Agent[]; + targetManagerAgentId: string | null; + onPickManager: (id: string) => void; + fullCompany: boolean; + onToggleFullCompany: (v: boolean) => void; + canBypassManager: boolean; +}) { + return ( +
+
+ This team's root agents need a manager in your company. Pick the agent who will become + their parent. Internal team hierarchy is preserved. +
+ +
+ Root agents +
    + {team.rootAgentSlugs.map((slug) => ( +
  • + + {titleCase(slug)} + + → ? + +
  • + ))} +
+
+ + {!fullCompany && ( +
+ Target manager + + + + No agents found. + + {agents.map((agent) => ( + onPickManager(agent.id)} + > +
+ + {agent.name} + {agent.role} + {targetManagerAgentId === agent.id && } +
+
+ ))} +
+
+
+
+ )} + + {canBypassManager && ( + + )} +
+ ); +} + +function StepSourcePolicy({ + team, + allowExternalSources, + allowUnpinnedOptionalSources, + allowLocalPathSources, + onChange, +}: { + team: CatalogTeam; + allowExternalSources: boolean; + allowUnpinnedOptionalSources: boolean; + allowLocalPathSources: boolean; + onChange: (key: "external" | "unpinned" | "localPath", value: boolean) => void; +}) { + const external = team.sourceRefs.filter((s) => s.type !== "include"); + const hasUnsupported = external.some((s) => sourceWarningCode(s) === "unsupported_in_ui"); + return ( +
+
+ This team references {external.length} external source{external.length === 1 ? "" : "s"}. + Review each one and decide what to allow before continuing. +
+ +
    + {external.map((source) => { + const Icon = sourceKindIcon(source.type); + const code = sourceWarningCode(source); + return ( +
  • + +
    +

    {source.ref}

    +

    + {code === "ok" && "pinned"} + {code === "unpinned" && "unpinned reference"} + {code === "unsupported_in_ui" && "not installable from the browser"} +

    +
    + + {source.type} + +
  • + ); + })} +
+ +
+ onChange("external", v)} + /> + onChange("unpinned", v)} + /> + onChange("localPath", v)} + /> +
+ + {hasUnsupported && !allowLocalPathSources && ( +

+ This team has local-path sources. Enable “Allow local-path sources” to continue, + or install it from the CLI. +

+ )} +
+ ); +} + +function PolicyToggle({ + label, + description, + checked, + onChange, +}: { + label: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ); +} + +const SKILL_ACTION_META: Record< + CatalogTeamSkillPreparation["action"], + { label: string; tone: string } +> = { + already_in_package: { label: "Bundled in package", tone: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30" }, + catalog_install_required: { label: "Will install from catalog", tone: "text-blue-600 dark:text-blue-300 border-blue-500/30" }, + external_import_required: { label: "Will import from source", tone: "text-amber-600 dark:text-amber-300 border-amber-500/30" }, + blocked: { label: "Blocked", tone: "text-rose-600 dark:text-rose-300 border-rose-500/30" }, +}; + +function StepSkillPlan({ + team, + preparations, +}: { + team: CatalogTeam; + preparations: CatalogTeamSkillPreparation[] | null; +}) { + // Use the live preparations when a preview has run; otherwise fall back to the + // static required-skill list (read-only — the strict API does not accept a + // per-skill plan override, design §7 graceful degradation). + return ( +
+
+ Before agents are imported, the catalog resolves the skills they depend on. This is the + resolution plan. +
+
    + {(preparations ?? team.requiredSkills.map(toPreparation)).map((prep) => { + const meta = SKILL_ACTION_META[prep.action]; + return ( +
  • + +
    +

    {prep.ref}

    + {prep.reason &&

    {prep.reason}

    } +
    + + {meta.label} + +
  • + ); + })} +
+
+ ); +} + +function toPreparation(skill: CatalogTeamSkillRequirement): CatalogTeamSkillPreparation { + return { + type: skill.type, + ref: skill.ref, + agentSlugs: skill.agentSlugs, + action: + skill.type === "catalog" + ? "catalog_install_required" + : skill.resolved + ? "already_in_package" + : "external_import_required", + catalogSkillId: skill.catalogSkillId ?? null, + catalogSkillKey: skill.catalogSkillKey ?? null, + sourceLocator: skill.sourceLocator ?? null, + sourceRef: skill.sourceRef ?? null, + reason: null, + }; +} + +const PLAN_ACTION_TONE: Record = { + create: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30", + update: "text-amber-600 dark:text-amber-300 border-amber-500/30", + skip: "text-muted-foreground border-border", +}; + +function PlanRow({ + slug, + action, + plannedName, + reason, + canRename, + override, + onRename, +}: { + slug: string; + action: string; + plannedName: string; + reason: string | null; + canRename: boolean; + override?: string; + onRename?: (slug: string, name: string) => void; +}) { + return ( +
  • + + {action} + + {slug} + + {canRename && onRename ? ( + onRename(slug, e.target.value)} + className="h-7 max-w-[14rem] font-mono text-xs" + /> + ) : ( + {plannedName} + )} + {reason && {reason}} +
  • + ); +} + +function StepPreview({ + team, + loading, + error, + result, + collisionStrategy, + onCollisionStrategyChange, + nameOverrides, + onRename, + onRetry, +}: { + team: CatalogTeam; + loading: boolean; + error: string | null; + result: CatalogTeamImportPreviewResult | null; + collisionStrategy: CompanyPortabilityCollisionStrategy; + onCollisionStrategyChange: (s: CompanyPortabilityCollisionStrategy) => void; + nameOverrides: Record; + onRename: (slug: string, name: string) => void; + onRetry: () => void; +}) { + if (loading && !result) { + return ( +
    + Preparing preview… +
    + ); + } + if (error) { + return ( +
    +
    + + {error} +
    + +
    + ); + } + if (!result) return null; + + const plan = result.portabilityPreview.plan; + const envInputs = result.portabilityPreview.envInputs; + const canRename = collisionStrategy === "rename"; + + return ( +
    + {/* Summary */} +
    + Summary +
    + + + + +
    +
    + + {/* Collision strategy */} +
    + Collision strategy + +
    + + {/* Errors / warnings */} + {result.errors.length > 0 && ( +
    +

    Install blocked

    +
      + {result.errors.map((e, i) =>
    • {e}
    • )} +
    +
    + )} + {result.warnings.length > 0 && ( +
    +
      + {result.warnings.map((w, i) =>
    • {w}
    • )} +
    +
    + )} + + {/* Agents */} + {plan.agentPlans.length > 0 && ( + + {plan.agentPlans.map((p) => ( + + ))} + + )} + + {/* Projects */} + {plan.projectPlans.length > 0 && ( + + {plan.projectPlans.map((p) => ( + + ))} + + )} + + {/* Starter tasks */} + {plan.issuePlans.length > 0 && ( + + {plan.issuePlans.map((p) => ( + + ))} + + )} + + {/* Env inputs (read-only) */} + {envInputs.length > 0 && ( + + {envInputs.map((input) => ( +
  • + + {input.key} + {input.description && {input.description}} + + {input.kind} + +
  • + ))} +
  • + Secret values are configured on the imported agents after install. +
  • +
    + )} + + {/* Provenance */} +
    + Imported entities are stamped with metadata.paperclip.catalogTeam{" "} + ({team.packageName ?? team.key}, content hash {team.contentHash.slice(0, 16)}…), + and an activity event is recorded for preview and install. +
    +
    + ); +} + +function SummaryCount({ label, value }: { label: string; value: number }) { + return ( +
    + {value} +

    {label}

    +
    + ); +} + +function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
    +
    + {title} +
    +
      {children}
    +
    + ); +} + +function ApplyProgress({ team }: { team: CatalogTeam }) { + const steps = [ + "Installing prerequisite skills", + "Importing agents", + "Importing projects", + "Importing routines", + "Stamping provenance metadata", + "Recording activity events", + ]; + return ( +
    +

    Installing {team.name}…

    + {steps.map((label) => ( +
    + + {label} +
    + ))} +
    + ); +} + +function ApplySuccess({ team, onClose }: { team: CatalogTeam; onClose: () => void }) { + return ( +
    +
    + +

    Team installed

    +
    +

    + {team.name} was imported into your company. Imported agents, projects, and routines are + stamped with catalog provenance. +

    + + + + +
    + ); +} + +// --------------------------------------------------------------------------- +// Browse list +// --------------------------------------------------------------------------- + +function TeamRow({ + team, + selected, + onSelect, +}: { + team: CatalogTeam; + selected: boolean; + onSelect: () => void; +}) { + const risk = teamRisk(team); + return ( + + ); +} + +type KindFilter = "all" | "bundled" | "optional"; +type RiskFilter = "any" | "safe" | "has_warnings" | "blocked"; + +function matchesSearch(team: CatalogTeam, q: string): boolean { + if (!q) return true; + const needle = q.toLowerCase(); + const haystack = [ + team.name, + team.description, + team.category, + ...team.tags, + ...team.agentSlugs, + ] + .join(" ") + .toLowerCase(); + return haystack.includes(needle); +} + +export function TeamCatalog() { + const { "*": routePath } = useParams<{ "*": string }>(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToastActions(); + + const parsedRoute = useMemo(() => parseTeamRoute(routePath), [routePath]); + const selectedRef = parsedRoute.catalogRef; + const selectedFilePath = parsedRoute.filePath; + + const q = searchParams.get("q") ?? ""; + const kindFilter = (searchParams.get("kind") as KindFilter) ?? "all"; + const categoryFilter = searchParams.get("category") ?? ""; + const riskFilter = (searchParams.get("risk") as RiskFilter) ?? "any"; + + const [installOpen, setInstallOpen] = useState(false); + + useEffect(() => { + setBreadcrumbs([ + { label: "Org Chart", href: "/org" }, + { label: "Teams", href: "/teams" }, + ]); + }, [setBreadcrumbs]); + + const catalogQuery = useQuery({ + queryKey: queryKeys.teamCatalog.catalog({ kind: kindFilter === "all" ? undefined : kindFilter }), + queryFn: () => teamCatalogApi.catalogList(kindFilter === "all" ? {} : { kind: kindFilter }), + enabled: Boolean(selectedCompanyId), + }); + + const teams = catalogQuery.data ?? []; + + const categories = useMemo( + () => Array.from(new Set(teams.map((t) => t.category))).sort(), + [teams], + ); + + const filtered = useMemo(() => { + return teams.filter((team) => { + if (kindFilter !== "all" && team.kind !== kindFilter) return false; + if (categoryFilter && team.category !== categoryFilter) return false; + if (riskFilter !== "any" && teamRisk(team) !== riskFilter) return false; + if (!matchesSearch(team, q)) return false; + return true; + }); + }, [teams, kindFilter, categoryFilter, riskFilter, q]); + + const selectedTeam = useMemo( + () => teams.find((t) => t.id === selectedRef || t.key === selectedRef || t.slug === selectedRef) ?? null, + [teams, selectedRef], + ); + + // Auto-select the first team when none is in the route. + useEffect(() => { + if (!selectedRef && filtered[0]) { + navigate(teamRoute(filtered[0].id), { replace: true }); + } + }, [selectedRef, filtered, navigate]); + + const fileQuery = useQuery({ + queryKey: queryKeys.teamCatalog.catalogFile(selectedTeam?.id ?? "", selectedFilePath ?? ""), + queryFn: () => teamCatalogApi.catalogFile(selectedTeam!.id, selectedFilePath!), + enabled: Boolean(selectedTeam && selectedFilePath), + }); + + const agentsQuery = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId ?? ""), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + function setFilterParam(key: string, value: string | null) { + setSearchParams((current) => { + const next = new URLSearchParams(current); + if (value === null || value === "" || value === "all" || value === "any") next.delete(key); + else next.set(key, value); + return next; + }); + } + + const anyFilterActive = q !== "" || kindFilter !== "all" || categoryFilter !== "" || riskFilter !== "any"; + + const grouped = useMemo(() => { + const bundled = filtered.filter((t) => t.kind === "bundled"); + const optional = filtered.filter((t) => t.kind === "optional"); + return { bundled, optional }; + }, [filtered]); + + const canInstall = true; // server enforces; UI shows the affordance to operators + + if (!selectedCompanyId) { + return ( +
    + +
    + ); + } + + return ( +
    + {/* Toolbar */} +
    +

    Teams

    +
    + + setFilterParam("q", e.target.value)} + placeholder="Search teams" + className="h-8 w-56 pl-8" + /> +
    + + + + + + + Kind + setFilterParam("kind", v)}> + All kinds + Bundled + Optional + + + + + {categories.length > 0 && ( + + + + + + Category + setFilterParam("category", v)}> + All categories + {categories.map((cat) => ( + {titleCase(cat)} + ))} + + + + )} + + + + + + + Risk + setFilterParam("risk", v)}> + Any risk + Safe only + Has warnings + Blocked + + {anyFilterActive && ( + <> + + + + )} + + + + {anyFilterActive && ( + + )} +
    + +
    + {/* List column */} +
    + {catalogQuery.isLoading ? ( +
    + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
    + ) : catalogQuery.isError ? ( +
    +
    + Failed to load team catalog. +
    + +
    + ) : teams.length === 0 ? ( + + ) : filtered.length === 0 ? ( + setSearchParams(new URLSearchParams())} + /> + ) : ( +
    + {grouped.bundled.length > 0 && ( + <> +
    + Bundled · {grouped.bundled.length} +
    + {grouped.bundled.map((team) => ( + navigate(teamRoute(team.id))} + /> + ))} + + )} + {grouped.optional.length > 0 && ( + <> +
    + Optional · {grouped.optional.length} +
    + {grouped.optional.map((team) => ( + navigate(teamRoute(team.id))} + /> + ))} + + )} +
    + )} +
    + + {/* Detail pane */} + {selectedTeam ? ( + + navigate(path ? teamRoute(selectedTeam.id, path) : teamRoute(selectedTeam.id)) + } + onInstall={() => setInstallOpen(true)} + canInstall={canInstall} + fileContent={fileQuery.data?.content ?? null} + /> + ) : ( +
    + Select a team to view details. +
    + )} +
    + + {selectedTeam && installOpen && ( + setInstallOpen(false)} + onInstalled={() => { + pushToast({ tone: "success", title: "Team installed", body: `${selectedTeam.name} was imported.` }); + }} + /> + )} +
    + ); +}