feat(workbench/replay): polish states, severity labels, metadata layout (wave 1c)
This commit is contained in:
parent
a4a24de836
commit
ac947845c7
5 changed files with 244 additions and 26 deletions
|
|
@ -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 (
|
||||
<div className="space-y-6" data-testid="replay-comparison-panel">
|
||||
{fromProposal && (
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
to={`/proposals?proposal_id=${encodeURIComponent(fromProposal)}`}
|
||||
className="text-xs text-[var(--color-link)] hover:underline inline-flex items-center gap-1 font-medium"
|
||||
data-testid="back-to-proposal"
|
||||
>
|
||||
Back to proposal #{fromProposal}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-4">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">Replay Evidence</h2>
|
||||
<ReplayStatusBadge value={status} />
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ export function ReplayDiffViewer({ divergences }: ReplayDiffViewerProps) {
|
|||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border-subtle)] pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ReplayDivergenceSeverityBadge value={div.severity as ReplayDivergenceSeverity} />
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]"
|
||||
data-testid={`severity-label-${div.severity}`}
|
||||
>
|
||||
{div.severity === "failure" ? "breaking" : div.severity === "warning" ? "material" : "low"}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[var(--color-text-secondary)] break-all select-all">
|
||||
{div.path}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-[var(--color-text-primary)]">Metadata Audit Details</h3>
|
||||
|
|
@ -22,8 +47,8 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl
|
|||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--color-border-subtle)] text-[var(--color-text-muted)]">
|
||||
<th className="pb-2 font-medium">Property</th>
|
||||
<th className="pb-2 font-medium">Value</th>
|
||||
<th className="pb-2 font-medium w-1/3">Property</th>
|
||||
<th className="pb-2 font-medium w-2/3">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--color-border-subtle)] text-[var(--color-text-secondary)]">
|
||||
|
|
@ -35,12 +60,32 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl
|
|||
<td className="py-2 font-medium">Kind</td>
|
||||
<td className="py-2">{artifact.kind}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Content Type</td>
|
||||
<td className="py-2 font-mono">{artifact.content_type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Path</td>
|
||||
<td className="py-2 font-mono" data-testid="artifact-path-text">
|
||||
{artifact.path}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Created At</td>
|
||||
<td className="py-2" data-testid="artifact-created-at">
|
||||
{timestamp || <span className="text-[var(--color-text-muted)]">None</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Artifact Digest / Trace Hash</td>
|
||||
<td className="py-2">
|
||||
{artifact.digest ? (
|
||||
<TraceHashBadge value={artifact.digest} />
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">None</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Original Hash</td>
|
||||
<td className="py-2">
|
||||
|
|
@ -62,24 +107,32 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl
|
|||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Divergences</td>
|
||||
<td className="py-2">
|
||||
<span className="inline-flex gap-2">
|
||||
<span className="text-[var(--color-review-rejected)]">
|
||||
Failure: {divergenceCounts.failure}
|
||||
</span>
|
||||
<span className="text-[var(--color-review-pending)]">
|
||||
Warning: {divergenceCounts.warning}
|
||||
</span>
|
||||
<span className="text-[var(--color-grounding-vault)]">
|
||||
Info: {divergenceCounts.info}
|
||||
</span>
|
||||
</span>
|
||||
<td className="py-2 font-medium">Run ID</td>
|
||||
<td className="py-2 font-mono" data-testid="artifact-run-id">
|
||||
{runId || <span className="text-[var(--color-text-muted)]">None</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Content Type</td>
|
||||
<td className="py-2 font-mono">{artifact.content_type}</td>
|
||||
<td className="py-2 font-medium">Lane</td>
|
||||
<td className="py-2" data-testid="artifact-lane">
|
||||
{lane || <span className="text-[var(--color-text-muted)]">None</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Divergences</td>
|
||||
<td className="py-2">
|
||||
<span className="inline-flex flex-wrap gap-x-3 gap-y-1">
|
||||
<span className="text-[var(--color-review-rejected)] font-medium">
|
||||
Failure (breaking): {divergenceCounts.failure}
|
||||
</span>
|
||||
<span className="text-[var(--color-review-pending)] font-medium">
|
||||
Warning (material): {divergenceCounts.warning}
|
||||
</span>
|
||||
<span className="text-[var(--color-grounding-vault)] font-medium">
|
||||
Info (low): {divergenceCounts.info}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<LoadingState label="Comparing artifacts..." />
|
||||
) : artifactsQuery.isError ? (
|
||||
<div className="p-2 text-xs text-[var(--color-state-danger-text)]">
|
||||
Failed to load artifacts.
|
||||
</div>
|
||||
<ErrorState
|
||||
whatFailed={
|
||||
artifactsError instanceof WorkbenchApiError
|
||||
? artifactsError.message
|
||||
: "Failed to load artifacts."
|
||||
}
|
||||
mutationStatus="No corpus mutation occurred."
|
||||
reproducer="curl -X GET /artifacts"
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
) : (
|
||||
<ArtifactList
|
||||
artifacts={artifactsQuery.data || []}
|
||||
|
|
@ -127,7 +150,12 @@ export function ReplayRoute() {
|
|||
: ReplayStatus.DIVERGED
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<EmptyState
|
||||
statement="Selected artifact not found."
|
||||
nextAction="Select a valid artifact from the list."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -209,11 +209,13 @@ describe("W-031 Replay Theater Tests", () => {
|
|||
};
|
||||
|
||||
render(
|
||||
<ReplayComparisonPanel
|
||||
artifact={mockArtifactDetail}
|
||||
comparison={eqComparison}
|
||||
status={ReplayStatus.EQUIVALENT}
|
||||
/>
|
||||
<MemoryRouter>
|
||||
<ReplayComparisonPanel
|
||||
artifact={mockArtifactDetail}
|
||||
comparison={eqComparison}
|
||||
status={ReplayStatus.EQUIVALENT}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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(<ReplayDiffViewer divergences={mockReplayComparison.divergences} />);
|
||||
|
||||
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(<ReplayDiffViewer divergences={[]} />);
|
||||
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(
|
||||
<ReplayMetadataTable
|
||||
artifact={detailWithMeta}
|
||||
comparison={mockReplayComparison}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(<ReplayRoute />, ["/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(<ReplayRoute />, ["/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(
|
||||
<ReplayComparisonPanel
|
||||
artifact={mockArtifactDetail}
|
||||
comparison={mockReplayComparison}
|
||||
status={ReplayStatus.DIVERGED}
|
||||
/>,
|
||||
["/replay?artifactId=art-trace-1"]
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("back-to-proposal")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderWithProviders(
|
||||
<ReplayComparisonPanel
|
||||
artifact={mockArtifactDetail}
|
||||
comparison={mockReplayComparison}
|
||||
status={ReplayStatus.DIVERGED}
|
||||
/>,
|
||||
["/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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue