Merge pull request #719 from AssetOverflow/feat/wb-r2-packs-route
feat(workbench): Packs route + TreeView primitive — manifest & checksum evidence (Wave R2)
This commit is contained in:
commit
19f8d5e3bd
11 changed files with 863 additions and 14 deletions
|
|
@ -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<PackSummary[]> {
|
||||
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<ItemsEnvelope<PackSummary>>(query ? `/packs?${query}` : "/packs");
|
||||
return envelope.items;
|
||||
}
|
||||
|
||||
export async function fetchPack(packId: string): Promise<PackDetail> {
|
||||
return apiFetch<PackDetail>(`/packs/${encodeURIComponent(packId)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PackSummary[], WorkbenchApiError>({
|
||||
queryKey: ["api", "packs", limit ?? null, offset ?? null],
|
||||
queryFn: () => fetchPacks(limit, offset),
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePack(packId?: string | null) {
|
||||
return useQuery<PackDetail, WorkbenchApiError>({
|
||||
queryKey: ["api", "pack", packId ?? null],
|
||||
queryFn: () => fetchPack(packId as string),
|
||||
enabled: typeof packId === "string" && packId.length > 0,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
|
||||
<Route path="evals/:laneId?" element={<EvalsRoute />} />
|
||||
<Route path="runs/:sessionId?" element={<RunsRoute />} />
|
||||
<Route path="packs/:packId?" element={<PacksRoutePlaceholder />} />
|
||||
<Route path="packs/:packId?" element={<PacksRoute />} />
|
||||
<Route path="vault" element={<VaultRoutePlaceholder />} />
|
||||
<Route path="audit" element={<AuditRoute />} />
|
||||
<Route path="settings" element={<SettingsRoutePlaceholder />} />
|
||||
|
|
|
|||
215
workbench-ui/src/app/packs/PacksRoute.test.tsx
Normal file
215
workbench-ui/src/app/packs/PacksRoute.test.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<span data-testid="location">{`${location.pathname}${location.search}`}</span>
|
||||
<span data-testid="nav-type">{navigationType}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRoute(initialEntry = "/packs") {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/packs/:packId?"
|
||||
element={
|
||||
<>
|
||||
<PacksRoute />
|
||||
<LocationProbe />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</EvidenceProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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/<packId> 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");
|
||||
});
|
||||
});
|
||||
301
workbench-ui/src/app/packs/PacksRoute.tsx
Normal file
301
workbench-ui/src/app/packs/PacksRoute.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<span className="inline-flex h-6 items-center rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 text-xs text-[var(--color-text-secondary)]">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PackRow({
|
||||
pack,
|
||||
selected,
|
||||
focused,
|
||||
onSelect,
|
||||
}: {
|
||||
pack: PackSummary;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={onSelect}
|
||||
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] ${
|
||||
selected ? "bg-[var(--color-selected-bg)]" : ""
|
||||
} ${
|
||||
selected
|
||||
? "border-l-2 border-l-[var(--color-selected-border)] pl-[10px]"
|
||||
: focused
|
||||
? "border-l-2 border-l-[var(--color-focus-ring)] pl-[10px]"
|
||||
: "border-l-2 border-l-transparent pl-[10px]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-[var(--color-text-primary)]">
|
||||
{pack.pack_id}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-secondary)]">
|
||||
<span>{sourceLabel(pack.source)}</span>
|
||||
{pack.version ? (
|
||||
<>
|
||||
<span aria-hidden className="text-[var(--color-text-muted)]">·</span>
|
||||
<span className="font-mono">{pack.version}</span>
|
||||
</>
|
||||
) : null}
|
||||
{pack.language ? <span className="text-[var(--color-text-muted)]">{pack.language}</span> : null}
|
||||
{pack.modality ? <span className="text-[var(--color-text-muted)]">{pack.modality}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="justify-self-end">
|
||||
{pack.determinism_class ? <Pill>{pack.determinism_class}</Pill> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-4">
|
||||
<MetadataTable
|
||||
rows={[
|
||||
{
|
||||
key: "manifest_digest",
|
||||
value: manifestDigest ? (
|
||||
<DigestBadge digest={manifestDigest} truncate={16} />
|
||||
) : (
|
||||
"not recorded"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "checksum",
|
||||
value: declared ? <DigestBadge digest={declared} truncate={16} /> : "not declared",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<section>
|
||||
<h3 className="m-0 mb-2 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
Manifest-declared checksums
|
||||
</h3>
|
||||
{fieldRows.length === 0 ? (
|
||||
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
|
||||
This manifest declares no per-field checksums.
|
||||
</p>
|
||||
) : (
|
||||
<MetadataTable
|
||||
rows={fieldRows.map(([field, value]) => ({
|
||||
key: field,
|
||||
value: <DigestBadge digest={digestPayload(value) ?? value} truncate={16} />,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RawTab({ detail }: { detail: PackDetail }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return expanded ? (
|
||||
<StableJsonViewer source={JSON.stringify(detail, null, 2)} />
|
||||
) : (
|
||||
<div className="grid justify-items-start gap-2">
|
||||
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
|
||||
Raw pack JSON is collapsed by default.
|
||||
</p>
|
||||
<Button type="button" variant="quiet" onClick={() => setExpanded(true)}>
|
||||
<Eye size={14} aria-hidden />
|
||||
Expand raw JSON
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackDetailPanel({ detail }: { detail: PackDetail }) {
|
||||
const [activeTab, setActiveTab] = useState("manifest");
|
||||
return (
|
||||
<Panel
|
||||
title={detail.pack_id}
|
||||
toolbar={<Pill>{sourceLabel(detail.source)}</Pill>}
|
||||
>
|
||||
<TabBar tabs={PACK_TABS} activeTab={activeTab} onTabChange={setActiveTab}>
|
||||
{activeTab === "manifest" ? (
|
||||
<TreeView data={detail.manifest} ariaLabel={`${detail.pack_id} manifest`} />
|
||||
) : null}
|
||||
{activeTab === "checksums" ? <ChecksumsTab detail={detail} /> : null}
|
||||
{activeTab === "raw" ? <RawTab detail={detail} /> : null}
|
||||
</TabBar>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
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 <LoadingState label="Loading packs..." />;
|
||||
}
|
||||
|
||||
if (packsQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
whatFailed={errorMessage(packsQuery.error)}
|
||||
mutationStatus="No packs mutation occurred."
|
||||
reproducer="curl /packs"
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (packs.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
statement="No packs discovered."
|
||||
nextAction={{ kind: "cli", command: "core pack validate <path>" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0">
|
||||
<SplitPane direction="horizontal" id="packs" defaultSplit={38} minSize={320}>
|
||||
<Panel title="Packs">
|
||||
<div className="grid min-h-0 gap-3">
|
||||
<SearchInput
|
||||
placeholder="Filter by pack, source, or language"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
{filteredPacks.length === 0 ? (
|
||||
<EmptyState
|
||||
statement="No packs match this filter."
|
||||
nextAction={{ kind: "cli", command: "core pack validate <path>" }}
|
||||
/>
|
||||
) : (
|
||||
<VirtualizedList
|
||||
ariaLabel="Packs"
|
||||
estimateSize={76}
|
||||
getKey={(pack) => pack.pack_id}
|
||||
height="calc(100vh - 14rem)"
|
||||
initialRect={{ width: 480, height: 560 }}
|
||||
items={filteredPacks}
|
||||
onActivate={(pack) => selectPack(pack)}
|
||||
renderItem={(pack, _index, focused) => (
|
||||
<PackRow
|
||||
pack={pack}
|
||||
selected={pack.pack_id === selectedPackId}
|
||||
focused={focused}
|
||||
onSelect={() => selectPack(pack)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="h-full min-h-0 overflow-y-auto pl-3">
|
||||
{selectedPackId === null ? (
|
||||
<EmptyState
|
||||
statement="Select a pack to inspect its manifest, checksums, and provenance."
|
||||
nextAction={{ kind: "cli", command: "core pack validate <path>" }}
|
||||
/>
|
||||
) : packQuery.isLoading ? (
|
||||
<LoadingState label="Loading pack detail..." />
|
||||
) : packQuery.isError ? (
|
||||
<ErrorState
|
||||
whatFailed={errorMessage(packQuery.error)}
|
||||
mutationStatus="No packs mutation occurred."
|
||||
reproducer={`curl /packs/${selectedPackId}`}
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
) : packQuery.data ? (
|
||||
<PackDetailPanel detail={packQuery.data} />
|
||||
) : null}
|
||||
</section>
|
||||
</SplitPane>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: <PacksRoute />,
|
||||
path: "/packs/:packId?",
|
||||
initialEntry: "/packs",
|
||||
loadingLabel: "Loading packs...",
|
||||
emptyStatement: "No packs discovered.",
|
||||
emptyCommand: "core pack validate <path>",
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(MOUNT_ROUTES)("route conformance: $name", (spec) => {
|
||||
|
|
|
|||
|
|
@ -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(<TreeView data={manifest} ariaLabel="Manifest" />);
|
||||
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(<TreeView data={manifest} ariaLabel="Manifest" />);
|
||||
|
||||
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(<TreeView data={manifest} ariaLabel="Manifest" />);
|
||||
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(<TreeView data={manifest} ariaLabel="Manifest" />);
|
||||
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(<TreeView data={manifest} ariaLabel="Manifest" />);
|
||||
|
||||
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(<TreeView data={{}} ariaLabel="Manifest" />);
|
||||
expect(screen.getByText("Empty manifest.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
196
workbench-ui/src/design/components/TreeView/TreeView.tsx
Normal file
196
workbench-ui/src/design/components/TreeView/TreeView.tsx
Normal file
|
|
@ -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<string, Json> | 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<string, Json> | 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<string, Json>)[key]]);
|
||||
}
|
||||
|
||||
function leafPreview(value: Json): string {
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function containerPreview(value: Record<string, Json> | Json[]): string {
|
||||
return Array.isArray(value) ? `[${value.length}]` : `{${Object.keys(value).length}}`;
|
||||
}
|
||||
|
||||
function flatten(root: Json, expanded: ReadonlySet<string>): 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<ReadonlySet<string>>(() => new Set());
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const [hasFocus, setHasFocus] = useState(false);
|
||||
const ref = useRef<HTMLUListElement>(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 (
|
||||
<p className="m-0 text-sm text-[var(--color-text-secondary)]">Empty manifest.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasFocus ? <TreeNavShortcuts /> : null}
|
||||
<ul
|
||||
ref={ref}
|
||||
role="tree"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={0}
|
||||
onFocus={() => 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) => (
|
||||
<li
|
||||
key={node.id}
|
||||
role="treeitem"
|
||||
aria-level={node.depth + 1}
|
||||
aria-expanded={node.expandable ? node.expanded : undefined}
|
||||
aria-selected={node === focused}
|
||||
onClick={() => {
|
||||
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)]" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5 w-3 shrink-0 text-[var(--color-text-muted)]">
|
||||
{node.expandable ? (
|
||||
<ChevronRight
|
||||
size={11}
|
||||
aria-hidden
|
||||
className={node.expanded ? "rotate-90" : ""}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-secondary)]">{node.label}</span>
|
||||
<span className="text-[var(--color-text-muted)]">:</span>
|
||||
<span className="min-w-0 break-all text-[var(--color-text-primary)]">
|
||||
{node.expandable
|
||||
? containerPreview(node.value as Record<string, Json> | Json[])
|
||||
: leafPreview(node.value)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { EmptyState } from "../design/components/states/EmptyState";
|
||||
|
||||
export function PacksRoutePlaceholder() {
|
||||
return (
|
||||
<EmptyState
|
||||
statement="Packs — no data loaded yet."
|
||||
nextAction={{ kind: "cli", command: "core pack list" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string>;
|
||||
}
|
||||
|
||||
export interface PackDetail extends PackSummary {
|
||||
manifest_digest: string;
|
||||
manifest: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ArtifactKind =
|
||||
| "trace"
|
||||
| "eval_result"
|
||||
|
|
|
|||
Loading…
Reference in a new issue