feat(workbench/W-029): proposal queue (#329)
This commit is contained in:
parent
19506e9f60
commit
30972e184e
18 changed files with 864 additions and 24 deletions
63
workbench-ui/src/api/__fixtures__/proposals.ts
Normal file
63
workbench-ui/src/api/__fixtures__/proposals.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
51
workbench-ui/src/app/proposals/ProposalChainViewer.tsx
Normal file
51
workbench-ui/src/app/proposals/ProposalChainViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx
Normal file
43
workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
workbench-ui/src/app/proposals/ProposalReplayBadge.tsx
Normal file
35
workbench-ui/src/app/proposals/ProposalReplayBadge.tsx
Normal 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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
workbench-ui/src/app/proposals/ProposalStateBadge.tsx
Normal file
26
workbench-ui/src/app/proposals/ProposalStateBadge.tsx
Normal 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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
31
workbench-ui/src/app/proposals/ProposalSummaryCard.tsx
Normal file
31
workbench-ui/src/app/proposals/ProposalSummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
workbench-ui/src/app/proposals/ProposalTable.tsx
Normal file
88
workbench-ui/src/app/proposals/ProposalTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
workbench-ui/src/app/proposals/ProposalsRoute.test.tsx
Normal file
146
workbench-ui/src/app/proposals/ProposalsRoute.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
141
workbench-ui/src/app/proposals/ProposalsRoute.tsx
Normal file
141
workbench-ui/src/app/proposals/ProposalsRoute.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
workbench-ui/src/app/proposals/ReplayEvidenceCard.tsx
Normal file
43
workbench-ui/src/app/proposals/ReplayEvidenceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
workbench-ui/src/app/proposals/SuggestedCLIBox.tsx
Normal file
36
workbench-ui/src/app/proposals/SuggestedCLIBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
workbench-ui/src/app/proposals/proposalView.ts
Normal file
78
workbench-ui/src/app/proposals/proposalView.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue