From aa6fb8ba9048fd391540eb05ad7e138ebbd5320b Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 16:30:34 -0700 Subject: [PATCH] feat(workbench): add trace route journal timeline --- workbench-ui/src/api/client.test.ts | 66 ++- workbench-ui/src/api/client.ts | 21 +- workbench-ui/src/api/queries.ts | 24 +- workbench-ui/src/app/App.tsx | 4 +- workbench-ui/src/app/EvidenceChainRail.tsx | 8 +- workbench-ui/src/app/evidenceContext.tsx | 3 +- .../src/app/routeConformance.test.tsx | 10 + .../src/app/trace/TraceRoute.test.tsx | 224 ++++++++++ workbench-ui/src/app/trace/TraceRoute.tsx | 388 ++++++++++++++++++ .../src/design/doctrine/schemaDrift.test.ts | 3 - .../src/routes/TraceRoutePlaceholder.tsx | 10 - workbench-ui/src/types/api.ts | 32 +- 12 files changed, 772 insertions(+), 21 deletions(-) create mode 100644 workbench-ui/src/app/trace/TraceRoute.test.tsx create mode 100644 workbench-ui/src/app/trace/TraceRoute.tsx delete mode 100644 workbench-ui/src/routes/TraceRoutePlaceholder.tsx diff --git a/workbench-ui/src/api/client.test.ts b/workbench-ui/src/api/client.test.ts index 61da64d4..46151498 100644 --- a/workbench-ui/src/api/client.test.ts +++ b/workbench-ui/src/api/client.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiFetch, fetchProposalDetail, fetchProposals, WorkbenchApiError } from "./client"; +import { + apiFetch, + fetchProposalDetail, + fetchProposals, + fetchTraceTurn, + fetchTraceTurns, + WorkbenchApiError, +} from "./client"; import { proposalDetail, proposalSummaries } from "./__fixtures__/proposals"; describe("apiFetch", () => { @@ -102,3 +109,60 @@ describe("proposal fetchers", () => { expect(init.method).toBeUndefined(); }); }); + +describe("trace fetchers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("unwraps the /trace/turns items envelope with pagination parameters", async () => { + const items = [ + { + turn_id: 7, + timestamp: "2026-06-12T00:00:00Z", + prompt_excerpt: "hello", + surface_excerpt: "world", + trace_hash: "sha256:abc", + grounding_source: "pack", + }, + ]; + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: { items } }), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchTraceTurns(25, 50)).resolves.toEqual(items); + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8765/trace/turns?limit=25&offset=50"); + }); + + it("fetches a trace turn detail without mutation options", async () => { + const turn = { + turn_id: 7, + timestamp: "2026-06-12T00:00:00Z", + trace_hash: "sha256:abc", + prompt: "hello", + surface: "world", + articulation_surface: "realizer", + walk_surface: "walk", + grounding_source: "pack", + epistemic_state: "evidenced", + normative_clearance: "cleared", + verdicts: {}, + refusal_emitted: false, + hedge_injected: false, + proposal_candidates: [], + turn_cost_ms: 1, + checkpoint_emitted: false, + journal_digest: "sha256:def", + }; + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: turn }), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchTraceTurn(7)).resolves.toEqual(turn); + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8765/trace/7"); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.method).toBeUndefined(); + }); +}); diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index 126ec28a..093e712d 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -10,6 +10,8 @@ import type { ProposalDetail, ProposalState, ProposalSummary, + TurnJournalEntry, + TurnJournalSummary, MathProposalSummary, MathProposalDetail, MathRatifyResult, @@ -107,6 +109,24 @@ export async function fetchProposalDetail(proposalId: string): Promise(`/proposals/${encodeURIComponent(proposalId)}`); } +export async function fetchTraceTurns( + limit?: number, + offset?: number, +): Promise { + const params = new URLSearchParams(); + if (limit !== undefined) params.set("limit", String(limit)); + if (offset !== undefined) params.set("offset", String(offset)); + const query = params.toString(); + const envelope = await apiFetch>( + query ? `/trace/turns?${query}` : "/trace/turns", + ); + return envelope.items; +} + +export async function fetchTraceTurn(turnId: number): Promise { + return apiFetch(`/trace/${encodeURIComponent(String(turnId))}`); +} + export async function fetchMathProposals(): Promise { const envelope = await apiFetch>("/math-proposals"); return envelope.items; @@ -154,4 +174,3 @@ export async function deferMathProposal( ); } - diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index 97415ffb..5838505d 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -13,6 +13,8 @@ import { fetchReplayComparison, fetchProposalDetail, fetchProposals, + fetchTraceTurn, + fetchTraceTurns, fetchMathProposals, fetchMathProposalDetail, ratifyMathProposal, @@ -27,6 +29,8 @@ import type { ArtifactDetail, ProposalSummary, ProposalDetail, + TurnJournalEntry, + TurnJournalSummary, EvalLaneSummary, EvalRunResult, ChatTurnResult, @@ -108,6 +112,25 @@ export function useProposalDetail(proposalId: string) { }); } +export function useTraceTurns(limit?: number, offset?: number) { + return useQuery({ + queryKey: ["api", "trace", "turns", limit ?? null, offset ?? null], + queryFn: () => fetchTraceTurns(limit, offset), + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + +export function useTraceTurn(turnId?: number | null) { + return useQuery({ + queryKey: ["api", "trace", "turn", turnId ?? null], + queryFn: () => fetchTraceTurn(turnId as number), + enabled: typeof turnId === "number", + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + export function useEvalLanes() { return useQuery({ queryKey: ["api", "evals"], @@ -216,4 +239,3 @@ export function useMathDefer() { } - diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 5b26701b..4bb53d4b 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -5,7 +5,7 @@ import { Shell } from "./Shell"; import { PreviewPage } from "../preview/PreviewPage"; import { ChatRoute } from "../routes/ChatRoute"; import { ProposalsRoute } from "./proposals/ProposalsRoute"; -import { TraceRoutePlaceholder } from "../routes/TraceRoutePlaceholder"; +import { TraceRoute } from "./trace/TraceRoute"; import { ReplayRoute } from "./replay/ReplayRoute"; import { EvalsRoute } from "./evals/EvalsRoute"; import { RunsRoutePlaceholder } from "../routes/RunsRoutePlaceholder"; @@ -22,7 +22,7 @@ export function App() { }> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/workbench-ui/src/app/EvidenceChainRail.tsx b/workbench-ui/src/app/EvidenceChainRail.tsx index 6a40e8e3..ad034544 100644 --- a/workbench-ui/src/app/EvidenceChainRail.tsx +++ b/workbench-ui/src/app/EvidenceChainRail.tsx @@ -65,7 +65,13 @@ export function deriveStages(subject: EvidenceSubject): RailStage[] | null { "epistemic_state + normative_clearance", ), stage("replay", d ? evidenceOf(d.trace_hash) : "hollow", "trace_hash recorded (not a verification claim)"), - stage("authority", d ? evidenceOf(d.mutation_mode) : "hollow", "mutation_mode"), + stage( + "authority", + d + ? evidenceOf("mutation_mode" in d ? d.mutation_mode : d.checkpoint_emitted) + : "hollow", + "mutation_mode / checkpoint_emitted", + ), stage("action", "dim", "not applicable to a completed turn"), ]; } diff --git a/workbench-ui/src/app/evidenceContext.tsx b/workbench-ui/src/app/evidenceContext.tsx index cd98bc0d..679f2e0c 100644 --- a/workbench-ui/src/app/evidenceContext.tsx +++ b/workbench-ui/src/app/evidenceContext.tsx @@ -7,6 +7,7 @@ import { } from "react"; import type { ChatTurnResult, + TurnJournalEntry, ProposalDetail, ArtifactDetail, EvalRunResult, @@ -16,7 +17,7 @@ import type { // 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: "turn"; turnId: number; data?: ChatTurnResult | TurnJournalEntry } | { kind: "proposal"; proposalId: string; data?: ProposalDetail } | { kind: "artifact"; artifactId: string; data?: ArtifactDetail } | { kind: "eval_result"; lane: string; data?: EvalRunResult } diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx index f37cf787..8e5edf51 100644 --- a/workbench-ui/src/app/routeConformance.test.tsx +++ b/workbench-ui/src/app/routeConformance.test.tsx @@ -8,6 +8,7 @@ import { createTestQueryClient } from "../test/createTestQueryClient"; import { EvidenceProvider } from "./evidenceContext"; import { ChatRoute } from "../routes/ChatRoute"; import { ProposalsRoute } from "./proposals/ProposalsRoute"; +import { TraceRoute } from "./trace/TraceRoute"; import { EvalsRoute } from "./evals/EvalsRoute"; import { ReplayRoute } from "./replay/ReplayRoute"; @@ -104,6 +105,15 @@ interface MountRouteSpec { } const MOUNT_ROUTES: MountRouteSpec[] = [ + { + name: "Trace", + element: , + path: "/trace/:turnId?", + initialEntry: "/trace", + loadingLabel: "Loading trace...", + emptyStatement: "No turns recorded yet. Use Chat to create evidence.", + emptyCommand: "core chat", + }, { name: "Proposals", element: , diff --git a/workbench-ui/src/app/trace/TraceRoute.test.tsx b/workbench-ui/src/app/trace/TraceRoute.test.tsx new file mode 100644 index 00000000..c0bd725c --- /dev/null +++ b/workbench-ui/src/app/trace/TraceRoute.test.tsx @@ -0,0 +1,224 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigationType, +} from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestQueryClient } from "../../test/createTestQueryClient"; +import type { TurnJournalEntry, TurnJournalSummary } from "../../types/api"; +import { EvidenceProvider } from "../evidenceContext"; +import { TraceRoute } from "./TraceRoute"; + +const summaries: TurnJournalSummary[] = [ + { + turn_id: 1, + timestamp: "2026-06-12T18:00:00Z", + prompt_excerpt: "First prompt\nwith more", + surface_excerpt: "First response", + trace_hash: "sha256:111111111111abcdef", + grounding_source: "pack", + }, + { + turn_id: 2, + timestamp: "2026-06-12T18:01:00Z", + prompt_excerpt: "Second prompt", + surface_excerpt: "Second response", + trace_hash: "sha256:222222222222abcdef", + grounding_source: "teaching", + }, + { + turn_id: 3, + timestamp: "2026-06-12T18:02:00Z", + prompt_excerpt: "Third prompt", + surface_excerpt: "Third response", + trace_hash: "sha256:333333333333abcdef", + grounding_source: "vault", + }, +]; + +function entry(id: number): TurnJournalEntry { + const summary = summaries.find((item) => item.turn_id === id) ?? summaries[0]; + return { + turn_id: summary.turn_id, + timestamp: summary.timestamp, + trace_hash: summary.trace_hash, + prompt: `${summary.prompt_excerpt} full text`, + surface: `User response for turn ${summary.turn_id}`, + articulation_surface: `Realizer surface for turn ${summary.turn_id}`, + walk_surface: `Walk evidence for turn ${summary.turn_id}`, + grounding_source: summary.grounding_source, + epistemic_state: "evidenced", + normative_clearance: "cleared", + verdicts: { + identity: { outcome: "cleared", runtime_detail: "identity ok" }, + safety: { outcome: "cleared", runtime_detail: "safety ok" }, + ethics: { outcome: "cleared", runtime_detail: "ethics ok" }, + }, + refusal_emitted: false, + hedge_injected: false, + proposal_candidates: [{ candidate_id: "candidate-1", source_kind: "discovery" }], + turn_cost_ms: 17, + checkpoint_emitted: true, + journal_digest: `sha256:journal${summary.turn_id}abcdef`, + }; +} + +function LocationProbe() { + const location = useLocation(); + const navigationType = useNavigationType(); + return ( + <> + {`${location.pathname}${location.search}`} + {navigationType} + + ); +} + +function renderRoute(initialEntry = "/trace") { + return render( + + + + + + + + + } + /> + + + + , + ); +} + +function stubTraceFetch(items: TurnJournalSummary[] = summaries) { + const fetchMock = vi.fn((input: unknown) => { + const path = new URL(String(input)).pathname; + if (path === "/trace/turns") { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items } }), + }); + } + const match = path.match(/^\/trace\/(\d+)$/); + if (match) { + return Promise.resolve({ + json: () => + Promise.resolve({ ok: true, generated_at: "now", data: entry(Number(match[1])) }), + }); + } + return Promise.resolve({ + json: () => + Promise.resolve({ + ok: false, + generated_at: "now", + error: { code: "not_found", message: `unexpected path ${path}` }, + }), + }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const offsetDescriptors = { + offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"), + offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"), +}; + +describe("TraceRoute", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get: () => 560, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get: () => 480, + }); + }); + + afterEach(() => { + if (offsetDescriptors.offsetHeight) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight); + } + if (offsetDescriptors.offsetWidth) { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth); + } + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it("renders the timeline from journal summaries", async () => { + stubTraceFetch(); + renderRoute(); + + expect(await screen.findByText("First prompt")).toBeInTheDocument(); + expect(screen.getByText("Second prompt")).toBeInTheDocument(); + expect(screen.getByText("sha256:111111111111...")).toBeInTheDocument(); + }); + + it("selecting a turn writes /trace/ with replace and shows evidence", async () => { + stubTraceFetch(); + const user = userEvent.setup(); + renderRoute(); + + await user.click(await screen.findByText("First prompt")); + + await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/trace/1")); + expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE"); + expect(await screen.findByText("User response for turn 1")).toBeInTheDocument(); + }); + + it("renders the three surface labels distinctly", async () => { + stubTraceFetch(); + renderRoute("/trace/2"); + + expect(await screen.findByText("User Surface (response)")).toBeInTheDocument(); + expect(screen.getByText("Articulation Surface (realizer)")).toBeInTheDocument(); + expect(screen.getByText("Walk Surface (telemetry/evidence)")).toBeInTheDocument(); + }); + + it("keeps raw JSON collapsed by default", async () => { + stubTraceFetch(); + const user = userEvent.setup(); + renderRoute("/trace/2"); + + await user.click(await screen.findByRole("tab", { name: "Raw" })); + + expect(screen.getByText("Raw journal JSON is collapsed by default.")).toBeInTheDocument(); + expect(screen.queryByTestId("json-rows")).not.toBeInTheDocument(); + }); + + it("restores selection from a deep-linked turn id", async () => { + stubTraceFetch(); + renderRoute("/trace/3"); + + expect(await screen.findByText("User response for turn 3")).toBeInTheDocument(); + expect(screen.getByText("Third prompt").closest('[aria-current="true"]')).not.toBeNull(); + }); + + it("moves timeline focus with j/k through the VirtualizedList keyboard spine", async () => { + stubTraceFetch(); + const user = userEvent.setup(); + renderRoute(); + + const list = await screen.findByRole("listbox", { name: "Trace turns" }); + list.focus(); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("j"); + expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("k"); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + }); +}); diff --git a/workbench-ui/src/app/trace/TraceRoute.tsx b/workbench-ui/src/app/trace/TraceRoute.tsx new file mode 100644 index 00000000..7eb8cd56 --- /dev/null +++ b/workbench-ui/src/app/trace/TraceRoute.tsx @@ -0,0 +1,388 @@ +import { useEffect, useMemo, useState } from "react"; +import { Eye } from "lucide-react"; +import { useNavigate, useParams } from "react-router-dom"; +import { WorkbenchApiError } from "../../api/client"; +import { useTraceTurn, useTraceTurns } from "../../api/queries"; +import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge"; +import { MetadataTable } from "../../design/components/MetadataTable/MetadataTable"; +import { Panel } from "../../design/components/Panel/Panel"; +import { SearchInput } from "../../design/components/SearchInput/SearchInput"; +import { SplitPane } from "../../design/components/SplitPane/SplitPane"; +import { StableJsonViewer } from "../../design/components/StableJsonViewer"; +import { TabBar, type Tab } from "../../design/components/TabBar/TabBar"; +import { Timestamp } from "../../design/components/Timestamp/Timestamp"; +import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; +import { + EpistemicStateBadge, + GroundingSourceBadge, + NormativeClearanceBadge, + type EpistemicState, + type GroundingSource, + type NormativeClearance, +} from "../../design/components/badges"; +import { Button } from "../../design/components/primitives/Button"; +import { EmptyState } from "../../design/components/states/EmptyState"; +import { ErrorState } from "../../design/components/states/ErrorState"; +import { LoadingState } from "../../design/components/states/LoadingState"; +import type { TurnJournalEntry, TurnJournalSummary } from "../../types/api"; +import { pushRecentItem } from "../commandRegistry"; +import { subjectToUrl } from "../evidenceAddress"; +import { useEvidenceSubject } from "../evidenceContext"; + +const TRACE_TABS: readonly Tab[] = [ + { id: "surfaces", label: "Surfaces" }, + { id: "grounding", label: "Grounding" }, + { id: "verdicts", label: "Verdicts" }, + { id: "metadata", label: "Metadata" }, + { id: "raw", label: "Raw" }, +]; + +function parseTurnId(raw: string | undefined): number | null { + if (!raw || !/^\d+$/.test(raw)) return null; + const value = Number(raw); + return Number.isSafeInteger(value) ? value : null; +} + +function errorMessage(error: unknown) { + return error instanceof WorkbenchApiError ? error.message : "Trace journal request failed."; +} + +function digestPayload(value: string | null | undefined): string | null { + if (!value) return null; + return value.replace(/^sha256:/, ""); +} + +function firstLine(value: string): string { + return value.split(/\r?\n/, 1)[0] || ""; +} + +function surfaceText(value: string | null): string { + return value && value.trim() ? value : "Not recorded."; +} + +function proposalCandidateLabel(candidate: Record): string { + const id = candidate.candidate_id; + const source = candidate.source_kind; + if (typeof id === "string" && typeof source === "string") return `${id} (${source})`; + if (typeof id === "string") return id; + return JSON.stringify(candidate); +} + +function asVerdict(value: unknown): { outcome: string; runtime_detail: string } | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + return { + outcome: typeof record.outcome === "string" ? record.outcome : "unassessable", + runtime_detail: typeof record.runtime_detail === "string" ? record.runtime_detail : "", + }; +} + +function SurfaceCard({ + label, + value, +}: { + label: string; + value: string | null; +}) { + return ( +
+

+ {label} +

+
+        {surfaceText(value)}
+      
+
+ ); +} + +function TraceRow({ + turn, + selected, + focused, + onSelect, +}: { + turn: TurnJournalSummary; + selected: boolean; + focused: boolean; + onSelect: () => void; +}) { + const digest = digestPayload(turn.trace_hash); + return ( +
+ + + + + + {firstLine(turn.prompt_excerpt) || `Turn #${turn.turn_id}`} + + + {turn.surface_excerpt} + + + + {digest ? ( + + ) : ( + no hash + )} + +
+ ); +} + +function SurfacesTab({ turn }: { turn: TurnJournalEntry }) { + return ( +
+ + + +
+ ); +} + +function GroundingTab({ turn }: { turn: TurnJournalEntry }) { + return ( + , + }, + { + key: "epistemic_state", + value: , + }, + { + key: "normative_clearance", + value: , + }, + ]} + /> + ); +} + +function VerdictsTab({ turn }: { turn: TurnJournalEntry }) { + const identity = asVerdict(turn.verdicts.identity); + const safety = asVerdict(turn.verdicts.safety); + const ethics = asVerdict(turn.verdicts.ethics); + return ( + + ); +} + +function MetadataTab({ turn }: { turn: TurnJournalEntry }) { + const traceDigest = digestPayload(turn.trace_hash); + const journalDigest = digestPayload(turn.journal_digest); + return ( + }, + { key: "turn_cost_ms", value: `${turn.turn_cost_ms}ms`, mono: true }, + { key: "checkpoint_emitted", value: turn.checkpoint_emitted ? "yes" : "no" }, + { + key: "trace_hash", + value: traceDigest ? : "not recorded", + }, + { + key: "journal_digest", + value: journalDigest ? : "not recorded", + }, + { + key: "proposal_candidates", + value: + turn.proposal_candidates.length > 0 + ? turn.proposal_candidates.map(proposalCandidateLabel).join(", ") + : "none", + }, + ]} + /> + ); +} + +function RawTab({ turn }: { turn: TurnJournalEntry }) { + const [expanded, setExpanded] = useState(false); + return expanded ? ( + + ) : ( +
+

+ Raw journal JSON is collapsed by default. +

+ +
+ ); +} + +function TraceDetail({ turn }: { turn: TurnJournalEntry }) { + const [activeTab, setActiveTab] = useState("surfaces"); + return ( + + ) : null + } + > + + {activeTab === "surfaces" ? : null} + {activeTab === "grounding" ? : null} + {activeTab === "verdicts" ? : null} + {activeTab === "metadata" ? : null} + {activeTab === "raw" ? : null} + + + ); +} + +export function TraceRoute() { + const { turnId } = useParams(); + const selectedTurnId = parseTurnId(turnId); + const navigate = useNavigate(); + const { setSubject } = useEvidenceSubject(); + const [search, setSearch] = useState(""); + + const turnsQuery = useTraceTurns(); + const turnQuery = useTraceTurn(selectedTurnId); + + const turns = turnsQuery.data ?? []; + const filteredTurns = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return turns; + return turns.filter((turn) => { + const trace = turn.trace_hash?.replace(/^sha256:/, "").toLowerCase() ?? ""; + return ( + turn.prompt_excerpt.toLowerCase().includes(q) || + trace.startsWith(q) || + trace.includes(q) + ); + }); + }, [search, turns]); + + useEffect(() => { + if (selectedTurnId === null) return; + setSubject({ kind: "turn", turnId: selectedTurnId, data: turnQuery.data }); + }, [selectedTurnId, setSubject, turnQuery.data]); + + function selectTurn(turn: TurnJournalSummary) { + const subject = { kind: "turn" as const, turnId: turn.turn_id }; + const path = subjectToUrl(subject); + navigate(path, { replace: true }); + pushRecentItem({ label: `Turn #${turn.turn_id}`, path }); + } + + if (turnsQuery.isLoading) { + return ; + } + + if (turnsQuery.isError) { + return ( + + ); + } + + if (turns.length === 0) { + return ( + + ); + } + + return ( +
+ + +
+ + {filteredTurns.length === 0 ? ( + + ) : ( + String(turn.turn_id)} + height="calc(100vh - 14rem)" + initialRect={{ width: 480, height: 560 }} + items={filteredTurns} + onActivate={(turn) => selectTurn(turn)} + renderItem={(turn, _index, focused) => ( + selectTurn(turn)} + /> + )} + /> + )} +
+
+ +
+ {selectedTurnId === null ? ( + + ) : turnQuery.isLoading ? ( + + ) : turnQuery.isError ? ( + + ) : turnQuery.data ? ( + + ) : null} +
+
+
+ ); +} diff --git a/workbench-ui/src/design/doctrine/schemaDrift.test.ts b/workbench-ui/src/design/doctrine/schemaDrift.test.ts index 88f6f5fc..9afa75cf 100644 --- a/workbench-ui/src/design/doctrine/schemaDrift.test.ts +++ b/workbench-ui/src/design/doctrine/schemaDrift.test.ts @@ -20,9 +20,6 @@ import { describe, expect, it } from "vitest"; */ const NOT_YET_MIRRORED = new Set([ - // Turn journal (R0 brief 1) — TS mirrors land with the R2 Trace route: - "TurnJournalEntrySchema", - "TurnJournalSummarySchema", // R2-B backend read substrate (#712) — TS mirrors land with each R2 route: "PackSummary", "PackDetail", diff --git a/workbench-ui/src/routes/TraceRoutePlaceholder.tsx b/workbench-ui/src/routes/TraceRoutePlaceholder.tsx deleted file mode 100644 index 2a9a7fa0..00000000 --- a/workbench-ui/src/routes/TraceRoutePlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { EmptyState } from "../design/components/states/EmptyState"; - -export function TraceRoutePlaceholder() { - return ( - " }} - /> - ); -} diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts index 0b1bae7d..e4693ca8 100644 --- a/workbench-ui/src/types/api.ts +++ b/workbench-ui/src/types/api.ts @@ -76,6 +76,37 @@ export interface ChatTurnResult { checkpoint_emitted: boolean; } +export interface TurnJournalSummary { + turn_id: number; + timestamp: string; + prompt_excerpt: string; + surface_excerpt: string; + trace_hash: string | null; + grounding_source: GroundingSource; +} + +export interface TurnJournalEntry { + turn_id: number; + timestamp: string; + trace_hash: string | null; + prompt: string; + surface: string; + articulation_surface: string | null; + walk_surface: string | null; + grounding_source: GroundingSource; + epistemic_state: EpistemicState; + normative_clearance: NormativeClearance; + verdicts: Record; + refusal_emitted: boolean; + hedge_injected: boolean; + proposal_candidates: Record[]; + turn_cost_ms: number; + checkpoint_emitted: boolean; + journal_digest: string; +} + +export type TurnEvidence = ChatTurnResult | TurnJournalEntry; + export type ArtifactKind = | "trace" | "eval_result" @@ -226,4 +257,3 @@ export interface MathRatifyResult { target_path: string | null; evidence_hash: string | null; } -