Skip to content

Add the Artifacts console pages - #1472

Merged
RhysSullivan merged 60 commits into
mainfrom
artifact-console
Jul 29, 2026
Merged

Add the Artifacts console pages#1472
RhysSullivan merged 60 commits into
mainfrom
artifact-console

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Console UI for persisted artifacts, completing the feature.

  • /artifacts list page (sidebar nav on all hosts) with rename/delete
  • /artifacts/$artifactId detail 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 receive
  • Shell embedding inverted through an ArtifactRendererProvider seam to avoid a react↔shell package cycle
  • artifacts added to reserved org slugs (plus toolkits, a pre-existing gap)
  • Cross-target e2e scenarios green on selfhost: render → fallback URL → page renders in sandbox → list/show tools → rename/delete round-trip

Stack

  1. Add artifact table and core artifacts API #1470
  2. Add first-party render-ui MCP tools and the MCP-Apps shell #1471
  3. Add the Artifacts console pages #1472 👈 current

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
executor-cloud b0575c6 Jul 29 2026, 08:49 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1472

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1472

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1472

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1472

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1472

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1472

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1472

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1472

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1472

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1472

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1472

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1472

executor

npm i https://pkg.pr.new/executor@1472

commit: b0575c6

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
RhysSullivan changed the base branch from artifact-render-ui to main July 29, 2026 20:22
@RhysSullivan
RhysSullivan marked this pull request as ready for review July 29, 2026 20:25
@RhysSullivan
RhysSullivan merged commit 1178e3b into main Jul 29, 2026
17 of 21 checks passed
@RhysSullivan
RhysSullivan deleted the artifact-console branch July 29, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant