diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index c62c60b3..5b26701b 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -22,10 +22,10 @@ export function App() { }> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/workbench-ui/src/app/RightInspector.test.tsx b/workbench-ui/src/app/RightInspector.test.tsx index 621ad62e..2b8cbb52 100644 --- a/workbench-ui/src/app/RightInspector.test.tsx +++ b/workbench-ui/src/app/RightInspector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { useEffect } from "react"; import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext"; import { RightInspector } from "./RightInspector"; @@ -71,4 +71,54 @@ describe("RightInspector", () => { renderInspector(); expect(screen.getByText("⌘I")).toBeInTheDocument(); }); + + it("renders an honest not-loaded state for an identity-only subject", () => { + function SetIdentityOnly() { + const { setSubject } = useEvidenceSubject(); + useEffect(() => { + setSubject({ kind: "proposal", proposalId: "proposal-xyz" }); + }, [setSubject]); + return ; + } + render( + + + , + ); + expect(screen.getByText("proposal-xyz")).toBeInTheDocument(); + expect(screen.getByText(/Detail not loaded in this session/)).toBeInTheDocument(); + }); + + it("shows a transient Copied confirmation after an address copy", () => { + vi.useFakeTimers(); + try { + function CopySignal() { + const { notifyAddressCopied } = useEvidenceSubject(); + return ( + <> + + + + ); + } + render( + + + , + ); + + expect(screen.queryByTestId("address-copied")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("signal-copy")); + expect(screen.getByTestId("address-copied")).toHaveTextContent("Copied"); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(screen.queryByTestId("address-copied")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/workbench-ui/src/app/RightInspector.tsx b/workbench-ui/src/app/RightInspector.tsx index 6c246cc2..2676cdee 100644 --- a/workbench-ui/src/app/RightInspector.tsx +++ b/workbench-ui/src/app/RightInspector.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { useEvidenceSubject, type EvidenceSubject } from "./evidenceContext"; import { MetadataTable } from "../design/components/MetadataTable/MetadataTable"; import { DigestBadge } from "../design/components/DigestBadge/DigestBadge"; @@ -11,8 +12,27 @@ import { type NormativeClearance, } from "../design/components/badges"; +// Rendered when a subject was restored from a URL but its detail has not +// loaded in this session yet. An honest absence state, not a guess. +function DetailNotLoaded() { + return ( +

+ Detail not loaded in this session. Open the subject's route to load + its evidence. +

+ ); +} + function TurnInspector({ subject }: { subject: Extract }) { const { data } = subject; + if (!data) { + return ( +
+

Turn #{subject.turnId}

+ +
+ ); + } return (

Turn #{subject.turnId}

@@ -48,6 +68,15 @@ function TurnInspector({ subject }: { subject: Extract }) { const { data } = subject; + if (!data) { + return ( +
+

Proposal

+

{subject.proposalId}

+ +
+ ); + } return (

Proposal

@@ -66,6 +95,15 @@ function ProposalInspector({ subject }: { subject: Extract }) { const { data } = subject; + if (!data) { + return ( +
+

Artifact

+

{subject.artifactId}

+ +
+ ); + } return (

Artifact

@@ -85,6 +123,14 @@ function ArtifactInspector({ subject }: { subject: Extract }) { const { data } = subject; + if (!data) { + return ( +
+

Eval: {subject.lane}

+ +
+ ); + } return (

Eval: {data.lane}

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