feat(workbench): Packs route + TreeView primitive — manifest & checksum evidence (Wave R2)
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/<packId> (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 <path>) - 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).
This commit is contained in:
parent
1086415b36
commit
cfcd7dc9ec
11 changed files with 863 additions and 14 deletions
|
|
@ -13,6 +13,8 @@ import type {
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
RunSummary,
|
RunSummary,
|
||||||
RunDetail,
|
RunDetail,
|
||||||
|
PackSummary,
|
||||||
|
PackDetail,
|
||||||
TurnJournalEntry,
|
TurnJournalEntry,
|
||||||
TurnJournalSummary,
|
TurnJournalSummary,
|
||||||
MathProposalSummary,
|
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,
|
fetchAuditEvents,
|
||||||
fetchRuns,
|
fetchRuns,
|
||||||
fetchRun,
|
fetchRun,
|
||||||
|
fetchPacks,
|
||||||
|
fetchPack,
|
||||||
fetchTraceTurn,
|
fetchTraceTurn,
|
||||||
fetchTraceTurns,
|
fetchTraceTurns,
|
||||||
fetchMathProposals,
|
fetchMathProposals,
|
||||||
|
|
@ -35,6 +37,8 @@ import type {
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
RunSummary,
|
RunSummary,
|
||||||
RunDetail,
|
RunDetail,
|
||||||
|
PackSummary,
|
||||||
|
PackDetail,
|
||||||
TurnJournalEntry,
|
TurnJournalEntry,
|
||||||
TurnJournalSummary,
|
TurnJournalSummary,
|
||||||
EvalLaneSummary,
|
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 { ReplayRoute } from "./replay/ReplayRoute";
|
||||||
import { EvalsRoute } from "./evals/EvalsRoute";
|
import { EvalsRoute } from "./evals/EvalsRoute";
|
||||||
import { RunsRoute } from "./runs/RunsRoute";
|
import { RunsRoute } from "./runs/RunsRoute";
|
||||||
import { PacksRoutePlaceholder } from "../routes/PacksRoutePlaceholder";
|
import { PacksRoute } from "./packs/PacksRoute";
|
||||||
import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder";
|
import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder";
|
||||||
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
|
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function App() {
|
||||||
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
|
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
|
||||||
<Route path="evals/:laneId?" element={<EvalsRoute />} />
|
<Route path="evals/:laneId?" element={<EvalsRoute />} />
|
||||||
<Route path="runs/:sessionId?" element={<RunsRoute />} />
|
<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="vault" element={<VaultRoutePlaceholder />} />
|
||||||
<Route path="audit" element={<AuditRoute />} />
|
<Route path="audit" element={<AuditRoute />} />
|
||||||
<Route path="settings" element={<SettingsRoutePlaceholder />} />
|
<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 { EvalsRoute } from "./evals/EvalsRoute";
|
||||||
import { ReplayRoute } from "./replay/ReplayRoute";
|
import { ReplayRoute } from "./replay/ReplayRoute";
|
||||||
import { RunsRoute } from "./runs/RunsRoute";
|
import { RunsRoute } from "./runs/RunsRoute";
|
||||||
|
import { PacksRoute } from "./packs/PacksRoute";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ADR-0162 §6 route conformance — executable, not aspirational.
|
* ADR-0162 §6 route conformance — executable, not aspirational.
|
||||||
|
|
@ -161,6 +162,15 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
|
||||||
emptyStatement: "No artifacts available.",
|
emptyStatement: "No artifacts available.",
|
||||||
emptyCommand: "core eval cognition",
|
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) => {
|
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([
|
const NOT_YET_MIRRORED = new Set([
|
||||||
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
|
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
|
||||||
"PackSummary",
|
|
||||||
"PackDetail",
|
|
||||||
"VaultSummary",
|
"VaultSummary",
|
||||||
"VaultEntry",
|
"VaultEntry",
|
||||||
// Wave R3 sealed turn replay backend — TS mirrors land with the frontend
|
// 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;
|
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 =
|
export type ArtifactKind =
|
||||||
| "trace"
|
| "trace"
|
||||||
| "eval_result"
|
| "eval_result"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue