Skip to content

Commit c9b59e5

Browse files
phodalcodex
andcommitted
feat(studio): organize control plane navigation
Implement the information architecture in docs/specs/2026-08-18-harness-studio-information-architecture.md. Keep retained Inspector evidence separate from live AG-UI runs, expose honest foundation states, and preserve responsive experiment navigation without interaction overlays. Validated with Harness Studio typecheck, 79 Vitest tests, 4 Playwright Chromium flows, 6 doc-link tests, and the root preview health and canvas-module smoke checks. Co-authored-by: Codex (GPT 5.6 Sol) <codex@openai.com>
1 parent 87edfeb commit c9b59e5

9 files changed

Lines changed: 351 additions & 124 deletions

File tree

docs/specs/2026-08-18-harness-studio-information-architecture.md

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## Traceability
44

55
- Spec ID: harness-studio-information-architecture
6-
- Status: Draft
6+
- Status: Implemented
77

88
## Intent
99

@@ -19,6 +19,42 @@ Workbench behavior remains authoritative. Studio may organize and embed those
1919
surfaces, but it must not merge their data models, turn observed associations
2020
into causal claims, or present roadmap capabilities as implemented.
2121

22+
The supplied research report is treated as a product-direction brief, not as a
23+
source-backed competitor benchmark. Its useful organizing unit is retained:
24+
`Harness Revision x Task Suite x Runtime Envelope`; statistical evaluation,
25+
promotion, collaboration, and registry maturity remain unproven roadmap scope.
26+
27+
## Information Architecture
28+
29+
```mermaid
30+
flowchart TD
31+
Studio["Harness Studio · local control plane"] --> Overview["Overview · operating model and loaded inputs"]
32+
Studio --> Inspector["Inspector · Observe"]
33+
Studio --> Harnesses["Harnesses · Compose · foundation"]
34+
Studio --> Suites["Task Suites · Compose · foundation"]
35+
Studio --> Experiments["Experiments · Validate"]
36+
Studio --> Registry["Registry · Govern · foundation"]
37+
38+
Inspector --> Delivery["Delivery Workbench"]
39+
Delivery --> Evidence["Capability or Date → Session → Commit or File"]
40+
Delivery -. "sandboxed read-only document" .-> Report["HarnessInspectorReportV1 HTML"]
41+
42+
Experiments --> Bench["Harness Bench · design and comparison notebook"]
43+
Experiments --> Live["Live trial · AG-UI observation"]
44+
Experiments --> Results["Frozen evidence results"]
45+
46+
Harnesses -. "future contract" .-> Revision["Harness Revision"]
47+
Suites -. "future contract" .-> TaskSuite["Task Suite"]
48+
Revision -. "analysis unit" .-> Bench
49+
TaskSuite -. "analysis unit" .-> Bench
50+
Bench -. "fixed runtime envelope" .-> Runtime["Runtime Envelope"]
51+
Results -. "future evidence bundle" .-> Registry
52+
```
53+
54+
The navigation order follows the operating loop `Observe -> Compose ->
55+
Experiment -> Explain -> Promote`. `Builder`, `Run`, `Compare`, `Results`, and
56+
`Workbench` are modes within those durable objects, not peer applications.
57+
2258
## Acceptance Scenarios
2359

2460
- AC-1: Studio has one persistent primary navigation with `Overview`,
@@ -34,12 +70,14 @@ into causal claims, or present roadmap capabilities as implemented.
3470
explicit CLI option and opened inside the `Inspector` workspace. The report
3571
remains read-only and authoritative behind a sandboxed document boundary;
3672
Studio does not duplicate its report model or rewrite its Workbench in React.
37-
When no report is supplied, the UI names the missing input and preserves any
38-
available Session Debugger entry.
73+
When no report is supplied, the UI names the missing retained-evidence input.
74+
A live AG-UI endpoint remains under `Experiments`; it must not make the
75+
hard-coded Session Debugger fixture appear to be real Inspector evidence.
3976
- AC-4: Existing experiment Builder, checkpoint lock, Run/Cancel, synchronized
4077
comparison selection, Trace, Evidence, live AG-UI run, and frozen verdict
4178
behavior remain reachable. Their current request, reducer, comparison, and
42-
evidence contracts are unchanged.
79+
evidence contracts are unchanged; the recorded Session Debugger sample is
80+
no longer a default Studio destination.
4381
- AC-5: `Harnesses`, `Task Suites`, and `Registry` pages expose the intended
4482
object hierarchy and the current implementation boundary. Unsupported source
4583
editing, suite curation, promotion, rollback, and revalidation controls are
@@ -94,9 +132,9 @@ into causal claims, or present roadmap capabilities as implemented.
94132
- AC-3/AC-8: server tests for `/api/config`, `/inspector`, missing-report 404,
95133
and CLI parsing; browser checks confirm the iframe loads the self-contained
96134
Workbench and remains sandboxed.
97-
- AC-4: existing Harness Studio Vitest and Playwright suites remain green,
98-
including experiment locking/running, comparison selection, Session Debugger,
99-
live AG-UI, and evidence rendering.
135+
- AC-4: existing Harness Studio model/server coverage remains green; built-app
136+
browser checks cover experiment locking/running, comparison selection, live
137+
AG-UI, Inspector drill-down, and evidence rendering.
100138
- AC-6: browser screenshots and measured document widths at 1440 by 900 and
101139
390 by 844, with console/page errors inspected.
102140
- Risk: primary navigation can imply unsupported products. Mitigation: every
@@ -109,3 +147,18 @@ into causal claims, or present roadmap capabilities as implemented.
109147
- Risk: the outer shell can reduce the space available to dense workbenches.
110148
Mitigation: use a compact rail on desktop, an overlay navigation on narrow
111149
screens, local scrolling, and direct desktop/narrow browser measurements.
150+
151+
### Implementation evidence
152+
153+
- `npm run build -w @qoder-ai/harness-studio` — passed.
154+
- `npm run typecheck -w @qoder-ai/harness-studio` — passed.
155+
- `npm test -w @qoder-ai/harness-studio` — 15 files / 79 tests passed.
156+
- `npx vitest run test/skills-docs/doc-link-graph.test.mjs` — 6 tests passed;
157+
the routing graph was regenerated and remained unchanged.
158+
- `npm run test:browser -w @qoder-ai/harness-studio` — 4 Chromium flows passed,
159+
including desktop and 390 px interaction and overflow checks.
160+
- In-app Playwright checks at 1440 x 900 and 390 x 844 exercised Overview,
161+
primary navigation, Bench, the mobile Bench / Live trial switch, checkpoint
162+
overlay, live-run composer, real Inspector Workbench, and Inspector session
163+
drill-down. Measured document width matched viewport width and inspected
164+
browser logs contained no errors or warnings.

packages/harness-studio/README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
# @qoder-ai/harness-studio
22

3-
A local React studio for [`@qoder-ai/harness`](../harness/README.md): one UI
4-
over the evidence and experiment surfaces the Harness toolchain produces.
3+
A local React control plane for [`@qoder-ai/harness`](../harness/README.md):
4+
durable Harness objects organize the evidence, inspection, live-run, and
5+
experiment surfaces the Harness toolchain produces.
6+
7+
- **Harness control plane** — organizes work as `Overview`, `Inspector`,
8+
`Harnesses`, `Task Suites`, `Experiments`, and `Registry`. Unimplemented
9+
source, suite, and promotion capabilities stay visibly marked as foundations
10+
instead of appearing as working controls.
511

612
- **Inspector workspace** — embeds an explicitly supplied, self-contained
713
Harness Inspector report behind a sandboxed, read-only document boundary.
@@ -32,7 +38,7 @@ npx @qoder-ai/harness-studio --evidence ./harness-readme-compare-evidence
3238
# Live runs only
3339
npx @qoder-ai/harness-studio --harness my-agent.harness
3440

35-
# Both surfaces on one port
41+
# Combined control plane on one port
3642
npx @qoder-ai/harness-studio \
3743
--inspector ./harness-inspector.html \
3844
--harness my-agent.harness \
@@ -73,6 +79,8 @@ src/app/ components plus pure state modules:
7379
agui-store.ts AG-UI event → run view state reducer
7480
compare-model.ts verdict.json → table model
7581
sse-client.ts incremental SSE frame parser
82+
studio-shell-model.ts
83+
config → IA/readiness projection
7684
src/server/ static host + /api/config + /api/evidence + embedded /agui
7785
read-only /inspector + checkpoint history list/resolve
7886
+ durable experiment lock
@@ -94,4 +102,5 @@ GitHub Actions `Publish npm` workflow. Local commands only build, test, pack,
94102
or dry-run; do not publish this workspace from a developer machine.
95103

96104
See the spec:
97-
[Harness UI and Studio](https://github.com/QoderAI/better-harness/blob/main/docs/specs/2026-08-15-harness-ui-studio.md).
105+
[Harness UI and Studio](https://github.com/QoderAI/better-harness/blob/main/docs/specs/2026-08-15-harness-ui-studio.md)
106+
and [Harness Studio information architecture](../../docs/specs/2026-08-18-harness-studio-information-architecture.md).

packages/harness-studio/src/app/App.tsx

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState, type ReactNode } from "react";
1+
import { useEffect, useRef, useState, type ReactNode } from "react";
22
import type { Icon } from "@phosphor-icons/react";
33
import { ArrowRight } from "@phosphor-icons/react/ArrowRight";
44
import { Binoculars } from "@phosphor-icons/react/Binoculars";
@@ -21,7 +21,6 @@ import {
2121
type StudioConfig,
2222
type StudioDestination,
2323
type StudioExperimentSurface,
24-
type StudioInspectorSurface,
2524
} from "./studio-shell-model.js";
2625

2726
const NAV_ICONS: Record<StudioArea, Icon> = {
@@ -52,10 +51,11 @@ const EMPTY_CONFIG: StudioConfig = {
5251

5352
export function App(): React.JSX.Element {
5453
const [config, setConfig] = useState<StudioConfig | undefined>(undefined);
54+
const [configFailure, setConfigFailure] = useState<string | null>(null);
5555
const [area, setArea] = useState<StudioArea>(areaFromHash);
5656
const [experimentSurface, setExperimentSurface] = useState<StudioExperimentSurface>("experiment");
57-
const [inspectorSurface, setInspectorSurface] = useState<StudioInspectorSurface>("workbench");
5857
const [navigationOpen, setNavigationOpen] = useState(false);
58+
const navigationToggleRef = useRef<HTMLButtonElement>(null);
5959

6060
useEffect(() => {
6161
let cancelled = false;
@@ -65,12 +65,15 @@ export function App(): React.JSX.Element {
6565
if (!response.ok) throw new Error(`Studio config failed (${response.status}).`);
6666
const loaded = { ...EMPTY_CONFIG, ...(await response.json() as Partial<StudioConfig>) };
6767
if (!cancelled) {
68+
setConfigFailure(null);
6869
setConfig(loaded);
6970
setExperimentSurface(experimentSurfaces(loaded)[0] ?? "experiment");
70-
setInspectorSurface(inspectorSurfaces(loaded)[0] ?? "workbench");
7171
}
72-
} catch {
73-
if (!cancelled) setConfig(EMPTY_CONFIG);
72+
} catch (error) {
73+
if (!cancelled) {
74+
setConfigFailure(error instanceof Error ? error.message : "Studio configuration is unavailable.");
75+
setConfig(EMPTY_CONFIG);
76+
}
7477
}
7578
})();
7679
return () => {
@@ -81,18 +84,42 @@ export function App(): React.JSX.Element {
8184
useEffect(() => {
8285
const onHashChange = (): void => setArea(areaFromHash());
8386
globalThis.addEventListener("hashchange", onHashChange);
84-
return () => globalThis.removeEventListener("hashchange", onHashChange);
87+
globalThis.addEventListener("popstate", onHashChange);
88+
return () => {
89+
globalThis.removeEventListener("hashchange", onHashChange);
90+
globalThis.removeEventListener("popstate", onHashChange);
91+
};
8592
}, []);
8693

94+
useEffect(() => {
95+
if (!navigationOpen) return undefined;
96+
const focusFrame = globalThis.requestAnimationFrame(() => {
97+
document.querySelector<HTMLButtonElement>(".studio-primary-nav nav button")?.focus();
98+
});
99+
const onKeyDown = (event: KeyboardEvent): void => {
100+
if (event.key !== "Escape") return;
101+
setNavigationOpen(false);
102+
navigationToggleRef.current?.focus();
103+
};
104+
globalThis.addEventListener("keydown", onKeyDown);
105+
return () => {
106+
globalThis.cancelAnimationFrame(focusFrame);
107+
globalThis.removeEventListener("keydown", onKeyDown);
108+
};
109+
}, [navigationOpen]);
110+
87111
function openArea(next: StudioArea): void {
88112
setArea(next);
89113
setNavigationOpen(false);
90-
globalThis.history.replaceState(null, "", `#/${next}`);
114+
if (area !== next) globalThis.history.pushState(null, "", `#/${next}`);
91115
}
92116

93117
if (config === undefined) {
94118
return <main className="studio-loading"><span className="studio-loading-mark"><GitBranch size={18} weight="bold" /></span><p>Loading Harness control plane…</p></main>;
95119
}
120+
if (configFailure !== null) {
121+
return <main className="studio-loading" role="alert"><span className="studio-loading-mark"><GitBranch size={18} weight="bold" /></span><strong>Cannot load Studio configuration.</strong><p>{configFailure}</p></main>;
122+
}
96123

97124
const destinations = studioDestinations(config);
98125
const current = destinations.find((destination) => destination.id === area) ?? destinations[0]!;
@@ -107,30 +134,23 @@ export function App(): React.JSX.Element {
107134
onSelect={setExperimentSurface}
108135
/>
109136
);
110-
const inspectorNavigation = (
111-
<SurfaceNavigation
112-
label="Inspector surfaces"
113-
items={inspectorSurfaces(config).map((id) => ({
114-
id,
115-
label: id === "workbench" ? "Delivery" : "Session",
116-
}))}
117-
active={inspectorSurface}
118-
onSelect={setInspectorSurface}
119-
/>
120-
);
137+
const contextNavigation = area === "experiments" && experimentSurfaces(config).length > 1
138+
? experimentNavigation
139+
: null;
121140

122141
return <div className={`studio-control-plane${navigationOpen ? " navigation-open" : ""}`}>
123142
<PrimaryNavigation destinations={destinations} current={area} onSelect={openArea} />
124-
<button className="studio-nav-backdrop" type="button" aria-label="Close Studio navigation" onClick={() => setNavigationOpen(false)} />
143+
<button className="studio-nav-backdrop" type="button" aria-label="Close Studio navigation" onClick={() => { setNavigationOpen(false); navigationToggleRef.current?.focus(); }} />
125144
<section className="studio-area">
126-
<header className="studio-context-bar">
127-
<button className="studio-nav-toggle" type="button" aria-label="Open Studio navigation" aria-expanded={navigationOpen} onClick={() => setNavigationOpen((value) => !value)}><SidebarSimple size={17} /></button>
128-
<div className="studio-context-title"><small>{AREA_COPY[area].eyebrow}</small><strong>{AREA_COPY[area].title}</strong></div>
145+
<header className={`studio-context-bar${contextNavigation ? " has-surface-navigation" : ""}`}>
146+
<button ref={navigationToggleRef} className="studio-nav-toggle" type="button" aria-label={navigationOpen ? "Close Studio navigation" : "Open Studio navigation"} aria-expanded={navigationOpen} onClick={() => setNavigationOpen((value) => !value)}><SidebarSimple size={17} /></button>
147+
<div className="studio-context-title"><small>{AREA_COPY[area].eyebrow}</small><h1>{AREA_COPY[area].title}</h1></div>
148+
{contextNavigation && <div className="studio-context-navigation">{contextNavigation}</div>}
129149
<div className="studio-context-state"><span className={`availability-dot availability-${current.availability}`} /><strong>{current.status}</strong><span>Local control plane</span></div>
130150
</header>
131151
<div className={`studio-surface studio-surface-${area}`}>
132152
{area === "overview" && <Overview config={config} onOpen={openArea} />}
133-
{area === "inspector" && <InspectorWorkspace config={config} surface={inspectorSurface} navigation={inspectorNavigation} />}
153+
{area === "inspector" && <InspectorWorkspace config={config} />}
134154
{area === "harnesses" && <HarnessesWorkspace config={config} onOpen={openArea} />}
135155
{area === "task-suites" && <TaskSuitesWorkspace config={config} onOpen={openArea} />}
136156
{area === "experiments" && <ExperimentsWorkspace config={config} surface={experimentSurface} navigation={experimentNavigation} />}
@@ -166,8 +186,10 @@ function Overview(props: { config: StudioConfig; onOpen: (area: StudioArea) => v
166186
const summary = capabilitySummary(props.config);
167187
const nextArea: StudioArea = props.config.experimentEnabled
168188
? "experiments"
169-
: props.config.inspectorEnabled || props.config.aguiEnabled
189+
: props.config.inspectorEnabled
170190
? "inspector"
191+
: props.config.aguiEnabled || props.config.evidenceEnabled
192+
? "experiments"
171193
: "harnesses";
172194
const inputs = [
173195
["Inspector report", props.config.inspectorEnabled],
@@ -177,7 +199,7 @@ function Overview(props: { config: StudioConfig; onOpen: (area: StudioArea) => v
177199
["History adapter", props.config.historyEnabled],
178200
] as const;
179201
const loop = [
180-
{ step: "01", label: "Observe", detail: "Inspect retained delivery evidence", ready: props.config.inspectorEnabled || props.config.aguiEnabled },
202+
{ step: "01", label: "Observe", detail: "Inspect retained delivery evidence", ready: props.config.inspectorEnabled },
181203
{ step: "02", label: "Compose", detail: "Name the Harness change", ready: props.config.aguiEnabled || props.config.experimentEnabled },
182204
{ step: "03", label: "Experiment", detail: "Hold task and runtime constant", ready: props.config.experimentEnabled },
183205
{ step: "04", label: "Explain", detail: "Compare outcome and trace evidence", ready: props.config.experimentEnabled || props.config.evidenceEnabled },
@@ -198,25 +220,18 @@ function Overview(props: { config: StudioConfig; onOpen: (area: StudioArea) => v
198220
</main>;
199221
}
200222

201-
function InspectorWorkspace(props: {
202-
config: StudioConfig;
203-
surface: StudioInspectorSurface;
204-
navigation: ReactNode;
205-
}): React.JSX.Element {
223+
function InspectorWorkspace(props: { config: StudioConfig }): React.JSX.Element {
206224
const available = inspectorSurfaces(props.config);
207225
if (available.length === 0) {
208-
return <EmptyWorkspace eyebrow="Observed delivery" title="Connect an Inspector report" detail="Render a privacy-filtered Harness Inspector HTML report, then start Studio with --inspector <report.html>. Session inspection becomes available when a Harness runtime is loaded." command="--inspector ./harness-inspector.html" />;
209-
}
210-
if (props.surface === "session" && props.config.aguiEnabled) {
211-
return <div className="debugger-mode"><RunView aguiEndpoint="agui" navigation={props.navigation} /></div>;
226+
return <EmptyWorkspace eyebrow="Observed delivery" title="Connect an Inspector report" detail="Render a privacy-filtered Harness Inspector HTML report, then start Studio with --inspector <report.html>. Live runs remain under Experiments until Studio has a retained-session contract." command="--inspector ./harness-inspector.html" />;
212227
}
213228
if (props.config.inspectorEnabled) {
214229
return <section className="inspector-workspace" aria-label="Inspector workspace">
215-
<header><div><strong>Inspector Workbench</strong><span>Cross-delivery evidence · read-only</span></div>{props.navigation}<p>Capability / Date → Session → Commit / File</p></header>
230+
<header><div><strong>Inspector Workbench</strong><span>Cross-delivery evidence · read-only</span></div><p>Capability / Date → Session → Commit / File</p></header>
216231
<iframe title="Harness Inspector Workbench" src="inspector" sandbox="allow-scripts" referrerPolicy="no-referrer" />
217232
</section>;
218233
}
219-
return <div className="debugger-mode"><RunView aguiEndpoint="agui" navigation={props.navigation} /></div>;
234+
return <EmptyWorkspace eyebrow="Observed delivery" title="Connect an Inspector report" detail="Inspector requires retained, privacy-filtered evidence. It never substitutes the recorded Session Debugger fixture for a real workspace." command="--inspector ./harness-inspector.html" />;
220235
}
221236

222237
function ExperimentsWorkspace(props: {

0 commit comments

Comments
 (0)