Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
be4ae9a
feat(web-shell): add web previews with saved delivery history
wenshao Sep 7, 2026
4ee1ead
fix(serve): register saved preview telemetry route
wenshao Sep 7, 2026
18f87ca
fix(web-shell): address preview review and merge main
wenshao Sep 7, 2026
c575fdf
test(cli): update telemetry route drift count
wenshao Sep 7, 2026
dfd508a
fix(web-shell): address preview review regressions
wenshao Sep 8, 2026
dfbe956
fix(web-shell): reclaim snapshot files and address preview findings (…
qwen-code-ci-bot Sep 8, 2026
1188090
Merge branch 'main' into codex/web-shell-preview-history
qwen-code-dev-bot Sep 8, 2026
a8701e1
Merge branch 'main' into codex/web-shell-preview-history
qwen-code-dev-bot Sep 8, 2026
bfd032e
fix(web-shell): Replace undefined mock in split rerender tests (#1140…
qwen-code-dev-bot Sep 9, 2026
603ef17
fix(web-shell): preserve saved preview ownership across session lifec…
wenshao Sep 9, 2026
0509aa8
test(cli): complete worktree ACP config fixture
wenshao Sep 9, 2026
13e7f0f
Merge branch 'main' into codex/web-shell-preview-history
qwen-code-dev-bot Sep 9, 2026
947b0f1
fix(acp-bridge): keep restored saved-webpage records when snapshot re…
qwen-code-ci-bot Sep 9, 2026
a9cf9d3
test(cli): make dangling-include screen test host-independent (#11276)
qwen-code-ci-bot Sep 9, 2026
83c97ff
Merge remote-tracking branch 'origin/main' into codex/web-shell-previ…
qwen-code-ci-bot Sep 10, 2026
7faae8d
fix(web-shell): preserve conversation forks with missing saved pages
wenshao Sep 10, 2026
f212575
Merge branch 'codex/web-shell-preview-history' of https://github.com/…
qwen-code-dev-bot Sep 10, 2026
063076f
chore: sync current CI gate from main
wenshao Sep 10, 2026
d8785f3
test(web-shell): align branding fixtures with workspace and sidebar b…
wenshao Sep 10, 2026
874042c
fix(web-shell): reclaim uncommitted preview snapshots
wenshao Sep 10, 2026
2ff9bb8
fix(web-shell): reconcile preview history with main
wenshao Sep 10, 2026
e180a24
fix(acp): preserve non-strict ingestion on snapshot reference errors
wenshao Sep 11, 2026
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
111 changes: 111 additions & 0 deletions docs/design/web-shell/web-shell-live-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Web Shell live development preview

## Problem and current behavior

The right panel renders HTML file contents in a sandboxed `srcDoc`, but cannot
open a running development server. Users must leave the conversation to inspect
an application, and the shipped shell's frame policy blocks other ports.

This first increment adds direct, browser-reachable development URLs to the
existing panel. It provides an address field, refresh, responsive width controls,
and external opening. The server continues to run through the existing terminal
or shell tool.

Preview history belongs to the conversation, independently of the viewing
panel. Opening an Artifact-published HTTP/HTTPS webpage from a turn opens its URL
in the preview when the feature is enabled. Ordinary `record_artifact` links
open metadata with an external action. Closing the panel or its tab does not
remove the artifact card; users can reopen it from the original message.
Manually entering an address in the panel does not create a transcript record.

These are historical entry points to a live URL, not frozen website versions.
The preview explicitly labels this distinction. Reusing a development URL
shows the current application even when opened from an older turn. Existing
artifact metadata also updates by identity, so it is not a version archive.
Replaying a previous website requires an immutable copy of that turn's HTML
and dependent assets, or an independently versioned deployment. That storage
is implemented for self-contained Artifact deliveries in
`web-shell-preview-snapshots.md`; panel localStorage is only viewing state.

## Design

| Layer | Change |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Panel | Add a `web_preview` tab and a `webPreview` action alongside review and terminal actions. Enable it in the standalone shell; embedded consumers opt in through `rightPanel.items`. |
| Preview | Add an internal `WebPreviewPanel` using shared input and button primitives. Desktop fills the panel; mobile uses a 390 CSS pixel viewport. |
| State | Store the entry URL and viewport with the tab in the existing workspace/session-keyed panel state. Keep inactive preview frames mounted while the panel stays open. |
| Navigation | The address is the requested entry URL. Without a page bridge, navigation within a cross-origin application cannot update this field. Refresh returns to the entry URL. |
| Server | Permit HTTP/HTTPS frames in the shell document policy while preserving the other CSP directives, frame-ancestor policy, and permissions policy. No new daemon route is introduced. |

The panel accepts absolute HTTP/HTTPS URLs without embedded credentials. It
rejects the shell and configured daemon origins. IPv6 literal URLs are excluded
from this increment because CSP host sources do not reliably support them; use
a hostname instead. URLs are validated again when rendering persisted state.
No daemon credentials are forwarded and preview requests omit the referrer.

A trusted, script-free `srcDoc` wrapper contains the application iframe. Its own
CSP pins `frame-src` to the validated development origin, blocking application
direct child redirects and navigations into the shell or a different origin.
Validation also rejects HTTP sources whose CSP-permitted HTTPS upgrade reaches
a protected origin. The inner frame
allows scripts, forms, and its own origin so ordinary modules and web storage
work, but cannot navigate ancestors or open popups. All interpolated markup is
escaped. Browser tests must verify both redirect and script-navigation blocking
before this design is accepted. The wrapper is not a recursive origin firewall:
the application's own descendant frames have their own policies. Shell HTML
must retain its existing `frame-ancestors` protection and file endpoints must
retain attachment/nosniff protection. Embedded consumers must prevent their own
app documents from being framed by preview origins. Vite development serves a
`frame-ancestors 'self'` policy for the same boundary.
Offline HTML artifact previews use their own trusted parent with
`frame-src 'none'` and an opaque content iframe. This parent restriction blocks
self-navigation from offline content even when the shell permits live URLs.

An iframe load event is not evidence of a successful response. The UI provides
an external-open fallback and a short explanation for blocked/unreachable pages,
without claiming that the development server is healthy. Applications with
frame restrictions may require external opening. Embedded Web Shell consumers
must also allow the desired HTTP/HTTPS frame origins in their own CSP.

The existing panel persistence key owns previews, including in multi-workspace
and session-switch flows. Existing turn-output callbacks route recorded webpage
requests into the viewing panel, with tab IDs distinguishing source sessions
and turns. No new filesystem request or annotation feedback route is added.
Closing a preview removes its iframe and does not terminate the user's server.
Closing/reopening the entire panel or changing sessions recreates browser state
from the saved entry URL; switching tabs within the open panel preserves it.

## Files affected

- `packages/web-shell/client/components/preview/WebPreviewPanel.tsx` and
its URL/document helper and focused tests.
- `packages/web-shell/client/components/artifacts/ArtifactPanel.tsx` and tests.
- `packages/web-shell/client/App.tsx` and tests for creation and persistence.
- `packages/web-shell/client/customization.tsx`, `main.tsx`, and `i18n.tsx`.
- `packages/web-shell/README.md` and a focused browser E2E spec.
- `packages/cli/src/serve/web-shell-static.ts` and its policy tests. This is a
process-global shell-document policy change; all static and deep-link shell
consumers use the same policy builder. Workspace runtime routing is unchanged.

## Follow-up increments

Element/region annotations need a controlled page bridge and correct routing to
the owning session's composer. Browser verification needs a shared page binding
to CUA/CDP. Remote development needs a separate-origin authenticated proxy with
HTTP and HMR WebSocket forwarding. None is implied by opening an entry URL in
this increment: the user's browser must already be able to reach it, through a
direct connection or an existing tunnel.

## Validation and open questions

Use the global `qwen` binary for the baseline, then the locally built bundle
for production CSP verification. Exercise a real development server, modules,
storage, WebSocket updates, viewport size, tab switches, refresh, external
opening, persistence, and invalid origins. Verify the trusted-wrapper policy
against hostile redirects and scripts. The detailed plan and observed results
live in `.qwen/e2e-tests/web-shell-live-preview.md`.

The pre-implementation browser spike passed modules, storage, Vite HMR and direct
redirect/script-navigation blocking on Chromium and Firefox. A descendant-frame
probe confirmed the host-policy requirement described above. WebKit was not
validated because the installed browser/runtime versions did not match.
86 changes: 86 additions & 0 deletions docs/design/web-shell/web-shell-preview-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Saved webpage versions in Web Shell

## Problem

A historical link to a development server shows the current page. The user
needs the page delivered in that conversation turn to remain available after
later edits, closing the preview, reloading the shell, and restarting the
daemon.

## Design

Each successful `Artifact` publication also writes a separate, immutable local
HTML snapshot. The existing publisher continues to update its stable latest
URL. The snapshot contains the exact wrapped HTML published in that invocation,
including inline styles, scripts, data, and embedded assets. Each invocation
gets a new ID, even when the source path or content is unchanged.

Snapshot bytes live under the producing runtime's storage directory in
`artifacts/snapshots/<UUID>/index.html`. Exclusive creation prevents overwrites.
The tool emits a separate published HTML artifact with a unique managed ID,
file URL, `artifactType: web_preview_snapshot`, the latest publication URL,
and the existing trusted `qwen.published.sha256` checksum. Existing tool-result
artifact ingestion and persistence associate that descriptor with its tool
call and original turn; HTML bytes do not enter model context or SSE messages.
Session restore and same-runtime forks retain these descriptors, including
their original timestamps and deletion markers. Only Artifact-produced snapshot
descriptors with the expected UUID path and checksum qualify for local file URL
restoration; ordinary local file links remain untrusted. Restore does not read
the HTML, so missing bytes do not prevent restoring the conversation record.

The new `GET /session/:id/artifacts/:artifactId/content` route is
**live-session-owner scoped**, with the same owner resolution, client filtering,
trust and cwd-bound read behavior as the artifact listing. It looks up the
registered artifact in that owner session and reads only the fixed snapshot
path beneath that runtime's storage directory. It validates the descriptor,
file URL, regular file, containment, 16 MiB bound and checksum. Missing or
altered snapshots return an error; there is no fallback to source files, latest
URLs or the primary runtime. Responses use attachment disposition and nosniff.

The browser reads this route through the authenticated daemon SDK using the
source session ID. The artifact panel renders the HTML in an opaque-origin,
script-enabled sandbox with the existing no-network artifact CSP. Saved and
ordinary HTML previews both use a fixed parent document whose `frame-src 'none'`
blocks the content frame's own navigations. The content frame has a separate
opaque origin, so its scripts cannot modify that parent policy. This preserves
the offline preview boundary while the shell allows live development URLs. The panel
shows that this is a saved version and its creation time. Closing the panel
removes only viewing state. Reopening from the original message fetches that
same version. Latest publication cards are omitted from a turn when that same
publication has its saved-version card, avoiding two indistinguishable outputs.

## Boundaries and retention

This saves self-contained Artifact deliveries, not arbitrary live websites or
the transient state of a user's browser. Live URL preview remains available
and labeled as live. The Artifact tool already requires inline dependencies;
its existing best-effort validator is unchanged. The offline viewer blocks network
resources at runtime; this feature does not bundle external dependencies. Interactive state
starts from the delivered HTML when reopening a saved version.

Snapshot descriptors use existing session artifact retention and its 200-record
default limit. This increment does not implement an unlimited archive or change
session deletion/retention policy. Snapshot files are not overwritten or garbage
collected when another version is generated. Older live-link records cannot be
retroactively reconstructed. Missing local snapshot bytes are reported as
unavailable, including when moving a transcript without its runtime storage.

## Affected components and validation

- Core Artifact tool, snapshot storage and persistence helpers, and focused tests.
- ACP artifact restore and deletion-marker validation.
- CLI owner-scoped content route and route tests.
- Daemon SDK authenticated content read method and tests.
- Web Shell turn selectors, artifact panel, translations and browser tests.
- Web Shell README and the live-preview design's version-history boundary.

Test publishing v1 then v2 from one source, retaining the stable latest URL
while both snapshot files retain their own bytes. Restart/read from persisted
session data, open each original message, exercise inline interaction, close
and reload, and make the source/latest page unavailable. Test owner isolation,
forged descriptors, symlinks, truncation and checksum mismatch. Run the global
CLI baseline first, then build/typecheck/bundle, focused unit tests and the
browser scenario on the local daemon. See
`.qwen/e2e-tests/web-shell-preview-snapshots.md` for commands and results.

There are no open design questions for this bounded increment.
103 changes: 103 additions & 0 deletions packages/acp-bridge/src/sessionArtifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
SessionArtifactValidationError,
} from './sessionArtifacts.js';
import {
rebuildSessionArtifactSnapshot,
stableSessionArtifactId,
type RebuiltSessionArtifactSnapshot,
type SessionArtifactEventRecordPayload,
Expand Down Expand Up @@ -6193,6 +6194,108 @@ describe('SessionArtifactStore', () => {
});
});

it('restores saved webpage records and deletion markers without reading their files', async () => {
const sessionId = 'saved-webpages';
const events: SessionArtifactEventRecordPayload[] = [];
const source = new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
persistence: {
recordEvent: async (payload) => {
events.push(payload);
},
recordSnapshot: async () => {},
},
});
await source.upsertMany(
[
'8c5e8dc7-4d9c-4a52-a703-7391e9b42dad',
'9c5e8dc7-4d9c-4a52-a703-7391e9b42dad',
].map((uuid) => ({
kind: 'html' as const,
storage: 'published' as const,
source: 'tool' as const,
toolName: 'artifact',
toolCallId: `call-${uuid}`,
title: 'Saved page',
managedId: `preview-${uuid}`,
url: pathToFileURL(
path.join(workspace, 'artifacts', 'snapshots', uuid, 'index.html'),
).href,
metadata: {
artifactType: 'web_preview_snapshot',
publishedUrl: 'https://example.com/latest',
'qwen.published.sha256': 'a'.repeat(64),
},
})),
{ strict: true, trustedPublisher: true },
);
const rebuilt = rebuildSessionArtifactSnapshot(
events.map((systemPayload) => ({
type: 'system',
subtype: 'session_artifact_event',
systemPayload,
})),
)!;
expect(rebuilt.artifacts).toHaveLength(2);
expect(rebuilt.artifacts[0]?.metadata?.['publishedUrl']).toBe(
'https://example.com/latest',
);
const snapshots: SessionArtifactSnapshotRecordPayload[] = [];
const restored = new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
persistence: {
recordEvent: async () => {},
recordSnapshot: async (payload) => {
snapshots.push(payload);
},
},
});
await expect(restored.restore(rebuilt)).resolves.toEqual([]);
const listed = await restored.list();
for (const saved of rebuilt.artifacts) {
expect(listed.artifacts).toContainEqual(
expect.objectContaining({ ...saved, restoreState: 'restored' }),
);
}
const first = rebuilt.artifacts[0]!;
await expect(
restored.restore({
...rebuilt,
artifacts: [rebuilt.artifacts[1]!],
tombstonedIds: [first.id],
markerArtifacts: [first],
}),
).resolves.toEqual([]);
await restored.recordSnapshot();
expect(snapshots.at(-1)?.markerArtifacts).toEqual([
expect.objectContaining(first),
]);
expect((await restored.list()).artifacts.map((entry) => entry.id)).toEqual([
rebuilt.artifacts[1]!.id,
]);

