diff --git a/apps/cli/web-server/index.ts b/apps/cli/web-server/index.ts
index 870fac0909..fdb2622c53 100644
--- a/apps/cli/web-server/index.ts
+++ b/apps/cli/web-server/index.ts
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import { isAiModelId } from '@studio/common/ai/models';
import {
appendModelChangeEntry,
+ appendStudioEntry,
createAiSession,
deleteAiSession,
listAiSessions,
@@ -25,6 +26,7 @@ import {
setBroadcast,
startAgentRun,
} from './agent-runs';
+import { ensureWorkspace, getWorkspaceFiles } from './workspaces';
import type { AgentRunEvent } from '@studio/common/ai/agent-events';
import type { AiSessionSummary } from '@studio/common/ai/sessions/types';
import type { SitesEndpointSite } from '@studio/common/types/sync';
@@ -123,16 +125,25 @@ api.get( '/events', ( req: Request, res: Response ) => {
} );
} );
-// Broadcast every agent event to all connected SSE clients, in the same
-// envelope the web connector expects (channel + payload).
-setBroadcast( ( event: AgentRunEvent ) => {
+function broadcastSse( envelope: unknown ): void {
if ( sseClients.size === 0 ) {
return;
}
- const data = JSON.stringify( { channel: 'agent', payload: event } );
+ const data = JSON.stringify( envelope );
for ( const client of sseClients ) {
client.write( `data: ${ data }\n\n` );
}
+}
+
+// Broadcast every agent event to all connected SSE clients, in the same
+// envelope the web connector expects (channel + payload). When a run ends, also
+// emit a `preview` signal so the client-side Playground re-fetches the session's
+// workspace files and re-renders the live preview.
+setBroadcast( ( event: AgentRunEvent ) => {
+ broadcastSse( { channel: 'agent', payload: event } );
+ if ( event.event.type === 'run.exited' ) {
+ broadcastSse( { channel: 'preview', payload: { sessionId: event.sessionId } } );
+ }
} );
// --- Health ------------------------------------------------------------------
@@ -249,9 +260,20 @@ api.get(
api.post(
'/sessions',
asyncHandler( async ( _req: Request, res: Response ) => {
- // `siteId` is accepted but not yet wired: sessions start unbound and the
- // agent creates/binds a site during the run.
- res.json( await createAiSession( root ) );
+ // Studio Web runs the agent on a per-session, git-backed workspace — the
+ // cloud analog of Studio App's local site, and where the live preview
+ // reads from. Create the session, provision its workspace, and bind it
+ // via a `studio.site_selected` entry so the forked agent (`code sessions
+ // resume`) resolves the workspace as its active site. (Seeding from an
+ // existing WordPress.com site via `siteId` is a later increment.)
+ const session = await createAiSession( root );
+ const workspace = ensureWorkspace( session.id );
+ await appendStudioEntry( root, session.id, 'studio.site_selected', {
+ siteName: workspace.name,
+ sitePath: workspace.path,
+ remote: false,
+ } );
+ res.json( await loadAiSession( root, session.id ) );
} )
);
@@ -302,6 +324,13 @@ api.post(
} )
);
+// The session workspace's files (path + base64 content). The browser overlays
+// these onto a client-side WordPress Playground to render a live preview of what
+// the agent built — no server-side site serving needed.
+api.get( '/sessions/:id/site-files', ( req: Request, res: Response ) => {
+ res.json( getWorkspaceFiles( req.params.id ) );
+} );
+
api.post( '/sessions/:id/messages', ( req: Request, res: Response ) => {
const { prompt, displayMessage } = req.body as { prompt?: string; displayMessage?: string };
if ( ! prompt ) {
diff --git a/apps/cli/web-server/workspaces.ts b/apps/cli/web-server/workspaces.ts
new file mode 100644
index 0000000000..60a3fa3145
--- /dev/null
+++ b/apps/cli/web-server/workspaces.ts
@@ -0,0 +1,165 @@
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths';
+
+/**
+ * Per-session workspace for Studio Web — the cloud analog of Studio App's local
+ * site layer, and the answer to "where does the agent build, and where does it
+ * persist". Each session gets its own git-backed working directory that the
+ * forked agent operates on as its active site.
+ *
+ * "git-backed" is deliberate: it's the project container Studio Web is intended
+ * to use (Matt B's git-as-project-container proposal) — `git status` is the
+ * change set and a later `git push` is the publish/deploy step. This increment
+ * only reads the workspace for the live preview; the draft/publish surface is a
+ * follow-up.
+ *
+ * (The workspace lives under STUDIO_SITES_ROOT so the agent's file tools —
+ * scoped to that root — can reach it without relocating HOME, which would move
+ * session storage too.)
+ */
+
+const WORKSPACE_PREFIX = 'studio-web';
+
+export interface Workspace {
+ name: string;
+ slug: string;
+ path: string;
+}
+
+const GIT_ENV = {
+ GIT_AUTHOR_NAME: 'Studio Web',
+ GIT_AUTHOR_EMAIL: 'studio-web@local',
+ GIT_COMMITTER_NAME: 'Studio Web',
+ GIT_COMMITTER_EMAIL: 'studio-web@local',
+};
+
+function git( cwd: string, args: string[] ): void {
+ execFileSync( 'git', args, { cwd, stdio: 'ignore', env: { ...process.env, ...GIT_ENV } } );
+}
+
+function gitOut( cwd: string, args: string[] ): string {
+ return execFileSync( 'git', args, {
+ cwd,
+ encoding: 'utf8',
+ env: { ...process.env, ...GIT_ENV },
+ } ).trim();
+}
+
+// Session ids come from HTTP requests. They're UUIDs, but validate before using
+// one in a filesystem path — derive the slug from an allowlisted slice only.
+function safeSessionToken( sessionId: string ): string {
+ if ( ! /^[a-zA-Z0-9-]+$/.test( sessionId ) ) {
+ throw new Error( `Invalid session id: ${ sessionId }` );
+ }
+ // The slug only ever contains these chars — no path separators can survive.
+ return sessionId.replace( /[^a-zA-Z0-9]/g, '' ).slice( 0, 8 );
+}
+
+/** The workspace identity for a session (slug/name/path), without touching disk. */
+export function workspaceFor( sessionId: string ): Workspace {
+ const token = safeSessionToken( sessionId );
+ const slug = `${ WORKSPACE_PREFIX }-${ token }`;
+ const workspacePath = path.join( STUDIO_SITES_ROOT, slug );
+ // Containment check: even with the token allowlist above, confirm the
+ // resolved path stays inside STUDIO_SITES_ROOT before anything touches the
+ // filesystem with it — a crafted session id must not escape the sites root
+ // (defends against CodeQL js/path-injection).
+ const relative = path.relative( STUDIO_SITES_ROOT, workspacePath );
+ if ( relative.startsWith( '..' ) || path.isAbsolute( relative ) ) {
+ throw new Error( `Workspace path escapes the sites root: ${ sessionId }` );
+ }
+ return { name: `Studio Web (${ token })`, slug, path: workspacePath };
+}
+
+/**
+ * Ensure a git-backed workspace exists for `sessionId` and return it. Idempotent:
+ * an existing workspace is returned untouched so re-runs of a session reuse it.
+ */
+export function ensureWorkspace( sessionId: string ): Workspace {
+ const workspace = workspaceFor( sessionId );
+ const workspacePath = workspace.path;
+
+ if ( ! fs.existsSync( workspacePath ) ) {
+ fs.mkdirSync( workspacePath, { recursive: true } );
+ }
+ if ( ! fs.existsSync( path.join( workspacePath, '.git' ) ) ) {
+ fs.writeFileSync(
+ path.join( workspacePath, '.gitignore' ),
+ // Track deployable code only, like a WordPress.com GitHub Deployments repo.
+ [ '/wp-content/uploads/', '/wp-content/database/', '**/*.sqlite', '.DS_Store', '' ].join(
+ '\n'
+ )
+ );
+ git( workspacePath, [ 'init', '-b', 'main' ] );
+ git( workspacePath, [ 'add', '-A' ] );
+ git( workspacePath, [ 'commit', '--allow-empty', '-m', 'studio-web: workspace baseline' ] );
+ }
+
+ return workspace;
+}
+
+export interface WorkspaceFile {
+ /** Path relative to the workspace root, POSIX-separated (e.g. 'wp-content/themes/foo/style.css'). */
+ path: string;
+ /** File contents, base64-encoded so binary assets survive the JSON round-trip. */
+ contentBase64: string;
+}
+
+// Guardrails so a runaway workspace can't produce an unbounded preview payload.
+const MAX_PREVIEW_FILES = 2000;
+const MAX_PREVIEW_FILE_BYTES = 10 * 1024 * 1024; // 10 MB per file (covers a small SQLite DB)
+
+// The SQLite database, when the agent built a real WP install in the workspace.
+// It's gitignored (not deployable code), but the preview overlays it so the
+// browser shows the agent's actual content, not an empty install.
+const PREVIEW_DB_PATH = 'wp-content/database/.ht.sqlite';
+
+function readWorkspaceFile( cwd: string, relPath: string ): WorkspaceFile | undefined {
+ const absPath = path.join( cwd, relPath );
+ let stat: fs.Stats;
+ try {
+ stat = fs.statSync( absPath );
+ } catch {
+ return undefined; // listed but gone (race with a concurrent agent edit)
+ }
+ if ( ! stat.isFile() || stat.size > MAX_PREVIEW_FILE_BYTES ) {
+ return undefined;
+ }
+ return { path: relPath, contentBase64: fs.readFileSync( absPath ).toString( 'base64' ) };
+}
+
+/**
+ * The workspace's files for the client-side Playground preview: the deployable
+ * code the agent wrote (tracked + untracked, .gitignore respected) plus the
+ * SQLite database if one exists, so the preview reflects real content. Empty for
+ * sessions whose workspace hasn't been created yet. Files over the per-file cap
+ * are skipped.
+ */
+export function getWorkspaceFiles( sessionId: string ): WorkspaceFile[] {
+ const { path: cwd } = workspaceFor( sessionId );
+ if ( ! fs.existsSync( path.join( cwd, '.git' ) ) ) {
+ return [];
+ }
+ // `--cached --others --exclude-standard` = tracked + untracked, honoring
+ // .gitignore — the same deployable set publish will operate on.
+ const listing = gitOut( cwd, [ 'ls-files', '--cached', '--others', '--exclude-standard' ] );
+ const relPaths = listing ? listing.split( '\n' ) : [];
+ // The DB is gitignored, so add it explicitly (preview needs it for content).
+ if ( fs.existsSync( path.join( cwd, PREVIEW_DB_PATH ) ) ) {
+ relPaths.push( PREVIEW_DB_PATH );
+ }
+
+ const files: WorkspaceFile[] = [];
+ for ( const relPath of relPaths ) {
+ if ( files.length >= MAX_PREVIEW_FILES ) {
+ break;
+ }
+ const file = readWorkspaceFile( cwd, relPath );
+ if ( file ) {
+ files.push( file );
+ }
+ }
+ return files;
+}
diff --git a/apps/ui/package.json b/apps/ui/package.json
index 0896b6b00a..d9136935a1 100644
--- a/apps/ui/package.json
+++ b/apps/ui/package.json
@@ -35,6 +35,7 @@
"@wordpress/theme": "^0.14.0",
"@wordpress/ui": "^0.14.0",
"@wp-playground/blueprints": "3.1.38",
+ "@wp-playground/client": "3.1.38",
"clsx": "^2.1.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
diff --git a/apps/ui/src/components/live-playground-preview/index.tsx b/apps/ui/src/components/live-playground-preview/index.tsx
new file mode 100644
index 0000000000..b9f94968bd
--- /dev/null
+++ b/apps/ui/src/components/live-playground-preview/index.tsx
@@ -0,0 +1,175 @@
+import { useEffect, useRef } from 'react';
+import type { SitePreviewFile } from '@/data/core';
+import type { PlaygroundClient } from '@wp-playground/client';
+
+// The Playground worker is hosted remotely; the iframe loads it cross-origin.
+const REMOTE_URL = 'https://playground.wordpress.net/remote.html';
+
+const PREVIEW_DB_PATH = 'wp-content/database/.ht.sqlite';
+
+function base64ToBytes( base64: string ): Uint8Array {
+ const binary = atob( base64 );
+ const bytes = new Uint8Array( binary.length );
+ for ( let i = 0; i < binary.length; i++ ) {
+ bytes[ i ] = binary.charCodeAt( i );
+ }
+ return bytes;
+}
+
+function posixDirname( filePath: string ): string {
+ const slash = filePath.lastIndexOf( '/' );
+ return slash <= 0 ? '/' : filePath.slice( 0, slash );
+}
+
+// `mkdir` isn't guaranteed recursive, so create each parent segment in turn.
+// Existing dirs throw, which we swallow.
+async function ensureDir( client: PlaygroundClient, dir: string ): Promise< void > {
+ const parts = dir.split( '/' ).filter( Boolean );
+ let current = '';
+ for ( const part of parts ) {
+ current += `/${ part }`;
+ try {
+ await client.mkdir( current );
+ } catch {
+ // Directory already exists.
+ }
+ }
+}
+
+// Overlay the agent's files onto the booted WordPress install at `documentRoot`.
+async function overlayFiles(
+ client: PlaygroundClient,
+ documentRoot: string,
+ files: SitePreviewFile[]
+): Promise< void > {
+ for ( const file of files ) {
+ const absolutePath = `${ documentRoot }/${ file.path }`.replace( /\/+/g, '/' );
+ await ensureDir( client, posixDirname( absolutePath ) );
+ await client.writeFile( absolutePath, base64ToBytes( file.contentBase64 ) );
+ }
+}
+
+// If the agent produced exactly one theme, return its slug so we can activate it.
+// Ambiguous cases (zero or many themes) keep the default theme.
+function singleThemeSlug( files: SitePreviewFile[] ): string | undefined {
+ const slugs = new Set< string >();
+ for ( const file of files ) {
+ const match = /^wp-content\/themes\/([^/]+)\//.exec( file.path );
+ if ( match ) {
+ slugs.add( match[ 1 ] );
+ }
+ }
+ return slugs.size === 1 ? [ ...slugs ][ 0 ] : undefined;
+}
+
+// A cheap content signature of the preview files, used as a React `key` on the
+// live preview. Playground caches its SQLite connection, so overlaying a changed
+// DB in place + reloading does NOT reflect it — only a fresh boot reads the new
+// DB. Keying the preview on this signature re-mounts it (and re-boots Playground)
+// exactly when the files actually change, so each agent turn's edits show up.
+export function livePreviewSignature( files: SitePreviewFile[] ): string {
+ let hash = 5381;
+ for ( const file of files ) {
+ const str = `${ file.path }:${ file.contentBase64 }`;
+ for ( let i = 0; i < str.length; i++ ) {
+ hash = ( ( hash << 5 ) + hash + str.charCodeAt( i ) ) | 0;
+ }
+ }
+ return `${ files.length }-${ hash }`;
+}
+
+/**
+ * Renders a live, client-side WordPress Playground preview of what the agent
+ * built. WordPress runs entirely in the visitor's browser (PHP-WASM); the
+ * agent's workspace files are overlaid onto it. There's no server-side site
+ * serving — the preview runs on the visitor's CPU and updates as the agent edits.
+ *
+ * `files` is re-overlaid whenever its identity changes (the parent re-fetches
+ * after each agent turn via the `preview` signal), so the preview follows the
+ * agent's work without a full re-import of WordPress itself.
+ */
+export function LivePlaygroundPreview( { files }: { files: SitePreviewFile[] } ) {
+ const iframeRef = useRef< HTMLIFrameElement >( null );
+ const clientRef = useRef< Promise< PlaygroundClient > | null >( null );
+
+ // Boot WordPress once when the iframe mounts.
+ useEffect( () => {
+ const iframe = iframeRef.current;
+ // Guard against React StrictMode's double-invoked effects (and any re-run):
+ // `startPlaygroundWeb` throws "Playground already booted" if the same iframe
+ // is booted twice. `clientRef.current` is already set (a pending or resolved
+ // boot) on the second invocation, so we skip it.
+ if ( ! iframe || clientRef.current ) {
+ return;
+ }
+ // Lazy-loaded: the Playground client is only pulled in when a preview is
+ // actually shown, keeping it out of the initial bundle.
+ clientRef.current = ( async () => {
+ const { startPlaygroundWeb } = await import( '@wp-playground/client' );
+ const client = await startPlaygroundWeb( {
+ iframe,
+ remoteUrl: REMOTE_URL,
+ blueprint: { landingPage: '/', preferredVersions: { php: '8.3', wp: 'latest' } },
+ } );
+ await client.isReady();
+ return client;
+ } )();
+ }, [] );
+
+ // Overlay the agent's files (and reload) whenever they change.
+ useEffect( () => {
+ let cancelled = false;
+ void ( async () => {
+ const client = await clientRef.current;
+ if ( ! client || cancelled ) {
+ return;
+ }
+ const documentRoot = await client.documentRoot;
+ await overlayFiles( client, documentRoot, files );
+ const hasDb = files.some( ( file ) => file.path === PREVIEW_DB_PATH );
+ if ( hasDb ) {
+ // The overlaid SQLite DB carries the workspace site's real content, its
+ // active theme, and its options — including siteurl/home pointing at the
+ // machine that built it. Repoint those at the Playground origin so links
+ // and assets resolve; the DB already drives the active theme and content,
+ // so no switch_theme is needed.
+ const origin = ( await client.absoluteUrl ).replace( /\/$/, '' );
+ try {
+ await client.run( {
+ code:
+ ` {
+ cancelled = true;
+ };
+ }, [ files ] );
+
+ return (
+
+ );
+}
diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts
index f4431f4ee4..67b6f8f928 100644
--- a/apps/ui/src/data/core/connectors/ipc/index.ts
+++ b/apps/ui/src/data/core/connectors/ipc/index.ts
@@ -592,6 +592,16 @@ export function createIpcConnector(): Connector {
);
},
+ // Client-side Playground preview is a Studio Web (browser) feature; the
+ // desktop app renders its own server-backed SitePreview, so there are no
+ // workspace files to overlay and nothing to signal.
+ async getSiteFiles() {
+ return [];
+ },
+ onPreviewChanged() {
+ return () => {};
+ },
+
// User preferences — the underlying main-process handlers are split
// per field; we fan out in parallel here so the UI can work with a
// single query/mutation pair.
diff --git a/apps/ui/src/data/core/connectors/web/index.ts b/apps/ui/src/data/core/connectors/web/index.ts
index 8f40e4fe02..37f06a4114 100644
--- a/apps/ui/src/data/core/connectors/web/index.ts
+++ b/apps/ui/src/data/core/connectors/web/index.ts
@@ -12,6 +12,7 @@ import type {
InstalledApps,
LoadedAiSession,
SiteDetails,
+ SitePreviewFile,
Snapshot,
SyncSite,
UserPreferences,
@@ -40,7 +41,8 @@ export class WebUnsupportedError extends Error {
// can carry both agent-run events and session-placement updates.
type ServerEvent =
| { channel: 'agent'; payload: AgentRunEvent }
- | { channel: 'placement'; payload: AiSessionPlacementUpdatedEvent };
+ | { channel: 'placement'; payload: AiSessionPlacementUpdatedEvent }
+ | { channel: 'preview'; payload: { sessionId: string } };
/**
* The Studio Web data source: the web analog of the Electron IPC connector.
@@ -66,6 +68,7 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne
const agentListeners = new Set< ( event: AgentRunEvent ) => void >();
const placementListeners = new Set< ( event: AiSessionPlacementUpdatedEvent ) => void >();
+ const previewListeners = new Set< ( sessionId: string ) => void >();
let eventSource: EventSource | undefined;
// Last site list fetched via getSites(), so one-off lookups (openSiteUrl)
// don't trigger an extra round-trip to the WordPress.com API.
@@ -116,6 +119,8 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne
agentListeners.forEach( ( listener ) => listener( parsed.payload ) );
} else if ( parsed.channel === 'placement' ) {
placementListeners.forEach( ( listener ) => listener( parsed.payload ) );
+ } else if ( parsed.channel === 'preview' ) {
+ previewListeners.forEach( ( listener ) => listener( parsed.payload.sessionId ) );
}
};
},
@@ -312,6 +317,19 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne
return () => placementListeners.delete( listener );
},
+ // Studio Web live preview: the session workspace's files, read by the
+ // backend from the agent's git-backed workspace. The browser overlays them
+ // onto a client-side Playground.
+ async getSiteFiles( sessionId ): Promise< SitePreviewFile[] > {
+ return api< SitePreviewFile[] >(
+ `/sessions/${ encodeURIComponent( sessionId ) }/site-files`
+ );
+ },
+ onPreviewChanged( listener ) {
+ previewListeners.add( listener );
+ return () => previewListeners.delete( listener );
+ },
+
// User preferences — sensible browser defaults.
async getUserPreferences(): Promise< UserPreferences > {
return {
diff --git a/apps/ui/src/data/core/index.ts b/apps/ui/src/data/core/index.ts
index b0eff4e788..9235f337e2 100644
--- a/apps/ui/src/data/core/index.ts
+++ b/apps/ui/src/data/core/index.ts
@@ -19,6 +19,7 @@ export type {
SelectedSiteFolder,
SessionEntry,
SiteDetails,
+ SitePreviewFile,
Snapshot,
StudioAgentQuestionData,
StudioChatFileAttachment,
diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts
index 5e171dbe3e..4a2a0d7981 100644
--- a/apps/ui/src/data/core/types.ts
+++ b/apps/ui/src/data/core/types.ts
@@ -85,6 +85,14 @@ export interface LocalMediaFile {
data: ArrayBuffer;
}
+// A single file from the agent's workspace, addressed by its path relative to
+// the WordPress root and its base64 content. Fed to a client-side Playground to
+// render a live preview of what the agent built, entirely in the browser.
+export interface SitePreviewFile {
+ path: string;
+ contentBase64: string;
+}
+
export interface AuthUser {
id: number;
email: string;
@@ -255,6 +263,15 @@ export interface Connector {
listener: ( event: AiSessionPlacementUpdatedEvent ) => void
): () => void;
+ // Studio Web live preview: the files of what the agent built
+ // for this session, overlaid onto a client-side WordPress Playground in the
+ // browser. `onPreviewChanged` fires when those files may have changed (after
+ // an agent turn) so the preview can re-fetch. Connectors without a browser-
+ // previewable workspace (desktop IPC, which renders its own SitePreview)
+ // return an empty list and never fire.
+ getSiteFiles( sessionId: string ): Promise< SitePreviewFile[] >;
+ onPreviewChanged( listener: ( sessionId: string ) => void ): () => void;
+
// Flip the session between acting on its owner site's local runtime vs.
// its linked WordPress.com live site. The owner site itself never changes.
setSessionEnvironment(
diff --git a/apps/ui/src/data/queries/use-site-files.ts b/apps/ui/src/data/queries/use-site-files.ts
new file mode 100644
index 0000000000..f964d85450
--- /dev/null
+++ b/apps/ui/src/data/queries/use-site-files.ts
@@ -0,0 +1,44 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect } from 'react';
+import { useConnector } from '@/data/core';
+
+export const SITE_FILES_QUERY_KEY = [ 'site-files' ] as const;
+
+/**
+ * The session workspace's files for a client-side Playground preview. Re-fetches
+ * whenever the connector signals the session's files changed (after an agent
+ * turn), so the live preview follows the agent's work.
+ *
+ * Disabled when `sessionId` is undefined. Connectors without a browser-
+ * previewable workspace (desktop IPC) return an empty list and never signal.
+ */
+export function useSiteFiles( sessionId: string | undefined ) {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+
+ useEffect( () => {
+ const unsubscribe = connector.onPreviewChanged( ( changedSessionId ) => {
+ if ( changedSessionId !== sessionId ) {
+ return;
+ }
+ void queryClient.invalidateQueries( {
+ queryKey: [ ...SITE_FILES_QUERY_KEY, sessionId ],
+ } );
+ } );
+ return unsubscribe;
+ }, [ connector, queryClient, sessionId ] );
+
+ return useQuery( {
+ queryKey: [ ...SITE_FILES_QUERY_KEY, sessionId ],
+ queryFn: () => connector.getSiteFiles( sessionId as string ),
+ enabled: !! sessionId,
+ // Each fetch is a fresh snapshot of the workspace files (incl. the SQLite
+ // DB). React Query's default structural sharing can return the previous
+ // array reference when its deep-equality pass treats the large base64
+ // payloads as unchanged, so the preview's `[files]` effect never re-runs
+ // after an agent turn. Disable it: every refetch yields a new reference,
+ // so the live preview re-overlays and reloads. Re-overlaying identical
+ // files is harmless.
+ structuralSharing: false,
+ } );
+}
diff --git a/apps/ui/src/ui-classic/components/session-view/index.tsx b/apps/ui/src/ui-classic/components/session-view/index.tsx
index 2c06074f38..21cbc541dc 100644
--- a/apps/ui/src/ui-classic/components/session-view/index.tsx
+++ b/apps/ui/src/ui-classic/components/session-view/index.tsx
@@ -10,6 +10,7 @@ import { type Annotation } from '@/components/site-preview/types';
import { useAgentRun } from '@/data/queries/use-agent-run';
import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
import { useSession, useSessionEffectiveEnvironment } from '@/data/queries/use-sessions';
+import { useSiteFiles } from '@/data/queries/use-site-files';
import { useSites } from '@/data/queries/use-sites';
import { useFullscreen } from '@/hooks/use-fullscreen';
import { useSessionCommands } from '@/hooks/use-session-commands';
@@ -173,7 +174,12 @@ function SessionViewContent( { sessionId }: { sessionId: string } ) {
const scrollRef = useRef< HTMLDivElement >( null );
useSessionCommands( sessionId );
const preview = useSessionPreviewUI();
- const canTogglePreview = !! ownerSite && effectiveEnvironment === 'local';
+ // Studio Web: the session's workspace renders as a client-side Playground
+ // preview, so the toggle is available whenever the agent has built files —
+ // even though the workspace isn't a registered Studio site. Empty on desktop.
+ const { data: siteFiles } = useSiteFiles( sessionId );
+ const hasLivePreview = ( siteFiles?.length ?? 0 ) > 0;
+ const canTogglePreview = hasLivePreview || ( !! ownerSite && effectiveEnvironment === 'local' );
const showPreview = preview.open && canTogglePreview;
const handleAnnotationsDone = useCallback(
diff --git a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
index a76f582974..ece9316558 100644
--- a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
+++ b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
@@ -1,9 +1,11 @@
import { createRoute, Outlet, useRouterState } from '@tanstack/react-router';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
+import { LivePlaygroundPreview, livePreviewSignature } from '@/components/live-playground-preview';
import { PreviewSplitFrame } from '@/components/preview-split-frame';
import { SidebarLayout } from '@/components/sidebar-layout';
import { SitePreview } from '@/components/site-preview';
import { useSession, useSessionEffectiveEnvironment } from '@/data/queries/use-sessions';
+import { useSiteFiles } from '@/data/queries/use-site-files';
import { useSites } from '@/data/queries/use-sites';
import {
SessionUIProvider,
@@ -66,14 +68,27 @@ function DashboardLayoutContent() {
? sites?.find( ( site ) => site.id === lastPreviewSiteId )
: undefined;
const previewSite = routeSite ?? lastPreviewSite;
- const showPreview = preview.open && supportsPreview && !! previewSite;
+
+ // Studio Web: the agent builds into a per-session workspace that isn't a
+ // registered Studio site, so there's no `previewSite` to drive a SitePreview.
+ // Instead the workspace's files render in a client-side WordPress Playground.
+ // Empty on desktop, where SitePreview handles the running site.
+ const { data: siteFiles } = useSiteFiles( sessionId );
+ const hasLivePreview = ( siteFiles?.length ?? 0 ) > 0;
+ // Re-key on a content signature so each agent turn re-boots Playground with
+ // the new files (it caches its SQLite connection across in-place overlays).
+ const livePreviewKey = useMemo( () => livePreviewSignature( siteFiles ?? [] ), [ siteFiles ] );
+
+ const showPreview = preview.open && ( hasLivePreview || ( supportsPreview && !! previewSite ) );
return (
+ ) : previewSite ? (
=22.19.0"
@@ -17591,6 +17592,16 @@
"node": ">=12"
}
},
+ "node_modules/@wp-playground/client": {
+ "version": "3.1.38",
+ "resolved": "https://registry.npmjs.org/@wp-playground/client/-/client-3.1.38.tgz",
+ "integrity": "sha512-Zw2/i0zbt8CMf4oVAdtWvQt1QXrH+VpO/v0X6XMn/odiSznLHxA8HM7fdS57WVdfVpF5w1VQtHa5cf0sOG02kg==",
+ "license": "GPL-2.0-or-later",
+ "engines": {
+ "node": ">=20.10.0",
+ "npm": ">=10.2.3"
+ }
+ },
"node_modules/@wp-playground/common": {
"version": "3.1.38",
"resolved": "https://registry.npmjs.org/@wp-playground/common/-/common-3.1.38.tgz",