fix(ui): user-defined empty-state semantics + sidebar expand-on-click (slice 18 Checkpoint C repair)

Two blockers from velocity-guard 18.C at a523c56.

Blocker 1 — emptyState contract drift:
IMPLEMENTATION-PRD §1.5 + T5 + HG-7 require the empty-state guidance
to render when no user-defined items exist, even if built-ins are
present. Prior commit only rendered emptyState when items.length === 0,
so an operator with only built-in skills never saw the "add a skill to
.openrig/skills/..." guidance.

Repair:
- LibraryTopLevelEntry adds isUserDefined?: (item: T) => boolean prop.
  When supplied, emptyState renders whenever NO item satisfies the
  predicate. Folder list still renders alongside so built-ins remain
  browsable (emptyState becomes a guidance banner above the list).
  Without the predicate, the prior items.length===0 fallback applies
  (back-compat for consumers who don't care about the distinction).
- SkillsIndexPage passes isUserDefined={(s) => s.source === "workspace"}.

Regressions (would all fail without isUserDefined semantics):
- emptyState renders when ALL items are built-ins (no user-defined)
- folder list STILL renders alongside emptyState in that case
- emptyState hidden when at least one user-defined item present
- without isUserDefined predicate, emptyState hidden if items non-empty (back-compat)

Blocker 2 — sidebar Skills link does not expand the Skills section:
PRD §3.3 + T7 require "click expands list below + opens page". Prior
commit added a plain Link to /specs/skills; clicking navigated but
left the Skills section in SpecsTreeView collapsed.

Repair:
- SpecsTreeView sidebar Skills link adds onClick that calls
  setExpanded((prev) => ({ ...prev, skills: true })). TanStack Router's
  Link runs onClick before its default navigation, so click both
  expands AND navigates.

Regression (specs-tree-view-sidebar.test.tsx):
- Skills section collapsed by default (no "No skills yet." placeholder)
- After click on sidebar-skills-top-level, the "No skills yet."
  placeholder appears inside the now-expanded section. Without the
  onClick handler this test fails because section state stays false.

Test counts after repair:
- LibraryTopLevelEntry: 13/13 (was 9; +4 isUserDefined regressions)
- SpecsTreeView sidebar: 3/3 NEW
- Full UI suite: 714 pass + 1 skipped + 49 pre-existing AppShell pollution fails. Net vs prior tip: +7 passing, 0 new failures.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 14:14:58 -07:00
1 parent a523c56f0e
commit 0fb4bf86d5
5 files changed
+188 -6

No files matched your search

@@ -30,6 +30,15 @@ export interface LibraryTopLevelEntryProps<T extends LibraryItem> {
onItemClick: (item: T) => void;
formatFolderLabel?: (folder: unknown) => string;
isLoading?: boolean;
/**
* Predicate identifying user-defined items (vs. built-ins). When
* provided, emptyState renders whenever NO item satisfies the
* predicate — surfacing guidance for adding user content even
* when built-ins are present. Without the predicate, emptyState
* renders only when items is empty (back-compat for consumers
* that don't care about the user/built-in distinction).
*/
isUserDefined?: (item: T) => boolean;
}
interface FolderGroup<T extends LibraryItem> {
@@ -63,8 +72,19 @@ export function LibraryTopLevelEntry<T extends LibraryItem>({
onItemClick,
formatFolderLabel,
isLoading,
isUserDefined,
}: LibraryTopLevelEntryProps<T>) {
const folders = useMemo(() => groupItems(items, folderField), [items, folderField]);
const userDefinedCount = useMemo(() => {
if (!isUserDefined) return items.length;
let count = 0;
for (const item of items) {
if (isUserDefined(item)) count += 1;
}
return count;
}, [items, isUserDefined]);
const showEmptyState = userDefinedCount === 0;
const showFolders = items.length > 0;
return (
<div
@@ -78,9 +98,12 @@ export function LibraryTopLevelEntry<T extends LibraryItem>({
</h1>
</header>
{items.length === 0 ? (
<div data-testid={`library-top-level-${slug}-empty`}>{emptyState}</div>
) : (
{showEmptyState ? (
<div data-testid={`library-top-level-${slug}-empty`} className="mb-4">
{emptyState}
</div>
) : null}
{showFolders ? (
<div className="space-y-4">
{folders.map((folder) => {
const label = formatFolderLabel
@@ -122,7 +145,7 @@ export function LibraryTopLevelEntry<T extends LibraryItem>({
);
})}
</div>
)}
) : null}
</div>
);
}
@@ -56,6 +56,7 @@ export function SkillsIndexPage() {
emptyState={<SkillsEmptyState />}
onItemClick={handleSkillClick}
isLoading={isLoading}
isUserDefined={(skill) => skill.source === "workspace"}
/>
);
}
@@ -201,13 +201,16 @@ export function SpecsTreeView() {
{/* 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. */}
Clicking the link both navigates AND expands the matching
section below (per IMPL-PRD §3.3 + T7 — "click expands list
below + opens page"). 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"
onClick={() => setExpanded((prev) => ({ ...prev, skills: true }))}
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
@@ -174,4 +174,86 @@ describe("LibraryTopLevelEntry", () => {
);
expect(screen.getByTestId("library-top-level-demo")).toBeTruthy();
});
// velocity-guard 18.C BLOCKING-CONCERN repair (Blocker 1):
// isUserDefined predicate must surface emptyState guidance when no
// item satisfies the predicate, even if built-ins are present.
describe("isUserDefined predicate (user-defined-aware empty state)", () => {
interface SkillLike extends DemoItem {
source: "workspace" | "openrig-managed";
}
const builtInsOnly: SkillLike[] = [
{ id: "b1", name: "built-in 1", group: "managed", source: "openrig-managed" },
{ id: "b2", name: "built-in 2", group: "managed", source: "openrig-managed" },
];
const withUserDefined: SkillLike[] = [
{ id: "u1", name: "user 1", group: "user", source: "workspace" },
{ id: "b1", name: "built-in 1", group: "managed", source: "openrig-managed" },
];
it("renders emptyState guidance when ALL items are built-ins (no user-defined)", () => {
render(
<LibraryTopLevelEntry<SkillLike>
slug="skills"
displayName="Skills"
iconKind="skill"
items={builtInsOnly}
folderField="source"
emptyState={<div data-testid="empty-guidance">add a skill to .openrig/skills/</div>}
onItemClick={() => {}}
isUserDefined={(s) => s.source === "workspace"}
/>,
);
expect(screen.getByTestId("empty-guidance")).toBeTruthy();
});
it("STILL renders the built-in folder list alongside emptyState (built-ins remain browsable)", () => {
render(
<LibraryTopLevelEntry<SkillLike>
slug="skills"
displayName="Skills"
iconKind="skill"
items={builtInsOnly}
folderField="source"
emptyState={<div data-testid="empty-guidance">guidance</div>}
onItemClick={() => {}}
isUserDefined={(s) => s.source === "workspace"}
/>,
);
expect(screen.getByTestId("library-folder-openrig-managed")).toBeTruthy();
expect(screen.getByTestId("library-item-b1")).toBeTruthy();
});
it("does NOT render emptyState when at least one item is user-defined", () => {
render(
<LibraryTopLevelEntry<SkillLike>
slug="skills"
displayName="Skills"
iconKind="skill"
items={withUserDefined}
folderField="source"
emptyState={<div data-testid="empty-guidance">guidance</div>}
onItemClick={() => {}}
isUserDefined={(s) => s.source === "workspace"}
/>,
);
expect(screen.queryByTestId("empty-guidance")).toBeNull();
});
it("without isUserDefined predicate, emptyState is hidden when items has anything (back-compat)", () => {
render(
<LibraryTopLevelEntry<SkillLike>
slug="skills"
displayName="Skills"
iconKind="skill"
items={builtInsOnly}
folderField="source"
emptyState={<div data-testid="empty-guidance">guidance</div>}
onItemClick={() => {}}
// no isUserDefined → falls back to items.length === 0 semantics
/>,
);
expect(screen.queryByTestId("empty-guidance")).toBeNull();
});
});
});
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { SpecsTreeView } from "../src/components/specs/SpecsTreeView.js";
import { createTestRouter } from "./helpers/test-router.js";
// velocity-guard 18.C BLOCKING-CONCERN repair (Blocker 2):
// Top-level "Skills" sidebar link must both navigate to /specs/skills
// AND expand the Skills section below (per IMPL-PRD §3.3 + T7).
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
// All Library endpoints return empty so the Section renders its
// "No skills yet." placeholder when expanded — visible expansion
// proof without needing real Library data.
mockFetch.mockImplementation(async () => ({
ok: true,
json: async () => [],
}));
Object.defineProperty(window, "scrollTo", { configurable: true, value: vi.fn() });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
function renderTree() {
return render(
createTestRouter({
path: "/specs",
initialPath: "/specs",
component: () => <SpecsTreeView />,
}),
);
}
describe("SpecsTreeView — top-level sidebar Skills link", () => {
it("renders a Skills top-level link in the sidebar", async () => {
renderTree();
await waitFor(() => {
expect(screen.getByTestId("sidebar-skills-top-level")).toBeTruthy();
});
});
it("Skills section is collapsed by default (chevron right; no placeholder visible)", async () => {
renderTree();
await waitFor(() => {
expect(screen.getByTestId("specs-section-skills")).toBeTruthy();
});
// Default expanded state for "skills" is false; placeholder not rendered.
expect(screen.queryByText(/no skills yet/i)).toBeNull();
});
it("clicking the sidebar Skills top-level link expands the Skills section below", async () => {
renderTree();
await waitFor(() => {
expect(screen.getByTestId("sidebar-skills-top-level")).toBeTruthy();
});
expect(screen.queryByText(/no skills yet/i)).toBeNull();
fireEvent.click(screen.getByTestId("sidebar-skills-top-level"));
await waitFor(() => {
// After the click, the section's expanded body renders. Because
// useLibrarySkills mock returns [], the Section shows its
// "No skills yet." placeholder — proof of expansion.
expect(screen.getByText(/no skills yet/i)).toBeTruthy();
});
});
});