Merge pull request #710 from AssetOverflow/feat/wb-r0c-evidence-addresses
feat(workbench): evidence addresses — deep-linkable subjects + inspector URL state
This commit is contained in:
commit
1c2f786445
18 changed files with 937 additions and 91 deletions
|
|
@ -22,10 +22,10 @@ export function App() {
|
|||
<Route path="/" element={<Shell />}>
|
||||
<Route index element={<Navigate to="/chat" replace />} />
|
||||
<Route path="chat" element={<ChatRoute />} />
|
||||
<Route path="trace" element={<TraceRoutePlaceholder />} />
|
||||
<Route path="replay" element={<ReplayRoute />} />
|
||||
<Route path="proposals" element={<ProposalsRoute />} />
|
||||
<Route path="evals" element={<EvalsRoute />} />
|
||||
<Route path="trace/:turnId?" element={<TraceRoutePlaceholder />} />
|
||||
<Route path="replay/:artifactId?" element={<ReplayRoute />} />
|
||||
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
|
||||
<Route path="evals/:laneId?" element={<EvalsRoute />} />
|
||||
<Route path="runs" element={<RunsRoutePlaceholder />} />
|
||||
<Route path="packs" element={<PacksRoutePlaceholder />} />
|
||||
<Route path="vault" element={<VaultRoutePlaceholder />} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
|
||||
import { RightInspector } from "./RightInspector";
|
||||
|
|
@ -71,4 +71,54 @@ describe("RightInspector", () => {
|
|||
renderInspector();
|
||||
expect(screen.getByText("⌘I")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an honest not-loaded state for an identity-only subject", () => {
|
||||
function SetIdentityOnly() {
|
||||
const { setSubject } = useEvidenceSubject();
|
||||
useEffect(() => {
|
||||
setSubject({ kind: "proposal", proposalId: "proposal-xyz" });
|
||||
}, [setSubject]);
|
||||
return <RightInspector />;
|
||||
}
|
||||
render(
|
||||
<EvidenceProvider>
|
||||
<SetIdentityOnly />
|
||||
</EvidenceProvider>,
|
||||
);
|
||||
expect(screen.getByText("proposal-xyz")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Detail not loaded in this session/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a transient Copied confirmation after an address copy", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
function CopySignal() {
|
||||
const { notifyAddressCopied } = useEvidenceSubject();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={notifyAddressCopied}>
|
||||
signal-copy
|
||||
</button>
|
||||
<RightInspector />
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<EvidenceProvider>
|
||||
<CopySignal />
|
||||
</EvidenceProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("address-copied")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("signal-copy"));
|
||||
expect(screen.getByTestId("address-copied")).toHaveTextContent("Copied");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(screen.queryByTestId("address-copied")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEvidenceSubject, type EvidenceSubject } from "./evidenceContext";
|
||||
import { MetadataTable } from "../design/components/MetadataTable/MetadataTable";
|
||||
import { DigestBadge } from "../design/components/DigestBadge/DigestBadge";
|
||||
|
|
@ -11,8 +12,27 @@ import {
|
|||
type NormativeClearance,
|
||||
} from "../design/components/badges";
|
||||
|
||||
// Rendered when a subject was restored from a URL but its detail has not
|
||||
// loaded in this session yet. An honest absence state, not a guess.
|
||||
function DetailNotLoaded() {
|
||||
return (
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)]">
|
||||
Detail not loaded in this session. Open the subject's route to load
|
||||
its evidence.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "turn" }> }) {
|
||||
const { data } = subject;
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Turn #{subject.turnId}</h3>
|
||||
<DetailNotLoaded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Turn #{subject.turnId}</h3>
|
||||
|
|
@ -48,6 +68,15 @@ function TurnInspector({ subject }: { subject: Extract<EvidenceSubject, { kind:
|
|||
|
||||
function ProposalInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "proposal" }> }) {
|
||||
const { data } = subject;
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Proposal</h3>
|
||||
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">{subject.proposalId}</p>
|
||||
<DetailNotLoaded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Proposal</h3>
|
||||
|
|
@ -66,6 +95,15 @@ function ProposalInspector({ subject }: { subject: Extract<EvidenceSubject, { ki
|
|||
|
||||
function ArtifactInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "artifact" }> }) {
|
||||
const { data } = subject;
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Artifact</h3>
|
||||
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">{subject.artifactId}</p>
|
||||
<DetailNotLoaded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Artifact</h3>
|
||||
|
|
@ -85,6 +123,14 @@ function ArtifactInspector({ subject }: { subject: Extract<EvidenceSubject, { ki
|
|||
|
||||
function EvalInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "eval_result" }> }) {
|
||||
const { data } = subject;
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Eval: {subject.lane}</h3>
|
||||
<DetailNotLoaded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Eval: {data.lane}</h3>
|
||||
|
|
@ -129,7 +175,21 @@ function InspectorContent() {
|
|||
}
|
||||
}
|
||||
|
||||
const COPY_FEEDBACK_MS = 2000;
|
||||
|
||||
export function RightInspector() {
|
||||
const { addressCopyCount } = useEvidenceSubject();
|
||||
const [showCopied, setShowCopied] = useState(false);
|
||||
|
||||
// Transient inline confirmation for Cmd+Shift+C; confirmation only, never
|
||||
// audit context (per ADR-0162 no auto-dismissing audit events).
|
||||
useEffect(() => {
|
||||
if (addressCopyCount === 0) return;
|
||||
setShowCopied(true);
|
||||
const timer = setTimeout(() => setShowCopied(false), COPY_FEEDBACK_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [addressCopyCount]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-region="inspector"
|
||||
|
|
@ -139,9 +199,19 @@ export function RightInspector() {
|
|||
<span className="text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
Inspector
|
||||
</span>
|
||||
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
⌘I
|
||||
</kbd>
|
||||
<span className="flex items-center gap-2">
|
||||
{showCopied && (
|
||||
<span
|
||||
data-testid="address-copied"
|
||||
className="text-[10px] font-semibold text-[var(--color-state-success-text)]"
|
||||
>
|
||||
Copied
|
||||
</span>
|
||||
)}
|
||||
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
⌘I
|
||||
</kbd>
|
||||
</span>
|
||||
</div>
|
||||
<InspectorContent />
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import { StatusFooter } from "./StatusFooter";
|
|||
import { RightInspector } from "./RightInspector";
|
||||
import { ApiErrorBoundary } from "./ApiErrorBoundary";
|
||||
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
|
||||
import { EvidenceUrlSync } from "./evidenceUrlSync";
|
||||
import { isAddressable, subjectToUrl } from "./evidenceAddress";
|
||||
import { KeyboardHelp } from "./KeyboardHelp";
|
||||
import { useGlobalKeyboard } from "./useGlobalKeyboard";
|
||||
|
||||
function ShellInner() {
|
||||
const { inspectorOpen, toggleInspector } = useEvidenceSubject();
|
||||
const { subject, inspectorOpen, toggleInspector, notifyAddressCopied } =
|
||||
useEvidenceSubject();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
|
|
@ -22,10 +25,21 @@ function ShellInner() {
|
|||
setHelpOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCopyEvidenceLink = useCallback(() => {
|
||||
if (!isAddressable(subject)) return;
|
||||
if (!navigator.clipboard?.writeText) return;
|
||||
const url = window.location.origin + subjectToUrl(subject);
|
||||
navigator.clipboard.writeText(url).then(
|
||||
() => notifyAddressCopied(),
|
||||
(err) => console.error("Evidence link copy failed:", err),
|
||||
);
|
||||
}, [subject, notifyAddressCopied]);
|
||||
|
||||
useGlobalKeyboard({
|
||||
onTogglePalette,
|
||||
onToggleInspector: toggleInspector,
|
||||
onShowHelp,
|
||||
onCopyEvidenceLink,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -75,6 +89,7 @@ function ShellInner() {
|
|||
export function Shell() {
|
||||
return (
|
||||
<EvidenceProvider>
|
||||
<EvidenceUrlSync />
|
||||
<ShellInner />
|
||||
</EvidenceProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useEvidenceSubject } from "../evidenceContext";
|
||||
import { subjectToUrl } from "../evidenceAddress";
|
||||
import { useEvalLanes } from "../../api/queries";
|
||||
import { EvalLaneCard } from "./EvalLaneCard";
|
||||
import { EvalRunButton } from "./EvalRunButton";
|
||||
|
|
@ -14,8 +16,11 @@ import { WorkbenchApiError } from "../../api/client";
|
|||
|
||||
export function EvalsRoute() {
|
||||
const { data: lanes, isLoading, isError, error } = useEvalLanes();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedLaneName = searchParams.get("lane") || "";
|
||||
const { laneId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setSubject } = useEvidenceSubject();
|
||||
const selectedLaneName = laneId ?? "";
|
||||
|
||||
// Maintain per-lane run states (pending, result, error)
|
||||
const [runStates, setRunStates] = useState<
|
||||
|
|
@ -29,6 +34,28 @@ export function EvalsRoute() {
|
|||
>
|
||||
>({});
|
||||
|
||||
const selectedRunResult = selectedLaneName
|
||||
? runStates[selectedLaneName]?.result
|
||||
: undefined;
|
||||
|
||||
// Publish the selected lane as the evidence subject: identity immediately,
|
||||
// run-result data once a run completes in this session.
|
||||
useEffect(() => {
|
||||
if (!selectedLaneName) return;
|
||||
setSubject({
|
||||
kind: "eval_result",
|
||||
lane: selectedLaneName,
|
||||
data: selectedRunResult,
|
||||
});
|
||||
}, [selectedLaneName, selectedRunResult, setSubject]);
|
||||
|
||||
function selectLane(lane: string) {
|
||||
const search = searchParams.toString();
|
||||
const path = subjectToUrl({ kind: "eval_result", lane });
|
||||
// Selection churn must not pollute history: replace, never push.
|
||||
navigate(search ? `${path}?${search}` : path, { replace: true });
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState label="Loading eval lanes..." />;
|
||||
}
|
||||
|
|
@ -59,7 +86,7 @@ export function EvalsRoute() {
|
|||
key={lane.lane}
|
||||
lane={lane}
|
||||
isSelected={lane.lane === selectedLaneName}
|
||||
onSelect={() => setSearchParams({ lane: lane.lane })}
|
||||
onSelect={() => selectLane(lane.lane)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createTestQueryClient } from "../../test/createTestQueryClient";
|
||||
import { EvidenceProvider } from "../evidenceContext";
|
||||
import { EvalLaneCard } from "./EvalLaneCard";
|
||||
import { EvalRunButton } from "./EvalRunButton";
|
||||
import { EvalMetricGrid } from "./EvalMetricGrid";
|
||||
|
|
@ -226,8 +227,12 @@ describe("W-030 Component Tests", () => {
|
|||
const client = makeClient();
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={["/evals?lane=contemplation_quality"]}>
|
||||
<EvalsRoute />
|
||||
<MemoryRouter initialEntries={["/evals/contemplation_quality"]}>
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route path="/evals/:laneId?" element={<EvalsRoute />} />
|
||||
</Routes>
|
||||
</EvidenceProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
@ -241,7 +246,9 @@ describe("W-030 Component Tests", () => {
|
|||
|
||||
// Simulate success
|
||||
expect(mutateCallback).toBeDefined();
|
||||
mutateCallback.onSuccess(mockResult);
|
||||
act(() => {
|
||||
mutateCallback.onSuccess(mockResult);
|
||||
});
|
||||
|
||||
// Result should be visible
|
||||
await waitFor(() => {
|
||||
|
|
@ -263,7 +270,11 @@ describe("W-030 Component Tests", () => {
|
|||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={["/evals"]}>
|
||||
<EvalsRoute />
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route path="/evals/:laneId?" element={<EvalsRoute />} />
|
||||
</Routes>
|
||||
</EvidenceProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
|||
192
workbench-ui/src/app/evidenceAddress.test.ts
Normal file
192
workbench-ui/src/app/evidenceAddress.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { EvidenceSubject } from "./evidenceContext";
|
||||
import {
|
||||
type AddressableSubject,
|
||||
INSPECT_PARAM,
|
||||
inspectValueToSubject,
|
||||
isAddressable,
|
||||
sameIdentity,
|
||||
subjectToInspectValue,
|
||||
subjectToUrl,
|
||||
urlToSubject,
|
||||
} from "./evidenceAddress";
|
||||
|
||||
// Simulates React Router's contribution to urlToSubject: match the path
|
||||
// against the app's route patterns and produce the params record.
|
||||
function routerParamsFor(url: string): Record<string, string | undefined> {
|
||||
const pathname = new URL(url, "http://localhost").pathname;
|
||||
const [, base, raw] = pathname.split("/");
|
||||
const segment = raw === undefined ? undefined : decodeURIComponent(raw);
|
||||
switch (base) {
|
||||
case "trace":
|
||||
return segment === undefined ? {} : { turnId: segment };
|
||||
case "proposals":
|
||||
return segment === undefined ? {} : { proposalId: segment };
|
||||
case "evals":
|
||||
return segment === undefined ? {} : { laneId: segment };
|
||||
case "replay":
|
||||
return segment === undefined ? {} : { artifactId: segment };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function roundTrip(subject: AddressableSubject) {
|
||||
const url = subjectToUrl(subject);
|
||||
const search = new URL(url, "http://localhost").searchParams;
|
||||
return urlToSubject(routerParamsFor(url), search);
|
||||
}
|
||||
|
||||
const KINDS: Array<{ name: string; subject: AddressableSubject; path: string }> = [
|
||||
{ name: "turn", subject: { kind: "turn", turnId: 42 }, path: "/trace/42" },
|
||||
{
|
||||
name: "proposal",
|
||||
subject: { kind: "proposal", proposalId: "proposal-pending-001abcdef" },
|
||||
path: "/proposals/proposal-pending-001abcdef",
|
||||
},
|
||||
{
|
||||
name: "eval_result",
|
||||
subject: { kind: "eval_result", lane: "gsm8k_math" },
|
||||
path: "/evals/gsm8k_math",
|
||||
},
|
||||
{
|
||||
name: "artifact",
|
||||
subject: { kind: "artifact", artifactId: "art-trace-1" },
|
||||
path: "/replay/art-trace-1",
|
||||
},
|
||||
];
|
||||
|
||||
describe("subjectToUrl", () => {
|
||||
it.each(KINDS)("addresses a $name subject canonically", ({ subject, path }) => {
|
||||
expect(subjectToUrl(subject)).toBe(path);
|
||||
});
|
||||
|
||||
it("percent-encodes ids containing reserved characters", () => {
|
||||
const url = subjectToUrl({ kind: "proposal", proposalId: "a/b c?d" });
|
||||
expect(url).toBe("/proposals/a%2Fb%20c%3Fd");
|
||||
});
|
||||
|
||||
it("appends ?inspect= when the inspector holds a different subject", () => {
|
||||
const url = subjectToUrl(
|
||||
{ kind: "proposal", proposalId: "abc" },
|
||||
{ kind: "turn", turnId: 7 },
|
||||
);
|
||||
expect(url).toBe("/proposals/abc?inspect=turn%3A7");
|
||||
});
|
||||
|
||||
it("omits ?inspect= for the same subject, an absent inspect, or kind none", () => {
|
||||
const subject: AddressableSubject = { kind: "proposal", proposalId: "abc" };
|
||||
expect(subjectToUrl(subject, { kind: "proposal", proposalId: "abc" })).toBe(
|
||||
"/proposals/abc",
|
||||
);
|
||||
expect(subjectToUrl(subject, null)).toBe("/proposals/abc");
|
||||
expect(subjectToUrl(subject, { kind: "none" })).toBe("/proposals/abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("urlToSubject round-trip", () => {
|
||||
it.each(KINDS)("recovers a $name subject from its URL", ({ subject }) => {
|
||||
const { route, inspect } = roundTrip(subject);
|
||||
expect(route).not.toBeNull();
|
||||
expect(sameIdentity(route!, subject)).toBe(true);
|
||||
expect(inspect).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers ids containing reserved characters", () => {
|
||||
const subject: AddressableSubject = {
|
||||
kind: "artifact",
|
||||
artifactId: "art/with space:colon",
|
||||
};
|
||||
const { route } = roundTrip(subject);
|
||||
expect(route).toEqual({ kind: "artifact", artifactId: "art/with space:colon" });
|
||||
});
|
||||
|
||||
it("recovers both route and inspect subjects from a full address", () => {
|
||||
const url = subjectToUrl(
|
||||
{ kind: "eval_result", lane: "gsm8k_math" },
|
||||
{ kind: "proposal", proposalId: "abc" },
|
||||
);
|
||||
const search = new URL(url, "http://localhost").searchParams;
|
||||
const { route, inspect } = urlToSubject(routerParamsFor(url), search);
|
||||
expect(route).toEqual({ kind: "eval_result", lane: "gsm8k_math" });
|
||||
expect(inspect).toEqual({ kind: "proposal", proposalId: "abc" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("urlToSubject malformed input", () => {
|
||||
it.each([
|
||||
["non-numeric turn id", { turnId: "abc" }],
|
||||
["negative turn id", { turnId: "-1" }],
|
||||
["fractional turn id", { turnId: "1.5" }],
|
||||
["overflowing turn id", { turnId: "99999999999999999999" }],
|
||||
["empty proposal id", { proposalId: "" }],
|
||||
["empty lane id", { laneId: "" }],
|
||||
["empty artifact id", { artifactId: "" }],
|
||||
["no params at all", {}],
|
||||
] as Array<[string, Record<string, string | undefined>]>)(
|
||||
"returns route null for %s",
|
||||
(_label, params) => {
|
||||
const { route } = urlToSubject(params, new URLSearchParams());
|
||||
expect(route).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["garbage", "garbage"],
|
||||
["unknown kind", "bogus:1"],
|
||||
["empty id", "proposal:"],
|
||||
["empty kind", ":abc"],
|
||||
["non-numeric turn", "turn:abc"],
|
||||
["empty value", ""],
|
||||
])("returns inspect null for %s", (_label, value) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set(INSPECT_PARAM, value);
|
||||
const { inspect } = urlToSubject({}, params);
|
||||
expect(inspect).toBeNull();
|
||||
});
|
||||
|
||||
it("never throws on arbitrary junk", () => {
|
||||
const junk = ["::::", "turn:", "🦊", "a".repeat(10_000), "%%%"];
|
||||
for (const value of junk) {
|
||||
const params = new URLSearchParams();
|
||||
params.set(INSPECT_PARAM, value);
|
||||
expect(() => urlToSubject({ turnId: value }, params)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspect value codec", () => {
|
||||
it.each(KINDS)("round-trips a $name inspect value", ({ subject }) => {
|
||||
const recovered = inspectValueToSubject(subjectToInspectValue(subject));
|
||||
expect(recovered).not.toBeNull();
|
||||
expect(sameIdentity(recovered!, subject)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves ids containing colons (splits on the first only)", () => {
|
||||
expect(inspectValueToSubject("proposal:a:b")).toEqual({
|
||||
kind: "proposal",
|
||||
proposalId: "a:b",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for null input", () => {
|
||||
expect(inspectValueToSubject(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAddressable / sameIdentity", () => {
|
||||
it("treats none as not addressable", () => {
|
||||
expect(isAddressable({ kind: "none" })).toBe(false);
|
||||
expect(isAddressable({ kind: "turn", turnId: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("compares identity, ignoring loaded data", () => {
|
||||
const a: EvidenceSubject = { kind: "turn", turnId: 1 };
|
||||
const b: EvidenceSubject = { kind: "turn", turnId: 1 };
|
||||
const c: EvidenceSubject = { kind: "turn", turnId: 2 };
|
||||
expect(sameIdentity(a, b)).toBe(true);
|
||||
expect(sameIdentity(a, c)).toBe(false);
|
||||
expect(sameIdentity(a, { kind: "proposal", proposalId: "1" })).toBe(false);
|
||||
expect(sameIdentity({ kind: "none" }, { kind: "none" })).toBe(true);
|
||||
});
|
||||
});
|
||||
151
workbench-ui/src/app/evidenceAddress.ts
Normal file
151
workbench-ui/src/app/evidenceAddress.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import type { EvidenceSubject } from "./evidenceContext";
|
||||
|
||||
// Evidence address codec: URL = subject. This file owns the entire grammar —
|
||||
// every EvidenceSubject kind is addressable here, including kinds whose routes
|
||||
// are still placeholders (the grammar is fixed once; routes grow into it).
|
||||
//
|
||||
// turn -> /trace/<turnId>
|
||||
// proposal -> /proposals/<proposalId>
|
||||
// eval_result -> /evals/<laneId>
|
||||
// artifact -> /replay/<artifactId>
|
||||
//
|
||||
// The `inspect` query param carries the inspector's subject as `<kind>:<id>`;
|
||||
// its presence means the inspector is open on that subject.
|
||||
|
||||
export type AddressableSubject = Exclude<EvidenceSubject, { kind: "none" }>;
|
||||
|
||||
export const INSPECT_PARAM = "inspect";
|
||||
|
||||
export function isAddressable(
|
||||
subject: EvidenceSubject,
|
||||
): subject is AddressableSubject {
|
||||
return subject.kind !== "none";
|
||||
}
|
||||
|
||||
export function sameIdentity(a: EvidenceSubject, b: EvidenceSubject): boolean {
|
||||
switch (a.kind) {
|
||||
case "turn":
|
||||
return b.kind === "turn" && b.turnId === a.turnId;
|
||||
case "proposal":
|
||||
return b.kind === "proposal" && b.proposalId === a.proposalId;
|
||||
case "eval_result":
|
||||
return b.kind === "eval_result" && b.lane === a.lane;
|
||||
case "artifact":
|
||||
return b.kind === "artifact" && b.artifactId === a.artifactId;
|
||||
case "none":
|
||||
return b.kind === "none";
|
||||
}
|
||||
}
|
||||
|
||||
function subjectPath(subject: AddressableSubject): string {
|
||||
switch (subject.kind) {
|
||||
case "turn":
|
||||
return `/trace/${subject.turnId}`;
|
||||
case "proposal":
|
||||
return `/proposals/${encodeURIComponent(subject.proposalId)}`;
|
||||
case "eval_result":
|
||||
return `/evals/${encodeURIComponent(subject.lane)}`;
|
||||
case "artifact":
|
||||
return `/replay/${encodeURIComponent(subject.artifactId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function subjectToInspectValue(subject: AddressableSubject): string {
|
||||
switch (subject.kind) {
|
||||
case "turn":
|
||||
return `turn:${subject.turnId}`;
|
||||
case "proposal":
|
||||
return `proposal:${subject.proposalId}`;
|
||||
case "eval_result":
|
||||
return `eval_result:${subject.lane}`;
|
||||
case "artifact":
|
||||
return `artifact:${subject.artifactId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function subjectToUrl(
|
||||
subject: AddressableSubject,
|
||||
inspect?: EvidenceSubject | null,
|
||||
): string {
|
||||
const path = subjectPath(subject);
|
||||
if (!inspect || !isAddressable(inspect) || sameIdentity(subject, inspect)) {
|
||||
return path;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set(INSPECT_PARAM, subjectToInspectValue(inspect));
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseTurnId(raw: string): number | null {
|
||||
if (!/^\d+$/.test(raw)) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
export function inspectValueToSubject(
|
||||
value: string | null,
|
||||
): AddressableSubject | null {
|
||||
if (value === null) return null;
|
||||
const sep = value.indexOf(":");
|
||||
if (sep <= 0 || sep === value.length - 1) return null;
|
||||
const kind = value.slice(0, sep);
|
||||
const id = value.slice(sep + 1);
|
||||
switch (kind) {
|
||||
case "turn": {
|
||||
const turnId = parseTurnId(id);
|
||||
return turnId === null ? null : { kind: "turn", turnId };
|
||||
}
|
||||
case "proposal":
|
||||
return { kind: "proposal", proposalId: id };
|
||||
case "eval_result":
|
||||
return { kind: "eval_result", lane: id };
|
||||
case "artifact":
|
||||
return { kind: "artifact", artifactId: id };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function routeParamsToSubject(
|
||||
params: Readonly<Record<string, string | undefined>>,
|
||||
): AddressableSubject | null {
|
||||
// React Router populates exactly one of these keys per matched route;
|
||||
// precedence below only matters for hand-built (malformed) inputs.
|
||||
if (params.turnId !== undefined) {
|
||||
const turnId = parseTurnId(params.turnId);
|
||||
return turnId === null ? null : { kind: "turn", turnId };
|
||||
}
|
||||
if (params.proposalId !== undefined) {
|
||||
return params.proposalId === ""
|
||||
? null
|
||||
: { kind: "proposal", proposalId: params.proposalId };
|
||||
}
|
||||
if (params.laneId !== undefined) {
|
||||
return params.laneId === ""
|
||||
? null
|
||||
: { kind: "eval_result", lane: params.laneId };
|
||||
}
|
||||
if (params.artifactId !== undefined) {
|
||||
return params.artifactId === ""
|
||||
? null
|
||||
: { kind: "artifact", artifactId: params.artifactId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface UrlSubjects {
|
||||
route: AddressableSubject | null;
|
||||
inspect: AddressableSubject | null;
|
||||
}
|
||||
|
||||
// Total inverse of subjectToUrl: malformed input yields null, never throws.
|
||||
// Returned subjects carry identity only — `data` loads via the route's query.
|
||||
export function urlToSubject(
|
||||
params: Readonly<Record<string, string | undefined>>,
|
||||
searchParams: URLSearchParams,
|
||||
): UrlSubjects {
|
||||
return {
|
||||
route: routeParamsToSubject(params),
|
||||
inspect: inspectValueToSubject(searchParams.get(INSPECT_PARAM)),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
MemoryRouter,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from "react-router-dom";
|
||||
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
|
||||
import { EvidenceUrlSync } from "./evidenceUrlSync";
|
||||
import type { ChatTurnResult } from "../types/api";
|
||||
|
||||
const MOCK_TURN: ChatTurnResult = {
|
||||
|
|
@ -24,12 +30,23 @@ const MOCK_TURN: ChatTurnResult = {
|
|||
};
|
||||
|
||||
function TestConsumer() {
|
||||
const { subject, setSubject, clearSubject, inspectorOpen, toggleInspector } =
|
||||
useEvidenceSubject();
|
||||
const {
|
||||
subject,
|
||||
setSubject,
|
||||
clearSubject,
|
||||
inspectorOpen,
|
||||
toggleInspector,
|
||||
addressCopyCount,
|
||||
notifyAddressCopied,
|
||||
} = useEvidenceSubject();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="kind">{subject.kind}</span>
|
||||
<span data-testid="subject-id">
|
||||
{subject.kind === "proposal" ? subject.proposalId : ""}
|
||||
</span>
|
||||
<span data-testid="inspector">{inspectorOpen ? "open" : "closed"}</span>
|
||||
<span data-testid="copy-count">{addressCopyCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubject({ kind: "turn", turnId: 1, data: MOCK_TURN })}
|
||||
|
|
@ -42,6 +59,9 @@ function TestConsumer() {
|
|||
<button type="button" onClick={toggleInspector}>
|
||||
toggle
|
||||
</button>
|
||||
<button type="button" onClick={notifyAddressCopied}>
|
||||
notify-copied
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -99,4 +119,85 @@ describe("EvidenceContext", () => {
|
|||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("notifyAddressCopied increments the copy signal", () => {
|
||||
render(
|
||||
<EvidenceProvider>
|
||||
<TestConsumer />
|
||||
</EvidenceProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("copy-count")).toHaveTextContent("0");
|
||||
fireEvent.click(screen.getByText("notify-copied"));
|
||||
fireEvent.click(screen.getByText("notify-copied"));
|
||||
expect(screen.getByTestId("copy-count")).toHaveTextContent("2");
|
||||
});
|
||||
});
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
return (
|
||||
<>
|
||||
<span data-testid="search">{location.search}</span>
|
||||
<span data-testid="nav-type">{navigationType}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithUrl(initialEntry: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<EvidenceProvider>
|
||||
<EvidenceUrlSync />
|
||||
<TestConsumer />
|
||||
<LocationProbe />
|
||||
</EvidenceProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("EvidenceUrlSync", () => {
|
||||
it("restores the inspector subject and open state from ?inspect=", () => {
|
||||
renderWithUrl("/proposals/abc?inspect=proposal:abc");
|
||||
expect(screen.getByTestId("kind")).toHaveTextContent("proposal");
|
||||
expect(screen.getByTestId("subject-id")).toHaveTextContent("abc");
|
||||
expect(screen.getByTestId("inspector")).toHaveTextContent("open");
|
||||
});
|
||||
|
||||
it("drops a malformed ?inspect= from the URL and stays closed", async () => {
|
||||
renderWithUrl("/proposals?inspect=garbage");
|
||||
expect(screen.getByTestId("kind")).toHaveTextContent("none");
|
||||
expect(screen.getByTestId("inspector")).toHaveTextContent("closed");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("search")).toHaveTextContent(/^$/),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes ?inspect= when the inspector opens on a subject, using replace", async () => {
|
||||
renderWithUrl("/chat");
|
||||
fireEvent.click(screen.getByText("set-turn"));
|
||||
fireEvent.click(screen.getByText("toggle"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("search")).toHaveTextContent("?inspect=turn%3A1"),
|
||||
);
|
||||
expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE");
|
||||
});
|
||||
|
||||
it("removes ?inspect= when the inspector closes", async () => {
|
||||
renderWithUrl("/trace/1?inspect=turn:1");
|
||||
expect(screen.getByTestId("inspector")).toHaveTextContent("open");
|
||||
fireEvent.click(screen.getByText("toggle"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("search")).toHaveTextContent(/^$/),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not write ?inspect= while the inspector stays closed", async () => {
|
||||
renderWithUrl("/chat");
|
||||
fireEvent.click(screen.getByText("set-turn"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("kind")).toHaveTextContent("turn"),
|
||||
);
|
||||
expect(screen.getByTestId("search")).toHaveTextContent(/^$/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ import type {
|
|||
EvalRunResult,
|
||||
} from "../types/api";
|
||||
|
||||
// `data` is optional: a subject restored from a URL carries identity only
|
||||
// until the owning route's query loads its detail. Inspectors must render
|
||||
// an honest "detail not loaded" state when data is absent.
|
||||
export type EvidenceSubject =
|
||||
| { kind: "turn"; turnId: number; data: ChatTurnResult }
|
||||
| { kind: "proposal"; proposalId: string; data: ProposalDetail }
|
||||
| { kind: "artifact"; artifactId: string; data: ArtifactDetail }
|
||||
| { kind: "eval_result"; lane: string; data: EvalRunResult }
|
||||
| { kind: "turn"; turnId: number; data?: ChatTurnResult }
|
||||
| { kind: "proposal"; proposalId: string; data?: ProposalDetail }
|
||||
| { kind: "artifact"; artifactId: string; data?: ArtifactDetail }
|
||||
| { kind: "eval_result"; lane: string; data?: EvalRunResult }
|
||||
| { kind: "none" };
|
||||
|
||||
interface EvidenceContextValue {
|
||||
|
|
@ -26,6 +29,8 @@ interface EvidenceContextValue {
|
|||
inspectorOpen: boolean;
|
||||
setInspectorOpen: (open: boolean) => void;
|
||||
toggleInspector: () => void;
|
||||
addressCopyCount: number;
|
||||
notifyAddressCopied: () => void;
|
||||
}
|
||||
|
||||
const NONE_SUBJECT: EvidenceSubject = { kind: "none" };
|
||||
|
|
@ -35,6 +40,7 @@ const EvidenceContext = createContext<EvidenceContextValue | null>(null);
|
|||
export function EvidenceProvider({ children }: { children: ReactNode }) {
|
||||
const [subject, setSubjectState] = useState<EvidenceSubject>(NONE_SUBJECT);
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [addressCopyCount, setAddressCopyCount] = useState(0);
|
||||
|
||||
const setSubject = useCallback((s: EvidenceSubject) => {
|
||||
setSubjectState(s);
|
||||
|
|
@ -48,6 +54,10 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|||
setInspectorOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const notifyAddressCopied = useCallback(() => {
|
||||
setAddressCopyCount((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EvidenceContext.Provider
|
||||
value={{
|
||||
|
|
@ -57,6 +67,8 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|||
inspectorOpen,
|
||||
setInspectorOpen,
|
||||
toggleInspector,
|
||||
addressCopyCount,
|
||||
notifyAddressCopied,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
58
workbench-ui/src/app/evidenceUrlSync.tsx
Normal file
58
workbench-ui/src/app/evidenceUrlSync.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useEvidenceSubject } from "./evidenceContext";
|
||||
import {
|
||||
INSPECT_PARAM,
|
||||
inspectValueToSubject,
|
||||
isAddressable,
|
||||
subjectToInspectValue,
|
||||
} from "./evidenceAddress";
|
||||
|
||||
// Keeps `?inspect=` and the evidence context in sync.
|
||||
// On first mount a valid `?inspect=` deep link restores the inspector
|
||||
// (identity-only subject; the owning route loads detail). After that the
|
||||
// URL follows state: param present iff the inspector is open on an
|
||||
// addressable subject. All writes use replace — selection churn must not
|
||||
// pollute history. A malformed `?inspect=` is dropped from the URL.
|
||||
export function EvidenceUrlSync() {
|
||||
const { subject, setSubject, inspectorOpen, setInspectorOpen } =
|
||||
useEvidenceSubject();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current) {
|
||||
restoredRef.current = true;
|
||||
const restored = inspectValueToSubject(searchParams.get(INSPECT_PARAM));
|
||||
if (restored) {
|
||||
setSubject(restored);
|
||||
setInspectorOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const desired =
|
||||
inspectorOpen && isAddressable(subject)
|
||||
? subjectToInspectValue(subject)
|
||||
: null;
|
||||
const current = searchParams.get(INSPECT_PARAM);
|
||||
if (desired === current) return;
|
||||
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (desired === null) {
|
||||
next.delete(INSPECT_PARAM);
|
||||
} else {
|
||||
next.set(INSPECT_PARAM, desired);
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [
|
||||
subject,
|
||||
inspectorOpen,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
setSubject,
|
||||
setInspectorOpen,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -2,10 +2,17 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||
import { createTestQueryClient } from "../../test/createTestQueryClient";
|
||||
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 {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} 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 { EvidenceProvider } from "../evidenceContext";
|
||||
import { SuggestedCLIBox } from "./SuggestedCLIBox";
|
||||
import { ProposalTable } from "./ProposalTable";
|
||||
import { ProposalsRoute } from "./ProposalsRoute";
|
||||
|
|
@ -17,7 +24,13 @@ function queryWrapper({ children }: { children: ReactNode }) {
|
|||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <span data-testid="location">{location.search}</span>;
|
||||
const navigationType = useNavigationType();
|
||||
return (
|
||||
<>
|
||||
<span data-testid="location">{`${location.pathname}${location.search}`}</span>
|
||||
<span data-testid="nav-type">{navigationType}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRoute(initialEntry = "/proposals") {
|
||||
|
|
@ -26,9 +39,14 @@ function renderRoute(initialEntry = "/proposals") {
|
|||
client={createTestQueryClient()}
|
||||
>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/proposals" element={<><ProposalsRoute /><LocationProbe /></>} />
|
||||
</Routes>
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/proposals/:proposalId?"
|
||||
element={<><ProposalsRoute /><LocationProbe /></>}
|
||||
/>
|
||||
</Routes>
|
||||
</EvidenceProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
|
@ -87,7 +105,7 @@ describe("ProposalsRoute", () => {
|
|||
expect(screen.queryByTitle("proposal-pending-001abcdef")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a row updates the URL query param and renders detail", async () => {
|
||||
it("selecting a row writes the path param (replace) and renders detail", async () => {
|
||||
stubProposalFetch();
|
||||
const user = userEvent.setup();
|
||||
renderRoute("/proposals?state=pending");
|
||||
|
|
@ -96,12 +114,22 @@ describe("ProposalsRoute", () => {
|
|||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("location")).toHaveTextContent(
|
||||
`?state=pending&proposal_id=${proposalDetail.proposal_id}`,
|
||||
`/proposals/${proposalDetail.proposal_id}?state=pending`,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE");
|
||||
expect(await screen.findByText("Contemplation proposed a coherence relation.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores selection from a deep-linked path param", async () => {
|
||||
stubProposalFetch();
|
||||
renderRoute(`/proposals/${proposalDetail.proposal_id}?state=pending`);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Contemplation proposed a coherence relation."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows LoadingState during fetch", () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
|
||||
renderRoute();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEvidenceSubject } from "../evidenceContext";
|
||||
import { subjectToUrl } from "../evidenceAddress";
|
||||
import { WorkbenchApiError, type ProposalStateFilter } from "../../api/client";
|
||||
import { useProposalDetail, useProposals, useMathProposals, useMathProposalDetail } from "../../api/queries";
|
||||
import type { ProposalSummary, MathProposalDetail, ProposalState, DownstreamEffect, MathReasoningStep } from "../../types/api";
|
||||
|
|
@ -33,8 +35,10 @@ function errorMessage(error: unknown) {
|
|||
}
|
||||
|
||||
export function ProposalsRoute() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedFromUrl = searchParams.get("proposal_id");
|
||||
const { proposalId: selectedFromUrl } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setSubject } = useEvidenceSubject();
|
||||
const filterFromUrl = searchParams.get("state");
|
||||
const domainFromUrl = searchParams.get("domain");
|
||||
|
||||
|
|
@ -113,11 +117,24 @@ export function ProposalsRoute() {
|
|||
setFocusedIndex(0);
|
||||
}, [domain, filter]);
|
||||
|
||||
// Publish the selected proposal as the evidence subject: identity
|
||||
// immediately, detail once the query resolves. Math proposals have a
|
||||
// distinct detail shape not carried by EvidenceSubject; they stay
|
||||
// route-local.
|
||||
useEffect(() => {
|
||||
if (!selectedProposalId || domain !== "cognition") return;
|
||||
setSubject({
|
||||
kind: "proposal",
|
||||
proposalId: selectedProposalId,
|
||||
data: cognitionDetailQuery.data,
|
||||
});
|
||||
}, [selectedProposalId, domain, cognitionDetailQuery.data, setSubject]);
|
||||
|
||||
function updateRoute(next: { proposalId?: string | null; state?: ProposalStateFilter; domain?: "math" | "cognition" }) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const nextDomain = next.domain ?? domain;
|
||||
const nextState = next.state ?? filter;
|
||||
|
||||
|
||||
if (nextDomain === "math") {
|
||||
params.set("domain", "math");
|
||||
} else {
|
||||
|
|
@ -125,12 +142,14 @@ export function ProposalsRoute() {
|
|||
}
|
||||
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 });
|
||||
const nextProposalId =
|
||||
next.proposalId === null ? null : (next.proposalId ?? selectedProposalId);
|
||||
const path = nextProposalId
|
||||
? subjectToUrl({ kind: "proposal", proposalId: nextProposalId })
|
||||
: "/proposals";
|
||||
const search = params.toString();
|
||||
// Selection churn must not pollute history: replace, never push.
|
||||
navigate(search ? `${path}?${search}` : path, { replace: true });
|
||||
}
|
||||
|
||||
function changeFilter(nextFilter: ProposalStateFilter) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { useSearchParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useEvidenceSubject } from "../evidenceContext";
|
||||
import { subjectToUrl } from "../evidenceAddress";
|
||||
import { useArtifacts, useArtifactDetail, useReplayComparison } from "../../api/queries";
|
||||
import { ArtifactList } from "./ArtifactList";
|
||||
import { ReplayComparisonPanel } from "./ReplayComparisonPanel";
|
||||
|
|
@ -9,15 +12,29 @@ import { WorkbenchApiError } from "../../api/client";
|
|||
import { ReplayStatus } from "../../design/components/badges";
|
||||
|
||||
export function ReplayRoute() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get("artifactId");
|
||||
const { artifactId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setSubject } = useEvidenceSubject();
|
||||
const selectedId = artifactId ?? null;
|
||||
|
||||
const artifactsQuery = useArtifacts();
|
||||
const detailQuery = useArtifactDetail(selectedId || "");
|
||||
const comparisonQuery = useReplayComparison(selectedId || "");
|
||||
|
||||
// Publish the selected artifact as the evidence subject: identity
|
||||
// immediately, detail once the query resolves.
|
||||
const detailData = detailQuery.data;
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
setSubject({ kind: "artifact", artifactId: selectedId, data: detailData });
|
||||
}, [selectedId, detailData, setSubject]);
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setSearchParams({ artifactId: id });
|
||||
const search = searchParams.toString();
|
||||
const path = subjectToUrl({ kind: "artifact", artifactId: id });
|
||||
// Selection churn must not pollute history: replace, never push.
|
||||
navigate(search ? `${path}?${search}` : path, { replace: true });
|
||||
}
|
||||
|
||||
// Handle loading states
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createTestQueryClient } from "../../test/createTestQueryClient";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { EvidenceProvider } from "../evidenceContext";
|
||||
import { ArtifactList } from "./ArtifactList";
|
||||
import { ReplayStatusBadge, ReplayStatus } from "../../design/components/badges";
|
||||
import { ReplayComparisonPanel } from "./ReplayComparisonPanel";
|
||||
|
|
@ -103,6 +104,20 @@ function renderWithProviders(ui: React.ReactElement, initialEntries = ["/"]) {
|
|||
);
|
||||
}
|
||||
|
||||
// ReplayRoute reads its selection from the :artifactId path param and
|
||||
// publishes the selected artifact as the evidence subject, so it needs the
|
||||
// real route declaration and an EvidenceProvider.
|
||||
function renderReplayRoute(initialEntry: string) {
|
||||
return renderWithProviders(
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route path="/replay/:artifactId?" element={<ReplayRoute />} />
|
||||
</Routes>
|
||||
</EvidenceProvider>,
|
||||
[initialEntry],
|
||||
);
|
||||
}
|
||||
|
||||
describe("W-031 Replay Theater Tests", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -141,39 +156,20 @@ describe("W-031 Replay Theater Tests", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("selecting a row updates the URL query param", async () => {
|
||||
function HelperRoute() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const artifactsQuery = mockArtifacts;
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="url-param">{searchParams.get("artifactId")}</div>
|
||||
<ArtifactList
|
||||
artifacts={artifactsQuery}
|
||||
selectedId={searchParams.get("artifactId")}
|
||||
onSelect={(id) => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`?artifactId=${encodeURIComponent(id)}`
|
||||
);
|
||||
// Trigger popstate so react-router notices change in this test environment
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
it("selecting a row invokes onSelect with the artifact id", () => {
|
||||
// URL writing is ReplayRoute's job (path param, replace) — covered by
|
||||
// the route tests below. ArtifactList only reports the selection.
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<ArtifactList
|
||||
artifacts={mockArtifacts}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
renderWithProviders(<HelperRoute />, ["/"]);
|
||||
const btn = screen.getByTestId("artifact-art-trace-1");
|
||||
fireEvent.click(btn);
|
||||
|
||||
// Verify that the query parameter was updated
|
||||
await waitFor(() => {
|
||||
const url = new URL(window.location.href);
|
||||
expect(url.searchParams.get("artifactId")).toBe("art-trace-1");
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("artifact-art-trace-1"));
|
||||
expect(onSelect).toHaveBeenCalledWith("art-trace-1");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -318,7 +314,7 @@ describe("W-031 Replay Theater Tests", () => {
|
|||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderWithProviders(<ReplayRoute />, ["/replay?artifactId=art-trace-1"]);
|
||||
renderReplayRoute("/replay/art-trace-1");
|
||||
|
||||
// Should load list, detail, and comparison
|
||||
expect(await screen.findByText("Replay Evidence")).toBeInTheDocument();
|
||||
|
|
@ -352,7 +348,7 @@ describe("W-031 Replay Theater Tests", () => {
|
|||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderWithProviders(<ReplayRoute />, ["/replay?artifactId=art-trace-1"]);
|
||||
renderReplayRoute("/replay/art-trace-1");
|
||||
|
||||
expect(await screen.findByText("evidence_unavailable")).toBeInTheDocument();
|
||||
expect(screen.getByText("Replay evidence is not available on the backend for this artifact kind.")).toBeInTheDocument();
|
||||
|
|
@ -384,7 +380,7 @@ describe("W-031 Replay Theater Tests", () => {
|
|||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderWithProviders(<ReplayRoute />, ["/replay?artifactId=art-trace-1"]);
|
||||
renderReplayRoute("/replay/art-trace-1");
|
||||
|
||||
expect(await screen.findByText("What failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("disk read error")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactElement } from "react";
|
||||
import { createTestQueryClient } from "../test/createTestQueryClient";
|
||||
import { EvidenceProvider } from "./evidenceContext";
|
||||
import { ChatRoute } from "../routes/ChatRoute";
|
||||
import { ProposalsRoute } from "./proposals/ProposalsRoute";
|
||||
import { EvalsRoute } from "./evals/EvalsRoute";
|
||||
|
|
@ -55,10 +56,19 @@ function installFetch(plan: FetchPlan) {
|
|||
);
|
||||
}
|
||||
|
||||
function renderRoute(element: ReactElement) {
|
||||
// Routes mount the way App.tsx declares them: under their (param) path and
|
||||
// inside EvidenceProvider, since routes publish their selection as the
|
||||
// evidence subject (R0c).
|
||||
function renderRoute(element: ReactElement, path = "/", initialEntry = "/") {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter>{element}</MemoryRouter>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route path={path} element={element} />
|
||||
</Routes>
|
||||
</EvidenceProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
|
@ -86,6 +96,8 @@ afterEach(() => {
|
|||
interface MountRouteSpec {
|
||||
name: string;
|
||||
element: ReactElement;
|
||||
path: string;
|
||||
initialEntry: string;
|
||||
loadingLabel: string;
|
||||
emptyStatement: string;
|
||||
emptyCommand: string;
|
||||
|
|
@ -95,6 +107,8 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
|
|||
{
|
||||
name: "Proposals",
|
||||
element: <ProposalsRoute />,
|
||||
path: "/proposals/:proposalId?",
|
||||
initialEntry: "/proposals",
|
||||
loadingLabel: "Loading proposal queue...",
|
||||
emptyStatement: "No proposals match this queue view.",
|
||||
emptyCommand: "core teaching proposals --state pending",
|
||||
|
|
@ -102,6 +116,8 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
|
|||
{
|
||||
name: "Evals",
|
||||
element: <EvalsRoute />,
|
||||
path: "/evals/:laneId?",
|
||||
initialEntry: "/evals",
|
||||
loadingLabel: "Loading eval lanes...",
|
||||
emptyStatement: "No eval lanes discovered.",
|
||||
emptyCommand: "core eval --list",
|
||||
|
|
@ -109,6 +125,8 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
|
|||
{
|
||||
name: "Replay",
|
||||
element: <ReplayRoute />,
|
||||
path: "/replay/:artifactId?",
|
||||
initialEntry: "/replay",
|
||||
loadingLabel: "Loading artifacts...",
|
||||
emptyStatement: "No artifacts available.",
|
||||
emptyCommand: "core eval cognition",
|
||||
|
|
@ -118,20 +136,20 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
|
|||
describe.each(MOUNT_ROUTES)("route conformance: $name", (spec) => {
|
||||
it("loading: shows a specific label, never 'Thinking...'", async () => {
|
||||
installFetch("pending");
|
||||
renderRoute(spec.element);
|
||||
renderRoute(spec.element, spec.path, spec.initialEntry);
|
||||
expect(await screen.findByText(spec.loadingLabel)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/thinking/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("error: surfaces what failed, mutation status, reproducer, retry safety", async () => {
|
||||
installFetch("error");
|
||||
renderRoute(spec.element);
|
||||
renderRoute(spec.element, spec.path, spec.initialEntry);
|
||||
await expectErrorContract();
|
||||
});
|
||||
|
||||
it("empty: states what is absent and offers a next action", async () => {
|
||||
installFetch("empty");
|
||||
renderRoute(spec.element);
|
||||
renderRoute(spec.element, spec.path, spec.initialEntry);
|
||||
expect((await screen.findAllByText(spec.emptyStatement)).length).toBeGreaterThan(0);
|
||||
expectEmptyContract(spec.emptyStatement, spec.emptyCommand);
|
||||
});
|
||||
|
|
|
|||
72
workbench-ui/src/app/useGlobalKeyboard.test.tsx
Normal file
72
workbench-ui/src/app/useGlobalKeyboard.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useGlobalKeyboard } from "./useGlobalKeyboard";
|
||||
|
||||
function Harness({
|
||||
onTogglePalette = () => {},
|
||||
onToggleInspector = () => {},
|
||||
onShowHelp = () => {},
|
||||
onCopyEvidenceLink = () => {},
|
||||
}: Partial<Parameters<typeof useGlobalKeyboard>[0]>) {
|
||||
useGlobalKeyboard({
|
||||
onTogglePalette,
|
||||
onToggleInspector,
|
||||
onShowHelp,
|
||||
onCopyEvidenceLink,
|
||||
});
|
||||
return <input data-testid="field" />;
|
||||
}
|
||||
|
||||
function renderHarness(handlers: Partial<Parameters<typeof useGlobalKeyboard>[0]>) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Harness {...handlers} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("useGlobalKeyboard Cmd+Shift+C", () => {
|
||||
afterEach(() => {
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it("invokes onCopyEvidenceLink, not the palette", () => {
|
||||
const onCopyEvidenceLink = vi.fn();
|
||||
const onTogglePalette = vi.fn();
|
||||
renderHarness({ onCopyEvidenceLink, onTogglePalette });
|
||||
|
||||
fireEvent.keyDown(window, { key: "C", metaKey: true, shiftKey: true });
|
||||
|
||||
expect(onCopyEvidenceLink).toHaveBeenCalledTimes(1);
|
||||
expect(onTogglePalette).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire while an input is focused", () => {
|
||||
const onCopyEvidenceLink = vi.fn();
|
||||
const { getByTestId } = renderHarness({ onCopyEvidenceLink });
|
||||
|
||||
getByTestId("field").focus();
|
||||
fireEvent.keyDown(window, { key: "C", metaKey: true, shiftKey: true });
|
||||
|
||||
expect(onCopyEvidenceLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire on Cmd+C without Shift", () => {
|
||||
const onCopyEvidenceLink = vi.fn();
|
||||
renderHarness({ onCopyEvidenceLink });
|
||||
|
||||
fireEvent.keyDown(window, { key: "c", metaKey: true });
|
||||
|
||||
expect(onCopyEvidenceLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Cmd+K still toggles the palette", () => {
|
||||
const onTogglePalette = vi.fn();
|
||||
renderHarness({ onTogglePalette });
|
||||
|
||||
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
||||
|
||||
expect(onTogglePalette).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -18,6 +18,7 @@ interface GlobalKeyboardOptions {
|
|||
onTogglePalette: () => void;
|
||||
onToggleInspector: () => void;
|
||||
onShowHelp: () => void;
|
||||
onCopyEvidenceLink: () => void;
|
||||
}
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
|
|
@ -31,6 +32,7 @@ export function useGlobalKeyboard({
|
|||
onTogglePalette,
|
||||
onToggleInspector,
|
||||
onShowHelp,
|
||||
onCopyEvidenceLink,
|
||||
}: GlobalKeyboardOptions) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -38,6 +40,13 @@ export function useGlobalKeyboard({
|
|||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
|
||||
if (meta && e.shiftKey && e.key.toLowerCase() === "c") {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
onCopyEvidenceLink();
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
onTogglePalette();
|
||||
|
|
@ -67,5 +76,5 @@ export function useGlobalKeyboard({
|
|||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [navigate, onTogglePalette, onToggleInspector, onShowHelp]);
|
||||
}, [navigate, onTogglePalette, onToggleInspector, onShowHelp, onCopyEvidenceLink]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue