diff --git a/workbench-ui/src/api/__fixtures__/proposals.ts b/workbench-ui/src/api/__fixtures__/proposals.ts new file mode 100644 index 00000000..f64569d4 --- /dev/null +++ b/workbench-ui/src/api/__fixtures__/proposals.ts @@ -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, +}; diff --git a/workbench-ui/src/api/client.test.ts b/workbench-ui/src/api/client.test.ts index 318039e7..61da64d4 100644 --- a/workbench-ui/src/api/client.test.ts +++ b/workbench-ui/src/api/client.test.ts @@ -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(); + }); +}); diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index 7e84bff5..7f7c9682 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -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 { return apiFetch(`/replay/${artifactId}`); } + +export type ProposalStateFilter = ProposalState | "all"; + +interface ItemsEnvelope { + items: T[]; +} + +export async function fetchProposals( + filter: ProposalStateFilter = "all", +): Promise { + const envelope = await apiFetch>("/proposals"); + if (filter === "all") { + return envelope.items; + } + return envelope.items.filter((proposal) => proposal.state === filter); +} + +export async function fetchProposalDetail(proposalId: string): Promise { + return apiFetch(`/proposals/${encodeURIComponent(proposalId)}`); +} diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index af809a68..c530fe2a 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -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({ - queryKey: ["api", "proposals"], - queryFn: () => apiFetch("/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({ - queryKey: ["api", "proposal", id], - queryFn: () => apiFetch(`/proposals/${id}`), - enabled: !!id, + queryKey: ["api", "proposal", proposalId], + queryFn: () => fetchProposalDetail(proposalId), + enabled: !!proposalId, + staleTime: 30_000, + refetchOnWindowFocus: false, }); } diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 99feb015..c62c60b3 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/workbench-ui/src/app/Shell.test.tsx b/workbench-ui/src/app/Shell.test.tsx index 10cf3910..8d7e7701 100644 --- a/workbench-ui/src/app/Shell.test.tsx +++ b/workbench-ui/src/app/Shell.test.tsx @@ -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") { }> } /> - } /> + } /> @@ -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", () => { diff --git a/workbench-ui/src/app/proposals/ProposalChainViewer.tsx b/workbench-ui/src/app/proposals/ProposalChainViewer.tsx new file mode 100644 index 00000000..275e2145 --- /dev/null +++ b/workbench-ui/src/app/proposals/ProposalChainViewer.tsx @@ -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 ( +
+

Proposed chain

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

No chain records are present.

