From 41f4435dc1d8d9785d0360aea4f0341fc269a4e9 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 12:12:00 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(workbench):=20evidence=20addresses=20?= =?UTF-8?q?=E2=80=94=20deep-linkable=20subjects=20+=20inspector=20URL=20st?= =?UTF-8?q?ate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL = subject: one codec (evidenceAddress.ts) owns the address grammar for every EvidenceSubject kind, including kinds whose routes are placeholders. urlToSubject is a total inverse — malformed input yields null, never throws. - Route params: /trace/:turnId?, /proposals/:proposalId?, /evals/:laneId?, /replay/:artifactId? (placeholders keep flat paths) - ?inspect= carries inspector subject + open state; EvidenceUrlSync restores deep links and write-through syncs with replace (selection churn never pollutes history); malformed values are dropped from the URL - Proposals/Evals/Replay restore selection from their param on load, write it on selection change (replace), and publish the selected subject to the evidence context (identity at once, detail when the query resolves) - EvidenceSubject.data now optional; inspectors render an honest 'detail not loaded' state for identity-only subjects - Cmd+Shift+C copies origin + subjectToUrl(subject); no-op without subject; input-focus guard; transient inline 'Copied' on the inspector header - Fix pre-existing unbounded re-render loop in RightInspector.test.tsx (render-phase setSubject); subjects now set from effects --- workbench-ui/src/app/App.tsx | 8 +- workbench-ui/src/app/RightInspector.test.tsx | 52 ++++- workbench-ui/src/app/RightInspector.tsx | 76 ++++++- workbench-ui/src/app/Shell.tsx | 17 +- workbench-ui/src/app/evals/EvalsRoute.tsx | 37 +++- workbench-ui/src/app/evals/evals.test.tsx | 23 ++- workbench-ui/src/app/evidenceAddress.test.ts | 192 ++++++++++++++++++ workbench-ui/src/app/evidenceAddress.ts | 151 ++++++++++++++ workbench-ui/src/app/evidenceContext.test.tsx | 107 +++++++++- workbench-ui/src/app/evidenceContext.tsx | 20 +- workbench-ui/src/app/evidenceUrlSync.tsx | 58 ++++++ .../src/app/proposals/ProposalsRoute.test.tsx | 42 +++- .../src/app/proposals/ProposalsRoute.tsx | 39 +++- workbench-ui/src/app/replay/ReplayRoute.tsx | 25 ++- workbench-ui/src/app/replay/replay.test.tsx | 68 +++---- .../src/app/useGlobalKeyboard.test.tsx | 72 +++++++ workbench-ui/src/app/useGlobalKeyboard.ts | 11 +- 17 files changed, 913 insertions(+), 85 deletions(-) create mode 100644 workbench-ui/src/app/evidenceAddress.test.ts create mode 100644 workbench-ui/src/app/evidenceAddress.ts create mode 100644 workbench-ui/src/app/evidenceUrlSync.tsx create mode 100644 workbench-ui/src/app/useGlobalKeyboard.test.tsx 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/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]); } From 922e700048d7bb65d47facc3d4317731a2147de9 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 12:16:32 -0700 Subject: [PATCH 2/2] test(workbench): mount conformance routes with param paths + EvidenceProvider Routes now publish selection as the evidence subject and read selection from path params (R0c), so the ADR-0162 conformance harness mounts them the way App.tsx declares them. --- .../src/app/routeConformance.test.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) 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); });