Skip to content

Commit b737e86

Browse files
phodalcodex
andcommitted
feat(studio): render live code artifacts
Add the code-backed Artifact lifecycle for TSX and JSX with confined esbuild-wasm compilation, immutable build snapshots, and handshake-gated opaque-origin previews. Stream filesystem invalidations so Studio refreshes the selected build without a page reload while preserving a Source view and bounded diagnostics. This implements docs/specs/2026-08-22-studio-live-artifact-preview.md. It was validated with Studio typecheck, the complete package suite, Playwright Artifact flows, and the Markdown link graph. Generated code remains limited to the sandboxed no-network runtime. Co-authored-by: Codex (GPT 5.6 Sol) <codex@openai.com>
1 parent 36ea279 commit b737e86

13 files changed

Lines changed: 1144 additions & 18 deletions
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Render live code artifacts in Studio
2+
3+
## Traceability
4+
5+
- Spec ID: studio-live-artifact-preview
6+
- Status: Implemented
7+
8+
## Intent
9+
10+
Turn the code-backed half of Artifact View into a working, Studio-owned runtime.
11+
An operator looking at a generated React artifact should see the rendered output,
12+
see compile or runtime diagnostics when it is invalid, and receive a newer
13+
revision without refreshing Studio after the artifact changes on disk.
14+
15+
The lifecycle for this increment is:
16+
17+
```text
18+
Artifact Revision
19+
-> confined source project
20+
-> ArtifactCompileRuntime
21+
-> immutable ArtifactBuildSnapshot
22+
-> sandboxed ArtifactPreviewRuntime
23+
-> ArtifactViewHost commit
24+
```
25+
26+
This is a Studio capability. It does not depend on Qoder Canvas and does not
27+
broaden the existing Qoder Canvas compatibility bridge.
28+
29+
## Decisions
30+
31+
### D-1: TSX and JSX use the code-backed lifecycle
32+
33+
Studio resolves `.tsx` and `.jsx` artifacts to the `studio.react-preview`
34+
renderer with `backing: code`. Other code extensions remain inert source views
35+
until their executable contract is specified. The preview pane keeps an
36+
explicit Source view so rendered output never hides the revision that produced
37+
it.
38+
39+
### D-2: The compiler owns a confined virtual project
40+
41+
The compile runtime bundles the selected entry plus relative imports that
42+
resolve to regular files inside the configured artifact directory. Imports that
43+
escape that directory, symbolic or multiply-linked sources, extensionless
44+
ambiguous resolution, and arbitrary package dependencies fail closed. React's
45+
runtime modules are the only package imports supplied by Studio.
46+
47+
Each build is bounded by source count, total source bytes, output bytes, and
48+
diagnostic length. Build identity covers the artifact revision and compile
49+
runtime version. Exact-revision build routes return immutable snapshots; a stale
50+
revision still answers `409`.
51+
52+
### D-3: Preview execution is opaque-origin and message-driven
53+
54+
The server hosts a revision-scoped preview document with a restrictive CSP. The
55+
iframe has `sandbox="allow-scripts"` without `allow-same-origin`; it receives no
56+
credentials, network access, top navigation, forms, or parent DOM access.
57+
58+
The parent creates a `MessageChannel` after the frame loads and sends
59+
`runtime.init` with the expected artifact, revision, build, and runtime ids. The
60+
preview executes only after that handshake and reports `renderCompleted` or
61+
`renderFailed` over the transferred port. The host validates all ids and commits
62+
status only for the latest requested build, so a slow older build cannot replace
63+
a newer revision.
64+
65+
### D-4: Catalog changes are streamed, builds stay revision-scoped
66+
67+
`/api/artifacts/events` is a server-sent event stream. The server observes the
68+
currently active artifact directory and emits a coalesced invalidation when an
69+
entry or a nested project dependency changes. The browser then refetches
70+
`/api/artifacts` and re-resolves the active build; it does not accept descriptors
71+
or artifact bytes directly from the event. A dependency-only change may keep the
72+
entry revision stable while producing a new build id.
73+
74+
The stream is advisory and reconnectable. The catalog and revision-scoped
75+
routes remain the authority, so a missed event cannot weaken correctness.
76+
77+
## Acceptance Scenarios
78+
79+
- **AC-1:** A `.tsx` or `.jsx` descriptor is code-backed, names the Studio
80+
sandboxed renderer, and advertises `execute` and `live-update`; `.ts`, `.js`,
81+
and non-code artifacts retain their current presentation behavior.
82+
- **AC-2:** The build endpoint returns a validated `ArtifactBuildSnapshotV1`
83+
bound to the descriptor revision. Repeating the same build reuses its build
84+
identity; changing source bytes produces a different revision and build id.
85+
- **AC-3:** A component with confined relative TSX/CSS imports renders inside an
86+
opaque-origin iframe. React runtime imports work, while filesystem escapes and
87+
unsupported package imports produce bounded diagnostics without crashing
88+
other Studio routes.
89+
- **AC-4:** Preview code cannot read the parent DOM or make network requests,
90+
and it starts only after a matching `runtime.init` handshake. The host exposes
91+
compiling, ready, compile-failed, and runtime-failed states with accessible
92+
text.
93+
- **AC-5:** Rewriting a selected component emits a changed catalog revision,
94+
refreshes the descriptor, compiles the new revision, and visibly commits the
95+
new render without a page reload. A stale build completion is ignored.
96+
- **AC-6:** Preview and Source are keyboard-reachable pane controls at wide,
97+
compact, and narrow layouts; the preview has bounded overflow, visible focus,
98+
no document-level horizontal overflow, and no unexpected console/page errors.
99+
- **AC-7:** Existing PPTX, SVG, image, text/diff, Qoder Canvas, immutable content,
100+
and stale-revision behavior remain covered and unchanged.
101+
102+
## Non-goals
103+
104+
- Changing, extending, or using Qoder Canvas as the Artifact View host.
105+
- Session Artifact manifests, event/tool-call trace links, or semantic
106+
selection back-links.
107+
- Revision retention, replay, or cross-revision comparison.
108+
- npm installation, arbitrary third-party package imports, Node APIs, server-side
109+
rendering, or executing `.ts`/`.js` files that do not declare a UI contract.
110+
- Additional native formats such as XLSX, DOCX, PDF, Mermaid, or Lottie.
111+
- Write-back from the preview into artifact source files.
112+
113+
## Plan and Tasks
114+
115+
1. Add build snapshot and preview protocol contracts with validators.
116+
2. Add the confined `ArtifactCompileRuntime` and focused behavior tests.
117+
3. Resolve TSX/JSX through the code-backed plugin provider and serve build plus
118+
sandboxed preview routes.
119+
4. Add the catalog SSE observer and browser refetch lifecycle.
120+
5. Extract a Studio `ArtifactPreviewHost` path for code-backed artifacts with
121+
Preview/Source controls, MessageChannel sequencing, and status UI.
122+
6. Run focused unit/server/browser verification, preview health and runtime
123+
smoke checks, visual review at 1440x900, 1024x768, and 390x844, and the
124+
required Markdown link graph update.
125+
126+
## Test and Review Evidence
127+
128+
- AC-1/AC-2: plugin resolution, catalog, build contract, cache, and
129+
stale-revision server tests.
130+
- AC-3/AC-4: compiler confinement and browser runtime protocol tests.
131+
- AC-5: browser test that rewrites the selected fixture and observes a new
132+
rendered revision without reloading the page.
133+
- AC-6: Playwright screenshots, keyboard interaction, overflow assertions, and
134+
console/page-error capture at all three layout widths.
135+
- AC-7: existing Artifact View focused tests plus package typecheck/build.
136+
137+
Implementation evidence captured on 2026-08-22:
138+
139+
- `npm run typecheck` and `npm run build` passed in
140+
`packages/harness-studio`.
141+
- Before concurrent Git History work appeared in the same worktree,
142+
`npm test -- --maxWorkers=1` passed 22 files and 143 tests in
143+
`packages/harness-studio`, and `npm run test:browser` passed all 20 then-current
144+
Playwright scenarios. The Artifact suite
145+
covers sandboxed TSX execution, handshake-only startup, compile diagnostics,
146+
source access, no-refresh rebuild, PPTX/SVG regressions, keyboard focus,
147+
console/page errors, and wide/compact/narrow overflow.
148+
- Live Preview screenshots were reviewed at
149+
`test-results/artifacts-live-wide.png`,
150+
`test-results/artifacts-live-compact.png`, and
151+
`test-results/artifacts-live-narrow.png`.
152+
- Root `npm test -- --maxWorkers=1` passed 100 files and 1,472 tests, with one
153+
existing skipped test before that concurrent work expanded. The Markdown link graph passed 8 tests and
154+
`git diff --check` passed.
155+
- A built Studio CLI smoke served the fixture artifact catalog and returned a
156+
ready `ArtifactBuildSnapshotV1` plus build-scoped Preview URI for
157+
`tool-mix.tsx`.
158+
- The repository-level optional Canvas preview smoke was not runnable because
159+
no Canvas SDK runtime is configured. This is an external prerequisite of the
160+
existing Qoder Canvas preview command; the Studio-owned React runtime does not
161+
load it.
162+
- After the concurrent Git History work was integrated into `HEAD`, the final
163+
full package rerun passed 24 files and 148 tests, and all 21 Playwright
164+
scenarios passed. This includes the final handshake and symlink-confinement
165+
hardening as well as the Git History regression surface.
166+
167+
Risk review:
168+
169+
- **Untrusted execution:** compilation does not make generated code trusted.
170+
Opaque-origin iframe sandboxing, CSP, denied dependencies, and an explicit
171+
handshake are required release gates.
172+
- **Resource exhaustion:** source and output budgets limit compilation, but a
173+
browser component can still consume CPU after mount. This increment does not
174+
claim process-level isolation; runtime failure/timeout handling is evidence
175+
for a future worker or process boundary.
176+
- **Watcher variance:** filesystem notifications differ across Windows, macOS,
177+
and Linux. The observer coalesces events and re-derives the catalog revision;
178+
tests assert the revision protocol rather than OS-specific event counts.

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

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
type PptxSlideSnapshot,
3333
} from "../artifact-model.js";
3434
import { CompareView } from "./CompareView.js";
35+
import { ArtifactPreviewHost } from "./ArtifactPreviewHost.js";
3536
import { ExperimentView } from "./ExperimentView.js";
3637
import { GitHistoryView } from "./GitHistoryView.js";
3738
import { HighlightedCode } from "./HighlightedCode.js";
@@ -643,24 +644,37 @@ function ArtifactsWorkspace(props: { config: StudioConfig; selectedSessionId?: s
643644
const [view, setView] = useState<"grouped" | "flat">("grouped");
644645
const [collapsed, setCollapsed] = useState<Set<ArtifactFamily>>(() => new Set());
645646
const [narrowSurface, setNarrowSurface] = useState<"explorer" | "preview">("explorer");
647+
const [liveGeneration, setLiveGeneration] = useState(0);
646648

647649
useEffect(() => {
648650
if (!props.config.artifactsEnabled) return;
649651
let cancelled = false;
650-
void (async () => {
652+
let requestSequence = 0;
653+
const refreshCatalog = async (liveUpdate = false): Promise<void> => {
654+
const request = ++requestSequence;
651655
try {
652656
const response = await fetch("/api/artifacts");
653657
if (!response.ok) throw new Error(`Artifact catalog failed (${response.status}).`);
654658
const payload: unknown = await response.json();
655659
if (!isArtifactCatalogResponse(payload)) throw new Error("Artifact catalog contract is unsupported.");
656-
if (cancelled) return;
660+
if (cancelled || request !== requestSequence) return;
657661
setFailure(undefined);
658662
setCatalog(payload);
663+
if (liveUpdate) setLiveGeneration((value) => value + 1);
659664
} catch (error) {
660-
if (!cancelled) setFailure(error instanceof Error ? error.message : String(error));
665+
if (!cancelled && request === requestSequence) setFailure(error instanceof Error ? error.message : String(error));
661666
}
662-
})();
663-
return () => { cancelled = true; };
667+
};
668+
void refreshCatalog();
669+
const events = new EventSource("/api/artifacts/events");
670+
const invalidate = (): void => {
671+
// Refetch the authoritative descriptor before asking the active Host to
672+
// rebuild. Starting from the stale descriptor would intentionally hit the
673+
// revision route's 409 guard during every ordinary file update.
674+
void refreshCatalog(true);
675+
};
676+
events.addEventListener("artifacts.invalidated", invalidate);
677+
return () => { cancelled = true; events.close(); };
664678
}, [props.config.artifactsEnabled]);
665679

666680
if (!props.config.artifactsEnabled) {
@@ -728,7 +742,7 @@ function ArtifactsWorkspace(props: { config: StudioConfig; selectedSessionId?: s
728742
<div className="artifact-preview-pane">
729743
{active === undefined
730744
? <p className="artifact-status" role="status">Select an artifact to preview it.</p>
731-
: <><header className="artifact-editor-header"><div><strong>{active.label}</strong><small>{formatLabel(active.format)} · {formatBytes(active.size)} · {shortRevision(active.revision.id)}</small></div><span>{active.adapter.id}{active.renderer.label}</span></header><ArtifactPreview artifact={active} /></>}
745+
: <><header className="artifact-editor-header"><div><strong>{active.label}</strong><small>{formatLabel(active.format)} · {formatBytes(active.size)} · {shortRevision(active.revision.id)}</small></div><span>{active.adapter.id}{active.renderer.label}</span></header><ArtifactPreview artifact={active} liveGeneration={liveGeneration} /></>}
732746
</div>
733747
</section>;
734748
}
@@ -783,10 +797,13 @@ function ArtifactRow(props: { artifact: ArtifactDescriptor; selected: boolean; o
783797
* a format that Studio classifies one way and renders another cannot exist.
784798
* An unrecognised renderer falls through to the honest unavailable state.
785799
*/
786-
function ArtifactPreview({ artifact }: { artifact: ArtifactDescriptor }): React.JSX.Element {
800+
function ArtifactPreview({ artifact, liveGeneration }: { artifact: ArtifactDescriptor; liveGeneration: number }): React.JSX.Element {
787801
const contentUrl = artifact.revision.content.uri;
788802
const contentKey = artifact.revision.digest;
789803
if (artifact.renderer.status === "ready") {
804+
if (artifact.renderer.id === "studio.react-preview" && artifact.backing === "code") {
805+
return <ArtifactPreviewHost artifact={artifact} liveGeneration={liveGeneration} />;
806+
}
790807
if (artifact.renderer.type === "qoder-canvas" && artifact.renderer.viewUri !== undefined) {
791808
return <iframe key={contentKey} className="artifact-frame" title={`Artifact preview: ${artifact.label}`} src={artifact.renderer.viewUri} sandbox="allow-scripts" referrerPolicy="no-referrer" />;
792809
}

0 commit comments

Comments
 (0)