+ ) : ( +
    + {records.map((record, index) => ( +
  1. +
    + + {index + 1}. {nodeLabel(record, index)} + + {provenance(record) ? ( + + {provenance(record)} + + ) : null} +
    + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx b/workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx new file mode 100644 index 00000000..ff49b399 --- /dev/null +++ b/workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx @@ -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 ( +
+
+

Source provenance

+
+
+
source_kind
+
{proposal.source_kind}
+
+
+
artifacts
+
{proposal.artifact_refs.length}
+
+
+
evidence
+
{proposal.evidence.length}
+
+
+ {proposal.artifact_refs.length > 0 ? ( +
    + {proposal.artifact_refs.map((artifact) => ( +
  • + {artifact.artifact_id} + {artifact.path} +
  • + ))} +
+ ) : null} +
+ + +
+ ); +} diff --git a/workbench-ui/src/app/proposals/ProposalReplayBadge.tsx b/workbench-ui/src/app/proposals/ProposalReplayBadge.tsx new file mode 100644 index 00000000..16e71de1 --- /dev/null +++ b/workbench-ui/src/app/proposals/ProposalReplayBadge.tsx @@ -0,0 +1,35 @@ +import { InfoBadge } from "../../design/components/badges/Badge"; + +export function ProposalReplayBadge({ value }: { value: boolean | null }) { + if (value === true) { + return ( + + ); + } + if (value === false) { + return ( + + ); + } + return ( + + ); +} diff --git a/workbench-ui/src/app/proposals/ProposalStateBadge.tsx b/workbench-ui/src/app/proposals/ProposalStateBadge.tsx new file mode 100644 index 00000000..f78b11f8 --- /dev/null +++ b/workbench-ui/src/app/proposals/ProposalStateBadge.tsx @@ -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 ; + case "accepted": + return ; + case "rejected": + return ; + case "withdrawn": + return ; + case "unknown": + return ( + + ); + } +} diff --git a/workbench-ui/src/app/proposals/ProposalSummaryCard.tsx b/workbench-ui/src/app/proposals/ProposalSummaryCard.tsx new file mode 100644 index 00000000..0c05f49a --- /dev/null +++ b/workbench-ui/src/app/proposals/ProposalSummaryCard.tsx @@ -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 ( +
+
+

+ {proposal.proposal_id} +

+ + +
+

+ {proposalSummaryText(proposal)} +

+
+
+
Created
+
{formatTimestamp(proposal.created_at)}
+
+
+
Downstream effect
+
{proposal.downstream_effect}
+
+
+
+ ); +} diff --git a/workbench-ui/src/app/proposals/ProposalTable.tsx b/workbench-ui/src/app/proposals/ProposalTable.tsx new file mode 100644 index 00000000..14ac246f --- /dev/null +++ b/workbench-ui/src/app/proposals/ProposalTable.tsx @@ -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 ( + + ); + } + + return ( +
+
+ proposal_id + state + replay + source + created +
+
+ {visibleProposals.map((proposal) => { + const selected = proposal.proposal_id === selectedProposalId; + return ( +
onSelect(proposal.proposal_id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(proposal.proposal_id); + } + }} + > + + {shortProposalId(proposal.proposal_id)} + + + + + {provenanceLabel(proposal)} + + + {formatTimestamp(proposal.created_at)} + +
+ ); + })} + {visibleCount < proposals.length ? ( + + ) : null} +
+
+ ); +} diff --git a/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx b/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx new file mode 100644 index 00000000..6fc71d57 --- /dev/null +++ b/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx @@ -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 {children}; +} + +function LocationProbe() { + const location = useLocation(); + return {location.search}; +} + +function renderRoute(initialEntry = "/proposals") { + return render( + + + + } /> + + + , + ); +} + +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( + , + { wrapper: queryWrapper }, + ); + + expect(screen.getByText("No proposals match this queue view.")).toBeInTheDocument(); + }); + + it("renders rows from a pending, accepted, and rejected fixture set", () => { + render( + , + { 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(); + + expect(screen.getByText("core teaching review --proposal-id proposal-123 --accept")).toBeInTheDocument(); + expect(screen.getByText("core teaching review --proposal-id proposal-123 --reject")).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/app/proposals/ProposalsRoute.tsx b/workbench-ui/src/app/proposals/ProposalsRoute.tsx new file mode 100644 index 00000000..c7946e5b --- /dev/null +++ b/workbench-ui/src/app/proposals/ProposalsRoute.tsx @@ -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( + 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 ; + } + + if (proposalsQuery.isError) { + return ( + + ); + } + + return ( +
+
+
+

Proposal Queue

+
+ {filters.map((state) => ( + + ))} +
+
+ + {proposals.length === 0 ? ( + + ) : ( + + )} +
+ +
+ {!selectedProposalId ? ( + + ) : detailQuery.isLoading ? ( + + ) : detailQuery.isError ? ( + + ) : detailQuery.data ? ( +
+ + + + +
+ ) : null} +
+
+ ); +} diff --git a/workbench-ui/src/app/proposals/ReplayEvidenceCard.tsx b/workbench-ui/src/app/proposals/ReplayEvidenceCard.tsx new file mode 100644 index 00000000..b9d1067c --- /dev/null +++ b/workbench-ui/src/app/proposals/ReplayEvidenceCard.tsx @@ -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 ( +
+
+
+

Replay evidence

+ + {proposal.replay_equivalent === true ? "Equivalent" : proposal.replay_equivalent === false ? "Divergent" : "Unknown"} + +
+
+
+
Original
+
{originalDigest ? : "unknown"}
+
+
+
Replay
+
{replayDigest ? : "unknown"}
+
+
+ {summary ?

{summary}

: null} +
+ +
+ ); +} diff --git a/workbench-ui/src/app/proposals/SuggestedCLIBox.tsx b/workbench-ui/src/app/proposals/SuggestedCLIBox.tsx new file mode 100644 index 00000000..ce14bff7 --- /dev/null +++ b/workbench-ui/src/app/proposals/SuggestedCLIBox.tsx @@ -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 ( +
+

Operator CLI review

+
+ {commands.map((command) => ( +
+ + {command} + + +
+ ))} +
+
+ ); +} diff --git a/workbench-ui/src/app/proposals/proposalView.ts b/workbench-ui/src/app/proposals/proposalView.ts new file mode 100644 index 00000000..1d011d25 --- /dev/null +++ b/workbench-ui/src/app/proposals/proposalView.ts @@ -0,0 +1,78 @@ +import type { ProposalDetail, ProposalSummary } from "../../types/api"; + +export type JsonRecord = Record; + +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) { + 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; +} diff --git a/workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx b/workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx deleted file mode 100644 index 5273ad6b..00000000 --- a/workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { EmptyState } from "../design/components/states/EmptyState"; - -export function ProposalsRoutePlaceholder() { - return ( - - ); -}