Files
PaperClipAI/ui/src/pages/TeamCatalog.tsx
T
TonioandPaperclip 56517dda6c fix(ui): bundle connector, toolbar, empty-state and agent roster fixes (#18716)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use connector setup, collection headers, empty states, and
agent lists to manage that work.
> - Provider redirects replace the setup page, and collection controls
use different sizes.
> - Agent selection inserts a bar above the list. The row link
highlights only part of the row.
> - This pull request keeps sign-in in a separate window and applies the
Tasks control sizes to collection and audit headers.
> - It also reuses artifact artwork for empty collections and gives
agent selection a fixed action card.
> - These changes preserve the operator's place and keep controls and
rows stable.

## Linked Issues or Issue Description

**What happened?**

Connector sign-in replaced the current page. Inline collection controls
had different heights. Projects and Artifacts used small placeholder
empty states. Agent selection moved the list, hover covered only the
identity cell, and chat/status shared that cell without matching
headings.

**Expected behavior**

Provider sign-in opens in a new tab or window. Collection controls match
Tasks. Empty collections use the large artifact graphics. Agent actions
float above the page while rows remain aligned.

**Steps to reproduce**

Start connector sign-in from a page or dialog. Compare collection and
Audit headers. Open an empty Projects or Artifacts collection. Select an
agent, then hover across its row.

Related public work: #13584 covers an earlier connector new-tab
implementation, including same-page fallbacks that this bundle does not
use. #8528 covers Tasks toggle sizing. #17742 covers scoped agent Audit
sections. This bundle does not change server callback or audit
authorization contracts.

## What Changed

- Reserve a separate authorization window during the click. Use
validated sign-in URLs, retain a new-tab fallback, and close blank
windows after failed requests. Store Cloud reauthentication state in the
destination window.
- Use the Tasks 44px control foundation for shared collection headers
and Audit, Costs, Timeline, Secrets, Teams, Gateways, and agent skill
controls.
- Use a large artifact stack with an off-white Paperclip image with a
dark icon for Projects and artifact Tasks empty states. Use one matching
artifact for other types. Set 20px gaps and use the label “Create
project”. Action hover and keyboard focus animate the artwork.
- Portal agent bulk actions to a fixed card 48px above the bottom safe
area. Use quintic entry/exit motion and respect reduced motion. Retain
card contents during exit.
- Give chat and status separate cells, add a Status heading, use the
MessagesSquare chat action with a hover tooltip, and highlight the
entire row.
- Add linked agent chips and a manager picker to the agent profile.
Block self and descendant manager choices. Keep the name crown and
custom title inline during edits.
- Update design guidance, the connection maps, regression tests, and
empty-state Storybook fixtures.

## Verification

- Final commit `a41a6c6494a839deaaec1ef7cc50c90078ab6381`: all 55 checks
passed, three skipped. [Canonical CI
run](https://github.com/paperclipai/paperclip/actions/runs/41999792769)
includes all eight browser shards and all server/runner/workspace
groups. Greptile: 5/5, zero unresolved review findings.
- Workspace typecheck/build passed. Local UI suite passed 11,006 tests
in 880 files, plus 542 CLI tests. Subsequent connector callback changes
passed all 245 AppsConnect tests, UI typecheck, token gates, and
query-error policy checks. Manager cache/error regressions passed four
tests. Map checks passed.
- Local connector browser suite: 20 passed. All six chat integration
shards: 1,931 passed. Workspace B groups passed; database tests passed
313 cases across 72 files with one worker.
- Browser preview verified agent manager changes, overview links, inline
name/title geometry, empty-state colors, row actions and the chat
tooltip. Live provider consent and post-deployment feature verification
remain separate from these fixtures.
- Preserved failures: ten bulk-roster tests initially missed the
portaled action card; their selectors were fixed and all twelve roster
tests passed. A nested fixture selected pnpm 11 and removed local
dependencies; the local shim now pins pnpm 9. Embedded PostgreSQL native
links were restored. A Python 3.9 fixture failure passed all 19 tests
with the current runtime. Concurrent local database startup failures
passed on the one-worker rerun.
- The broad local serialized server run passed 3,791 tests with one
scheduling assertion failure, which passed in isolation. The local
general-server run reproduced a native recovery provider/model fixture
failure and has not completed. These server sources are unchanged by
this bundle; all final canonical CI server shards passed. This is not a
claim that every broad local run passed.
- [Earlier CI
run](https://github.com/paperclipai/paperclip/actions/runs/41995119880)
failed a browser test during cleanup when heartbeat-run cancellation hit
a PostgreSQL lock timeout. All final browser shards passed without
weakening that test.
- Review follow-ups cover Slack popup/fallback recovery, a single
artwork hover transform, shared manager error rendering, detached
standalone OAuth windows, and verified task-bound callbacks in both
hosts.

## Risks

- Browser popup policy and provider callback behavior can affect sign-in
completion. The original setup page stays open; blocked windows have an
explicit recovery path.
- Shared toolbar styles affect several surfaces. Menu content remains in
its existing portals; canonical label tabs and search clear buttons
retain their own geometry.
- No schema, server, API authorization, or migration change.
- Required CI and review are complete. Deployment provenance and hosted
feature verification are checked separately after merge.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, test
execution, and browser inspection. The exact model ID and context-window
size were not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run relevant tests locally and they pass (broader local
exceptions are recorded above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-05 01:23:53 -08:00

2631 lines
97 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
CatalogTeam,
CatalogTeamEnvInputSummary,
CatalogTeamImportPreviewResult,
CatalogTeamSkillPreparation,
CatalogTeamSkillRequirement,
CatalogTeamSourceRef,
CatalogTeamTrustLevel,
CatalogTeamCompatibility,
CatalogTeamImportOptions,
CatalogTeamInstallOptions,
CatalogTeamInstallResult,
InstalledCatalogTeam,
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 {
useAdapterRegistryLoaded,
useDisabledAdaptersSync,
} from "../adapters/use-disabled-adapters";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { describeError } from "../api/errors";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertTriangle,
ArrowRight,
Boxes,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Cpu,
Crown,
Download,
Eye,
EyeOff,
FileText,
Filter,
Folder,
FolderKanban,
FolderOpen,
KeyRound,
Link2,
Loader2,
Package,
Repeat,
RotateCcw,
Search,
ShieldCheck,
Users2,
XCircle,
XOctagon,
} from "lucide-react";
import { GithubIcon } from "../components/icons/github-icon";
// Matches design §11 breakpoints. Module-level so stories and the page agree.
const DESKTOP_MIN = 1024;
const MOBILE_MAX = 767;
const TEAM_INSTALL_FALLBACK_ADAPTER_TYPE = "claude_local";
const TEAM_INSTALL_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]);
export function listTeamInstallAdapterTypes(
disabledTypes: Set<string>,
adapterRegistryLoaded: boolean,
) {
return AGENT_ADAPTER_TYPES.filter(
(type) =>
!TEAM_INSTALL_FORBIDDEN_ADAPTER_TYPES.has(type) &&
!disabledTypes.has(type) &&
(type !== "paperclip_runner" || adapterRegistryLoaded),
);
}
export function resolveTeamInstallAdapterType(
requestedType: string,
selectableAdapterTypes: readonly string[],
) {
if (selectableAdapterTypes.includes(requestedType)) return requestedType;
if (selectableAdapterTypes.includes(TEAM_INSTALL_FALLBACK_ADAPTER_TYPE)) {
return TEAM_INSTALL_FALLBACK_ADAPTER_TYPE;
}
return selectableAdapterTypes[0] ?? null;
}
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).
// ---------------------------------------------------------------------------
type TeamRisk = "safe" | "has_warnings" | "blocked";
const UI_UNSUPPORTED_SOURCE_TYPES = new Set<CatalogTeamSourceRef["type"]>([
"local_path",
"agent_package",
]);
function sourceWarningCode(
source: CatalogTeamSourceRef,
): "ok" | "unpinned" | "unsupported_in_ui" {
if (UI_UNSUPPORTED_SOURCE_TYPES.has(source.type)) return "unsupported_in_ui";
if (!source.pinned) return "unpinned";
return "ok";
}
function teamRisk(team: CatalogTeam): TeamRisk {
let risk: TeamRisk = "safe";
for (const source of team.sourceRefs) {
const code = sourceWarningCode(source);
if (code === "unsupported_in_ui") return "blocked";
if (code === "unpinned") risk = "has_warnings";
}
return risk;
}
function externalSourceCount(team: CatalogTeam): number {
return team.sourceRefs.filter((s) => s.type !== "include").length;
}
function skillCount(team: CatalogTeam): number {
return (
team.counts.localSkills + team.counts.catalogSkills + team.counts.externalSkillSources
);
}
function titleCase(slug: string): string {
return slug
.replace(/[-_/]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())
.trim();
}
function encodeTeamFilePath(filePath: string): string {
return filePath.split("/").map(encodeURIComponent).join("%7E");
}
function decodeTeamFilePath(encoded: string | undefined): string | null {
if (!encoded) return null;
return encoded.split("%7E").map(decodeURIComponent).join("/");
}
type ParsedRoute = { catalogRef: string | null; filePath: string | null };
const TEAM_CATALOG_ROUTE_ROOT = "/teams-catalog";
export function parseTeamRoute(routePath: string | undefined): ParsedRoute {
if (!routePath) return { catalogRef: null, filePath: null };
const segments = routePath.split("/").filter(Boolean);
if (segments.length === 0) return { catalogRef: null, filePath: null };
const catalogRef = decodeURIComponent(segments[0]);
if (segments[1] === "files" && segments[2]) {
return { catalogRef, filePath: decodeTeamFilePath(segments[2]) };
}
return { catalogRef, filePath: null };
}
export function teamRoute(catalogRef: string, filePath?: string | null): string {
const base = `${TEAM_CATALOG_ROUTE_ROOT}/${encodeURIComponent(catalogRef)}`;
if (filePath) return `${base}/files/${encodeTeamFilePath(filePath)}`;
return base;
}
// ---------------------------------------------------------------------------
// Small presentational components (siblings of the Skills catalog chips).
// ---------------------------------------------------------------------------
const TRUST_META: Record<
CatalogTeamTrustLevel,
{ label: string; tip: string; tone: string; Icon: typeof ShieldCheck }
> = {
markdown_only: {
label: "Markdown only",
tip: "Contains only markdown and references. No executable content.",
tone: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30",
Icon: ShieldCheck,
},
assets: {
label: "Assets",
tip: "Includes static assets (images, fixtures). No executable content.",
tone: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30",
Icon: ShieldCheck,
},
scripts_executables: {
label: "Scripts",
tip: "Includes executable scripts that were security-reviewed before bundling.",
tone: "text-amber-600 dark:text-amber-300 border-amber-500/30",
Icon: AlertTriangle,
},
external_sources: {
label: "External sources",
tip: "References external sources resolved at install time.",
tone: "text-amber-600 dark:text-amber-300 border-amber-500/30",
Icon: AlertTriangle,
},
};
function TrustChip({ level, iconOnly = false }: { level: CatalogTeamTrustLevel; iconOnly?: boolean }) {
const meta = TRUST_META[level];
const { Icon } = meta;
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline"
className={cn(
"px-1.5 text-(length:--text-micro)",
meta.tone,
)}
>
<Icon className="h-3 w-3" />
{!iconOnly && meta.label}
</Badge>
</TooltipTrigger>
<TooltipContent>{meta.tip}</TooltipContent>
</Tooltip>
);
}
const COMPAT_META: Record<
CatalogTeamCompatibility,
{ label: string; tone: string }
> = {
compatible: { label: "Compatible", tone: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30" },
unknown: { label: "Unknown compat", tone: "text-muted-foreground border-border" },
invalid: { label: "Invalid", tone: "text-rose-600 dark:text-rose-300 border-rose-500/30" },
};
function CompatChip({ compatibility }: { compatibility: CatalogTeamCompatibility }) {
const meta = COMPAT_META[compatibility];
return (
<Badge variant="outline"
className={cn(
"px-1.5 text-(length:--text-micro)",
meta.tone,
)}
>
{meta.label}
</Badge>
);
}
function ProvenanceBadge({ team }: { team: CatalogTeam }) {
if (!team.packageName) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-border px-1.5 text-(length:--text-micro) text-muted-foreground">
<Package className="h-3 w-3" />
{team.packageName}
{team.packageVersion ? `@${team.packageVersion}` : ""}
</Badge>
</TooltipTrigger>
<TooltipContent>Catalog package provenance</TooltipContent>
</Tooltip>
);
}
function RiskBanner({ team }: { team: CatalogTeam }) {
const unsafe = team.sourceRefs.filter(
(s) => sourceWarningCode(s) !== "ok",
);
if (unsafe.length === 0) return null;
return (
<div
role="alert"
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-amber-700 dark:text-amber-300"
>
<div className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="h-4 w-4" />
This team references {unsafe.length} external source
{unsafe.length === 1 ? "" : "s"}
</div>
<ul className="mt-1.5 space-y-0.5 text-xs">
{unsafe.map((s) => (
<li key={`${s.type}:${s.ref}`} className="font-mono">
{s.ref}{" "}
<span className="not-italic font-sans opacity-80">
({sourceWarningCode(s) === "unsupported_in_ui" ? "unsupported in browser install" : "unpinned"})
</span>
</li>
))}
</ul>
</div>
);
}
function sourceKindIcon(type: CatalogTeamSourceRef["type"]) {
switch (type) {
case "github":
return GithubIcon;
case "url":
return Link2;
case "local_path":
return Folder;
case "agent_package":
return Package;
case "skills_sh":
return Boxes;
default:
return Link2;
}
}
// ---------------------------------------------------------------------------
// File tree
// ---------------------------------------------------------------------------
type TreeNode = {
name: string;
path: string | null;
kind: "dir" | "file";
children: TreeNode[];
};
function buildTree(files: CatalogTeam["files"]): TreeNode[] {
const root: TreeNode = { name: "", path: null, kind: "dir", children: [] };
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
const parts = file.path.split("/");
let cursor = root;
parts.forEach((part, idx) => {
const isLeaf = idx === parts.length - 1;
let child = cursor.children.find((c) => c.name === part);
if (!child) {
child = {
name: part,
path: isLeaf ? file.path : null,
kind: isLeaf ? "file" : "dir",
children: [],
};
cursor.children.push(child);
}
cursor = child;
});
}
const sort = (node: TreeNode) => {
node.children.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
});
node.children.forEach(sort);
};
sort(root);
return root.children;
}
function TeamFileTree({
nodes,
depth = 0,
selectedPath,
expanded,
onToggleDir,
onSelectFile,
}: {
nodes: TreeNode[];
depth?: number;
selectedPath: string | null;
expanded: Set<string>;
onToggleDir: (name: string) => void;
onSelectFile: (path: string) => void;
}) {
return (
<ul className="text-xs">
{nodes.map((node) => {
const key = node.path ?? `dir:${depth}:${node.name}`;
if (node.kind === "dir") {
const open = expanded.has(node.name);
const DirIcon = open ? FolderOpen : Folder;
return (
<li key={key}>
<button
type="button"
onClick={() => onToggleDir(node.name)}
className="flex w-full items-center gap-1.5 rounded px-1.5 py-1 hover:bg-accent/40"
style={{ paddingLeft: depth * 14 + 6 }}
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<DirIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{node.name}</span>
</button>
{open && (
<TeamFileTree
nodes={node.children}
depth={depth + 1}
selectedPath={selectedPath}
expanded={expanded}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
/>
)}
</li>
);
}
const active = node.path === selectedPath;
return (
<li key={key}>
<button
type="button"
onClick={() => node.path && onSelectFile(node.path)}
className={cn(
"flex w-full items-center gap-1.5 rounded px-1.5 py-1 hover:bg-accent/40",
active && "bg-accent/50 text-foreground",
)}
style={{ paddingLeft: depth * 14 + 22 }}
>
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{node.name}</span>
</button>
</li>
);
})}
</ul>
);
}
// ---------------------------------------------------------------------------
// Agent hierarchy preview (manifest exposes agentSlugs + rootAgentSlugs only;
// per-agent reportsTo is not in the list manifest, so we render roots distinctly
// and group the remaining members — graceful degradation per design §7).
// ---------------------------------------------------------------------------
export function TeamHierarchyPreview({ team }: { team: CatalogTeam }) {
const roots = new Set(team.rootAgentSlugs);
const members = team.agentSlugs.filter((slug) => !roots.has(slug));
const requiresManager = team.rootAgentSlugs.length > 0;
return (
<div className="max-h-72 overflow-auto rounded-md border border-border">
<ul className="divide-y divide-border/60">
{team.rootAgentSlugs.map((slug) => (
<li
key={slug}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm",
requiresManager && "border-l-2 border-amber-500/50",
)}
>
<Crown className="h-3.5 w-3.5 text-amber-500" />
<span className="font-medium">{titleCase(slug)}</span>
<span className="text-xs text-muted-foreground">root agent</span>
</li>
))}
{members.map((slug) => (
<li key={slug} className="flex items-center gap-2 px-3 py-2 pl-7 text-sm">
<Users2 className="h-3.5 w-3.5 text-muted-foreground" />
<span>{titleCase(slug)}</span>
</li>
))}
{team.agentSlugs.length === 0 && (
<li className="px-3 py-2 text-xs text-muted-foreground">No agents in this team.</li>
)}
</ul>
</div>
);
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{children}
</h3>
);
}
// ---------------------------------------------------------------------------
// Detail pane
// ---------------------------------------------------------------------------
function MetricTile({
label,
value,
Icon,
}: {
label: string;
value: number;
Icon: typeof Users2;
}) {
return (
<Card className="block px-3 py-2.5">
<div className="flex items-center justify-between">
<span className="text-xl font-semibold tabular-nums">{value}</span>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<span className="text-xs text-muted-foreground">{label}</span>
</Card>
);
}
export function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] }) {
if (skills.length === 0) return <p className="text-sm text-muted-foreground">No required skills.</p>;
return (
<ul className="space-y-1">
{skills.map((skill) => (
<li
key={`${skill.type}:${skill.ref}`}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm"
>
<Boxes className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs">{skill.ref}</span>
<Badge variant="outline" className="ml-auto text-(length:--text-nano)">
{skill.type}
</Badge>
{skill.resolved ? (
<Badge variant="outline" className="text-(length:--text-nano) text-emerald-600 dark:text-emerald-300 border-emerald-500/30">
resolved
</Badge>
) : (
<Badge variant="outline" className="text-(length:--text-nano) text-amber-600 dark:text-amber-300 border-amber-500/30">
external
</Badge>
)}
</li>
))}
</ul>
);
}
export function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) {
if (inputs.length === 0) return null;
return (
<div className="space-y-1.5">
<SectionHeader>Secrets & env inputs</SectionHeader>
<ul className="space-y-1">
{inputs.map((input) => (
<li
key={input.key}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm"
>
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs uppercase tracking-wide">{input.key}</span>
<Badge
variant="outline"
className={cn(
"ml-auto text-(length:--text-nano)",
input.kind === "secret"
? "text-rose-600 dark:text-rose-300 border-rose-500/30"
: "text-muted-foreground",
)}
>
{input.kind}
</Badge>
{input.requirement === "required" && (
<Badge variant="outline" className="text-(length:--text-nano)">required</Badge>
)}
</li>
))}
</ul>
</div>
);
}
function envInputFormKey(input: CatalogTeamEnvInputSummary) {
if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`;
if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`;
return input.key;
}
export function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) {
const external = sources.filter((s) => s.type !== "include");
const [open, setOpen] = useState(false);
if (external.length === 0) return null;
return (
<div className="space-y-1.5">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
External sources · {external.length}
</button>
{open && (
<ul className="divide-y divide-border rounded-md border border-border">
{external.map((source) => {
const Icon = sourceKindIcon(source.type);
const code = sourceWarningCode(source);
return (
<li key={`${source.type}:${source.ref}`} className="flex items-center gap-2 px-3 py-2 text-sm">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs truncate">{source.ref}</span>
<span className="ml-auto text-(length:--text-micro)">
{code === "ok" && (
<span className="text-emerald-600 dark:text-emerald-300">Pinned</span>
)}
{code === "unpinned" && (
<span className="text-amber-600 dark:text-amber-300">Unpinned</span>
)}
{code === "unsupported_in_ui" && (
<span className="text-rose-600 dark:text-rose-300">Unsupported in browser install</span>
)}
</span>
</li>
);
})}
</ul>
)}
</div>
);
}
export function TeamDetailPane({
team,
selectedPath,
onSelectFile,
onInstall,
canInstall,
fileContent,
installed,
}: {
team: CatalogTeam;
selectedPath: string | null;
onSelectFile: (path: string | null) => void;
onInstall: () => void;
canInstall: boolean;
fileContent: string | null;
installed?: InstalledCatalogTeam | null;
}) {
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const tree = useMemo(() => buildTree(team.files), [team.files]);
const invalid = team.compatibility === "invalid";
const unsafe = team.trustLevel === "scripts_executables";
const isInstalled = Boolean(installed);
const outOfDate = Boolean(installed?.outOfDate);
const toggleDir = (name: string) =>
setExpandedDirs((current) => {
const next = new Set(current);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
// Installed teams default to update/re-install semantics; out-of-date teams
// get the primary amber affordance (design §5 / PAP-10256).
const installButton = (
<Button
onClick={onInstall}
disabled={invalid || !canInstall}
variant={outOfDate ? "default" : isInstalled ? "outline" : "default"}
>
{unsafe ? (
<AlertTriangle className="h-4 w-4" />
) : isInstalled ? (
<RotateCcw className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalled ? "Re-install latest" : "Install team"}
</Button>
);
return (
<div className="flex-1 overflow-auto">
<div className="space-y-5 p-5">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1.5">
<h2 className="text-base font-semibold">{team.name}</h2>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={team.kind === "bundled" ? "secondary" : "outline"} className="text-(length:--text-nano) capitalize">
{team.kind}
</Badge>
<span className="text-xs text-muted-foreground">{team.category}</span>
<TrustChip level={team.trustLevel} />
<CompatChip compatibility={team.compatibility} />
<ProvenanceBadge team={team} />
{isInstalled && !outOfDate && (
<Badge variant="secondary" className="gap-1 text-(length:--text-nano)">
<CheckCircle2 className="h-3 w-3" /> Installed
</Badge>
)}
{outOfDate && (
<Badge
variant="outline"
className="gap-1 border-amber-500/40 bg-amber-500/10 text-(length:--text-nano) text-amber-600 dark:text-amber-300"
>
<ChevronUp className="h-3 w-3" /> Update available
</Badge>
)}
</div>
</div>
{invalid ? (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{installButton}</span>
</TooltipTrigger>
<TooltipContent>This team cannot be installed — the package manifest is invalid.</TooltipContent>
</Tooltip>
) : !canInstall ? (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{installButton}</span>
</TooltipTrigger>
<TooltipContent>Requires board operator or agent-create permissions.</TooltipContent>
</Tooltip>
) : (
installButton
)}
</div>
<RiskBanner team={team} />
{/* Description */}
{team.description && (
<div className="prose prose-sm prose-neutral max-w-none dark:prose-invert">
<MarkdownBody>{team.description}</MarkdownBody>
</div>
)}
{/* Summary grid */}
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<MetricTile label="Agents" value={team.counts.agents} Icon={Users2} />
<MetricTile label="Projects" value={team.counts.projects} Icon={FolderKanban} />
<MetricTile label="Routines" value={team.counts.routines} Icon={Repeat} />
<MetricTile label="Required skills" value={skillCount(team)} Icon={Boxes} />
</div>
{/* Agent hierarchy */}
<div className="space-y-2">
<SectionHeader>Agent hierarchy</SectionHeader>
<TeamHierarchyPreview team={team} />
</div>
{/* Projects */}
{team.projectSlugs.length > 0 && (
<div className="space-y-2">
<SectionHeader>Projects</SectionHeader>
<ul className="space-y-1">
{team.projectSlugs.map((slug) => (
<li key={slug} className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<FolderKanban className="h-3.5 w-3.5 text-muted-foreground" />
<span>{titleCase(slug)}</span>
<span className="ml-auto font-mono text-(length:--text-micro) text-muted-foreground">{slug}</span>
</li>
))}
</ul>
</div>
)}
{/* Required skills */}
<div className="space-y-2">
<SectionHeader>Required skills</SectionHeader>
<RequiredSkillsList skills={team.requiredSkills} />
</div>
{/* Env inputs */}
<EnvInputsList inputs={team.envInputs} />
{/* External sources */}
<ExternalSourcesList sources={team.sourceRefs} />
{/* File inventory */}
<div className="space-y-2">
<SectionHeader>Files</SectionHeader>
<div className="rounded-md border border-border p-1.5">
<TeamFileTree
nodes={tree}
selectedPath={selectedPath}
expanded={expandedDirs}
onToggleDir={toggleDir}
onSelectFile={(path) => onSelectFile(path)}
/>
</div>
{selectedPath && (
<div className="rounded-md border border-border">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="font-mono text-xs">{selectedPath}</span>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => onSelectFile(null)}
>
Close
</button>
</div>
<div className="max-h-96 overflow-auto p-3">
{fileContent === null ? (
<Skeleton className="h-32 w-full" />
) : selectedPath.endsWith(".md") ? (
<div className="prose prose-sm prose-neutral max-w-none dark:prose-invert">
<MarkdownBody>{fileContent}</MarkdownBody>
</div>
) : (
<pre className="whitespace-pre-wrap break-words font-mono text-xs">{fileContent}</pre>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Install wizard
// ---------------------------------------------------------------------------
type WizardStep = "target_manager" | "source_policy" | "skill_plan" | "preview";
const STEP_LABELS: Record<WizardStep, string> = {
target_manager: "Target manager",
source_policy: "Source policy",
skill_plan: "Prerequisite skills",
preview: "Preview",
};
// `simplified` is the onboarding seam (design §6): the newly created company is
// treated as a full-company-equivalent target, so the target-manager step is
// dropped and source policy is never surfaced (onboarding only offers
// markdown_only/assets teams). The skill plan collapses to a count and the
// preview is minimal, but the skill step still renders when required skills
// exist so resolution stays honest.
function computeSteps(team: CatalogTeam, simplified = false): WizardStep[] {
const steps: WizardStep[] = [];
if (!simplified && team.rootAgentSlugs.length > 0) steps.push("target_manager");
if (!simplified && team.sourceRefs.some((s) => sourceWarningCode(s) !== "ok")) {
steps.push("source_policy");
}
if (team.requiredSkills.length > 0) steps.push("skill_plan");
steps.push("preview");
return steps;
}
type ApplyPhase = "form" | "applying" | "done" | "error";
// ---------------------------------------------------------------------------
// Install hook — the onboarding seam (design §6 + §12.5).
//
// `useInstallTeamCatalogEntry` owns the preview/install engine: option building,
// the two API mutations, and the resolved result/phase state. The installer
// dialog drives it with operator-entered form state; a future onboarding step
// can drive the same hook with `{ simplified: true }` and default form state to
// run the collapsed, no-target-manager flow without any UI rework.
// ---------------------------------------------------------------------------
export interface TeamInstallFormState {
targetManagerAgentId: string | null;
fullCompany: boolean;
allowExternalSources: boolean;
allowUnpinnedOptionalSources: boolean;
allowLocalPathSources: boolean;
collisionStrategy: CompanyPortabilityCollisionStrategy;
/** slug -> renamed entity name */
nameOverrides: Record<string, string>;
/** slug -> adapterType override */
adapterOverrides: Record<string, string>;
/** scoped env input key -> operator-entered value */
secretValues: Record<string, string>;
}
export const EMPTY_INSTALL_FORM: TeamInstallFormState = {
targetManagerAgentId: null,
fullCompany: false,
allowExternalSources: false,
allowUnpinnedOptionalSources: false,
allowLocalPathSources: false,
collisionStrategy: "rename",
nameOverrides: {},
adapterOverrides: {},
secretValues: {},
};
export interface UseInstallTeamCatalogEntryOptions {
companyId: string;
team: CatalogTeam;
/**
* Run the simplified onboarding flow: no target-manager step, source policy
* never surfaced, collapsed preview. The company is treated as a
* full-company-equivalent target (`targetManagerAgentId: null`).
*/
simplified?: boolean;
onInstalled?: (result: CatalogTeamInstallResult) => void;
}
export interface UseInstallTeamCatalogEntryResult {
simplified: boolean;
steps: WizardStep[];
phase: ApplyPhase;
setPhase: (phase: ApplyPhase) => void;
previewResult: CatalogTeamImportPreviewResult | null;
previewError: string | null;
isPreviewing: boolean;
installResult: CatalogTeamInstallResult | null;
applyError: string | null;
runPreview: (form: TeamInstallFormState) => void;
runInstall: (form: TeamInstallFormState) => void;
buildPreviewOptions: (form: TeamInstallFormState) => CatalogTeamImportOptions;
buildInstallOptions: (form: TeamInstallFormState) => CatalogTeamInstallOptions;
reset: () => void;
}
export function useInstallTeamCatalogEntry({
companyId,
team,
simplified = false,
onInstalled,
}: UseInstallTeamCatalogEntryOptions): UseInstallTeamCatalogEntryResult {
const [phase, setPhase] = useState<ApplyPhase>("form");
const [previewResult, setPreviewResult] = useState<CatalogTeamImportPreviewResult | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [installResult, setInstallResult] = useState<CatalogTeamInstallResult | null>(null);
const [applyError, setApplyError] = useState<string | null>(null);
const steps = useMemo(() => computeSteps(team, simplified), [team, simplified]);
// Preview body — the preview schema is strict and does NOT accept adapterOverrides.
const buildPreviewOptions = useCallback(
(form: TeamInstallFormState): CatalogTeamImportOptions => ({
targetManagerAgentId: simplified || form.fullCompany ? null : form.targetManagerAgentId,
collisionStrategy: form.collisionStrategy,
nameOverrides:
Object.keys(form.nameOverrides).length > 0 ? form.nameOverrides : undefined,
sourcePolicy: {
allowExternalSources: form.allowExternalSources,
allowUnpinnedOptionalSources: form.allowUnpinnedOptionalSources,
allowLocalPathSources: form.allowLocalPathSources,
},
}),
[simplified],
);
// Install body extends the preview body with adapterOverrides.
const buildInstallOptions = useCallback(
(form: TeamInstallFormState): CatalogTeamInstallOptions => {
const overrides: Record<string, CompanyPortabilityAdapterOverride> = {};
for (const [slug, adapterType] of Object.entries(form.adapterOverrides)) {
if (adapterType) overrides[slug] = { adapterType };
}
return {
...buildPreviewOptions(form),
adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
secretValues: Object.keys(form.secretValues).length > 0 ? form.secretValues : undefined,
};
},
[buildPreviewOptions],
);
const previewMutation = useMutation({
mutationFn: (form: TeamInstallFormState) =>
teamCatalogApi.preview(companyId, team.id, buildPreviewOptions(form)),
onSuccess: (result) => {
setPreviewResult(result);
setPreviewError(null);
},
onError: (error) => {
setPreviewError(describeError(error, { action: "load the install preview" }).body);
},
});
const installMutation = useMutation({
mutationFn: (form: TeamInstallFormState) =>
teamCatalogApi.install(companyId, team.id, buildInstallOptions(form)),
onMutate: () => {
setPhase("applying");
setApplyError(null);
},
onSuccess: (result) => {
setInstallResult(result);
setPhase("done");
onInstalled?.(result);
},
onError: (error) => {
setPhase("error");
setApplyError(describeError(error, { action: "install the team" }).body);
},
});
const reset = useCallback(() => {
setPhase("form");
setPreviewResult(null);
setPreviewError(null);
setInstallResult(null);
setApplyError(null);
}, []);
return {
simplified,
steps,
phase,
setPhase,
previewResult,
previewError,
isPreviewing: previewMutation.isPending,
installResult,
applyError,
runPreview: previewMutation.mutate,
runInstall: installMutation.mutate,
buildPreviewOptions,
buildInstallOptions,
reset,
};
}
export function TeamInstallerDialog({
team,
companyId,
agents,
open,
onClose,
onInstalled,
onReturnFocus,
defaultCollisionStrategy = "rename",
}: {
team: CatalogTeam;
companyId: string;
agents: Agent[];
open: boolean;
onClose: () => void;
onReturnFocus?: () => void;
defaultCollisionStrategy?: CompanyPortabilityCollisionStrategy;
onInstalled: (result: CatalogTeamInstallResult) => void;
}) {
const steps = useMemo(() => computeSteps(team), [team]);
const [stepIndex, setStepIndex] = useState(0);
const [phase, setPhase] = useState<ApplyPhase>("form");
const disabledAdapterTypes = useDisabledAdaptersSync({ enabled: open });
const adapterRegistryLoaded = useAdapterRegistryLoaded({ enabled: open });
const selectableAdapterTypes = useMemo(
() => listTeamInstallAdapterTypes(disabledAdapterTypes, adapterRegistryLoaded),
[adapterRegistryLoaded, disabledAdapterTypes],
);
// Step 1 — target manager
const [targetManagerAgentId, setTargetManagerAgentId] = useState<string | null>(null);
const [fullCompany, setFullCompany] = useState(false);
const canBypassManager = team.recommendedForCompanyTypes.includes("company-root");
// Step 2 — source policy (the strict API exposes 3 booleans)
const [allowExternalSources, setAllowExternalSources] = useState(false);
const [allowUnpinnedOptionalSources, setAllowUnpinnedOptionalSources] = useState(false);
const [allowLocalPathSources, setAllowLocalPathSources] = useState(false);
// Step 4 — preview controls
const [collisionStrategy, setCollisionStrategy] = useState<CompanyPortabilityCollisionStrategy>(defaultCollisionStrategy);
const [nameOverrides, setNameOverrides] = useState<Record<string, string>>({});
// slug -> adapterType override (the install schema accepts adapterOverrides).
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
const [visibleSecretKeys, setVisibleSecretKeys] = useState<Record<string, boolean>>({});
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(() => {
if (open) {
setStepIndex(0);
setPhase("form");
setTargetManagerAgentId(null);
setFullCompany(false);
setAllowExternalSources(false);
setAllowUnpinnedOptionalSources(false);
setAllowLocalPathSources(false);
setCollisionStrategy(defaultCollisionStrategy);
setNameOverrides({});
setAdapterOverrides({});
setSecretValues({});
setVisibleSecretKeys({});
setConfirmScripts(false);
setPreviewResult(null);
setPreviewError(null);
setApplyError(null);
setInstallResult(null);
}
}, [open, defaultCollisionStrategy]);
const currentStep = steps[stepIndex];
// 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,
sourcePolicy: {
allowExternalSources,
allowUnpinnedOptionalSources,
allowLocalPathSources,
},
});
// 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) {
const resolvedAdapterType = resolveTeamInstallAdapterType(
adapterType,
selectableAdapterTypes,
);
if (!resolvedAdapterType) {
throw new Error("Enable a legacy adapter before installing this team.");
}
overrides[slug] = {
adapterType: resolvedAdapterType,
};
}
}
for (const agent of previewResult?.portabilityPreview.manifest.agents ?? []) {
if (overrides[agent.slug]) continue;
const adapterType = resolveTeamInstallAdapterType(
agent.adapterType,
selectableAdapterTypes,
);
if (!adapterType) {
throw new Error("Enable a legacy adapter before installing this team.");
}
if (adapterType !== agent.adapterType) {
overrides[agent.slug] = { adapterType };
}
}
const enteredSecretValues = Object.fromEntries(
Object.entries(secretValues).filter(([, value]) => value.trim().length > 0),
);
return {
...buildPreviewOptions(),
adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
secretValues: Object.keys(enteredSecretValues).length > 0 ? enteredSecretValues : undefined,
};
};
const previewMutation = useMutation({
mutationFn: () => teamCatalogApi.preview(companyId, team.id, buildPreviewOptions()),
onSuccess: (result) => {
setPreviewResult(result);
setPreviewError(null);
},
onError: (error) => {
setPreviewError(describeError(error, { action: "load the install preview" }).body);
},
});
const installMutation = useMutation({
mutationFn: () => teamCatalogApi.install(companyId, team.id, buildInstallOptions()),
onMutate: () => {
setPhase("applying");
setApplyError(null);
},
onSuccess: (result) => {
setInstallResult(result);
setPhase("done");
onInstalled(result);
},
onError: (error) => {
setPhase("error");
setApplyError(describeError(error, { action: "install the team" }).body);
},
});
// Auto-load preview when reaching the preview step.
const previewRequested = useRef(false);
useEffect(() => {
if (currentStep === "preview" && !previewRequested.current && !previewMutation.isPending) {
previewRequested.current = true;
previewMutation.mutate();
}
if (currentStep !== "preview") previewRequested.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
const targetManagerResolved = fullCompany || Boolean(targetManagerAgentId);
function canContinue(step: WizardStep): boolean {
if (step === "target_manager") return targetManagerResolved;
if (step === "source_policy") {
// Block forward when an unsupported source is present and not (cannot be) allowed.
const hasUnsupported = team.sourceRefs.some((s) => sourceWarningCode(s) === "unsupported_in_ui");
if (hasUnsupported && !allowLocalPathSources) return false;
return true;
}
return true;
}
const hasErrors = (previewResult?.errors.length ?? 0) > 0;
const blockedCount = previewResult?.errors.length ?? 0;
const missingRequiredSecretInputs = (previewResult?.portabilityPreview.envInputs ?? [])
.filter((input) => input.requirement === "required" && (secretValues[envInputFormKey(input)] ?? "").trim().length === 0);
const missingRequiredSecretCount = missingRequiredSecretInputs.length;
const missingEnabledAdapter = Boolean(
previewResult?.portabilityPreview.manifest.agents.some(
(agent) =>
!resolveTeamInstallAdapterType(
adapterOverrides[agent.slug] ?? agent.adapterType,
selectableAdapterTypes,
),
),
);
const installBlocked = hasErrors || missingRequiredSecretCount > 0 || missingEnabledAdapter;
const needsScriptsConfirm = team.trustLevel === "scripts_executables";
function goNext() {
if (stepIndex < steps.length - 1) setStepIndex((i) => i + 1);
}
function goBack() {
if (stepIndex > 0) setStepIndex((i) => i - 1);
}
function submitInstall() {
if (needsScriptsConfirm && !confirmScripts) {
setConfirmScripts(true);
return;
}
installMutation.mutate();
}
const totalSteps = steps.length;
const isMobileSheet = useMediaQuery(`(max-width: ${MOBILE_MAX}px)`);
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="space-y-4 overflow-auto pr-1 md:max-h-(--sz-60vh)">
{currentStep === "target_manager" && (
<StepTargetManager
team={team}
agents={agents}
targetManagerAgentId={targetManagerAgentId}
onPickManager={(id) => { setTargetManagerAgentId(id); setFullCompany(false); }}
fullCompany={fullCompany}
onToggleFullCompany={(v) => { setFullCompany(v); if (v) setTargetManagerAgentId(null); }}
canBypassManager={canBypassManager}
/>
)}
{currentStep === "source_policy" && (
<StepSourcePolicy
team={team}
allowExternalSources={allowExternalSources}
allowUnpinnedOptionalSources={allowUnpinnedOptionalSources}
allowLocalPathSources={allowLocalPathSources}
onChange={(key, value) => {
if (key === "external") setAllowExternalSources(value);
if (key === "unpinned") setAllowUnpinnedOptionalSources(value);
if (key === "localPath") setAllowLocalPathSources(value);
}}
/>
)}
{currentStep === "skill_plan" && (
<StepSkillPlan team={team} preparations={previewResult?.skillPreparations ?? null} />
)}
{currentStep === "preview" && (
<StepPreview
team={team}
loading={previewMutation.isPending}
error={previewError}
result={previewResult}
collisionStrategy={collisionStrategy}
onCollisionStrategyChange={(s) => { setCollisionStrategy(s); previewRequested.current = false; previewMutation.mutate(); }}
nameOverrides={nameOverrides}
onRename={(slug, name) => setNameOverrides((cur) => ({ ...cur, [slug]: name }))}
adapterOverrides={adapterOverrides}
selectableAdapterTypes={selectableAdapterTypes}
onAdapterChange={(slug, adapterType) => setAdapterOverrides((cur) => ({ ...cur, [slug]: adapterType }))}
secretValues={secretValues}
visibleSecretKeys={visibleSecretKeys}
onSecretChange={(key, value) => setSecretValues((cur) => ({ ...cur, [key]: value }))}
onToggleSecretVisibility={(key) => setVisibleSecretKeys((cur) => ({ ...cur, [key]: !cur[key] }))}
onRetry={() => previewMutation.mutate()}
/>
)}
</div>
)}
{phase === "applying" && <ApplyProgress team={team} />}
{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">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Install failed</p>
<p className="mt-0.5 text-xs">{applyError}</p>
<p className="mt-1 text-xs opacity-80">
Partial state is not rolled back. Review the organization activity log before retrying.
</p>
</div>
</div>
</div>
)}
</>
);
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" && !hasErrors && missingRequiredSecretCount > 0 && (
<span className="text-xs text-rose-600 dark:text-rose-300">
Required secrets missing: {missingRequiredSecretCount}
</span>
)}
{currentStep === "preview" && !hasErrors && missingRequiredSecretCount === 0 && missingEnabledAdapter && (
<span className="text-xs text-rose-600 dark:text-rose-300">
Enable a legacy adapter to install this team
</span>
)}
{currentStep === "preview" ? (
needsScriptsConfirm && confirmScripts ? (
<Button variant="destructive" onClick={submitInstall} disabled={installBlocked || previewMutation.isPending}>
<AlertTriangle className="h-4 w-4" />
Confirm — install with executables
</Button>
) : (
<Button onClick={submitInstall} disabled={installBlocked || 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;
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 onCloseAutoFocus={onReturnFocus ? (event) => { event.preventDefault(); onReturnFocus(); } : undefined} side="bottom" className="flex h-(--sz-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 onCloseAutoFocus={onReturnFocus ? (event) => { event.preventDefault(); onReturnFocus(); } : undefined} className="max-w-3xl">
<DialogHeader>
<DialogTitle>{headerTitle}</DialogTitle>
{headerDescription && <DialogDescription>{headerDescription}</DialogDescription>}
</DialogHeader>
{body}
{footer && <DialogFooter>{footer}</DialogFooter>}
</DialogContent>
</Dialog>
);
}
export function StepTargetManager({
team,
agents,
targetManagerAgentId,
onPickManager,
fullCompany,
onToggleFullCompany,
canBypassManager,
}: {
team: CatalogTeam;
agents: Agent[];
targetManagerAgentId: string | null;
onPickManager: (id: string) => void;
fullCompany: boolean;
onToggleFullCompany: (v: boolean) => void;
canBypassManager: boolean;
}) {
return (
<div className="space-y-4">
<div
className="rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-2.5 text-sm text-blue-700 dark:text-blue-300"
id="target-manager-help"
>
This team&apos;s root agents need a manager in your organization. Pick the agent who will become
their parent. Internal team hierarchy is preserved.
</div>
<div className="space-y-1.5">
<SectionHeader>Root agents</SectionHeader>
<ul className="rounded-md border border-border">
{team.rootAgentSlugs.map((slug) => (
<li key={slug} className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-sm last:border-b-0">
<Crown className="h-3.5 w-3.5 text-amber-500" />
<span className="font-medium">{titleCase(slug)}</span>
<Badge variant="ghost" className="ml-auto bg-accent text-(length:--text-micro) text-accent-foreground">
→ ?
</Badge>
</li>
))}
</ul>
</div>
{!fullCompany && (
<div className="space-y-1.5" aria-describedby="target-manager-help">
<SectionHeader>Target manager</SectionHeader>
{agents.length === 0 ? <p role="status" className="rounded-md border border-border p-3 text-sm text-muted-foreground">No agents are available to manage this team. Create an agent in your company, then try again.</p> : <Command className="rounded-md border border-border">
<CommandInput placeholder="Search agents…" />
<CommandList>
<CommandEmpty>No agents found.</CommandEmpty>
<CommandGroup>
{agents.map((agent) => (
<CommandItem
key={agent.id}
value={`${agent.name} ${agent.role} ${agent.title ?? ""}`}
onSelect={() => onPickManager(agent.id)}
>
<div className="flex w-full items-center gap-2">
<Users2 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{agent.name}</span>
<span className="text-xs capitalize">{agent.role}</span>
{targetManagerAgentId === agent.id && <Check className="ml-auto h-4 w-4 text-emerald-500" />}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>}
</div>
)}
{canBypassManager && (
<label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="radio"
checked={fullCompany}
onChange={(e) => onToggleFullCompany(e.target.checked)}
/>
Use this team as a full-organization package (no target manager)
</label>
)}
</div>
);
}
export function StepSourcePolicy({
team,
allowExternalSources,
allowUnpinnedOptionalSources,
allowLocalPathSources,
onChange,
}: {
team: CatalogTeam;
allowExternalSources: boolean;
allowUnpinnedOptionalSources: boolean;
allowLocalPathSources: boolean;
onChange: (key: "external" | "unpinned" | "localPath", value: boolean) => void;
}) {
const external = team.sourceRefs.filter((s) => s.type !== "include");
const hasUnsupported = external.some((s) => sourceWarningCode(s) === "unsupported_in_ui");
return (
<div className="space-y-4">
<div role="alert" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-300">
This team references {external.length} external source{external.length === 1 ? "" : "s"}.
Review each one and decide what to allow before continuing.
</div>
<ul className="divide-y divide-border rounded-md border border-border">
{external.map((source) => {
const Icon = sourceKindIcon(source.type);
const code = sourceWarningCode(source);
return (
<li key={`${source.type}:${source.ref}`} className="flex items-center gap-2 px-3 py-2.5 text-sm">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<div className="min-w-0">
<p className="font-mono text-xs truncate">{source.ref}</p>
<p className="text-(length:--text-micro) text-muted-foreground">
{code === "ok" && "pinned"}
{code === "unpinned" && "unpinned reference"}
{code === "unsupported_in_ui" && "not installable from the browser"}
</p>
</div>
<Badge
variant="outline"
className={cn(
"ml-auto text-(length:--text-nano)",
code === "unsupported_in_ui"
? "text-rose-600 dark:text-rose-300 border-rose-500/30"
: code === "unpinned"
? "text-amber-600 dark:text-amber-300 border-amber-500/30"
: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30",
)}
>
{source.type}
</Badge>
</li>
);
})}
</ul>
<div className="space-y-2.5 rounded-md border border-border p-3">
<PolicyToggle
label="Allow external sources"
description="Resolve github/url skill and team sources at install time."
checked={allowExternalSources}
onChange={(v) => onChange("external", v)}
/>
<PolicyToggle
label="Allow unpinned optional sources"
description="Permit optional sources that are not pinned to a ref or checksum."
checked={allowUnpinnedOptionalSources}
onChange={(v) => onChange("unpinned", v)}
/>
<PolicyToggle
label="Allow local-path sources"
description="Required for local_path / agent_package sources. Development use only."
checked={allowLocalPathSources}
onChange={(v) => onChange("localPath", v)}
/>
</div>
{hasUnsupported && !allowLocalPathSources && (
<p className="text-xs text-rose-600 dark:text-rose-300">
This team has local-path sources. Enable &ldquo;Allow local-path sources&rdquo; to continue,
or install it from the CLI.
</p>
)}
</div>
);
}
function PolicyToggle({
label,
description,
checked,
onChange,
}: {
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<ToggleSwitch checked={checked} onCheckedChange={onChange} />
</div>
);
}
const SKILL_ACTION_META: Record<
CatalogTeamSkillPreparation["action"],
{ label: string; tone: string }
> = {
already_in_package: { label: "Bundled in package", tone: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30" },
catalog_install_required: { label: "Will install from catalog", tone: "text-blue-600 dark:text-blue-300 border-blue-500/30" },
external_import_required: { label: "Will import from source", tone: "text-amber-600 dark:text-amber-300 border-amber-500/30" },
blocked: { label: "Blocked", tone: "text-rose-600 dark:text-rose-300 border-rose-500/30" },
};
export function StepSkillPlan({
team,
preparations,
}: {
team: CatalogTeam;
preparations: CatalogTeamSkillPreparation[] | null;
}) {
// Use the live preparations when a preview has run; otherwise fall back to the
// static required-skill list (read-only — the strict API does not accept a
// per-skill plan override, design §7 graceful degradation).
return (
<div className="space-y-4">
<div role="alert" className="rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-2.5 text-sm text-blue-700 dark:text-blue-300">
Before agents are imported, the catalog resolves the skills they depend on. This is the
resolution plan.
</div>
<ul className="divide-y divide-border rounded-md border border-border">
{(preparations ?? team.requiredSkills.map(toPreparation)).map((prep) => {
const meta = SKILL_ACTION_META[prep.action];
return (
<li key={`${prep.type}:${prep.ref}`} className="flex items-center gap-2 px-3 py-2.5 text-sm">
<Boxes className="h-3.5 w-3.5 text-muted-foreground" />
<div className="min-w-0">
<p className="font-mono text-xs truncate">{prep.ref}</p>
{prep.reason && <p className="text-(length:--text-micro) text-muted-foreground">{prep.reason}</p>}
</div>
<Badge variant="outline" className={cn("ml-auto text-(length:--text-nano)", meta.tone)}>
{meta.label}
</Badge>
</li>
);
})}
</ul>
</div>
);
}
function toPreparation(skill: CatalogTeamSkillRequirement): CatalogTeamSkillPreparation {
return {
type: skill.type,
ref: skill.ref,
agentSlugs: skill.agentSlugs,
action:
skill.type === "catalog"
? "catalog_install_required"
: skill.resolved
? "already_in_package"
: "external_import_required",
catalogSkillId: skill.catalogSkillId ?? null,
catalogSkillKey: skill.catalogSkillKey ?? null,
sourceLocator: skill.sourceLocator ?? null,
sourceRef: skill.sourceRef ?? null,
reason: null,
};
}
const PLAN_ACTION_TONE: Record<string, string> = {
create: "text-emerald-600 dark:text-emerald-300 border-emerald-500/30",
update: "text-amber-600 dark:text-amber-300 border-amber-500/30",
skip: "text-muted-foreground border-border",
};
function PlanRow({
slug,
action,
plannedName,
reason,
canRename,
override,
onRename,
}: {
slug: string;
action: string;
plannedName: string;
reason: string | null;
canRename: boolean;
override?: string;
onRename?: (slug: string, name: string) => void;
}) {
return (
<li className="flex items-center gap-2 px-3 py-2 text-sm">
<Badge variant="outline" className={cn("text-(length:--text-nano) uppercase", PLAN_ACTION_TONE[action] ?? "border-border")}>
{action}
</Badge>
<span className={cn("font-mono text-xs", action === "skip" && "line-through opacity-60")}>{slug}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
{canRename && onRename ? (
<Input
value={override ?? plannedName}
onChange={(e) => onRename(slug, e.target.value)}
className="h-7 max-w-(--sz-14rem) font-mono text-xs"
/>
) : (
<span className="font-mono text-xs">{plannedName}</span>
)}
{reason && <span className="ml-auto text-(length:--text-micro) text-muted-foreground">{reason}</span>}
</li>
);
}
export function StepPreview({
team,
loading,
error,
result,
collisionStrategy,
onCollisionStrategyChange,
nameOverrides,
onRename,
adapterOverrides,
selectableAdapterTypes,
onAdapterChange,
secretValues = {},
visibleSecretKeys = {},
onSecretChange = () => {},
onToggleSecretVisibility = () => {},
onRetry,
}: {
team: CatalogTeam;
loading: boolean;
error: string | null;
result: CatalogTeamImportPreviewResult | null;
collisionStrategy: CompanyPortabilityCollisionStrategy;
onCollisionStrategyChange: (s: CompanyPortabilityCollisionStrategy) => void;
nameOverrides: Record<string, string>;
onRename: (slug: string, name: string) => void;
adapterOverrides: Record<string, string>;
selectableAdapterTypes: readonly string[];
onAdapterChange: (slug: string, adapterType: string) => void;
secretValues?: Record<string, string>;
visibleSecretKeys?: Record<string, boolean>;
onSecretChange?: (key: string, value: string) => void;
onToggleSecretVisibility?: (key: string) => void;
onRetry: () => void;
}) {
if (loading && !result) {
return (
<div className="flex items-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Preparing preview…
</div>
);
}
if (error) {
return (
<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">
<XOctagon className="mt-0.5 h-4 w-4 shrink-0" />
{error}
</div>
<Button variant="outline" onClick={onRetry}>
<RotateCcw className="h-4 w-4" /> Retry
</Button>
</div>
);
}
if (!result) return null;
const plan = result.portabilityPreview.plan;
const envInputs = result.portabilityPreview.envInputs;
const manifestAgents = result.portabilityPreview.manifest.agents;
const canRename = collisionStrategy === "rename";
return (
<div className="space-y-4">
{/* Summary */}
<div className="space-y-2">
<SectionHeader>Summary</SectionHeader>
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
<SummaryCount label="Agents" value={plan.agentPlans.length} />
<SummaryCount label="Projects" value={plan.projectPlans.length} />
<SummaryCount label="Starter tasks" value={plan.issuePlans.length} />
<SummaryCount label="Required skills" value={result.skillPreparations.length} />
</div>
</div>
{/* Collision strategy */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Collision strategy</span>
<Select value={collisionStrategy} onValueChange={(v) => onCollisionStrategyChange(v as CompanyPortabilityCollisionStrategy)}>
<SelectTrigger className="h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rename">Rename collisions</SelectItem>
<SelectItem value="skip">Skip collisions</SelectItem>
</SelectContent>
</Select>
</div>
{/* Errors / warnings */}
{result.errors.length > 0 && (
<div role="alert" className="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">
<p className="font-medium">Install blocked</p>
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-xs">
{result.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</div>
)}
{result.warnings.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-700 dark:text-amber-300">
<ul className="list-disc space-y-0.5 pl-4">
{result.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{/* Agents */}
{plan.agentPlans.length > 0 && (
<PreviewSection title={`Agents · ${plan.agentPlans.length}`}>
{plan.agentPlans.map((p) => (
<PlanRow
key={p.slug}
slug={p.slug}
action={p.action}
plannedName={p.plannedName}
reason={p.reason}
canRename={canRename && p.action !== "skip"}
override={nameOverrides[p.slug]}
onRename={onRename}
/>
))}
</PreviewSection>
)}
{/* Projects */}
{plan.projectPlans.length > 0 && (
<PreviewSection title={`Projects · ${plan.projectPlans.length}`}>
{plan.projectPlans.map((p) => (
<PlanRow
key={p.slug}
slug={p.slug}
action={p.action}
plannedName={p.plannedName}
reason={p.reason}
canRename={canRename && p.action !== "skip"}
override={nameOverrides[p.slug]}
onRename={onRename}
/>
))}
</PreviewSection>
)}
{/* Starter tasks */}
{plan.issuePlans.length > 0 && (
<PreviewSection title={`Starter tasks · ${plan.issuePlans.length}`}>
{plan.issuePlans.map((p) => (
<PlanRow key={p.slug} slug={p.slug} action={p.action} plannedName={p.plannedTitle} reason={p.reason} canRename={false} />
))}
</PreviewSection>
)}
{/* Adapter selection — install schema accepts adapterOverrides (design §4.4) */}
{manifestAgents.length > 0 && (
<PreviewSection title={`Adapter selection · ${manifestAgents.length}`}>
{manifestAgents.map((agent) => {
const selected = resolveTeamInstallAdapterType(
adapterOverrides[agent.slug] ?? agent.adapterType,
selectableAdapterTypes,
);
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-(length:--text-micro) text-muted-foreground">{agent.slug}</span>
{selected ? (
<Select value={selected} onValueChange={(v) => onAdapterChange(agent.slug, v)}>
<SelectTrigger className="ml-auto h-8 w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectableAdapterTypes.map((type) => (
<SelectItem key={type} value={type}>{getAdapterLabel(type)}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="ml-auto text-xs text-rose-600 dark:text-rose-300">
No enabled legacy adapter
</span>
)}
</li>
);
})}
<li className="px-3 py-1.5 text-(length:--text-micro) 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 */}
{envInputs.length > 0 && (
<PreviewSection title={`Secrets & env inputs · ${envInputs.length}`}>
{envInputs.map((input) => {
const formKey = envInputFormKey(input);
const visible = Boolean(visibleSecretKeys[formKey]);
const missingRequired = input.requirement === "required" && (secretValues[formKey] ?? "").trim().length === 0;
return (
<li key={formKey} className="grid gap-2 px-3 py-2 text-sm sm:grid-cols-(--gtc-56) sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<KeyRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-mono text-xs uppercase tracking-wide">{input.key}</span>
{input.description && <span className="truncate text-xs text-muted-foreground">{input.description}</span>}
{input.requirement === "required" && (
<Badge variant="outline" className="text-(length:--text-nano)">required</Badge>
)}
<Badge
variant="outline"
className={cn("ml-auto text-(length:--text-nano)", input.kind === "secret" ? "text-rose-600 dark:text-rose-300 border-rose-500/30" : "text-muted-foreground")}
>
{input.kind}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Input
type={visible ? "text" : "password"}
value={secretValues[formKey] ?? ""}
onChange={(event) => onSecretChange(formKey, event.target.value)}
placeholder={input.requirement === "required" ? "Required" : "Optional"}
aria-label={`${input.key} value`}
aria-invalid={missingRequired || undefined}
className={cn("h-8 min-w-0", missingRequired && "border-rose-500/60 focus-visible:ring-rose-500/30")}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-8 w-8"
onClick={() => onToggleSecretVisibility(formKey)}
aria-label={visible ? `Hide ${input.key}` : `Show ${input.key}`}
>
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{visible ? "Hide value" : "Show value"}</TooltipContent>
</Tooltip>
</div>
</li>
);
})}
</PreviewSection>
)}
{/* Provenance */}
<div className="rounded-md border border-border px-3 py-2.5 text-xs text-muted-foreground">
Imported entities are stamped with <code className="font-mono">metadata.paperclip.catalogTeam</code>{" "}
({team.packageName ?? team.key}, content hash <code className="font-mono">{team.contentHash.slice(0, 16)}…</code>),
and an activity event is recorded for preview and install.
</div>
</div>
);
}
function SummaryCount({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md border border-border px-3 py-2">
<span className="text-lg font-semibold tabular-nums">{value}</span>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
);
}
function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-md border border-border">
<div className="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</div>
<ul className="divide-y divide-border/60">{children}</ul>
</div>
);
}
// 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="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 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">
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
<p className="text-base font-semibold">Team installed</p>
</div>
<p className="text-sm text-muted-foreground">
{team.name} was imported into your organization. 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>
<div className="flex justify-end">
<Button onClick={onClose}>Done</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Browse list
// ---------------------------------------------------------------------------
export function TeamRow({
team,
selected,
onSelect,
installed,
}: {
team: CatalogTeam;
selected: boolean;
onSelect: () => void;
installed?: InstalledCatalogTeam | null;
}) {
const risk = teamRisk(team);
const outOfDate = Boolean(installed?.outOfDate);
return (
<button
type="button"
onClick={onSelect}
className={cn(
"flex w-full flex-col gap-1 border-b border-border/60 px-3 py-2.5 text-left transition-colors hover:bg-accent/30",
selected && "bg-accent/40",
)}
>
<div className="flex items-center gap-2">
<Users2 className={cn("h-3.5 w-3.5 text-muted-foreground", team.kind === "optional" && "opacity-70")} />
<span className={cn("line-clamp-2 text-(length:--text-compact) font-medium", selected && "text-foreground")}>
{team.name}
</span>
{outOfDate && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label="Update available"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300"
>
<ChevronUp className="h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent>Update available — installed team is out of date</TooltipContent>
</Tooltip>
)}
{risk !== "safe" && (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className={cn("ml-auto h-3.5 w-3.5", risk === "blocked" ? "text-rose-500" : "text-amber-500")} />
</TooltipTrigger>
<TooltipContent>Has external sources</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-1.5 text-(length:--text-micro) text-muted-foreground">
<span>
{team.counts.agents}a · {team.counts.projects}p · {team.counts.routines}r · {skillCount(team)}s
</span>
<TrustChip level={team.trustLevel} iconOnly />
</div>
</button>
);
}
// ---------------------------------------------------------------------------
// TeamCard — square tile for the onboarding "Pick a starter team" grid
// (design §6 + §12.5). Rendered in a 3-col grid of `defaultInstall` bundled
// teams. Selection is owned by the parent (the onboarding step) so the same
// tile works for the future live flow without rework.
// ---------------------------------------------------------------------------
export function TeamCard({
team,
selected = false,
onSelect,
}: {
team: CatalogTeam;
selected?: boolean;
onSelect?: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={cn(
// design-allow(card-pattern): interactive <button> tile; Card renders a div and would break button semantics (C5a Run 3)
"flex aspect-square w-full flex-col gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected && "ring-2 ring-ring",
)}
>
<div className="flex items-start justify-between gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background">
<Users2 className="h-4 w-4 text-muted-foreground" />
</span>
{team.trustLevel !== "markdown_only" && <TrustChip level={team.trustLevel} iconOnly />}
</div>
<div className="space-y-0.5">
<h3 className="text-sm font-semibold leading-snug">{team.name}</h3>
<p className="text-xs text-muted-foreground">
{team.counts.agents} agent{team.counts.agents === 1 ? "" : "s"} ·{" "}
{team.counts.projects} project{team.counts.projects === 1 ? "" : "s"} ·{" "}
{team.counts.routines} routine{team.counts.routines === 1 ? "" : "s"}
</p>
</div>
{team.description && (
<p className="line-clamp-3 text-xs text-muted-foreground">{team.description}</p>
)}
{team.tags.length > 0 && (
<div className="mt-auto flex flex-wrap gap-1">
{team.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-(length:--text-nano)">
{tag}
</Badge>
))}
</div>
)}
</button>
);
}
type KindFilter = "all" | "bundled" | "optional";
type RiskFilter = "any" | "safe" | "has_warnings" | "blocked";
function matchesSearch(team: CatalogTeam, q: string): boolean {
if (!q) return true;
const needle = q.toLowerCase();
const haystack = [
team.name,
team.description,
team.category,
...team.tags,
...team.agentSlugs,
]
.join(" ")
.toLowerCase();
return haystack.includes(needle);
}
export function TeamCatalog() {
const { "*": routePath } = useParams<{ "*": string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const queryClient = useQueryClient();
const parsedRoute = useMemo(() => parseTeamRoute(routePath), [routePath]);
const selectedRef = parsedRoute.catalogRef;
const selectedFilePath = parsedRoute.filePath;
const q = searchParams.get("search") ?? "";
const kindFilter = (searchParams.get("kind") as KindFilter) ?? "all";
const categoryFilter = searchParams.get("category") ?? "";
const riskFilter = (searchParams.get("risk") as RiskFilter) ?? "any";
// Preserve the active filter query (search/kind/category/risk) across in-page
// navigation. Without this, auto-select and team-row / file-tree clicks rebuild
// a fresh `/teams-catalog/<key>` path from `teamRoute()` and drop the query string, so a
// landing `?search=…` unfiltered the list and emptied the search box (PAP-10257
// follow-up). `applyCompanyPrefix` already preserves query strings.
const filterQuery = searchParams.toString();
const withFilters = useCallback(
(path: string) => (filterQuery ? `${path}?${filterQuery}` : path),
[filterQuery],
);
const [installOpen, setInstallOpen] = useState(false);
const isDesktop = useMediaQuery(`(min-width: ${DESKTOP_MIN}px)`);
useEffect(() => {
setBreadcrumbs([
{ label: "Org Chart", href: "/org" },
{ label: "Teams", href: TEAM_CATALOG_ROUTE_ROOT },
]);
}, [setBreadcrumbs]);
const catalogQuery = useQuery({
queryKey: queryKeys.teamCatalog.catalog({ kind: kindFilter === "all" ? undefined : kindFilter }),
queryFn: () => teamCatalogApi.catalogList(kindFilter === "all" ? {} : { kind: kindFilter }),
enabled: Boolean(selectedCompanyId),
});
const catalogView = useQueryView(catalogQuery);
const teams = catalogQuery.data ?? [];
const categories = useMemo(
() => Array.from(new Set(teams.map((t) => t.category))).sort(),
[teams],
);
const filtered = useMemo(() => {
return teams.filter((team) => {
if (kindFilter !== "all" && team.kind !== kindFilter) return false;
if (categoryFilter && team.category !== categoryFilter) return false;
if (riskFilter !== "any" && teamRisk(team) !== riskFilter) return false;
if (!matchesSearch(team, q)) return false;
return true;
});
}, [teams, kindFilter, categoryFilter, riskFilter, q]);
const selectedTeam = useMemo(
() => teams.find((t) => t.id === selectedRef || t.key === selectedRef || t.slug === selectedRef) ?? null,
[teams, selectedRef],
);
// Auto-select the first team when none is in the route — desktop only. On
// narrow viewports the list stands alone until the operator picks a team
// (design §11).
useEffect(() => {
if (isDesktop && !selectedRef && filtered[0]) {
navigate(withFilters(teamRoute(filtered[0].id)), { replace: true });
}
}, [isDesktop, selectedRef, filtered, navigate, withFilters]);
const fileQuery = useQuery({
queryKey: queryKeys.teamCatalog.catalogFile(selectedTeam?.id ?? "", selectedFilePath ?? ""),
queryFn: () => teamCatalogApi.catalogFile(selectedTeam!.id, selectedFilePath!),
enabled: Boolean(selectedTeam && selectedFilePath),
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? ""),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
// Server-computed installed/out-of-date state. Drives the `INSTALLED · N`
// group, the per-row out-of-date badge, and the detail header chip from a
// real server signal (design §3.2 + §5 / PAP-10256).
const installedQuery = useQuery({
queryKey: queryKeys.teamCatalog.installed(selectedCompanyId ?? ""),
queryFn: () => teamCatalogApi.installed(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const installedById = useMemo(() => {
const map = new Map<string, InstalledCatalogTeam>();
for (const entry of installedQuery.data ?? []) {
if (entry.present) map.set(entry.catalogId, entry);
}
return map;
}, [installedQuery.data]);
function setFilterParam(key: string, value: string | null) {
setSearchParams((current) => {
const next = new URLSearchParams(current);
if (value === null || value === "" || value === "all" || value === "any") next.delete(key);
else next.set(key, value);
return next;
});
}
const anyFilterActive = q !== "" || kindFilter !== "all" || categoryFilter !== "" || riskFilter !== "any";
// Installed teams collapse under a single `INSTALLED · N` group and drop out
// of their BUNDLED/OPTIONAL home (design §5 "Already installed").
const grouped = useMemo(() => {
const installed = filtered.filter((t) => installedById.has(t.id));
const remaining = filtered.filter((t) => !installedById.has(t.id));
const bundled = remaining.filter((t) => t.kind === "bundled");
const optional = remaining.filter((t) => t.kind === "optional");
return { bundled, optional, installed };
}, [filtered, installedById]);
const canInstall = true; // server enforces; UI shows the affordance to operators
if (!selectedCompanyId) {
return (
<div className="p-8">
<EmptyState icon={Users2} message="Select an organization to browse the team catalog." />
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div data-canonical-toolbar="teams" role="toolbar" aria-label="Teams controls" className="flex flex-wrap items-center gap-2 border-b border-border px-5 py-3">
<h1 className="text-lg font-semibold">Teams</h1>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setFilterParam("search", e.target.value)}
placeholder="Search teams"
className="h-8 w-56 pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Filter className="h-3.5 w-3.5" />
{kindFilter === "all" ? "All kinds" : kindFilter === "bundled" ? "Bundled" : "Optional"}
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Kind</DropdownMenuLabel>
<DropdownMenuRadioGroup value={kindFilter} onValueChange={(v) => setFilterParam("kind", v)}>
<DropdownMenuRadioItem value="all">All kinds</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bundled">Bundled</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="optional">Optional</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{categories.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
{categoryFilter ? `Category · ${titleCase(categoryFilter)}` : "All categories"}
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Category</DropdownMenuLabel>
<DropdownMenuRadioGroup value={categoryFilter} onValueChange={(v) => setFilterParam("category", v)}>
<DropdownMenuRadioItem value="">All categories</DropdownMenuRadioItem>
{categories.map((cat) => (
<DropdownMenuRadioItem key={cat} value={cat}>{titleCase(cat)}</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
{riskFilter === "any" ? "Any risk" : riskFilter === "safe" ? "Safe only" : riskFilter === "has_warnings" ? "Has warnings" : "Blocked"}
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Risk</DropdownMenuLabel>
<DropdownMenuRadioGroup value={riskFilter} onValueChange={(v) => setFilterParam("risk", v)}>
<DropdownMenuRadioItem value="any">Any risk</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="safe">Safe only</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="has_warnings">Has warnings</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="blocked">Blocked</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
{anyFilterActive && (
<>
<DropdownMenuSeparator />
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setSearchParams(new URLSearchParams())}
>
<RotateCcw className="h-3 w-3" /> Reset filters
</button>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{anyFilterActive && (
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setSearchParams(new URLSearchParams())}>
Reset filters
</Button>
)}
</div>
<div className="flex min-h-0 flex-1">
{/* 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-(--sz-28rem) lg:shrink-0",
!isDesktop && selectedTeam && "hidden",
)}
>
{catalogView.kind === "loading" || catalogView.kind === "reconnecting" ? (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : catalogView.kind === "error" ? (
<div className="p-4">
<QueryErrorState
error={catalogView.error}
action="load the team catalog"
onRetry={catalogView.retry}
retrying={catalogView.isFetching}
/>
</div>
) : teams.length === 0 ? (
<EmptyState icon={Users2} message="No team catalog configured." />
) : filtered.length === 0 ? (
<EmptyState
icon={Search}
message="No teams match this filter."
action="Reset filters"
onAction={() => setSearchParams(new URLSearchParams())}
/>
) : (
<div>
{grouped.bundled.length > 0 && (
<>
<div className="px-3 py-2 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Bundled · {grouped.bundled.length}
</div>
{grouped.bundled.map((team) => (
<TeamRow
key={team.id}
team={team}
selected={team.id === selectedTeam?.id}
onSelect={() => navigate(withFilters(teamRoute(team.id)))}
/>
))}
</>
)}
{grouped.optional.length > 0 && (
<>
<div className="px-3 py-2 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Optional · {grouped.optional.length}
</div>
{grouped.optional.map((team) => (
<TeamRow
key={team.id}
team={team}
selected={team.id === selectedTeam?.id}
onSelect={() => navigate(withFilters(teamRoute(team.id)))}
/>
))}
</>
)}
{grouped.installed.length > 0 && (
<>
<div className="px-3 py-2 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Installed · {grouped.installed.length}
</div>
{grouped.installed.map((team) => (
<TeamRow
key={team.id}
team={team}
selected={team.id === selectedTeam?.id}
onSelect={() => navigate(withFilters(teamRoute(team.id)))}
installed={installedById.get(team.id) ?? null}
/>
))}
</>
)}
</div>
)}
</div>
{/* 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(withFilters(TEAM_CATALOG_ROUTE_ROOT))}
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(withFilters(path ? teamRoute(selectedTeam.id, path) : teamRoute(selectedTeam.id)))
}
onInstall={() => setInstallOpen(true)}
canInstall={canInstall}
fileContent={fileQuery.data?.content ?? null}
installed={installedById.get(selectedTeam.id) ?? null}
/>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a team to view details.
</div>
)}
</div>
)}
</div>
{selectedTeam && installOpen && (
<TeamInstallerDialog
team={selectedTeam}
companyId={selectedCompanyId}
agents={agentsQuery.data ?? []}
open={installOpen}
onClose={() => setInstallOpen(false)}
onInstalled={() => {
pushToast({ tone: "success", title: "Team installed", body: `${selectedTeam.name} was imported.` });
// Provenance now lives on the new agents — refresh installed/out-of-date state.
void queryClient.invalidateQueries({
queryKey: queryKeys.teamCatalog.installed(selectedCompanyId),
});
void queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
}}
/>
)}
</div>
);
}