From 0ff3e3b2c8835588631ce8523bd5729a2293db95 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 21:38:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20Settings=20route=20=E2=80=94?= =?UTF-8?q?=20wired=20prefs=20+=20read-only=20runtime=20status=20(Wave=20R?= =?UTF-8?q?2,=20final=20route)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces SettingsRoutePlaceholder, completing all six placeholder routes. - workbenchPrefs.ts: typed localStorage prefs with safe parse + live same-tab sync (useWorkbenchPrefs). Every pref has a real consumer — no toggle that does nothing: * landingRoute -> consumed by the App index redirect (Navigate) * inspectorDefaultOpen -> consumed by EvidenceProvider initial state (EvidenceUrlSync still force-opens on a ?inspect= deep link) - SettingsRoute: two panels. Preferences (landing route select + inspector default switch, applied on next load, survive reload). Runtime (read-only /runtime/status: backend, git_revision DigestBadge, engine_state_present, checkpoint_revision, revision_warning with warning token, active_session_id, mutation_mode) under the standing statement 'Engine configuration is CLI-only. This page mutates nothing on the server.' - no backend change, no new schema/subject, no NOT_YET_MIRRORED change, no engine mutation of any kind - bespoke conformance block (Settings has no empty state — the prefs panel always renders); asserts the loading label + CLI-only statement + the error contract over the status fetch - tests: workbenchPrefs (defaults / persist+reload / invalid+malformed fallback / partial merge) + SettingsRoute (both panels, landing-route persistence, inspector toggle, error-without-mutation) DEFERRED (flagged, not shipped as theater): list-density pref — honest wiring requires threading a density token through every list row, a separate change; shipping a control that does nothing would violate ADR-0160 (audit-native, not analytics theater). Token-only styling (hexScan green); full vitest 363 green. --- workbench-ui/src/app/App.tsx | 7 +- workbench-ui/src/app/evidenceContext.tsx | 7 +- .../src/app/routeConformance.test.tsx | 23 +++ .../src/app/settings/SettingsRoute.test.tsx | 98 ++++++++++++ .../src/app/settings/SettingsRoute.tsx | 151 ++++++++++++++++++ workbench-ui/src/app/workbenchPrefs.test.ts | 50 ++++++ workbench-ui/src/app/workbenchPrefs.ts | 92 +++++++++++ .../src/routes/SettingsRoutePlaceholder.tsx | 10 -- 8 files changed, 424 insertions(+), 14 deletions(-) create mode 100644 workbench-ui/src/app/settings/SettingsRoute.test.tsx create mode 100644 workbench-ui/src/app/settings/SettingsRoute.tsx create mode 100644 workbench-ui/src/app/workbenchPrefs.test.ts create mode 100644 workbench-ui/src/app/workbenchPrefs.ts delete mode 100644 workbench-ui/src/routes/SettingsRoutePlaceholder.tsx diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 06565738..7247e2f5 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -12,7 +12,8 @@ import { EvalsRoute } from "./evals/EvalsRoute"; import { RunsRoute } from "./runs/RunsRoute"; import { PacksRoute } from "./packs/PacksRoute"; import { VaultRoute } from "./vault/VaultRoute"; -import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder"; +import { SettingsRoute } from "./settings/SettingsRoute"; +import { getWorkbenchPrefs } from "./workbenchPrefs"; export function App() { return ( @@ -20,7 +21,7 @@ export function App() { }> - } /> + } /> } /> } /> } /> @@ -30,7 +31,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/workbench-ui/src/app/evidenceContext.tsx b/workbench-ui/src/app/evidenceContext.tsx index f18c6b69..1e01ce84 100644 --- a/workbench-ui/src/app/evidenceContext.tsx +++ b/workbench-ui/src/app/evidenceContext.tsx @@ -5,6 +5,7 @@ import { useCallback, type ReactNode, } from "react"; +import { getWorkbenchPrefs } from "./workbenchPrefs"; import type { ChatTurnResult, TurnJournalEntry, @@ -79,7 +80,11 @@ const EvidenceContext = createContext(null); export function EvidenceProvider({ children }: { children: ReactNode }) { const [subject, setSubjectState] = useState(NONE_SUBJECT); - const [inspectorOpen, setInspectorOpen] = useState(false); + // Initial visibility follows the workbench pref; EvidenceUrlSync still + // force-opens on a `?inspect=` deep link. + const [inspectorOpen, setInspectorOpen] = useState( + () => getWorkbenchPrefs().inspectorDefaultOpen, + ); const [addressCopyCount, setAddressCopyCount] = useState(0); const setSubject = useCallback((s: EvidenceSubject) => { diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx index dfad7348..0f8ce0b7 100644 --- a/workbench-ui/src/app/routeConformance.test.tsx +++ b/workbench-ui/src/app/routeConformance.test.tsx @@ -15,6 +15,7 @@ import { ReplayRoute } from "./replay/ReplayRoute"; import { RunsRoute } from "./runs/RunsRoute"; import { PacksRoute } from "./packs/PacksRoute"; import { VaultRoute } from "./vault/VaultRoute"; +import { SettingsRoute } from "./settings/SettingsRoute"; /** * ADR-0162 §6 route conformance — executable, not aspirational. @@ -239,3 +240,25 @@ describe("route conformance: Chat (interaction-driven states)", () => { await expectErrorContract(); }); }); + +// Settings has no empty state — the preferences panel always renders — so it +// carries a bespoke loading/error contract over its read-only status fetch. +describe("route conformance: Settings (no empty state — prefs always render)", () => { + it("loading: shows a specific label and the CLI-only statement, never 'Thinking...'", async () => { + installFetch("pending"); + renderRoute(); + expect(await screen.findByText("Loading runtime status...")).toBeInTheDocument(); + expect( + screen.getByText( + "Engine configuration is CLI-only. This page mutates nothing on the server.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/thinking/i)).not.toBeInTheDocument(); + }); + + it("error: surfaces what failed, mutation status, reproducer, retry safety", async () => { + installFetch("error"); + renderRoute(); + await expectErrorContract(); + }); +}); diff --git a/workbench-ui/src/app/settings/SettingsRoute.test.tsx b/workbench-ui/src/app/settings/SettingsRoute.test.tsx new file mode 100644 index 00000000..0380b532 --- /dev/null +++ b/workbench-ui/src/app/settings/SettingsRoute.test.tsx @@ -0,0 +1,98 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTestQueryClient } from "../../test/createTestQueryClient"; +import type { RuntimeStatus } from "../../types/api"; +import { getWorkbenchPrefs } from "../workbenchPrefs"; +import { SettingsRoute } from "./SettingsRoute"; + +const status: RuntimeStatus = { + backend: "numpy", + git_revision: "sha256:abcdef0123456789", + engine_state_present: true, + checkpoint_revision: "rev-42", + revision_warning: false, + active_session_id: null, + mutation_mode: "read_only", +}; + +function renderRoute() { + return render( + + + + + , + ); +} + +function stubStatus(ok = true) { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve( + ok + ? { ok: true, generated_at: "now", data: status } + : { ok: false, generated_at: "now", error: { code: "read_error", message: "boom" } }, + ), + }), + ), + ); +} + +describe("SettingsRoute", () => { + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it("renders both panels with the CLI-only statement and runtime status", async () => { + stubStatus(); + renderRoute(); + + expect(screen.getByText("Workbench preferences")).toBeInTheDocument(); + expect( + screen.getByText( + "Engine configuration is CLI-only. This page mutates nothing on the server.", + ), + ).toBeInTheDocument(); + expect(await screen.findByText("backend")).toBeInTheDocument(); + expect(screen.getByText("mutation_mode")).toBeInTheDocument(); + }); + + it("changing the landing route persists and survives reload", async () => { + stubStatus(); + const user = userEvent.setup(); + renderRoute(); + + await user.selectOptions(screen.getByLabelText("Landing route"), "vault"); + + expect(getWorkbenchPrefs().landingRoute).toBe("vault"); + expect((screen.getByLabelText("Landing route") as HTMLSelectElement).value).toBe("vault"); + }); + + it("toggling inspector-open-by-default persists immediately", async () => { + stubStatus(); + const user = userEvent.setup(); + renderRoute(); + + const toggle = screen.getByRole("switch", { name: "Inspector open by default" }); + expect(toggle).toHaveAttribute("aria-checked", "false"); + await user.click(toggle); + + expect(getWorkbenchPrefs().inspectorDefaultOpen).toBe(true); + await waitFor(() => expect(toggle).toHaveAttribute("aria-checked", "true")); + }); + + it("surfaces a runtime-status error without claiming a mutation", async () => { + stubStatus(false); + renderRoute(); + + expect(await screen.findByText("What failed")).toBeInTheDocument(); + expect(screen.getByText(/No settings mutation occurred\./)).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/app/settings/SettingsRoute.tsx b/workbench-ui/src/app/settings/SettingsRoute.tsx new file mode 100644 index 00000000..5e5c8a08 --- /dev/null +++ b/workbench-ui/src/app/settings/SettingsRoute.tsx @@ -0,0 +1,151 @@ +import { WorkbenchApiError } from "../../api/client"; +import { useRuntimeStatus } 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 { ErrorState } from "../../design/components/states/ErrorState"; +import { LoadingState } from "../../design/components/states/LoadingState"; +import { + LANDING_ROUTES, + useSetWorkbenchPref, + useWorkbenchPrefs, + type LandingRoute, +} from "../workbenchPrefs"; + +const CLI_ONLY_STATEMENT = + "Engine configuration is CLI-only. This page mutates nothing on the server."; + +function errorMessage(error: unknown) { + return error instanceof WorkbenchApiError ? error.message : "Runtime status request failed."; +} + +function digestPayload(value: string | null | undefined): string | null { + if (!value) return null; + return value.replace(/^sha256:/, ""); +} + +function PrefRow({ + label, + hint, + control, +}: { + label: string; + hint: string; + control: React.ReactNode; +}) { + return ( +
+ + {label} + {hint} + + {control} +
+ ); +} + +function PreferencesPanel() { + const prefs = useWorkbenchPrefs(); + const setPref = useSetWorkbenchPref(); + return ( + +

+ Local to this browser. Applied on the next workbench load. +

+ setPref("landingRoute", e.target.value as LandingRoute)} + className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 py-1 text-sm text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]" + > + {LANDING_ROUTES.map((route) => ( + + ))} + + } + /> + setPref("inspectorDefaultOpen", !prefs.inspectorDefaultOpen)} + className={`inline-flex h-6 items-center rounded-md border px-2 text-xs transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${ + prefs.inspectorDefaultOpen + ? "border-[var(--color-selected-border)] bg-[var(--color-selected-bg)] text-[var(--color-text-primary)]" + : "border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] text-[var(--color-text-secondary)]" + }`} + > + {prefs.inspectorDefaultOpen ? "On" : "Off"} + + } + /> +
+ ); +} + +function RuntimePanel() { + const statusQuery = useRuntimeStatus(); + const status = statusQuery.data; + const gitDigest = digestPayload(status?.git_revision); + return ( + +

{CLI_ONLY_STATEMENT}

+ {statusQuery.isLoading ? ( + + ) : statusQuery.isError ? ( + + ) : status ? ( + : "unknown", + }, + { key: "engine_state_present", value: status.engine_state_present ? "yes" : "no" }, + { + key: "checkpoint_revision", + value: status.checkpoint_revision || "none", + mono: true, + }, + { + key: "revision_warning", + value: status.revision_warning ? ( + revision mismatch + ) : ( + "none" + ), + }, + { key: "active_session_id", value: status.active_session_id ?? "none", mono: true }, + { key: "mutation_mode", value: status.mutation_mode, mono: true }, + ]} + /> + ) : null} +
+ ); +} + +export function SettingsRoute() { + return ( +
+ + +
+ ); +} diff --git a/workbench-ui/src/app/workbenchPrefs.test.ts b/workbench-ui/src/app/workbenchPrefs.test.ts new file mode 100644 index 00000000..15a402d3 --- /dev/null +++ b/workbench-ui/src/app/workbenchPrefs.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + DEFAULT_PREFS, + getWorkbenchPrefs, + setWorkbenchPref, +} from "./workbenchPrefs"; + +const PREFS_KEY = "core-workbench-prefs"; + +describe("workbenchPrefs", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("returns defaults when nothing is stored", () => { + expect(getWorkbenchPrefs()).toEqual(DEFAULT_PREFS); + }); + + it("persists a pref and reads it back (survives reload)", () => { + setWorkbenchPref("landingRoute", "vault"); + setWorkbenchPref("inspectorDefaultOpen", true); + // a fresh read models a page reload — values survive + expect(getWorkbenchPrefs()).toEqual({ + landingRoute: "vault", + inspectorDefaultOpen: true, + }); + }); + + it("falls back to defaults for an invalid stored landing route", () => { + localStorage.setItem( + PREFS_KEY, + JSON.stringify({ landingRoute: "not-a-route", inspectorDefaultOpen: "yes" }), + ); + expect(getWorkbenchPrefs()).toEqual(DEFAULT_PREFS); + }); + + it("falls back to defaults on malformed JSON", () => { + localStorage.setItem(PREFS_KEY, "{not json"); + expect(getWorkbenchPrefs()).toEqual(DEFAULT_PREFS); + }); + + it("merges a partial update without dropping the other pref", () => { + setWorkbenchPref("inspectorDefaultOpen", true); + setWorkbenchPref("landingRoute", "packs"); + expect(getWorkbenchPrefs()).toEqual({ + landingRoute: "packs", + inspectorDefaultOpen: true, + }); + }); +}); diff --git a/workbench-ui/src/app/workbenchPrefs.ts b/workbench-ui/src/app/workbenchPrefs.ts new file mode 100644 index 00000000..3f46a442 --- /dev/null +++ b/workbench-ui/src/app/workbenchPrefs.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useState } from "react"; + +// Local, single-operator workbench preferences (ADR-0160: local-only, no +// cloud, no accounts). Persisted in localStorage; every pref here is read +// by a real consumer — no setting exists that does nothing. + +const PREFS_KEY = "core-workbench-prefs"; +const PREFS_EVENT = "core-workbench-prefs-change"; + +export const LANDING_ROUTES = [ + "chat", + "trace", + "proposals", + "evals", + "runs", + "packs", + "vault", + "audit", + "settings", +] as const; + +export type LandingRoute = (typeof LANDING_ROUTES)[number]; + +export interface WorkbenchPrefs { + /** Route the workbench opens to (consumed by the App index redirect). */ + landingRoute: LandingRoute; + /** Whether the evidence inspector starts open (consumed by EvidenceProvider). */ + inspectorDefaultOpen: boolean; +} + +export const DEFAULT_PREFS: WorkbenchPrefs = { + landingRoute: "chat", + inspectorDefaultOpen: false, +}; + +function isLandingRoute(value: unknown): value is LandingRoute { + return typeof value === "string" && (LANDING_ROUTES as readonly string[]).includes(value); +} + +export function getWorkbenchPrefs(): WorkbenchPrefs { + try { + const raw = localStorage.getItem(PREFS_KEY); + if (!raw) return DEFAULT_PREFS; + const parsed = JSON.parse(raw) as Partial; + return { + landingRoute: isLandingRoute(parsed.landingRoute) + ? parsed.landingRoute + : DEFAULT_PREFS.landingRoute, + inspectorDefaultOpen: + typeof parsed.inspectorDefaultOpen === "boolean" + ? parsed.inspectorDefaultOpen + : DEFAULT_PREFS.inspectorDefaultOpen, + }; + } catch { + return DEFAULT_PREFS; + } +} + +export function setWorkbenchPref( + key: K, + value: WorkbenchPrefs[K], +): void { + const next = { ...getWorkbenchPrefs(), [key]: value }; + try { + localStorage.setItem(PREFS_KEY, JSON.stringify(next)); + } catch { + // Best-effort: restricted storage contexts must not break the UI. + } + // Notify same-tab listeners (the native `storage` event only fires + // cross-tab); the Settings controls update live. + window.dispatchEvent(new Event(PREFS_EVENT)); +} + +export function useWorkbenchPrefs(): WorkbenchPrefs { + const [prefs, setPrefs] = useState(getWorkbenchPrefs); + + useEffect(() => { + const sync = () => setPrefs(getWorkbenchPrefs()); + window.addEventListener(PREFS_EVENT, sync); + window.addEventListener("storage", sync); + return () => { + window.removeEventListener(PREFS_EVENT, sync); + window.removeEventListener("storage", sync); + }; + }, []); + + return prefs; +} + +export function useSetWorkbenchPref(): typeof setWorkbenchPref { + return useCallback(setWorkbenchPref, []); +} diff --git a/workbench-ui/src/routes/SettingsRoutePlaceholder.tsx b/workbench-ui/src/routes/SettingsRoutePlaceholder.tsx deleted file mode 100644 index 3ba9e87a..00000000 --- a/workbench-ui/src/routes/SettingsRoutePlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { EmptyState } from "../design/components/states/EmptyState"; - -export function SettingsRoutePlaceholder() { - return ( - - ); -}