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>
No files matched your search
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
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/`);
|
||||
@@ -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 = () => {};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||