Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/add-inline-html-renderer/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-24
99 changes: 99 additions & 0 deletions openspec/changes/add-inline-html-renderer/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
## Context

HTML artifact previews are rendered by `HTMLPreview` (`code-block-preview/html-preview.ts`), which delegates to `linkIframe` (`code-block-preview/iframe-container.ts`). Today `linkIframe`:

1. Points the iframe at `https://affine.run/static/container.html`.
2. Adds a permissive sandbox including both `allow-scripts` **and** `allow-same-origin`.
3. On `onload`, posts the artifact HTML to the remote container via `contentWindow.postMessage(html, 'https://affine.run')`.

The remote page is the isolation boundary: it runs on the `affine.run` origin, so artifact scripts execute cross-origin to the host app. The cost is a hard runtime dependency on affine.run for something that is otherwise a purely local render. This renderer is shared by two call sites — the chat code-artifact preview (`ai-tools/code-artifact.ts` → `<affine-html-preview>`) and inserted `affine:code` blocks with `preview: true` in docs (`CodeBlockHtmlPreview` extension) — so both benefit from a local replacement.

The only existing local path (`adapter-panel/.../adapter-panel-body.ts`) uses `<iframe srcdoc sandbox="allow-same-origin">`, which cannot run scripts and is therefore not interactive.

Constraints: artifact HTML is untrusted (model-generated). The host app holds auth tokens and workspace data in its origin. The renderer runs in both web and Electron desktop builds.

## Goals / Non-Goals

**Goals:**

- Render artifacts with zero dependency on a remote container origin (works offline / self-hosted).
- Preserve interactivity: scripts, forms, input, pointer.
- Isolate the artifact from the host origin at least as strongly as the current remote flow.
- Size the preview to content instead of a fixed 544px, with a bounded max and an expand affordance.
- Preserve artifact runtime state across host re-renders that don't change the HTML.

**Non-Goals:**

- Changing the `code_artifact` server tool or its `{ title, html, size }` result contract.
- Guaranteeing that artifacts which fetch their _own_ external resources (CDN scripts, remote fonts) work offline — that depends on the artifact, not the container. Only the _container_ dependency is removed.
- Migrating `adapter-panel-body.ts` to the new renderer (possible follow-up).
- Persisting artifact state across full page reloads or serializing it to storage.

## Decisions

### 1. Deliver HTML via `srcdoc`, not a remote `src`

Set `iframe.srcdoc = wrappedHtml` and remove the remote `src` navigation and the cross-window `postMessage` handshake. `srcdoc` needs no URL lifecycle management (unlike `blob:`), keeps everything in-page, and — combined with the sandbox below — yields an opaque origin.

_Alternatives:_ `blob:`/`data:` URL (also opaque under sandbox, but adds object-URL lifecycle and, for `data:`, size/encoding overhead). Keeping the remote container (rejected: the whole point is to drop it).

### 2. Sandbox with an opaque origin — `allow-scripts` **without** `allow-same-origin`

Sandbox flags: `allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-downloads allow-pointer-lock`. Deliberately **omit `allow-same-origin`**.

Without `allow-same-origin` a sandboxed frame gets a unique opaque origin: scripts run, but the frame is cross-origin to the host, so it cannot touch the host DOM, and `localStorage`/`sessionStorage`/`document.cookie` resolve to the guest's own opaque (empty) origin — it cannot read host storage or auth. This is strictly _more_ isolated than today's `allow-same-origin` remote flow. (The browser also refuses to treat `allow-scripts allow-same-origin` as sandboxed when frame and embedder share an origin, which is exactly the escape we avoid by dropping the flag.)

_Trade-off:_ guest `localStorage` throws / is unavailable — acceptable for one-shot artifacts, and matches what a foreign-origin container already implied.

### 3. Host bootstrap injected into the wrapped document (sizing + handshake over `postMessage`)

Because the frame is cross-origin, the host cannot measure it directly. A small bootstrap script is injected into the artifact HTML (before `</body>`, with a head/append fallback) that:

- observes document height via `ResizeObserver` on `documentElement`/`body`,
- posts `{ source: 'affine-artifact', type: 'resize', height }` to `parent` with target origin `'*'` (guest is opaque, so it cannot know the parent origin), and
- posts a `ready` message on load and an `error` message from `window.onerror`.

