Merge pull request #710 from AssetOverflow/feat/wb-r0c-evidence-addresses

feat(workbench): evidence addresses — deep-linkable subjects + inspector URL state
This commit is contained in:
Shay 2026-06-12 12:27:53 -07:00 committed by GitHub
commit 1c2f786445
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 937 additions and 91 deletions

View file

@ -22,10 +22,10 @@ export function App() {
<Route path="/" element={<Shell />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route path="chat" element={<ChatRoute />} />
<Route path="trace" element={<TraceRoutePlaceholder />} />
<Route path="replay" element={<ReplayRoute />} />
<Route path="proposals" element={<ProposalsRoute />} />
<Route path="evals" element={<EvalsRoute />} />
<Route path="trace/:turnId?" element={<TraceRoutePlaceholder />} />
<Route path="replay/:artifactId?" element={<ReplayRoute />} />
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
<Route path="evals/:laneId?" element={<EvalsRoute />} />
<Route path="runs" element={<RunsRoutePlaceholder />} />
<Route path="packs" element={<PacksRoutePlaceholder />} />
<Route path="vault" element={<VaultRoutePlaceholder />} />

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useEffect } from "react";
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
import { RightInspector } from "./RightInspector";
@ -71,4 +71,54 @@ describe("RightInspector", () => {
renderInspector();
expect(screen.getByText("⌘I")).toBeInTheDocument();
});
it("renders an honest not-loaded state for an identity-only subject", () => {
function SetIdentityOnly() {
const { setSubject } = useEvidenceSubject();
useEffect(() => {
setSubject({ kind: "proposal", proposalId: "proposal-xyz" });
}, [setSubject]);
return <RightInspector />;
}
render(
<EvidenceProvider>
<SetIdentityOnly />
</EvidenceProvider>,
);
expect(screen.getByText("proposal-xyz")).toBeInTheDocument();
expect(screen.getByText(/Detail not loaded in this session/)).toBeInTheDocument();
});
it("shows a transient Copied confirmation after an address copy", () => {
vi.useFakeTimers();
try {
function CopySignal() {
const { notifyAddressCopied } = useEvidenceSubject();
return (
<>
<button type="button" onClick={notifyAddressCopied}>
signal-copy
</button>
<RightInspector />
</>
);
}
render(
<EvidenceProvider>
<CopySignal />
</EvidenceProvider>,
);
expect(screen.queryByTestId("address-copied")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("signal-copy"));
expect(screen.getByTestId("address-copied")).toHaveTextContent("Copied");
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.queryByTestId("address-copied")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});

View file

@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useEvidenceSubject, type EvidenceSubject } from "./evidenceContext";
import { MetadataTable } from "../design/components/MetadataTable/MetadataTable";
import { DigestBadge } from "../design/components/DigestBadge/DigestBadge";
@ -11,8 +12,27 @@ import {
type NormativeClearance,
} from "../design/components/badges";
// Rendered when a subject was restored from a URL but its detail has not
// loaded in this session yet. An honest absence state, not a guess.
function DetailNotLoaded() {
return (
<p className="m-0 text-xs text-[var(--color-text-muted)]">
Detail not loaded in this session. Open the subject&apos;s route to load
its evidence.
</p>
);
}
function TurnInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "turn" }> }) {
const { data } = subject;
if (!data) {
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Turn #{subject.turnId}</h3>
<DetailNotLoaded />
</div>
);
}
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Turn #{subject.turnId}</h3>
@ -48,6 +68,15 @@ function TurnInspector({ subject }: { subject: Extract<EvidenceSubject, { kind:
function ProposalInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "proposal" }> }) {
const { data } = subject;
if (!data) {
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Proposal</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">{subject.proposalId}</p>
<DetailNotLoaded />
</div>
);
}
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Proposal</h3>
@ -66,6 +95,15 @@ function ProposalInspector({ subject }: { subject: Extract<EvidenceSubject, { ki
function ArtifactInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "artifact" }> }) {
const { data } = subject;
if (!data) {
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Artifact</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">{subject.artifactId}</p>
<DetailNotLoaded />
</div>
);
}
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Artifact</h3>
@ -85,6 +123,14 @@ function ArtifactInspector({ subject }: { subject: Extract<EvidenceSubject, { ki
function EvalInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "eval_result" }> }) {
const { data } = subject;
if (!data) {
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Eval: {subject.lane}</h3>
<DetailNotLoaded />
</div>
);
}
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Eval: {data.lane}</h3>
@ -129,7 +175,21 @@ function InspectorContent() {
}
}
const COPY_FEEDBACK_MS = 2000;
export function RightInspector() {
const { addressCopyCount } = useEvidenceSubject();
const [showCopied, setShowCopied] = useState(false);
// Transient inline confirmation for Cmd+Shift+C; confirmation only, never
// audit context (per ADR-0162 no auto-dismissing audit events).
useEffect(() => {
if (addressCopyCount === 0) return;
setShowCopied(true);
const timer = setTimeout(() => setShowCopied(false), COPY_FEEDBACK_MS);
return () => clearTimeout(timer);
}, [addressCopyCount]);
return (
<aside
data-region="inspector"
@ -139,9 +199,19 @@ export function RightInspector() {
<span className="text-xs font-semibold text-[var(--color-text-secondary)]">
Inspector
</span>
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 text-[10px] text-[var(--color-text-muted)]">
I
</kbd>
<span className="flex items-center gap-2">
{showCopied && (
<span
data-testid="address-copied"
className="text-[10px] font-semibold text-[var(--color-state-success-text)]"
>
Copied
</span>
)}
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 text-[10px] text-[var(--color-text-muted)]">
I
</kbd>
</span>
</div>
<InspectorContent />
</aside>

View file

@ -6,11 +6,14 @@ import { StatusFooter } from "./StatusFooter";
import { RightInspector } from "./RightInspector";
import { ApiErrorBoundary } from "./ApiErrorBoundary";
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
import { EvidenceUrlSync } from "./evidenceUrlSync";
import { isAddressable, subjectToUrl } from "./evidenceAddress";
import { KeyboardHelp } from "./KeyboardHelp";
import { useGlobalKeyboard } from "./useGlobalKeyboard";
function ShellInner() {
const { inspectorOpen, toggleInspector } = useEvidenceSubject();
const { subject, inspectorOpen, toggleInspector, notifyAddressCopied } =
useEvidenceSubject();
const [helpOpen, setHelpOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
@ -22,10 +25,21 @@ function ShellInner() {
setHelpOpen(true);
}, []);
const onCopyEvidenceLink = useCallback(() => {
if (!isAddressable(subject)) return;
if (!navigator.clipboard?.writeText) return;
const url = window.location.origin + subjectToUrl(subject);
navigator.clipboard.writeText(url).then(
() => notifyAddressCopied(),
(err) => console.error("Evidence link copy failed:", err),
);
}, [subject, notifyAddressCopied]);
useGlobalKeyboard({
onTogglePalette,
onToggleInspector: toggleInspector,
onShowHelp,
onCopyEvidenceLink,
});
return (
@ -75,6 +89,7 @@ function ShellInner() {
export function Shell() {
return (
<EvidenceProvider>
<EvidenceUrlSync />
<ShellInner />
</EvidenceProvider>
);

View file

@ -1,5 +1,7 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useEvidenceSubject } from "../evidenceContext";
import { subjectToUrl } from "../evidenceAddress";
import { useEvalLanes } from "../../api/queries";
import { EvalLaneCard } from "./EvalLaneCard";
import { EvalRunButton } from "./EvalRunButton";
@ -14,8 +16,11 @@ import { WorkbenchApiError } from "../../api/client";
export function EvalsRoute() {
const { data: lanes, isLoading, isError, error } = useEvalLanes();
const [searchParams, setSearchParams] = useSearchParams();
const selectedLaneName = searchParams.get("lane") || "";
const { laneId } = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setSubject } = useEvidenceSubject();
const selectedLaneName = laneId ?? "";
// Maintain per-lane run states (pending, result, error)
const [runStates, setRunStates] = useState<
@ -29,6 +34,28 @@ export function EvalsRoute() {
>
>({});
const selectedRunResult = selectedLaneName
? runStates[selectedLaneName]?.result
: undefined;
// Publish the selected lane as the evidence subject: identity immediately,
// run-result data once a run completes in this session.
useEffect(() => {
if (!selectedLaneName) return;
setSubject({
kind: "eval_result",
lane: selectedLaneName,
data: selectedRunResult,
});
}, [selectedLaneName, selectedRunResult, setSubject]);
function selectLane(lane: string) {
const search = searchParams.toString();
const path = subjectToUrl({ kind: "eval_result", lane });
// Selection churn must not pollute history: replace, never push.
navigate(search ? `${path}?${search}` : path, { replace: true });
}
if (isLoading) {
return <LoadingState label="Loading eval lanes..." />;
}
@ -59,7 +86,7 @@ export function EvalsRoute() {
key={lane.lane}
lane={lane}
isSelected={lane.lane === selectedLaneName}
onSelect={() => setSearchParams({ lane: lane.lane })}
onSelect={() => selectLane(lane.lane)}
/>
))
) : (

View file

@ -1,8 +1,9 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTestQueryClient } from "../../test/createTestQueryClient";
import { EvidenceProvider } from "../evidenceContext";
import { EvalLaneCard } from "./EvalLaneCard";
import { EvalRunButton } from "./EvalRunButton";
import { EvalMetricGrid } from "./EvalMetricGrid";
@ -226,8 +227,12 @@ describe("W-030 Component Tests", () => {
const client = makeClient();
render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={["/evals?lane=contemplation_quality"]}>
<EvalsRoute />
<MemoryRouter initialEntries={["/evals/contemplation_quality"]}>
<EvidenceProvider>
<Routes>
<Route path="/evals/:laneId?" element={<EvalsRoute />} />
</Routes>
</EvidenceProvider>
</MemoryRouter>
</QueryClientProvider>
);
@ -241,7 +246,9 @@ describe("W-030 Component Tests", () => {
// Simulate success
expect(mutateCallback).toBeDefined();
mutateCallback.onSuccess(mockResult);
act(() => {
mutateCallback.onSuccess(mockResult);
});
// Result should be visible
await waitFor(() => {
@ -263,7 +270,11 @@ describe("W-030 Component Tests", () => {
render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={["/evals"]}>
<EvalsRoute />
<EvidenceProvider>
<Routes>
<Route path="/evals/:laneId?" element={<EvalsRoute />} />
</Routes>
</EvidenceProvider>
</MemoryRouter>
</QueryClientProvider>
);

View file

@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import type { EvidenceSubject } from "./evidenceContext";
import {
type AddressableSubject,
INSPECT_PARAM,
inspectValueToSubject,
isAddressable,
sameIdentity,
subjectToInspectValue,
subjectToUrl,
urlToSubject,
} from "./evidenceAddress";
// Simulates React Router's contribution to urlToSubject: match the path
// against the app's route patterns and produce the params record.
function routerParamsFor(url: string): Record<string, string | undefined> {
const pathname = new URL(url, "http://localhost").pathname;
const [, base, raw] = pathname.split("/");
const segment = raw === undefined ? undefined : decodeURIComponent(raw);
switch (base) {
case "trace":
return segment === undefined ? {} : { turnId: segment };
case "proposals":
return segment === undefined ? {} : { proposalId: segment };
case "evals":
return segment === undefined ? {} : { laneId: segment };
case "replay":
return segment === undefined ? {} : { artifactId: segment };
default:
return {};
}
}
function roundTrip(subject: AddressableSubject) {
const url = subjectToUrl(subject);
const search = new URL(url, "http://localhost").searchParams;
return urlToSubject(routerParamsFor(url), search);
}
const KINDS: Array<{ name: string; subject: AddressableSubject; path: string }> = [
{ name: "turn", subject: { kind: "turn", turnId: 42 }, path: "/trace/42" },
{
name: "proposal",
subject: { kind: "proposal", proposalId: "proposal-pending-001abcdef" },
path: "/proposals/proposal-pending-001abcdef",
},
{
name: "eval_result",
subject: { kind: "eval_result", lane: "gsm8k_math" },
path: "/evals/gsm8k_math",
},
{
name: "artifact",
subject: { kind: "artifact", artifactId: "art-trace-1" },
path: "/replay/art-trace-1",
},
];
describe("subjectToUrl", () => {
it.each(KINDS)("addresses a $name subject canonically", ({ subject, path }) => {
expect(subjectToUrl(subject)).toBe(path);
});
it("percent-encodes ids containing reserved characters", () => {
const url = subjectToUrl({ kind: "proposal", proposalId: "a/b c?d" });
expect(url).toBe("/proposals/a%2Fb%20c%3Fd");
});
it("appends ?inspect= when the inspector holds a different subject", () => {
const url = subjectToUrl(
{ kind: "proposal", proposalId: "abc" },
{ kind: "turn", turnId: 7 },
);
expect(url).toBe("/proposals/abc?inspect=turn%3A7");
});
it("omits ?inspect= for the same subject, an absent inspect, or kind none", () => {
const subject: AddressableSubject = { kind: "proposal", proposalId: "abc" };
expect(subjectToUrl(subject, { kind: "proposal", proposalId: "abc" })).toBe(
"/proposals/abc",
);
expect(subjectToUrl(subject, null)).toBe("/proposals/abc");
expect(subjectToUrl(subject, { kind: "none" })).toBe("/proposals/abc");
});
});
describe("urlToSubject round-trip", () => {
it.each(KINDS)("recovers a $name subject from its URL", ({ subject }) => {
const { route, inspect } = roundTrip(subject);
expect(route).not.toBeNull();
expect(sameIdentity(route!, subject)).toBe(true);
expect(inspect).toBeNull();
});
it("recovers ids containing reserved characters", () => {
const subject: AddressableSubject = {
kind: "artifact",
artifactId: "art/with space:colon",
};
const { route } = roundTrip(subject);
expect(route).toEqual({ kind: "artifact", artifactId: "art/with space:colon" });
});
it("recovers both route and inspect subjects from a full address", () => {
const url = subjectToUrl(
{ kind: "eval_result", lane: "gsm8k_math" },
{ kind: "proposal", proposalId: "abc" },
);
const search = new URL(url, "http://localhost").searchParams;
const { route, inspect } = urlToSubject(routerParamsFor(url), search);
expect(route).toEqual({ kind: "eval_result", lane: "gsm8k_math" });
expect(inspect).toEqual({ kind: "proposal", proposalId: "abc" });
});
});
describe("urlToSubject malformed input", () => {
it.each([
["non-numeric turn id", { turnId: "abc" }],
["negative turn id", { turnId: "-1" }],
["fractional turn id", { turnId: "1.5" }],
["overflowing turn id", { turnId: "99999999999999999999" }],
["empty proposal id", { proposalId: "" }],
["empty lane id", { laneId: "" }],
["empty artifact id", { artifactId: "" }],
["no params at all", {}],
] as Array<[string, Record<string, string | undefined>]>)(
"returns route null for %s",
(_label, params) => {
const { route } = urlToSubject(params, new URLSearchParams());
expect(route).toBeNull();
},
);
it.each([
["garbage", "garbage"],
["unknown kind", "bogus:1"],
["empty id", "proposal:"],
["empty kind", ":abc"],
["non-numeric turn", "turn:abc"],
["empty value", ""],
])("returns inspect null for %s", (_label, value) => {
const params = new URLSearchParams();
params.set(INSPECT_PARAM, value);
const { inspect } = urlToSubject({}, params);
expect(inspect).toBeNull();
});
it("never throws on arbitrary junk", () => {
const junk = ["::::", "turn:", "🦊", "a".repeat(10_000), "%%%"];
for (const value of junk) {
const params = new URLSearchParams();
params.set(INSPECT_PARAM, value);
expect(() => urlToSubject({ turnId: value }, params)).not.toThrow();
}
});
});
describe("inspect value codec", () => {
it.each(KINDS)("round-trips a $name inspect value", ({ subject }) => {
const recovered = inspectValueToSubject(subjectToInspectValue(subject));
expect(recovered).not.toBeNull();
expect(sameIdentity(recovered!, subject)).toBe(true);
});
it("preserves ids containing colons (splits on the first only)", () => {
expect(inspectValueToSubject("proposal:a:b")).toEqual({
kind: "proposal",
proposalId: "a:b",
});
});
it("returns null for null input", () => {
expect(inspectValueToSubject(null)).toBeNull();
});
});
describe("isAddressable / sameIdentity", () => {
it("treats none as not addressable", () => {
expect(isAddressable({ kind: "none" })).toBe(false);
expect(isAddressable({ kind: "turn", turnId: 1 })).toBe(true);
});
it("compares identity, ignoring loaded data", () => {
const a: EvidenceSubject = { kind: "turn", turnId: 1 };
const b: EvidenceSubject = { kind: "turn", turnId: 1 };
const c: EvidenceSubject = { kind: "turn", turnId: 2 };
expect(sameIdentity(a, b)).toBe(true);
expect(sameIdentity(a, c)).toBe(false);
expect(sameIdentity(a, { kind: "proposal", proposalId: "1" })).toBe(false);
expect(sameIdentity({ kind: "none" }, { kind: "none" })).toBe(true);
});
});

View file

@ -0,0 +1,151 @@
import type { EvidenceSubject } from "./evidenceContext";
// Evidence address codec: URL = subject. This file owns the entire grammar —
// every EvidenceSubject kind is addressable here, including kinds whose routes
// are still placeholders (the grammar is fixed once; routes grow into it).
//
// turn -> /trace/<turnId>
// proposal -> /proposals/<proposalId>
// eval_result -> /evals/<laneId>
// artifact -> /replay/<artifactId>
//
// The `inspect` query param carries the inspector's subject as `<kind>:<id>`;
// its presence means the inspector is open on that subject.
export type AddressableSubject = Exclude<EvidenceSubject, { kind: "none" }>;
export const INSPECT_PARAM = "inspect";
export function isAddressable(
subject: EvidenceSubject,
): subject is AddressableSubject {
return subject.kind !== "none";
}
export function sameIdentity(a: EvidenceSubject, b: EvidenceSubject): boolean {
switch (a.kind) {
case "turn":
return b.kind === "turn" && b.turnId === a.turnId;
case "proposal":
return b.kind === "proposal" && b.proposalId === a.proposalId;
case "eval_result":
return b.kind === "eval_result" && b.lane === a.lane;
case "artifact":
return b.kind === "artifact" && b.artifactId === a.artifactId;
case "none":
return b.kind === "none";
}
}
function subjectPath(subject: AddressableSubject): string {
switch (subject.kind) {
case "turn":
return `/trace/${subject.turnId}`;
case "proposal":
return `/proposals/${encodeURIComponent(subject.proposalId)}`;
case "eval_result":
return `/evals/${encodeURIComponent(subject.lane)}`;
case "artifact":
return `/replay/${encodeURIComponent(subject.artifactId)}`;
}
}
export function subjectToInspectValue(subject: AddressableSubject): string {
switch (subject.kind) {
case "turn":
return `turn:${subject.turnId}`;
case "proposal":
return `proposal:${subject.proposalId}`;
case "eval_result":
return `eval_result:${subject.lane}`;
case "artifact":
return `artifact:${subject.artifactId}`;
}
}
export function subjectToUrl(
subject: AddressableSubject,
inspect?: EvidenceSubject | null,
): string {
const path = subjectPath(subject);
if (!inspect || !isAddressable(inspect) || sameIdentity(subject, inspect)) {
return path;
}
const params = new URLSearchParams();
params.set(INSPECT_PARAM, subjectToInspectValue(inspect));
return `${path}?${params.toString()}`;
}
function parseTurnId(raw: string): number | null {
if (!/^\d+$/.test(raw)) return null;
const value = Number(raw);
return Number.isSafeInteger(value) ? value : null;
}
export function inspectValueToSubject(
value: string | null,
): AddressableSubject | null {
if (value === null) return null;
const sep = value.indexOf(":");
if (sep <= 0 || sep === value.length - 1) return null;
const kind = value.slice(0, sep);
const id = value.slice(sep + 1);
switch (kind) {
case "turn": {
const turnId = parseTurnId(id);
return turnId === null ? null : { kind: "turn", turnId };
}
case "proposal":
return { kind: "proposal", proposalId: id };
case "eval_result":
return { kind: "eval_result", lane: id };
case "artifact":
return { kind: "artifact", artifactId: id };
default:
return null;
}
}
function routeParamsToSubject(
params: Readonly<Record<string, string | undefined>>,
): AddressableSubject | null {
// React Router populates exactly one of these keys per matched route;
// precedence below only matters for hand-built (malformed) inputs.
if (params.turnId !== undefined) {
const turnId = parseTurnId(params.turnId);
return turnId === null ? null : { kind: "turn", turnId };
}
if (params.proposalId !== undefined) {
return params.proposalId === ""
? null
: { kind: "proposal", proposalId: params.proposalId };
}
if (params.laneId !== undefined) {
return params.laneId === ""
? null
: { kind: "eval_result", lane: params.laneId };
}
if (params.artifactId !== undefined) {
return params.artifactId === ""
? null
: { kind: "artifact", artifactId: params.artifactId };
}
return null;
}
export interface UrlSubjects {
route: AddressableSubject | null;
inspect: AddressableSubject | null;
}
// Total inverse of subjectToUrl: malformed input yields null, never throws.
// Returned subjects carry identity only — `data` loads via the route's query.
export function urlToSubject(
params: Readonly<Record<string, string | undefined>>,
searchParams: URLSearchParams,
): UrlSubjects {
return {
route: routeParamsToSubject(params),
inspect: inspectValueToSubject(searchParams.get(INSPECT_PARAM)),
};
}

View file

@ -1,5 +1,11 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import {
MemoryRouter,
useLocation,
useNavigationType,
} from "react-router-dom";
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
import { EvidenceUrlSync } from "./evidenceUrlSync";
import type { ChatTurnResult } from "../types/api";
const MOCK_TURN: ChatTurnResult = {
@ -24,12 +30,23 @@ const MOCK_TURN: ChatTurnResult = {
};
function TestConsumer() {
const { subject, setSubject, clearSubject, inspectorOpen, toggleInspector } =
useEvidenceSubject();
const {
subject,
setSubject,
clearSubject,
inspectorOpen,
toggleInspector,
addressCopyCount,
notifyAddressCopied,
} = useEvidenceSubject();
return (
<div>
<span data-testid="kind">{subject.kind}</span>
<span data-testid="subject-id">
{subject.kind === "proposal" ? subject.proposalId : ""}
</span>
<span data-testid="inspector">{inspectorOpen ? "open" : "closed"}</span>
<span data-testid="copy-count">{addressCopyCount}</span>
<button
type="button"
onClick={() => setSubject({ kind: "turn", turnId: 1, data: MOCK_TURN })}
@ -42,6 +59,9 @@ function TestConsumer() {
<button type="button" onClick={toggleInspector}>
toggle
</button>
<button type="button" onClick={notifyAddressCopied}>
notify-copied
</button>
</div>
);
}
@ -99,4 +119,85 @@ describe("EvidenceContext", () => {
);
spy.mockRestore();
});
it("notifyAddressCopied increments the copy signal", () => {
render(
<EvidenceProvider>
<TestConsumer />
</EvidenceProvider>,
);
expect(screen.getByTestId("copy-count")).toHaveTextContent("0");
fireEvent.click(screen.getByText("notify-copied"));
fireEvent.click(screen.getByText("notify-copied"));
expect(screen.getByTestId("copy-count")).toHaveTextContent("2");
});
});
function LocationProbe() {
const location = useLocation();
const navigationType = useNavigationType();
return (
<>
<span data-testid="search">{location.search}</span>
<span data-testid="nav-type">{navigationType}</span>
</>
);
}
function renderWithUrl(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<EvidenceProvider>
<EvidenceUrlSync />
<TestConsumer />
<LocationProbe />
</EvidenceProvider>
</MemoryRouter>,
);
}
describe("EvidenceUrlSync", () => {
it("restores the inspector subject and open state from ?inspect=", () => {
renderWithUrl("/proposals/abc?inspect=proposal:abc");
expect(screen.getByTestId("kind")).toHaveTextContent("proposal");
expect(screen.getByTestId("subject-id")).toHaveTextContent("abc");
expect(screen.getByTestId("inspector")).toHaveTextContent("open");
});
it("drops a malformed ?inspect= from the URL and stays closed", async () => {
renderWithUrl("/proposals?inspect=garbage");
expect(screen.getByTestId("kind")).toHaveTextContent("none");
expect(screen.getByTestId("inspector")).toHaveTextContent("closed");
await waitFor(() =>
expect(screen.getByTestId("search")).toHaveTextContent(/^$/),
);
});
it("writes ?inspect= when the inspector opens on a subject, using replace", async () => {
renderWithUrl("/chat");
fireEvent.click(screen.getByText("set-turn"));
fireEvent.click(screen.getByText("toggle"));
await waitFor(() =>
expect(screen.getByTestId("search")).toHaveTextContent("?inspect=turn%3A1"),
);
expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE");
});
it("removes ?inspect= when the inspector closes", async () => {
renderWithUrl("/trace/1?inspect=turn:1");
expect(screen.getByTestId("inspector")).toHaveTextContent("open");
fireEvent.click(screen.getByText("toggle"));
await waitFor(() =>
expect(screen.getByTestId("search")).toHaveTextContent(/^$/),
);
});
it("does not write ?inspect= while the inspector stays closed", async () => {
renderWithUrl("/chat");
fireEvent.click(screen.getByText("set-turn"));
await waitFor(() =>
expect(screen.getByTestId("kind")).toHaveTextContent("turn"),
);
expect(screen.getByTestId("search")).toHaveTextContent(/^$/);
});
});

View file

@ -12,11 +12,14 @@ import type {
EvalRunResult,
} from "../types/api";
// `data` is optional: a subject restored from a URL carries identity only
// until the owning route's query loads its detail. Inspectors must render
// an honest "detail not loaded" state when data is absent.
export type EvidenceSubject =
| { kind: "turn"; turnId: number; data: ChatTurnResult }
| { kind: "proposal"; proposalId: string; data: ProposalDetail }
| { kind: "artifact"; artifactId: string; data: ArtifactDetail }
| { kind: "eval_result"; lane: string; data: EvalRunResult }
| { kind: "turn"; turnId: number; data?: ChatTurnResult }
| { kind: "proposal"; proposalId: string; data?: ProposalDetail }
| { kind: "artifact"; artifactId: string; data?: ArtifactDetail }
| { kind: "eval_result"; lane: string; data?: EvalRunResult }
| { kind: "none" };
interface EvidenceContextValue {
@ -26,6 +29,8 @@ interface EvidenceContextValue {
inspectorOpen: boolean;
setInspectorOpen: (open: boolean) => void;
toggleInspector: () => void;
addressCopyCount: number;
notifyAddressCopied: () => void;
}
const NONE_SUBJECT: EvidenceSubject = { kind: "none" };
@ -35,6 +40,7 @@ const EvidenceContext = createContext<EvidenceContextValue | null>(null);
export function EvidenceProvider({ children }: { children: ReactNode }) {
const [subject, setSubjectState] = useState<EvidenceSubject>(NONE_SUBJECT);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [addressCopyCount, setAddressCopyCount] = useState(0);
const setSubject = useCallback((s: EvidenceSubject) => {
setSubjectState(s);
@ -48,6 +54,10 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
setInspectorOpen((prev) => !prev);
}, []);
const notifyAddressCopied = useCallback(() => {
setAddressCopyCount((prev) => prev + 1);
}, []);
return (
<EvidenceContext.Provider
value={{
@ -57,6 +67,8 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
inspectorOpen,
setInspectorOpen,
toggleInspector,
addressCopyCount,
notifyAddressCopied,
}}
>
{children}

View file

@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { useEvidenceSubject } from "./evidenceContext";
import {
INSPECT_PARAM,
inspectValueToSubject,
isAddressable,
subjectToInspectValue,
} from "./evidenceAddress";
// Keeps `?inspect=` and the evidence context in sync.
// On first mount a valid `?inspect=` deep link restores the inspector
// (identity-only subject; the owning route loads detail). After that the
// URL follows state: param present iff the inspector is open on an
// addressable subject. All writes use replace — selection churn must not
// pollute history. A malformed `?inspect=` is dropped from the URL.
export function EvidenceUrlSync() {
const { subject, setSubject, inspectorOpen, setInspectorOpen } =
useEvidenceSubject();
const [searchParams, setSearchParams] = useSearchParams();
const restoredRef = useRef(false);
useEffect(() => {
if (!restoredRef.current) {
restoredRef.current = true;
const restored = inspectValueToSubject(searchParams.get(INSPECT_PARAM));
if (restored) {
setSubject(restored);
setInspectorOpen(true);
return;
}
}
const desired =
inspectorOpen && isAddressable(subject)
? subjectToInspectValue(subject)
: null;
const current = searchParams.get(INSPECT_PARAM);
if (desired === current) return;
const next = new URLSearchParams(searchParams);
if (desired === null) {
next.delete(INSPECT_PARAM);
} else {
next.set(INSPECT_PARAM, desired);
}
setSearchParams(next, { replace: true });
}, [
subject,
inspectorOpen,
searchParams,
setSearchParams,
setSubject,
setInspectorOpen,
]);
return null;
}

View file

@ -2,10 +2,17 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { createTestQueryClient } from "../../test/createTestQueryClient";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigationType,
} from "react-router-dom";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { proposalDetail, proposalSummaries } from "../../api/__fixtures__/proposals";
import { EvidenceProvider } from "../evidenceContext";
import { SuggestedCLIBox } from "./SuggestedCLIBox";
import { ProposalTable } from "./ProposalTable";
import { ProposalsRoute } from "./ProposalsRoute";
@ -17,7 +24,13 @@ function queryWrapper({ children }: { children: ReactNode }) {
function LocationProbe() {
const location = useLocation();
return <span data-testid="location">{location.search}</span>;
const navigationType = useNavigationType();
return (
<>
<span data-testid="location">{`${location.pathname}${location.search}`}</span>
<span data-testid="nav-type">{navigationType}</span>
</>
);
}
function renderRoute(initialEntry = "/proposals") {
@ -26,9 +39,14 @@ function renderRoute(initialEntry = "/proposals") {
client={createTestQueryClient()}
>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/proposals" element={<><ProposalsRoute /><LocationProbe /></>} />
</Routes>
<EvidenceProvider>
<Routes>
<Route
path="/proposals/:proposalId?"
element={<><ProposalsRoute /><LocationProbe /></>}
/>
</Routes>
</EvidenceProvider>
</MemoryRouter>
</QueryClientProvider>,
);
@ -87,7 +105,7 @@ describe("ProposalsRoute", () => {
expect(screen.queryByTitle("proposal-pending-001abcdef")).not.toBeInTheDocument();
});
it("selecting a row updates the URL query param and renders detail", async () => {
it("selecting a row writes the path param (replace) and renders detail", async () => {
stubProposalFetch();
const user = userEvent.setup();
renderRoute("/proposals?state=pending");
@ -96,12 +114,22 @@ describe("ProposalsRoute", () => {
await waitFor(() =>
expect(screen.getByTestId("location")).toHaveTextContent(
`?state=pending&proposal_id=${proposalDetail.proposal_id}`,
`/proposals/${proposalDetail.proposal_id}?state=pending`,
),
);
expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE");
expect(await screen.findByText("Contemplation proposed a coherence relation.")).toBeInTheDocument();
});
it("restores selection from a deep-linked path param", async () => {
stubProposalFetch();
renderRoute(`/proposals/${proposalDetail.proposal_id}?state=pending`);
expect(
await screen.findByText("Contemplation proposed a coherence relation."),
).toBeInTheDocument();
});
it("shows LoadingState during fetch", () => {
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
renderRoute();

View file

@ -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) {

View file

@ -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

View file

@ -1,8 +1,9 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { createTestQueryClient } from "../../test/createTestQueryClient";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { describe, expect, it, vi, afterEach } from "vitest";
import { EvidenceProvider } from "../evidenceContext";
import { ArtifactList } from "./ArtifactList";
import { ReplayStatusBadge, ReplayStatus } from "../../design/components/badges";
import { ReplayComparisonPanel } from "./ReplayComparisonPanel";
@ -103,6 +104,20 @@ function renderWithProviders(ui: React.ReactElement, initialEntries = ["/"]) {
);
}
// ReplayRoute reads its selection from the :artifactId path param and
// publishes the selected artifact as the evidence subject, so it needs the
// real route declaration and an EvidenceProvider.
function renderReplayRoute(initialEntry: string) {
return renderWithProviders(
<EvidenceProvider>
<Routes>
<Route path="/replay/:artifactId?" element={<ReplayRoute />} />
</Routes>
</EvidenceProvider>,
[initialEntry],
);
}
describe("W-031 Replay Theater Tests", () => {
afterEach(() => {
vi.restoreAllMocks();
@ -141,39 +156,20 @@ describe("W-031 Replay Theater Tests", () => {
});
});
it("selecting a row updates the URL query param", async () => {
function HelperRoute() {
const [searchParams] = useSearchParams();
const artifactsQuery = mockArtifacts;
return (
<div>
<div data-testid="url-param">{searchParams.get("artifactId")}</div>
<ArtifactList
artifacts={artifactsQuery}
selectedId={searchParams.get("artifactId")}
onSelect={(id) => {
window.history.replaceState(
{},
"",
`?artifactId=${encodeURIComponent(id)}`
);
// Trigger popstate so react-router notices change in this test environment
window.dispatchEvent(new PopStateEvent("popstate"));
}}
/>
</div>
);
}
it("selecting a row invokes onSelect with the artifact id", () => {
// URL writing is ReplayRoute's job (path param, replace) — covered by
// the route tests below. ArtifactList only reports the selection.
const onSelect = vi.fn();
render(
<ArtifactList
artifacts={mockArtifacts}
selectedId={null}
onSelect={onSelect}
/>
);
renderWithProviders(<HelperRoute />, ["/"]);
const btn = screen.getByTestId("artifact-art-trace-1");
fireEvent.click(btn);
// Verify that the query parameter was updated
await waitFor(() => {
const url = new URL(window.location.href);
expect(url.searchParams.get("artifactId")).toBe("art-trace-1");
});
fireEvent.click(screen.getByTestId("artifact-art-trace-1"));
expect(onSelect).toHaveBeenCalledWith("art-trace-1");
});
});
@ -318,7 +314,7 @@ describe("W-031 Replay Theater Tests", () => {
});
vi.stubGlobal("fetch", fetchMock);
renderWithProviders(<ReplayRoute />, ["/replay?artifactId=art-trace-1"]);
renderReplayRoute("/replay/art-trace-1");
// Should load list, detail, and comparison
expect(await screen.findByText("Replay Evidence")).toBeInTheDocument();
@ -352,7 +348,7 @@ describe("W-031 Replay Theater Tests", () => {
});
vi.stubGlobal("fetch", fetchMock);
renderWithProviders(<ReplayRoute />, ["/replay?artifactId=art-trace-1"]);
renderReplayRoute("/replay/art-trace-1");
expect(await screen.findByText("evidence_unavailable")).toBeInTheDocument();
expect(screen.getByText("Replay evidence is not available on the backend for this artifact kind.")).toBeInTheDocument();
@ -384,7 +380,7 @@ describe("W-031 Replay Theater Tests", () => {
});
vi.stubGlobal("fetch", fetchMock);
renderWithProviders(<ReplayRoute />, ["/replay?artifactId=art-trace-1"]);
renderReplayRoute("/replay/art-trace-1");
expect(await screen.findByText("What failed")).toBeInTheDocument();
expect(screen.getByText("disk read error")).toBeInTheDocument();

View file

@ -1,10 +1,11 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ReactElement } from "react";
import { createTestQueryClient } from "../test/createTestQueryClient";
import { EvidenceProvider } from "./evidenceContext";
import { ChatRoute } from "../routes/ChatRoute";
import { ProposalsRoute } from "./proposals/ProposalsRoute";
import { EvalsRoute } from "./evals/EvalsRoute";
@ -55,10 +56,19 @@ function installFetch(plan: FetchPlan) {
);
}
function renderRoute(element: ReactElement) {
// Routes mount the way App.tsx declares them: under their (param) path and
// inside EvidenceProvider, since routes publish their selection as the
// evidence subject (R0c).
function renderRoute(element: ReactElement, path = "/", initialEntry = "/") {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter>{element}</MemoryRouter>
<MemoryRouter initialEntries={[initialEntry]}>
<EvidenceProvider>
<Routes>
<Route path={path} element={element} />
</Routes>
</EvidenceProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
@ -86,6 +96,8 @@ afterEach(() => {
interface MountRouteSpec {
name: string;
element: ReactElement;
path: string;
initialEntry: string;
loadingLabel: string;
emptyStatement: string;
emptyCommand: string;
@ -95,6 +107,8 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
{
name: "Proposals",
element: <ProposalsRoute />,
path: "/proposals/:proposalId?",
initialEntry: "/proposals",
loadingLabel: "Loading proposal queue...",
emptyStatement: "No proposals match this queue view.",
emptyCommand: "core teaching proposals --state pending",
@ -102,6 +116,8 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
{
name: "Evals",
element: <EvalsRoute />,
path: "/evals/:laneId?",
initialEntry: "/evals",
loadingLabel: "Loading eval lanes...",
emptyStatement: "No eval lanes discovered.",
emptyCommand: "core eval --list",
@ -109,6 +125,8 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
{
name: "Replay",
element: <ReplayRoute />,
path: "/replay/:artifactId?",
initialEntry: "/replay",
loadingLabel: "Loading artifacts...",
emptyStatement: "No artifacts available.",
emptyCommand: "core eval cognition",
@ -118,20 +136,20 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
describe.each(MOUNT_ROUTES)("route conformance: $name", (spec) => {
it("loading: shows a specific label, never 'Thinking...'", async () => {
installFetch("pending");
renderRoute(spec.element);
renderRoute(spec.element, spec.path, spec.initialEntry);
expect(await screen.findByText(spec.loadingLabel)).toBeInTheDocument();
expect(screen.queryByText(/thinking/i)).not.toBeInTheDocument();
});
it("error: surfaces what failed, mutation status, reproducer, retry safety", async () => {
installFetch("error");
renderRoute(spec.element);
renderRoute(spec.element, spec.path, spec.initialEntry);
await expectErrorContract();
});
it("empty: states what is absent and offers a next action", async () => {
installFetch("empty");
renderRoute(spec.element);
renderRoute(spec.element, spec.path, spec.initialEntry);
expect((await screen.findAllByText(spec.emptyStatement)).length).toBeGreaterThan(0);
expectEmptyContract(spec.emptyStatement, spec.emptyCommand);
});

View file

@ -0,0 +1,72 @@
import { fireEvent, render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useGlobalKeyboard } from "./useGlobalKeyboard";
function Harness({
onTogglePalette = () => {},
onToggleInspector = () => {},
onShowHelp = () => {},
onCopyEvidenceLink = () => {},
}: Partial<Parameters<typeof useGlobalKeyboard>[0]>) {
useGlobalKeyboard({
onTogglePalette,
onToggleInspector,
onShowHelp,
onCopyEvidenceLink,
});
return <input data-testid="field" />;
}
function renderHarness(handlers: Partial<Parameters<typeof useGlobalKeyboard>[0]>) {
return render(
<MemoryRouter>
<Harness {...handlers} />
</MemoryRouter>,
);
}
describe("useGlobalKeyboard Cmd+Shift+C", () => {
afterEach(() => {
document.body.focus();
});
it("invokes onCopyEvidenceLink, not the palette", () => {
const onCopyEvidenceLink = vi.fn();
const onTogglePalette = vi.fn();
renderHarness({ onCopyEvidenceLink, onTogglePalette });
fireEvent.keyDown(window, { key: "C", metaKey: true, shiftKey: true });
expect(onCopyEvidenceLink).toHaveBeenCalledTimes(1);
expect(onTogglePalette).not.toHaveBeenCalled();
});
it("does not fire while an input is focused", () => {
const onCopyEvidenceLink = vi.fn();
const { getByTestId } = renderHarness({ onCopyEvidenceLink });
getByTestId("field").focus();
fireEvent.keyDown(window, { key: "C", metaKey: true, shiftKey: true });
expect(onCopyEvidenceLink).not.toHaveBeenCalled();
});
it("does not fire on Cmd+C without Shift", () => {
const onCopyEvidenceLink = vi.fn();
renderHarness({ onCopyEvidenceLink });
fireEvent.keyDown(window, { key: "c", metaKey: true });
expect(onCopyEvidenceLink).not.toHaveBeenCalled();
});
it("Cmd+K still toggles the palette", () => {
const onTogglePalette = vi.fn();
renderHarness({ onTogglePalette });
fireEvent.keyDown(window, { key: "k", metaKey: true });
expect(onTogglePalette).toHaveBeenCalledTimes(1);
});
});

View file

@ -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]);
}