feat: skill library

This commit is contained in:
Richard Tang
2026-04-21 18:48:22 -07:00
parent 8a0ec070b8
commit 14f927996c
23 changed files with 3734 additions and 150 deletions
+2
View File
@@ -5,6 +5,7 @@ import ColonyChat from "./pages/colony-chat";
import QueenDM from "./pages/queen-dm";
import OrgChart from "./pages/org-chart";
import PromptLibrary from "./pages/prompt-library";
import SkillsLibrary from "./pages/skills-library";
import ToolLibrary from "./pages/tool-library";
import CredentialsPage from "./pages/credentials";
import NotFound from "./pages/not-found";
@@ -17,6 +18,7 @@ function App() {
<Route path="/colony/:colonyId" element={<ColonyChat />} />
<Route path="/queen/:queenId" element={<QueenDM />} />
<Route path="/org-chart" element={<OrgChart />} />
<Route path="/skills-library" element={<SkillsLibrary />} />
<Route path="/prompt-library" element={<PromptLibrary />} />
<Route path="/tool-library" element={<ToolLibrary />} />
<Route path="/credentials" element={<CredentialsPage />} />
+153
View File
@@ -0,0 +1,153 @@
import { api } from "./client";
export type SkillScopeKind = "queen" | "colony" | "user";
export type SkillProvenance =
| "framework"
| "user_dropped"
| "user_ui_created"
| "queen_created"
| "learned_runtime"
| "project_dropped"
| "other";
export interface SkillOwner {
type: "queen" | "colony";
id: string;
name: string;
}
export interface SkillRow {
name: string;
description: string;
source_scope: string;
provenance: SkillProvenance;
enabled: boolean;
editable: boolean;
deletable: boolean;
location: string;
base_dir?: string;
visibility: string[] | null;
trust: string | null;
created_at: string | null;
created_by: string | null;
notes: string | null;
param_overrides?: Record<string, unknown>;
owner?: SkillOwner | null;
visible_to?: { queens: string[]; colonies: string[] };
enabled_by_default?: boolean;
}
export interface ScopeSkillsResponse {
queen_id?: string;
colony_name?: string;
all_defaults_disabled: boolean;
skills: SkillRow[];
inherited_from_queen?: string[];
}
export interface AggregatedSkillsResponse {
skills: SkillRow[];
queens: Array<{ id: string; name: string }>;
colonies: Array<{ name: string; queen_id: string | null }>;
}
export interface SkillScopesResponse {
queens: Array<{ id: string; name: string }>;
colonies: Array<{ name: string; queen_id: string | null }>;
}
export interface SkillDetailResponse {
name: string;
description: string;
source_scope: string;
location: string;
base_dir: string;
body: string;
visibility: string[] | null;
}
export interface SkillCreatePayload {
name: string;
description: string;
body: string;
files?: Array<{ path: string; content: string }>;
enabled?: boolean;
notes?: string | null;
replace_existing?: boolean;
}
export interface SkillPatchPayload {
enabled?: boolean;
param_overrides?: Record<string, unknown>;
notes?: string | null;
all_defaults_disabled?: boolean;
}
const scopePath = (scope: "queen" | "colony", targetId: string) =>
scope === "queen"
? `/queen/${encodeURIComponent(targetId)}/skills`
: `/colonies/${encodeURIComponent(targetId)}/skills`;
export const skillsApi = {
// Aggregated library
listAll: () => api.get<AggregatedSkillsResponse>("/skills"),
listScopes: () => api.get<SkillScopesResponse>("/skills/scopes"),
getDetail: (name: string) =>
api.get<SkillDetailResponse>(`/skills/${encodeURIComponent(name)}`),
// Per-scope
listForQueen: (queenId: string) =>
api.get<ScopeSkillsResponse>(`/queen/${encodeURIComponent(queenId)}/skills`),
listForColony: (colonyName: string) =>
api.get<ScopeSkillsResponse>(
`/colonies/${encodeURIComponent(colonyName)}/skills`,
),
create: (
scope: "queen" | "colony",
targetId: string,
payload: SkillCreatePayload,
) => api.post<SkillRow>(scopePath(scope, targetId), payload),
patch: (
scope: "queen" | "colony",
targetId: string,
skillName: string,
payload: SkillPatchPayload,
) =>
api.patch<{ name: string; enabled: boolean | null; ok: boolean }>(
`${scopePath(scope, targetId)}/${encodeURIComponent(skillName)}`,
payload,
),
putBody: (
scope: "queen" | "colony",
targetId: string,
skillName: string,
payload: { body: string; description?: string },
) =>
api.put<{ name: string; installed_path: string }>(
`${scopePath(scope, targetId)}/${encodeURIComponent(skillName)}/body`,
payload,
),
remove: (scope: "queen" | "colony", targetId: string, skillName: string) =>
api.delete<{ name: string; removed: boolean }>(
`${scopePath(scope, targetId)}/${encodeURIComponent(skillName)}`,
),
reload: (scope: "queen" | "colony", targetId: string) =>
api.post<{ ok: boolean }>(`${scopePath(scope, targetId)}/reload`),
// Multipart upload. File may be a SKILL.md or a .zip bundle.
upload: (formData: FormData) =>
api.upload<{
name: string;
installed_path: string;
replaced: boolean;
scope: SkillScopeKind;
target_id: string | null;
enabled: boolean;
}>("/skills/upload", formData),
};
+8
View File
@@ -13,6 +13,7 @@ import {
Crown,
Loader2,
Wrench,
Library,
} from "lucide-react";
import SidebarColonyItem from "./SidebarColonyItem";
import SidebarQueenItem from "./SidebarQueenItem";
@@ -166,6 +167,13 @@ export default function Sidebar() {
<Network className="w-4 h-4" />
<span>Org Chart</span>
</button>
<button
onClick={() => navigate("/skills-library")}
className="flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
>
<Library className="w-4 h-4" />
<span>Skills Library</span>
</button>
<button
onClick={() => navigate("/prompt-library")}
className="flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
+979
View File
@@ -0,0 +1,979 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Library,
Crown,
Network,
BookOpen,
Search,
Plus,
Upload,
X,
Trash2,
Lock,
AlertCircle,
Loader2,
CheckCircle2,
Circle,
} from "lucide-react";
import { queensApi } from "@/api/queens";
import { coloniesApi, type ColonySummary } from "@/api/colonies";
import { slugToDisplayName } from "@/lib/colony-registry";
import { ApiError } from "@/api/client";
import {
skillsApi,
type AggregatedSkillsResponse,
type ScopeSkillsResponse,
type SkillDetailResponse,
type SkillProvenance,
type SkillRow,
} from "@/api/skills";
type Tab = "queens" | "colonies" | "catalog";
const PROVENANCE_LABEL: Record<SkillProvenance, string> = {
framework: "Framework",
user_dropped: "User",
user_ui_created: "User (UI)",
queen_created: "Queen",
learned_runtime: "Learned",
project_dropped: "Colony",
other: "Other",
};
function ProvenanceBadge({ provenance }: { provenance: SkillProvenance }) {
const tone =
provenance === "framework"
? "bg-slate-400/10 text-slate-400"
: provenance === "queen_created"
? "bg-amber-500/10 text-amber-500"
: provenance === "learned_runtime"
? "bg-purple-500/10 text-purple-500"
: "bg-primary/10 text-primary";
return (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${tone}`}>
{PROVENANCE_LABEL[provenance]}
</span>
);
}
// ---------------------------------------------------------------------------
// Page shell
// ---------------------------------------------------------------------------
export default function SkillsLibrary() {
const [tab, setTab] = useState<Tab>("queens");
return (
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<div className="px-6 py-4 border-b border-border/60">
<div className="flex items-baseline gap-3 mb-3">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Library className="w-5 h-5 text-primary" />
Skills Library
</h2>
<span className="text-xs text-muted-foreground">
Curate which skills each queen and colony exposes, upload your own, or browse the full catalog.
</span>
</div>
<div className="flex items-center gap-1">
<TabButton active={tab === "queens"} onClick={() => setTab("queens")} icon={<Crown className="w-3.5 h-3.5" />}>
Queens
</TabButton>
<TabButton active={tab === "colonies"} onClick={() => setTab("colonies")} icon={<Network className="w-3.5 h-3.5" />}>
Colonies
</TabButton>
<TabButton active={tab === "catalog"} onClick={() => setTab("catalog")} icon={<BookOpen className="w-3.5 h-3.5" />}>
Catalog
</TabButton>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{tab === "queens" && <QueensTab />}
{tab === "colonies" && <ColoniesTab />}
{tab === "catalog" && <CatalogTab />}
</div>
</div>
);
}
function TabButton({
active,
onClick,
icon,
children,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium ${
active
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/30"
}`}
>
{icon}
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// Queens tab
// ---------------------------------------------------------------------------
function QueensTab() {
const [queens, setQueens] = useState<Array<{ id: string; name: string; title: string }> | null>(
null,
);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
queensApi
.list()
.then((r) => {
setQueens(r.queens);
if (r.queens.length > 0) setSelected((prev) => prev ?? r.queens[0].id);
})
.catch((e: Error) => setError(e.message || "Failed to load queens"));
}, []);
if (error) return <ErrorBlock message={error} />;
if (queens === null) return <LoadingBlock label="Loading queens…" />;
if (queens.length === 0) return <EmptyBlock label="No queens yet." />;
return (
<div className="flex h-full">
<SidePicker>
{queens.map((q) => (
<PickerItem
key={q.id}
active={selected === q.id}
onClick={() => setSelected(q.id)}
primary={q.name}
secondary={q.title}
/>
))}
</SidePicker>
<div className="flex-1 overflow-y-auto px-6 py-5 min-w-0">
{selected ? (
<>
{(() => {
const q = queens.find((x) => x.id === selected);
return q ? (
<div className="mb-4 pb-3 border-b border-border/40">
<h3 className="text-base font-semibold text-foreground">{q.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">{q.title}</p>
</div>
) : null;
})()}
<SkillsPerScopeSection scopeKind="queen" targetId={selected} />
</>
) : (
<EmptyBlock label="Pick a queen to edit her skill catalog." />
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Colonies tab
// ---------------------------------------------------------------------------
function ColoniesTab() {
const [colonies, setColonies] = useState<ColonySummary[] | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
coloniesApi
.list()
.then((r) => {
setColonies(r.colonies);
if (r.colonies.length > 0) setSelected((prev) => prev ?? r.colonies[0].name);
})
.catch((e: Error) => setError(e.message || "Failed to load colonies"));
}, []);
const sorted = useMemo(() => {
if (!colonies) return null;
return [...colonies].sort((a, b) => a.name.localeCompare(b.name));
}, [colonies]);
if (error) return <ErrorBlock message={error} />;
if (sorted === null) return <LoadingBlock label="Loading colonies…" />;
if (sorted.length === 0)
return (
<EmptyBlock label="No colonies yet. Ask a queen to incubate one and its skills will show up here." />
);
return (
<div className="flex h-full">
<SidePicker>
{sorted.map((c) => (
<PickerItem
key={c.name}
active={selected === c.name}
onClick={() => setSelected(c.name)}
primary={slugToDisplayName(c.name)}
secondary={c.queen_name ? `@${c.queen_name}` : undefined}
tertiary={c.name}
/>
))}
</SidePicker>
<div className="flex-1 overflow-y-auto px-6 py-5 min-w-0">
{selected ? (
<>
<div className="mb-4 pb-3 border-b border-border/40">
<h3 className="text-base font-semibold text-foreground">
{slugToDisplayName(selected)}
</h3>
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{selected}</p>
</div>
<SkillsPerScopeSection scopeKind="colony" targetId={selected} />
</>
) : (
<EmptyBlock label="Pick a colony to edit its skill catalog." />
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Per-scope section (shared body for Queens + Colonies tabs)
// ---------------------------------------------------------------------------
function SkillsPerScopeSection({
scopeKind,
targetId,
}: {
scopeKind: "queen" | "colony";
targetId: string;
}) {
const [resp, setResp] = useState<ScopeSkillsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [detailName, setDetailName] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const r =
scopeKind === "queen"
? await skillsApi.listForQueen(targetId)
: await skillsApi.listForColony(targetId);
setResp(r);
} catch (e) {
setError(e instanceof ApiError ? e.body.error : String(e));
} finally {
setLoading(false);
}
}, [scopeKind, targetId]);
useEffect(() => {
reload();
}, [reload]);
const rows = resp?.skills ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q),
);
}, [rows, search]);
const toggle = async (row: SkillRow) => {
try {
await skillsApi.patch(scopeKind, targetId, row.name, { enabled: !row.enabled });
await reload();
} catch (e) {
setError(e instanceof ApiError ? e.body.error : String(e));
}
};
const remove = async (row: SkillRow) => {
if (!window.confirm(`Delete skill '${row.name}'? This removes its files.`)) return;
try {
await skillsApi.remove(scopeKind, targetId, row.name);
await reload();
} catch (e) {
setError(e instanceof ApiError ? e.body.error : String(e));
}
};
return (
<div>
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or description"
className="w-full pl-9 pr-3 py-1.5 rounded-md border border-border/60 bg-muted/30 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div className="flex-1" />
<button
onClick={() => setCreateOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20"
>
<Plus className="w-3.5 h-3.5" /> New Skill
</button>
</div>
{resp?.inherited_from_queen?.length ? (
<div className="mb-3 text-xs text-muted-foreground">
Inherited from queen{resp.queen_id ? ` (${resp.queen_id})` : ""}:{" "}
{resp.inherited_from_queen.join(", ")}
</div>
) : null}
{loading && <LoadingBlock label="Loading skills…" />}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{!loading && filtered.length === 0 && (
<p className="text-sm text-muted-foreground">No skills match your filter.</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filtered.map((row) => (
<SkillCard
key={row.name}
row={row}
onToggle={() => toggle(row)}
onOpen={() => setDetailName(row.name)}
onRemove={row.deletable ? () => remove(row) : undefined}
/>
))}
</div>
<CreateSkillModal
open={createOpen}
scopeKind={scopeKind}
targetId={targetId}
onClose={() => setCreateOpen(false)}
onSaved={reload}
/>
<SkillDetailDrawer skillName={detailName} onClose={() => setDetailName(null)} />
</div>
);
}
// ---------------------------------------------------------------------------
// Catalog tab
// ---------------------------------------------------------------------------
function CatalogTab() {
const [resp, setResp] = useState<AggregatedSkillsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [detailName, setDetailName] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
setResp(await skillsApi.listAll());
} catch (e) {
setError(e instanceof ApiError ? e.body.error : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reload();
}, [reload]);
const rows = resp?.skills ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q),
);
}, [rows, search]);
return (
<div className="px-6 py-5">
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 min-w-[200px] max-w-[360px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search every skill on this machine"
className="w-full pl-9 pr-3 py-1.5 rounded-md border border-border/60 bg-muted/30 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div className="flex-1" />
<button
onClick={() => setUploadOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border/60 bg-card text-sm font-medium text-foreground hover:bg-muted/50"
>
<Upload className="w-3.5 h-3.5" /> Upload
</button>
</div>
{loading && <LoadingBlock label="Loading catalog…" />}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{!loading && filtered.length === 0 && (
<p className="text-sm text-muted-foreground">No skills match your filter.</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((row) => (
<SkillCard
key={row.name}
row={row}
onOpen={() => setDetailName(row.name)}
// Catalog view is read-only for toggle/delete — all mutations
// happen in the scoped tabs.
/>
))}
</div>
<UploadSkillModal
open={uploadOpen}
scopes={{
queens: resp?.queens ?? [],
colonies: resp?.colonies ?? [],
}}
onClose={() => setUploadOpen(false)}
onUploaded={reload}
/>
<SkillDetailDrawer skillName={detailName} onClose={() => setDetailName(null)} />
</div>
);
}
// ---------------------------------------------------------------------------
// Skill card (shared across all three tabs)
// ---------------------------------------------------------------------------
function SkillCard({
row,
onOpen,
onToggle,
onRemove,
}: {
row: SkillRow;
onOpen: () => void;
onToggle?: () => void;
onRemove?: () => void;
}) {
return (
<div className="rounded-lg border border-border/60 bg-card p-4 hover:border-primary/30 transition-colors flex flex-col">
<div className="flex items-start gap-2 mb-1">
{onToggle ? (
<button
onClick={onToggle}
title={row.enabled ? "Disable" : "Enable"}
className="flex-shrink-0 mt-0.5"
>
{row.enabled ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : (
<Circle className="w-4 h-4 text-muted-foreground" />
)}
</button>
) : (
<div className="flex-shrink-0 mt-0.5" aria-hidden>
{row.enabled ? (
<CheckCircle2 className="w-4 h-4 text-muted-foreground/40" />
) : (
<Circle className="w-4 h-4 text-muted-foreground/40" />
)}
</div>
)}
<div className="min-w-0 flex-1">
<button
onClick={onOpen}
className="text-sm font-medium text-foreground text-left hover:text-primary line-clamp-1"
>
{row.name}
</button>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<ProvenanceBadge provenance={row.provenance} />
{row.owner && (
<span className="text-[10px] text-muted-foreground">@{row.owner.id}</span>
)}
{!row.editable && (
<Lock
className="w-3 h-3 text-muted-foreground"
aria-label="Read-only"
>
<title>Read-only</title>
</Lock>
)}
</div>
</div>
{onRemove && (
<button
onClick={onRemove}
className="p-1 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Delete skill"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">{row.description}</p>
{row.visible_to && (
<p className="text-[10px] text-muted-foreground mt-auto">
Visible on {row.visible_to.queens.length} queens, {row.visible_to.colonies.length}{" "}
colonies
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Modals + drawer (shared)
// ---------------------------------------------------------------------------
function CreateSkillModal({
open,
scopeKind,
targetId,
onClose,
onSaved,
}: {
open: boolean;
scopeKind: "queen" | "colony";
targetId: string;
onClose: () => void;
onSaved: () => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [body, setBody] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) return null;
const submit = async () => {
setError(null);
if (!name.trim() || !description.trim() || !body.trim()) {
setError("Name, description, and body are required.");
return;
}
setSaving(true);
try {
await skillsApi.create(scopeKind, targetId, {
name: name.trim(),
description: description.trim(),
body,
enabled: true,
});
setName("");
setDescription("");
setBody("");
onSaved();
onClose();
} catch (e) {
setError(e instanceof ApiError ? e.body.error : String(e));
} finally {
setSaving(false);
}
};
const label = scopeKind === "queen" ? `Queen: ${targetId}` : `Colony: ${targetId}`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-card border border-border/60 rounded-2xl shadow-2xl w-full max-w-[640px] p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-lg font-semibold text-foreground">New Skill</h3>
<p className="text-xs text-muted-foreground mt-0.5">Scope: {label}</p>
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
Name <span className="text-primary">*</span>
</label>
<input
value={name}
onChange={(e) => setName(e.target.value.toLowerCase())}
placeholder="e.g. vendor-api-protocol"
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Lowercase letters, digits, hyphens, dots. Max 64 chars.
</p>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
Description <span className="text-primary">*</span>
</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="One-line summary shown in the catalog picker"
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
Body (SKILL.md content) <span className="text-primary">*</span>
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={14}
placeholder={"## When to use\n\n...\n\n## Steps\n\n1. ..."}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none"
/>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-xs">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30"
>
Cancel
</button>
<button
onClick={submit}
disabled={saving}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Saving…" : "Create"}
</button>
</div>
</div>
</div>
</div>
);
}
function UploadSkillModal({
open,
scopes,
onClose,
onUploaded,
}: {
open: boolean;
scopes: {
queens: Array<{ id: string; name: string }>;
colonies: Array<{ name: string; queen_id: string | null }>;
};
onClose: () => void;
onUploaded: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [scopeKind, setScopeKind] = useState<"user" | "queen" | "colony">("user");
const [targetId, setTargetId] = useState<string>("");
const [enabled, setEnabled] = useState(true);
const [replaceExisting, setReplaceExisting] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (scopeKind === "queen" && scopes.queens.length > 0 && !targetId) {
setTargetId(scopes.queens[0].id);
} else if (scopeKind === "colony" && scopes.colonies.length > 0 && !targetId) {
setTargetId(scopes.colonies[0].name);
} else if (scopeKind === "user") {
setTargetId("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeKind]);
if (!open) return null;
const submit = async () => {
if (!file) {
setError("Pick a .md or .zip file first.");
return;
}
setError(null);
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("scope", scopeKind);
if (scopeKind !== "user") fd.append("target_id", targetId);
fd.append("enabled", String(enabled));
fd.append("replace_existing", String(replaceExisting));
await skillsApi.upload(fd);
onUploaded();
onClose();
setFile(null);
} catch (e) {
setError(e instanceof ApiError ? e.body.error : String(e));
} finally {
setUploading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-card border border-border/60 rounded-2xl shadow-2xl w-full max-w-[520px] p-6">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-foreground">Upload Skill</h3>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
File (.md or .zip)
</label>
<input
type="file"
accept=".md,.zip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="w-full text-sm text-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary/10 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary hover:file:bg-primary/20"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">Scope</label>
<select
value={scopeKind}
onChange={(e) => setScopeKind(e.target.value as typeof scopeKind)}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
>
<option value="user">User library (available to all queens)</option>
<option value="queen">Queen</option>
<option value="colony">Colony</option>
</select>
</div>
{scopeKind === "queen" && (
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">Queen</label>
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
>
{scopes.queens.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
</select>
</div>
)}
{scopeKind === "colony" && (
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">Colony</label>
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
>
{scopes.colonies.map((c) => (
<option key={c.name} value={c.name}>
{c.name} {c.queen_id ? `(${c.queen_id})` : ""}
</option>
))}
</select>
</div>
)}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Enable immediately
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={replaceExisting}
onChange={(e) => setReplaceExisting(e.target.checked)}
/>
Replace if exists
</label>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-xs">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30"
>
Cancel
</button>
<button
onClick={submit}
disabled={uploading || !file}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{uploading ? "Uploading…" : "Upload"}
</button>
</div>
</div>
</div>
</div>
);
}
function SkillDetailDrawer({
skillName,
onClose,
}: {
skillName: string | null;
onClose: () => void;
}) {
const [detail, setDetail] = useState<SkillDetailResponse | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!skillName) return;
setLoading(true);
skillsApi
.getDetail(skillName)
.then(setDetail)
.catch(() => setDetail(null))
.finally(() => setLoading(false));
}, [skillName]);
if (!skillName) return null;
return (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative w-full max-w-[640px] h-full bg-card border-l border-border/60 overflow-y-auto p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-foreground">{skillName}</h3>
{detail && (
<p className="text-xs text-muted-foreground mt-0.5">{detail.description}</p>
)}
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50"
>
<X className="w-4 h-4" />
</button>
</div>
{loading && <p className="text-sm text-muted-foreground">Loading</p>}
{detail && (
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/30 border border-border/40 rounded-lg p-4 text-foreground/80">
{detail.body}
</pre>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Primitives (match tool-library style)
// ---------------------------------------------------------------------------
function SidePicker({ children }: { children: React.ReactNode }) {
return (
<div className="w-[260px] flex-shrink-0 border-r border-border/60 overflow-y-auto py-3 px-2 flex flex-col gap-1">
{children}
</div>
);
}
function PickerItem({
active,
onClick,
primary,
secondary,
tertiary,
}: {
active: boolean;
onClick: () => void;
primary: string;
secondary?: string;
tertiary?: string;
}) {
return (
<button
onClick={onClick}
className={`text-left px-3 py-2 rounded-md text-sm ${
active ? "bg-primary/15 text-primary" : "text-foreground hover:bg-muted/30"
}`}
>
<div className="font-medium truncate">{primary}</div>
{secondary && (
<div className="text-[11px] text-muted-foreground truncate">{secondary}</div>
)}
{tertiary && (
<div className="text-[10px] text-muted-foreground/60 font-mono truncate">{tertiary}</div>
)}
</button>
);
}
function LoadingBlock({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-6 py-6">
<Loader2 className="w-3 h-3 animate-spin" />
{label}
</div>
);
}
function EmptyBlock({ label }: { label: string }) {
return (
<div className="flex items-start gap-2 text-xs text-muted-foreground px-6 py-6">
<AlertCircle className="w-3.5 h-3.5 mt-0.5" />
<span>{label}</span>
</div>
);
}
function ErrorBlock({ message }: { message: string }) {
return (
<div className="flex items-start gap-2 text-xs text-destructive px-6 py-6">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{message}</span>
</div>
);
}