The host (`HTMLPreview`) listens for `message`, and **validates `event.source === this.iframe.contentWindow`** (the security check that replaces origin checking for an opaque frame), then sets the iframe height clamped to `[min, maxHeight]`. Direct DOM measurement of the frame is never attempted.

Injection wraps rather than replaces the artifact's own document so model output (`<!DOCTYPE html>…</html>` from `preprocessHtml`) renders unchanged.

### 4. Content-aware height, but only where it applies

**Discovered during implementation:** `affine-html-preview` has two call sites with different sizing contracts.

1. **Chat code-artifact** renders inside `artifacts-preview-panel` — a full-size panel (`position: absolute`, `height: calc(100% - 52px)`). `code-artifact.ts` deliberately stretches `.html-preview-iframe` to `height: 100%` to fill it, so the hard-coded `544px` is already overridden there.
2. **Doc `affine:code` block** with `preview: true` renders inline in document flow. This is where `544px` is actually live and where content-aware sizing is the real win.

Naively setting an inline `height` would be a regression: inline styles beat stylesheet rules, so it would override the panel's `height: 100%` and break the chat layout.

**Decision:** `HTMLPreview` gains an `autoResize` property, defaulting to `true`. Doc code blocks auto-size to content, clamped to `[ARTIFACT_MIN_HEIGHT, ARTIFACT_MAX_HEIGHT]` with internal scrolling beyond the max. `code-artifact.ts` passes `.autoResize=${false}` so the panel keeps filling exactly as today. Resize messages are still parsed in both modes; only the height application is gated.

**Max height / expand:** clamp at `640px`. No new expand control is built — the chat path already has the full preview panel and doc code blocks have their own block affordances, so a second competing control would be redundant. (This retires task 4.4.)

### 5. Persist state by keeping the iframe instance stable

Two mechanisms:

- **Guard reloads:** `HTMLPreview` reloads (`linkIframe`) only when the normalized HTML actually changes — track the last-rendered string and skip `_link()` on unrelated `updated()` calls.
- **Keep the element mounted:** in `code-artifact.ts`, toggling Code⇄Preview keeps the `<affine-html-preview>` element in the DOM (hidden, not removed) so the iframe — and its runtime state — survives. Key the preview by `toolCallId` so chat message re-renders reuse the same element instead of recreating it.

## Risks / Trade-offs

- **Content-Security-Policy blocks `srcdoc` scripts** → Verify the app CSP (`frame-src`, `sandbox`, `script-src`) permits sandboxed `srcdoc` frames with scripts in both web and Electron (`webPreferences`/`webview`). If blocked, add the minimal directive; capture in Open Questions before implementation lands.
- **Guest storage APIs throw under opaque origin** → Document as expected; artifacts relying on `localStorage` degrade, not crash. The injected bootstrap must not itself use storage.
- **`postMessage` spoofing** → Host accepts resize/ready/error only when `event.source` matches the specific iframe `contentWindow` and the payload carries the `affine-artifact` marker; height is clamped, so a hostile value cannot blow up layout.
- **Loss of a real reason for the remote container** → If affine.run's container.html provided behavior beyond isolation (e.g. shared polyfills), confirm before removal. Current code shows only postMessage-render, so none is assumed.
- **Two call sites** (chat preview + inserted doc code blocks) share `affine-html-preview` → verify both after the change; the doc-block path has no toggle but must still size and isolate correctly.

## Migration Plan

- Change is internal to the renderer; the `code_artifact` contract and both call sites' public shape are unchanged. Deploy is a straight replacement of `linkIframe`/`HTMLPreview` internals.
- **Rollback:** revert the frontend commit — restores the remote-container behavior with no data migration.
- Optionally gate behind a feature flag during rollout to A/B the local renderer against the remote container; not required given the clean revert path.

## Open Questions

- ~~Exact `maxHeight` for the bounded preview, and whether expand opens a fullscreen modal or an in-place expansion.~~ **Resolved:** `640px` max; no new expand affordance (see Decision 4).
- ~~Does the current app CSP already permit sandboxed `srcdoc` script execution in web **and** Electron, or is a directive change needed?~~ **Resolved:** yes, no change needed — no `script-src`/`frame-src` policy exists on the host document in either build; Electron only sets `frame-ancestors`, which does not apply to `srcdoc` children. Recorded in tasks 1.1/1.2.
- Should this change also retire the browser "not supported / download the Desktop App" fallback entirely, or keep it for unrelated failure modes?
- Should `adapter-panel-body.ts`'s script-less `srcdoc` path be migrated in this change or a follow-up? (Currently Non-Goal.)
33 changes: 33 additions & 0 deletions openspec/changes/add-inline-html-renderer/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Why

