mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
Restructure library taxonomy and skills browsing
This commit is contained in:
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user