diff --git a/workbench-ui/src/app/replay/ReplayComparisonPanel.tsx b/workbench-ui/src/app/replay/ReplayComparisonPanel.tsx index 05a3d7fe..103157f4 100644 --- a/workbench-ui/src/app/replay/ReplayComparisonPanel.tsx +++ b/workbench-ui/src/app/replay/ReplayComparisonPanel.tsx @@ -3,6 +3,7 @@ import { ReplayStatusBadge, TraceHashBadge, ReplayStatus } from "../../design/co import { EmptyState } from "../../design/components/states/EmptyState"; import { ReplayDiffViewer } from "./ReplayDiffViewer"; import { ReplayMetadataTable } from "./ReplayMetadataTable"; +import { Link, useSearchParams } from "react-router-dom"; interface ReplayComparisonPanelProps { artifact: ArtifactDetail; @@ -15,6 +16,9 @@ export function ReplayComparisonPanel({ comparison, status, }: ReplayComparisonPanelProps) { + const [searchParams] = useSearchParams(); + const fromProposal = searchParams.get("fromProposal"); + const finalComparison: ReplayComparison = comparison || { artifact_id: artifact.artifact_id, original_hash: artifact.digest, @@ -25,6 +29,17 @@ export function ReplayComparisonPanel({ return (
+ {fromProposal && ( +
+ + Back to proposal #{fromProposal} + +
+ )}

Replay Evidence

diff --git a/workbench-ui/src/app/replay/ReplayDiffViewer.tsx b/workbench-ui/src/app/replay/ReplayDiffViewer.tsx index df0e2927..2ad4f32c 100644 --- a/workbench-ui/src/app/replay/ReplayDiffViewer.tsx +++ b/workbench-ui/src/app/replay/ReplayDiffViewer.tsx @@ -54,6 +54,12 @@ export function ReplayDiffViewer({ divergences }: ReplayDiffViewerProps) {
+ + {div.severity === "failure" ? "breaking" : div.severity === "warning" ? "material" : "low"} + {div.path} diff --git a/workbench-ui/src/app/replay/ReplayMetadataTable.tsx b/workbench-ui/src/app/replay/ReplayMetadataTable.tsx index 4a3b0093..1d3b21ee 100644 --- a/workbench-ui/src/app/replay/ReplayMetadataTable.tsx +++ b/workbench-ui/src/app/replay/ReplayMetadataTable.tsx @@ -15,6 +15,31 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl { info: 0, warning: 0, failure: 0 } as Record<"info" | "warning" | "failure", number> ); + // Extract run ID if present in content + let runId: string | null = null; + if (artifact.content && typeof artifact.content === "object") { + const contentObj = artifact.content as any; + runId = contentObj.run_id || contentObj.runId || contentObj.workflow_run_id || contentObj.session_id || contentObj.reasoning_trace_id || null; + } + + // Extract lane if present in content or parsed from path + let lane: string | null = null; + if (artifact.content && typeof artifact.content === "object") { + const contentObj = artifact.content as any; + lane = contentObj.lane || contentObj.lane_name || contentObj.laneName || contentObj.split || null; + } + if (!lane && artifact.path) { + const parts = artifact.path.split("/"); + if (parts[0] === "evals" && parts.length > 1) { + lane = parts[1]; + } + } + + // Format timestamp nicely + const timestamp = artifact.created_at + ? new Date(artifact.created_at).toLocaleString() + : null; + return (

Metadata Audit Details

@@ -22,8 +47,8 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl - - + + @@ -35,12 +60,32 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl + + + + + + + + + + + + - - + - - + + + + + +
PropertyValuePropertyValue
Kind {artifact.kind}
Content Type{artifact.content_type}
Path {artifact.path}
Created At + {timestamp || None} +
Artifact Digest / Trace Hash + {artifact.digest ? ( + + ) : ( + None + )} +
Original Hash @@ -62,24 +107,32 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl
Divergences - - - Failure: {divergenceCounts.failure} - - - Warning: {divergenceCounts.warning} - - - Info: {divergenceCounts.info} - - + Run ID + {runId || None}
Content Type{artifact.content_type}Lane + {lane || None} +
Divergences + + + Failure (breaking): {divergenceCounts.failure} + + + Warning (material): {divergenceCounts.warning} + + + Info (low): {divergenceCounts.info} + + +
diff --git a/workbench-ui/src/app/replay/ReplayRoute.tsx b/workbench-ui/src/app/replay/ReplayRoute.tsx index 9144c805..44c31017 100644 --- a/workbench-ui/src/app/replay/ReplayRoute.tsx +++ b/workbench-ui/src/app/replay/ReplayRoute.tsx @@ -1,3 +1,19 @@ +/** + * SURVEY OF THE REPLAY THEATER COMPONENTS (src/app/replay/): + * + * 1. ArtifactList.tsx: Grouped and sorted list of available artifacts (traces, eval results, + * proposals, etc.) generated by the backend. Allows selection via `onSelect` callback. + * 2. ReplayComparisonPanel.tsx: Orchestrates the display of original and replay digests (hashes), + * the status badges, and switches between empty states (intact/equivalent, not replayed, unsupported) + * and the divergence differences viewer. + * 3. ReplayDiffViewer.tsx: Displays side-by-side original vs. replayed value comparisons for + * detected divergences using StableJsonViewer, sorted by severity (failure -> warning -> info). + * 4. ReplayMetadataTable.tsx: Standard property table detailing the selected artifact's metadata + * (artifact ID, kind, path, original hash, replay hash, divergence severity counts, content type). + * 5. ReplayRoute.tsx: The main page route coordinating state from URL search params (artifactId, fromProposal), + * querying artifacts lists/details/comparisons, and displaying appropriate loading, error, and detail panels. + */ + import { useSearchParams } from "react-router-dom"; import { useArtifacts, useArtifactDetail, useReplayComparison } from "../../api/queries"; import { ArtifactList } from "./ArtifactList"; @@ -83,9 +99,16 @@ export function ReplayRoute() { {isLoadingArtifacts ? ( ) : artifactsQuery.isError ? ( -
- Failed to load artifacts. -
+ ) : ( - ) : null} + ) : ( + + )}
); diff --git a/workbench-ui/src/app/replay/replay.test.tsx b/workbench-ui/src/app/replay/replay.test.tsx index 95ee316c..b00fe538 100644 --- a/workbench-ui/src/app/replay/replay.test.tsx +++ b/workbench-ui/src/app/replay/replay.test.tsx @@ -209,11 +209,13 @@ describe("W-031 Replay Theater Tests", () => { }; render( - + + + ); // Check for calm empty state text @@ -262,6 +264,14 @@ describe("W-031 Replay Theater Tests", () => { expect(badges[2].textContent).toBe("info"); }); + it("renders severity label text ('breaking', 'material', 'low') next to badges", () => { + render(); + + expect(screen.getByTestId("severity-label-failure").textContent).toBe("breaking"); + expect(screen.getByTestId("severity-label-warning").textContent).toBe("material"); + expect(screen.getByTestId("severity-label-info").textContent).toBe("low"); + }); + it("renders nothing (null) with 0 divergences", () => { const { container } = render(); expect(container.firstChild).toBeNull(); @@ -298,6 +308,36 @@ describe("W-031 Replay Theater Tests", () => { expect(pathEl.closest("a")).toBeNull(); expect(pathEl.textContent).toBe(mockArtifactDetail.path); }); + + it("renders timestamp, digest, run id, lane, and explicit severity labels", () => { + const detailWithMeta: ArtifactDetail = { + ...mockArtifactDetail, + created_at: "2026-05-26T12:00:00Z", + content: { + run_id: "run-999", + lane: "contemplation_quality", + }, + }; + + render( + + ); + + // Verify timestamp + expect(screen.getByTestId("artifact-created-at")).toHaveTextContent("2026"); + + // Verify run id & lane + expect(screen.getByTestId("artifact-run-id")).toHaveTextContent("run-999"); + expect(screen.getByTestId("artifact-lane")).toHaveTextContent("contemplation_quality"); + + // Verify divergence counts with textual labels + expect(screen.getByText("Failure (breaking): 1")).toBeInTheDocument(); + expect(screen.getByText("Warning (material): 1")).toBeInTheDocument(); + expect(screen.getByText("Info (low): 1")).toBeInTheDocument(); + }); }); describe("ReplayRoute", () => { @@ -393,6 +433,82 @@ describe("W-031 Replay Theater Tests", () => { expect(await screen.findByText("What failed")).toBeInTheDocument(); expect(screen.getByText("disk read error")).toBeInTheDocument(); }); + + it("renders Selected artifact not found when selected ID returns null detail data", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.endsWith("/artifacts")) { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: mockArtifacts } }), + }); + } + if (url.endsWith("/artifacts/missing-id")) { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: null }), + }); + } + if (url.endsWith("/replay/missing-id")) { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockReplayComparison }), + }); + } + return Promise.reject(new Error("Unknown route")); + }); + vi.stubGlobal("fetch", fetchMock); + + renderWithProviders(, ["/replay?artifactId=missing-id"]); + + expect(await screen.findByText("Selected artifact not found.")).toBeInTheDocument(); + }); + + it("renders ErrorState in left pane when artifacts loading fails", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.endsWith("/artifacts")) { + return Promise.resolve({ + json: () => Promise.resolve({ + ok: false, + generated_at: "now", + error: { code: "read_error", message: "Failed to read artifacts index" }, + }), + }); + } + return Promise.reject(new Error("Unknown route")); + }); + vi.stubGlobal("fetch", fetchMock); + + renderWithProviders(, ["/replay"]); + + const errorElements = await screen.findAllByText("Failed to read artifacts index"); + expect(errorElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("No corpus mutation occurred.").length).toBeGreaterThanOrEqual(1); + }); + + it("renders Back to proposal link only when fromProposal query parameter is present", () => { + const { unmount } = renderWithProviders( + , + ["/replay?artifactId=art-trace-1"] + ); + + expect(screen.queryByTestId("back-to-proposal")).not.toBeInTheDocument(); + unmount(); + + renderWithProviders( + , + ["/replay?artifactId=art-trace-1&fromProposal=proposal-777"] + ); + + const link = screen.getByTestId("back-to-proposal"); + expect(link).toBeInTheDocument(); + expect(link).toHaveTextContent("Back to proposal #proposal-777"); + expect(link.getAttribute("href")).toBe("/proposals?proposal_id=proposal-777"); + }); }); describe("Anti-motion & Animation Constraints", () => {