Restructure library taxonomy and skills browsing

This commit is contained in:
mvschwarz committed 2026-05-07 04:17:39 -07:00
1 parent 7284cda909
commit ff2b1a6d40
11 files changed
+708 -128

No files matched your search

@@ -23,7 +23,7 @@ export function AgentSpecReview() {
<WorkflowHeader
eyebrow="Agent Spec Review"
title="No AgentSpec Selected"
description="Choose a current or recent agent draft from the Specs drawer to review it here before you validate it."
description="Choose a current or recent agent draft from the Library drawer to review it here before you validate it."
/>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/agents/validate" })}>
Open Validate
+8 -9
View File
@@ -14,7 +14,7 @@
//
// SC-1 satisfied: exactly 2 left chromes on desktop (rail + explore).
// SC-2 satisfied: rail order Dashboard / Topology / For You / Project /
// Specs / Settings + Advisor + Operator (no discovery in rail).
// Library / Settings + Advisor + Operator (no discovery in rail).
// SC-7 satisfied: Settings mounts in CENTER (rail icon → /settings route,
// not a drawer toggle).
// SC-8 satisfied: mobile rail collapses to top-bar menu; explore
@@ -164,7 +164,7 @@ const RAIL_ICONS: RailIconSpec[] = [
},
{
id: "specs",
label: "Specs",
label: "Library",
to: "/specs",
icon: FileText,
activeWhen: (p) => p.startsWith("/specs"),
@@ -293,9 +293,9 @@ function Rail({
: "text-stone-700 hover:bg-stone-200/60 hover:text-stone-900",
)}
>
{/* Lighter icon line weight (founder direction 2026-05-06):
stroke-width 1.25 (default lucide is 2) for an architectural
drafting feel that matches the 1px ghost border doctrine. */}
{/* Lighter icon line weight: stroke-width 1.25 (default lucide is 2)
for an architectural drafting feel that matches the 1px ghost
border doctrine. */}
<Icon className="h-5 w-5" strokeWidth={1.25} />
{active && (
<span
@@ -314,10 +314,9 @@ function Rail({
className={cn(
// V1 border weight doctrine (universal-shell.md L39–L48):
// 1px outline-variant ghost line for inter-region edges.
// Vellum surface (founder direction 2026-05-06): same translucent
// treatment as the topology-graph Explorer overlay, so the rail
// reads as a paper sheet layered over the canvas (sheets-of-vellum
// aesthetic per universal-shell.md L48).
// Vellum surface: same translucent treatment as the topology-graph
// Explorer overlay, so the rail reads as a paper sheet layered over
// the canvas (sheets-of-vellum aesthetic per universal-shell.md L48).
"vellum border-outline-variant flex shrink-0",
vertical
? "w-12 flex-col items-center border-r py-2 gap-1"
+5 -5
View File
@@ -247,7 +247,7 @@ function LibrarySpecReview({ entryId }: LibraryReviewProps) {
<WorkspacePage>
<div data-testid="library-review-error" className="space-y-4">
<WorkflowHeader eyebrow="Library" title="Spec Not Found" description={(error as Error)?.message ?? "Could not load spec."} />
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Library</Button>
</div>
</WorkspacePage>
);
@@ -533,7 +533,7 @@ function LibraryContextPackReviewPage({ entryId }: { entryId: string }) {
title="Context Pack Not Found"
description={(packsError as Error)?.message ?? `No context pack with id ${entryId}.`}
/>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Library</Button>
</div>
</WorkspacePage>
);
@@ -609,7 +609,7 @@ function ContextPackReviewBody({
>
{showSendPicker ? "Hide Send" : "Send to seat"}
</Button>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Library</Button>
</div>
}
/>
@@ -773,7 +773,7 @@ function LibraryAgentImageReviewPage({ entryId }: { entryId: string }) {
title="Agent Image Not Found"
description={(imagesError as Error)?.message ?? `No agent image with id ${entryId}.`}
/>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Library</Button>
</div>
</WorkspacePage>
);
@@ -831,7 +831,7 @@ function AgentImageReviewBody({
>
{entry.pinned ? "Unpin" : "Pin"}
</Button>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Library</Button>
</div>
}
/>
+1 -1
View File
@@ -112,7 +112,7 @@ export function PackageList() {
<div className="flex flex-col items-center justify-center min-h-[60vh]" data-testid="packages-empty">
<h2 className="text-display-lg text-foreground mb-spacing-4">NO LEGACY PACKAGE INSTALLS</h2>
<p className="text-body-md text-foreground-muted mb-spacing-8">
Legacy package tools remain available for bootstrap internals while Specs becomes the main authoring surface.
Legacy package tools remain available for bootstrap internals while Library becomes the main authoring surface.
</p>
<div className="flex flex-col items-center gap-spacing-3">
<Button
+1 -1
View File
@@ -26,7 +26,7 @@ export function RigSpecReview() {
<WorkflowHeader
eyebrow="Rig Spec Review"
title="No RigSpec Selected"
description="Choose a current or recent rig draft from the Specs drawer to review it here before you import or bootstrap it."
description="Choose a current or recent rig draft from the Library drawer to review it here before you import or bootstrap it."
/>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/import" })}>
Open Import
@@ -19,7 +19,7 @@ export function SpecsCard() {
<div className="bg-stone-900 text-white px-4 py-1.5 flex items-center gap-2">
<FileText className="h-3 w-3" />
<SectionHeader tone="default" className="text-stone-50">
Specs
Library
</SectionHeader>
</div>
<div className="p-4 space-y-2">
@@ -28,10 +28,10 @@ export function SpecsCard() {
<span className="text-stone-900 font-bold" data-testid="specs-count">{total}</span>
</div>
<div className="text-xs text-on-surface-variant">
Rigs / workspaces / workflows / context-packs / agent-images
Specs / context packs / agent images / skills
</div>
<div className="font-mono text-[9px] uppercase tracking-wide text-on-surface-variant pt-1">
Open library →
Open Library →
</div>
</div>
</VellumCard>
@@ -1,54 +1,352 @@
// V1 attempt-3 Phase 3 — Specs library top-level page per specs-tree.md L51–L82 + SC-28.
import { useMemo, useState } from "react";
import { Link } from "@tanstack/react-router";
import { SectionHeader } from "../ui/section-header.js";
import { SpecsTable } from "./SpecsTable.js";
import { EmptyState } from "../ui/empty-state.js";
import { FileViewer } from "../drawer-viewers/FileViewer.js";
import { useSpecLibrary, type SpecLibraryEntry } from "../../hooks/useSpecLibrary.js";
import { useContextPackLibrary, type ContextPackEntry } from "../../hooks/useContextPackLibrary.js";
import { useAgentImageLibrary, type AgentImageEntry } from "../../hooks/useAgentImageLibrary.js";
import { useLibrarySkills, type LibrarySkillEntry, type LibrarySkillFile } from "../../hooks/useLibrarySkills.js";
const TOOLBAR_ACTIONS: Array<{
label: string;
to: string;
testId: string;
}> = [
const TOOLBAR_ACTIONS = [
{ label: "+ Add spec", to: "/specs/rig", testId: "specs-toolbar-add" },
{ label: "Discover", to: "/discovery", testId: "specs-toolbar-discover" },
{ label: "Import bundle", to: "/import", testId: "specs-toolbar-import" },
{ label: "Import", to: "/import", testId: "specs-toolbar-import" },
{ label: "Discover", to: "/search", testId: "specs-toolbar-discover" },
{ label: "Create rig", to: "/specs/rig", testId: "specs-toolbar-create-rig" },
{ label: "Generate workflow", to: "/specs/agent", testId: "specs-toolbar-gen-workflow" },
{ label: "Audit", to: "/search", testId: "specs-toolbar-audit" },
];
] as const;
export function SpecsLibraryPage() {
interface LibraryRow {
id: string;
label: string;
meta: string;
entryId?: string;
}
function specRow(entry: SpecLibraryEntry): LibraryRow {
return {
id: entry.id,
label: entry.name,
meta: `${entry.version} · ${entry.sourceType}`,
entryId: entry.id,
};
}
function contextPackRow(entry: ContextPackEntry): LibraryRow {
return {
id: entry.id,
label: entry.name,
meta: `${entry.version} · ${entry.sourceType} · ~${entry.derivedEstimatedTokens} tokens`,
entryId: entry.id,
};
}
function agentImageRow(entry: AgentImageEntry): LibraryRow {
return {
id: entry.id,
label: entry.name,
meta: `${entry.version} · ${entry.runtime} · ${entry.sourceType}`,
entryId: entry.id,
};
}
function LibrarySection({
id,
title,
rows,
isLoading,
emptyLabel,
}: {
id: string;
title: string;
rows: LibraryRow[];
isLoading?: boolean;
emptyLabel?: string;
}) {
return (
<div
data-testid="specs-library-page"
className="mx-auto w-full max-w-[1200px] px-6 py-8"
>
<header className="border-b border-outline-variant pb-4 mb-6">
<SectionHeader tone="muted">Library</SectionHeader>
<h1 className="font-headline text-headline-md font-bold tracking-tight uppercase text-stone-900 mt-1">
Specs
</h1>
<section data-testid={`library-section-${id}`} className="border border-outline-variant bg-white/25 hard-shadow">
<header className="flex items-baseline justify-between border-b border-outline-variant bg-white/30 px-3 py-2">
<SectionHeader tone="default">{title}</SectionHeader>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{isLoading ? "loading" : `${rows.length} items`}
</span>
</header>
{rows.length > 0 ? (
<ul className="divide-y divide-outline-variant">
{rows.map((row) => (
<li key={row.id}>
{row.entryId ? (
<Link
to="/specs/library/$entryId"
params={{ entryId: row.entryId }}
data-testid={`library-row-${id}-${row.id}`}
className="block px-3 py-2 hover:bg-stone-100/50"
>
<LibraryRowContent row={row} />
</Link>
) : (
<div data-testid={`library-row-${id}-${row.id}`} className="px-3 py-2">
<LibraryRowContent row={row} />
</div>
)}
</li>
))}
</ul>
) : (
<div className="px-3 py-4 font-mono text-[10px] text-stone-500">
{isLoading ? "Loading..." : emptyLabel ?? "No entries."}
</div>
)}
</section>
);
}
<div
data-testid="specs-toolbar"
role="toolbar"
aria-label="Specs library actions"
className="flex flex-wrap gap-2 mb-6"
>
{TOOLBAR_ACTIONS.map((a) => (
<Link
key={a.testId}
to={a.to}
data-testid={a.testId}
className="inline-flex items-center px-3 py-1.5 border border-outline-variant bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-stone-900 hover:bg-stone-100"
>
{a.label}
</Link>
))}
function LibraryRowContent({ row }: { row: LibraryRow }) {
return (
<div className="flex items-baseline justify-between gap-3 font-mono">
<span className="truncate text-xs font-bold text-stone-900">{row.label}</span>
<span className="shrink-0 text-[9px] uppercase tracking-[0.08em] text-stone-500">{row.meta}</span>
</div>
);
}
function SkillSourceLabel({ source }: { source: LibrarySkillEntry["source"] }) {
return (
<span className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500">
{source === "workspace" ? "workspace" : "openrig"}
</span>
);
}
function SkillsSection({
skills,
isLoading,
}: {
skills: LibrarySkillEntry[];
isLoading: boolean;
}) {
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null);
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const selectedSkill = skills.find((skill) => skill.id === selectedSkillId) ?? skills[0] ?? null;
const selectedFile = selectedSkill
? selectedSkill.files.find((file) => file.path === selectedFilePath) ?? selectedSkill.files[0] ?? null
: null;
const selectSkill = (skill: LibrarySkillEntry) => {
setSelectedSkillId(skill.id);
setSelectedFilePath(skill.files[0]?.path ?? null);
};
const selectFile = (file: LibrarySkillFile) => {
setSelectedFilePath(file.path);
};
return (
<section data-testid="library-section-skills" className="border border-outline-variant bg-white/25 hard-shadow">
<header className="flex items-baseline justify-between border-b border-outline-variant bg-white/30 px-3 py-2">
<SectionHeader tone="default">Skills</SectionHeader>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{isLoading ? "loading" : `${skills.length} folders`}
</span>
</header>
{skills.length === 0 ? (
<div className="px-3 py-4">
<EmptyState
label={isLoading ? "LOADING" : "NO SKILLS FOUND"}
description={isLoading
? "Loading skill folders..."
: "No .openrig/skills or packaged OpenRig skill folders are visible through configured file roots."}
variant="card"
testId="library-skills-empty"
/>
</div>
) : (
<div className="grid min-h-[28rem] grid-cols-[18rem_minmax(0,1fr)]">
<aside className="border-r border-outline-variant">
<ul className="divide-y divide-outline-variant">
{skills.map((skill) => (
<li key={skill.id}>
<button
type="button"
data-testid={`library-skill-${skill.name}`}
data-active={selectedSkill?.id === skill.id}
onClick={() => selectSkill(skill)}
className={`block w-full px-3 py-2 text-left hover:bg-stone-100/50 ${
selectedSkill?.id === skill.id ? "bg-stone-100/70" : ""
}`}
>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-mono text-xs font-bold text-stone-900">{skill.name}</span>
<SkillSourceLabel source={skill.source} />
</div>
<div className="mt-1 font-mono text-[9px] text-stone-500">
{skill.files.length} markdown files
</div>
</button>
</li>
))}
</ul>
</aside>
<div className="grid min-w-0 grid-cols-[14rem_minmax(0,1fr)]">
<aside className="border-r border-outline-variant bg-white/20">
<div className="border-b border-outline-variant px-3 py-2 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
Files
</div>
<ul className="divide-y divide-outline-variant">
{selectedSkill?.files.map((file) => (
<li key={file.path}>
<button
type="button"
data-testid={`library-skill-file-${file.name}`}
data-active={selectedFile?.path === file.path}
onClick={() => selectFile(file)}
className={`block w-full px-3 py-2 text-left font-mono text-[10px] hover:bg-stone-100/50 ${
selectedFile?.path === file.path ? "bg-stone-100/70 text-stone-900" : "text-stone-700"
}`}
>
{file.name}
</button>
</li>
))}
</ul>
</aside>
<div className="min-w-0">
{selectedSkill && selectedFile ? (
<FileViewer
path={`${selectedSkill.name}/${selectedFile.name}`}
root={selectedSkill.root}
readPath={selectedFile.path}
kind="markdown"
/>
) : (
<EmptyState
label="NO FILE SELECTED"
description="Select a skill file to preview it."
variant="card"
testId="library-skill-no-file"
/>
)}
</div>
</div>
</div>
)}
</section>
);
}
export function SpecsLibraryPage() {
const { data: specs = [], isLoading: specsLoading } = useSpecLibrary();
const { data: contextPacks = [], isLoading: contextPacksLoading } = useContextPackLibrary();
const { data: agentImages = [], isLoading: agentImagesLoading } = useAgentImageLibrary();
const { data: skills = [], isLoading: skillsLoading } = useLibrarySkills();
const sections = useMemo(() => {
const rigSpecs = specs.filter((entry) => entry.kind === "rig" && !entry.hasServices).map(specRow);
const workflowSpecs = specs.filter((entry) => entry.kind === "workflow").map(specRow);
const agentSpecs = specs.filter((entry) => entry.kind === "agent").map(specRow);
const applications = specs.filter((entry) => entry.kind === "rig" && entry.hasServices).map(specRow);
return { rigSpecs, workflowSpecs, agentSpecs, applications };
}, [specs]);
const total =
sections.rigSpecs.length
+ sections.workflowSpecs.length
+ sections.agentSpecs.length
+ sections.applications.length
+ contextPacks.length
+ agentImages.length
+ skills.length;
return (
<div
data-testid="specs-library-page"
className="h-full overflow-y-auto bg-paper-grid px-6 py-5 lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]"
>
<header className="mb-5 flex items-start justify-between gap-4">
<div>
<SectionHeader tone="muted">Library</SectionHeader>
<h1 className="mt-1 font-headline text-2xl font-bold tracking-tight text-stone-900">
Library
</h1>
<p className="mt-1 max-w-3xl text-sm text-stone-600">
Specs, context packs, agent images, applications, and skill folders.
</p>
</div>
<nav
aria-label="Library actions"
className="flex flex-wrap justify-end gap-2"
>
{TOOLBAR_ACTIONS.map((a) => (
<Link
key={a.testId}
to={a.to}
data-testid={a.testId}
className="border border-outline-variant bg-white/25 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-stone-700 hard-shadow hover:bg-white/40"
>
{a.label}
</Link>
))}
</nav>
</header>
<div className="mb-4 font-mono text-[10px] uppercase tracking-[0.14em] text-stone-500">
{total} library entries visible through current sources
</div>
<SpecsTable />
<div className="grid gap-4 xl:grid-cols-2">
<LibrarySection
id="rig-specs"
title="Rig Specs"
rows={sections.rigSpecs}
isLoading={specsLoading}
emptyLabel="No rig specs found."
/>
<LibrarySection
id="workspace-specs"
title="Workspace Specs"
rows={[]}
emptyLabel="No workspace specs source is wired yet."
/>
<LibrarySection
id="workflow-specs"
title="Workflow Specs"
rows={sections.workflowSpecs}
isLoading={specsLoading}
emptyLabel="No workflow specs found."
/>
<LibrarySection
id="context-packs"
title="Context Packs"
rows={contextPacks.map(contextPackRow)}
isLoading={contextPacksLoading}
emptyLabel="No context packs found."
/>
<LibrarySection
id="agent-specs"
title="Agent Specs"
rows={sections.agentSpecs}
isLoading={specsLoading}
emptyLabel="No agent specs found."
/>
<LibrarySection
id="agent-images"
title="Agent Images"
rows={agentImages.map(agentImageRow)}
isLoading={agentImagesLoading}
emptyLabel="No agent images found."
/>
<LibrarySection
id="applications"
title="Applications"
rows={sections.applications}
isLoading={specsLoading}
emptyLabel="No application specs found."
/>
</div>
<div className="mt-4">
<SkillsSection skills={skills} isLoading={skillsLoading} />
</div>
</div>
);
}
@@ -99,7 +99,7 @@ export function SpecsTable() {
<div data-testid="specs-library-table" className="space-y-3">
<div className="flex items-center gap-2">
<VellumInput
placeholder="Search specs..."
placeholder="Search library..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
@@ -137,7 +137,7 @@ export function SpecsTable() {
) : table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={COLUMNS.length} className="px-3 py-6 text-center font-mono text-xs text-on-surface-variant">
No specs match.
No library entries match.
</td>
</tr>
) : (
@@ -1,42 +1,37 @@
// V1 attempt-3 Phase 3 — Specs tree per specs-tree.md L13–L46 + SC-27.
//
// 6 sections (rigs / workspaces / workflows / context-packs / agent-images
// / applications). useSpecLibrary surfaces rig / agent / workflow today;
// the remaining 3 sections render with V2 placeholders so the canonical
// tree shape is correct.
import { useState } from "react";
import { useMemo, useState } from "react";
import { Link } from "@tanstack/react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { cn } from "../../lib/utils.js";
import { useSpecLibrary } from "../../hooks/useSpecLibrary.js";
import { useSpecLibrary, type SpecLibraryEntry } from "../../hooks/useSpecLibrary.js";
import { useContextPackLibrary } from "../../hooks/useContextPackLibrary.js";
import { useAgentImageLibrary } from "../../hooks/useAgentImageLibrary.js";
import { useLibrarySkills } from "../../hooks/useLibrarySkills.js";
interface TreeEntry {
id: string;
name: string;
entryId?: string;
meta?: string;
}
interface SectionDef {
id: string;
label: string;
matchKind?: string;
v2Note?: string;
entries: TreeEntry[];
loading?: boolean;
}
const SECTIONS: SectionDef[] = [
{ id: "rigs", label: "Rigs", matchKind: "rig" },
{ id: "workspaces", label: "Workspaces", v2Note: "V2" },
{ id: "workflows", label: "Workflows", matchKind: "workflow" },
{ id: "context-packs", label: "Context Packs", v2Note: "V2" },
{ id: "agent-images", label: "Agent Images", matchKind: "agent" },
{ id: "applications", label: "Applications", v2Note: "V2" },
];
function specEntry(entry: SpecLibraryEntry): TreeEntry {
return { id: entry.id, name: entry.name, entryId: entry.id, meta: entry.version };
}
function Section({
def,
expanded,
onToggle,
entries,
}: {
def: SectionDef;
expanded: boolean;
onToggle: () => void;
entries: Array<{ id: string; name: string; relativePath: string }> | null;
}) {
const Chevron = expanded ? ChevronDown : ChevronRight;
return (
@@ -51,36 +46,42 @@ function Section({
<span className="font-mono text-[11px] uppercase tracking-wide text-stone-900 flex-1">
{def.label}
</span>
{def.v2Note ? (
<span className="font-mono text-[9px] uppercase tracking-wide text-on-surface-variant">
{def.v2Note}
</span>
) : entries ? (
<span className="font-mono text-[10px] text-on-surface-variant">{entries.length}</span>
) : null}
<span className="font-mono text-[10px] text-on-surface-variant">
{def.loading ? "..." : def.entries.length}
</span>
</button>
{expanded ? (
<ul className="ml-5 border-l border-stone-200">
{def.v2Note ? (
<li className="px-2 py-1 font-mono text-[10px] text-on-surface-variant italic">
Coming in V2.
</li>
) : entries && entries.length > 0 ? (
entries.map((e) => (
<li key={e.id} className="px-2 py-0.5">
<Link
to="/specs/library/$entryId"
params={{ entryId: e.id }}
data-testid={`specs-leaf-${e.id}`}
className="block font-mono text-xs text-on-surface hover:text-stone-900 hover:bg-surface-low truncate"
>
{e.name}
</Link>
{def.entries.length > 0 ? (
def.entries.map((entry) => (
<li key={entry.id} className="px-2 py-0.5">
{entry.entryId ? (
<Link
to="/specs/library/$entryId"
params={{ entryId: entry.entryId }}
data-testid={`specs-leaf-${entry.id}`}
className="block truncate font-mono text-xs text-on-surface hover:bg-surface-low hover:text-stone-900"
>
{entry.name}
</Link>
) : (
<div
data-testid={`specs-leaf-${entry.id}`}
className="truncate font-mono text-xs text-on-surface"
>
{entry.name}
</div>
)}
{entry.meta ? (
<div className="truncate font-mono text-[9px] text-on-surface-variant">
{entry.meta}
</div>
) : null}
</li>
))
) : (
<li className="px-2 py-1 font-mono text-[10px] text-on-surface-variant italic">
No {def.label.toLowerCase()} yet.
{def.loading ? "Loading..." : `No ${def.label.toLowerCase()} yet.`}
</li>
)}
</ul>
@@ -90,18 +91,78 @@ function Section({
}
export function SpecsTreeView() {
const { data: library } = useSpecLibrary();
const { data: library = [], isLoading: specsLoading } = useSpecLibrary();
const { data: contextPacks = [], isLoading: contextPacksLoading } = useContextPackLibrary();
const { data: agentImages = [], isLoading: agentImagesLoading } = useAgentImageLibrary();
const { data: skills = [], isLoading: skillsLoading } = useLibrarySkills();
const [expanded, setExpanded] = useState<Record<string, boolean>>({
rigs: true,
workflows: false,
"agent-images": false,
workspaces: false,
"rig-specs": true,
"workspace-specs": false,
"workflow-specs": false,
"context-packs": false,
"agent-specs": false,
"agent-images": false,
applications: false,
skills: false,
});
const toggle = (id: string) =>
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
const sections = useMemo<SectionDef[]>(() => {
const rigSpecs = library.filter((entry) => entry.kind === "rig" && !entry.hasServices).map(specEntry);
const workflowSpecs = library.filter((entry) => entry.kind === "workflow").map(specEntry);
const agentSpecs = library.filter((entry) => entry.kind === "agent").map(specEntry);
const applications = library.filter((entry) => entry.kind === "rig" && entry.hasServices).map(specEntry);
return [
{ id: "rig-specs", label: "Rig Specs", entries: rigSpecs, loading: specsLoading },
{ id: "workspace-specs", label: "Workspace Specs", entries: [] },
{ id: "workflow-specs", label: "Workflow Specs", entries: workflowSpecs, loading: specsLoading },
{
id: "context-packs",
label: "Context Packs",
entries: contextPacks.map((entry) => ({
id: entry.id,
name: entry.name,
entryId: entry.id,
meta: `${entry.version} · ${entry.sourceType}`,
})),
loading: contextPacksLoading,
},
{ id: "agent-specs", label: "Agent Specs", entries: agentSpecs, loading: specsLoading },
{
id: "agent-images",
label: "Agent Images",
entries: agentImages.map((entry) => ({
id: entry.id,
name: entry.name,
entryId: entry.id,
meta: `${entry.version} · ${entry.runtime}`,
})),
loading: agentImagesLoading,
},
{ id: "applications", label: "Applications", entries: applications, loading: specsLoading },
{
id: "skills",
label: "Skills",
entries: skills.map((skill) => ({
id: skill.id,
name: skill.name,
meta: skill.source === "workspace" ? "workspace" : "openrig",
})),
loading: skillsLoading,
},
];
}, [
agentImages,
agentImagesLoading,
contextPacks,
contextPacksLoading,
library,
skills,
skillsLoading,
specsLoading,
]);
return (
<div data-testid="specs-tree-view" className="flex-1 overflow-y-auto py-2">
<div className="px-2 mb-2">
@@ -110,26 +171,18 @@ export function SpecsTreeView() {
data-testid="specs-tree-overview-link"
className="block font-mono text-[11px] uppercase tracking-wide text-stone-900 px-2 py-1 hover:bg-surface-low"
>
{"> "}Specs library
{"> "}Library
</Link>
</div>
<ul>
{SECTIONS.map((def) => {
const entries = def.matchKind && library
? library
.filter((e) => e.kind === def.matchKind)
.map((e) => ({ id: e.id, name: e.name, relativePath: e.relativePath }))
: null;
return (
<Section
key={def.id}
def={def}
expanded={!!expanded[def.id]}
onToggle={() => toggle(def.id)}
entries={entries}
/>
);
})}
{sections.map((def) => (
<Section
key={def.id}
def={def}
expanded={!!expanded[def.id]}
onToggle={() => toggle(def.id)}
/>
))}
</ul>
</div>
);
+101
View File
@@ -0,0 +1,101 @@
import { useQuery } from "@tanstack/react-query";
import type { FileEntry, FilesListResponse, FilesRootsResponse, FilesUnavailable } from "./useFiles.js";
export type LibrarySkillSource = "workspace" | "openrig-managed";
export interface LibrarySkillFile {
name: string;
path: string;
size: number | null;
mtime: string | null;
}
export interface LibrarySkillEntry {
id: string;
name: string;
source: LibrarySkillSource;
root: string;
directoryPath: string;
files: LibrarySkillFile[];
}
const SKILL_DIRECTORIES: Array<{ source: LibrarySkillSource; path: string }> = [
{ source: "workspace", path: ".openrig/skills" },
{ source: "openrig-managed", path: "packages/daemon/specs/agents/shared/skills" },
{ source: "openrig-managed", path: "node_modules/@openrig/daemon/specs/agents/shared/skills" },
];
function isUnavailable(data: unknown): data is FilesUnavailable {
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
}
async function fetchJson<T>(url: string): Promise<T | null> {
const res = await fetch(url);
if (res.status === 404 || res.status === 400 || res.status === 503) return null;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T;
}
async function listDirectory(root: string, path: string): Promise<FilesListResponse | null> {
return fetchJson<FilesListResponse>(
`/api/files/list?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`,
);
}
function markdownFiles(directoryPath: string, entries: FileEntry[]): LibrarySkillFile[] {
return entries
.filter((entry) => entry.type === "file" && /\.(md|mdx)$/i.test(entry.name))
.map((entry) => ({
name: entry.name,
path: `${directoryPath}/${entry.name}`,
size: entry.size,
mtime: entry.mtime,
}))
.sort((a, b) => {
if (a.name === "SKILL.md") return -1;
if (b.name === "SKILL.md") return 1;
return a.name.localeCompare(b.name);
});
}
async function fetchLibrarySkills(): Promise<LibrarySkillEntry[]> {
const rootsResponse = await fetchJson<FilesRootsResponse | FilesUnavailable>("/api/files/roots");
if (!rootsResponse || isUnavailable(rootsResponse)) return [];
const skills: LibrarySkillEntry[] = [];
for (const root of rootsResponse.roots) {
for (const candidate of SKILL_DIRECTORIES) {
const directory = await listDirectory(root.name, candidate.path);
if (!directory) continue;
const skillDirs = directory.entries.filter((entry) => entry.type === "dir");
for (const skillDir of skillDirs) {
const skillPath = `${candidate.path}/${skillDir.name}`;
const skillFiles = await listDirectory(root.name, skillPath);
if (!skillFiles) continue;
const files = markdownFiles(skillPath, skillFiles.entries);
if (files.length === 0) continue;
skills.push({
id: `${candidate.source}:${root.name}:${skillPath}`,
name: skillDir.name,
source: candidate.source,
root: root.name,
directoryPath: skillPath,
files,
});
}
}
}
return skills.sort((a, b) => {
if (a.source !== b.source) return a.source === "workspace" ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
export function useLibrarySkills() {
return useQuery({
queryKey: ["library", "skills"],
queryFn: fetchLibrarySkills,
staleTime: 30_000,
});
}
+129
View File
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { SpecsLibraryPage } from "../src/components/specs/SpecsLibraryPage.js";
import { createTestRouter } from "./helpers/test-router.js";
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function renderLibraryPage() {
return render(
createTestRouter({
path: "/specs",
component: () => <SpecsLibraryPage />,
}),
);
}
function fileList(entries: Array<{ name: string; type: "dir" | "file" }>) {
return {
root: "workspace",
path: "",
entries: entries.map((entry) => ({
...entry,
size: entry.type === "file" ? 42 : null,
mtime: "2026-05-07T00:00:00.000Z",
})),
};
}
describe("Library page taxonomy", () => {
it("renders Library labels, split sections, and skills from existing file routes", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/specs/library") {
return {
ok: true,
json: async () => [
{ id: "rig-1", kind: "rig", name: "openrig-build", version: "1", sourceType: "builtin", sourcePath: "/pkg/rig.yaml", relativePath: "rigs/build/rig.yaml" },
{ id: "agent-1", kind: "agent", name: "implementer", version: "1", sourceType: "builtin", sourcePath: "/pkg/agent.yaml", relativePath: "agents/impl/agent.yaml" },
{ id: "workflow:rsi:1", kind: "workflow", name: "rsi-loop", version: "1", sourceType: "builtin", sourcePath: "/pkg/workflow.yaml", relativePath: "workflow.yaml" },
{ id: "app-1", kind: "rig", name: "vault-app", version: "1", sourceType: "builtin", sourcePath: "/pkg/app/rig.yaml", relativePath: "apps/vault/rig.yaml", hasServices: true },
],
};
}
if (url === "/api/context-packs/library") {
return {
ok: true,
json: async () => [
{ id: "context-pack:demo:1", kind: "context-pack", name: "demo-pack", version: "1", sourceType: "workspace", sourcePath: "/workspace/.openrig/context-packs/demo", relativePath: "demo", updatedAt: "2026-05-07T00:00:00.000Z", manifestEstimatedTokens: null, derivedEstimatedTokens: 120, files: [] },
],
};
}
if (url === "/api/agent-images/library") {
return {
ok: true,
json: async () => [
{ id: "agent-image:driver:1", kind: "agent-image", name: "driver-image", version: "1", runtime: "claude-code", sourceSeat: "driver", sourceSessionId: "s", sourceCwd: null, notes: null, createdAt: "2026-05-07T00:00:00.000Z", sourceType: "workspace", sourcePath: "/workspace/.openrig/agent-images/driver", relativePath: "driver", updatedAt: "2026-05-07T00:00:00.000Z", manifestEstimatedTokens: null, derivedEstimatedTokens: 200, files: [], sourceResumeToken: "(redacted)", stats: { forkCount: 0, lastUsedAt: null, estimatedSizeBytes: 0, lineage: [] }, lineage: [], pinned: false },
],
};
}
if (url === "/api/files/roots") {
return {
ok: true,
json: async () => ({ roots: [{ name: "workspace", path: "/workspace" }] }),
};
}
if (url === "/api/files/list?root=workspace&path=.openrig%2Fskills") {
return { ok: true, json: async () => fileList([{ name: "operator-skill", type: "dir" }]) };
}
if (url === "/api/files/list?root=workspace&path=.openrig%2Fskills%2Foperator-skill") {
return { ok: true, json: async () => fileList([{ name: "SKILL.md", type: "file" }]) };
}
if (url === "/api/files/list?root=workspace&path=packages%2Fdaemon%2Fspecs%2Fagents%2Fshared%2Fskills") {
return { ok: true, json: async () => fileList([{ name: "openrig-user", type: "dir" }]) };
}
if (url === "/api/files/list?root=workspace&path=packages%2Fdaemon%2Fspecs%2Fagents%2Fshared%2Fskills%2Fopenrig-user") {
return { ok: true, json: async () => fileList([{ name: "SKILL.md", type: "file" }]) };
}
if (url === "/api/files/list?root=workspace&path=node_modules%2F%40openrig%2Fdaemon%2Fspecs%2Fagents%2Fshared%2Fskills") {
return { status: 404, ok: false, json: async () => ({ error: "not_found" }) };
}
if (url === "/api/files/read?root=workspace&path=.openrig%2Fskills%2Foperator-skill%2FSKILL.md") {
return {
ok: true,
json: async () => ({
root: "workspace",
path: ".openrig/skills/operator-skill/SKILL.md",
absolutePath: "/workspace/.openrig/skills/operator-skill/SKILL.md",
content: "# Operator Skill\nSkill body.",
mtime: "2026-05-07T00:00:00.000Z",
contentHash: "hash",
size: 28,
truncated: false,
truncatedAtBytes: null,
totalBytes: 28,
}),
};
}
throw new Error(`unexpected fetch ${url}`);
});
renderLibraryPage();
expect(await screen.findByRole("heading", { name: "Library" })).toBeDefined();
await waitFor(() => {
expect(screen.getByTestId("library-section-rig-specs")).toBeDefined();
expect(screen.getByTestId("library-section-workspace-specs")).toBeDefined();
expect(screen.getByTestId("library-section-workflow-specs")).toBeDefined();
expect(screen.getByTestId("library-section-context-packs")).toBeDefined();
expect(screen.getByTestId("library-section-agent-specs")).toBeDefined();
expect(screen.getByTestId("library-section-agent-images")).toBeDefined();
expect(screen.getByTestId("library-section-applications")).toBeDefined();
expect(screen.getByTestId("library-section-skills")).toBeDefined();
});
expect(screen.getByText("implementer")).toBeDefined();
expect(screen.getByText("driver-image")).toBeDefined();
expect(screen.getByTestId("library-skill-operator-skill")).toBeDefined();
expect(screen.getByTestId("library-skill-openrig-user")).toBeDefined();
expect(await screen.findByText(/Skill body/)).toBeDefined();
});
});