for (const override of [
{ source: 'client' as const },
{ source: 'hook' as const },
{ toolName: 'record_artifact' },
]) {
const rejected = new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
});
const warnings = await rejected.restore({
...rebuilt,
artifacts: [{ ...first, ...override }],
});
expect(warnings).toContain(
'artifact snapshot restore failed; kept existing live artifacts',
);
expect((await rejected.list()).artifacts).toEqual([]);
}
});

it('does not trust persisted published file urls during restore', async () => {
const store = new SessionArtifactStore({
sessionId: 's11-restore-published-file',
Expand Down
8 changes: 6 additions & 2 deletions packages/acp-bridge/src/sessionArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
isRecordableDerivedChild,
isAdoptableContentFingerprintKey,
isReservedWorkspaceMetadataKey,
getWebPreviewSnapshotId,
MAX_DIRECTORY_ARTIFACT_DEPTH,
MAX_DIRECTORY_ARTIFACT_FILES,
PUBLISHED_CONTENT_SHA256_METADATA_KEY,
Expand Down Expand Up @@ -757,7 +758,8 @@ export class SessionArtifactStore {
input,
++this.receivedSeq,
artifact.storage === 'published' &&
!isFileArtifactUrl(artifact.url),
(!isFileArtifactUrl(artifact.url) ||
Boolean(getWebPreviewSnapshotId(artifact))),
{
metadataBudget: 'persisted',
workspaceExpected: workspaceExpectedFromArtifact(artifact),
Expand Down Expand Up @@ -907,7 +909,9 @@ export class SessionArtifactStore {
const normalized = await this.normalizeInput(
input,
++this.receivedSeq,
artifact.storage === 'published' && !isFileArtifactUrl(artifact.url),
artifact.storage === 'published' &&
(!isFileArtifactUrl(artifact.url) ||
Boolean(getWebPreviewSnapshotId(artifact))),
{
metadataBudget: 'persisted',
workspaceExpected: workspaceExpectedFromArtifact(artifact),
Expand Down
Loading
Loading