feat(workbench): Settings route — wired prefs + read-only runtime status (Wave R2, final route)

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.
This commit is contained in:
Shay 2026-06-12 21:38:16 -07:00
parent 3afc8eee01
commit 0ff3e3b2c8
8 changed files with 424 additions and 14 deletions

View file

@ -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() {
<BrowserRouter>
<Routes>
<Route path="/" element={<Shell />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route index element={<Navigate to={`/${getWorkbenchPrefs().landingRoute}`} replace />} />
<Route path="chat" element={<ChatRoute />} />
<Route path="trace/:turnId?" element={<TraceRoute />} />
<Route path="replay/:artifactId?" element={<ReplayRoute />} />
@ -30,7 +31,7 @@ export function App() {
<Route path="packs/:packId?" element={<PacksRoute />} />
<Route path="vault" element={<VaultRoute />} />
<Route path="audit" element={<AuditRoute />} />
<Route path="settings" element={<SettingsRoutePlaceholder />} />
<Route path="settings" element={<SettingsRoute />} />
</Route>
<Route path="/preview" element={<PreviewPage />} />
</Routes>

View file

@ -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<EvidenceContextValue | null>(null);
export function EvidenceProvider({ children }: { children: ReactNode }) {
const [subject, setSubjectState] = useState<EvidenceSubject>(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) => {

View file

@ -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(<SettingsRoute />);
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(<SettingsRoute />);
await expectErrorContract();
});
});

View file

@ -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(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={["/settings"]}>
<SettingsRoute />
</MemoryRouter>
</QueryClientProvider>,
);
}
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();
});
});

View file

@ -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 (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 border-b border-[var(--color-border-subtle)] py-3 last:border-b-0">
<span className="min-w-0">
<span className="block text-sm text-[var(--color-text-primary)]">{label}</span>
<span className="mt-0.5 block text-xs text-[var(--color-text-muted)]">{hint}</span>
</span>
<span className="justify-self-end">{control}</span>
</div>
);
}
function PreferencesPanel() {
const prefs = useWorkbenchPrefs();
const setPref = useSetWorkbenchPref();
return (
<Panel title="Workbench preferences">
<p className="m-0 mb-1 text-xs text-[var(--color-text-muted)]">
Local to this browser. Applied on the next workbench load.
</p>
<PrefRow
label="Landing route"
hint="Where the workbench opens."
control={
<select
aria-label="Landing route"
value={prefs.landingRoute}
onChange={(e) => 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) => (
<option key={route} value={route}>
/{route}
</option>
))}
</select>
}
/>
<PrefRow
label="Inspector open by default"
hint="Start each session with the evidence inspector visible."
control={
<button
type="button"
role="switch"
aria-checked={prefs.inspectorDefaultOpen}
aria-label="Inspector open by default"
onClick={() => 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"}
</button>
}
/>
</Panel>
);
}
function RuntimePanel() {
const statusQuery = useRuntimeStatus();
const status = statusQuery.data;
const gitDigest = digestPayload(status?.git_revision);
return (
<Panel title="Runtime">
<p className="m-0 mb-3 text-xs text-[var(--color-text-muted)]">{CLI_ONLY_STATEMENT}</p>
{statusQuery.isLoading ? (
<LoadingState label="Loading runtime status..." />
) : statusQuery.isError ? (
<ErrorState
whatFailed={errorMessage(statusQuery.error)}
mutationStatus="No settings mutation occurred."
reproducer="curl /runtime/status"
retrySafety="Retry: safe"
/>
) : status ? (
<MetadataTable
rows={[
{ key: "backend", value: status.backend, mono: true },
{
key: "git_revision",
value: gitDigest ? <DigestBadge digest={gitDigest} truncate={12} /> : "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 ? (
<span className="text-[var(--color-state-warning-text)]">revision mismatch</span>
) : (
"none"
),
},
{ key: "active_session_id", value: status.active_session_id ?? "none", mono: true },
{ key: "mutation_mode", value: status.mutation_mode, mono: true },
]}
/>
) : null}
</Panel>
);
}
export function SettingsRoute() {
return (
<div className="grid gap-4 overflow-y-auto p-1">
<PreferencesPanel />
<RuntimePanel />
</div>
);
}

View file

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

View file

@ -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<WorkbenchPrefs>;
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<K extends keyof WorkbenchPrefs>(
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<WorkbenchPrefs>(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, []);
}

View file

@ -1,10 +0,0 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function SettingsRoutePlaceholder() {
return (
<EmptyState
statement="Settings — no data loaded yet."
nextAction={{ kind: "cli", command: "docs/runtime_contracts.md" }}
/>
);
}