diff --git a/screenshots/PAP-10242/01-browse-light.png b/screenshots/PAP-10242/01-browse-light.png new file mode 100644 index 00000000000..1f65b7b8aa4 Binary files /dev/null and b/screenshots/PAP-10242/01-browse-light.png differ diff --git a/screenshots/PAP-10242/02-detail-dark.png b/screenshots/PAP-10242/02-detail-dark.png new file mode 100644 index 00000000000..9bad4b3107c Binary files /dev/null and b/screenshots/PAP-10242/02-detail-dark.png differ diff --git a/screenshots/PAP-10242/02-detail-light.png b/screenshots/PAP-10242/02-detail-light.png new file mode 100644 index 00000000000..083bb510711 Binary files /dev/null and b/screenshots/PAP-10242/02-detail-light.png differ diff --git a/screenshots/PAP-10242/03-install-target-manager-light.png b/screenshots/PAP-10242/03-install-target-manager-light.png new file mode 100644 index 00000000000..7470b832465 Binary files /dev/null and b/screenshots/PAP-10242/03-install-target-manager-light.png differ diff --git a/screenshots/PAP-10242/04-install-source-policy-light.png b/screenshots/PAP-10242/04-install-source-policy-light.png new file mode 100644 index 00000000000..96b5e6cad1b Binary files /dev/null and b/screenshots/PAP-10242/04-install-source-policy-light.png differ diff --git a/screenshots/PAP-10242/05-install-skill-plan-light.png b/screenshots/PAP-10242/05-install-skill-plan-light.png new file mode 100644 index 00000000000..c5b41d630de Binary files /dev/null and b/screenshots/PAP-10242/05-install-skill-plan-light.png differ diff --git a/screenshots/PAP-10242/06-install-preview-dark.png b/screenshots/PAP-10242/06-install-preview-dark.png new file mode 100644 index 00000000000..c7bda717754 Binary files /dev/null and b/screenshots/PAP-10242/06-install-preview-dark.png differ diff --git a/screenshots/PAP-10242/06-install-preview-light.png b/screenshots/PAP-10242/06-install-preview-light.png new file mode 100644 index 00000000000..ae2a1447343 Binary files /dev/null and b/screenshots/PAP-10242/06-install-preview-light.png differ diff --git a/screenshots/PAP-10242/07-install-blocked-light.png b/screenshots/PAP-10242/07-install-blocked-light.png new file mode 100644 index 00000000000..533e82357f3 Binary files /dev/null and b/screenshots/PAP-10242/07-install-blocked-light.png differ diff --git a/screenshots/PAP-10242/08-apply-progress-light.png b/screenshots/PAP-10242/08-apply-progress-light.png new file mode 100644 index 00000000000..72c32dd9904 Binary files /dev/null and b/screenshots/PAP-10242/08-apply-progress-light.png differ diff --git a/screenshots/PAP-10242/09-success-light.png b/screenshots/PAP-10242/09-success-light.png new file mode 100644 index 00000000000..b99820b1be2 Binary files /dev/null and b/screenshots/PAP-10242/09-success-light.png differ diff --git a/screenshots/PAP-10242/mobile-detail-light.png b/screenshots/PAP-10242/mobile-detail-light.png new file mode 100644 index 00000000000..5dc5f04c572 Binary files /dev/null and b/screenshots/PAP-10242/mobile-detail-light.png differ diff --git a/screenshots/PAP-10242/mobile-install-preview-light.png b/screenshots/PAP-10242/mobile-install-preview-light.png new file mode 100644 index 00000000000..003f6f6858c Binary files /dev/null and b/screenshots/PAP-10242/mobile-install-preview-light.png differ diff --git a/scripts/screenshot-pap-10242-storybook.mjs b/scripts/screenshot-pap-10242-storybook.mjs new file mode 100644 index 00000000000..ec275bc0c70 --- /dev/null +++ b/scripts/screenshot-pap-10242-storybook.mjs @@ -0,0 +1,58 @@ +#!/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 BASE = process.env.SB_BASE ?? "http://127.0.0.1:6099"; +const outDir = path.join(repoRoot, "screenshots/PAP-10242"); +await fs.mkdir(outDir, { recursive: true }); + +const PREFIX = "surfaces-team-catalog"; +const desktop = [ + ["browse-list", "01-browse"], + ["detail-pane", "02-detail"], + ["install-target-manager", "03-install-target-manager"], + ["install-source-policy", "04-install-source-policy"], + ["install-skill-plan", "05-install-skill-plan"], + ["install-preview", "06-install-preview"], + ["install-preview-blocked", "07-install-blocked"], + ["install-apply-progress", "08-apply-progress"], + ["install-success", "09-success"], +]; +const mobile = [ + ["detail-pane", "mobile-detail"], + ["install-preview", "mobile-install-preview"], +]; + +const browser = await chromium.launch({ + executablePath: "/srv/paperclip/home/.cache/ms-playwright/chromium-1223/chrome-linux/chrome", + args: ["--no-sandbox"], +}); +const captured = []; +try { + async function capture(viewport, theme, jobs, suffix) { + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + for (const [storyId, slug] of jobs) { + const url = `${BASE}/iframe.html?id=${PREFIX}--${storyId}&viewMode=story&globals=theme:${theme}`; + await page.goto(url, { waitUntil: "networkidle" }).catch(() => {}); + await page.waitForTimeout(900); + const target = path.join(outDir, `${slug}-${theme}${suffix}.png`); + await page.screenshot({ path: target, fullPage: true }); + captured.push(path.basename(target)); + console.log("captured", path.basename(target)); + } + await context.close(); + } + + await capture({ width: 1440, height: 900 }, "light", desktop, ""); + await capture({ width: 1440, height: 900 }, "dark", [["detail-pane", "02-detail"], ["install-preview", "06-install-preview"]], ""); + await capture({ width: 390, height: 844 }, "light", mobile, ""); +} finally { + await browser.close(); +} +console.log(`\n${captured.length} screenshots written to screenshots/PAP-10242/`); diff --git a/ui/src/pages/TeamCatalog.test.tsx b/ui/src/pages/TeamCatalog.test.tsx index 3906d45c0e5..12b231cf570 100644 --- a/ui/src/pages/TeamCatalog.test.tsx +++ b/ui/src/pages/TeamCatalog.test.tsx @@ -67,6 +67,19 @@ class ResizeObserverStub { } // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub; +// jsdom has no matchMedia. Default to desktop (min-width queries match, max-width don't). +if (typeof window !== "undefined" && !window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: /min-width/.test(query), + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; +} if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } diff --git a/ui/src/pages/TeamCatalog.tsx b/ui/src/pages/TeamCatalog.tsx index f6ccbb04dea..ab6219eb101 100644 --- a/ui/src/pages/TeamCatalog.tsx +++ b/ui/src/pages/TeamCatalog.tsx @@ -11,10 +11,14 @@ import type { CatalogTeamSourceRef, CatalogTeamTrustLevel, CatalogTeamCompatibility, + CatalogTeamInstallResult, + CompanyPortabilityAdapterOverride, CompanyPortabilityCollisionStrategy, } from "@paperclipai/shared"; +import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import { teamCatalogApi } from "../api/teamCatalog"; import { agentsApi } from "../api/agents"; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; @@ -50,6 +54,13 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { DropdownMenu, @@ -67,7 +78,9 @@ import { Check, CheckCircle2, ChevronDown, + ChevronLeft, ChevronRight, + Cpu, Crown, Download, FileText, @@ -89,6 +102,25 @@ import { XOctagon, } from "lucide-react"; +// Matches design §11 breakpoints. Module-level so stories and the page agree. +const DESKTOP_MIN = 1024; +const MOBILE_MAX = 767; + +function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => + typeof window !== "undefined" ? window.matchMedia(query).matches : false, + ); + useEffect(() => { + if (typeof window === "undefined") return; + const mql = window.matchMedia(query); + const onChange = () => setMatches(mql.matches); + onChange(); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, [query]); + return matches; +} + // --------------------------------------------------------------------------- // Risk model — derived client-side from the team's source refs (design §8). // --------------------------------------------------------------------------- @@ -592,7 +624,7 @@ function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) { ); } -function TeamDetailPane({ +export function TeamDetailPane({ team, selectedPath, onSelectFile, @@ -813,11 +845,14 @@ function TeamInstallerDialog({ // Step 4 — preview controls const [collisionStrategy, setCollisionStrategy] = useState("rename"); const [nameOverrides, setNameOverrides] = useState>({}); + // slug -> adapterType override (the install schema accepts adapterOverrides). + const [adapterOverrides, setAdapterOverrides] = useState>({}); const [confirmScripts, setConfirmScripts] = useState(false); const [previewResult, setPreviewResult] = useState(null); const [previewError, setPreviewError] = useState(null); const [applyError, setApplyError] = useState(null); + const [installResult, setInstallResult] = useState(null); // Reset on open useEffect(() => { @@ -831,16 +866,19 @@ function TeamInstallerDialog({ setAllowLocalPathSources(false); setCollisionStrategy("rename"); setNameOverrides({}); + setAdapterOverrides({}); setConfirmScripts(false); setPreviewResult(null); setPreviewError(null); setApplyError(null); + setInstallResult(null); } }, [open]); const currentStep = steps[stepIndex]; - const buildOptions = () => ({ + // Preview body — the preview schema is strict and does NOT accept adapterOverrides. + const buildPreviewOptions = () => ({ targetManagerAgentId: fullCompany ? null : targetManagerAgentId, collisionStrategy, nameOverrides: Object.keys(nameOverrides).length > 0 ? nameOverrides : undefined, @@ -851,8 +889,20 @@ function TeamInstallerDialog({ }, }); + // Install body extends the preview body with adapterOverrides. + const buildInstallOptions = () => { + const overrides: Record = {}; + for (const [slug, adapterType] of Object.entries(adapterOverrides)) { + if (adapterType) overrides[slug] = { adapterType }; + } + return { + ...buildPreviewOptions(), + adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined, + }; + }; + const previewMutation = useMutation({ - mutationFn: () => teamCatalogApi.preview(companyId, team.id, buildOptions()), + mutationFn: () => teamCatalogApi.preview(companyId, team.id, buildPreviewOptions()), onSuccess: (result) => { setPreviewResult(result); setPreviewError(null); @@ -863,12 +913,13 @@ function TeamInstallerDialog({ }); const installMutation = useMutation({ - mutationFn: () => teamCatalogApi.install(companyId, team.id, buildOptions()), + mutationFn: () => teamCatalogApi.install(companyId, team.id, buildInstallOptions()), onMutate: () => { setPhase("applying"); setApplyError(null); }, - onSuccess: () => { + onSuccess: (result) => { + setInstallResult(result); setPhase("done"); onInstalled(); }, @@ -922,24 +973,38 @@ function TeamInstallerDialog({ } const totalSteps = steps.length; + const isMobileSheet = useMediaQuery(`(max-width: ${MOBILE_MAX}px)`); - return ( - { if (!next && phase !== "applying") onClose(); }}> - - - - - Install {team.name} - - {phase === "form" && ( - - Step {stepIndex + 1} of {totalSteps} · {STEP_LABELS[currentStep]} - - )} - + const headerTitle = ( + + + Install {team.name} + + ); + const headerDescription = + phase === "form" ? ( + + + Step {stepIndex + 1} of {totalSteps} · {STEP_LABELS[currentStep]} + + + {steps.map((s, i) => ( + + ))} + + + ) : null; + const body = ( + <> {phase === "form" && ( -
+
{currentStep === "target_manager" && ( { setCollisionStrategy(s); previewRequested.current = false; previewMutation.mutate(); }} nameOverrides={nameOverrides} onRename={(slug, name) => setNameOverrides((cur) => ({ ...cur, [slug]: name }))} + adapterOverrides={adapterOverrides} + onAdapterChange={(slug, adapterType) => setAdapterOverrides((cur) => ({ ...cur, [slug]: adapterType }))} onRetry={() => previewMutation.mutate()} /> )} @@ -987,7 +1054,7 @@ function TeamInstallerDialog({ )} {phase === "applying" && } - {phase === "done" && } + {phase === "done" && } {phase === "error" && (
@@ -1002,61 +1069,81 @@ function TeamInstallerDialog({
)} + + ); - {phase === "form" && ( - -
- {stepIndex > 0 ? ( - - ) : ( - - )} -
-
- {currentStep === "preview" && hasErrors && ( - - Install blocked: {blockedCount} error{blockedCount === 1 ? "" : "s"} - - )} - {currentStep === "preview" ? ( - needsScriptsConfirm && confirmScripts ? ( - - ) : ( - - ) - ) : ( - - )} -
-
- )} + const footer = + phase === "form" ? ( +
+
+ {stepIndex > 0 ? ( + + ) : ( + + )} +
+
+ {currentStep === "preview" && hasErrors && ( + + Install blocked: {blockedCount} error{blockedCount === 1 ? "" : "s"} + + )} + {currentStep === "preview" ? ( + needsScriptsConfirm && confirmScripts ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ) : phase === "error" ? ( +
+ +
+ ) : null; - {phase === "error" && ( - - - - )} + const dismissable = phase !== "applying"; + + // <768px → full-height Sheet with sticky footer (design §11); otherwise Dialog. + if (isMobileSheet) { + return ( + { if (!next && dismissable) onClose(); }}> + + + {headerTitle} + {headerDescription && {headerDescription}} + +
{body}
+ {footer &&
{footer}
} +
+
+ ); + } + + return ( + { if (!next && dismissable) onClose(); }}> + + + {headerTitle} + {headerDescription && {headerDescription}} + + {body} + {footer && {footer}} ); } -function StepTargetManager({ +export function StepTargetManager({ team, agents, targetManagerAgentId, @@ -1140,7 +1227,7 @@ function StepTargetManager({ ); } -function StepSourcePolicy({ +export function StepSourcePolicy({ team, allowExternalSources, allowUnpinnedOptionalSources, @@ -1258,7 +1345,7 @@ const SKILL_ACTION_META: Record< blocked: { label: "Blocked", tone: "text-rose-600 dark:text-rose-300 border-rose-500/30" }, }; -function StepSkillPlan({ +export function StepSkillPlan({ team, preparations, }: { @@ -1358,7 +1445,7 @@ function PlanRow({ ); } -function StepPreview({ +export function StepPreview({ team, loading, error, @@ -1367,6 +1454,8 @@ function StepPreview({ onCollisionStrategyChange, nameOverrides, onRename, + adapterOverrides, + onAdapterChange, onRetry, }: { team: CatalogTeam; @@ -1377,6 +1466,8 @@ function StepPreview({ onCollisionStrategyChange: (s: CompanyPortabilityCollisionStrategy) => void; nameOverrides: Record; onRename: (slug: string, name: string) => void; + adapterOverrides: Record; + onAdapterChange: (slug: string, adapterType: string) => void; onRetry: () => void; }) { if (loading && !result) { @@ -1403,6 +1494,7 @@ function StepPreview({ const plan = result.portabilityPreview.plan; const envInputs = result.portabilityPreview.envInputs; + const manifestAgents = result.portabilityPreview.manifest.agents; const canRename = collisionStrategy === "rename"; return ( @@ -1494,7 +1586,37 @@ function StepPreview({ )} - {/* Env inputs (read-only) */} + {/* Adapter selection — install schema accepts adapterOverrides (design §4.4) */} + {manifestAgents.length > 0 && ( + + {manifestAgents.map((agent) => { + const selected = adapterOverrides[agent.slug] ?? agent.adapterType; + return ( +
  • + + {agent.name} + {agent.slug} + +
  • + ); + })} +
  • + Each imported agent defaults to its package adapter; override here before install. + Deeper per-adapter model config is editable on the agent after install. +
  • +
    + )} + + {/* Env inputs (read-only — install schema does not yet accept secretValues) */} {envInputs.length > 0 && ( {envInputs.map((input) => ( @@ -1502,6 +1624,9 @@ function StepPreview({ {input.key} {input.description && {input.description}} + {input.requirement === "required" && ( + required + )} ))} -
  • - Secret values are configured on the imported agents after install. +
  • + + + The teams-catalog install API does not yet accept secret values, so these are listed + read-only and must be set on each imported agent after install. Inline entry is tracked + in the Phase E follow-up (see comment). +
  • )} @@ -1546,29 +1676,47 @@ function PreviewSection({ title, children }: { title: string; children: React.Re ); } -function ApplyProgress({ team }: { team: CatalogTeam }) { - const steps = [ - "Installing prerequisite skills", - "Importing agents", - "Importing projects", - "Importing routines", - "Stamping provenance metadata", - "Recording activity events", - ]; +// The install API is a single non-streaming POST, so we cannot show truthful +// per-step progress mid-flight. Show one honest in-flight row; the resolved +// per-category checklist is rendered from the real result on the success screen. +export function ApplyProgress({ team }: { team: CatalogTeam }) { return ( -
    -

    Installing {team.name}…

    - {steps.map((label) => ( -
    - - {label} -
    - ))} +
    + +
    +

    Installing {team.name}…

    +

    + Resolving skills, importing agents, projects, and routines. This may take a moment. +

    +
    ); } -function ApplySuccess({ team, onClose }: { team: CatalogTeam; onClose: () => void }) { +function ResultRow({ label, count }: { label: string; count: number }) { + return ( +
  • + + {label} + {count} +
  • + ); +} + +export function ApplySuccess({ + team, + result, + onClose, +}: { + team: CatalogTeam; + result: CatalogTeamInstallResult | null; + onClose: () => void; +}) { + const imp = result?.portabilityImport; + const agentsCreated = imp?.agents.filter((a) => a.action !== "skipped").length ?? 0; + const projectsCreated = imp?.projects.filter((p) => p.action !== "skipped").length ?? 0; + const skillsResolved = result?.skillPreparations.length ?? 0; + const warnings = result?.warnings ?? []; return (
    @@ -1576,18 +1724,31 @@ function ApplySuccess({ team, onClose }: { team: CatalogTeam; onClose: () => voi

    Team installed

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

    + {result && ( +
      + + + +
    + )} + {warnings.length > 0 && ( +
    +
      + {warnings.map((w, i) =>
    • {w}
    • )} +
    +
    + )} - +
    - +
    ); } @@ -1596,7 +1757,7 @@ function ApplySuccess({ team, onClose }: { team: CatalogTeam; onClose: () => voi // Browse list // --------------------------------------------------------------------------- -function TeamRow({ +export function TeamRow({ team, selected, onSelect, @@ -1675,6 +1836,7 @@ export function TeamCatalog() { const riskFilter = (searchParams.get("risk") as RiskFilter) ?? "any"; const [installOpen, setInstallOpen] = useState(false); + const isDesktop = useMediaQuery(`(min-width: ${DESKTOP_MIN}px)`); useEffect(() => { setBreadcrumbs([ @@ -1711,12 +1873,14 @@ export function TeamCatalog() { [teams, selectedRef], ); - // Auto-select the first team when none is in the route. + // Auto-select the first team when none is in the route — desktop only. On + // narrow viewports the list stands alone until the operator picks a team + // (design §11). useEffect(() => { - if (!selectedRef && filtered[0]) { + if (isDesktop && !selectedRef && filtered[0]) { navigate(teamRoute(filtered[0].id), { replace: true }); } - }, [selectedRef, filtered, navigate]); + }, [isDesktop, selectedRef, filtered, navigate]); const fileQuery = useQuery({ queryKey: queryKeys.teamCatalog.catalogFile(selectedTeam?.id ?? "", selectedFilePath ?? ""), @@ -1848,8 +2012,13 @@ export function TeamCatalog() {
    - {/* List column */} -
    + {/* List column — full width on < lg, fixed rail on >= lg (design §11) */} +
    {catalogQuery.isLoading ? (
    {Array.from({ length: 6 }).map((_, i) => ( @@ -1910,21 +2079,39 @@ export function TeamCatalog() { )}
    - {/* 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. + {/* Detail pane — hidden on < lg until a team is selected (design §11) */} + {(isDesktop || selectedTeam) && ( +
    + {!isDesktop && selectedTeam && ( + + )} + {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. +
    + )}
    )}
    diff --git a/ui/storybook/stories/team-catalog.stories.tsx b/ui/storybook/stories/team-catalog.stories.tsx new file mode 100644 index 00000000000..012c5e84d2f --- /dev/null +++ b/ui/storybook/stories/team-catalog.stories.tsx @@ -0,0 +1,376 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { + Agent, + CatalogTeam, + CatalogTeamImportPreviewResult, + CompanyPortabilityCollisionStrategy, +} from "@paperclipai/shared"; +import { + ApplyProgress, + ApplySuccess, + StepPreview, + StepSkillPlan, + StepSourcePolicy, + StepTargetManager, + TeamDetailPane, + TeamRow, +} from "@/pages/TeamCatalog"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const baseTeam: CatalogTeam = { + id: "paperclipai:bundled:company-defaults:core-exec-team", + key: "paperclipai/bundled/company-defaults/core-exec-team", + kind: "bundled", + category: "company-defaults", + slug: "core-exec-team", + name: "Core Exec Team", + description: + "A starter executive team: a CEO who manages a CTO and a CMO, plus a launch project and a weekly standup routine. Installs ready-to-run agents you can customize.", + path: "catalog/bundled/company-defaults/core-exec-team", + entrypoint: "TEAM.md", + schema: "agentcompanies/v1", + defaultInstall: true, + recommendedForCompanyTypes: ["company-root"], + tags: ["exec", "starter"], + counts: { + agents: 3, + projects: 1, + tasks: 1, + routines: 1, + localSkills: 1, + catalogSkills: 1, + externalSkillSources: 1, + }, + rootAgentSlugs: ["ceo"], + agentSlugs: ["ceo", "cto", "cmo"], + projectSlugs: ["launch"], + requiredSkills: [ + { type: "catalog", ref: "engineering/code-review", agentSlugs: ["cto"], resolved: true, catalogSkillKey: "engineering/code-review" }, + { type: "github", ref: "acme/growth-playbook@v1.2.0", agentSlugs: ["cmo"], resolved: false, sourceRef: "v1.2.0" }, + ], + envInputs: [ + { key: "OPENAI_API_KEY", agentSlug: "cto", projectSlug: null, kind: "secret", requirement: "required" }, + { key: "DEFAULT_TIMEZONE", agentSlug: null, projectSlug: "launch", kind: "plain", requirement: "optional" }, + ], + sourceRefs: [ + { type: "github", ref: "acme/growth-playbook@v1.2.0", pinned: true }, + { type: "url", ref: "https://example.com/policies/brand.md", pinned: false }, + ], + files: [ + { path: "TEAM.md", kind: "team", sizeBytes: 2144, sha256: "a1" }, + { path: "README.md", kind: "readme", sizeBytes: 980, sha256: "a2" }, + { path: "agents/ceo/AGENTS.md", kind: "agent", sizeBytes: 1200, sha256: "a3" }, + { path: "agents/cto/AGENTS.md", kind: "agent", sizeBytes: 1100, sha256: "a4" }, + { path: "agents/cmo/AGENTS.md", kind: "agent", sizeBytes: 1050, sha256: "a5" }, + { path: "projects/launch/PROJECT.md", kind: "project", sizeBytes: 640, sha256: "a6" }, + ], + trustLevel: "external_sources", + compatibility: "compatible", + contentHash: "sha256:deadbeefdeadbeefdeadbeefdeadbeef", + packageName: "@paperclipai/teams-catalog", + packageVersion: "0.1.0", +}; + +const optionalTeam: CatalogTeam = { + ...baseTeam, + id: "paperclipai:optional:software-development:platform-pod", + key: "paperclipai/optional/software-development/platform-pod", + kind: "optional", + category: "software-development", + slug: "platform-pod", + name: "Platform Engineering Pod", + description: "An optional platform pod with a tech lead and two engineers.", + recommendedForCompanyTypes: [], + counts: { ...baseTeam.counts, agents: 4, routines: 2 }, + rootAgentSlugs: ["tech-lead"], + agentSlugs: ["tech-lead", "eng-1", "eng-2", "sre"], + trustLevel: "markdown_only", + sourceRefs: [], +}; + +const warnTeam: CatalogTeam = { + ...baseTeam, + id: "paperclipai:optional:research:lab-with-local-source", + slug: "lab-with-local-source", + name: "Research Lab (local source)", + kind: "optional", + category: "research", + trustLevel: "scripts_executables", + sourceRefs: [ + { type: "url", ref: "https://example.com/unpinned.md", pinned: false }, + { type: "local_path", ref: "/Users/dev/skills/secret-sauce", pinned: false }, + ], +}; + +const companyAgents: Agent[] = [ + makeAgent("agent-1", "Founder", "ceo"), + makeAgent("agent-2", "Head of Eng", "engineer"), + makeAgent("agent-3", "Ops Lead", "general"), +]; + +function makeAgent(id: string, name: string, role: string): Agent { + return { + id, + companyId: "company-storybook", + name, + urlKey: name.toLowerCase().replace(/\s+/g, "-"), + role: role as Agent["role"], + title: null, + icon: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "claude_local" as Agent["adapterType"], + adapterConfig: {}, + runtimeConfig: {} as Agent["runtimeConfig"], + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: {} as Agent["permissions"], + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }; +} + +function makePreview(errors: string[] = []): CatalogTeamImportPreviewResult { + return { + team: baseTeam, + portabilityPreview: { + include: { company: false, agents: true, projects: true, issues: false, skills: true }, + targetCompanyId: "company-storybook", + targetCompanyName: "Paperclip", + collisionStrategy: "rename", + selectedAgentSlugs: ["ceo", "cto", "cmo"], + plan: { + companyAction: "none", + agentPlans: [ + { slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null }, + { slug: "cto", action: "create", plannedName: "CTO", existingAgentId: null, reason: null }, + { slug: "cmo", action: "create", plannedName: "CMO (from Core Exec Team)", existingAgentId: "agent-x", reason: "Renamed — name collision with existing agent" }, + ], + projectPlans: [ + { slug: "launch", action: "create", plannedName: "Launch", existingProjectId: null, reason: null }, + ], + issuePlans: [ + { slug: "kickoff", action: "skip", plannedTitle: "Kickoff", reason: "Starter tasks not selected" }, + ], + }, + 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: [ + manifestAgent("ceo", "CEO"), + manifestAgent("cto", "CTO"), + manifestAgent("cmo", "CMO"), + ], + skills: [], + projects: [], + issues: [], + envInputs: [], + }, + files: {}, + envInputs: [ + { key: "OPENAI_API_KEY", description: "API key for the CTO agent", agentSlug: "cto", projectSlug: null, kind: "secret", requirement: "required", defaultValue: null, portability: "system_dependent" }, + { key: "DEFAULT_TIMEZONE", description: "Project timezone", agentSlug: null, projectSlug: "launch", kind: "plain", requirement: "optional", defaultValue: "UTC", portability: "portable" }, + ], + warnings: ["Skill acme/growth-playbook will be imported from an external GitHub source."], + errors, + }, + skillPreparations: [ + { type: "catalog", ref: "engineering/code-review", agentSlugs: ["cto"], action: "already_in_package", catalogSkillId: "skill-1", catalogSkillKey: "engineering/code-review", sourceLocator: null, sourceRef: null, reason: null }, + { type: "github", ref: "acme/growth-playbook@v1.2.0", agentSlugs: ["cmo"], action: "external_import_required", catalogSkillId: null, catalogSkillKey: null, sourceLocator: "github.com/acme/growth-playbook", sourceRef: "v1.2.0", reason: "Resolved from GitHub at install time" }, + ], + warnings: [], + errors, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function manifestAgent(slug: string, name: string): any { + return { + slug, name, path: `agents/${slug}/AGENTS.md`, skills: [], role: slug, title: null, icon: null, + capabilities: null, reportsToSlug: slug === "ceo" ? null : "ceo", adapterType: "claude_local", + adapterConfig: {}, runtimeConfig: {}, permissions: {}, budgetMonthlyCents: 0, metadata: null, + }; +} + +const noop = () => {}; + +function Frame({ children, width = "max-w-3xl" }: { children: React.ReactNode; width?: string }) { + return
    {children}
    ; +} + +const meta: Meta = { + title: "Surfaces/Team Catalog", +}; +export default meta; +type Story = StoryObj; + +export const BrowseList: Story = { + render: () => ( +
    +
    Bundled · 1
    + +
    Optional · 2
    + + +
    + ), +}; + +export const DetailPane: Story = { + render: () => ( +
    + +
    + ), +}; + +export const InstallTargetManager: Story = { + render: function Render() { + const [managerId, setManagerId] = useState(null); + const [fullCompany, setFullCompany] = useState(false); + return ( + + + + ); + }, +}; + +export const InstallSourcePolicy: Story = { + render: function Render() { + const [ext, setExt] = useState(false); + const [unpinned, setUnpinned] = useState(false); + const [local, setLocal] = useState(false); + return ( + + { + if (key === "external") setExt(v); + if (key === "unpinned") setUnpinned(v); + if (key === "localPath") setLocal(v); + }} + /> + + ); + }, +}; + +export const InstallSkillPlan: Story = { + render: () => ( + + + + ), +}; + +export const InstallPreview: Story = { + render: function Render() { + const [collision, setCollision] = useState("rename"); + const [names, setNames] = useState>({}); + const [adapters, setAdapters] = useState>({}); + return ( + + setNames((c) => ({ ...c, [slug]: name }))} + adapterOverrides={adapters} + onAdapterChange={(slug, t) => setAdapters((c) => ({ ...c, [slug]: t }))} + onRetry={noop} + /> + + ); + }, +}; + +export const InstallPreviewBlocked: Story = { + render: () => ( + + + + ), +}; + +export const InstallApplyProgress: Story = { + render: () => ( + + + + ), +}; + +export const InstallSuccess: Story = { + render: () => ( + + + + ), +};