core/workbench-ui/src/app/evidenceContext.tsx
Shay 41f4435dc1 feat(workbench): evidence addresses — deep-linkable subjects + inspector URL state
URL = subject: one codec (evidenceAddress.ts) owns the address grammar for
every EvidenceSubject kind, including kinds whose routes are placeholders.
urlToSubject is a total inverse — malformed input yields null, never throws.

- Route params: /trace/:turnId?, /proposals/:proposalId?, /evals/:laneId?,
  /replay/:artifactId? (placeholders keep flat paths)
- ?inspect= carries inspector subject + open state; EvidenceUrlSync restores
  deep links and write-through syncs with replace (selection churn never
  pollutes history); malformed values are dropped from the URL
- Proposals/Evals/Replay restore selection from their param on load, write it
  on selection change (replace), and publish the selected subject to the
  evidence context (identity at once, detail when the query resolves)
- EvidenceSubject.data now optional; inspectors render an honest
  'detail not loaded' state for identity-only subjects
- Cmd+Shift+C copies origin + subjectToUrl(subject); no-op without subject;
  input-focus guard; transient inline 'Copied' on the inspector header
- Fix pre-existing unbounded re-render loop in RightInspector.test.tsx
  (render-phase setSubject); subjects now set from effects
2026-06-12 12:17:57 -07:00

85 lines
2.3 KiB
TypeScript

import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
import type {
ChatTurnResult,
ProposalDetail,
ArtifactDetail,
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: "none" };
interface EvidenceContextValue {
subject: EvidenceSubject;
setSubject: (subject: EvidenceSubject) => void;
clearSubject: () => void;
inspectorOpen: boolean;
setInspectorOpen: (open: boolean) => void;
toggleInspector: () => void;
addressCopyCount: number;
notifyAddressCopied: () => void;
}
const NONE_SUBJECT: EvidenceSubject = { kind: "none" };
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);
}, []);
const clearSubject = useCallback(() => {
setSubjectState(NONE_SUBJECT);
}, []);
const toggleInspector = useCallback(() => {
setInspectorOpen((prev) => !prev);
}, []);
const notifyAddressCopied = useCallback(() => {
setAddressCopyCount((prev) => prev + 1);
}, []);
return (
<EvidenceContext.Provider
value={{
subject,
setSubject,
clearSubject,
inspectorOpen,
setInspectorOpen,
toggleInspector,
addressCopyCount,
notifyAddressCopied,
}}
>
{children}
</EvidenceContext.Provider>
);
}
export function useEvidenceSubject(): EvidenceContextValue {
const ctx = useContext(EvidenceContext);
if (!ctx) {
throw new Error("useEvidenceSubject must be used within EvidenceProvider");
}
return ctx;
}