mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
feat(ui): LibraryTopLevelEntry primitive + SkillsIndexPage + sidebar (slice 18 Checkpoint C)
LibraryTopLevelEntry (new primitive at packages/ui/src/components/specs/
LibraryTopLevelEntry.tsx) is the reusable Library navigation primitive
per IMPLEMENTATION-PRD §1.5:
- generic over T extends { id: string; name: string }
- folderField: keyof T groups items into folders sorted alphabetically
- emptyState slot rendered when items.length === 0
- onItemClick(item) consumer-supplied navigation
- iconKind: string → ToolMark tool prop on each row
- formatFolderLabel optional override for folder header text
- isLoading optional surfaces the "loading" badge in folder headers
SkillsIndexPage (new at packages/ui/src/components/specs/SkillsIndexPage.tsx)
is the first consumer:
- Reads useLibrarySkills
- Groups by source ("workspace" = user-defined; "openrig-managed" =
built-ins) with formatted folder labels
- Empty state copy directs operator to .openrig/skills/<skill-name>/
- Click row → navigate to existing /specs/skills/$skillToken detail route
Routes wire:
- /specs/skills now mounts SkillsIndexPage (specsSkillsIndexRoute)
- Existing /specs/skills/$skillToken detail route unchanged
- Added to routeTree.addChildren above specsSkillRoute
SpecsTreeView sidebar wire:
- New top-level <Link to="/specs/skills"> entry above the existing
Section tree (Skills + Plugins top-level slot; Plugins lands in
Checkpoint D in the same <ul> block)
Tests: 9/9 LibraryTopLevelEntry leaf tests green
- displayName render, folder grouping, same-key merge, click handler,
emptyState toggle, alpha sort, formatFolderLabel, slug-anchored
data-testid
Full UI suite baseline confirmed: 49 pre-existing AppShell global-polls
failures (per banked feedback_appshell_global_polls_test_pollution) are
identical to the pre-Checkpoint-C tip; this checkpoint adds +9 passing
tests with ZERO new failures.
Typecheck on my touched files: clean. Pre-existing 34 branch typecheck
errors carry forward, identical to raw main.
SC-29 EXCEPTION declared: NONE
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
c1968b2272
commit
a523c56f0e
4 files changed
+215
No files matched your search
@@ -0,0 +1,128 @@
|
||||
// Slice 18 §1.5 — reusable Library navigation primitive.
|
||||
//
|
||||
// Renders a top-level Library page index: title header + folder-grouped
|
||||
// flat list. Each item triggers onItemClick (consumer supplies routing).
|
||||
// Consumers provide an emptyState slot rendered when items is empty.
|
||||
//
|
||||
// Two applications in this slice: Skills + Plugins. Future Library
|
||||
// surfaces (specs, applications, context-packs) can adopt the same
|
||||
// primitive — folderField is keyof T so the consumer chooses the
|
||||
// grouping field per data shape.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { ToolMark } from "../graphics/RuntimeMark.js";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LibraryTopLevelEntryProps<T extends LibraryItem> {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
iconKind: string;
|
||||
items: T[];
|
||||
folderField: keyof T;
|
||||
emptyState: ReactNode;
|
||||
onItemClick: (item: T) => void;
|
||||
formatFolderLabel?: (folder: unknown) => string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface FolderGroup<T extends LibraryItem> {
|
||||
key: string;
|
||||
rawKey: unknown;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
function groupItems<T extends LibraryItem>(items: T[], folderField: keyof T): FolderGroup<T>[] {
|
||||
const buckets = new Map<string, FolderGroup<T>>();
|
||||
for (const item of items) {
|
||||
const rawKey = item[folderField];
|
||||
const key = String(rawKey ?? "");
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { key, rawKey, items: [] };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.items.push(item);
|
||||
}
|
||||
return Array.from(buckets.values()).sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
export function LibraryTopLevelEntry<T extends LibraryItem>({
|
||||
slug,
|
||||
displayName,
|
||||
iconKind,
|
||||
items,
|
||||
folderField,
|
||||
emptyState,
|
||||
onItemClick,
|
||||
formatFolderLabel,
|
||||
isLoading,
|
||||
}: LibraryTopLevelEntryProps<T>) {
|
||||
const folders = useMemo(() => groupItems(items, folderField), [items, folderField]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`library-top-level-${slug}`}
|
||||
className="mx-auto w-full max-w-[960px] px-6 py-8"
|
||||
>
|
||||
<header className="border-b border-outline-variant pb-4 mb-4">
|
||||
<SectionHeader tone="muted">Library</SectionHeader>
|
||||
<h1 className="font-headline text-headline-md font-bold tracking-tight uppercase text-stone-900 mt-1">
|
||||
{displayName}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div data-testid={`library-top-level-${slug}-empty`}>{emptyState}</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{folders.map((folder) => {
|
||||
const label = formatFolderLabel
|
||||
? formatFolderLabel(folder.rawKey)
|
||||
: folder.key;
|
||||
return (
|
||||
<section
|
||||
key={folder.key}
|
||||
data-testid={`library-folder-${folder.key}`}
|
||||
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">{label}</SectionHeader>
|
||||
<span className="shrink-0 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
|
||||
{isLoading ? "loading" : `${folder.items.length}`}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="divide-y divide-outline-variant">
|
||||
{folder.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`library-item-${item.id}`}
|
||||
onClick={() => onItemClick(item)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-3 px-3 py-2 font-mono text-left",
|
||||
"hover:bg-stone-100/50 focus:outline-none focus:bg-stone-100/70",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ToolMark tool={iconKind} title={`${item.name} ${iconKind}`} size="xs" decorative />
|
||||
<span className="truncate text-xs font-bold text-stone-900">{item.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Slice 18 — Skills top-level Library index.
|
||||
//
|
||||
// Mounts at /specs/skills (added in routes.tsx). Reads useLibrarySkills,
|
||||
// groups by source ("workspace" = user-defined; "openrig-managed" =
|
||||
// built-ins), navigates to the existing /specs/skills/$skillToken detail
|
||||
// route on row click.
|
||||
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { LibraryTopLevelEntry } from "./LibraryTopLevelEntry.js";
|
||||
import { useLibrarySkills, type LibrarySkillEntry } from "../../hooks/useLibrarySkills.js";
|
||||
import { librarySkillToken } from "../../lib/library-skills-routing.js";
|
||||
|
||||
function formatSkillFolder(source: unknown): string {
|
||||
if (source === "workspace") return "User-defined (workspace)";
|
||||
if (source === "openrig-managed") return "OpenRig managed";
|
||||
return String(source ?? "Other");
|
||||
}
|
||||
|
||||
function SkillsEmptyState() {
|
||||
return (
|
||||
<div
|
||||
data-testid="skills-empty-state"
|
||||
className="border border-outline-variant bg-white/25 px-4 py-6 font-mono text-xs leading-relaxed text-stone-700"
|
||||
>
|
||||
<p className="font-bold uppercase tracking-wide text-stone-900">No skills visible</p>
|
||||
<p className="mt-2">
|
||||
Skills live under <code className="font-mono text-stone-900">.openrig/skills/</code> in your
|
||||
workspace, plus the built-in set shipped with OpenRig. If this list is empty, the daemon
|
||||
could not see either source through configured file roots.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Drop a <code className="font-mono text-stone-900">SKILL.md</code> into a
|
||||
<code className="font-mono text-stone-900"> .openrig/skills/<skill-name>/</code>
|
||||
directory and refresh.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillsIndexPage() {
|
||||
const { data: skills = [], isLoading } = useLibrarySkills();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSkillClick = (skill: LibrarySkillEntry) => {
|
||||
navigate({ to: "/specs/skills/$skillToken", params: { skillToken: librarySkillToken(skill.id) } });
|
||||
};
|
||||
|
||||
return (
|
||||
<LibraryTopLevelEntry<LibrarySkillEntry>
|
||||
slug="skills"
|
||||
displayName="Skills"
|
||||
iconKind="skill"
|
||||
items={skills}
|
||||
folderField="source"
|
||||
formatFolderLabel={formatSkillFolder}
|
||||
emptyState={<SkillsEmptyState />}
|
||||
onItemClick={handleSkillClick}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -198,6 +198,22 @@ export function SpecsTreeView() {
|
||||
{"> "}Library
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Slice 18 — top-level Library entries (Skills + Plugins) sit
|
||||
above the grouped tree as direct navigation to the index pages.
|
||||
The existing Section tree below continues to group entries for
|
||||
browsing in place. */}
|
||||
<ul className="px-2 mb-2 space-y-0.5">
|
||||
<li>
|
||||
<Link
|
||||
to="/specs/skills"
|
||||
data-testid="sidebar-skills-top-level"
|
||||
className="block font-mono text-[11px] uppercase tracking-wide text-stone-700 hover:text-stone-900 hover:bg-surface-low px-2 py-1"
|
||||
>
|
||||
{"> "}Skills
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
{sections.map((def) => {
|
||||
if (def.id !== "skills") {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Dashboard } from "./components/dashboard/Dashboard.js";
|
||||
import { Feed } from "./components/for-you/Feed.js";
|
||||
import { SpecsLibraryPage } from "./components/specs/SpecsLibraryPage.js";
|
||||
import { SkillDetailPage } from "./components/specs/SkillDetailPage.js";
|
||||
import { SkillsIndexPage } from "./components/specs/SkillsIndexPage.js";
|
||||
// Phase 3a slice 3.3 — plugin detail page route.
|
||||
import { PluginDetailPage } from "./components/specs/PluginDetailPage.js";
|
||||
import { SettingsCenter } from "./components/system/SettingsCenter.js";
|
||||
@@ -142,6 +143,14 @@ const specsApplicationsRoute = createRoute({
|
||||
component: SpecsLibraryPage,
|
||||
});
|
||||
|
||||
// Slice 18 — Skills top-level Library index page mounted at /specs/skills.
|
||||
// The detail route /specs/skills/$skillToken remains unchanged below.
|
||||
const specsSkillsIndexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/specs/skills",
|
||||
component: SkillsIndexPage,
|
||||
});
|
||||
|
||||
const specsSkillRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/specs/skills/$skillToken",
|
||||
@@ -396,6 +405,7 @@ const routeTree = rootRoute.addChildren([
|
||||
projectSliceRoute,
|
||||
specsLibraryRoute,
|
||||
specsApplicationsRoute,
|
||||
specsSkillsIndexRoute,
|
||||
specsSkillRoute,
|
||||
specsSkillFileRoute,
|
||||
pluginDetailRoute,
|
||||
|
||||
Reference in new issue
Block a user