feat(workbench/W-029): proposal queue (#329)

This commit is contained in:
Shay 2026-05-26 21:08:36 -07:00 committed by GitHub
parent 19506e9f60
commit 30972e184e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 864 additions and 24 deletions

View file

@ -0,0 +1,63 @@
import type { ProposalDetail, ProposalSummary } from "../../types/api";
export const proposalSummaries: ProposalSummary[] = [
{
proposal_id: "proposal-pending-001abcdef",
state: "pending",
source_kind: "contemplation",
replay_equivalent: true,
created_at: "2026-05-26T00:00:00Z",
downstream_effect: "observed",
},
{
proposal_id: "proposal-accepted-002abcdef",
state: "accepted",
source_kind: "corpus",
replay_equivalent: true,
created_at: "2026-05-26T00:01:00Z",
downstream_effect: "none",
},
{
proposal_id: "proposal-rejected-003abcdef",
state: "rejected",
source_kind: "contemplation",
replay_equivalent: false,
created_at: "2026-05-26T00:02:00Z",
downstream_effect: "unknown",
},
];
export const proposalDetail: ProposalDetail = {
...proposalSummaries[0],
proposed_chain: {
chain_records: [
{
subject: "truth",
predicate: "requires",
object: "coherence",
provenance: "cognition_chains_v1",
},
],
},
replay_evidence: {
original_digest: "11111111111111111111111111111111",
replay_digest: "11111111111111111111111111111111",
divergences: [],
},
source: {
source_id: "contemplation-run-001",
corpus_id: "cognition_chains_v1",
summary: "Contemplation proposed a coherence relation.",
},
evidence: [{ artifact_id: "trace-001", trail: ["turn", "proposal"] }],
artifact_refs: [
{
artifact_id: "artifact-001",
kind: "proposal",
path: "teaching/proposals/proposals.jsonl",
digest: "sha256:1111",
created_at: "2026-05-26T00:00:00Z",
},
],
suggested_cli: null,
};

View file

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, WorkbenchApiError } from "./client";
import { apiFetch, fetchProposalDetail, fetchProposals, WorkbenchApiError } from "./client";
import { proposalDetail, proposalSummaries } from "./__fixtures__/proposals";
describe("apiFetch", () => {
afterEach(() => {
@ -68,3 +69,36 @@ describe("apiFetch", () => {
fetchPromise.catch(() => {});
});
});
describe("proposal fetchers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("unwraps the /proposals items envelope and applies a state filter locally", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
ok: true,
generated_at: "now",
data: { items: proposalSummaries },
}),
}),
);
await expect(fetchProposals("accepted")).resolves.toEqual([proposalSummaries[1]]);
});
it("fetches proposal detail without mutating the proposal log", async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: proposalDetail }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchProposalDetail("proposal/detail id")).resolves.toEqual(proposalDetail);
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8765/proposals/proposal%2Fdetail%20id");
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.method).toBeUndefined();
});
});

View file

@ -7,6 +7,9 @@ import type {
ArtifactRef,
ArtifactDetail,
ReplayComparison,
ProposalDetail,
ProposalState,
ProposalSummary,
} from "../types/api";
export class WorkbenchApiError extends Error {
@ -80,3 +83,23 @@ export async function fetchArtifactDetail(artifactId: string): Promise<ArtifactD
export async function fetchReplayComparison(artifactId: string): Promise<ReplayComparison> {
return apiFetch<ReplayComparison>(`/replay/${artifactId}`);
}
export type ProposalStateFilter = ProposalState | "all";
interface ItemsEnvelope<T> {
items: T[];
}
export async function fetchProposals(
filter: ProposalStateFilter = "all",
): Promise<ProposalSummary[]> {
const envelope = await apiFetch<ItemsEnvelope<ProposalSummary>>("/proposals");
if (filter === "all") {
return envelope.items;
}
return envelope.items.filter((proposal) => proposal.state === filter);
}
export async function fetchProposalDetail(proposalId: string): Promise<ProposalDetail> {
return apiFetch<ProposalDetail>(`/proposals/${encodeURIComponent(proposalId)}`);
}

View file

@ -11,6 +11,9 @@ import {
fetchArtifacts,
fetchArtifactDetail,
fetchReplayComparison,
fetchProposalDetail,
fetchProposals,
type ProposalStateFilter,
} from "./client";
import type { WorkbenchApiError } from "./client";
import type {
@ -78,18 +81,22 @@ export function useReplayComparison(artifactId: string) {
});
}
export function useProposals() {
export function useProposals(filter: ProposalStateFilter = "all") {
return useQuery<ProposalSummary[]>({
queryKey: ["api", "proposals"],
queryFn: () => apiFetch<ProposalSummary[]>("/proposals"),
queryKey: ["api", "proposals", filter],
queryFn: () => fetchProposals(filter),
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useProposal(id: string) {
export function useProposalDetail(proposalId: string) {
return useQuery<ProposalDetail>({
queryKey: ["api", "proposal", id],
queryFn: () => apiFetch<ProposalDetail>(`/proposals/${id}`),
enabled: !!id,
queryKey: ["api", "proposal", proposalId],
queryFn: () => fetchProposalDetail(proposalId),
enabled: !!proposalId,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}

View file

@ -4,9 +4,9 @@ import { queryClient } from "../api/queries";
import { Shell } from "./Shell";
import { PreviewPage } from "../preview/PreviewPage";
import { ChatRoute } from "../routes/ChatRoute";
import { ProposalsRoute } from "./proposals/ProposalsRoute";
import { TraceRoutePlaceholder } from "../routes/TraceRoutePlaceholder";
import { ReplayRoute } from "./replay/ReplayRoute";
import { ProposalsRoutePlaceholder } from "../routes/ProposalsRoutePlaceholder";
import { EvalsRoute } from "./evals/EvalsRoute";
import { RunsRoutePlaceholder } from "../routes/RunsRoutePlaceholder";
import { PacksRoutePlaceholder } from "../routes/PacksRoutePlaceholder";
@ -24,7 +24,7 @@ export function App() {
<Route path="chat" element={<ChatRoute />} />
<Route path="trace" element={<TraceRoutePlaceholder />} />
<Route path="replay" element={<ReplayRoute />} />
<Route path="proposals" element={<ProposalsRoutePlaceholder />} />
<Route path="proposals" element={<ProposalsRoute />} />
<Route path="evals" element={<EvalsRoute />} />
<Route path="runs" element={<RunsRoutePlaceholder />} />
<Route path="packs" element={<PacksRoutePlaceholder />} />

View file

@ -1,10 +1,10 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Shell } from "./Shell";
import { ChatRoute } from "../routes/ChatRoute";
import { ProposalsRoutePlaceholder } from "../routes/ProposalsRoutePlaceholder";
import { ProposalsRoute } from "./proposals/ProposalsRoute";
import type { RuntimeStatus } from "../types/api";
// Mock the API queries module
@ -40,7 +40,7 @@ function renderShell(initialPath = "/chat") {
<Routes>
<Route path="/" element={<Shell />}>
<Route path="chat" element={<ChatRoute />} />
<Route path="proposals" element={<ProposalsRoutePlaceholder />} />
<Route path="proposals" element={<ProposalsRoute />} />
</Route>
</Routes>
</MemoryRouter>
@ -49,6 +49,10 @@ function renderShell(initialPath = "/chat") {
}
describe("Shell", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(useRuntimeStatus).mockReturnValue({
@ -92,6 +96,7 @@ describe("Shell", () => {
});
it("clicking a nav item changes route (main shows new content)", () => {
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
renderShell("/chat");
expect(screen.getByText("Ask CORE a question.")).toBeInTheDocument();
@ -99,7 +104,7 @@ describe("Shell", () => {
const proposalsLink = screen.getByRole("link", { name: "Proposals" });
fireEvent.click(proposalsLink);
expect(screen.getByText("Proposals — no data loaded yet.")).toBeInTheDocument();
expect(screen.getByText("Loading proposal queue...")).toBeInTheDocument();
});
it("StatusFooter shows mutation_mode Read Only label for read_only", () => {

View file

@ -0,0 +1,51 @@
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import type { ProposalDetail } from "../../types/api";
import { chainRecords, isRecord, jsonSource, stringField } from "./proposalView";
function nodeLabel(record: unknown, index: number) {
if (!isRecord(record)) return `Chain node ${index + 1}`;
return (
stringField(record, ["id", "chain_id", "subject", "predicate", "object"]) ??
`Chain node ${index + 1}`
);
}
function provenance(record: unknown) {
if (!isRecord(record)) return null;
const direct = stringField(record, ["provenance", "source", "source_id", "corpus_id"]);
return direct;
}
export function ProposalChainViewer({ proposal }: { proposal: ProposalDetail }) {
const records = chainRecords(proposal.proposed_chain);
return (
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)]">Proposed chain</h3>
{records.length === 0 ? (
<p className="text-sm text-[var(--color-text-secondary)]">No chain records are present.</p>
) : (
<ol className="mt-3 grid gap-3 p-0">
{records.map((record, index) => (
<li
className="grid gap-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-3"
key={`${nodeLabel(record, index)}-${index}`}
>
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-xs text-[var(--color-text-primary)]">
{index + 1}. {nodeLabel(record, index)}
</span>
{provenance(record) ? (
<span className="truncate text-xs text-[var(--color-text-muted)]">
{provenance(record)}
</span>
) : null}
</div>
<StableJsonViewer source={jsonSource(record)} />
</li>
))}
</ol>
)}
</section>
);
}

View file

@ -0,0 +1,43 @@
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import type { ProposalDetail } from "../../types/api";
import { jsonSource } from "./proposalView";
import { SuggestedCLIBox } from "./SuggestedCLIBox";
export function ProposalProvenanceViewer({ proposal }: { proposal: ProposalDetail }) {
return (
<section className="grid gap-3">
<div className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)]">Source provenance</h3>
<dl className="mt-3 grid gap-2 text-xs">
<div className="grid grid-cols-[8rem_1fr] gap-3">
<dt className="text-[var(--color-text-muted)]">source_kind</dt>
<dd className="m-0 font-mono text-[var(--color-text-primary)]">{proposal.source_kind}</dd>
</div>
<div className="grid grid-cols-[8rem_1fr] gap-3">
<dt className="text-[var(--color-text-muted)]">artifacts</dt>
<dd className="m-0 text-[var(--color-text-primary)]">{proposal.artifact_refs.length}</dd>
</div>
<div className="grid grid-cols-[8rem_1fr] gap-3">
<dt className="text-[var(--color-text-muted)]">evidence</dt>
<dd className="m-0 text-[var(--color-text-primary)]">{proposal.evidence.length}</dd>
</div>
</dl>
{proposal.artifact_refs.length > 0 ? (
<ul className="mt-3 grid gap-2 p-0">
{proposal.artifact_refs.map((artifact) => (
<li
className="grid gap-1 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-2 text-xs"
key={artifact.artifact_id}
>
<span className="font-mono text-[var(--color-text-primary)]">{artifact.artifact_id}</span>
<span className="truncate text-[var(--color-text-secondary)]">{artifact.path}</span>
</li>
))}
</ul>
) : null}
</div>
<StableJsonViewer source={jsonSource({ source: proposal.source, evidence: proposal.evidence })} />
<SuggestedCLIBox proposalId={proposal.proposal_id} />
</section>
);
}

View file

@ -0,0 +1,35 @@
import { InfoBadge } from "../../design/components/badges/Badge";
export function ProposalReplayBadge({ value }: { value: boolean | null }) {
if (value === true) {
return (
<InfoBadge
label="Replay match"
colorToken="--color-state-verified"
meaning="Replay evidence is equivalent to the original artifact."
adr="ADR-0160"
evidence="ProposalSummary.replay_equivalent is true."
/>
);
}
if (value === false) {
return (
<InfoBadge
label="Replay differs"
colorToken="--color-state-contradicted"
meaning="Replay evidence diverged from the original artifact."
adr="ADR-0160"
evidence="ProposalSummary.replay_equivalent is false."
/>
);
}
return (
<InfoBadge
label="Replay unknown"
colorToken="--color-state-undetermined"
meaning="No replay-equivalence result is present for this proposal."
adr="ADR-0160"
evidence="ProposalSummary.replay_equivalent is null."
/>
);
}

View file

@ -0,0 +1,26 @@
import { ReviewState, ReviewStateBadge } from "../../design/components/badges";
import { InfoBadge } from "../../design/components/badges/Badge";
import type { ProposalState } from "../../types/api";
export function ProposalStateBadge({ value }: { value: ProposalState }) {
switch (value) {
case "pending":
return <ReviewStateBadge value={ReviewState.PENDING} />;
case "accepted":
return <ReviewStateBadge value={ReviewState.ACCEPTED} />;
case "rejected":
return <ReviewStateBadge value={ReviewState.REJECTED} />;
case "withdrawn":
return <ReviewStateBadge value={ReviewState.WITHDRAWN} />;
case "unknown":
return (
<InfoBadge
label="Unknown"
colorToken="--color-state-undetermined"
meaning="The proposal read model could not map this state to a terminal review state."
adr="ADR-0160 / ADR-0162"
evidence="ProposalSummary.state is unknown."
/>
);
}
}

View file

@ -0,0 +1,31 @@
import type { ProposalDetail } from "../../types/api";
import { ProposalReplayBadge } from "./ProposalReplayBadge";
import { ProposalStateBadge } from "./ProposalStateBadge";
import { formatTimestamp, proposalSummaryText } from "./proposalView";
export function ProposalSummaryCard({ proposal }: { proposal: ProposalDetail }) {
return (
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="m-0 font-mono text-sm font-semibold text-[var(--color-text-primary)]">
{proposal.proposal_id}
</h2>
<ProposalStateBadge value={proposal.state} />
<ProposalReplayBadge value={proposal.replay_equivalent} />
</div>
<p className="mt-3 text-sm text-[var(--color-text-secondary)]">
{proposalSummaryText(proposal)}
</p>
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs">
<div>
<dt className="text-[var(--color-text-muted)]">Created</dt>
<dd className="m-0 text-[var(--color-text-primary)]">{formatTimestamp(proposal.created_at)}</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">Downstream effect</dt>
<dd className="m-0 text-[var(--color-text-primary)]">{proposal.downstream_effect}</dd>
</div>
</dl>
</section>
);
}

View file

@ -0,0 +1,88 @@
import { useMemo, useState } from "react";
import { EmptyState } from "../../design/components/states/EmptyState";
import type { ProposalSummary } from "../../types/api";
import { ProposalReplayBadge } from "./ProposalReplayBadge";
import { ProposalStateBadge } from "./ProposalStateBadge";
import { formatTimestamp, provenanceLabel, shortProposalId } from "./proposalView";
const INITIAL_ROWS = 60;
const ROW_INCREMENT = 40;
export function ProposalTable({
proposals,
selectedProposalId,
onSelect,
}: {
proposals: ProposalSummary[];
selectedProposalId: string | null;
onSelect: (proposalId: string) => void;
}) {
const [visibleCount, setVisibleCount] = useState(INITIAL_ROWS);
const visibleProposals = useMemo(
() => proposals.slice(0, visibleCount),
[proposals, visibleCount],
);
if (proposals.length === 0) {
return (
<EmptyState
statement="No proposals match this queue view."
nextAction={{ kind: "cli", command: "core teaching proposals --state pending" }}
/>
);
}
return (
<section className="overflow-hidden rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)]">
<div className="grid grid-cols-[minmax(7rem,1fr)_auto_auto_minmax(7rem,1fr)_minmax(9rem,1fr)] gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-xs font-medium uppercase tracking-normal text-[var(--color-text-muted)]">
<span>proposal_id</span>
<span>state</span>
<span>replay</span>
<span>source</span>
<span>created</span>
</div>
<div className="max-h-[calc(100vh-17rem)] overflow-y-auto">
{visibleProposals.map((proposal) => {
const selected = proposal.proposal_id === selectedProposalId;
return (
<div
aria-current={selected ? "true" : undefined}
role="button"
tabIndex={0}
className="grid w-full grid-cols-[minmax(7rem,1fr)_auto_auto_minmax(7rem,1fr)_minmax(9rem,1fr)] items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] aria-[current=true]:bg-[var(--color-surface-inset)]"
key={proposal.proposal_id}
onClick={() => onSelect(proposal.proposal_id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(proposal.proposal_id);
}
}}
>
<span className="font-mono text-xs" title={proposal.proposal_id}>
{shortProposalId(proposal.proposal_id)}
</span>
<ProposalStateBadge value={proposal.state} />
<ProposalReplayBadge value={proposal.replay_equivalent} />
<span className="truncate text-[var(--color-text-secondary)]">
{provenanceLabel(proposal)}
</span>
<span className="text-xs text-[var(--color-text-secondary)]">
{formatTimestamp(proposal.created_at)}
</span>
</div>
);
})}
{visibleCount < proposals.length ? (
<button
className="w-full px-3 py-2 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)]"
onClick={() => setVisibleCount((count) => count + ROW_INCREMENT)}
type="button"
>
Load more proposals
</button>
) : null}
</div>
</section>
);
}

View file

@ -0,0 +1,146 @@
import { QueryClient, 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 } from "react-router-dom";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { proposalDetail, proposalSummaries } from "../../api/__fixtures__/proposals";
import { SuggestedCLIBox } from "./SuggestedCLIBox";
import { ProposalTable } from "./ProposalTable";
import { ProposalsRoute } from "./ProposalsRoute";
function queryWrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
function LocationProbe() {
const location = useLocation();
return <span data-testid="location">{location.search}</span>;
}
function renderRoute(initialEntry = "/proposals") {
return render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}
>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/proposals" element={<><ProposalsRoute /><LocationProbe /></>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
function stubProposalFetch(items = proposalSummaries) {
const fetchMock = vi.fn((url: string) => {
if (url.endsWith(`/proposals/${proposalDetail.proposal_id}`)) {
return Promise.resolve({
json: () => Promise.resolve({ ok: true, generated_at: "now", data: proposalDetail }),
});
}
return Promise.resolve({
json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items } }),
});
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("ProposalTable", () => {
it("renders empty state when the API returns no proposals", () => {
render(
<ProposalTable proposals={[]} selectedProposalId={null} onSelect={vi.fn()} />,
{ wrapper: queryWrapper },
);
expect(screen.getByText("No proposals match this queue view.")).toBeInTheDocument();
});
it("renders rows from a pending, accepted, and rejected fixture set", () => {
render(
<ProposalTable proposals={proposalSummaries} selectedProposalId={null} onSelect={vi.fn()} />,
{ wrapper: queryWrapper },
);
expect(screen.getByTitle("proposal-pending-001abcdef")).toBeInTheDocument();
expect(screen.getByTitle("proposal-accepted-002abcdef")).toBeInTheDocument();
expect(screen.getByTitle("proposal-rejected-003abcdef")).toBeInTheDocument();
});
});
describe("ProposalsRoute", () => {
afterEach(() => vi.restoreAllMocks());
it("restricts visible rows by filter state", async () => {
stubProposalFetch();
const user = userEvent.setup();
renderRoute("/proposals?state=pending");
expect(await screen.findByTitle("proposal-pending-001abcdef")).toBeInTheDocument();
expect(screen.queryByTitle("proposal-accepted-002abcdef")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "accepted" }));
expect(await screen.findByTitle("proposal-accepted-002abcdef")).toBeInTheDocument();
expect(screen.queryByTitle("proposal-pending-001abcdef")).not.toBeInTheDocument();
});
it("selecting a row updates the URL query param and renders detail", async () => {
stubProposalFetch();
const user = userEvent.setup();
renderRoute("/proposals?state=pending");
await user.click(await screen.findByRole("button", { name: /proposal-p/i }));
await waitFor(() =>
expect(screen.getByTestId("location")).toHaveTextContent(
`?state=pending&proposal_id=${proposalDetail.proposal_id}`,
),
);
expect(await screen.findByText("Contemplation proposed a coherence relation.")).toBeInTheDocument();
});
it("shows LoadingState during fetch", () => {
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
renderRoute();
expect(screen.getByText("Loading proposal queue...")).toBeInTheDocument();
});
it("shows ErrorState on API failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: () =>
Promise.resolve({
ok: false,
generated_at: "now",
error: { code: "read_error", message: "proposal log unavailable" },
}),
}),
);
renderRoute();
expect(await screen.findByText("What failed")).toBeInTheDocument();
expect(screen.getByText("proposal log unavailable")).toBeInTheDocument();
});
it("shows EmptyState on empty result", async () => {
stubProposalFetch([]);
renderRoute();
expect(await screen.findByText("No proposals match this queue view.")).toBeInTheDocument();
});
});
describe("SuggestedCLIBox", () => {
it("shows the correct terminal review commands for a proposal id", () => {
render(<SuggestedCLIBox proposalId="proposal-123" />);
expect(screen.getByText("core teaching review --proposal-id proposal-123 --accept")).toBeInTheDocument();
expect(screen.getByText("core teaching review --proposal-id proposal-123 --reject")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { WorkbenchApiError, type ProposalStateFilter } from "../../api/client";
import { useProposalDetail, useProposals } from "../../api/queries";
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 { ProposalChainViewer } from "./ProposalChainViewer";
import { ProposalProvenanceViewer } from "./ProposalProvenanceViewer";
import { ProposalSummaryCard } from "./ProposalSummaryCard";
import { ProposalTable } from "./ProposalTable";
import { ReplayEvidenceCard } from "./ReplayEvidenceCard";
const filters: ProposalStateFilter[] = ["pending", "accepted", "rejected", "all"];
function isProposalFilter(value: string | null): value is ProposalStateFilter {
return value === "pending" || value === "accepted" || value === "rejected" || value === "withdrawn" || value === "unknown" || value === "all";
}
function errorMessage(error: unknown) {
return error instanceof WorkbenchApiError ? error.message : "Proposal API request failed.";
}
export function ProposalsRoute() {
const [searchParams, setSearchParams] = useSearchParams();
const selectedFromUrl = searchParams.get("proposal_id");
const filterFromUrl = searchParams.get("state");
const [filter, setFilter] = useState<ProposalStateFilter>(
isProposalFilter(filterFromUrl) ? filterFromUrl : "pending",
);
const proposalsQuery = useProposals(filter);
const selectedProposalId = selectedFromUrl ?? null;
const detailQuery = useProposalDetail(selectedProposalId ?? "");
useEffect(() => {
const urlFilter = searchParams.get("state");
if (isProposalFilter(urlFilter) && urlFilter !== filter) {
setFilter(urlFilter);
}
}, [filter, searchParams]);
const proposals = useMemo(() => proposalsQuery.data ?? [], [proposalsQuery.data]);
function updateRoute(next: { proposalId?: string | null; state?: ProposalStateFilter }) {
const params = new URLSearchParams(searchParams);
const nextState = next.state ?? filter;
params.set("state", nextState);
if (next.proposalId === null) {
params.delete("proposal_id");
} else if (next.proposalId) {
params.set("proposal_id", next.proposalId);
}
setSearchParams(params, { replace: false });
}
function changeFilter(nextFilter: ProposalStateFilter) {
setFilter(nextFilter);
updateRoute({ state: nextFilter, proposalId: null });
}
function selectProposal(proposalId: string) {
updateRoute({ proposalId });
}
if (proposalsQuery.isLoading) {
return <LoadingState label="Loading proposal queue..." />;
}
if (proposalsQuery.isError) {
return (
<ErrorState
whatFailed={errorMessage(proposalsQuery.error)}
mutationStatus="No proposal mutation occurred."
reproducer="curl /proposals"
retrySafety="Retry: safe"
/>
);
}
return (
<div className="grid h-full min-h-0 gap-4 xl:grid-cols-[minmax(34rem,0.95fr)_minmax(32rem,1.05fr)]">
<section className="grid min-h-0 content-start gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="m-0 text-base font-semibold text-[var(--color-text-primary)]">Proposal Queue</h1>
<div className="flex flex-wrap gap-2" role="group" aria-label="Proposal state filter">
{filters.map((state) => (
<Button
aria-pressed={filter === state}
key={state}
onClick={() => changeFilter(state)}
type="button"
variant={filter === state ? "primary" : "quiet"}
>
{state}
</Button>
))}
</div>
</div>
{proposals.length === 0 ? (
<EmptyState
statement="No proposals match this queue view."
nextAction={{ kind: "cli", command: "core teaching proposals --state pending" }}
/>
) : (
<ProposalTable
proposals={proposals}
selectedProposalId={selectedProposalId}
onSelect={selectProposal}
/>
)}
</section>
<section className="min-h-0 overflow-y-auto">
{!selectedProposalId ? (
<EmptyState
statement="Select a proposal to inspect replay evidence, chain records, and provenance."
nextAction={{ kind: "cli", command: "core teaching hitl-queue list" }}
/>
) : detailQuery.isLoading ? (
<LoadingState label="Loading proposal detail..." />
) : detailQuery.isError ? (
<ErrorState
whatFailed={errorMessage(detailQuery.error)}
mutationStatus="No proposal mutation occurred."
reproducer={`curl /proposals/${selectedProposalId}`}
retrySafety="Retry: safe"
/>
) : detailQuery.data ? (
<div className="grid gap-4">
<ProposalSummaryCard proposal={detailQuery.data} />
<ReplayEvidenceCard proposal={detailQuery.data} />
<ProposalChainViewer proposal={detailQuery.data} />
<ProposalProvenanceViewer proposal={detailQuery.data} />
</div>
) : null}
</section>
</div>
);
}

View file

@ -0,0 +1,43 @@
import { TraceHashBadge } from "../../design/components/badges";
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import type { ProposalDetail } from "../../types/api";
import { digestField, divergenceSummary, jsonSource } from "./proposalView";
export function ReplayEvidenceCard({ proposal }: { proposal: ProposalDetail }) {
const originalDigest = digestField(proposal.replay_evidence, [
"original_digest",
"original_hash",
"source_digest",
]);
const replayDigest = digestField(proposal.replay_evidence, [
"replay_digest",
"replay_hash",
"result_digest",
]);
const summary = divergenceSummary(proposal.replay_evidence);
return (
<section className="grid gap-3">
<div className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<div className="flex flex-wrap items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)]">Replay evidence</h3>
<span className="text-xs text-[var(--color-text-secondary)]">
{proposal.replay_equivalent === true ? "Equivalent" : proposal.replay_equivalent === false ? "Divergent" : "Unknown"}
</span>
</div>
<dl className="mt-3 grid gap-2 text-xs">
<div className="flex flex-wrap items-center gap-2">
<dt className="text-[var(--color-text-muted)]">Original</dt>
<dd className="m-0">{originalDigest ? <TraceHashBadge value={originalDigest} /> : "unknown"}</dd>
</div>
<div className="flex flex-wrap items-center gap-2">
<dt className="text-[var(--color-text-muted)]">Replay</dt>
<dd className="m-0">{replayDigest ? <TraceHashBadge value={replayDigest} /> : "unknown"}</dd>
</div>
</dl>
{summary ? <p className="mt-3 text-sm text-[var(--color-text-secondary)]">{summary}</p> : null}
</div>
<StableJsonViewer source={jsonSource(proposal.replay_evidence)} />
</section>
);
}

View file

@ -0,0 +1,36 @@
import { Copy } from "lucide-react";
import { Button } from "../../design/components/primitives/Button";
import { copyText } from "../../design/lib";
export function SuggestedCLIBox({ proposalId }: { proposalId: string }) {
const commands = [
`core teaching review --proposal-id ${proposalId} --accept`,
`core teaching review --proposal-id ${proposalId} --reject`,
];
return (
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)]">Operator CLI review</h3>
<div className="mt-3 grid gap-2">
{commands.map((command) => (
<div className="flex items-center gap-2" key={command}>
<code className="min-w-0 flex-1 overflow-x-auto rounded-md bg-[var(--color-surface-inset)] px-2 py-2 font-mono text-xs text-[var(--color-text-primary)]">
{command}
</code>
<Button
aria-label={`Copy ${command}`}
className="shrink-0"
onClick={() => void copyText(command)}
title="Copy command"
type="button"
variant="quiet"
>
<Copy size={14} aria-hidden />
Copy
</Button>
</div>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,78 @@
import type { ProposalDetail, ProposalSummary } from "../../types/api";
export type JsonRecord = Record<string, unknown>;
export function shortProposalId(proposalId: string) {
return proposalId.length > 14 ? `${proposalId.slice(0, 10)}...` : proposalId;
}
export function formatTimestamp(value: string | null) {
if (!value) return "unknown";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return value;
return new Intl.DateTimeFormat("en", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(timestamp));
}
export function jsonSource(value: unknown) {
return JSON.stringify(value ?? null, null, 2);
}
export function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function provenanceLabel(proposal: Pick<ProposalSummary, "source_kind" | "proposal_id">) {
return proposal.source_kind || proposal.proposal_id;
}
export function proposalSummaryText(proposal: ProposalDetail) {
if (isRecord(proposal.source)) {
for (const key of ["summary", "title", "label", "source_id"]) {
const value = proposal.source[key];
if (typeof value === "string" && value.trim()) {
return value;
}
}
}
return `${proposal.source_kind} proposal`;
}
export function chainRecords(proposedChain: unknown): unknown[] {
if (Array.isArray(proposedChain)) return proposedChain;
if (isRecord(proposedChain)) {
const records = proposedChain.chain_records;
if (Array.isArray(records)) return records;
const nodes = proposedChain.nodes;
if (Array.isArray(nodes)) return nodes;
}
return proposedChain === undefined || proposedChain === null ? [] : [proposedChain];
}
export function stringField(record: JsonRecord, keys: string[]) {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value;
}
}
return null;
}
export function digestField(value: unknown, keys: string[]) {
if (!isRecord(value)) return null;
return stringField(value, keys);
}
export function divergenceSummary(value: unknown) {
if (!isRecord(value)) return null;
const direct = stringField(value, ["divergence_summary", "summary", "divergence"]);
if (direct) return direct;
const divergences = value.divergences;
if (Array.isArray(divergences)) {
return divergences.length === 0 ? "No divergences reported." : `${divergences.length} divergence record(s).`;
}
return null;
}

View file

@ -1,10 +0,0 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function ProposalsRoutePlaceholder() {
return (
<EmptyState
statement="Proposals — no data loaded yet."
nextAction={{ kind: "cli", command: "core teaching hitl-queue list" }}
/>
);
}