diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx
index c62c60b3..5b26701b 100644
--- a/workbench-ui/src/app/App.tsx
+++ b/workbench-ui/src/app/App.tsx
@@ -22,10 +22,10 @@ export function App() {
}>
} />
} />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/workbench-ui/src/app/RightInspector.test.tsx b/workbench-ui/src/app/RightInspector.test.tsx
index 621ad62e..2b8cbb52 100644
--- a/workbench-ui/src/app/RightInspector.test.tsx
+++ b/workbench-ui/src/app/RightInspector.test.tsx
@@ -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 ;
+ }
+ render(
+
+
+ ,
+ );
+ 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 (
+ <>
+
+
+ >
+ );
+ }
+ render(
+
+
+ ,
+ );
+
+ 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();
+ }
+ });
});
diff --git a/workbench-ui/src/app/RightInspector.tsx b/workbench-ui/src/app/RightInspector.tsx
index 6c246cc2..2676cdee 100644
--- a/workbench-ui/src/app/RightInspector.tsx
+++ b/workbench-ui/src/app/RightInspector.tsx
@@ -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 (
+
+ Detail not loaded in this session. Open the subject's route to load
+ its evidence.
+
+ );
+}
+
function TurnInspector({ subject }: { subject: Extract }) {
const { data } = subject;
+ if (!data) {
+ return (
+
+
Turn #{subject.turnId}
+
+
+ );
+ }
return (
Turn #{subject.turnId}
@@ -48,6 +68,15 @@ function TurnInspector({ subject }: { subject: Extract
}) {
const { data } = subject;
+ if (!data) {
+ return (
+
+
Proposal
+
{subject.proposalId}
+
+
+ );
+ }
return (
Proposal
@@ -66,6 +95,15 @@ function ProposalInspector({ subject }: { subject: Extract
}) {
const { data } = subject;
+ if (!data) {
+ return (
+
+
Artifact
+
{subject.artifactId}
+
+
+ );
+ }
return (
Artifact
@@ -85,6 +123,14 @@ function ArtifactInspector({ subject }: { subject: Extract
}) {
const { data } = subject;
+ if (!data) {
+ return (
+
+
Eval: {subject.lane}
+
+
+ );
+ }
return (
Eval: {data.lane}
@@ -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 (
diff --git a/workbench-ui/src/app/Shell.tsx b/workbench-ui/src/app/Shell.tsx
index 0740812c..80cd91b8 100644
--- a/workbench-ui/src/app/Shell.tsx
+++ b/workbench-ui/src/app/Shell.tsx
@@ -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 (
+
);
diff --git a/workbench-ui/src/app/evals/EvalsRoute.tsx b/workbench-ui/src/app/evals/EvalsRoute.tsx
index 13fc2bca..7f504ab9 100644
--- a/workbench-ui/src/app/evals/EvalsRoute.tsx
+++ b/workbench-ui/src/app/evals/EvalsRoute.tsx
@@ -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 ;
}
@@ -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)}
/>
))
) : (
diff --git a/workbench-ui/src/app/evals/evals.test.tsx b/workbench-ui/src/app/evals/evals.test.tsx
index eaddb874..b6aa28a0 100644
--- a/workbench-ui/src/app/evals/evals.test.tsx
+++ b/workbench-ui/src/app/evals/evals.test.tsx
@@ -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(
-
-
+
+
+
+ } />
+
+
);
@@ -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(
-
+
+
+ } />
+
+
);
diff --git a/workbench-ui/src/app/evidenceAddress.test.ts b/workbench-ui/src/app/evidenceAddress.test.ts
new file mode 100644
index 00000000..9bcaeb86
--- /dev/null
+++ b/workbench-ui/src/app/evidenceAddress.test.ts
@@ -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 {
+ 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]>)(
+ "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);
+ });
+});
diff --git a/workbench-ui/src/app/evidenceAddress.ts b/workbench-ui/src/app/evidenceAddress.ts
new file mode 100644
index 00000000..37d640ce
--- /dev/null
+++ b/workbench-ui/src/app/evidenceAddress.ts
@@ -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/
+// proposal -> /proposals/
+// eval_result -> /evals/
+// artifact -> /replay/
+//
+// The `inspect` query param carries the inspector's subject as `:`;
+// its presence means the inspector is open on that subject.
+
+export type AddressableSubject = Exclude;
+
+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>,
+): 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>,
+ searchParams: URLSearchParams,
+): UrlSubjects {
+ return {
+ route: routeParamsToSubject(params),
+ inspect: inspectValueToSubject(searchParams.get(INSPECT_PARAM)),
+ };
+}
diff --git a/workbench-ui/src/app/evidenceContext.test.tsx b/workbench-ui/src/app/evidenceContext.test.tsx
index 68082743..ac62dbcd 100644
--- a/workbench-ui/src/app/evidenceContext.test.tsx
+++ b/workbench-ui/src/app/evidenceContext.test.tsx
@@ -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 (
{subject.kind}
+
+ {subject.kind === "proposal" ? subject.proposalId : ""}
+
{inspectorOpen ? "open" : "closed"}
+ {addressCopyCount}
);
}
@@ -99,4 +119,85 @@ describe("EvidenceContext", () => {
);
spy.mockRestore();
});
+
+ it("notifyAddressCopied increments the copy signal", () => {
+ render(
+
+
+ ,
+ );
+ 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 (
+ <>
+ {location.search}
+ {navigationType}
+ >
+ );
+}
+
+function renderWithUrl(initialEntry: string) {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+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(/^$/);
+ });
});
diff --git a/workbench-ui/src/app/evidenceContext.tsx b/workbench-ui/src/app/evidenceContext.tsx
index 6d84f0dc..cd98bc0d 100644
--- a/workbench-ui/src/app/evidenceContext.tsx
+++ b/workbench-ui/src/app/evidenceContext.tsx
@@ -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(null);
export function EvidenceProvider({ children }: { children: ReactNode }) {
const [subject, setSubjectState] = useState(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 (
{children}
diff --git a/workbench-ui/src/app/evidenceUrlSync.tsx b/workbench-ui/src/app/evidenceUrlSync.tsx
new file mode 100644
index 00000000..180a730c
--- /dev/null
+++ b/workbench-ui/src/app/evidenceUrlSync.tsx
@@ -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;
+}
diff --git a/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx b/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx
index c47a911b..94db5ed5 100644
--- a/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx
+++ b/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx
@@ -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 {location.search};
+ const navigationType = useNavigationType();
+ return (
+ <>
+ {`${location.pathname}${location.search}`}
+ {navigationType}
+ >
+ );
}
function renderRoute(initialEntry = "/proposals") {
@@ -26,9 +39,14 @@ function renderRoute(initialEntry = "/proposals") {
client={createTestQueryClient()}
>
-
- >} />
-
+
+
+ >}
+ />
+
+
,
);
@@ -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();
diff --git a/workbench-ui/src/app/proposals/ProposalsRoute.tsx b/workbench-ui/src/app/proposals/ProposalsRoute.tsx
index 03e9c4fa..540415f1 100644
--- a/workbench-ui/src/app/proposals/ProposalsRoute.tsx
+++ b/workbench-ui/src/app/proposals/ProposalsRoute.tsx
@@ -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) {
diff --git a/workbench-ui/src/app/replay/ReplayRoute.tsx b/workbench-ui/src/app/replay/ReplayRoute.tsx
index d3b137c7..28d6f252 100644
--- a/workbench-ui/src/app/replay/ReplayRoute.tsx
+++ b/workbench-ui/src/app/replay/ReplayRoute.tsx
@@ -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
diff --git a/workbench-ui/src/app/replay/replay.test.tsx b/workbench-ui/src/app/replay/replay.test.tsx
index 6ca97376..bc848e41 100644
--- a/workbench-ui/src/app/replay/replay.test.tsx
+++ b/workbench-ui/src/app/replay/replay.test.tsx
@@ -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(
+
+
+ } />
+
+ ,
+ [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 (
-
-
{searchParams.get("artifactId")}
-
{
- window.history.replaceState(
- {},
- "",
- `?artifactId=${encodeURIComponent(id)}`
- );
- // Trigger popstate so react-router notices change in this test environment
- window.dispatchEvent(new PopStateEvent("popstate"));
- }}
- />
-
- );
- }
+ 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(
+
+ );
- renderWithProviders(, ["/"]);
- 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(, ["/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(, ["/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(, ["/replay?artifactId=art-trace-1"]);
+ renderReplayRoute("/replay/art-trace-1");
expect(await screen.findByText("What failed")).toBeInTheDocument();
expect(screen.getByText("disk read error")).toBeInTheDocument();
diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx
index 0420061d..f37cf787 100644
--- a/workbench-ui/src/app/routeConformance.test.tsx
+++ b/workbench-ui/src/app/routeConformance.test.tsx
@@ -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(
- {element}
+
+
+
+
+
+
+
,
);
}
@@ -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: ,
+ 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: ,
+ 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: ,
+ 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);
});
diff --git a/workbench-ui/src/app/useGlobalKeyboard.test.tsx b/workbench-ui/src/app/useGlobalKeyboard.test.tsx
new file mode 100644
index 00000000..0d0608ed
--- /dev/null
+++ b/workbench-ui/src/app/useGlobalKeyboard.test.tsx
@@ -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[0]>) {
+ useGlobalKeyboard({
+ onTogglePalette,
+ onToggleInspector,
+ onShowHelp,
+ onCopyEvidenceLink,
+ });
+ return ;
+}
+
+function renderHarness(handlers: Partial[0]>) {
+ return render(
+
+
+ ,
+ );
+}
+
+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);
+ });
+});
diff --git a/workbench-ui/src/app/useGlobalKeyboard.ts b/workbench-ui/src/app/useGlobalKeyboard.ts
index bfe11d2c..401c8ef6 100644
--- a/workbench-ui/src/app/useGlobalKeyboard.ts
+++ b/workbench-ui/src/app/useGlobalKeyboard.ts
@@ -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]);
}