Add the Artifacts console pages - #1472
Merged
Merged
Conversation
RhysSullivan
force-pushed
the
artifact-render-ui
branch
from
July 28, 2026 07:10
a8e153d to
be86f24
Compare
RhysSullivan
force-pushed
the
artifact-console
branch
from
July 28, 2026 07:10
046d533 to
43df520
Compare
This was referenced Jul 28, 2026
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
executor-cloud | b0575c6 | Jul 29 2026, 08:49 PM |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-marketing | b0575c6 | Commit Preview URL Branch Preview URL |
Jul 29 2026, 08:52 PM |
Contributor
Cloudflare previewTorn down — the PR is closed. |
@executor-js/cli
@executor-js/config
@executor-js/execution
@executor-js/sdk
@executor-js/codemode-core
@executor-js/runtime-quickjs
@executor-js/plugin-file-secrets
@executor-js/plugin-graphql
@executor-js/plugin-keychain
@executor-js/plugin-mcp
@executor-js/plugin-onepassword
@executor-js/plugin-openapi
executor
commit: |
The MCP-Apps shell imports @tailwindcss/browser, which touches document at import scope. Importing the shell statically from the root routes put it in cloud's SSR graph, so every document request failed with "document is not defined" while the entry graph loaded. The renderer seam now carries an async loader instead of a component. The composition roots register the shell through a dynamic import that only resolves in the browser, and the artifact page renders a placeholder frame server-side, hydrating the shell behind ClientOnly and Suspense.
The artifact page mounted McpAppsShell as a React component in the console document. The shell is written to be its own document: it imports @tailwindcss/browser and its globals.css at import scope, and themes by toggling document.documentElement. Inlined, its palette overwrote the console's grayscale tokens for the rest of the session, and its content-height notifications reached no host, so tall artifacts clipped. The page now loads the built shell document — the same bytes served as ui://executor/shell.html — into a sandboxed iframe and implements the host half of the protocol with ext-apps' AppBridge over PostMessageTransport. The stored source arrives as tool-input plus a tool-result carrying structuredContent.code, exactly as after a render-ui call, so the shell takes one path in every host. callServerTool routes to the existing HTTP mapping, size-changed drives the iframe height, and theme travels as host context. A new mcpAppsShellAsset vite plugin builds the shell and emits it into the client output (client only; the worker never serves it), exporting just its hashed URL so the document stays out of the console's JS. Two shell bugs the host exposed: - The shell registered its one-shot tool-input/tool-result handlers in a mount effect, after connect() had completed the handshake, so a prompt host's delivery was dropped. They are captured in onAppCreated and replayed once the shell has mounted. - The shell capped its height at a hardcoded 800px. It now honors the protocol's containerDimensions and otherwise reports true content height, leaving the frame to the host. e2e asserts the console's tokens and stylesheet count are unchanged after rendering while the shell keeps its own palette one document down, and that a 40-row artifact grows the frame past its initial height. Both guards were mutation-tested.
Opening an artifact could leave the shell on "Connecting" forever. ui/initialize is fire-and-forget: the app posts it once, ext-apps has no retry and AppBridge buffers nothing, and the host only starts listening inside bridge.connect(). Connecting on the iframe's onLoad put that listener up after the shell document had already parsed and run its module script, so the request could land on a window with no listener and be dropped, leaving both sides waiting. It passed locally because it won the race by about 5ms. Measured on the running cloud instance, ui/initialize arrived 5.4-6.3ms after the host registered; delaying only that registration by 300ms reproduced the stuck state 3/3. The bridge is now built in the iframe's ref callback, which runs synchronously once React inserts the element and before the browser fetches src. contentWindow is the initial about:blank window there, and a same-origin navigation preserves the WindowProxy, so the transport's target and its event.source filter still match the shell document. The same margin now measures 124-127ms, and the listener is up before the frame is navigated at all. sunpeak's host connects in its ref callback too, which is why real MCP-Apps hosts never hit this. The ref callback's cleanup also replaces the teardown effect keyed on props.code. That effect was a second owner of bridgeRef and could close a bridge a later mount had just connected; each bridge now closes only itself. The e2e asserted only that the artifact eventually rendered, which the old code did on a fast machine. It now asserts the ordering instead: the host's listener must be registered while the shell frame is still about:blank. That fails on the previous implementation, which records the shell document's URL. Stuck-on-Connecting also now fails directly.
Remove the run(code) escape hatch from the model-facing scope. Arbitrary code is opaque to the invalidation helpers (a hand-rolled queryKey defeats queryFilter) and to artifact analysis, which reads tools.<path> references out of the source. Close the pagination gap it was reached for: add .infiniteQueryOptions to the tools proxy, modeled on tRPC v11. The model supplies TanStack's own initialPageParam and getNextPageParam; the proxy merges each page param into the tool input at cursorKey (default "cursor", dotted for nested inputs) and mints the query key so queryFilter invalidation keeps working. Constrain execute-action to the one grammar the proxy emits, return await tools.<path>(<json>), so the wire matches the surface and an iframe cannot smuggle arbitrary code through the app channel. execute is unchanged. render-ui also rejects code calling run().
The shell shipped stock shadcn tokens (a teal primary in a strictly grayscale product), no webfont, and no design guidance for the model. - Ship Geist and Geist Mono, subset and inlined as data: URLs, since the renderer frame's CSP is font-src data:. - Replace the shell theme with executor's own tokens, pinned against the app's stylesheet so drift fails a test. - Add an ordered, desaturated eight-slot chart palette plus status colors, led by a neutral so single-series charts stay grayscale. - Give the renderer frame a Tailwind compiler and the theme as source. It previously received only the compiled stylesheet, so any class the model wrote that executor's own components don't use silently did nothing: text-2xl computed to 16px and md:grid-cols-4 stayed one column. - Add ArtifactLoading / ArtifactEmpty / ArtifactError to generated-code scope and render ArtifactError from the error boundaries instead of a raw stack trace. - Let the shell own the artifact's outer padding and measure. - Add an artifact-style skill carrying the design system, separate from the create-artifact capability manifest.
Artifact code addressed tools by full five-segment address, which welded
each saved artifact to its author's connection and made sharing
impossible. Artifact source now names an integration —
tools.vercel.domains.getDomains — and, when it needs two accounts of one,
a role: tools.linear("prod").issues.list.
The connection is resolved once at create time against the author's own
connections (sole connection binds silently, several is an error listing
candidates, or pass a connections map) and stored in a new nullable
bindings column. execute-action takes the artifact id, looks up the
bindings, and rebuilds the full address server-side, so the iframe never
chooses an address. Artifacts saved before this carry bindings: null and
run their old paths as written.
Image beats layout beats schematic, so there is always something to show. The markup is inserted directly rather than into an iframe: it is our own allowlist-sanitized server render, and a srcdoc document would inherit neither the console's stylesheet nor its fonts, so every card would have to carry both.
The inner frame waits for its queries to go quiet, rasterizes itself through an SVG foreignObject, and hands the snapshot back. Only where the host advertises somewhere to put one, so a real MCP client never does the work. Best-effort throughout: a failed capture leaves the layout preview in place.
Keeps the renderer message handler built once, and drops the bare promise catch the lint rule forbids.
The capture's shared names now live in a module with no imports. Taking them from shell-app pulled its virtual modules — the in-frame Tailwind compiler and inner renderer — into the console, where nothing supplies them, and the artifact renderer failed to load at all.
A container-query divisor produces a length, and scale() takes a number, so the browser dropped the transform and drew the markup at 1:1 — a card showed the top-left corner of an artifact rather than the artifact. Measured against the card, which is fluid anyway.
The gallery draws stored markup in the console document, so there is no in-browser Tailwind to fall back on. The console never needs a four-column stat row; a generated dashboard almost always does, and without the class it collapsed to one column.
The shell inlines ~5MB of JavaScript that includes React source strings building markup as text, so matching the raw file reported tags that exist only inside string literals. Blanking inline script bodies first — while keeping any script that carries a src, which is the regression being guarded — reports what the document actually references. Mutation-tested against a sibling bundle, a stylesheet link, and a remote font.
Rasterizing was the intent and cannot work: the render frame is sandboxed without allow-same-origin, so it runs in an opaque origin, every image it can build taints the canvas, and toDataURL throws. Measured, not assumed — a 203-byte inline SVG fails exactly as the real one does, so nothing short of giving the frame a real origin fixes it, and that origin is the sandbox. Capturing the settled DOM keeps what the upgrade was for: the card shows the artifact with its real numbers instead of its skeleton. Both previews are now markup through one sanitizer, so the console renders them identically.
An artifact rendered on the console's detail page IS the page, but the shell only ever ran one way: the inner renderer reports its content height, the host grows the frame, and the console page scrolls. A generated dashboard with 143 rows therefore pushed its own header, search box and filters off the top of the screen, and no amount of `h-full` in the artifact could hold them there — `h-full` against an unbounded parent is just `h-auto`. Inline growth stays exactly as it is for chat hosts, where an artifact is one block in a scrolling transcript. The detail page now negotiates the other mode through the MCP Apps host context: `displayMode: "fullscreen"` plus a fixed `containerDimensions.height`, both spec fields, since `McpUiDisplayMode` is a closed union with no room for one of our own. The host measures its container with a ResizeObserver, sizes the frame to it, and ignores the app's size reports; the app keeps reporting them unconditionally, which is what keeps the mode entirely the host's business. The rest is the height chain, which had to hold at every link: content area, outer frame, shell document, inner srcdoc frame, `#root`, and the artifact's own root element. `min-height: 100%` is what a document uses to grow; a definite `height: 100%` is what a percentage child resolves against, so fill mode swaps to one under a document class and makes `.artifact-root` a flex column its child can stretch inside.
The style skill previously banned the pattern outright — "never a nested scroll container", "never position: sticky" — which was honest advice when the frame always grew to content and the page always scrolled. With a bounded viewport those become the right answer rather than the wrong one, so the bans are narrowed to what is still true: no `fixed`, no hardcoded scroll heights, no viewport units. In their place is the pattern itself: root `flex h-full flex-col`, chrome as ordinary children, one data region as `flex-1 min-h-0 overflow-auto`, and a `sticky top-0` table header with a background inside it. The `min-h-0` gets its own note because omitting it is the failure that looks like the rule not working. `config.maxHeight` keeps its inline meaning and is documented as ignored where the artifact already has the whole viewport. The preview sanitizer carries the whole pattern in class names, so a test pins that it does — a card is drawn from sanitized markup, and silently dropping `flex-1` there would show a layout the artifact does not have.
Two tests against a real Chromium, using the layout the style skill tells a model to write: a 120-row table in a `flex-1 min-h-0 overflow-auto` region under a header. In fill mode, three things have to hold together, because any one alone can be true while the layout is still broken: the region overflows a bounded height, scrolling it really moves the rows, and neither the artifact's header nor its sticky table header moves while they do. The sticky header is compared against its own starting position rather than the container's edge — `sticky` pins to the padding box, so the 1px border makes the naive assertion off by one. In inline mode the shell must still grow past the frame and report it, which is what every chat host depends on. The harness gained a way to send a host-context-change notification carrying only the changed fields, which is how a real host announces a mode and a measured viewport after the handshake.
The tall-artifact step asserted the old contract directly — "the host grew the iframe past its initial height", frame height > 600 — which is precisely the behaviour that made a long table push its own heading off the screen. Left as-is it would have failed against the fix while describing the bug as correct. Rewritten against what the page now promises, and the fixture with it: the artifact is laid out the way the style skill teaches, the frame is bounded by the window rather than by its content, the console page does not scroll at all, and the artifact's header is still exactly where it was after its rows have been scrolled 300px.
Sessions that connect with ?artifacts=false (--no-artifacts on the stdio CLI) get no artifact surface: none of the five artifact tools, no ui:// shell resource, and no artifact entries in the skills inventory. On by default; the install card's Advanced section carries the toggle.
Opening an artifact walked through three placeholders in a row — the row fetch, the lazy renderer chunk, and the shell's own boot — each in its own layout and each visible for a few hundred milliseconds. Those are internal boundaries, not facts a viewer needs, and crossing them was visible only as churn. Now one skeleton mounts with the route and stays until the artifact has painted, with every boundary crossed underneath it. Where the artifact has a stored gallery preview it waits against its own layout, seeded from the list cache so it is right on the first frame after a click; otherwise hairline blocks in the artifact's own framing. The renderer chunk is preloaded from the gallery and alongside the row fetch, so its placeholder never appears. The shell reports when the artifact has actually painted — nothing on the console side can see two documents down — and the skeleton cross-fades out on that signal. A host that holds its own surface no longer gets the shell's card underneath it. Error surfaces replace the skeleton rather than stacking on it, and the frame is mounted at full size behind it throughout so fill-mode sizing never jumps.
The preview-backed skeleton scaled its markup to a fixed 880px, so it sat 88px left of where the artifact resolved and every element slid into place at the handoff. The stage has the artifact's real room, so there is nothing to scale: mirroring .artifact-root's framing lays the same markup out at exactly the width the artifact is about to occupy. The generic skeleton distributes its rows down the region rather than stacking them under the header, since an artifact given the viewport fills it.
The signal is what lets the console take its skeleton down at the right moment, so both directions need holding: a host that asks is told once the artifact has painted and only once, even when a second artifact is delivered into the same shell; a host that never asks is untouched and keeps the shell's own loading card. The second half is the guard for every real MCP client, and the harness's default context omits the key exactly as Claude's does.
The paint travels up from the inner frame and the "tell me when it happens" capability travels down on the host context, over independent channels whose order is not guaranteed. A host that advertises it in a later host-context-changed rather than its initialize reply arrives second, and the paint never happens twice — so testing the capability at the moment of the paint dropped the only notification there would ever be, leaving that host's loading surface up over a rendered artifact. The paint is remembered instead, and whichever of the two arrives second sends the call. Still latched, so it stays exactly once.
The skeleton drew the artifact's stored preview scaled to fill the stage, then cross-faded it out. Both were attempts to predict a layout that is arbitrary user code: the preview is a render of a PREVIOUS visit, so what faded in was never quite what had been held, and the eye tracked every piece that moved. One rounded block over the stage region makes no claim it can break, and the handoff is a hard swap on the paint signal rather than a 150ms interpolation between two things that share no shapes. The paint signal, the renderer preload and the single-surface structure are unchanged; the stored-preview peek is gone with its only consumer.
RhysSullivan
force-pushed
the
artifact-render-ui
branch
from
July 29, 2026 20:09
be86f24 to
0bd7cdb
Compare
RhysSullivan
force-pushed
the
artifact-console
branch
from
July 29, 2026 20:09
0b74fe7 to
9f4c420
Compare
RhysSullivan
marked this pull request as ready for review
July 29, 2026 20:25
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Console UI for persisted artifacts, completing the feature.
/artifactslist page (sidebar nav on all hosts) with rename/delete/artifacts/$artifactIddetail page rendering the stored JSX live in the embedded shell over an HTTP-backed host (executions API), including the trusted elicitation approval flow — this page is the deep-link target non-MCP-Apps clients receiveArtifactRendererProviderseam to avoid a react↔shell package cycleartifactsadded to reserved org slugs (plustoolkits, a pre-existing gap)Stack