Add company skills library and agent skills UI

This commit is contained in:
Dotta
2026-03-14 10:55:04 -05:00
parent 2137c2f715
commit 0bf53bc513
22 changed files with 8050 additions and 131 deletions

View File

@@ -22,6 +22,7 @@ import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanySkills } from "./pages/CompanySkills";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceSettings } from "./pages/InstanceSettings";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
@@ -111,6 +112,8 @@ function boardRoutes() {
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="skills" element={<CompanySkills />} />
<Route path="skills/:skillId" element={<CompanySkills />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="org" element={<OrgChart />} />
@@ -302,6 +305,8 @@ export function App() {
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="skills" element={<UnprefixedBoardRedirect />} />
<Route path="skills/:skillId" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />

View File

@@ -0,0 +1,20 @@
import type {
CompanySkillDetail,
CompanySkillImportResult,
CompanySkillListItem,
} from "@paperclipai/shared";
import { api } from "./client";
export const companySkillsApi = {
list: (companyId: string) =>
api.get<CompanySkillListItem[]>(`/companies/${encodeURIComponent(companyId)}/skills`),
detail: (companyId: string, skillId: string) =>
api.get<CompanySkillDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
),
importFromSource: (companyId: string, source: string) =>
api.post<CompanySkillImportResult>(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
{ source },
),
};

View File

@@ -13,3 +13,4 @@ export { activityApi } from "./activity";
export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { sidebarBadgesApi } from "./sidebarBadges";
export { companySkillsApi } from "./companySkills";

View File

@@ -8,6 +8,7 @@ import {
Search,
SquarePen,
Network,
Boxes,
Settings,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
@@ -93,6 +94,7 @@ export function Sidebar() {
<SidebarSection label="Company">
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />

View File

@@ -4,6 +4,10 @@ export const queryKeys = {
detail: (id: string) => ["companies", id] as const,
stats: ["companies", "stats"] as const,
},
companySkills: {
list: (companyId: string) => ["company-skills", companyId] as const,
detail: (companyId: string, skillId: string) => ["company-skills", companyId, skillId] as const,
},
agents: {
list: (companyId: string) => ["agents", companyId] as const,
detail: (id: string) => ["agents", "detail", id] as const,

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { companySkillsApi } from "../api/companySkills";
import { heartbeatsApi } from "../api/heartbeats";
import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
@@ -175,10 +176,11 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior });
}
type AgentDetailView = "dashboard" | "configuration" | "runs";
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs";
function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "configure" || value === "configuration") return "configuration";
if (value === "skills") return "skills";
if (value === "runs") return value;
return "dashboard";
}
@@ -315,6 +317,8 @@ export function AgentDetail() {
const canonicalTab =
activeView === "configuration"
? "configuration"
: activeView === "skills"
? "skills"
: activeView === "runs"
? "runs"
: "dashboard";
@@ -414,6 +418,8 @@ export function AgentDetail() {
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
} else if (activeView === "configuration") {
crumbs.push({ label: "Configuration" });
} else if (activeView === "skills") {
crumbs.push({ label: "Skills" });
} else if (activeView === "runs") {
crumbs.push({ label: "Runs" });
} else {
@@ -571,6 +577,7 @@ export function AgentDetail() {
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "configuration", label: "Configuration" },
{ value: "skills", label: "Skills" },
{ value: "runs", label: "Runs" },
]}
value={activeView}
@@ -667,6 +674,13 @@ export function AgentDetail() {
/>
)}
{activeView === "skills" && (
<AgentSkillsTab
agent={agent}
companyId={resolvedCompanyId ?? undefined}
/>
)}
{activeView === "runs" && (
<RunsTab
runs={heartbeats ?? []}
@@ -1045,8 +1059,6 @@ function ConfigurationTab({
}) {
const queryClient = useQueryClient();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [skillDirty, setSkillDirty] = useState(false);
const lastAgentRef = useRef(agent);
const { data: adapterModels } = useQuery({
@@ -1058,12 +1070,6 @@ function ConfigurationTab({
enabled: Boolean(companyId),
});
const { data: skillSnapshot } = useQuery({
queryKey: queryKeys.agents.skills(agent.id),
queryFn: () => agentsApi.skills(agent.id, companyId),
enabled: Boolean(companyId),
});
const updateAgent = useMutation({
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
onMutate: () => {
@@ -1079,30 +1085,12 @@ function ConfigurationTab({
},
});
const syncSkills = useMutation({
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
onSuccess: (snapshot) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) });
setSkillDraft(snapshot.desiredSkills);
setSkillDirty(false);
},
});
useEffect(() => {
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
setAwaitingRefreshAfterSave(false);
}
lastAgentRef.current = agent;
}, [agent, awaitingRefreshAfterSave]);
useEffect(() => {
if (!skillSnapshot) return;
setSkillDraft(skillSnapshot.desiredSkills);
setSkillDirty(false);
}, [skillSnapshot]);
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
useEffect(() => {
@@ -1143,53 +1131,300 @@ function ConfigurationTab({
</div>
</div>
</div>
</div>
);
}
<div>
<h3 className="text-sm font-medium mb-3">Skills</h3>
<div className="border border-border rounded-lg p-4 space-y-3">
{!skillSnapshot ? (
<p className="text-sm text-muted-foreground">Loading skill sync state</p>
) : !skillSnapshot.supported ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
This adapter does not implement skill sync yet.
</p>
function AgentSkillsTab({
agent,
companyId,
}: {
agent: Agent;
companyId?: string;
}) {
const queryClient = useQueryClient();
const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [skillDirty, setSkillDirty] = useState(false);
const { data: skillSnapshot, isLoading } = useQuery({
queryKey: queryKeys.agents.skills(agent.id),
queryFn: () => agentsApi.skills(agent.id, companyId),
enabled: Boolean(companyId),
});
const { data: companySkills } = useQuery({
queryKey: queryKeys.companySkills.list(companyId ?? ""),
queryFn: () => companySkillsApi.list(companyId!),
enabled: Boolean(companyId),
});
useEffect(() => {
if (!skillSnapshot) return;
setSkillDraft(skillSnapshot.desiredSkills);
setSkillDirty(false);
}, [skillSnapshot]);
const syncSkills = useMutation({
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
onSuccess: async (snapshot) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }),
companyId
? queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) })
: Promise.resolve(),
]);
setSkillDraft(snapshot.desiredSkills);
setSkillDirty(false);
},
});
const companySkillBySlug = useMemo(
() => new Map((companySkills ?? []).map((skill) => [skill.slug, skill])),
[companySkills],
);
const adapterEntryByName = useMemo(
() => new Map((skillSnapshot?.entries ?? []).map((entry) => [entry.name, entry])),
[skillSnapshot],
);
const desiredOnlyMissingSkills = useMemo(
() => skillDraft.filter((slug) => !companySkillBySlug.has(slug)),
[companySkillBySlug, skillDraft],
);
const externalEntries = (skillSnapshot?.entries ?? []).filter((entry) => entry.state === "external");
const modeCopy = useMemo(() => {
if (!skillSnapshot) return "Loading skill state...";
if (!skillSnapshot.supported) {
return "This adapter does not implement direct skill sync yet. Paperclip can still store the desired skill set for this agent.";
}
if (skillSnapshot.mode === "persistent") {
return "Selected skills are synchronized into the adapter's persistent skills home.";
}
if (skillSnapshot.mode === "ephemeral") {
return "Selected skills are mounted for each run instead of being installed globally.";
}
return "This adapter reports skill state but does not define a persistent install model.";
}, [skillSnapshot]);
const primaryActionLabel = !skillSnapshot || skillSnapshot.supported
? "Sync skills"
: "Save desired skills";
return (
<div className="max-w-5xl space-y-6">
<section className="overflow-hidden rounded-2xl border border-border bg-card">
<div className="border-b border-border bg-[linear-gradient(135deg,rgba(14,165,233,0.08),transparent_45%),linear-gradient(315deg,rgba(16,185,129,0.08),transparent_45%)] px-5 py-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Skills
</div>
<h3 className="text-2xl font-semibold tracking-tight">Attach reusable skills to {agent.name}.</h3>
<p className="mt-2 text-sm text-muted-foreground">{modeCopy}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link
to="/skills"
className="inline-flex items-center gap-1 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground no-underline transition-colors hover:bg-accent/40"
>
Open company library
<ArrowLeft className="h-3.5 w-3.5 rotate-180" />
</Link>
<Button
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) })}
disabled={isLoading}
variant="outline"
>
Refresh state
</Button>
</div>
</div>
</div>
<div className="space-y-4 px-5 py-5">
{skillSnapshot?.warnings.length ? (
<div className="space-y-1 rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
{skillSnapshot.warnings.map((warning) => (
<p key={warning} className="text-xs text-muted-foreground">
{warning}
</p>
<div key={warning}>{warning}</div>
))}
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
{skillSnapshot.mode === "persistent"
? "These skills are synced into the adapter's persistent skills home."
: "These skills are mounted ephemerally for each Claude run."}
</p>
) : null}
<div className="space-y-2">
{skillSnapshot.entries
.filter((entry) => entry.managed)
.map((entry) => {
const checked = skillDraft.includes(entry.name);
return (
<label
key={entry.name}
className="flex items-start gap-3 rounded-md border border-border/70 px-3 py-2"
{isLoading ? (
<PageSkeleton variant="list" />
) : (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div className="space-y-4">
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium">Company skills</h4>
<p className="text-xs text-muted-foreground">
Attach skills from the company library by shortname.
</p>
</div>
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{(companySkills ?? []).length} available
</span>
</div>
{(companySkills ?? []).length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Import skills into the company library first, then attach them here.
</div>
) : (
<div className="mt-4 space-y-2">
{(companySkills ?? []).map((skill) => {
const checked = skillDraft.includes(skill.slug);
const adapterEntry = adapterEntryByName.get(skill.slug);
return (
<label
key={skill.id}
className="flex items-start gap-3 rounded-xl border border-border/70 px-3 py-3 transition-colors hover:bg-accent/20"
>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
const next = event.target.checked
? Array.from(new Set([...skillDraft, skill.slug]))
: skillDraft.filter((value) => value !== skill.slug);
setSkillDraft(next);
setSkillDirty(true);
}}
className="mt-1"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{skill.name}</span>
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{skill.slug}
</span>
</div>
{skill.description && (
<p className="mt-1 text-xs text-muted-foreground">{skill.description}</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{adapterEntry?.state && (
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{adapterEntry.state}
</span>
)}
<Link
to={`/skills/${skill.id}`}
className="text-xs text-muted-foreground no-underline hover:text-foreground"
>
View skill
</Link>
</div>
</div>
</div>
</label>
);
})}
</div>
)}
</section>
{desiredOnlyMissingSkills.length > 0 && (
<section className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-4 dark:border-amber-500/30 dark:bg-amber-950/20">
<h4 className="text-sm font-medium text-amber-900 dark:text-amber-100">
Desired skills not found in the company library
</h4>
<div className="mt-3 space-y-2">
{desiredOnlyMissingSkills.map((skillName) => {
const adapterEntry = adapterEntryByName.get(skillName);
return (
<div key={skillName} className="flex items-center justify-between gap-3 rounded-lg border border-amber-300/50 bg-background/70 px-3 py-2 dark:border-amber-500/20">
<div>
<div className="text-sm font-medium">{skillName}</div>
<div className="text-xs text-muted-foreground">
This skill is still requested for the agent, but it is not tracked in the company library.
</div>
</div>
{adapterEntry?.state && (
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{adapterEntry.state}
</span>
)}
</div>
);
})}
</div>
</section>
)}
</div>
<div className="space-y-4">
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
<h4 className="text-sm font-medium">Adapter state</h4>
<div className="mt-3 grid gap-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Adapter</span>
<span className="font-medium">{agent.adapterType}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Sync mode</span>
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{skillSnapshot?.mode ?? "unsupported"}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Desired skills</span>
<span>{skillDraft.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">External skills</span>
<span>{externalEntries.length}</span>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<Button
size="sm"
onClick={() => syncSkills.mutate(skillDraft)}
disabled={syncSkills.isPending || !skillDirty}
>
{syncSkills.isPending ? "Saving..." : primaryActionLabel}
</Button>
{skillDirty && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSkillDraft(skillSnapshot?.desiredSkills ?? []);
setSkillDirty(false);
}}
disabled={syncSkills.isPending}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const next = e.target.checked
? Array.from(new Set([...skillDraft, entry.name]))
: skillDraft.filter((value) => value !== entry.name);
setSkillDraft(next);
setSkillDirty(true);
}}
/>
<div className="min-w-0 flex-1">
Reset
</Button>
)}
</div>
{syncSkills.isError && (
<p className="mt-3 text-xs text-destructive">
{syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"}
</p>
)}
</section>
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
<h4 className="text-sm font-medium">External skills</h4>
{externalEntries.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">
No external skills were discovered by the adapter.
</p>
) : (
<div className="mt-3 space-y-2">
{externalEntries.map((entry) => (
<div key={entry.name} className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{entry.name}</span>
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
@@ -1197,74 +1432,18 @@ function ConfigurationTab({
</span>
</div>
{entry.detail && (
<p className="mt-1 text-xs text-muted-foreground">
{entry.detail}
</p>
<p className="mt-1 text-xs text-muted-foreground">{entry.detail}</p>
)}
</div>
</label>
);
})}
))}
</div>
)}
</section>
</div>
{skillSnapshot.entries.some((entry) => entry.state === "external") && (
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
External skills
</div>
{skillSnapshot.entries
.filter((entry) => entry.state === "external")
.map((entry) => (
<div key={entry.name} className="text-xs text-muted-foreground">
{entry.name}
{entry.detail ? ` - ${entry.detail}` : ""}
</div>
))}
</div>
)}
{skillSnapshot.warnings.length > 0 && (
<div className="space-y-1 rounded-md border border-amber-300/60 bg-amber-50/60 px-3 py-2 text-xs text-amber-700">
{skillSnapshot.warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
)}
{syncSkills.isError && (
<p className="text-xs text-destructive">
{syncSkills.error instanceof Error
? syncSkills.error.message
: "Failed to sync skills"}
</p>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => syncSkills.mutate(skillDraft)}
disabled={syncSkills.isPending || !skillDirty}
>
{syncSkills.isPending ? "Syncing..." : "Sync skills"}
</Button>
{skillDirty && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSkillDraft(skillSnapshot.desiredSkills);
setSkillDirty(false);
}}
disabled={syncSkills.isPending}
>
Reset
</Button>
)}
</div>
</>
</div>
)}
</div>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,434 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CompanySkillDetail,
CompanySkillListItem,
CompanySkillTrustLevel,
} from "@paperclipai/shared";
import { companySkillsApi } from "../api/companySkills";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
import { PageSkeleton } from "../components/PageSkeleton";
import { EntityRow } from "../components/EntityRow";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ArrowUpRight,
BookOpen,
Boxes,
FolderInput,
RefreshCw,
ShieldAlert,
ShieldCheck,
TerminalSquare,
} from "lucide-react";
function stripFrontmatter(markdown: string) {
const normalized = markdown.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) return normalized.trim();
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0) return normalized.trim();
return normalized.slice(closing + 5).trim();
}
function trustTone(trustLevel: CompanySkillTrustLevel) {
switch (trustLevel) {
case "markdown_only":
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
case "assets":
return "bg-amber-500/10 text-amber-700 dark:text-amber-300";
case "scripts_executables":
return "bg-red-500/10 text-red-700 dark:text-red-300";
default:
return "bg-muted text-muted-foreground";
}
}
function trustLabel(trustLevel: CompanySkillTrustLevel) {
switch (trustLevel) {
case "markdown_only":
return "Markdown only";
case "assets":
return "Assets";
case "scripts_executables":
return "Scripts";
default:
return trustLevel;
}
}
function compatibilityLabel(detail: CompanySkillDetail | CompanySkillListItem) {
switch (detail.compatibility) {
case "compatible":
return "Compatible";
case "unknown":
return "Unknown";
case "invalid":
return "Invalid";
default:
return detail.compatibility;
}
}
function SkillListItem({
skill,
selected,
}: {
skill: CompanySkillListItem;
selected: boolean;
}) {
return (
<Link
to={`/skills/${skill.id}`}
className={cn(
"block rounded-xl border p-3 no-underline transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border/70 bg-card hover:border-border hover:bg-accent/20",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">{skill.name}</span>
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{skill.slug}
</span>
</div>
{skill.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{skill.description}
</p>
)}
</div>
<span className={cn("shrink-0 rounded-full px-2 py-1 text-[10px] font-medium", trustTone(skill.trustLevel))}>
{trustLabel(skill.trustLevel)}
</span>
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
<span>{skill.attachedAgentCount} agent{skill.attachedAgentCount === 1 ? "" : "s"}</span>
<span>{skill.fileInventory.length} file{skill.fileInventory.length === 1 ? "" : "s"}</span>
</div>
</Link>
);
}
function SkillDetailPanel({
detail,
isLoading,
}: {
detail: CompanySkillDetail | null | undefined;
isLoading: boolean;
}) {
if (isLoading) {
return <PageSkeleton variant="detail" />;
}
if (!detail) {
return (
<div className="rounded-2xl border border-dashed border-border bg-card/40 p-8">
<div className="max-w-md space-y-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-accent text-accent-foreground">
<BookOpen className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold">Select a skill</h2>
<p className="mt-1 text-sm text-muted-foreground">
Review its markdown, inspect files, and see which agents have it attached.
</p>
</div>
</div>
</div>
);
}
const markdownBody = stripFrontmatter(detail.markdown);
return (
<div className="space-y-5">
<section className="overflow-hidden rounded-2xl border border-border bg-card">
<div className="border-b border-border bg-[linear-gradient(135deg,rgba(24,24,27,0.02),rgba(24,24,27,0.06))] px-5 py-4 dark:bg-[linear-gradient(135deg,rgba(255,255,255,0.03),rgba(255,255,255,0.06))]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold">{detail.name}</h2>
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{detail.slug}
</span>
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", trustTone(detail.trustLevel))}>
{trustLabel(detail.trustLevel)}
</span>
</div>
{detail.description && (
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{detail.description}</p>
)}
</div>
<div className="grid shrink-0 gap-1 text-right text-[11px] text-muted-foreground">
<span>{compatibilityLabel(detail)}</span>
<span>{detail.attachedAgentCount} attached agent{detail.attachedAgentCount === 1 ? "" : "s"}</span>
</div>
</div>
</div>
<div className="grid gap-5 px-5 py-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium">SKILL.md</h3>
{detail.sourceLocator && (
<a
href={detail.sourceLocator}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Open source
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
)}
</div>
<div className="rounded-xl border border-border/70 bg-background px-4 py-4">
<MarkdownBody>{markdownBody}</MarkdownBody>
</div>
</div>
<div className="space-y-4">
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
<h3 className="text-sm font-medium">Inventory</h3>
<div className="mt-3 space-y-2">
{detail.fileInventory.map((entry) => (
<div key={`${entry.kind}:${entry.path}`} className="flex items-center justify-between gap-3 text-xs">
<span className="truncate font-mono text-muted-foreground">{entry.path}</span>
<span className="rounded-full border border-border/70 px-2 py-0.5 uppercase tracking-wide text-[10px] text-muted-foreground">
{entry.kind}
</span>
</div>
))}
</div>
</section>
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
<h3 className="text-sm font-medium">Used By Agents</h3>
{detail.usedByAgents.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">No agents are currently attached to this skill.</p>
) : (
<div className="mt-3 space-y-2">
{detail.usedByAgents.map((agent) => (
<EntityRow
key={agent.id}
title={agent.name}
subtitle={agent.adapterType}
to={`/agents/${agent.urlKey}/skills`}
trailing={agent.actualState ? (
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{agent.actualState}
</span>
) : undefined}
/>
))}
</div>
)}
</section>
</div>
</div>
</section>
</div>
);
}
export function CompanySkills() {
const { skillId } = useParams<{ skillId?: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const [source, setSource] = useState("");
useEffect(() => {
setBreadcrumbs([
{ label: "Skills", href: "/skills" },
...(skillId ? [{ label: "Detail" }] : []),
]);
}, [setBreadcrumbs, skillId]);
const {
data: skills,
isLoading,
error,
} = useQuery({
queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""),
queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const selectedSkillId = useMemo(() => {
if (!skillId) return skills?.[0]?.id ?? null;
return skillId;
}, [skillId, skills]);
const {
data: detail,
isLoading: detailLoading,
} = useQuery({
queryKey: queryKeys.companySkills.detail(selectedCompanyId ?? "", selectedSkillId ?? ""),
queryFn: () => companySkillsApi.detail(selectedCompanyId!, selectedSkillId!),
enabled: Boolean(selectedCompanyId && selectedSkillId),
});
const importSkill = useMutation({
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
if (result.imported[0]) {
navigate(`/skills/${result.imported[0].id}`);
}
pushToast({
tone: "success",
title: "Skills imported",
body: `${result.imported.length} skill${result.imported.length === 1 ? "" : "s"} added to the company library.`,
});
if (result.warnings[0]) {
pushToast({
tone: "warn",
title: "Import warnings",
body: result.warnings[0],
});
}
setSource("");
},
onError: (importError) => {
pushToast({
tone: "error",
title: "Skill import failed",
body: importError instanceof Error ? importError.message : "Failed to import skill source.",
});
},
});
if (!selectedCompanyId) {
return <EmptyState icon={Boxes} message="Select a company to manage skills." />;
}
return (
<div className="space-y-5">
<section className="overflow-hidden rounded-2xl border border-border bg-card">
<div className="border-b border-border bg-[radial-gradient(circle_at_top_left,rgba(16,185,129,0.10),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(59,130,246,0.10),transparent_40%)] px-5 py-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<Boxes className="h-3.5 w-3.5" />
Company skill library
</div>
<h1 className="text-2xl font-semibold tracking-tight">Manage reusable skills once, attach them anywhere.</h1>
<p className="mt-2 text-sm text-muted-foreground">
Import `SKILL.md` packages from local paths, GitHub repos, or direct URLs. Agents attach by skill shortname, while adapters decide how those skills are installed or mounted.
</p>
</div>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<div className="rounded-xl border border-border/70 bg-background/70 px-3 py-3">
<div className="flex items-center gap-2 font-medium text-foreground">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
Markdown-first
</div>
<p className="mt-1">`skills.sh` compatible packages stay readable and repo-native.</p>
</div>
<div className="rounded-xl border border-border/70 bg-background/70 px-3 py-3">
<div className="flex items-center gap-2 font-medium text-foreground">
<FolderInput className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" />
GitHub aware
</div>
<p className="mt-1">Import a repo, a subtree, or a single skill file without a registry.</p>
</div>
<div className="rounded-xl border border-border/70 bg-background/70 px-3 py-3">
<div className="flex items-center gap-2 font-medium text-foreground">
<ShieldAlert className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
Trust surfaced
</div>
<p className="mt-1">Scripts and executable bundles stay visible instead of being hidden in setup.</p>
</div>
</div>
</div>
</div>
<div className="border-t border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex-1">
<Input
value={source}
onChange={(event) => setSource(event.target.value)}
placeholder="Local path, GitHub repo/tree/blob URL, or direct SKILL.md URL"
className="h-10"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId) })}
disabled={isLoading}
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
<Button
size="sm"
onClick={() => importSkill.mutate(source.trim())}
disabled={importSkill.isPending || source.trim().length === 0}
>
{importSkill.isPending ? "Importing..." : "Import skill"}
</Button>
</div>
</div>
</div>
</section>
{error && <p className="text-sm text-destructive">{error.message}</p>}
{!isLoading && (skills?.length ?? 0) === 0 ? (
<EmptyState
icon={TerminalSquare}
message="No company skills yet."
action="Import your first skill"
onAction={() => {
const trimmed = source.trim();
if (trimmed) importSkill.mutate(trimmed);
}}
/>
) : (
<div className="grid gap-5 xl:grid-cols-[22rem_minmax(0,1fr)]">
<section className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">Library</h2>
<p className="text-xs text-muted-foreground">
{skills?.length ?? 0} tracked skill{(skills?.length ?? 0) === 1 ? "" : "s"}
</p>
</div>
</div>
{isLoading ? (
<PageSkeleton variant="list" />
) : (
<div className="space-y-2">
{(skills ?? []).map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
selected={skill.id === selectedSkillId}
/>
))}
</div>
)}
</section>
<SkillDetailPanel detail={detail} isLoading={detailLoading} />
</div>
)}
</div>
);
}