The copilot's HTML artifact preview renders untrusted, model-generated HTML by loading a **remote** page — `https://affine.run/static/container.html` — into an iframe and posting the HTML into it (`iframe-container.ts`). The remote page is the security isolation boundary: it runs the artifact's scripts on a foreign origin so they can't touch the host app. This couples every preview to affine.run being reachable: a self-hosted / requesty-fork deployment, an offline desktop session, or any network hiccup produces a blank or fallback ("download the Desktop App") preview. The only fully-local alternative in the codebase (`adapter-panel-body.ts`) uses `srcdoc` with `sandbox="allow-same-origin"` and therefore **cannot run scripts** — so it is not interactive. We want an interactive HTML renderer that needs no remote container.

## What Changes

- **Replace the remote container with a self-contained inline renderer.** `linkIframe` stops pointing the iframe at `https://affine.run/static/container.html` and instead renders the artifact HTML locally via `srcdoc` (host-wrapped document), with **no** dependency on any external origin.
- **Keep it interactive while isolated.** The sandbox uses `allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-downloads allow-pointer-lock` but deliberately **omits `allow-same-origin`**, giving the frame an opaque origin. Scripts and forms run, but the artifact cannot read the host app's DOM, storage, cookies, or auth tokens. This is a **security tightening** relative to the current remote flow's `allow-same-origin`.
- **Content-aware sizing.** A tiny host bootstrap injected into the wrapped document measures content height (`ResizeObserver`) and posts it to the parent, which sizes the iframe to content (with a sane max + expand/fullscreen affordance) instead of the fixed 544px. Because the frame is cross-origin, all sizing goes over `postMessage`, never direct DOM measurement.
- **State persistence across re-renders.** The rendered iframe is cached/keyed by artifact identity so toggling Code⇄Preview, or the chat message re-rendering, does not reload the frame and discard the artifact's runtime state.
- **Removed dependency:** the hard runtime coupling to `affine.run/static/container.html`. Renderer now works offline, self-hosted, and in the desktop app uniformly; the browser "not supported / download Desktop App" fallback path is no longer needed for this reason.

## Capabilities

### New Capabilities

- `inline-html-renderer`: Model-generated HTML artifacts render in a fully local, self-contained sandboxed iframe (no remote container origin) that executes scripts and forms in an isolated opaque origin, sizes itself to its content, and preserves its runtime state across host re-renders.

### Modified Capabilities

<!-- None: there are no existing specs under openspec/specs/. The current renderer has no spec of record. -->

## Impact

- **Code (frontend only):**
- `packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts` — `linkIframe` rewritten: build the host-wrapped `srcdoc`, set the isolated sandbox, drop the remote `src` + cross-window `postMessage(html, 'https://affine.run')` handshake.
- `packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts` — `HTMLPreview`: content-aware height (replace fixed `544px`), `postMessage` height listener, state/`error`/`fallback` handling; keep the iframe element stable across `updated`.
- `packages/frontend/core/src/blocksuite/ai/components/ai-tools/code-artifact.ts` — preview caching keyed by artifact id so Code⇄Preview toggling and message re-render don't reload; sizing hooks.
- A new host bootstrap/wrapper module (e.g. `code-block-preview/host-bootstrap.ts`) producing the injected sizing/state script and the wrapped document.
- **Security / policy:** guest runs on an opaque origin (no `allow-same-origin`). Confirm the app **Content-Security-Policy** (`frame-src`, `sandbox` directives) permits `srcdoc` frames with scripts in both web and Electron; document any CSP adjustment needed.
- **No changes** to the copilot server, native crates, or the `code_artifact` tool contract (`{ title, html, size }` is unchanged).
- **Reuse:** the same local renderer can replace the script-less `srcdoc` path in `adapter-panel-body.ts` (optional follow-up, out of scope here).
- **Tests:** frontend unit/integration for `linkIframe` wrapping + sandbox flags and the sizing/persistence behavior; manual verification of an interactive artifact (script + form) rendering offline.
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
## ADDED Requirements

### Requirement: Local, self-contained rendering

