PAP-10242 Phase G: address UX review (apply progress, adapters, responsive, fixtures)

Resolve the Phase F UX review must-fix items on the Team Catalog UI:

- Honest apply progress (#1): the install API is a single non-streaming POST,
  so replace the six perpetual spinners with one truthful in-flight row and a
  result-driven checklist (agents/projects/skills + warnings) on success.
- Adapter selection (#2): Step 4 now lists each imported agent with an adapter
  picker (AGENT_ADAPTER_TYPES), wired to install `adapterOverrides`. Preview vs
  install option builders are split because the preview schema rejects it.
- Secrets honesty (#3): env inputs stay read-only (install schema does not
  accept secretValues) but now carry a required pill and an explicit amber note
  pointing at the Phase E follow-up; tracked in PAP-10253.
- Responsive collapse (#4): list is full-width < lg and collapses to a single
  detail column with "Back to catalog"; install renders as a full-height Sheet
  < 768px. Added a useMediaQuery hook; auto-select is desktop-only.
- Storybook fixture (#5): ui/storybook/stories/team-catalog.stories.tsx renders
  browse/detail and every install step (incl. blocked) with fixtures, exported
  the presentational components, and captured 1440x900 + 390x844 + dark
  screenshots into screenshots/PAP-10242/ (also attached to the issue).
- Added step-indicator dots to the wizard header.

Follow-ups filed as children: PAP-10253 (secrets API), PAP-10254 (onboarding
seam), PAP-10255 (design-guide showcase), PAP-10256 (installed-group badge).

Verified: tsc -b, build-storybook, and vitest (3/3) all pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 03:20:50 +00:00
1 parent 5f59f05129
commit 005849811e
17 files changed
+752 -118

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

@@ -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/`);
+13
View File
@@ -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 = () => {};
}
+305 -118
View File
@@ -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<CompanyPortabilityCollisionStrategy>("rename");
const [nameOverrides, setNameOverrides] = useState<Record<string, string>>({});
// slug -> adapterType override (the install schema accepts adapterOverrides).
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
const [confirmScripts, setConfirmScripts] = useState(false);
const [previewResult, setPreviewResult] = useState<CatalogTeamImportPreviewResult | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [applyError, setApplyError] = useState<string | null>(null);
const [installResult, setInstallResult] = useState<CatalogTeamInstallResult | null>(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<string, CompanyPortabilityAdapterOverride> = {};
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 (
<Dialog open={open} onOpenChange={(next) => { if (!next && phase !== "applying") onClose(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users2 className="h-4 w-4" />
Install {team.name}
</DialogTitle>
{phase === "form" && (
<DialogDescription>
Step {stepIndex + 1} of {totalSteps} · {STEP_LABELS[currentStep]}
</DialogDescription>
)}
</DialogHeader>
const headerTitle = (
<span className="flex items-center gap-2">
<Users2 className="h-4 w-4" />
Install {team.name}
</span>
);
const headerDescription =
phase === "form" ? (
<span className="flex items-center gap-2">
<span>
Step {stepIndex + 1} of {totalSteps} · {STEP_LABELS[currentStep]}
</span>
<span className="flex items-center gap-1" aria-hidden>
{steps.map((s, i) => (
<span
key={s}
className={cn(
"h-1.5 w-1.5 rounded-full",
i === stepIndex ? "bg-primary" : i < stepIndex ? "bg-primary/50" : "bg-muted",
)}
/>
))}
</span>
</span>
) : null;
const body = (
<>
{phase === "form" && (
<div className="max-h-[60vh] space-y-4 overflow-auto pr-1">
<div className="space-y-4 overflow-auto pr-1 md:max-h-[60vh]">
{currentStep === "target_manager" && (
<StepTargetManager
team={team}
@@ -980,6 +1045,8 @@ function TeamInstallerDialog({
onCollisionStrategyChange={(s) => { 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" && <ApplyProgress team={team} />}
{phase === "done" && <ApplySuccess team={team} onClose={onClose} />}
{phase === "done" && <ApplySuccess team={team} result={installResult} onClose={onClose} />}
{phase === "error" && (
<div className="space-y-3">
<div role="alert" className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2.5 text-sm text-rose-700 dark:text-rose-300">
@@ -1002,61 +1069,81 @@ function TeamInstallerDialog({
</div>
</div>
)}
</>
);
{phase === "form" && (
<DialogFooter className="flex items-center sm:justify-between">
<div>
{stepIndex > 0 ? (
<Button variant="ghost" onClick={goBack}>Back</Button>
) : (
<Button variant="ghost" onClick={onClose}>Cancel</Button>
)}
</div>
<div className="flex items-center gap-3">
{currentStep === "preview" && hasErrors && (
<span className="text-xs text-rose-600 dark:text-rose-300">
Install blocked: {blockedCount} error{blockedCount === 1 ? "" : "s"}
</span>
)}
{currentStep === "preview" ? (
needsScriptsConfirm && confirmScripts ? (
<Button
variant="destructive"
onClick={submitInstall}
disabled={hasErrors || previewMutation.isPending}
>
<AlertTriangle className="h-4 w-4" />
Confirm — install with executables
</Button>
) : (
<Button
onClick={submitInstall}
disabled={hasErrors || previewMutation.isPending || !previewResult}
>
{needsScriptsConfirm ? <AlertTriangle className="h-4 w-4" /> : <Download className="h-4 w-4" />}
{needsScriptsConfirm ? "Install with executables" : "Install team"}
</Button>
)
) : (
<Button onClick={goNext} disabled={!canContinue(currentStep)}>
Continue
</Button>
)}
</div>
</DialogFooter>
)}
const footer =
phase === "form" ? (
<div className="flex items-center justify-between gap-3">
<div>
{stepIndex > 0 ? (
<Button variant="ghost" onClick={goBack}>Back</Button>
) : (
<Button variant="ghost" onClick={onClose}>Cancel</Button>
)}
</div>
<div className="flex items-center gap-3">
{currentStep === "preview" && hasErrors && (
<span className="text-xs text-rose-600 dark:text-rose-300">
Install blocked: {blockedCount} error{blockedCount === 1 ? "" : "s"}
</span>
)}
{currentStep === "preview" ? (
needsScriptsConfirm && confirmScripts ? (
<Button variant="destructive" onClick={submitInstall} disabled={hasErrors || previewMutation.isPending}>
<AlertTriangle className="h-4 w-4" />
Confirm — install with executables
</Button>
) : (
<Button onClick={submitInstall} disabled={hasErrors || previewMutation.isPending || !previewResult}>
{needsScriptsConfirm ? <AlertTriangle className="h-4 w-4" /> : <Download className="h-4 w-4" />}
{needsScriptsConfirm ? "Install with executables" : "Install team"}
</Button>
)
) : (
<Button onClick={goNext} disabled={!canContinue(currentStep)}>Continue</Button>
)}
</div>
</div>
) : phase === "error" ? (
<div className="flex justify-end">
<Button variant="ghost" onClick={onClose}>Close</Button>
</div>
) : null;
{phase === "error" && (
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Close</Button>
</DialogFooter>
)}
const dismissable = phase !== "applying";
// <768px → full-height Sheet with sticky footer (design §11); otherwise Dialog.
if (isMobileSheet) {
return (
<Sheet open={open} onOpenChange={(next) => { if (!next && dismissable) onClose(); }}>
<SheetContent side="bottom" className="flex h-[100dvh] flex-col gap-0 p-0">
<SheetHeader className="border-b border-border">
<SheetTitle>{headerTitle}</SheetTitle>
{headerDescription && <SheetDescription>{headerDescription}</SheetDescription>}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-auto p-4">{body}</div>
{footer && <div className="border-t border-border bg-background p-4">{footer}</div>}
</SheetContent>
</Sheet>
);
}
return (
<Dialog open={open} onOpenChange={(next) => { if (!next && dismissable) onClose(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{headerTitle}</DialogTitle>
{headerDescription && <DialogDescription>{headerDescription}</DialogDescription>}
</DialogHeader>
{body}
{footer && <DialogFooter>{footer}</DialogFooter>}
</DialogContent>
</Dialog>
);
}
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<string, string>;
onRename: (slug: string, name: string) => void;
adapterOverrides: Record<string, string>;
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({
</PreviewSection>
)}
{/* Env inputs (read-only) */}
{/* Adapter selection — install schema accepts adapterOverrides (design §4.4) */}
{manifestAgents.length > 0 && (
<PreviewSection title={`Adapter selection · ${manifestAgents.length}`}>
{manifestAgents.map((agent) => {
const selected = adapterOverrides[agent.slug] ?? agent.adapterType;
return (
<li key={agent.slug} className="flex items-center gap-2 px-3 py-2 text-sm">
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
<span className="min-w-0 truncate">{agent.name}</span>
<span className="font-mono text-[11px] text-muted-foreground">{agent.slug}</span>
<Select value={selected} onValueChange={(v) => onAdapterChange(agent.slug, v)}>
<SelectTrigger className="ml-auto h-8 w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENT_ADAPTER_TYPES.map((type) => (
<SelectItem key={type} value={type}>{getAdapterLabel(type)}</SelectItem>
))}
</SelectContent>
</Select>
</li>
);
})}
<li className="px-3 py-1.5 text-[11px] text-muted-foreground">
Each imported agent defaults to its package adapter; override here before install.
Deeper per-adapter model config is editable on the agent after install.
</li>
</PreviewSection>
)}
{/* Env inputs (read-only — install schema does not yet accept secretValues) */}
{envInputs.length > 0 && (
<PreviewSection title={`Secrets & env inputs · ${envInputs.length}`}>
{envInputs.map((input) => (
@@ -1502,6 +1624,9 @@ function StepPreview({
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs uppercase tracking-wide">{input.key}</span>
{input.description && <span className="text-xs text-muted-foreground">{input.description}</span>}
{input.requirement === "required" && (
<Badge variant="outline" className="text-[10px]">required</Badge>
)}
<Badge
variant="outline"
className={cn("ml-auto text-[10px]", input.kind === "secret" ? "text-rose-600 dark:text-rose-300 border-rose-500/30" : "text-muted-foreground")}
@@ -1510,8 +1635,13 @@ function StepPreview({
</Badge>
</li>
))}
<li className="px-3 py-1.5 text-[11px] text-muted-foreground">
Secret values are configured on the imported agents after install.
<li className="flex items-start gap-1.5 px-3 py-2 text-[11px] text-amber-700 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>
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).
</span>
</li>
</PreviewSection>
)}
@@ -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 (
<div className="space-y-1 py-4">
<p className="mb-3 text-sm text-muted-foreground">Installing {team.name}…</p>
{steps.map((label) => (
<div key={label} className="flex items-center gap-3 py-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
{label}
</div>
))}
<div className="flex items-center gap-3 py-10 text-sm">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<div>
<p className="font-medium">Installing {team.name}…</p>
<p className="text-xs text-muted-foreground">
Resolving skills, importing agents, projects, and routines. This may take a moment.
</p>
</div>
</div>
);
}
function ApplySuccess({ team, onClose }: { team: CatalogTeam; onClose: () => void }) {
function ResultRow({ label, count }: { label: string; count: number }) {
return (
<li className="flex items-center gap-2.5 py-1.5 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<span>{label}</span>
<span className="ml-auto font-mono tabular-nums text-muted-foreground">{count}</span>
</li>
);
}
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 (
<div className="space-y-4 py-2">
<div className="flex items-center gap-2">
@@ -1576,18 +1724,31 @@ function ApplySuccess({ team, onClose }: { team: CatalogTeam; onClose: () => voi
<p className="text-base font-semibold">Team installed</p>
</div>
<p className="text-sm text-muted-foreground">
{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.
</p>
{result && (
<ul className="divide-y divide-border/60 rounded-md border border-border px-3">
<ResultRow label="Agents imported" count={agentsCreated} />
<ResultRow label="Projects imported" count={projectsCreated} />
<ResultRow label="Skills resolved" count={skillsResolved} />
</ul>
)}
{warnings.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<ul className="list-disc space-y-0.5 pl-4">
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
<ul className="space-y-1 text-sm">
<li><a className="text-primary hover:underline" href="/agents/all">View imported agents →</a></li>
<li><a className="text-primary hover:underline" href="/projects">View imported projects →</a></li>
<li><a className="text-primary hover:underline" href="/routines">View routines →</a></li>
<li><a className="text-primary hover:underline" href="/activity">View activity log →</a></li>
</ul>
<DialogFooter>
<div className="flex justify-end">
<Button onClick={onClose}>Done</Button>
</DialogFooter>
</div>
</div>
);
}
@@ -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() {
</div>
<div className="flex min-h-0 flex-1">
{/* List column */}
<div className="w-[28rem] shrink-0 overflow-auto border-r border-border">
{/* List column — full width on < lg, fixed rail on >= lg (design §11) */}
<div
className={cn(
"w-full overflow-auto border-r border-border lg:w-[28rem] lg:shrink-0",
!isDesktop && selectedTeam && "hidden",
)}
>
{catalogQuery.isLoading ? (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
@@ -1910,21 +2079,39 @@ export function TeamCatalog() {
)}
</div>
{/* Detail pane */}
{selectedTeam ? (
<TeamDetailPane
team={selectedTeam}
selectedPath={selectedFilePath}
onSelectFile={(path) =>
navigate(path ? teamRoute(selectedTeam.id, path) : teamRoute(selectedTeam.id))
}
onInstall={() => setInstallOpen(true)}
canInstall={canInstall}
fileContent={fileQuery.data?.content ?? null}
/>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a team to view details.
{/* Detail pane — hidden on < lg until a team is selected (design §11) */}
{(isDesktop || selectedTeam) && (
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
!isDesktop && !selectedTeam && "hidden",
)}
>
{!isDesktop && selectedTeam && (
<button
type="button"
onClick={() => navigate("/teams")}
className="flex items-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" /> Back to catalog
</button>
)}
{selectedTeam ? (
<TeamDetailPane
team={selectedTeam}
selectedPath={selectedFilePath}
onSelectFile={(path) =>
navigate(path ? teamRoute(selectedTeam.id, path) : teamRoute(selectedTeam.id))
}
onInstall={() => setInstallOpen(true)}
canInstall={canInstall}
fileContent={fileQuery.data?.content ?? null}
/>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a team to view details.
</div>
)}
</div>
)}
</div>
@@ -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 <div className={`mx-auto w-full ${width} rounded-lg border border-border bg-background p-5`}>{children}</div>;
}
const meta: Meta = {
title: "Surfaces/Team Catalog",
};
export default meta;
type Story = StoryObj;
export const BrowseList: Story = {
render: () => (
<div className="w-[28rem] border border-border">
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Bundled · 1</div>
<TeamRow team={baseTeam} selected onSelect={noop} />
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Optional · 2</div>
<TeamRow team={optionalTeam} selected={false} onSelect={noop} />
<TeamRow team={warnTeam} selected={false} onSelect={noop} />
</div>
),
};
export const DetailPane: Story = {
render: () => (
<div className="h-[760px] overflow-hidden border border-border">
<TeamDetailPane
team={baseTeam}
selectedPath={null}
onSelectFile={noop}
onInstall={noop}
canInstall
fileContent={null}
/>
</div>
),
};
export const InstallTargetManager: Story = {
render: function Render() {
const [managerId, setManagerId] = useState<string | null>(null);
const [fullCompany, setFullCompany] = useState(false);
return (
<Frame>
<StepTargetManager
team={baseTeam}
agents={companyAgents}
targetManagerAgentId={managerId}
onPickManager={setManagerId}
fullCompany={fullCompany}
onToggleFullCompany={setFullCompany}
canBypassManager
/>
</Frame>
);
},
};
export const InstallSourcePolicy: Story = {
render: function Render() {
const [ext, setExt] = useState(false);
const [unpinned, setUnpinned] = useState(false);
const [local, setLocal] = useState(false);
return (
<Frame>
<StepSourcePolicy
team={warnTeam}
allowExternalSources={ext}
allowUnpinnedOptionalSources={unpinned}
allowLocalPathSources={local}
onChange={(key, v) => {
if (key === "external") setExt(v);
if (key === "unpinned") setUnpinned(v);
if (key === "localPath") setLocal(v);
}}
/>
</Frame>
);
},
};
export const InstallSkillPlan: Story = {
render: () => (
<Frame>
<StepSkillPlan team={baseTeam} preparations={makePreview().skillPreparations} />
</Frame>
),
};
export const InstallPreview: Story = {
render: function Render() {
const [collision, setCollision] = useState<CompanyPortabilityCollisionStrategy>("rename");
const [names, setNames] = useState<Record<string, string>>({});
const [adapters, setAdapters] = useState<Record<string, string>>({});
return (
<Frame>
<StepPreview
team={baseTeam}
loading={false}
error={null}
result={makePreview()}
collisionStrategy={collision}
onCollisionStrategyChange={setCollision}
nameOverrides={names}
onRename={(slug, name) => setNames((c) => ({ ...c, [slug]: name }))}
adapterOverrides={adapters}
onAdapterChange={(slug, t) => setAdapters((c) => ({ ...c, [slug]: t }))}
onRetry={noop}
/>
</Frame>
);
},
};
export const InstallPreviewBlocked: Story = {
render: () => (
<Frame>
<StepPreview
team={baseTeam}
loading={false}
error={null}
result={makePreview(["Target manager is required before this team can be installed.", "Skill acme/growth-playbook is blocked by source policy."])}
collisionStrategy="rename"
onCollisionStrategyChange={noop}
nameOverrides={{}}
onRename={noop}
adapterOverrides={{}}
onAdapterChange={noop}
onRetry={noop}
/>
</Frame>
),
};
export const InstallApplyProgress: Story = {
render: () => (
<Frame>
<ApplyProgress team={baseTeam} />
</Frame>
),
};
export const InstallSuccess: Story = {
render: () => (
<Frame>
<ApplySuccess
team={baseTeam}
result={{
team: baseTeam,
portabilityImport: {
company: { id: "company-storybook", name: "Paperclip", action: "unchanged" },
agents: [
{ slug: "ceo", id: "a1", action: "created", name: "CEO", reason: null },
{ slug: "cto", id: "a2", action: "created", name: "CTO", reason: null },
{ slug: "cmo", id: "a3", action: "created", name: "CMO (from Core Exec Team)", reason: null },
],
projects: [{ slug: "launch", id: "p1", action: "created", name: "Launch", reason: null }],
envInputs: [],
warnings: [],
},
skillPreparations: makePreview().skillPreparations,
warnings: ["Skill acme/growth-playbook imported from GitHub — review pinned ref."],
}}
onClose={noop}
/>
</Frame>
),
};