From cfcd7dc9eca13ec878b3c11223c7ba076d17cbd8 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 21:11:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20Packs=20route=20+=20TreeView?= =?UTF-8?q?=20primitive=20=E2=80=94=20manifest=20&=20checksum=20evidence?= =?UTF-8?q?=20(Wave=20R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces PacksRoutePlaceholder with the real triad route over the R2-B /packs read substrate (#712). - TS mirrors PackSource/PackSummary/PackDetail; PackSummary/PackDetail removed from NOT_YET_MIRRORED (drift gate now enforces them) - new design primitive TreeView (src/design/components/TreeView): a deterministic, keyboard-driven JSON tree — object keys sorted, array indices in order, no animation; ←/→ collapse/expand, ↑/↓ + j/k move, Home/End jump, Enter toggles; registers its shortcuts only while focused (KeyboardHelp never advertises tree controls when no tree is on screen) - list pane: VirtualizedList + useListNavigation (j/k) + SearchInput; rows show pack_id, source, version, language/modality, determinism_class pill - detail pane: Panel + TabBar (Manifest / Checksums / Raw); Manifest renders via TreeView; Checksums is the verify affordance — manifest_digest and declared checksum as DigestBadges plus the per-field manifest checksums table (the checksum-hashes-the-bytes-on-disk doctrine made visible); Raw collapsed by default - selection publishes the pack subject (R2-S), records /packs/ (replace), pushRecentItem on visit - Packs row added to routeConformance MOUNT_ROUTES (loading 'Loading packs...', error 'No packs mutation occurred.', empty 'No packs discovered.' + core pack validate ) - tests: TreeView (sorted/collapsed/expand/collapse/focus/click/empty) + PacksRoute (list, replace-mode select + manifest tree, checksum verify affordance, j/k spine) Token-only styling (hexScan green). --- workbench-ui/src/api/client.ts | 15 + workbench-ui/src/api/queries.ts | 23 ++ workbench-ui/src/app/App.tsx | 4 +- .../src/app/packs/PacksRoute.test.tsx | 215 +++++++++++++ workbench-ui/src/app/packs/PacksRoute.tsx | 301 ++++++++++++++++++ .../src/app/routeConformance.test.tsx | 10 + .../components/TreeView/TreeView.test.tsx | 82 +++++ .../design/components/TreeView/TreeView.tsx | 196 ++++++++++++ .../src/design/doctrine/schemaDrift.test.ts | 2 - .../src/routes/PacksRoutePlaceholder.tsx | 10 - workbench-ui/src/types/api.ts | 19 ++ 11 files changed, 863 insertions(+), 14 deletions(-) create mode 100644 workbench-ui/src/app/packs/PacksRoute.test.tsx create mode 100644 workbench-ui/src/app/packs/PacksRoute.tsx create mode 100644 workbench-ui/src/design/components/TreeView/TreeView.test.tsx create mode 100644 workbench-ui/src/design/components/TreeView/TreeView.tsx delete mode 100644 workbench-ui/src/routes/PacksRoutePlaceholder.tsx diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index b59cc05e..2332e1d1 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -13,6 +13,8 @@ import type { AuditEvent, RunSummary, RunDetail, + PackSummary, + PackDetail, TurnJournalEntry, TurnJournalSummary, MathProposalSummary, @@ -209,3 +211,16 @@ export async function deferMathProposal( }, ); } + +export async function fetchPacks(limit?: number, offset?: number): Promise { + const params = new URLSearchParams(); + if (limit !== undefined) params.set("limit", String(limit)); + if (offset !== undefined) params.set("offset", String(offset)); + const query = params.toString(); + const envelope = await apiFetch>(query ? `/packs?${query}` : "/packs"); + return envelope.items; +} + +export async function fetchPack(packId: string): Promise { + return apiFetch(`/packs/${encodeURIComponent(packId)}`); +} diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index 55ddc2d0..968bf0ca 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -16,6 +16,8 @@ import { fetchAuditEvents, fetchRuns, fetchRun, + fetchPacks, + fetchPack, fetchTraceTurn, fetchTraceTurns, fetchMathProposals, @@ -35,6 +37,8 @@ import type { AuditEvent, RunSummary, RunDetail, + PackSummary, + PackDetail, TurnJournalEntry, TurnJournalSummary, EvalLaneSummary, @@ -272,3 +276,22 @@ export function useMathDefer() { }); } +export function usePacks(limit?: number, offset?: number) { + return useQuery({ + queryKey: ["api", "packs", limit ?? null, offset ?? null], + queryFn: () => fetchPacks(limit, offset), + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + +export function usePack(packId?: string | null) { + return useQuery({ + queryKey: ["api", "pack", packId ?? null], + queryFn: () => fetchPack(packId as string), + enabled: typeof packId === "string" && packId.length > 0, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index f6c69005..4a43103a 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -10,7 +10,7 @@ import { AuditRoute } from "./audit/AuditRoute"; import { ReplayRoute } from "./replay/ReplayRoute"; import { EvalsRoute } from "./evals/EvalsRoute"; import { RunsRoute } from "./runs/RunsRoute"; -import { PacksRoutePlaceholder } from "../routes/PacksRoutePlaceholder"; +import { PacksRoute } from "./packs/PacksRoute"; import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder"; import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder"; @@ -27,7 +27,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/workbench-ui/src/app/packs/PacksRoute.test.tsx b/workbench-ui/src/app/packs/PacksRoute.test.tsx new file mode 100644 index 00000000..433b331d --- /dev/null +++ b/workbench-ui/src/app/packs/PacksRoute.test.tsx @@ -0,0 +1,215 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigationType, +} from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestQueryClient } from "../../test/createTestQueryClient"; +import type { PackDetail, PackSummary } from "../../types/api"; +import { EvidenceProvider } from "../evidenceContext"; +import { PacksRoute } from "./PacksRoute"; + +const summaries: PackSummary[] = [ + { + pack_id: "en_core_cognition_v1", + source: "language_pack", + manifest_path: "language_packs/data/en_core_cognition_v1/manifest.json", + version: "1", + language: "en", + modality: "text", + determinism_class: "deterministic", + checksum: "sha256:declared0000000000000000", + checksums: {}, + }, + { + pack_id: "safety_core_v1", + source: "runtime_pack", + manifest_path: "packs/runtime/safety_core_v1/manifest.json", + version: "1", + language: null, + modality: null, + determinism_class: "pinned", + checksum: null, + checksums: {}, + }, +]; + +function detailFor(packId: string): PackDetail { + const summary = summaries.find((s) => s.pack_id === packId) ?? summaries[0]; + return { + ...summary, + checksums: { + lexicon_sha256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaa", + manifest_checksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbb", + }, + manifest_digest: "sha256:cccccccccccccccccccccccc", + manifest: { + pack_id: packId, + version: "1", + lexicon: { entries: 128, sealed: true }, + }, + }; +} + +function LocationProbe() { + const location = useLocation(); + const navigationType = useNavigationType(); + return ( + <> + {`${location.pathname}${location.search}`} + {navigationType} + + ); +} + +function renderRoute(initialEntry = "/packs") { + return render( + + + + + + + + + } + /> + + + + , + ); +} + +function stubPacksFetch(items: PackSummary[] = summaries) { + const fetchMock = vi.fn((input: unknown) => { + const path = new URL(String(input)).pathname; + if (path === "/packs") { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items } }), + }); + } + const match = path.match(/^\/packs\/(.+)$/); + if (match) { + return Promise.resolve({ + json: () => + Promise.resolve({ + ok: true, + generated_at: "now", + data: detailFor(decodeURIComponent(match[1])), + }), + }); + } + return Promise.resolve({ + json: () => + Promise.resolve({ + ok: false, + generated_at: "now", + error: { code: "not_found", message: `unexpected path ${path}` }, + }), + }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const offsetDescriptors = { + offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"), + offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"), +}; + +describe("PacksRoute", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get: () => 560, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get: () => 480, + }); + }); + + afterEach(() => { + if (offsetDescriptors.offsetHeight) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight); + } + if (offsetDescriptors.offsetWidth) { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth); + } + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it("renders packs with source, version, and determinism class", async () => { + stubPacksFetch(); + renderRoute(); + + expect(await screen.findByText("en_core_cognition_v1")).toBeInTheDocument(); + expect(screen.getByText("safety_core_v1")).toBeInTheDocument(); + expect(screen.getAllByText("Language pack").length).toBeGreaterThan(0); + expect(screen.getByText("deterministic")).toBeInTheDocument(); + expect(screen.getByText("pinned")).toBeInTheDocument(); + }); + + it("selecting a pack writes /packs/ with replace and shows the manifest tree", async () => { + stubPacksFetch(); + const user = userEvent.setup(); + renderRoute(); + + await user.click(await screen.findByText("en_core_cognition_v1")); + + await waitFor(() => + expect(screen.getByTestId("location")).toHaveTextContent("/packs/en_core_cognition_v1"), + ); + expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE"); + // manifest tree is the default tab — top-level keys render as treeitems + const tree = await screen.findByRole("tree"); + expect(tree).toBeInTheDocument(); + expect(screen.getByText("lexicon")).toBeInTheDocument(); + }); + + it("surfaces the manifest checksums as the verify affordance", async () => { + stubPacksFetch(); + const user = userEvent.setup(); + renderRoute("/packs/en_core_cognition_v1"); + + await user.click(await screen.findByRole("tab", { name: "Checksums" })); + + expect(await screen.findByText("manifest_digest")).toBeInTheDocument(); + expect(screen.getByText("lexicon_sha256")).toBeInTheDocument(); + expect(screen.getByText("manifest_checksum")).toBeInTheDocument(); + // the manifest_digest renders as a sha256 digest badge (full value in aria-label) + expect( + screen.getByLabelText(/Digest sha256:cccccccccccccccccccccccc/), + ).toBeInTheDocument(); + // per-field checksum digests are present as the verify affordance + expect( + screen.getByLabelText(/Digest sha256:aaaaaaaaaaaaaaaaaaaaaaaa/), + ).toBeInTheDocument(); + }); + + it("moves the pack list focus with j/k through the VirtualizedList spine", async () => { + stubPacksFetch(); + const user = userEvent.setup(); + renderRoute(); + + const list = await screen.findByRole("listbox", { name: "Packs" }); + list.focus(); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("j"); + expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("k"); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + }); +}); diff --git a/workbench-ui/src/app/packs/PacksRoute.tsx b/workbench-ui/src/app/packs/PacksRoute.tsx new file mode 100644 index 00000000..08ce0865 --- /dev/null +++ b/workbench-ui/src/app/packs/PacksRoute.tsx @@ -0,0 +1,301 @@ +import { useEffect, useMemo, useState } from "react"; +import { Eye } from "lucide-react"; +import { useNavigate, useParams } from "react-router-dom"; +import { WorkbenchApiError } from "../../api/client"; +import { usePack, usePacks } from "../../api/queries"; +import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge"; +import { MetadataTable } from "../../design/components/MetadataTable/MetadataTable"; +import { Panel } from "../../design/components/Panel/Panel"; +import { SearchInput } from "../../design/components/SearchInput/SearchInput"; +import { SplitPane } from "../../design/components/SplitPane/SplitPane"; +import { StableJsonViewer } from "../../design/components/StableJsonViewer"; +import { TabBar, type Tab } from "../../design/components/TabBar/TabBar"; +import { TreeView } from "../../design/components/TreeView/TreeView"; +import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; +import { Button } from "../../design/components/primitives/Button"; +import { EmptyState } from "../../design/components/states/EmptyState"; +import { ErrorState } from "../../design/components/states/ErrorState"; +import { LoadingState } from "../../design/components/states/LoadingState"; +import type { PackDetail, PackSummary } from "../../types/api"; +import { pushRecentItem } from "../commandRegistry"; +import { subjectToUrl } from "../evidenceAddress"; +import { useEvidenceSubject } from "../evidenceContext"; + +const PACK_TABS: readonly Tab[] = [ + { id: "manifest", label: "Manifest" }, + { id: "checksums", label: "Checksums" }, + { id: "raw", label: "Raw" }, +]; + +const SOURCE_LABEL: Record = { + language_pack: "Language pack", + runtime_pack: "Runtime pack", +}; + +function sourceLabel(source: string): string { + return SOURCE_LABEL[source] ?? source; +} + +function errorMessage(error: unknown) { + return error instanceof WorkbenchApiError ? error.message : "Packs request failed."; +} + +function digestPayload(value: string | null | undefined): string | null { + if (!value) return null; + return value.replace(/^sha256:/, ""); +} + +function Pill({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function PackRow({ + pack, + selected, + focused, + onSelect, +}: { + pack: PackSummary; + selected: boolean; + focused: boolean; + onSelect: () => void; +}) { + return ( +
+ + + {pack.pack_id} + + + {sourceLabel(pack.source)} + {pack.version ? ( + <> + · + {pack.version} + + ) : null} + {pack.language ? {pack.language} : null} + {pack.modality ? {pack.modality} : null} + + + + {pack.determinism_class ? {pack.determinism_class} : null} + +
+ ); +} + +function ChecksumsTab({ detail }: { detail: PackDetail }) { + const manifestDigest = digestPayload(detail.manifest_digest); + const declared = digestPayload(detail.checksum); + const fieldRows = Object.entries(detail.checksums).sort(([a], [b]) => a.localeCompare(b)); + return ( +
+ + ) : ( + "not recorded" + ), + }, + { + key: "checksum", + value: declared ? : "not declared", + }, + ]} + /> +
+

+ Manifest-declared checksums +

+ {fieldRows.length === 0 ? ( +

+ This manifest declares no per-field checksums. +

+ ) : ( + ({ + key: field, + value: , + }))} + /> + )} +
+
+ ); +} + +function RawTab({ detail }: { detail: PackDetail }) { + const [expanded, setExpanded] = useState(false); + return expanded ? ( + + ) : ( +
+

+ Raw pack JSON is collapsed by default. +

+ +
+ ); +} + +function PackDetailPanel({ detail }: { detail: PackDetail }) { + const [activeTab, setActiveTab] = useState("manifest"); + return ( + {sourceLabel(detail.source)}} + > + + {activeTab === "manifest" ? ( + + ) : null} + {activeTab === "checksums" ? : null} + {activeTab === "raw" ? : null} + + + ); +} + +export function PacksRoute() { + const { packId } = useParams(); + const selectedPackId = packId && packId.length > 0 ? packId : null; + const navigate = useNavigate(); + const { setSubject } = useEvidenceSubject(); + const [search, setSearch] = useState(""); + + const packsQuery = usePacks(); + const packQuery = usePack(selectedPackId); + + const packs = packsQuery.data ?? []; + const filteredPacks = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return packs; + return packs.filter( + (pack) => + pack.pack_id.toLowerCase().includes(q) || + sourceLabel(pack.source).toLowerCase().includes(q) || + (pack.language ?? "").toLowerCase().includes(q), + ); + }, [search, packs]); + + useEffect(() => { + if (selectedPackId === null) return; + setSubject({ kind: "pack", packId: selectedPackId, data: packQuery.data }); + }, [selectedPackId, setSubject, packQuery.data]); + + function selectPack(pack: PackSummary) { + const subject = { kind: "pack" as const, packId: pack.pack_id }; + const path = subjectToUrl(subject); + navigate(path, { replace: true }); + pushRecentItem({ label: pack.pack_id, path }); + } + + if (packsQuery.isLoading) { + return ; + } + + if (packsQuery.isError) { + return ( + + ); + } + + if (packs.length === 0) { + return ( + " }} + /> + ); + } + + return ( +
+ + +
+ + {filteredPacks.length === 0 ? ( + " }} + /> + ) : ( + pack.pack_id} + height="calc(100vh - 14rem)" + initialRect={{ width: 480, height: 560 }} + items={filteredPacks} + onActivate={(pack) => selectPack(pack)} + renderItem={(pack, _index, focused) => ( + selectPack(pack)} + /> + )} + /> + )} +
+
+ +
+ {selectedPackId === null ? ( + " }} + /> + ) : packQuery.isLoading ? ( + + ) : packQuery.isError ? ( + + ) : packQuery.data ? ( + + ) : null} +
+
+
+ ); +} diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx index 5c353b94..c56bff82 100644 --- a/workbench-ui/src/app/routeConformance.test.tsx +++ b/workbench-ui/src/app/routeConformance.test.tsx @@ -13,6 +13,7 @@ import { AuditRoute } from "./audit/AuditRoute"; import { EvalsRoute } from "./evals/EvalsRoute"; import { ReplayRoute } from "./replay/ReplayRoute"; import { RunsRoute } from "./runs/RunsRoute"; +import { PacksRoute } from "./packs/PacksRoute"; /** * ADR-0162 §6 route conformance — executable, not aspirational. @@ -161,6 +162,15 @@ const MOUNT_ROUTES: MountRouteSpec[] = [ emptyStatement: "No artifacts available.", emptyCommand: "core eval cognition", }, + { + name: "Packs", + element: , + path: "/packs/:packId?", + initialEntry: "/packs", + loadingLabel: "Loading packs...", + emptyStatement: "No packs discovered.", + emptyCommand: "core pack validate ", + }, ]; describe.each(MOUNT_ROUTES)("route conformance: $name", (spec) => { diff --git a/workbench-ui/src/design/components/TreeView/TreeView.test.tsx b/workbench-ui/src/design/components/TreeView/TreeView.test.tsx new file mode 100644 index 00000000..0fe799de --- /dev/null +++ b/workbench-ui/src/design/components/TreeView/TreeView.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { TreeView } from "./TreeView"; + +const manifest = { + zeta: "last-alpha", + alpha: { nested_b: 2, nested_a: 1 }, + items: ["x", "y"], +}; + +describe("TreeView", () => { + it("renders top-level keys in deterministic sorted order, collapsed", () => { + render(); + const items = screen.getAllByRole("treeitem"); + // alpha, items, zeta — sorted ascending, children hidden until expanded + expect(items.map((el) => within(el).getAllByText(/.+/)[0].textContent)).toEqual([ + "alpha", + "items", + "zeta", + ]); + expect(screen.queryByText("nested_a")).not.toBeInTheDocument(); + }); + + it("expands a branch with ArrowRight and reveals children sorted", async () => { + const user = userEvent.setup(); + render(); + + const tree = screen.getByRole("tree", { name: "Manifest" }); + tree.focus(); + // focus starts on the first node (alpha); expand it + await user.keyboard("{ArrowRight}"); + + const visible = screen.getAllByRole("treeitem").map((el) => el.getAttribute("aria-level")); + // alpha(1) now shows nested_a(2), nested_b(2) before items(1) + expect(screen.getByText("nested_a")).toBeInTheDocument(); + expect(screen.getByText("nested_b")).toBeInTheDocument(); + const labels = screen.getAllByRole("treeitem").map((el) => within(el).getAllByText(/.+/)[0].textContent); + expect(labels.slice(0, 3)).toEqual(["alpha", "nested_a", "nested_b"]); + expect(visible).toContain("2"); + }); + + it("collapses an expanded branch with ArrowLeft", async () => { + const user = userEvent.setup(); + render(); + const tree = screen.getByRole("tree", { name: "Manifest" }); + tree.focus(); + + await user.keyboard("{ArrowRight}"); + expect(screen.getByText("nested_a")).toBeInTheDocument(); + await user.keyboard("{ArrowLeft}"); + expect(screen.queryByText("nested_a")).not.toBeInTheDocument(); + }); + + it("moves focus down with ArrowDown / j and marks the focused node selected", async () => { + const user = userEvent.setup(); + render(); + const tree = screen.getByRole("tree", { name: "Manifest" }); + tree.focus(); + + const items = () => screen.getAllByRole("treeitem"); + expect(items()[0]).toHaveAttribute("aria-selected", "true"); + await user.keyboard("j"); + expect(items()[1]).toHaveAttribute("aria-selected", "true"); + expect(items()[0]).toHaveAttribute("aria-selected", "false"); + }); + + it("toggles a branch on click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("alpha")); + expect(screen.getByText("nested_a")).toBeInTheDocument(); + await user.click(screen.getByText("alpha")); + expect(screen.queryByText("nested_a")).not.toBeInTheDocument(); + }); + + it("renders an honest empty state for an empty manifest", () => { + render(); + expect(screen.getByText("Empty manifest.")).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/design/components/TreeView/TreeView.tsx b/workbench-ui/src/design/components/TreeView/TreeView.tsx new file mode 100644 index 00000000..db55fbc4 --- /dev/null +++ b/workbench-ui/src/design/components/TreeView/TreeView.tsx @@ -0,0 +1,196 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { ChevronRight } from "lucide-react"; +import { useRegisterShortcuts, type ShortcutEntry } from "../../../app/shortcutRegistry"; + +const TREE_NAV_SHORTCUTS: readonly ShortcutEntry[] = [ + { id: "tree-nav-move", keys: "↑ / ↓", action: "Move through tree", order: 42 }, + { id: "tree-nav-expand", keys: "← / →", action: "Collapse / expand node", order: 43 }, +]; + +// Registered only while the tree holds focus, so KeyboardHelp never +// advertises tree controls when no tree is interactable. +function TreeNavShortcuts() { + useRegisterShortcuts(TREE_NAV_SHORTCUTS); + return null; +} + +type Json = unknown; + +interface FlatNode { + id: string; + depth: number; + label: string; + value: Json; + expandable: boolean; + expanded: boolean; +} + +function isContainer(value: Json): value is Record | Json[] { + return value !== null && typeof value === "object"; +} + +// Deterministic child order: object keys sorted ascending, array indices in +// order. No layout depends on insertion order or animation. +function childEntries(value: Record | Json[]): [string, Json][] { + if (Array.isArray(value)) return value.map((item, i) => [String(i), item]); + return Object.keys(value) + .sort() + .map((key) => [key, (value as Record)[key]]); +} + +function leafPreview(value: Json): string { + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +function containerPreview(value: Record | Json[]): string { + return Array.isArray(value) ? `[${value.length}]` : `{${Object.keys(value).length}}`; +} + +function flatten(root: Json, expanded: ReadonlySet): FlatNode[] { + const out: FlatNode[] = []; + const walk = (label: string, value: Json, id: string, depth: number) => { + const expandable = isContainer(value) && childEntries(value).length > 0; + const isOpen = expandable && expanded.has(id); + out.push({ id, depth, label, value, expandable, expanded: isOpen }); + if (isOpen && isContainer(value)) { + for (const [childKey, childValue] of childEntries(value)) { + walk(childKey, childValue, `${id}.${childKey}`, depth + 1); + } + } + }; + if (isContainer(root)) { + for (const [key, value] of childEntries(root)) walk(key, value, key, 0); + } + return out; +} + +export function TreeView({ data, ariaLabel }: { data: Json; ariaLabel: string }) { + const [expanded, setExpanded] = useState>(() => new Set()); + const [focusIndex, setFocusIndex] = useState(0); + const [hasFocus, setHasFocus] = useState(false); + const ref = useRef(null); + + const nodes = useMemo(() => flatten(data, expanded), [data, expanded]); + const focused = nodes[Math.min(focusIndex, nodes.length - 1)]; + + const toggle = useCallback((id: string, open: boolean) => { + setExpanded((prev) => { + const next = new Set(prev); + if (open) next.add(id); + else next.delete(id); + return next; + }); + }, []); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (nodes.length === 0) return; + const node = nodes[Math.min(focusIndex, nodes.length - 1)]; + switch (e.key) { + case "ArrowDown": + case "j": + e.preventDefault(); + setFocusIndex((i) => Math.min(i + 1, nodes.length - 1)); + break; + case "ArrowUp": + case "k": + e.preventDefault(); + setFocusIndex((i) => Math.max(i - 1, 0)); + break; + case "ArrowRight": + e.preventDefault(); + if (node.expandable && !node.expanded) toggle(node.id, true); + else if (node.expandable) setFocusIndex((i) => Math.min(i + 1, nodes.length - 1)); + break; + case "ArrowLeft": + e.preventDefault(); + if (node.expandable && node.expanded) toggle(node.id, false); + else { + const parentDepth = node.depth - 1; + for (let i = focusIndex - 1; i >= 0; i--) { + if (nodes[i].depth === parentDepth) { + setFocusIndex(i); + break; + } + } + } + break; + case "Home": + e.preventDefault(); + setFocusIndex(0); + break; + case "End": + e.preventDefault(); + setFocusIndex(nodes.length - 1); + break; + case "Enter": + e.preventDefault(); + if (node.expandable) toggle(node.id, !node.expanded); + break; + default: + break; + } + }, + [nodes, focusIndex, toggle], + ); + + if (nodes.length === 0) { + return ( +

Empty manifest.

+ ); + } + + return ( + <> + {hasFocus ? : null} +
    setHasFocus(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) setHasFocus(false); + }} + onKeyDown={onKeyDown} + className="m-0 grid list-none gap-0.5 p-0 font-mono text-xs focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]" + > + {nodes.map((node, index) => ( +
  • { + setFocusIndex(index); + if (node.expandable) toggle(node.id, !node.expanded); + }} + style={{ paddingLeft: `${node.depth * 16}px` }} + className={`flex cursor-default items-start gap-1 rounded px-1 py-0.5 ${ + hasFocus && node === focused ? "bg-[var(--color-selected-bg)]" : "" + }`} + > + + {node.expandable ? ( + + ) : null} + + {node.label} + : + + {node.expandable + ? containerPreview(node.value as Record | Json[]) + : leafPreview(node.value)} + +
  • + ))} +
+ + ); +} diff --git a/workbench-ui/src/design/doctrine/schemaDrift.test.ts b/workbench-ui/src/design/doctrine/schemaDrift.test.ts index 237d4d4a..13404089 100644 --- a/workbench-ui/src/design/doctrine/schemaDrift.test.ts +++ b/workbench-ui/src/design/doctrine/schemaDrift.test.ts @@ -21,8 +21,6 @@ import { describe, expect, it } from "vitest"; const NOT_YET_MIRRORED = new Set([ // R2-B backend read substrate (#712) — TS mirrors land with each R2 route: - "PackSummary", - "PackDetail", "VaultSummary", "VaultEntry", // Wave R3 sealed turn replay backend — TS mirrors land with the frontend diff --git a/workbench-ui/src/routes/PacksRoutePlaceholder.tsx b/workbench-ui/src/routes/PacksRoutePlaceholder.tsx deleted file mode 100644 index ba028e24..00000000 --- a/workbench-ui/src/routes/PacksRoutePlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { EmptyState } from "../design/components/states/EmptyState"; - -export function PacksRoutePlaceholder() { - return ( - - ); -} diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts index 2b138fac..8c2e108d 100644 --- a/workbench-ui/src/types/api.ts +++ b/workbench-ui/src/types/api.ts @@ -153,6 +153,25 @@ export interface AuditEvent { payload: unknown; } +export type PackSource = "language_pack" | "runtime_pack"; + +export interface PackSummary { + pack_id: string; + source: PackSource; + manifest_path: string; + version: string | null; + language: string | null; + modality: string | null; + determinism_class: string | null; + checksum: string | null; + checksums: Record; +} + +export interface PackDetail extends PackSummary { + manifest_digest: string; + manifest: Record; +} + export type ArtifactKind = | "trace" | "eval_result"