The renderer SHALL display model-generated HTML artifacts using only local browser resources, with no dependency on any external origin or remotely hosted container page. It SHALL NOT load `https://affine.run/static/container.html` or any other network URL to display an artifact, and SHALL NOT depend on cross-window `postMessage` to a foreign origin to deliver the artifact HTML.

#### Scenario: Renders with no network access

- **WHEN** an HTML artifact is previewed while the browser has no network connectivity (offline, or a self-hosted deployment that cannot reach affine.run)
- **THEN** the artifact renders fully and interactively
- **AND** no request is made to `affine.run` or any external host to render it

#### Scenario: Artifact HTML delivered locally

- **WHEN** the renderer mounts an artifact
- **THEN** the artifact HTML is provided to the iframe via a local mechanism (`srcdoc` or an in-page `blob:`/`data:` document), not by navigating the iframe to a remote `src`

### Requirement: Interactive execution in an isolated sandbox

The renderer SHALL execute the artifact's scripts and forms inside a sandboxed iframe whose origin is opaque to the host application. The sandbox SHALL grant `allow-scripts` and form/interaction permissions, and SHALL NOT grant `allow-same-origin`, so the artifact cannot access the host application's DOM, storage, cookies, or authentication state.

#### Scenario: Scripts run

- **WHEN** an artifact contains a `<script>` that mutates its own DOM (e.g. a counter button, a canvas animation)
- **THEN** the script executes and the artifact behaves interactively within the preview

#### Scenario: Forms and input work

- **WHEN** an artifact contains form controls (text inputs, buttons, selects)
- **THEN** the user can type into, focus, and submit them within the sandbox

#### Scenario: Host isolation is preserved

- **WHEN** an artifact script attempts to read `window.parent`, the host's `localStorage`/`cookie`, or the host DOM
- **THEN** the access is blocked by the opaque cross-origin sandbox and the host application state is unaffected

### Requirement: Content-aware sizing

The renderer SHALL size the preview to the artifact's content rather than a fixed height. Because the frame is cross-origin, height SHALL be reported by an in-frame host bootstrap over `postMessage`, and the host SHALL NOT attempt direct DOM measurement of the frame. The renderer SHALL apply a sensible maximum height and provide an expand/fullscreen affordance for taller content.

#### Scenario: Short content is not over-tall

- **WHEN** an artifact's rendered content is shorter than the previous fixed height
- **THEN** the iframe shrinks to fit the content

#### Scenario: Content growth is tracked

- **WHEN** an artifact's content height changes after load (e.g. script reveals more content)
- **THEN** the in-frame bootstrap reports the new height and the host resizes the iframe accordingly

#### Scenario: Very tall content is bounded

- **WHEN** an artifact's content exceeds the configured maximum preview height
- **THEN** the iframe is capped at the maximum and the artifact remains scrollable, with an expand/fullscreen control available

### Requirement: State persistence across host re-renders

The renderer SHALL preserve a rendered artifact's live runtime state across host-driven re-renders that do not change the artifact HTML — including toggling between Preview and Code views and chat message list re-renders — by keeping the same iframe instance rather than reloading it.

#### Scenario: Toggling Code/Preview keeps state

- **WHEN** the user interacts with an artifact (e.g. increments a counter), switches to the Code view, then switches back to Preview
- **THEN** the artifact's runtime state is retained (the counter shows its prior value) and the iframe is not reloaded

#### Scenario: Message re-render does not reset the artifact

- **WHEN** the surrounding chat message re-renders while the artifact HTML is unchanged
- **THEN** the iframe is not reloaded and the artifact's runtime state is preserved

#### Scenario: Changed HTML reloads

- **WHEN** the artifact HTML content itself changes
- **THEN** the renderer reloads the frame with the new content

### Requirement: Graceful failure

The renderer SHALL surface a clear, local error state when an artifact cannot be rendered, and SHALL NOT present a fallback that instructs the user to install another application solely because a remote container was unreachable.

#### Scenario: Malformed artifact

- **WHEN** the artifact HTML is empty or cannot be wrapped/rendered
- **THEN** the renderer shows a local error/empty state rather than a blank frame or a crash

#### Scenario: No remote-dependency fallback

- **WHEN** the browser lacks network access
- **THEN** the renderer does not show a "feature not supported / download the Desktop App" message attributable to a missing remote container, because rendering no longer depends on one
Loading
Loading