Skip to content

Commit a8922dc

Browse files
phodalQoder-AI
andcommitted
feat(studio): render compiled TSX artifacts in a sandboxed pane
Implements Increment 1 of docs/specs/2026-08-20-harness-studio-artifact-view.md: Studio can now show what a run produced instead of sending the reviewer out to open files by hand. `--artifacts <dir>` indexes a directory behind opaque ids, compiles `.tsx` and `.jsx` artifacts on request through esbuild-wasm `transformSync`, and mounts them in an iframe sandboxed without `allow-same-origin`, so artifact code runs on an opaque origin and cannot reach the Studio shell. The opaque origin splits subresource loading: the host bundle ships as a classic script while the compiled module needs CORS, including on the failure response, or the compiler diagnostic is lost behind a generic fetch error. Non-module kinds state that no renderer exists yet rather than rendering a guess, and nothing compiles until the reader selects a row. `esbuild-wasm` moves from devDependency to dependency because compilation now happens at request time and the published files list is dist/ only. Both artifact routes guard their own directory reads: the server dispatches with `void route(...)`, so a directory removed after startup would otherwise reject out of the handler and end the process. The served module's sourceMappingURL now names a sibling route that answers with a real map. Validated with 111 Studio tests, 13 Playwright tests with no console or page errors and screenshots at wide, compact, and narrow widths, and 1412 root tests. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 842a6b5 commit a8922dc

20 files changed

Lines changed: 1631 additions & 7 deletions

docs/specs/2026-08-20-harness-studio-artifact-view.md

Lines changed: 408 additions & 0 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/harness-studio/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@shikijs/engine-javascript": "^4.4.3",
4848
"@shikijs/langs": "^4.4.3",
4949
"@tanstack/react-virtual": "^3.14.9",
50+
"esbuild-wasm": "0.28.1",
5051
"react": "19.2.8",
5152
"react-dom": "19.2.8"
5253
},
@@ -55,7 +56,6 @@
5556
"@types/node": "26.2.0",
5657
"@types/react": "19.2.18",
5758
"@types/react-dom": "19.2.4",
58-
"esbuild-wasm": "0.28.1",
5959
"typescript": "7.0.2",
6060
"vitest": "4.1.10"
6161
},

packages/harness-studio/scripts/build-app.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,26 @@ await build({
2525
define: { "process.env.NODE_ENV": '"production"' },
2626
logLevel: "warning",
2727
});
28+
// The artifact host is a separate IIFE bundle, not a chunk of the shell: it runs
29+
// inside the sandboxed artifact iframe, which has an opaque origin and can load
30+
// a classic script without CORS but not a module script.
31+
await build({
32+
entryPoints: { "artifact-host": join(packageRoot, "src", "app", "artifact-host.ts") },
33+
outdir: join(appDir, "assets"),
34+
entryNames: "[name]",
35+
bundle: true,
36+
format: "iife",
37+
globalName: "harnessArtifactHost",
38+
platform: "browser",
39+
target: "es2022",
40+
minify: true,
41+
sourcemap: true,
42+
define: { "process.env.NODE_ENV": '"production"' },
43+
logLevel: "warning",
44+
});
2845
await Promise.all([
2946
copyFile(join(packageRoot, "src", "app", "index.html"), join(appDir, "index.html")),
47+
copyFile(join(packageRoot, "src", "app", "artifact-host.html"), join(appDir, "artifact-host.html")),
3048
...["tokens.css", "shell.css", "workbench.css"].map((file) =>
3149
copyFile(join(packageRoot, "src", "app", "styles", file), join(appDir, "assets", file)),
3250
),

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Binoculars } from "@phosphor-icons/react/Binoculars";
55
import { BugBeetle } from "@phosphor-icons/react/BugBeetle";
66
import { Flask } from "@phosphor-icons/react/Flask";
77
import { GitBranch } from "@phosphor-icons/react/GitBranch";
8+
import { Package } from "@phosphor-icons/react/Package";
89
import { SidebarSimple } from "@phosphor-icons/react/SidebarSimple";
910
import { SquaresFour } from "@phosphor-icons/react/SquaresFour";
1011
import { CompareView } from "./CompareView.js";
@@ -26,13 +27,15 @@ import {
2627
const NAV_ICONS: Record<StudioArea, Icon> = {
2728
overview: SquaresFour,
2829
inspector: Binoculars,
30+
artifacts: Package,
2931
debugger: BugBeetle,
3032
compare: Flask,
3133
};
3234

3335
const AREA_COPY: Record<StudioArea, { eyebrow: string; title: string }> = {
3436
overview: { eyebrow: "Control", title: "Harness Control Center" },
3537
inspector: { eyebrow: "Observe", title: "Inspector" },
38+
artifacts: { eyebrow: "Observe", title: "Artifacts" },
3639
debugger: { eyebrow: "Run", title: "Debugger" },
3740
compare: { eyebrow: "Validate", title: "Compare" },
3841
};
@@ -48,6 +51,7 @@ interface StudioSourceOption {
4851

4952
const EMPTY_CONFIG: StudioConfig = {
5053
aguiEnabled: false,
54+
artifactsEnabled: false,
5155
evidenceEnabled: false,
5256
experimentEnabled: false,
5357
historyEnabled: false,
@@ -191,6 +195,7 @@ export function App(): React.JSX.Element {
191195
<div className={`studio-surface studio-surface-${area}`}>
192196
{area === "overview" && <Overview config={config} onOpen={openArea} />}
193197
{area === "inspector" && <InspectorWorkspace key={`inspector-${dataRevision}-${config.inspectorEnabled}`} config={config} />}
198+
{area === "artifacts" && <ArtifactsWorkspace key={`artifacts-${dataRevision}-${config.artifactsEnabled}`} config={config} />}
194199
{area === "debugger" && <DebuggerWorkspace config={config} />}
195200
{area === "compare" && <CompareWorkspace key={`compare-${dataRevision}-${config.experimentEnabled}-${config.evidenceEnabled}`} config={config} surface={compareSurface} navigation={compareNavigation} />}
196201
</div>
@@ -315,6 +320,96 @@ function InspectorWorkspace(props: { config: StudioConfig }): React.JSX.Element
315320
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" />;
316321
}
317322

323+
interface ArtifactDescriptor {
324+
id: string;
325+
kind: string;
326+
label: string;
327+
size: number;
328+
}
329+
330+
/**
331+
* Artifacts pane: a row list of run outputs plus a sandboxed preview.
332+
*
333+
* The preview frame withholds `allow-same-origin`, so artifact code runs on an
334+
* opaque origin and cannot reach the Studio shell.
335+
*/
336+
function ArtifactsWorkspace(props: { config: StudioConfig }): React.JSX.Element {
337+
const [artifacts, setArtifacts] = useState<ArtifactDescriptor[] | undefined>(undefined);
338+
const [failure, setFailure] = useState<string | undefined>(undefined);
339+
const [selected, setSelected] = useState<string | undefined>(undefined);
340+
341+
useEffect(() => {
342+
if (!props.config.artifactsEnabled) return;
343+
let cancelled = false;
344+
void (async () => {
345+
try {
346+
const response = await fetch("api/artifacts");
347+
if (!response.ok) throw new Error(`Artifact catalog failed (${response.status}).`);
348+
const payload = await response.json() as { artifacts?: ArtifactDescriptor[] };
349+
if (cancelled) return;
350+
// Deliberately no auto-selection: compiling an artifact the reader has
351+
// not asked for spends server work and can open the pane on an error.
352+
setArtifacts(Array.isArray(payload.artifacts) ? payload.artifacts : []);
353+
} catch (error) {
354+
if (!cancelled) setFailure(error instanceof Error ? error.message : String(error));
355+
}
356+
})();
357+
return () => { cancelled = true; };
358+
}, [props.config.artifactsEnabled]);
359+
360+
if (!props.config.artifactsEnabled) {
361+
return <EmptyWorkspace eyebrow="Run outputs" title="Load an artifact directory" detail="Artifacts renders what a run produced. It reads a directory read-only and never writes back to it." command="--artifacts ./artifacts" />;
362+
}
363+
if (failure !== undefined) {
364+
return <EmptyWorkspace eyebrow="Run outputs" title="Cannot read the artifact catalog" detail={failure} command="--artifacts ./artifacts" />;
365+
}
366+
if (artifacts === undefined) {
367+
return <p className="artifact-status" role="status">Loading artifacts…</p>;
368+
}
369+
if (artifacts.length === 0) {
370+
return <EmptyWorkspace eyebrow="Run outputs" title="No artifacts in this directory" detail="The configured directory holds no files Studio can render yet. Artifact rows appear once a run writes into it." command="--artifacts ./artifacts" />;
371+
}
372+
373+
const active = artifacts.find((entry) => entry.id === selected);
374+
return <section className="artifact-workspace" aria-label="Artifacts workspace">
375+
<div className="artifact-list-pane">
376+
<header><div><small>Retained</small><h2>Artifacts</h2></div><span>{artifacts.length}</span></header>
377+
<ul className="artifact-rows">
378+
{artifacts.map((entry) => <li key={entry.id}>
379+
<button
380+
type="button"
381+
className={entry.id === selected ? "selected" : undefined}
382+
aria-current={entry.id === selected}
383+
onClick={() => setSelected(entry.id)}
384+
>
385+
<span className="artifact-row-copy"><strong>{entry.label}</strong><small>{entry.kind} · {formatBytes(entry.size)}</small></span>
386+
</button>
387+
</li>)}
388+
</ul>
389+
</div>
390+
<div className="artifact-preview-pane">
391+
{active === undefined
392+
? <p className="artifact-status" role="status">Select an artifact to preview it.</p>
393+
: active.kind === "module"
394+
? <iframe
395+
key={active.id}
396+
className="artifact-frame"
397+
title={`Artifact preview: ${active.label}`}
398+
src={`artifact-host.html?module=api/artifacts/${active.id}/module.js`}
399+
sandbox="allow-scripts"
400+
referrerPolicy="no-referrer"
401+
/>
402+
: <p className="artifact-status" role="status">No renderer for <code>{active.kind}</code> yet. Only compiled modules render in this increment.</p>}
403+
</div>
404+
</section>;
405+
}
406+
407+
function formatBytes(size: number): string {
408+
if (size < 1024) return `${size} B`;
409+
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
410+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
411+
}
412+
318413
function DebuggerWorkspace(props: { config: StudioConfig }): React.JSX.Element {
319414
if (!props.config.aguiEnabled) {
320415
return <EmptyWorkspace eyebrow="Live runs" title="Load a harness for live runs" detail="The Debugger drives a live harness run over the embedded AG-UI endpoint and saves finished runs for replay." command="--harness ./my-agent.harness" />;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Harness Studio Artifact</title>
7+
<link rel="stylesheet" href="./assets/tokens.css" />
8+
<style>
9+
html,
10+
body {
11+
margin: 0;
12+
min-height: 100%;
13+
background: var(--color-surface, #ffffff);
14+
color: var(--color-text, #263244);
15+
font: 13px/1.5 var(--font-ui, system-ui, sans-serif);
16+
}
17+
#artifact-root {
18+
box-sizing: border-box;
19+
min-height: 100vh;
20+
padding: 12px;
21+
}
22+
.artifact-failure {
23+
display: flex;
24+
flex-direction: column;
25+
gap: 8px;
26+
padding: 12px;
27+
border: 1px solid var(--color-danger, #a63d45);
28+
border-radius: var(--radius-md, 4px);
29+
background: var(--color-danger-surface, #fdebed);
30+
color: var(--color-text, #263244);
31+
}
32+
.artifact-failure pre {
33+
margin: 0;
34+
overflow-x: auto;
35+
font: 12px/1.5 var(--font-code, ui-monospace, monospace);
36+
white-space: pre-wrap;
37+
word-break: break-word;
38+
}
39+
</style>
40+
</head>
41+
<body>
42+
<div id="artifact-root"></div>
43+
<!-- Classic script: the sandbox gives this document an opaque origin, where a
44+
module script would require CORS but a classic script does not. -->
45+
<script src="./assets/artifact-host.js"></script>
46+
<script>
47+
harnessArtifactHost.mount(new URLSearchParams(location.search).get("module"));
48+
</script>
49+
</body>
50+
</html>
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { Component, createElement, type ErrorInfo, type ReactNode } from "react";
2+
import * as React from "react";
3+
import { createRoot } from "react-dom/client";
4+
5+
/**
6+
* Artifact host runtime.
7+
*
8+
* This bundle runs inside the sandboxed artifact iframe, never in the Studio
9+
* shell. It is built as an IIFE and loaded with a classic `<script>` tag: the
10+
* iframe carries `sandbox="allow-scripts"` without `allow-same-origin`, so it
11+
* has an opaque origin, and classic scripts load from an opaque origin without
12+
* CORS while module scripts would not.
13+
*
14+
* The compiled artifact module is lowered to `React.createElement` and relies on
15+
* the `React` global installed here, so one React instance is shared with the
16+
* host and the artifact needs no import map.
17+
*/
18+
19+
interface BoundaryProps {
20+
children: ReactNode;
21+
}
22+
23+
interface BoundaryState {
24+
message: string | undefined;
25+
}
26+
27+
class ArtifactErrorBoundary extends Component<BoundaryProps, BoundaryState> {
28+
override state: BoundaryState = { message: undefined };
29+
30+
static getDerivedStateFromError(error: unknown): BoundaryState {
31+
return { message: error instanceof Error ? error.message : String(error) };
32+
}
33+
34+
override componentDidCatch(error: unknown, info: ErrorInfo): void {
35+
// Keep the failure observable for browser verification without letting a
36+
// throwing artifact take the frame down silently.
37+
console.error("[artifact] render failed", error, info.componentStack);
38+
}
39+
40+
override render(): ReactNode {
41+
const { message } = this.state;
42+
if (message === undefined) return this.props.children;
43+
return failureElement("This artifact failed while rendering.", message);
44+
}
45+
}
46+
47+
function failureElement(headline: string, detail: string): ReactNode {
48+
return createElement(
49+
"div",
50+
{ className: "artifact-failure", role: "alert" },
51+
createElement("strong", null, headline),
52+
createElement("pre", null, detail),
53+
);
54+
}
55+
56+
/**
57+
* Only same-origin artifact module paths are accepted. Without this the host
58+
* would import whatever URL a query string names.
59+
*/
60+
export function resolveModuleUrl(raw: string | null): string {
61+
const candidate = (raw ?? "").trim();
62+
if (candidate === "") throw new Error("No artifact module was requested.");
63+
if (!/^\/?api\/artifacts\/[A-Za-z0-9_-]+\/module\.js$/u.test(candidate)) {
64+
throw new Error(`Refusing to load an artifact module from '${candidate}'.`);
65+
}
66+
return new URL(candidate, document.baseURI).href;
67+
}
68+
69+
export async function mount(rawModule: string | null): Promise<void> {
70+
const root = document.getElementById("artifact-root");
71+
if (!(root instanceof HTMLElement)) {
72+
console.error("[artifact] host root element is missing");
73+
return;
74+
}
75+
76+
// The compiled artifact calls `React.createElement`, so the namespace has to
77+
// land on the global before the module is imported.
78+
(globalThis as unknown as { React: unknown }).React = React;
79+
80+
let url: string;
81+
try {
82+
url = resolveModuleUrl(rawModule);
83+
} catch (error) {
84+
createRoot(root).render(failureElement("This artifact could not be loaded.", messageOf(error)));
85+
return;
86+
}
87+
88+
try {
89+
const loaded = (await import(url)) as { default?: unknown };
90+
const Artifact = loaded.default;
91+
if (typeof Artifact !== "function") {
92+
throw new Error("Artifact modules must default-export a React component.");
93+
}
94+
createRoot(root).render(
95+
createElement(ArtifactErrorBoundary, null, createElement(Artifact as React.ComponentType)),
96+
);
97+
} catch (error) {
98+
// A failed module import reports only a generic fetch failure, so ask the
99+
// server directly for the diagnostic it already produced.
100+
const detail = (await serverDiagnostic(url)) ?? messageOf(error);
101+
createRoot(root).render(failureElement("This artifact could not be compiled or loaded.", detail));
102+
}
103+
}
104+
105+
/**
106+
* Recover the server's error body for a module the browser refused to import.
107+
* Returns undefined when the request succeeded or carried no readable reason.
108+
*/
109+
async function serverDiagnostic(url: string): Promise<string | undefined> {
110+
try {
111+
const response = await fetch(url);
112+
if (response.ok) return undefined;
113+
const body = await response.text();
114+
try {
115+
const parsed = JSON.parse(body) as { error?: unknown };
116+
return typeof parsed.error === "string" && parsed.error !== "" ? parsed.error : undefined;
117+
} catch {
118+
return body.trim() === "" ? undefined : body.trim();
119+
}
120+
} catch {
121+
return undefined;
122+
}
123+
}
124+
125+
function messageOf(error: unknown): string {
126+
return error instanceof Error && error.message ? error.message : String(error);
127+
}

0 commit comments

Comments
 (0)