From f627cad3ed758b3b9ac4ffb6f887ae348600f3e4 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 11:16:53 -0300 Subject: [PATCH 1/8] Studio Web: run the portable UI in a browser via a web connector and a CLI web-server command Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + apps/cli/index.ts | 16 + apps/cli/package.json | 2 + apps/cli/vite.config.base.ts | 1 + apps/cli/web-server/README.md | 64 +++ apps/cli/web-server/agent-runs.ts | 167 +++++++ apps/cli/web-server/index.ts | 262 +++++++++++ apps/ui/index.web.html | 12 + apps/ui/package.json | 5 +- apps/ui/src/app/use-ui-mode.ts | 24 +- apps/ui/src/data/core/connectors/web/index.ts | 409 ++++++++++++++++++ apps/ui/src/lib/get-site-url.ts | 6 + apps/ui/src/main.web.tsx | 55 +++ apps/ui/src/vite-env.d.ts | 9 + apps/ui/vite.config.ts | 48 +- package-lock.json | 117 ++++- 16 files changed, 1191 insertions(+), 7 deletions(-) create mode 100644 apps/cli/web-server/README.md create mode 100644 apps/cli/web-server/agent-runs.ts create mode 100644 apps/cli/web-server/index.ts create mode 100644 apps/ui/index.web.html create mode 100644 apps/ui/src/data/core/connectors/web/index.ts create mode 100644 apps/ui/src/main.web.tsx diff --git a/.gitignore b/.gitignore index 6d1e78965d..b06c66cf62 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,7 @@ cli/vendor/ # Build output dist +dist-web # Playwright traces test-results diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 3e61f24cda..aac340a1ae 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -190,6 +190,22 @@ async function main() { ); studioArgv.command( 'ai', false, studioCodeCommandBuilder ); + studioArgv.command( { + command: 'web-server', + describe: __( 'Start the Studio Web backend (HTTP/SSE) for the browser UI' ), + builder: ( webYargs: StudioArgv ) => { + return webYargs.option( 'port', { + type: 'number', + description: __( 'Port to listen on' ), + default: 8088, + } ); + }, + handler: async ( argv ) => { + process.env.STUDIO_WEB_SERVER_PORT = String( ( argv as { port?: number } ).port ?? 8088 ); + await import( 'cli/web-server/index.js' ); + }, + } ); + registerExportCommand( studioArgv ); registerImportCommand( studioArgv ); registerMcpCommand( studioArgv ); diff --git a/apps/cli/package.json b/apps/cli/package.json index a1a5645bce..28e42d6925 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -46,6 +46,7 @@ "atomically": "^2.1.1", "chokidar": "^5.0.0", "cli-table3": "^0.6.5", + "express": "^4.22.0", "fs-extra": "^11.3.4", "http-proxy": "^1.18.1", "ignore": "^7.0.5", @@ -76,6 +77,7 @@ "devDependencies": { "@studio/common": "file:../../tools/common", "@types/archiver": "^7.0.0", + "@types/express": "^4.17.23", "@types/http-proxy": "^1.17.17", "@types/node-forge": "^1.3.14", "@types/yargs": "^17.0.35", diff --git a/apps/cli/vite.config.base.ts b/apps/cli/vite.config.base.ts index e0bcb32366..5045f9c6b3 100644 --- a/apps/cli/vite.config.base.ts +++ b/apps/cli/vite.config.base.ts @@ -61,6 +61,7 @@ export const baseConfig = defineConfig( { lib: { entry: { main: resolve( __dirname, 'index.ts' ), + 'web-server': resolve( __dirname, 'web-server/index.ts' ), 'process-manager-daemon': resolve( __dirname, 'process-manager-daemon.ts' ), 'proxy-daemon': resolve( __dirname, 'proxy-daemon.ts' ), 'playground-server-child': resolve( __dirname, 'playground-server-child.ts' ), diff --git a/apps/cli/web-server/README.md b/apps/cli/web-server/README.md new file mode 100644 index 0000000000..3cc540d07a --- /dev/null +++ b/apps/cli/web-server/README.md @@ -0,0 +1,64 @@ +# Studio Web server + +The CLI `web-server` command is the HTTP + SSE backend for running Studio's +agent from a browser (the "Studio Web" exploration). It exposes the same +capabilities the desktop app reaches over IPC, but over HTTP, so the portable +`apps/ui` renderer can talk to it through the **web connector** +(`apps/ui/src/data/core/connectors/web`). + +``` +node apps/cli/dist/cli/main.mjs web-server # listens on 127.0.0.1:8088 (--port / STUDIO_WEB_SERVER_PORT) +``` + +To run the UI against it: + +``` +cd apps/ui && npm run dev:web # serves the browser entry on :5300 +``` + +## Process topology + +Everything here runs on one machine, but the pieces map cleanly onto a hosted +deployment. Three distinctions matter before counting: + +- A **server** is a long-lived listener. +- The **agent** is a short-lived child process forked _per message_ — not a server. + +### Local + +| Piece | Lifetime | Role | +|-------|----------|------| +| Web UI dev server (Vite) | long-lived | serves the SPA to the browser | +| `web-server` (Express) | long-lived | HTTP + SSE API: sessions, sites, agent runs | +| agent (`code sessions resume … --json`) | per message | forked child, same subcommand the desktop app forks | + +The server binds to loopback only: it exposes the local user's sessions and +WordPress.com data without authentication, so it must not be reachable from the +network. + +### Hosted (direction, not in this increment) + +| Piece | Lifetime | Notes | +|-------|----------|-------| +| Web UI | static | served from a CDN / static host | +| `web-server` API | long-lived **fleet** | one multi-tenant backend | +| session sandbox | **ephemeral, one per session** | the agent runs here; spun up and down per session | + +Going from local to hosted, only `web-server` becomes a fleet; the per-session +sandbox replaces "your laptop" as the place the agent runs rather than adding a +new always-on server. + +## Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/health` | liveness check | +| `GET` | `/events` | SSE stream carrying every run's `AgentRunEvent`s | +| `GET` | `/sites` | the user's workable WordPress.com sites (requires `studio auth login`) | +| `GET/POST` | `/sessions` | list / create AI sessions (shared session store) | +| `GET/PATCH/DELETE` | `/sessions/:id` | load / star-archive / delete a session | +| `POST` | `/sessions/:id/messages` | send a prompt — forks the agent, returns `{ runId }` | +| `POST` | `/sessions/:id/model` | persist a model override for the session | +| `GET` | `/runs/active` | active agent runs | +| `POST` | `/runs/:runId/interrupt` | graceful interrupt, SIGKILL on second attempt | +| `POST` | `/runs/:runId/answer` | answer an agent question | diff --git a/apps/cli/web-server/agent-runs.ts b/apps/cli/web-server/agent-runs.ts new file mode 100644 index 0000000000..cd84477aba --- /dev/null +++ b/apps/cli/web-server/agent-runs.ts @@ -0,0 +1,167 @@ +import { fork, type ChildProcess } from 'node:child_process'; +import crypto from 'node:crypto'; +import type { ActiveAgentRun, AgentEvent, AgentRunEvent } from '@studio/common/ai/agent-events'; +import type { JsonEvent } from '@studio/common/ai/json-events'; + +/** + * Headless analog of the desktop `run-manager` (apps/studio/src/modules/ + * ai-agent/run-manager.ts). It forks the exact same CLI subcommand the desktop + * forks — `code sessions resume --json` — relays the child's + * `JsonEvent`s as `AgentRunEvent`s, and synthesizes the same lifecycle events + * (`run.started`, `run.exited`, ...). The only difference is the sink: instead + * of `webContents.send`, events go to a broadcaster (SSE) injected via + * `setBroadcast`. + */ + +interface AgentRun { + runId: string; + sessionId: string; + child: ChildProcess; + interrupted: boolean; + interruptAttempts: number; + startedAt: number; +} + +const runsBySessionId = new Map< string, AgentRun >(); +const runsById = new Map< string, AgentRun >(); + +type Broadcast = ( event: AgentRunEvent ) => void; +let broadcast: Broadcast = () => {}; + +export function setBroadcast( fn: Broadcast ): void { + broadcast = fn; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function send( run: AgentRun, event: AgentEvent ): void { + broadcast( { runId: run.runId, sessionId: run.sessionId, event } ); +} + +export interface StartAgentRunOptions { + sessionId: string; + prompt: string; + displayMessage?: string; +} + +export function startAgentRun( options: StartAgentRunOptions ): { runId: string } { + const { sessionId, prompt, displayMessage } = options; + + if ( runsBySessionId.has( sessionId ) ) { + throw new Error( `A run is already in progress for session ${ sessionId }` ); + } + + const runId = crypto.randomUUID(); + const startedAt = Date.now(); + const args = [ 'code', 'sessions', 'resume', sessionId, prompt, '--json', '--avoid-telemetry' ]; + if ( displayMessage ) { + args.push( '--display-message', displayMessage ); + } + + // Re-invoke this same CLI bundle. The child emits JSON transport events over + // the Node IPC channel (process.send), which we read via `message`. + const child = fork( process.argv[ 1 ], args, { + stdio: [ 'ignore', 'inherit', 'inherit', 'ipc' ], + execArgv: [ '--experimental-wasm-jspi' ], + env: { ...process.env }, + } ); + + const run: AgentRun = { + runId, + sessionId, + child, + interrupted: false, + interruptAttempts: 0, + startedAt, + }; + + runsBySessionId.set( sessionId, run ); + runsById.set( runId, run ); + + child.on( 'spawn', () => { + send( run, { type: 'run.started', timestamp: nowIso() } ); + } ); + + child.on( 'message', ( message ) => { + // The CLI's Logger also writes to this channel with a different shape; + // forward only messages that look like the JSON transport envelope. + if ( message && typeof message === 'object' && 'type' in message ) { + send( run, message as JsonEvent ); + } + } ); + + child.on( 'error', ( error ) => { + send( run, { + type: 'error', + timestamp: nowIso(), + message: error.message || 'CLI subprocess failed to start', + } ); + } ); + + child.on( 'exit', ( code ) => { + runsBySessionId.delete( sessionId ); + runsById.delete( runId ); + if ( run.interrupted ) { + send( run, { type: 'run.interrupted', timestamp: nowIso() } ); + } + send( run, { + type: 'run.exited', + timestamp: nowIso(), + status: code === 0 ? 'success' : 'error', + code, + } ); + } ); + + return { runId }; +} + +export function listActiveAgentRuns(): ActiveAgentRun[] { + return Array.from( runsBySessionId.values() ).map( ( run ) => ( { + runId: run.runId, + sessionId: run.sessionId, + startedAt: run.startedAt, + phase: run.interrupted ? 'interrupting' : 'running', + } ) ); +} + +const INTERRUPT_FORCE_KILL_TIMEOUT_MS = 2000; + +export function interruptAgentRun( runId: string ): void { + const run = runsById.get( runId ); + if ( ! run ) { + return; + } + run.interrupted = true; + run.interruptAttempts += 1; + if ( runsBySessionId.get( run.sessionId ) === run ) { + runsBySessionId.delete( run.sessionId ); + } + + if ( run.interruptAttempts > 1 ) { + run.child.kill( 'SIGKILL' ); + return; + } + + if ( run.child.connected ) { + run.child.send( { type: 'interrupt' } ); + send( run, { type: 'run.interrupting', timestamp: nowIso() } ); + setTimeout( () => { + if ( runsById.get( runId ) === run && ! run.child.killed ) { + run.child.kill( 'SIGKILL' ); + } + }, INTERRUPT_FORCE_KILL_TIMEOUT_MS ).unref(); + return; + } + + run.child.kill( 'SIGKILL' ); +} + +export function answerAgentRun( runId: string, answers: Record< string, string > ): void { + const run = runsById.get( runId ); + if ( ! run || ! run.child.connected ) { + return; + } + run.child.send( { type: 'answer', answers } ); +} diff --git a/apps/cli/web-server/index.ts b/apps/cli/web-server/index.ts new file mode 100644 index 0000000000..a108ca18c4 --- /dev/null +++ b/apps/cli/web-server/index.ts @@ -0,0 +1,262 @@ +import { isAiModelId } from '@studio/common/ai/models'; +import { + appendModelChangeEntry, + createAiSession, + deleteAiSession, + listAiSessions, + loadAiSession, +} from '@studio/common/ai/sessions/store'; +import { readAuthToken } from '@studio/common/lib/shared-config'; +import { getSyncSupport } from '@studio/common/lib/sync/sync-support'; +import express from 'express'; +import { getAiSessionsRootDirectory } from 'cli/ai/sessions/paths'; +import { + answerAgentRun, + interruptAgentRun, + listActiveAgentRuns, + setBroadcast, + startAgentRun, +} from './agent-runs'; +import type { AgentRunEvent } from '@studio/common/ai/agent-events'; +import type { SitesEndpointSite } from '@studio/common/types/sync'; +import type { Request, Response } from 'express'; + +const DEFAULT_PORT = 8088; + +function getPort(): number { + return parseInt( process.env.STUDIO_WEB_SERVER_PORT ?? String( DEFAULT_PORT ), 10 ); +} + +function param( req: Request, name: string ): string { + return req.params[ name ] ?? ''; +} + +const root = getAiSessionsRootDirectory(); +const app = express(); + +// Permissive CORS for the localhost PoC: the SPA dev server (5300) and this +// backend live on different ports. EventSource (GET /events) needs no +// preflight, but the JSON POST/PATCH/DELETE routes do. +app.use( ( req: Request, res: Response, next ) => { + res.setHeader( 'Access-Control-Allow-Origin', req.headers.origin ?? '*' ); + res.setHeader( 'Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS' ); + res.setHeader( 'Access-Control-Allow-Headers', 'Content-Type' ); + if ( req.method === 'OPTIONS' ) { + res.sendStatus( 204 ); + return; + } + next(); +} ); + +app.use( express.json() ); + +// --- Server-Sent Events: one stream carries every run's AgentRunEvents ------- + +const sseClients = new Set< Response >(); + +app.get( '/events', ( req: Request, res: Response ) => { + res.setHeader( 'Content-Type', 'text/event-stream' ); + res.setHeader( 'Cache-Control', 'no-cache' ); + res.setHeader( 'Connection', 'keep-alive' ); + res.flushHeaders?.(); + res.write( ': connected\n\n' ); + sseClients.add( res ); + req.on( 'close', () => { + sseClients.delete( res ); + } ); +} ); + +// Broadcast every agent event to all connected SSE clients, in the same +// envelope the web connector expects (channel + payload). +setBroadcast( ( event: AgentRunEvent ) => { + const data = JSON.stringify( { channel: 'agent', payload: event } ); + for ( const client of sseClients ) { + client.write( `data: ${ data }\n\n` ); + } +} ); + +// --- Health ------------------------------------------------------------------ + +app.get( '/health', ( _req: Request, res: Response ) => { + res.json( { status: 'ok' } ); +} ); + +// --- Sites ------------------------------------------------------------------- + +// Studio Web is a hosted product: the browser has no local Studio. The site +// list is the user's WordPress.com sites, fetched live from the dotcom API +// using the stored auth token — not local Studio config. We list only sites the +// user can actually work on in Studio (Studio's own "syncable" criterion: +// administered, supported host/plan, not deleted), so the 1000s of P2s a user +// can merely read don't flood the list. +const WPCOM_SITE_FIELDS = [ + 'ID', + 'name', + 'URL', + 'icon', + 'is_deleted', + 'capabilities', + 'is_wpcom_atomic', + 'plan', + 'jetpack', + 'hosting_provider_guess', + 'environment_type', + 'options', +].join( ',' ); + +app.get( '/sites', async ( _req: Request, res: Response ) => { + const token = await readAuthToken(); + if ( ! token ) { + // Not signed in to WordPress.com — no sites to show. + res.json( [] ); + return; + } + + const url = new URL( 'https://public-api.wordpress.com/rest/v1.1/me/sites' ); + url.searchParams.set( 'fields', WPCOM_SITE_FIELDS ); + // `wpcom_staging_blog_ids` lets us point the browser at a site's staging + // environment instead of production (see below). + url.searchParams.set( 'options', 'wpcom_staging_blog_ids' ); + const response = await fetch( url, { + headers: { Authorization: `Bearer ${ token.accessToken }` }, + } ); + if ( ! response.ok ) { + res.status( response.status ).json( { + error: `WordPress.com sites fetch failed (${ response.status })`, + } ); + return; + } + + const body = ( await response.json() ) as { sites?: SitesEndpointSite[] }; + const allSites = body.sites ?? []; + + // Look up any site's URL by blog id, and collect every staging blog id so + // staging environments aren't listed as their own cards (they're surfaced + // through their production parent below). + const urlByBlogId = new Map< number, string >(); + const stagingBlogIds = new Set< number >(); + for ( const site of allSites ) { + urlByBlogId.set( site.ID, site.URL ); + for ( const stagingId of site.options?.wpcom_staging_blog_ids ?? [] ) { + stagingBlogIds.add( stagingId ); + } + } + + const sites = allSites + // `getSyncSupport` with no connected ids returns 'syncable' for sites the + // user administers on a supported host/plan — i.e. usable in Studio. + .filter( ( site ) => getSyncSupport( site, [] ) === 'syncable' ) + .filter( ( site ) => ! stagingBlogIds.has( site.ID ) ) + .map( ( site ) => { + // Studio Web edits sites on their staging environment, never + // production. When a site has a staging blog, surface its URL. + const stagingId = site.options?.wpcom_staging_blog_ids?.[ 0 ]; + const siteUrl = ( stagingId && urlByBlogId.get( stagingId ) ) || site.URL; + return { + id: String( site.ID ), + name: site.name || site.URL || String( site.ID ), + path: '', + port: 0, + running: true, + url: siteUrl, + phpVersion: '', + siteIcon: site.icon?.img ?? null, + }; + } ); + res.json( sites ); +} ); + +// --- AI sessions ------------------------------------------------------------- + +app.get( '/sessions', async ( _req: Request, res: Response ) => { + res.json( await listAiSessions( root ) ); +} ); + +app.post( '/sessions', async ( _req: Request, res: Response ) => { + // `siteId` is accepted but ignored for the PoC: sessions start unbound and + // the agent creates/binds a site during the run. + res.json( await createAiSession( root ) ); +} ); + +app.get( '/sessions/:id', async ( req: Request, res: Response ) => { + res.json( await loadAiSession( root, param( req, 'id' ) ) ); +} ); + +app.delete( '/sessions/:id', async ( req: Request, res: Response ) => { + await deleteAiSession( root, param( req, 'id' ) ); + res.sendStatus( 204 ); +} ); + +app.patch( '/sessions/:id', async ( req: Request, res: Response ) => { + // Star/archive aren't persisted in the PoC (no shared store helper); echo + // the requested state on top of the current summary so the UI stays in sync. + const { summary } = await loadAiSession( root, param( req, 'id' ) ); + const patch = req.body as { starred?: boolean; archived?: boolean }; + res.json( { ...summary, ...patch } ); +} ); + +app.post( '/sessions/:id/model', async ( req: Request, res: Response ) => { + const { model } = req.body as { model?: string }; + if ( ! model || ! isAiModelId( model ) ) { + res.status( 400 ).json( { error: `Unknown AI model: ${ model }` } ); + return; + } + await appendModelChangeEntry( root, param( req, 'id' ), '', model ); + res.sendStatus( 204 ); +} ); + +app.post( '/sessions/:id/messages', ( req: Request, res: Response ) => { + const { prompt, displayMessage } = req.body as { prompt?: string; displayMessage?: string }; + if ( ! prompt ) { + res.status( 400 ).json( { error: 'prompt is required' } ); + return; + } + const { runId } = startAgentRun( { sessionId: param( req, 'id' ), prompt, displayMessage } ); + res.json( { runId } ); +} ); + +// --- Runs -------------------------------------------------------------------- + +app.get( '/runs/active', ( _req: Request, res: Response ) => { + res.json( listActiveAgentRuns() ); +} ); + +app.post( '/runs/:runId/interrupt', ( req: Request, res: Response ) => { + interruptAgentRun( param( req, 'runId' ) ); + res.sendStatus( 204 ); +} ); + +app.post( '/runs/:runId/answer', ( req: Request, res: Response ) => { + const { answers } = req.body as { answers?: Record< string, string > }; + answerAgentRun( param( req, 'runId' ), answers ?? {} ); + res.sendStatus( 204 ); +} ); + +// --- Error handling ---------------------------------------------------------- + +app.use( ( err: unknown, _req: Request, res: Response, _next: ( e?: unknown ) => void ) => { + const message = err instanceof Error ? err.message : String( err ); + res.status( 500 ).json( { error: message } ); +} ); + +const port = getPort(); +// Bind to loopback only: the server exposes the local user's sessions and +// WordPress.com data without authentication, so it must not be reachable from +// the network. +const server = app.listen( port, '127.0.0.1', () => { + console.log( `\nWordPress Studio Web Server` ); + console.log( `==========================` ); + console.log( `Listening: http://localhost:${ port }` ); + console.log( `Health: http://localhost:${ port }/health` ); + console.log( `Events: http://localhost:${ port }/events (SSE)` ); + console.log( '' ); + console.log( `Point the web UI at this with VITE_STUDIO_API_URL=http://localhost:${ port }` ); + console.log( '' ); +} ); + +process.on( 'SIGINT', () => { + server.close( () => process.exit( 0 ) ); +} ); +process.on( 'SIGTERM', () => { + server.close( () => process.exit( 0 ) ); +} ); diff --git a/apps/ui/index.web.html b/apps/ui/index.web.html new file mode 100644 index 0000000000..a5e1268de4 --- /dev/null +++ b/apps/ui/index.web.html @@ -0,0 +1,12 @@ + + + + + + Studio Web + + +
+ + + diff --git a/apps/ui/package.json b/apps/ui/package.json index 02ca4db048..0896b6b00a 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -5,10 +5,13 @@ "type": "module", "scripts": { "dev": "vite", + "dev:web": "STUDIO_TARGET=web vite", "build": "vite build", + "build:web": "STUDIO_TARGET=web vite build", "lint": "eslint src", "typecheck": "tsc -p tsconfig.json --noEmit", - "preview": "vite preview" + "preview": "vite preview", + "preview:web": "STUDIO_TARGET=web vite preview" }, "dependencies": { "@base-ui/react": "^1.3.0", diff --git a/apps/ui/src/app/use-ui-mode.ts b/apps/ui/src/app/use-ui-mode.ts index fd234760ca..501f117e0b 100644 --- a/apps/ui/src/app/use-ui-mode.ts +++ b/apps/ui/src/app/use-ui-mode.ts @@ -3,6 +3,7 @@ import { useCallback, useState } from 'react'; export type UiMode = 'classic' | 'desks'; const STUDIO_UI_MODE_PARAM = 'studio-ui-mode'; +const STUDIO_UI_MODE_STORAGE_KEY = 'studio-ui-mode'; const DEFAULT_UI_MODE: UiMode = 'desks'; function readLaunchUiMode(): UiMode | undefined { @@ -23,8 +24,28 @@ function readLaunchUiMode(): UiMode | undefined { } } +// Persisted so a real-path web build keeps its mode across reloads/deep links +// (the desktop renderer stays on a single hash route, so this just mirrors the +// in-memory default there). +function readStoredUiMode(): UiMode | undefined { + try { + const stored = window.localStorage?.getItem( STUDIO_UI_MODE_STORAGE_KEY ); + return stored === 'desks' || stored === 'classic' ? stored : undefined; + } catch { + return undefined; + } +} + +function storeUiMode( mode: UiMode ) { + try { + window.localStorage?.setItem( STUDIO_UI_MODE_STORAGE_KEY, mode ); + } catch { + // Ignore storage failures (private mode, etc.). + } +} + function readInitialUiMode(): UiMode { - return readLaunchUiMode() ?? DEFAULT_UI_MODE; + return readLaunchUiMode() ?? readStoredUiMode() ?? DEFAULT_UI_MODE; } function resetRoute() { @@ -54,6 +75,7 @@ export function useUiMode() { } setModeState( nextMode ); + storeUiMode( nextMode ); resetRoute(); }, [ mode ] diff --git a/apps/ui/src/data/core/connectors/web/index.ts b/apps/ui/src/data/core/connectors/web/index.ts new file mode 100644 index 0000000000..0419b16c99 --- /dev/null +++ b/apps/ui/src/data/core/connectors/web/index.ts @@ -0,0 +1,409 @@ +import { createDefaultDeskSettings } from '@studio/common/lib/desk-settings'; +import type { + ActiveAgentRun, + AiSessionPlacementUpdatedEvent, + AiSessionSummary, + AuthUser, + Connector, + DeskConfig, + DeskSettings, + FeaturedBlueprint, + InstalledApps, + LoadedAiSession, + SiteDetails, + Snapshot, + SyncSite, + UserPreferences, +} from '../../types'; +import type { AgentRunEvent } from '@studio/common/ai/agent-events'; + +export interface WebConnectorOptions { + // Base URL of the `studio web-server` backend, e.g. http://localhost:8088. + apiBaseUrl: string; +} + +/** + * Thrown by connector methods that have no meaning in a browser (native file + * dialogs, opening an editor/terminal, etc.). Callers in the UI already wrap + * these affordances in try/catch, so throwing keeps the surface honest without + * breaking the app. + */ +export class WebUnsupportedError extends Error { + constructor( operation: string ) { + super( `"${ operation }" is not available in Studio Web.` ); + this.name = 'WebUnsupportedError'; + } +} + +// Envelope used by the backend's `/events` SSE stream so a single connection +// can carry both agent-run events and session-placement updates. +type ServerEvent = + | { channel: 'agent'; payload: AgentRunEvent } + | { channel: 'placement'; payload: AiSessionPlacementUpdatedEvent }; + +/** + * Connector that talks to the headless `studio web-server` over HTTP + SSE. + * It is the web analog of the Electron IPC connector: the same React app, the + * same `AgentRunEvent` stream, a different transport. + * + * Only the surface the PoC exercises is implemented for real (AI sessions and + * runs, the site list, featured blueprints, external links). Desktop-only + * capabilities either return benign defaults (so mount-time queries don't + * throw) or throw `WebUnsupportedError` for user-triggered actions. + */ +export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Connector { + const base = apiBaseUrl.replace( /\/$/, '' ); + + const agentListeners = new Set< ( event: AgentRunEvent ) => void >(); + const placementListeners = new Set< ( event: AiSessionPlacementUpdatedEvent ) => void >(); + let eventSource: EventSource | undefined; + + async function api< T >( path: string, init?: RequestInit ): Promise< T > { + const response = await fetch( `${ base }${ path }`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...init?.headers, + }, + } ); + if ( ! response.ok ) { + const text = await response.text().catch( () => '' ); + throw new Error( + `${ init?.method ?? 'GET' } ${ path } failed (${ response.status }): ${ text }` + ); + } + if ( response.status === 204 ) { + return undefined as T; + } + return ( await response.json() ) as T; + } + + function findSiteUrl( sites: SiteDetails[], siteId: string ): string { + const site = sites.find( ( candidate ) => candidate.id === siteId ); + if ( ! site?.url ) { + throw new Error( `Site ${ siteId } has no URL` ); + } + return site.url; + } + + return { + async init() { + // One SSE connection carries both agent and placement events; the + // browser's EventSource reconnects automatically. + eventSource = new EventSource( `${ base }/events` ); + eventSource.onmessage = ( message ) => { + let parsed: ServerEvent; + try { + parsed = JSON.parse( message.data ) as ServerEvent; + } catch { + return; + } + if ( parsed.channel === 'agent' ) { + agentListeners.forEach( ( listener ) => listener( parsed.payload ) ); + } else if ( parsed.channel === 'placement' ) { + placementListeners.forEach( ( listener ) => listener( parsed.payload ) ); + } + }; + }, + + // Auth — the PoC runs unauthenticated, like the desktop app. + requiresAuth: false, + async isAuthenticated() { + return true; + }, + async getAuthUser(): Promise< AuthUser | null > { + return null; + }, + async authenticate() { + // No-op: the PoC has no login gate. + }, + async logout() { + // No-op. + }, + onAuthStateChanged() { + return () => {}; + }, + + // Sites + async getSites(): Promise< SiteDetails[] > { + return api< SiteDetails[] >( '/sites' ); + }, + async createSite() { + throw new WebUnsupportedError( 'createSite' ); + }, + async deleteSite() { + throw new WebUnsupportedError( 'deleteSite' ); + }, + async copySite(): Promise< SiteDetails > { + throw new WebUnsupportedError( 'copySite' ); + }, + async startSite() { + throw new WebUnsupportedError( 'startSite' ); + }, + async stopSite() { + throw new WebUnsupportedError( 'stopSite' ); + }, + async updateSite() { + throw new WebUnsupportedError( 'updateSite' ); + }, + async refreshSiteIcon() { + // No-op: icons come back with getSites(). + }, + async getXdebugEnabledSite(): Promise< SiteDetails | null > { + return null; + }, + async exportFullSite(): Promise< string | null > { + throw new WebUnsupportedError( 'exportFullSite' ); + }, + async exportDatabase(): Promise< string | null > { + throw new WebUnsupportedError( 'exportDatabase' ); + }, + async generateProposedSiteName(): Promise< string > { + throw new WebUnsupportedError( 'generateProposedSiteName' ); + }, + async generateProposedSitePath() { + throw new WebUnsupportedError( 'generateProposedSitePath' ); + }, + async selectSiteFolder() { + throw new WebUnsupportedError( 'selectSiteFolder' ); + }, + async comparePaths() { + throw new WebUnsupportedError( 'comparePaths' ); + }, + async getAllCustomDomains(): Promise< string[] > { + return []; + }, + + // Featured blueprints — public endpoint, identical to the IPC connector. + async getFeaturedBlueprints( locale ) { + const url = new URL( 'https://public-api.wordpress.com/wpcom/v2/studio-app/blueprints' ); + if ( locale ) { + url.searchParams.set( 'locale', locale ); + } + const response = await fetch( url.toString() ); + if ( ! response.ok ) { + throw new Error( `Failed to fetch blueprints: ${ response.status }` ); + } + const body = ( await response.json() ) as { + blueprints?: Array< { + slug?: string; + title?: string; + excerpt?: string; + image?: string; + playground_url?: string; + blueprint?: unknown; + } >; + }; + const list: FeaturedBlueprint[] = []; + for ( const item of body.blueprints ?? [] ) { + if ( + typeof item.slug !== 'string' || + typeof item.title !== 'string' || + typeof item.excerpt !== 'string' || + typeof item.image !== 'string' || + typeof item.playground_url !== 'string' || + ! item.blueprint || + typeof item.blueprint !== 'object' + ) { + continue; + } + list.push( { + slug: item.slug, + title: item.title, + excerpt: item.excerpt, + image: item.image, + playgroundUrl: item.playground_url, + blueprint: item.blueprint as FeaturedBlueprint[ 'blueprint' ], + } ); + } + return list; + }, + + async getFilePath() { + // Browsers can't resolve a real filesystem path for a File. + return ''; + }, + async readLocalMediaFile() { + throw new WebUnsupportedError( 'readLocalMediaFile' ); + }, + async extractBlueprintBundle() { + throw new WebUnsupportedError( 'extractBlueprintBundle' ); + }, + async cleanupBlueprintTempDir() { + // No-op. + }, + async importSiteFromBackup(): Promise< SiteDetails > { + throw new WebUnsupportedError( 'importSiteFromBackup' ); + }, + + // Preview snapshots / sync — out of PoC scope. + async getSnapshots(): Promise< Snapshot[] > { + return []; + }, + async publishPreviewSite(): Promise< { url: string } > { + throw new WebUnsupportedError( 'publishPreviewSite' ); + }, + async getConnectedWpcomSites(): Promise< SyncSite[] > { + return []; + }, + async fetchSyncableWpcomSites(): Promise< SyncSite[] > { + return []; + }, + async connectWpcomSite() { + throw new WebUnsupportedError( 'connectWpcomSite' ); + }, + async disconnectWpcomSite() { + throw new WebUnsupportedError( 'disconnectWpcomSite' ); + }, + onSyncConnectSite() { + return () => {}; + }, + async pushSiteToLive() { + throw new WebUnsupportedError( 'pushSiteToLive' ); + }, + async pullSiteFromLive() { + throw new WebUnsupportedError( 'pullSiteFromLive' ); + }, + getPublishCheckoutUrl() { + return undefined; + }, + + // AI sessions — the headline. HTTP routes on the web-server, backed by + // the shared session store and the same CLI agent the desktop forks. + async getSessions(): Promise< AiSessionSummary[] > { + return api< AiSessionSummary[] >( '/sessions' ); + }, + async getSession( sessionId ): Promise< LoadedAiSession > { + return api< LoadedAiSession >( `/sessions/${ encodeURIComponent( sessionId ) }` ); + }, + async deleteSession( sessionId ) { + await api( `/sessions/${ encodeURIComponent( sessionId ) }`, { method: 'DELETE' } ); + }, + async updateSessionMetadata( sessionId, patch ): Promise< AiSessionSummary > { + return api< AiSessionSummary >( `/sessions/${ encodeURIComponent( sessionId ) }`, { + method: 'PATCH', + body: JSON.stringify( patch ), + } ); + }, + async createSession( siteId ): Promise< AiSessionSummary > { + return api< AiSessionSummary >( '/sessions', { + method: 'POST', + body: JSON.stringify( { siteId } ), + } ); + }, + async continueSession( sessionId, prompt, options ): Promise< { runId: string } > { + return api< { runId: string } >( `/sessions/${ encodeURIComponent( sessionId ) }/messages`, { + method: 'POST', + body: JSON.stringify( { prompt, displayMessage: options?.displayMessage } ), + } ); + }, + async getActiveAgentRuns(): Promise< ActiveAgentRun[] > { + return api< ActiveAgentRun[] >( '/runs/active' ); + }, + async setSessionModel( sessionId, model ) { + await api( `/sessions/${ encodeURIComponent( sessionId ) }/model`, { + method: 'POST', + body: JSON.stringify( { model } ), + } ); + }, + async interruptAgentRun( runId ) { + await api( `/runs/${ encodeURIComponent( runId ) }/interrupt`, { method: 'POST' } ); + }, + async answerAgentQuestion( runId, answers ) { + await api( `/runs/${ encodeURIComponent( runId ) }/answer`, { + method: 'POST', + body: JSON.stringify( { answers } ), + } ); + }, + async setSessionEnvironment( _sessionId, environment ) { + // The PoC's agent always acts on the backend's local runtime. + return { environment }; + }, + onAgentEvent( listener ) { + agentListeners.add( listener ); + return () => agentListeners.delete( listener ); + }, + onSessionPlacementUpdated( listener ) { + placementListeners.add( listener ); + return () => placementListeners.delete( listener ); + }, + + // User preferences — sensible browser defaults. + async getUserPreferences(): Promise< UserPreferences > { + return { + editor: null, + terminal: null, + colorScheme: 'system', + locale: undefined, + }; + }, + async setUserPreferences() { + // No-op for the PoC. + }, + async getInstalledApps(): Promise< InstalledApps > { + return {} as InstalledApps; + }, + + // Desks — defaults so both UI modes mount cleanly. + async getDeskSettings(): Promise< DeskSettings > { + return createDefaultDeskSettings(); + }, + async saveDeskSettings() { + // No-op for the PoC. + }, + async exportDeskConfig(): Promise< string | null > { + return null; + }, + async importDeskConfig(): Promise< DeskConfig | null > { + return null; + }, + async getUserDeskConfig(): Promise< DeskConfig | undefined > { + return undefined; + }, + async saveUserDeskConfig() { + // No-op for the PoC. + }, + async getSiteDeskConfig(): Promise< DeskConfig | undefined > { + return undefined; + }, + async saveSiteDeskConfig() { + // No-op for the PoC. + }, + + async fetchSiteRest() { + throw new WebUnsupportedError( 'fetchSiteRest' ); + }, + + // Filesystem / native integrations — not available in a browser. + async openSiteFolder() { + throw new WebUnsupportedError( 'openSiteFolder' ); + }, + async openSiteInEditor() { + throw new WebUnsupportedError( 'openSiteInEditor' ); + }, + async openSiteInTerminal() { + throw new WebUnsupportedError( 'openSiteInTerminal' ); + }, + + // External links work natively in the browser. + async openExternalUrl( url ) { + window.open( url, '_blank', 'noopener,noreferrer' ); + }, + async openSiteUrl( siteId, relativeUrl = '' ) { + const sites = await api< SiteDetails[] >( '/sites' ); + const target = new URL( relativeUrl || '/', findSiteUrl( sites, siteId ) ).toString(); + window.open( target, '_blank', 'noopener,noreferrer' ); + }, + + // Window chrome — no traffic lights in a browser tab. + async isFullscreen() { + return false; + }, + onFullscreenChange() { + return () => {}; + }, + onSiteEvent() { + return () => {}; + }, + }; +} diff --git a/apps/ui/src/lib/get-site-url.ts b/apps/ui/src/lib/get-site-url.ts index 80fd55cf55..86af68367f 100644 --- a/apps/ui/src/lib/get-site-url.ts +++ b/apps/ui/src/lib/get-site-url.ts @@ -8,6 +8,12 @@ export function getSiteUrl( site: SiteDetails ): string { const protocol = site.enableHttps ? 'https' : 'http'; return `${ protocol }://${ site.customDomain }`; } + // Hosted sites (e.g. Studio Web) carry an absolute remote URL and no local + // port. Prefer it; on desktop running sites this is the same localhost URL + // the fallback would build, so behavior there is unchanged. + if ( site.url ) { + return site.url; + } return `http://localhost:${ site.port }`; } diff --git a/apps/ui/src/main.web.tsx b/apps/ui/src/main.web.tsx new file mode 100644 index 0000000000..712a1c2f66 --- /dev/null +++ b/apps/ui/src/main.web.tsx @@ -0,0 +1,55 @@ +import { getLocaleData, isSupportedLocale } from '@studio/common/lib/locale'; +import { defaultI18n } from '@wordpress/i18n'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from '@/app'; +import { persistPromise } from '@/data/core'; +import { createWebConnector } from '@/data/core/connectors/web'; +import type { Connector } from '@/data/core'; + +// Web entry point. Identical to `main.tsx` except it wires the HTTP/SSE web +// connector instead of the Electron IPC connector, so the same React app runs +// in a plain browser tab against the `studio web-server` backend. + +async function loadTranslations( connector: Connector ) { + const { locale } = await connector.getUserPreferences(); + if ( ! locale || ! isSupportedLocale( locale ) ) { + return; + } + const translations = getLocaleData( locale )?.messages; + if ( translations ) { + defaultI18n.setLocaleData( translations ); + } +} + +// Studio Web defaults to the classic (agentic) UI — it uses real-path routing +// that survives reloads/deep links. Seed the persisted mode only when the user +// hasn't already chosen one. +function seedDefaultUiMode() { + try { + const hasParam = new URLSearchParams( window.location.search ).has( 'studio-ui-mode' ); + if ( ! hasParam && ! window.localStorage.getItem( 'studio-ui-mode' ) ) { + window.localStorage.setItem( 'studio-ui-mode', 'classic' ); + } + } catch { + // Ignore storage failures. + } +} + +async function bootstrap() { + seedDefaultUiMode(); + + const connector = createWebConnector( { + apiBaseUrl: import.meta.env.VITE_STUDIO_API_URL ?? 'http://localhost:8088', + } ); + + await Promise.all( [ connector.init?.(), loadTranslations( connector ), persistPromise ] ); + + createRoot( document.getElementById( 'root' )! ).render( + + + + ); +} + +void bootstrap(); diff --git a/apps/ui/src/vite-env.d.ts b/apps/ui/src/vite-env.d.ts index 11f02fe2a0..1abcbf9cae 100644 --- a/apps/ui/src/vite-env.d.ts +++ b/apps/ui/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + // Base URL of the `studio web-server` backend the web connector talks to. + readonly VITE_STUDIO_API_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/ui/vite.config.ts b/apps/ui/vite.config.ts index 2a622c404d..c385e53229 100644 --- a/apps/ui/vite.config.ts +++ b/apps/ui/vite.config.ts @@ -18,8 +18,48 @@ const directDeps = Object.entries( pkg.dependencies ?? {} ) .filter( ( [ , version ] ) => ! version.startsWith( 'file:' ) ) .map( ( [ name ] ) => name ); +// Web target (`STUDIO_TARGET=web`) builds a standalone browser app wired to the +// HTTP/SSE web connector. It uses a separate entry/output/port so the default +// Electron-renderer build (`dist/`, port 5200) stays byte-for-byte unchanged. +const isWeb = process.env.STUDIO_TARGET === 'web'; + +// In dev, Vite serves the root `index.html` (which loads the Electron entry, +// `main.tsx`) for every SPA navigation, regardless of `build` input options. +// Serve `index.web.html` instead for any document navigation (`/`, `/sites`, +// `/sessions/:id`, …) so the web entry + web connector load and client-side +// routing/refresh works. Module and asset requests pass through untouched. +const webDevEntryPlugin = { + name: 'studio-web-dev-entry', + apply: 'serve' as const, + configureServer( server: { + middlewares: { + use: ( + fn: ( + req: { url?: string; headers?: Record< string, unknown > }, + res: unknown, + next: () => void + ) => void + ) => void; + }; + } ) { + server.middlewares.use( ( req, _res, next ) => { + const accept = String( req.headers?.accept ?? '' ); + const [ pathname ] = ( req.url ?? '' ).split( '?' ); + const isInternal = + pathname.startsWith( '/@' ) || + pathname.startsWith( '/src/' ) || + pathname.startsWith( '/node_modules/' ) || + pathname.includes( '.' ); + if ( accept.includes( 'text/html' ) && ! isInternal ) { + req.url = '/index.web.html'; + } + next(); + } ); + }, +}; + export default defineConfig( { - plugins: [ react(), dsTokenFallbacks() ], + plugins: [ react(), dsTokenFallbacks(), ...( isWeb ? [ webDevEntryPlugin ] : [] ) ], css: { postcss: { plugins: [ dsTokenFallbacksPostcss ], @@ -44,12 +84,12 @@ export default defineConfig( { include: directDeps, }, server: { - port: 5200, + port: isWeb ? 5300 : 5200, }, build: { - outDir: 'dist', + outDir: isWeb ? 'dist-web' : 'dist', rolldownOptions: { - input: resolve( __dirname, 'index.html' ), + input: resolve( __dirname, isWeb ? 'index.web.html' : 'index.html' ), onwarn( warning, defaultHandler ) { // These dynamic imports break a circular dependency in ui-desks // (definition.ts → widget-context/editor-commands → registry → definition.ts) diff --git a/package-lock.json b/package-lock.json index 26d460e8eb..2db396b21d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "atomically": "^2.1.1", "chokidar": "^5.0.0", "cli-table3": "^0.6.5", + "express": "^4.22.0", "fs-extra": "^11.3.4", "http-proxy": "^1.18.1", "ignore": "^7.0.5", @@ -96,6 +97,7 @@ "devDependencies": { "@studio/common": "file:../../tools/common", "@types/archiver": "^7.0.0", + "@types/express": "^4.17.23", "@types/http-proxy": "^1.17.17", "@types/node-forge": "^1.3.14", "@types/yargs": "^17.0.35", @@ -4577,7 +4579,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -4693,6 +4695,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4709,6 +4714,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4725,6 +4733,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4741,6 +4752,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4757,6 +4771,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -15278,6 +15295,17 @@ "integrity": "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==", "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/btoa-lite": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.2.tgz", @@ -15350,6 +15378,32 @@ "@types/estree": "*" } }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/follow-redirects": { "version": "1.14.4", "dev": true, @@ -15389,6 +15443,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-proxy": { "version": "1.17.17", "dev": true, @@ -15488,6 +15549,13 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mousetrap": { "version": "1.6.15", "license": "MIT" @@ -15556,6 +15624,20 @@ "@types/pg": "*" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", @@ -15601,6 +15683,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/shell-quote": { "version": "1.7.5", "dev": true, From 353c1b8ef298150c1c67d2681baf9e0ecae27930 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 12:35:40 -0300 Subject: [PATCH 2/8] Simplify Studio Web foundation: reuse shared blueprints helper, single owner for UI-mode storage, cache site list in web connector Co-Authored-By: Claude Fable 5 --- apps/cli/web-server/index.ts | 18 +++--- apps/ui/src/app/use-ui-mode.ts | 16 ++++- apps/ui/src/data/core/connectors/web/index.ts | 63 ++++++------------- apps/ui/src/main.web.tsx | 19 ++---- apps/ui/vite.config.ts | 20 ++---- 5 files changed, 47 insertions(+), 89 deletions(-) diff --git a/apps/cli/web-server/index.ts b/apps/cli/web-server/index.ts index a108ca18c4..fbad632fba 100644 --- a/apps/cli/web-server/index.ts +++ b/apps/cli/web-server/index.ts @@ -27,10 +27,6 @@ function getPort(): number { return parseInt( process.env.STUDIO_WEB_SERVER_PORT ?? String( DEFAULT_PORT ), 10 ); } -function param( req: Request, name: string ): string { - return req.params[ name ] ?? ''; -} - const root = getAiSessionsRootDirectory(); const app = express(); @@ -179,18 +175,18 @@ app.post( '/sessions', async ( _req: Request, res: Response ) => { } ); app.get( '/sessions/:id', async ( req: Request, res: Response ) => { - res.json( await loadAiSession( root, param( req, 'id' ) ) ); + res.json( await loadAiSession( root, req.params.id ) ); } ); app.delete( '/sessions/:id', async ( req: Request, res: Response ) => { - await deleteAiSession( root, param( req, 'id' ) ); + await deleteAiSession( root, req.params.id ); res.sendStatus( 204 ); } ); app.patch( '/sessions/:id', async ( req: Request, res: Response ) => { // Star/archive aren't persisted in the PoC (no shared store helper); echo // the requested state on top of the current summary so the UI stays in sync. - const { summary } = await loadAiSession( root, param( req, 'id' ) ); + const { summary } = await loadAiSession( root, req.params.id ); const patch = req.body as { starred?: boolean; archived?: boolean }; res.json( { ...summary, ...patch } ); } ); @@ -201,7 +197,7 @@ app.post( '/sessions/:id/model', async ( req: Request, res: Response ) => { res.status( 400 ).json( { error: `Unknown AI model: ${ model }` } ); return; } - await appendModelChangeEntry( root, param( req, 'id' ), '', model ); + await appendModelChangeEntry( root, req.params.id, '', model ); res.sendStatus( 204 ); } ); @@ -211,7 +207,7 @@ app.post( '/sessions/:id/messages', ( req: Request, res: Response ) => { res.status( 400 ).json( { error: 'prompt is required' } ); return; } - const { runId } = startAgentRun( { sessionId: param( req, 'id' ), prompt, displayMessage } ); + const { runId } = startAgentRun( { sessionId: req.params.id, prompt, displayMessage } ); res.json( { runId } ); } ); @@ -222,13 +218,13 @@ app.get( '/runs/active', ( _req: Request, res: Response ) => { } ); app.post( '/runs/:runId/interrupt', ( req: Request, res: Response ) => { - interruptAgentRun( param( req, 'runId' ) ); + interruptAgentRun( req.params.runId ); res.sendStatus( 204 ); } ); app.post( '/runs/:runId/answer', ( req: Request, res: Response ) => { const { answers } = req.body as { answers?: Record< string, string > }; - answerAgentRun( param( req, 'runId' ), answers ?? {} ); + answerAgentRun( req.params.runId, answers ?? {} ); res.sendStatus( 204 ); } ); diff --git a/apps/ui/src/app/use-ui-mode.ts b/apps/ui/src/app/use-ui-mode.ts index 501f117e0b..c52e27a5e3 100644 --- a/apps/ui/src/app/use-ui-mode.ts +++ b/apps/ui/src/app/use-ui-mode.ts @@ -24,9 +24,10 @@ function readLaunchUiMode(): UiMode | undefined { } } -// Persisted so a real-path web build keeps its mode across reloads/deep links -// (the desktop renderer stays on a single hash route, so this just mirrors the -// in-memory default there). +// Persisted so a real-path web build keeps its mode across reloads/deep links. +// On desktop the launch query param (derived from feature flags, see +// apps/studio/src/main-window.ts) is always present and takes precedence, so +// the stored value only ever decides the mode in the web build. function readStoredUiMode(): UiMode | undefined { try { const stored = window.localStorage?.getItem( STUDIO_UI_MODE_STORAGE_KEY ); @@ -48,6 +49,15 @@ function readInitialUiMode(): UiMode { return readLaunchUiMode() ?? readStoredUiMode() ?? DEFAULT_UI_MODE; } +// Entries whose default differs from DEFAULT_UI_MODE (the web build defaults +// to classic) call this at bootstrap. It only seeds when the user hasn't +// already chosen a mode via query param or an earlier visit. +export function seedDefaultUiMode( defaultMode: UiMode ) { + if ( readLaunchUiMode() === undefined && readStoredUiMode() === undefined ) { + storeUiMode( defaultMode ); + } +} + function resetRoute() { if ( typeof window === 'undefined' ) { return; diff --git a/apps/ui/src/data/core/connectors/web/index.ts b/apps/ui/src/data/core/connectors/web/index.ts index 0419b16c99..08a3e905a4 100644 --- a/apps/ui/src/data/core/connectors/web/index.ts +++ b/apps/ui/src/data/core/connectors/web/index.ts @@ -1,4 +1,5 @@ import { createDefaultDeskSettings } from '@studio/common/lib/desk-settings'; +import { fetchStudioBlueprints } from '@studio/common/lib/studio-blueprints-api'; import type { ActiveAgentRun, AiSessionPlacementUpdatedEvent, @@ -57,6 +58,9 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne const agentListeners = new Set< ( event: AgentRunEvent ) => void >(); const placementListeners = new Set< ( event: AiSessionPlacementUpdatedEvent ) => 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. + let lastSites: SiteDetails[] | undefined; async function api< T >( path: string, init?: RequestInit ): Promise< T > { const response = await fetch( `${ base }${ path }`, { @@ -126,7 +130,8 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne // Sites async getSites(): Promise< SiteDetails[] > { - return api< SiteDetails[] >( '/sites' ); + lastSites = await api< SiteDetails[] >( '/sites' ); + return lastSites; }, async createSite() { throw new WebUnsupportedError( 'createSite' ); @@ -174,49 +179,17 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne return []; }, - // Featured blueprints — public endpoint, identical to the IPC connector. - async getFeaturedBlueprints( locale ) { - const url = new URL( 'https://public-api.wordpress.com/wpcom/v2/studio-app/blueprints' ); - if ( locale ) { - url.searchParams.set( 'locale', locale ); - } - const response = await fetch( url.toString() ); - if ( ! response.ok ) { - throw new Error( `Failed to fetch blueprints: ${ response.status }` ); - } - const body = ( await response.json() ) as { - blueprints?: Array< { - slug?: string; - title?: string; - excerpt?: string; - image?: string; - playground_url?: string; - blueprint?: unknown; - } >; - }; - const list: FeaturedBlueprint[] = []; - for ( const item of body.blueprints ?? [] ) { - if ( - typeof item.slug !== 'string' || - typeof item.title !== 'string' || - typeof item.excerpt !== 'string' || - typeof item.image !== 'string' || - typeof item.playground_url !== 'string' || - ! item.blueprint || - typeof item.blueprint !== 'object' - ) { - continue; - } - list.push( { - slug: item.slug, - title: item.title, - excerpt: item.excerpt, - image: item.image, - playgroundUrl: item.playground_url, - blueprint: item.blueprint as FeaturedBlueprint[ 'blueprint' ], - } ); - } - return list; + // Featured blueprints — public endpoint, same source as the desktop app. + async getFeaturedBlueprints( locale ): Promise< FeaturedBlueprint[] > { + const blueprints = await fetchStudioBlueprints( locale ); + return blueprints.map( ( blueprint ) => ( { + slug: blueprint.slug, + title: blueprint.title, + excerpt: blueprint.excerpt, + image: blueprint.image, + playgroundUrl: blueprint.playground_url, + blueprint: blueprint.blueprint as FeaturedBlueprint[ 'blueprint' ], + } ) ); }, async getFilePath() { @@ -390,7 +363,7 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne window.open( url, '_blank', 'noopener,noreferrer' ); }, async openSiteUrl( siteId, relativeUrl = '' ) { - const sites = await api< SiteDetails[] >( '/sites' ); + const sites = lastSites ?? ( await api< SiteDetails[] >( '/sites' ) ); const target = new URL( relativeUrl || '/', findSiteUrl( sites, siteId ) ).toString(); window.open( target, '_blank', 'noopener,noreferrer' ); }, diff --git a/apps/ui/src/main.web.tsx b/apps/ui/src/main.web.tsx index 712a1c2f66..7224eaa1a4 100644 --- a/apps/ui/src/main.web.tsx +++ b/apps/ui/src/main.web.tsx @@ -3,6 +3,7 @@ import { defaultI18n } from '@wordpress/i18n'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from '@/app'; +import { seedDefaultUiMode } from '@/app/use-ui-mode'; import { persistPromise } from '@/data/core'; import { createWebConnector } from '@/data/core/connectors/web'; import type { Connector } from '@/data/core'; @@ -22,22 +23,10 @@ async function loadTranslations( connector: Connector ) { } } -// Studio Web defaults to the classic (agentic) UI — it uses real-path routing -// that survives reloads/deep links. Seed the persisted mode only when the user -// hasn't already chosen one. -function seedDefaultUiMode() { - try { - const hasParam = new URLSearchParams( window.location.search ).has( 'studio-ui-mode' ); - if ( ! hasParam && ! window.localStorage.getItem( 'studio-ui-mode' ) ) { - window.localStorage.setItem( 'studio-ui-mode', 'classic' ); - } - } catch { - // Ignore storage failures. - } -} - async function bootstrap() { - seedDefaultUiMode(); + // Studio Web defaults to the classic (agentic) UI — it uses real-path + // routing that survives reloads/deep links. + seedDefaultUiMode( 'classic' ); const connector = createWebConnector( { apiBaseUrl: import.meta.env.VITE_STUDIO_API_URL ?? 'http://localhost:8088', diff --git a/apps/ui/vite.config.ts b/apps/ui/vite.config.ts index c385e53229..e029d6dcca 100644 --- a/apps/ui/vite.config.ts +++ b/apps/ui/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; import dsTokenFallbacks from '@wordpress/theme/vite-plugins/vite-ds-token-fallbacks'; import dsTokenFallbacksPostcss from '@wordpress/theme/postcss-plugins/postcss-ds-token-fallbacks'; @@ -28,22 +28,12 @@ const isWeb = process.env.STUDIO_TARGET === 'web'; // Serve `index.web.html` instead for any document navigation (`/`, `/sites`, // `/sessions/:id`, …) so the web entry + web connector load and client-side // routing/refresh works. Module and asset requests pass through untouched. -const webDevEntryPlugin = { +const webDevEntryPlugin: Plugin = { name: 'studio-web-dev-entry', - apply: 'serve' as const, - configureServer( server: { - middlewares: { - use: ( - fn: ( - req: { url?: string; headers?: Record< string, unknown > }, - res: unknown, - next: () => void - ) => void - ) => void; - }; - } ) { + apply: 'serve', + configureServer( server ) { server.middlewares.use( ( req, _res, next ) => { - const accept = String( req.headers?.accept ?? '' ); + const accept = req.headers.accept ?? ''; const [ pathname ] = ( req.url ?? '' ).split( '?' ); const isInternal = pathname.startsWith( '/@' ) || From dd549389a6df6dc64ce8d2ed2360a3d45f68df15 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 15:41:01 -0300 Subject: [PATCH 3/8] Adapt web connector to new Connector.onToggleSitePreview after trunk merge Co-Authored-By: Claude Fable 5 --- apps/ui/src/data/core/connectors/web/index.ts | 4 ++++ package-lock.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/data/core/connectors/web/index.ts b/apps/ui/src/data/core/connectors/web/index.ts index 08a3e905a4..efd5313510 100644 --- a/apps/ui/src/data/core/connectors/web/index.ts +++ b/apps/ui/src/data/core/connectors/web/index.ts @@ -378,5 +378,9 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne onSiteEvent() { return () => {}; }, + onToggleSitePreview() { + // No application menu in a browser tab. + return () => {}; + }, }; } diff --git a/package-lock.json b/package-lock.json index 2db396b21d..8520564a86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4579,7 +4579,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" From ab184d36be4befb66daab3960fbd39555b6625fe Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 16:14:45 -0300 Subject: [PATCH 4/8] Simplify web-server: persist session star/archive via shared config, skip SSE work with no clients Co-Authored-By: Claude Fable 5 --- apps/cli/web-server/index.ts | 44 ++++++++++++++++--- apps/ui/src/data/core/connectors/web/index.ts | 1 + 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/cli/web-server/index.ts b/apps/cli/web-server/index.ts index fbad632fba..e9984d6c84 100644 --- a/apps/cli/web-server/index.ts +++ b/apps/cli/web-server/index.ts @@ -6,7 +6,11 @@ import { listAiSessions, loadAiSession, } from '@studio/common/ai/sessions/store'; -import { readAuthToken } from '@studio/common/lib/shared-config'; +import { + readAuthToken, + readSharedSessions, + updateSharedSession, +} from '@studio/common/lib/shared-config'; import { getSyncSupport } from '@studio/common/lib/sync/sync-support'; import express from 'express'; import { getAiSessionsRootDirectory } from 'cli/ai/sessions/paths'; @@ -18,6 +22,7 @@ import { startAgentRun, } from './agent-runs'; 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'; import type { Request, Response } from 'express'; @@ -27,6 +32,16 @@ function getPort(): number { return parseInt( process.env.STUDIO_WEB_SERVER_PORT ?? String( DEFAULT_PORT ), 10 ); } +// Star/archive live in the shared config (`~/.studio/shared.json`), not the +// session JSONL — the same store the desktop app reads, so flags set in either +// surface show up in both. +function hydrateAiSessionSummary( + summary: AiSessionSummary, + metadata?: Pick< AiSessionSummary, 'starred' | 'archived' > +): AiSessionSummary { + return { ...summary, starred: metadata?.starred, archived: metadata?.archived }; +} + const root = getAiSessionsRootDirectory(); const app = express(); @@ -65,6 +80,9 @@ app.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 ) => { + if ( sseClients.size === 0 ) { + return; + } const data = JSON.stringify( { channel: 'agent', payload: event } ); for ( const client of sseClients ) { client.write( `data: ${ data }\n\n` ); @@ -165,7 +183,13 @@ app.get( '/sites', async ( _req: Request, res: Response ) => { // --- AI sessions ------------------------------------------------------------- app.get( '/sessions', async ( _req: Request, res: Response ) => { - res.json( await listAiSessions( root ) ); + const [ sessions, sessionMetadata ] = await Promise.all( [ + listAiSessions( root ), + readSharedSessions(), + ] ); + res.json( + sessions.map( ( session ) => hydrateAiSessionSummary( session, sessionMetadata[ session.id ] ) ) + ); } ); app.post( '/sessions', async ( _req: Request, res: Response ) => { @@ -175,7 +199,14 @@ app.post( '/sessions', async ( _req: Request, res: Response ) => { } ); app.get( '/sessions/:id', async ( req: Request, res: Response ) => { - res.json( await loadAiSession( root, req.params.id ) ); + const [ loaded, sessionMetadata ] = await Promise.all( [ + loadAiSession( root, req.params.id ), + readSharedSessions(), + ] ); + res.json( { + ...loaded, + summary: hydrateAiSessionSummary( loaded.summary, sessionMetadata[ loaded.summary.id ] ), + } ); } ); app.delete( '/sessions/:id', async ( req: Request, res: Response ) => { @@ -184,11 +215,12 @@ app.delete( '/sessions/:id', async ( req: Request, res: Response ) => { } ); app.patch( '/sessions/:id', async ( req: Request, res: Response ) => { - // Star/archive aren't persisted in the PoC (no shared store helper); echo - // the requested state on top of the current summary so the UI stays in sync. const { summary } = await loadAiSession( root, req.params.id ); const patch = req.body as { starred?: boolean; archived?: boolean }; - res.json( { ...summary, ...patch } ); + // Same persistence the desktop app uses (updateAiSessionMetadata in + // ipc-handlers.ts): flags go to the shared config under its lock. + const metadata = await updateSharedSession( summary.id, patch ); + res.json( hydrateAiSessionSummary( summary, metadata ) ); } ); app.post( '/sessions/:id/model', async ( req: Request, res: Response ) => { diff --git a/apps/ui/src/data/core/connectors/web/index.ts b/apps/ui/src/data/core/connectors/web/index.ts index efd5313510..9bfd45b9c4 100644 --- a/apps/ui/src/data/core/connectors/web/index.ts +++ b/apps/ui/src/data/core/connectors/web/index.ts @@ -94,6 +94,7 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne async init() { // One SSE connection carries both agent and placement events; the // browser's EventSource reconnects automatically. + eventSource?.close(); eventSource = new EventSource( `${ base }/events` ); eventSource.onmessage = ( message ) => { let parsed: ServerEvent; From 73154b33506a46a0d80b160428b6f8372609dbdc Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 16:38:35 -0300 Subject: [PATCH 5/8] Rate-limit the web-server API (fixes CodeQL js/missing-rate-limiting) Co-Authored-By: Claude Fable 5 --- apps/cli/package.json | 1 + apps/cli/web-server/index.ts | 13 +++++++++++++ package-lock.json | 13 ++++++++----- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 159d80701e..7d010557af 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -47,6 +47,7 @@ "chokidar": "^5.0.0", "cli-table3": "^0.6.5", "express": "^4.22.0", + "express-rate-limit": "^8.5.2", "fs-extra": "^11.3.4", "http-proxy": "^1.18.1", "ignore": "^7.0.5", diff --git a/apps/cli/web-server/index.ts b/apps/cli/web-server/index.ts index e9984d6c84..ff55cef638 100644 --- a/apps/cli/web-server/index.ts +++ b/apps/cli/web-server/index.ts @@ -13,6 +13,7 @@ import { } from '@studio/common/lib/shared-config'; import { getSyncSupport } from '@studio/common/lib/sync/sync-support'; import express from 'express'; +import { rateLimit } from 'express-rate-limit'; import { getAiSessionsRootDirectory } from 'cli/ai/sessions/paths'; import { answerAgentRun, @@ -59,6 +60,18 @@ app.use( ( req: Request, res: Response, next ) => { next(); } ); +// Generous ceiling a single local user never hits in practice — the server is +// loopback-only, but a runaway client (or anything that does slip through) +// shouldn't be able to hammer the session store or the WordPress.com API. +app.use( + rateLimit( { + windowMs: 60_000, + limit: 1_000, + standardHeaders: true, + legacyHeaders: false, + } ) +); + app.use( express.json() ); // --- Server-Sent Events: one stream carries every run's AgentRunEvents ------- diff --git a/package-lock.json b/package-lock.json index 8520564a86..43ec4b1240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "chokidar": "^5.0.0", "cli-table3": "^0.6.5", "express": "^4.22.0", + "express-rate-limit": "^8.5.2", "fs-extra": "^11.3.4", "http-proxy": "^1.18.1", "ignore": "^7.0.5", @@ -22882,12 +22883,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -25183,7 +25184,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" From 9e319bc54b7a140e86f37b7cbb0878991bd694f2 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 16:51:46 -0300 Subject: [PATCH 6/8] Reword PoC-era comments: this is the first Studio Web increment, link the exploration PR Co-Authored-By: Claude Fable 5 --- apps/cli/web-server/index.ts | 6 +++--- apps/ui/src/data/core/connectors/web/index.ts | 21 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/cli/web-server/index.ts b/apps/cli/web-server/index.ts index ff55cef638..0a0e9320f4 100644 --- a/apps/cli/web-server/index.ts +++ b/apps/cli/web-server/index.ts @@ -46,7 +46,7 @@ function hydrateAiSessionSummary( const root = getAiSessionsRootDirectory(); const app = express(); -// Permissive CORS for the localhost PoC: the SPA dev server (5300) and this +// Permissive CORS for local development: the SPA dev server (5300) and this // backend live on different ports. EventSource (GET /events) needs no // preflight, but the JSON POST/PATCH/DELETE routes do. app.use( ( req: Request, res: Response, next ) => { @@ -206,8 +206,8 @@ app.get( '/sessions', async ( _req: Request, res: Response ) => { } ); app.post( '/sessions', async ( _req: Request, res: Response ) => { - // `siteId` is accepted but ignored for the PoC: sessions start unbound and - // the agent creates/binds a site during the run. + // `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 ) ); } ); diff --git a/apps/ui/src/data/core/connectors/web/index.ts b/apps/ui/src/data/core/connectors/web/index.ts index 9bfd45b9c4..c8103b6227 100644 --- a/apps/ui/src/data/core/connectors/web/index.ts +++ b/apps/ui/src/data/core/connectors/web/index.ts @@ -47,7 +47,9 @@ type ServerEvent = * It is the web analog of the Electron IPC connector: the same React app, the * same `AgentRunEvent` stream, a different transport. * - * Only the surface the PoC exercises is implemented for real (AI sessions and + * This is the first Studio Web increment, extracted from the broader + * exploration in https://github.com/Automattic/studio/pull/3746. Only the + * surface it exercises is implemented for real (AI sessions and * runs, the site list, featured blueprints, external links). Desktop-only * capabilities either return benign defaults (so mount-time queries don't * throw) or throw `WebUnsupportedError` for user-triggered actions. @@ -111,7 +113,8 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne }; }, - // Auth — the PoC runs unauthenticated, like the desktop app. + // Auth — runs unauthenticated, like the desktop app. WordPress.com login + // in the browser is a follow-up (explored in the PR linked above). requiresAuth: false, async isAuthenticated() { return true; @@ -120,7 +123,7 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne return null; }, async authenticate() { - // No-op: the PoC has no login gate. + // No-op: there is no login gate yet. }, async logout() { // No-op. @@ -210,7 +213,7 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne throw new WebUnsupportedError( 'importSiteFromBackup' ); }, - // Preview snapshots / sync — out of PoC scope. + // Preview snapshots / sync — out of scope for this increment. async getSnapshots(): Promise< Snapshot[] > { return []; }, @@ -290,7 +293,7 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne } ); }, async setSessionEnvironment( _sessionId, environment ) { - // The PoC's agent always acts on the backend's local runtime. + // The agent always acts on the backend's local runtime. return { environment }; }, onAgentEvent( listener ) { @@ -312,7 +315,7 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne }; }, async setUserPreferences() { - // No-op for the PoC. + // No-op: preferences aren't persisted in the browser yet. }, async getInstalledApps(): Promise< InstalledApps > { return {} as InstalledApps; @@ -323,7 +326,7 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne return createDefaultDeskSettings(); }, async saveDeskSettings() { - // No-op for the PoC. + // No-op: desk settings aren't persisted in the browser yet. }, async exportDeskConfig(): Promise< string | null > { return null; @@ -335,13 +338,13 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne return undefined; }, async saveUserDeskConfig() { - // No-op for the PoC. + // No-op: desk settings aren't persisted in the browser yet. }, async getSiteDeskConfig(): Promise< DeskConfig | undefined > { return undefined; }, async saveSiteDeskConfig() { - // No-op for the PoC. + // No-op: desk settings aren't persisted in the browser yet. }, async fetchSiteRest() { From 5f0ce029bf1f902ebdd50ad209aefb94a753738d Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 12 Jun 2026 18:18:46 -0300 Subject: [PATCH 7/8] Serve the built web UI from the web-server: one command, one origin, /api namespace Co-Authored-By: Claude Fable 5 --- apps/cli/web-server/README.md | 28 +- apps/cli/web-server/index.ts | 297 +++++++++++------- apps/ui/src/data/core/connectors/web/index.ts | 4 +- apps/ui/src/main.web.tsx | 9 +- 4 files changed, 213 insertions(+), 125 deletions(-) diff --git a/apps/cli/web-server/README.md b/apps/cli/web-server/README.md index 3cc540d07a..1b1cbbd8df 100644 --- a/apps/cli/web-server/README.md +++ b/apps/cli/web-server/README.md @@ -7,10 +7,16 @@ capabilities the desktop app reaches over IPC, but over HTTP, so the portable (`apps/ui/src/data/core/connectors/web`). ``` +npm run build:web --workspace=apps/ui # once, or after UI changes node apps/cli/dist/cli/main.mjs web-server # listens on 127.0.0.1:8088 (--port / STUDIO_WEB_SERVER_PORT) ``` -To run the UI against it: +The server serves the built UI itself — open http://localhost:8088 and that's +the whole setup. The API is namespaced under `/api` so the SPA's real-path +routes (`/sessions/:id`, `/sites/:id`) can share the origin. + +For UI development with hot reload, run the Vite dev server instead (it targets +the backend's default port cross-origin): ``` cd apps/ui && npm run dev:web # serves the browser entry on :5300 @@ -52,13 +58,13 @@ new always-on server. | Method | Path | Purpose | |--------|------|---------| -| `GET` | `/health` | liveness check | -| `GET` | `/events` | SSE stream carrying every run's `AgentRunEvent`s | -| `GET` | `/sites` | the user's workable WordPress.com sites (requires `studio auth login`) | -| `GET/POST` | `/sessions` | list / create AI sessions (shared session store) | -| `GET/PATCH/DELETE` | `/sessions/:id` | load / star-archive / delete a session | -| `POST` | `/sessions/:id/messages` | send a prompt — forks the agent, returns `{ runId }` | -| `POST` | `/sessions/:id/model` | persist a model override for the session | -| `GET` | `/runs/active` | active agent runs | -| `POST` | `/runs/:runId/interrupt` | graceful interrupt, SIGKILL on second attempt | -| `POST` | `/runs/:runId/answer` | answer an agent question | +| `GET` | `/api/health` | liveness check | +| `GET` | `/api/events` | SSE stream carrying every run's `AgentRunEvent`s | +| `GET` | `/api/sites` | the user's workable WordPress.com sites (requires `studio auth login`) | +| `GET/POST` | `/api/sessions` | list / create AI sessions (shared session store) | +| `GET/PATCH/DELETE` | `/api/sessions/:id` | load / star-archive / delete a session | +| `POST` | `/api/sessions/:id/messages` | send a prompt — forks the agent, returns `{ runId }` | +| `POST` | `/api/sessions/:id/model` | persist a model override for the session | +| `GET` | `/api/runs/active` | active agent runs | +| `POST` | `/api/runs/:runId/interrupt` | graceful interrupt, SIGKILL on second attempt | +| `POST` | `/api/runs/:runId/answer` | answer an agent question | diff --git a/apps/cli/web-server/index.ts b/apps/cli/web-server/index.ts index 0a0e9320f4..a9cf85e64f 100644 --- a/apps/cli/web-server/index.ts +++ b/apps/cli/web-server/index.ts @@ -1,3 +1,6 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { isAiModelId } from '@studio/common/ai/models'; import { appendModelChangeEntry, @@ -45,6 +48,18 @@ function hydrateAiSessionSummary( const root = getAiSessionsRootDirectory(); const app = express(); +// All API routes live under `/api` so they can't collide with the SPA's +// real-path routes (`/sessions/:id` is both an app URL and an API resource). +const api = express.Router(); + +// Express 4 doesn't forward async rejections to the error middleware — an +// unhandled rejection would take the whole process down — so async routes go +// through this wrapper. +function asyncHandler( fn: ( req: Request, res: Response ) => Promise< void > ) { + return ( req: Request, res: Response, next: ( e?: unknown ) => void ) => { + fn( req, res ).catch( next ); + }; +} // Permissive CORS for local development: the SPA dev server (5300) and this // backend live on different ports. EventSource (GET /events) needs no @@ -78,7 +93,7 @@ app.use( express.json() ); const sseClients = new Set< Response >(); -app.get( '/events', ( req: Request, res: Response ) => { +api.get( '/events', ( req: Request, res: Response ) => { res.setHeader( 'Content-Type', 'text/event-stream' ); res.setHeader( 'Cache-Control', 'no-cache' ); res.setHeader( 'Connection', 'keep-alive' ); @@ -104,7 +119,7 @@ setBroadcast( ( event: AgentRunEvent ) => { // --- Health ------------------------------------------------------------------ -app.get( '/health', ( _req: Request, res: Response ) => { +api.get( '/health', ( _req: Request, res: Response ) => { res.json( { status: 'ok' } ); } ); @@ -131,122 +146,145 @@ const WPCOM_SITE_FIELDS = [ 'options', ].join( ',' ); -app.get( '/sites', async ( _req: Request, res: Response ) => { - const token = await readAuthToken(); - if ( ! token ) { - // Not signed in to WordPress.com — no sites to show. - res.json( [] ); - return; - } +api.get( + '/sites', + asyncHandler( async ( _req: Request, res: Response ) => { + const token = await readAuthToken(); + if ( ! token ) { + // Not signed in to WordPress.com — no sites to show. + res.json( [] ); + return; + } - const url = new URL( 'https://public-api.wordpress.com/rest/v1.1/me/sites' ); - url.searchParams.set( 'fields', WPCOM_SITE_FIELDS ); - // `wpcom_staging_blog_ids` lets us point the browser at a site's staging - // environment instead of production (see below). - url.searchParams.set( 'options', 'wpcom_staging_blog_ids' ); - const response = await fetch( url, { - headers: { Authorization: `Bearer ${ token.accessToken }` }, - } ); - if ( ! response.ok ) { - res.status( response.status ).json( { - error: `WordPress.com sites fetch failed (${ response.status })`, + const url = new URL( 'https://public-api.wordpress.com/rest/v1.1/me/sites' ); + url.searchParams.set( 'fields', WPCOM_SITE_FIELDS ); + // `wpcom_staging_blog_ids` lets us point the browser at a site's staging + // environment instead of production (see below). + url.searchParams.set( 'options', 'wpcom_staging_blog_ids' ); + const response = await fetch( url, { + headers: { Authorization: `Bearer ${ token.accessToken }` }, } ); - return; - } + if ( ! response.ok ) { + res.status( response.status ).json( { + error: `WordPress.com sites fetch failed (${ response.status })`, + } ); + return; + } - const body = ( await response.json() ) as { sites?: SitesEndpointSite[] }; - const allSites = body.sites ?? []; - - // Look up any site's URL by blog id, and collect every staging blog id so - // staging environments aren't listed as their own cards (they're surfaced - // through their production parent below). - const urlByBlogId = new Map< number, string >(); - const stagingBlogIds = new Set< number >(); - for ( const site of allSites ) { - urlByBlogId.set( site.ID, site.URL ); - for ( const stagingId of site.options?.wpcom_staging_blog_ids ?? [] ) { - stagingBlogIds.add( stagingId ); + const body = ( await response.json() ) as { sites?: SitesEndpointSite[] }; + const allSites = body.sites ?? []; + + // Look up any site's URL by blog id, and collect every staging blog id so + // staging environments aren't listed as their own cards (they're surfaced + // through their production parent below). + const urlByBlogId = new Map< number, string >(); + const stagingBlogIds = new Set< number >(); + for ( const site of allSites ) { + urlByBlogId.set( site.ID, site.URL ); + for ( const stagingId of site.options?.wpcom_staging_blog_ids ?? [] ) { + stagingBlogIds.add( stagingId ); + } } - } - const sites = allSites - // `getSyncSupport` with no connected ids returns 'syncable' for sites the - // user administers on a supported host/plan — i.e. usable in Studio. - .filter( ( site ) => getSyncSupport( site, [] ) === 'syncable' ) - .filter( ( site ) => ! stagingBlogIds.has( site.ID ) ) - .map( ( site ) => { - // Studio Web edits sites on their staging environment, never - // production. When a site has a staging blog, surface its URL. - const stagingId = site.options?.wpcom_staging_blog_ids?.[ 0 ]; - const siteUrl = ( stagingId && urlByBlogId.get( stagingId ) ) || site.URL; - return { - id: String( site.ID ), - name: site.name || site.URL || String( site.ID ), - path: '', - port: 0, - running: true, - url: siteUrl, - phpVersion: '', - siteIcon: site.icon?.img ?? null, - }; - } ); - res.json( sites ); -} ); + const sites = allSites + // `getSyncSupport` with no connected ids returns 'syncable' for sites the + // user administers on a supported host/plan — i.e. usable in Studio. + .filter( ( site ) => getSyncSupport( site, [] ) === 'syncable' ) + .filter( ( site ) => ! stagingBlogIds.has( site.ID ) ) + .map( ( site ) => { + // Studio Web edits sites on their staging environment, never + // production. When a site has a staging blog, surface its URL. + const stagingId = site.options?.wpcom_staging_blog_ids?.[ 0 ]; + const siteUrl = ( stagingId && urlByBlogId.get( stagingId ) ) || site.URL; + return { + id: String( site.ID ), + name: site.name || site.URL || String( site.ID ), + path: '', + port: 0, + running: true, + url: siteUrl, + phpVersion: '', + siteIcon: site.icon?.img ?? null, + }; + } ); + res.json( sites ); + } ) +); // --- AI sessions ------------------------------------------------------------- -app.get( '/sessions', async ( _req: Request, res: Response ) => { - const [ sessions, sessionMetadata ] = await Promise.all( [ - listAiSessions( root ), - readSharedSessions(), - ] ); - res.json( - sessions.map( ( session ) => hydrateAiSessionSummary( session, sessionMetadata[ session.id ] ) ) - ); -} ); +api.get( + '/sessions', + asyncHandler( async ( _req: Request, res: Response ) => { + const [ sessions, sessionMetadata ] = await Promise.all( [ + listAiSessions( root ), + readSharedSessions(), + ] ); + res.json( + sessions.map( ( session ) => + hydrateAiSessionSummary( session, sessionMetadata[ session.id ] ) + ) + ); + } ) +); -app.post( '/sessions', 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 ) ); -} ); +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 ) ); + } ) +); -app.get( '/sessions/:id', async ( req: Request, res: Response ) => { - const [ loaded, sessionMetadata ] = await Promise.all( [ - loadAiSession( root, req.params.id ), - readSharedSessions(), - ] ); - res.json( { - ...loaded, - summary: hydrateAiSessionSummary( loaded.summary, sessionMetadata[ loaded.summary.id ] ), - } ); -} ); +api.get( + '/sessions/:id', + asyncHandler( async ( req: Request, res: Response ) => { + const [ loaded, sessionMetadata ] = await Promise.all( [ + loadAiSession( root, req.params.id ), + readSharedSessions(), + ] ); + res.json( { + ...loaded, + summary: hydrateAiSessionSummary( loaded.summary, sessionMetadata[ loaded.summary.id ] ), + } ); + } ) +); -app.delete( '/sessions/:id', async ( req: Request, res: Response ) => { - await deleteAiSession( root, req.params.id ); - res.sendStatus( 204 ); -} ); +api.delete( + '/sessions/:id', + asyncHandler( async ( req: Request, res: Response ) => { + await deleteAiSession( root, req.params.id ); + res.sendStatus( 204 ); + } ) +); -app.patch( '/sessions/:id', async ( req: Request, res: Response ) => { - const { summary } = await loadAiSession( root, req.params.id ); - const patch = req.body as { starred?: boolean; archived?: boolean }; - // Same persistence the desktop app uses (updateAiSessionMetadata in - // ipc-handlers.ts): flags go to the shared config under its lock. - const metadata = await updateSharedSession( summary.id, patch ); - res.json( hydrateAiSessionSummary( summary, metadata ) ); -} ); +api.patch( + '/sessions/:id', + asyncHandler( async ( req: Request, res: Response ) => { + const { summary } = await loadAiSession( root, req.params.id ); + const patch = req.body as { starred?: boolean; archived?: boolean }; + // Same persistence the desktop app uses (updateAiSessionMetadata in + // ipc-handlers.ts): flags go to the shared config under its lock. + const metadata = await updateSharedSession( summary.id, patch ); + res.json( hydrateAiSessionSummary( summary, metadata ) ); + } ) +); -app.post( '/sessions/:id/model', async ( req: Request, res: Response ) => { - const { model } = req.body as { model?: string }; - if ( ! model || ! isAiModelId( model ) ) { - res.status( 400 ).json( { error: `Unknown AI model: ${ model }` } ); - return; - } - await appendModelChangeEntry( root, req.params.id, '', model ); - res.sendStatus( 204 ); -} ); +api.post( + '/sessions/:id/model', + asyncHandler( async ( req: Request, res: Response ) => { + const { model } = req.body as { model?: string }; + if ( ! model || ! isAiModelId( model ) ) { + res.status( 400 ).json( { error: `Unknown AI model: ${ model }` } ); + return; + } + await appendModelChangeEntry( root, req.params.id, '', model ); + res.sendStatus( 204 ); + } ) +); -app.post( '/sessions/:id/messages', ( req: Request, res: Response ) => { +api.post( '/sessions/:id/messages', ( req: Request, res: Response ) => { const { prompt, displayMessage } = req.body as { prompt?: string; displayMessage?: string }; if ( ! prompt ) { res.status( 400 ).json( { error: 'prompt is required' } ); @@ -258,21 +296,49 @@ app.post( '/sessions/:id/messages', ( req: Request, res: Response ) => { // --- Runs -------------------------------------------------------------------- -app.get( '/runs/active', ( _req: Request, res: Response ) => { +api.get( '/runs/active', ( _req: Request, res: Response ) => { res.json( listActiveAgentRuns() ); } ); -app.post( '/runs/:runId/interrupt', ( req: Request, res: Response ) => { +api.post( '/runs/:runId/interrupt', ( req: Request, res: Response ) => { interruptAgentRun( req.params.runId ); res.sendStatus( 204 ); } ); -app.post( '/runs/:runId/answer', ( req: Request, res: Response ) => { +api.post( '/runs/:runId/answer', ( req: Request, res: Response ) => { const { answers } = req.body as { answers?: Record< string, string > }; answerAgentRun( req.params.runId, answers ?? {} ); res.sendStatus( 204 ); } ); +app.use( '/api', api ); + +// --- Web UI ------------------------------------------------------------------ + +// Serve the built browser UI (apps/ui `npm run build:web`) so `studio +// web-server` is the only command needed: API and SPA share one origin. When +// the build output isn't there (API-only usage, or UI served by the Vite dev +// server on :5300), the server still works and the startup message says how to +// get the UI. +const uiDist = + process.env.STUDIO_WEB_UI_DIST ?? + path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '../../../ui/dist-web' ); +const uiIndex = path.join( uiDist, 'index.web.html' ); +const hasUi = existsSync( uiIndex ); +if ( hasUi ) { + app.use( express.static( uiDist ) ); + // SPA fallback: the app uses real-path routing (/sessions/:id, /sites/:id), + // so any unmatched HTML navigation reloads into the app shell. API routes + // are registered above and keep precedence. + app.get( '*', ( req: Request, res: Response, next ) => { + if ( ( req.headers.accept ?? '' ).includes( 'text/html' ) ) { + res.sendFile( uiIndex ); + return; + } + next(); + } ); +} + // --- Error handling ---------------------------------------------------------- app.use( ( err: unknown, _req: Request, res: Response, _next: ( e?: unknown ) => void ) => { @@ -287,12 +353,19 @@ const port = getPort(); const server = app.listen( port, '127.0.0.1', () => { console.log( `\nWordPress Studio Web Server` ); console.log( `==========================` ); - console.log( `Listening: http://localhost:${ port }` ); - console.log( `Health: http://localhost:${ port }/health` ); - console.log( `Events: http://localhost:${ port }/events (SSE)` ); - console.log( '' ); - console.log( `Point the web UI at this with VITE_STUDIO_API_URL=http://localhost:${ port }` ); + if ( hasUi ) { + console.log( `Open: http://localhost:${ port }` ); + } + console.log( `Health: http://localhost:${ port }/api/health` ); + console.log( `Events: http://localhost:${ port }/api/events (SSE)` ); console.log( '' ); + if ( ! hasUi ) { + console.log( `No web UI build found at ${ uiDist }.` ); + console.log( + `Build it with \`npm run build:web --workspace=apps/ui\`, or run the dev server with \`npm run dev:web --workspace=apps/ui\` and open http://localhost:5300.` + ); + console.log( '' ); + } } ); process.on( 'SIGINT', () => { diff --git a/apps/ui/src/data/core/connectors/web/index.ts b/apps/ui/src/data/core/connectors/web/index.ts index c8103b6227..bb559cb643 100644 --- a/apps/ui/src/data/core/connectors/web/index.ts +++ b/apps/ui/src/data/core/connectors/web/index.ts @@ -55,7 +55,9 @@ type ServerEvent = * throw) or throw `WebUnsupportedError` for user-triggered actions. */ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Connector { - const base = apiBaseUrl.replace( /\/$/, '' ); + // The backend namespaces its API under /api so the SPA's real-path routes + // (also /sessions/:id, /sites/:id) can share the same origin. + const base = `${ apiBaseUrl.replace( /\/$/, '' ) }/api`; const agentListeners = new Set< ( event: AgentRunEvent ) => void >(); const placementListeners = new Set< ( event: AiSessionPlacementUpdatedEvent ) => void >(); diff --git a/apps/ui/src/main.web.tsx b/apps/ui/src/main.web.tsx index 7224eaa1a4..2ce397fe15 100644 --- a/apps/ui/src/main.web.tsx +++ b/apps/ui/src/main.web.tsx @@ -23,13 +23,20 @@ async function loadTranslations( connector: Connector ) { } } +function getDefaultApiBaseUrl(): string { + // Production builds are served by `studio web-server` itself, so the API is + // same-origin. The Vite dev server (:5300) is a separate origin and targets + // the backend's default port instead. + return import.meta.env.DEV ? 'http://localhost:8088' : window.location.origin; +} + async function bootstrap() { // Studio Web defaults to the classic (agentic) UI — it uses real-path // routing that survives reloads/deep links. seedDefaultUiMode( 'classic' ); const connector = createWebConnector( { - apiBaseUrl: import.meta.env.VITE_STUDIO_API_URL ?? 'http://localhost:8088', + apiBaseUrl: import.meta.env.VITE_STUDIO_API_URL ?? getDefaultApiBaseUrl(), } ); await Promise.all( [ connector.init?.(), loadTranslations( connector ), persistPromise ] ); From cd980d31f200d2ca1ae9a9ef48e7bc301470572e Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Mon, 15 Jun 2026 21:58:59 -0300 Subject: [PATCH 8/8] Studio Web: add selectable SecEx backend to create and list sandbox sites in the browser Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ui/src/components/wpcom-login-screen.tsx | 21 + apps/ui/src/data/core/connectors/ipc/index.ts | 11 + .../src/data/core/connectors/secex/index.ts | 1204 +++++++++++++++++ .../connectors/secex/preview-blueprint.ts | 23 + apps/ui/src/data/core/connectors/web/index.ts | 19 +- apps/ui/src/data/core/index.ts | 1 + apps/ui/src/data/core/types.ts | 16 + apps/ui/src/lib/wpcom-web-auth.ts | 96 ++ apps/ui/src/main.web.tsx | 59 +- apps/ui/src/vite-env.d.ts | 7 + 10 files changed, 1450 insertions(+), 7 deletions(-) create mode 100644 apps/ui/src/components/wpcom-login-screen.tsx create mode 100644 apps/ui/src/data/core/connectors/secex/index.ts create mode 100644 apps/ui/src/data/core/connectors/secex/preview-blueprint.ts create mode 100644 apps/ui/src/lib/wpcom-web-auth.ts diff --git a/apps/ui/src/components/wpcom-login-screen.tsx b/apps/ui/src/components/wpcom-login-screen.tsx new file mode 100644 index 0000000000..f0fd43acc0 --- /dev/null +++ b/apps/ui/src/components/wpcom-login-screen.tsx @@ -0,0 +1,21 @@ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +// Minimal pre-auth screen shown in SecEx mode when there's no WordPress.com +// token yet. Clicking the button starts the implicit OAuth flow; the browser +// returns to the web origin with the token in the URL fragment. +export function WpcomLoginScreen( { onLogin }: { onLogin: () => void } ) { + return ( +
+
+

Studio Web

+

+ { __( 'Log in with your WordPress.com account to run the agent.' ) } +

+ +
+
+ ); +} diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index f4431f4ee4..36cf6945bc 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -17,6 +17,7 @@ import type { ProposedSitePath, SelectedSiteFolder, SiteDetails, + SitePreviewFile, Snapshot, SupportedEditor, SupportedTerminal, @@ -592,6 +593,16 @@ export function createIpcConnector(): Connector { ); }, + // Desktop has no browser-previewable web workspace; the renderer previews + // the live local site directly (SitePreview). The client-side Playground + // preview is a Studio Web (web connector) affordance. + async getSiteFiles(): Promise< SitePreviewFile[] > { + 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/secex/index.ts b/apps/ui/src/data/core/connectors/secex/index.ts new file mode 100644 index 0000000000..ec2fe57c59 --- /dev/null +++ b/apps/ui/src/data/core/connectors/secex/index.ts @@ -0,0 +1,1204 @@ +import { createDefaultDeskSettings } from '@studio/common/lib/desk-settings'; +import { buildPreviewUrl } from './preview-blueprint'; +import type { + ActiveAgentRun, + AiSessionSummary, + AuthUser, + Connector, + DeskConfig, + DeskSettings, + FeaturedBlueprint, + InstalledApps, + LoadedAiSession, + SessionEntry, + SiteDetails, + SitePreviewFile, + Snapshot, + SyncSite, + UserPreferences, +} from '../../types'; +import type { AgentEvent, AgentRunEvent } from '@studio/common/ai/agent-events'; +import type { JsonEvent } from '@studio/common/ai/json-events'; + +export interface SecexConnectorOptions { + // Full URL of the wpcom Studio Code endpoint, e.g. + // https://public-api.wordpress.com/wpcom/v2/studio-code/run + runUrl: string; + // WordPress.com OAuth Bearer token forwarded to the endpoint (which forwards + // it into the sandbox as STUDIO_WPCOM_TOKEN for the model proxy). + token: string; +} + +export class SecexUnsupportedError extends Error { + constructor( operation: string ) { + super( `"${ operation }" is not available in Studio Web (SecEx).` ); + this.name = 'SecexUnsupportedError'; + } +} + +const SESSIONS_KEY = 'studio-secex-sessions'; +const CLI_IDS_KEY = 'studio-secex-cli-session-ids'; +const ENTRIES_KEY = 'studio-secex-entries'; +const SITES_KEY = 'studio-secex-sites'; +const SITE_CLI_IDS_KEY = 'studio-secex-site-cli-session-ids'; + +function nowIso(): string { + return new Date().toISOString(); +} + +function readJson< T >( key: string, fallback: T ): T { + try { + const raw = window.localStorage.getItem( key ); + return raw ? ( JSON.parse( raw ) as T ) : fallback; + } catch { + return fallback; + } +} + +function writeJson( key: string, value: unknown ): void { + try { + window.localStorage.setItem( key, JSON.stringify( value ) ); + } catch { + // Ignore storage failures (private mode, quota). + } +} + +/** + * Connector that drives the agent directly against the hosted wpcom Studio Code + * endpoint (`/wpcom/v2/studio-code/run`) — no local `web-server`. The browser is + * already sandbox-routed, so a same-session `fetch()` reaches the user's SecEx + * sandbox; the endpoint streams the CLI's NDJSON back as SSE, which we translate + * into the `AgentRunEvent`s the UI already understands. + * + * What the `/run` endpoint does NOT provide is modelled client-side for the PoC: + * the session list lives in `localStorage`, multi-turn uses the CLI `session_id` + * surfaced in the stream, and site/preview/sync surfaces are stubbed. + */ +export function createSecexConnector( { runUrl, token }: SecexConnectorOptions ): Connector { + const agentListeners = new Set< ( event: AgentRunEvent ) => void >(); + const previewListeners = new Set< ( sessionId: string ) => void >(); + const activeRuns = new Map< + string, + { runId: string; sessionId: string; startedAt: number; controller: AbortController } + >(); + + function emit( runId: string, sessionId: string, event: AgentEvent ): void { + const payload: AgentRunEvent = { runId, sessionId, event }; + agentListeners.forEach( ( listener ) => listener( payload ) ); + } + + // Opt-in connector tracing. Flip to `true` (or set localStorage + // `studio-secex-debug`) to log the per-user run gate, /run posts, busy-retries, + // SSE frames, and preview notifications — useful when diagnosing the SecEx + // sandbox round-trip. Off by default to keep the console quiet. + const SECEX_DEBUG = + typeof window !== 'undefined' && window.localStorage?.getItem( 'studio-secex-debug' ) === '1'; + let postSeq = 0; + let gateSeqCounter = 0; + let inFlightPosts = 0; + let gateDepth = 0; + const dbg = ( ...args: unknown[] ): void => { + if ( ! SECEX_DEBUG ) { + return; + } + + console.log( + '%c[secex]', + 'color:#a06bff;font-weight:bold', + `+${ Math.round( performance.now() ) }ms`, + ...args + ); + }; + dbg( 'connector instance created', { runUrl, hasToken: Boolean( token ) } ); + + function getSessions(): AiSessionSummary[] { + return readJson< AiSessionSummary[] >( SESSIONS_KEY, [] ); + } + function putSessions( sessions: AiSessionSummary[] ): void { + writeJson( SESSIONS_KEY, sessions ); + } + function patchSession( sessionId: string, patch: Partial< AiSessionSummary > ): AiSessionSummary { + const sessions = getSessions(); + const index = sessions.findIndex( ( s ) => s.id === sessionId ); + if ( index === -1 ) { + throw new Error( `Unknown session ${ sessionId }` ); + } + const updated = { ...sessions[ index ], ...patch, updatedAt: nowIso() }; + sessions[ index ] = updated; + putSessions( sessions ); + return updated; + } + + function getCliSessionId( sessionId: string ): string | undefined { + return readJson< Record< string, string > >( CLI_IDS_KEY, {} )[ sessionId ]; + } + function setCliSessionId( sessionId: string, cliSessionId: string ): void { + const map = readJson< Record< string, string > >( CLI_IDS_KEY, {} ); + map[ sessionId ] = cliSessionId; + writeJson( CLI_IDS_KEY, map ); + } + + // The conversation is persisted client-side so that the run-end refetch of + // getSession (which use-agent-run fires to swap optimistic entries for + // "disk-backed" ones) returns the real history instead of wiping it — and so + // it survives reloads. We accumulate the same SessionEntry shapes the + // use-agent-run reducer builds from the live stream. + function getEntries( sessionId: string ): SessionEntry[] { + return readJson< Record< string, SessionEntry[] > >( ENTRIES_KEY, {} )[ sessionId ] ?? []; + } + function appendEntry( sessionId: string, entry: SessionEntry ): void { + const map = readJson< Record< string, SessionEntry[] > >( ENTRIES_KEY, {} ); + map[ sessionId ] = [ ...( map[ sessionId ] ?? [] ), entry ]; + writeJson( ENTRIES_KEY, map ); + } + function entryId(): string { + return Math.random().toString( 36 ).slice( 2, 10 ); + } + + // Sites created in the sandbox (via the agent's `site_create`). The /run + // endpoint can't list the sandbox's sites yet, so we mirror them client-side + // so the sidebar, routing, and session binding work like the desktop app. + function getStoredSites(): SiteDetails[] { + return readJson< SiteDetails[] >( SITES_KEY, [] ); + } + function putStoredSites( sites: SiteDetails[] ): void { + writeJson( SITES_KEY, sites ); + } + function upsertStoredSite( site: SiteDetails ): void { + const sites = getStoredSites().filter( ( s ) => s.id !== site.id ); + putStoredSites( [ site, ...sites ] ); + } + + // The CLI session id from a site's creation run, so the site's first chat + // resumes that same warm sandbox session (where the new site is already the + // active one) instead of starting cold. + function setSiteCliSessionId( siteId: string, cliSessionId: string ): void { + const map = readJson< Record< string, string > >( SITE_CLI_IDS_KEY, {} ); + map[ siteId ] = cliSessionId; + writeJson( SITE_CLI_IDS_KEY, map ); + } + function takeSiteCliSessionId( siteId: string ): string | undefined { + const map = readJson< Record< string, string > >( SITE_CLI_IDS_KEY, {} ); + const value = map[ siteId ]; + if ( value ) { + delete map[ siteId ]; + writeJson( SITE_CLI_IDS_KEY, map ); + } + return value; + } + + // The /run endpoint serializes turns per user with an advisory lock and rejects + // a concurrent request with HTTP 429 + `{ code: 'busy' }`. That lock is held + // until the END of the previous turn — including the post-stream durability step + // (sandbox snapshot + pause), which runs for several seconds AFTER the client's + // stream has already closed. So the first chat fired right after a create can + // legitimately race that tail. We retry the POST with backoff when (and only + // when) the body is the `busy` lock; a bodyless 429 (wpcom edge rate limiting) + // is surfaced immediately. `onBusyWait` lets the caller show a "waiting" hint. + const BUSY_RETRY_DELAYS_MS = [ 2000, 4000, 6000, 8000, 10000 ]; + + function delay( ms: number, signal: AbortSignal ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const timer = setTimeout( resolve, ms ); + signal.addEventListener( + 'abort', + () => { + clearTimeout( timer ); + reject( new DOMException( 'Aborted', 'AbortError' ) ); + }, + { once: true } + ); + } ); + } + + type RunPostResult = + | { ok: true; response: Response } + | { ok: false; status: number; text: string }; + + async function postRun( + body: Record< string, unknown >, + signal: AbortSignal, + onBusyWait?: ( attempt: number ) => void + ): Promise< RunPostResult > { + const seq = ++postSeq; + inFlightPosts++; + const sessionTag = body.session_id + ? `resume:${ String( body.session_id ).slice( 0, 8 ) }` + : 'fresh'; + dbg( + `POST#${ seq } START`, + `inFlight=${ inFlightPosts }`, + sessionTag, + `prompt="${ String( body.prompt ?? '' ).slice( 0, 60 ) }"` + ); + if ( inFlightPosts > 1 ) { + dbg( `⚠️ POST#${ seq } CONCURRENT — ${ inFlightPosts } posts in flight (gate breach?)` ); + } + try { + for ( let attempt = 0; ; attempt++ ) { + const response = await fetch( runUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${ token }`, + 'X-WPCOM-AI-Feature': 'studio-code', + 'Content-Type': 'application/json', + }, + body: JSON.stringify( body ), + signal, + } ); + dbg( `POST#${ seq } attempt#${ attempt } -> HTTP ${ response.status }` ); + if ( response.status !== 429 ) { + return { ok: true, response }; + } + // 429: peek the body. Only the endpoint's per-user lock (`busy`) is + // retryable; a bodyless edge rate-limit is not. + const text = await response.text().catch( () => '' ); + dbg( `POST#${ seq } 429 body:`, text || '(empty)' ); + if ( ! text.includes( '"busy"' ) || attempt >= BUSY_RETRY_DELAYS_MS.length ) { + dbg( `POST#${ seq } GIVE UP — busy=${ text.includes( '"busy"' ) } attempt=${ attempt }` ); + return { ok: false, status: response.status, text }; + } + dbg( `POST#${ seq } retry in ${ BUSY_RETRY_DELAYS_MS[ attempt ] }ms` ); + onBusyWait?.( attempt + 1 ); + await delay( BUSY_RETRY_DELAYS_MS[ attempt ], signal ); + } + } finally { + inFlightPosts--; + dbg( `POST#${ seq } DONE inFlight=${ inFlightPosts }` ); + } + } + + // The /run endpoint is strictly serial per user (a single advisory lock). + // Mirror that on the client: chain every /run POST so we never have two in + // flight at once — a second run waits for the first to finish streaming instead + // of racing it into a `busy` 429. This covers the create→first-chat handoff and + // the site-overview "new chat" path (which fires a run, then navigates to the + // session view, which can start another). The busy-retry above stays as a safety + // net for concurrency we don't control (e.g. another tab on the same user). + let runGate: Promise< void > = Promise.resolve(); + function serializeRun< T >( fn: () => Promise< T > ): Promise< T > { + const gseq = ++gateSeqCounter; + gateDepth++; + dbg( `gate#${ gseq } ENQUEUE depth=${ gateDepth }` ); + const start = (): Promise< T > => { + dbg( `gate#${ gseq } START` ); + return fn(); + }; + const result = runGate.then( start, start ); + runGate = result.then( + () => { + gateDepth--; + dbg( `gate#${ gseq } SETTLED ok depth=${ gateDepth }` ); + }, + () => { + gateDepth--; + dbg( `gate#${ gseq } SETTLED err depth=${ gateDepth }` ); + } + ); + return result; + } + + // Stream one /run turn: POST the prompt, parse the SSE frames, translate each + // `data:` JsonEvent into an AgentRunEvent, and synthesize run lifecycle events. + async function streamRun( + runId: string, + sessionId: string, + prompt: string, + controller: AbortController + ): Promise< void > { + dbg( + `streamRun run=${ runId.slice( 0, 8 ) } session=${ sessionId.slice( 0, 8 ) } — entering gate` + ); + let priorCliSessionId = getCliSessionId( sessionId ); + let resolvedCliSessionId = priorCliSessionId; + let sawError = false; + + await serializeRun( async () => { + // Re-read inside the gate: a run that finished ahead of us in the chain may + // have just learned (or created) the CLI session id we should resume. + const cliSessionId = getCliSessionId( sessionId ); + priorCliSessionId = cliSessionId; + resolvedCliSessionId = cliSessionId; + try { + const posted = await postRun( + { + prompt, + ...( cliSessionId ? { session_id: cliSessionId } : {} ), + }, + controller.signal, + ( attempt ) => + appendEntry( sessionId, { + type: 'custom', + id: entryId(), + parentId: null, + timestamp: nowIso(), + customType: 'studio.tool_progress', + data: { + message: `Waiting for the previous step to finish… (retry ${ attempt })`, + }, + } as unknown as SessionEntry ) + ); + const response = posted.ok ? posted.response : undefined; + + if ( ! response || ! response.ok || ! response.body ) { + const status = posted.ok ? posted.response.status : posted.status; + const text = posted.ok ? await posted.response.text().catch( () => '' ) : posted.text; + emit( runId, sessionId, { + type: 'error', + timestamp: nowIso(), + message: `studio-code/run failed (${ status }): ${ text }`, + } ); + sawError = true; + } else { + dbg( `run=${ runId.slice( 0, 8 ) } HTTP 200 — streaming started` ); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let frameCount = 0; + + const handleFrame = ( frame: string ): void => { + let eventName = 'message'; + const dataLines: string[] = []; + for ( const line of frame.split( '\n' ) ) { + if ( line.startsWith( 'event:' ) ) { + eventName = line.slice( 6 ).trim(); + } else if ( line.startsWith( 'data:' ) ) { + dataLines.push( line.slice( 5 ).replace( /^ /, '' ) ); + } + } + const dataStr = dataLines.join( '\n' ); + frameCount++; + dbg( + `run=${ runId.slice( 0, 8 ) } frame#${ frameCount } event=${ eventName }`, + `len=${ dataStr.length }`, + dataStr.slice( 0, 140 ) + ); + if ( ! dataStr ) { + return; + } + + if ( eventName === 'error' ) { + let message = dataStr; + try { + const parsed = JSON.parse( dataStr ) as { message?: string; code?: string }; + message = parsed.message ?? parsed.code ?? dataStr; + } catch { + // keep raw + } + emit( runId, sessionId, { type: 'error', timestamp: nowIso(), message } ); + sawError = true; + return; + } + + if ( eventName === 'session' || eventName === 'done' ) { + try { + const parsed = JSON.parse( dataStr ) as { session_id?: string }; + if ( parsed.session_id ) { + resolvedCliSessionId = parsed.session_id; + } + } catch { + // ignore + } + return; + } + + // eventName === 'data' (or default): the payload is a CLI JsonEvent. + let json: JsonEvent; + try { + json = JSON.parse( dataStr ) as JsonEvent; + } catch { + return; + } + + // The deployed CLI (1.10.0, pi runtime) already emits pi-shaped events + // that use-agent-run renders natively — message_start/update/end, + // turn_start/turn_end, tool_execution_*, agent_end — each wrapped as + // `{ type: 'message', message: }`. Pass them straight through; + // no translation is needed. (The earlier code translated for the old + // Claude Agent SDK shape, whose `assistant`/`user` inner types never + // matched the pi events, so it silently emitted nothing and the chat + // stayed blank.) The CLI session id rides on the `turn.completed` event + // as `sessionId` (camelCase) — capture it wherever it appears. + resolvedCliSessionId = + ( json as { session_id?: string } ).session_id ?? + ( json as { sessionId?: string } ).sessionId ?? + resolvedCliSessionId; + + if ( json.type === 'message' ) { + const inner = json.message as { + type?: string; + session_id?: string; + message?: unknown; + }; + if ( inner?.session_id ) { + resolvedCliSessionId = inner.session_id; + } + // Persist the assistant's terminal messages so the run-end getSession + // refetch (and reloads) keep the conversation. Mirror exactly what + // use-agent-run renders live (assistant `message_end`) so the + // refetched history matches the optimistic stream — no duplicates, + // no disappearing replies. + if ( + inner?.type === 'message_end' && + ( inner.message as { role?: string } )?.role === 'assistant' + ) { + appendEntry( sessionId, { + type: 'message', + id: entryId(), + parentId: null, + timestamp: json.timestamp ?? nowIso(), + message: inner.message, + } as unknown as SessionEntry ); + } + emit( runId, sessionId, json as AgentEvent ); + return; + } + + if ( json.type === 'progress' ) { + appendEntry( sessionId, { + type: 'custom', + id: entryId(), + parentId: null, + timestamp: json.timestamp ?? nowIso(), + customType: 'studio.tool_progress', + data: { message: json.message }, + } as unknown as SessionEntry ); + } + emit( runId, sessionId, json as AgentEvent ); + }; + + for (;;) { + const { value, done } = await reader.read(); + if ( done ) { + break; + } + buffer += decoder.decode( value, { stream: true } ); + let sep = buffer.indexOf( '\n\n' ); + while ( sep !== -1 ) { + handleFrame( buffer.slice( 0, sep ) ); + buffer = buffer.slice( sep + 2 ); + sep = buffer.indexOf( '\n\n' ); + } + } + if ( buffer.trim() ) { + handleFrame( buffer ); + } + dbg( + `run=${ runId.slice( + 0, + 8 + ) } stream ENDED — frames=${ frameCount } sawError=${ sawError }` + ); + } + } catch ( error ) { + dbg( + `run=${ runId.slice( 0, 8 ) } stream threw:`, + ( error as Error ).name, + ( error as Error ).message + ); + if ( ( error as Error ).name !== 'AbortError' ) { + emit( runId, sessionId, { + type: 'error', + timestamp: nowIso(), + message: ( error as Error ).message || 'studio-code/run stream failed', + } ); + sawError = true; + } + } + } ); + + if ( resolvedCliSessionId && resolvedCliSessionId !== priorCliSessionId ) { + setCliSessionId( sessionId, resolvedCliSessionId ); + } + try { + patchSession( sessionId, {} ); + } catch { + // Session may have been deleted mid-run. + } + dbg( + `run=${ runId.slice( 0, 8 ) } run.exited status=${ sawError ? 'error' : 'success' }`, + `resolvedCli=${ resolvedCliSessionId?.slice( 0, 8 ) ?? 'none' }` + ); + emit( runId, sessionId, { + type: 'run.exited', + timestamp: nowIso(), + status: sawError ? 'error' : 'success', + code: sawError ? 1 : 0, + } ); + // The agent may have edited the site this turn — tell the live preview to + // re-export and re-render. Fire even on error/interrupt: the agent often + // applies changes before a long run drops (e.g. an HTTP/2 timeout), so the + // re-export should reflect whatever state the site is actually in. + dbg( `run=${ runId.slice( 0, 8 ) } notifying ${ previewListeners.size } preview listener(s)` ); + previewListeners.forEach( ( listener ) => listener( sessionId ) ); + } + + interface SiteCreateResult { + id: string; + name: string; + path: string; + url?: string; + // CLI session id of the creation run, so the site's first chat can resume it. + cliSessionId?: string; + } + + // Pull the CLI session id out of a frame (it rides in `session`/`done` events + // and in each message's `session_id`). + function extractSessionId( dataStr: string ): string | undefined { + try { + const parsed = JSON.parse( dataStr ) as { + session_id?: string; + message?: { session_id?: string }; + }; + return parsed.session_id ?? parsed.message?.session_id; + } catch { + return undefined; + } + } + + // Pull the `site_create` tool result out of one streamed tool turn. The tool + // returns `textResult(JSON.stringify({ id, name, path, url, ... }))`, which the + // CLI surfaces as a `tool_result` block inside a `user` message. We dig that + // block out and JSON-parse it. + function extractSiteFromFrame( dataStr: string ): SiteCreateResult | undefined { + let json: JsonEvent; + try { + json = JSON.parse( dataStr ) as JsonEvent; + } catch { + return undefined; + } + if ( json.type !== 'message' ) { + return undefined; + } + const inner = json.message as { + type?: string; + message?: { content?: unknown }; + }; + if ( inner?.type !== 'user' ) { + return undefined; + } + const content = inner.message?.content; + const candidates: string[] = []; + const pushBlock = ( block: unknown ): void => { + if ( ! block || typeof block !== 'object' ) { + return; + } + const typed = block as { type?: string; text?: string; content?: unknown }; + if ( typed.type === 'text' && typeof typed.text === 'string' ) { + candidates.push( typed.text ); + } else if ( typed.type === 'tool_result' ) { + if ( typeof typed.content === 'string' ) { + candidates.push( typed.content ); + } else if ( Array.isArray( typed.content ) ) { + typed.content.forEach( pushBlock ); + } + } + }; + if ( typeof content === 'string' ) { + candidates.push( content ); + } else if ( Array.isArray( content ) ) { + content.forEach( pushBlock ); + } + for ( const text of candidates ) { + try { + const parsed = JSON.parse( text ) as Partial< SiteCreateResult >; + if ( parsed.id && parsed.name && parsed.path ) { + return { + id: parsed.id, + name: parsed.name, + path: parsed.path, + url: parsed.url, + }; + } + } catch { + // Not the site_create result blob. + } + } + return undefined; + } + + // Sandbox sites live under STUDIO_SITES_ROOT, which is ~/Studio for the + // sandbox user (overridable for other deployments). + const SANDBOX_SITES_ROOT = import.meta.env.VITE_STUDIO_SECEX_SITES_ROOT ?? '/home/user/Studio'; + + function slugify( name: string ): string { + return name + .toLowerCase() + .replace( /[^a-z0-9]+/g, '-' ) + .replace( /^-|-$/g, '' ); + } + + // Create a site in the sandbox by driving the deployed Studio CLI directly + // with `--no-start`. We do NOT use the agent's `site_create` tool because it + // always starts the server, which can't boot in the sandbox (PHP-WASM) and + // makes the create command roll back and delete the site. `--no-start` + // persists the site (the preview is rendered client-side, not served from the + // sandbox). This works against the deployed template — no rebuild needed. + // + // We detect success from the CLI's "Site created successfully" marker (and pull + // the site id/path from the tool output if present, else derive them from the + // deterministic sandbox path), but we DRAIN the stream to completion rather than + // aborting early. Aborting the client fetch does not stop the run inside the + // sandbox: the endpoint keeps the agent's turn alive and holds the per-user + // `studio_code_session` lock until that run finishes. The first chat then adopts + // the same warm CLI session and collides with the still-running create — the + // endpoint rejects the concurrent /run with a 429. Draining lets the lock + // release cleanly so the follow-up chat succeeds. + async function createSiteViaAgent( name: string ): Promise< SiteCreateResult > { + dbg( `createSiteViaAgent name="${ name }"` ); + const slug = slugify( name ); + if ( ! slug ) { + throw new Error( 'Site name must contain at least one letter or digit (a-z, 0-9).' ); + } + const sandboxPath = `${ SANDBOX_SITES_ROOT }/${ slug }`; + // Sanitize the name we interpolate into the prompt (the real name is + // preserved in the returned SiteDetails). + const safeName = name.replace( /["\\\n]/g, '' ).trim() || slug; + + const controller = new AbortController(); + // The sandbox agent only uses the `mcp__studio__` tools (it refuses Bash for + // site management), so drive `site_create` directly. The hosted template's + // CLI skips the server start (no-start), so the site persists instead of + // rolling back on the PHP-WASM start failure. + const prompt = + `Use the site_create tool to create a new WordPress site named "${ safeName }". ` + + `Create only that one site, then stop — do not install plugins, add content, or take other actions.`; + return serializeRun( async () => { + const posted = await postRun( { prompt }, controller.signal ); + if ( ! posted.ok ) { + throw new Error( `studio-code/run failed (${ posted.status }): ${ posted.text }` ); + } + const response = posted.response; + if ( ! response.ok || ! response.body ) { + const text = await response.text().catch( () => '' ); + throw new Error( `studio-code/run failed (${ response.status }): ${ text }` ); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let result: SiteCreateResult | undefined; + let cliSessionId: string | undefined; + let succeeded = false; + + const scan = ( frame: string ): void => { + const dataLines: string[] = []; + for ( const line of frame.split( '\n' ) ) { + if ( line.startsWith( 'data:' ) ) { + dataLines.push( line.slice( 5 ).replace( /^ /, '' ) ); + } + } + const dataStr = dataLines.join( '\n' ); + if ( ! dataStr ) { + return; + } + cliSessionId = cliSessionId ?? extractSessionId( dataStr ); + result = result ?? extractSiteFromFrame( dataStr ); + // The CLI prints this on a successful create (with or without --start). + if ( dataStr.includes( 'Site created successfully' ) ) { + succeeded = true; + } + }; + + try { + for (;;) { + const { value, done } = await reader.read(); + if ( done ) { + break; + } + buffer += decoder.decode( value, { stream: true } ); + let sep = buffer.indexOf( '\n\n' ); + while ( sep !== -1 ) { + scan( buffer.slice( 0, sep ) ); + buffer = buffer.slice( sep + 2 ); + sep = buffer.indexOf( '\n\n' ); + } + } + if ( buffer.trim() ) { + scan( buffer ); + } + } catch ( error ) { + if ( ( error as Error ).name !== 'AbortError' ) { + throw error; + } + } + + if ( ! result && ! succeeded ) { + throw new Error( + 'Site creation did not complete — the agent may not have created the site.' + ); + } + return { + id: result?.id ?? crypto.randomUUID(), + name: result?.name ?? name, + path: result?.path ?? sandboxPath, + url: result?.url, + cliSessionId, + }; + } ); + } + + // The `/export` sibling of the `/run` endpoint: a deterministic, agent-free + // file dump. Asking the agent to base64 the theme tripped its exfiltration + // guard ("I'm not able to run that command — it reads every file…"), so the + // endpoint runs the export node script directly via the sandbox commands API + // (no model turn) and returns `{ files: { path: base64 } }`. Fast (~1-2s, no + // agent), reliable (no refusal), and free (no model cost). + const exportUrl = runUrl.replace( /\/run(\?|$)/, '/export$1' ); + + // Export the session site's edited theme from the sandbox for a client-side + // Playground preview. Goes through the serialization gate so it never races a + // chat turn into the endpoint's per-user `busy` lock. + async function exportSiteFiles( sitePath: string ): Promise< SitePreviewFile[] > { + return serializeRun( async () => { + dbg( `exportSiteFiles START path=${ sitePath }` ); + try { + const response = await fetch( exportUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${ token }`, + 'X-WPCOM-AI-Feature': 'studio-code', + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { path: sitePath } ), + } ); + if ( ! response.ok ) { + dbg( `exportSiteFiles HTTP ${ response.status }` ); + return []; + } + const data = ( await response.json() ) as { files?: Record< string, string > }; + const files = Object.entries( data.files ?? {} ).map( ( [ path, contentBase64 ] ) => ( { + path, + contentBase64, + } ) ); + dbg( `exportSiteFiles DONE files=${ files.length }` ); + return files; + } catch ( error ) { + dbg( `exportSiteFiles error: ${ ( error as Error ).message }` ); + return []; + } + } ); + } + + return { + async init() { + // No global SSE connection — each run streams over its own fetch. + }, + + // Auth — the PoC carries a pre-provisioned Bearer; no interactive gate. + requiresAuth: false, + async isAuthenticated() { + return Boolean( token ); + }, + async getAuthUser(): Promise< AuthUser | null > { + return null; + }, + async authenticate() { + // No-op. + }, + async logout() { + // No-op. + }, + onAuthStateChanged() { + return () => {}; + }, + + // Sites — the agentic UI is site-centric (chats hang off a site). Studio Web + // sites are created by the agent (`site_create`) inside the SecEx sandbox + // and mirrored client-side. With no sites yet, the UI shows the onboarding + // "create a site" flow (reused from the desktop app). + async getSites(): Promise< SiteDetails[] > { + return getStoredSites(); + }, + // Reuse the desktop create-site form/onboarding: this drives the agent's + // `site_create` in the sandbox, mirrors the result client-side, and returns + // a SiteDetails so the UI navigates into the new site like the desktop app. + // `url` is a client-side WordPress Playground URL (foreign origin); the + // dashboard renders it in a bare iframe (PlaygroundPreviewFrame) rather than + // SitePreview, whose same-origin machinery would OOM-crash a cross-origin one. + async createSite( params ): Promise< SiteDetails > { + const created = await createSiteViaAgent( params.name ); + const site: SiteDetails = { + id: created.id, + name: created.name, + path: created.path, + port: 0, + // Not served from the sandbox (PHP-WASM can't boot there); the preview + // is client-side Playground, so the "running" server concept doesn't apply. + running: false, + url: buildPreviewUrl( created.name ), + phpVersion: '', + }; + upsertStoredSite( site ); + // Remember the creation run's CLI session so the site's first chat resumes + // it (the new site is already the active site in that warm sandbox session). + if ( created.cliSessionId ) { + setSiteCliSessionId( created.id, created.cliSessionId ); + } + return site; + }, + async deleteSite() { + throw new SecexUnsupportedError( 'deleteSite' ); + }, + async copySite(): Promise< SiteDetails > { + throw new SecexUnsupportedError( 'copySite' ); + }, + async startSite() { + // The synthetic sandbox "site" is always considered running. + }, + async stopSite() { + // No-op for the synthetic sandbox site. + }, + async updateSite() { + throw new SecexUnsupportedError( 'updateSite' ); + }, + async refreshSiteIcon() { + // No-op. + }, + async getXdebugEnabledSite(): Promise< SiteDetails | null > { + return null; + }, + async exportFullSite(): Promise< string | null > { + throw new SecexUnsupportedError( 'exportFullSite' ); + }, + async exportDatabase(): Promise< string | null > { + throw new SecexUnsupportedError( 'exportDatabase' ); + }, + // Site-creation helpers: in Studio Web sites are created by the agent + // (`site_create`), not the desktop folder-picker form. Return benign values + // so any UI that probes these doesn't throw; the local-path form isn't the + // SecEx path. + async generateProposedSiteName( usedSites ): Promise< string > { + return usedSites.length ? `My Site ${ usedSites.length + 1 }` : 'My Site'; + }, + async generateProposedSitePath( siteName ) { + // Studio Web has no local filesystem — the site is created inside the + // SecEx sandbox. Return a synthetic, non-empty path so the reused desktop + // create form validates (createSite ignores the path and the agent + // creates the site under STUDIO_SITES_ROOT in the sandbox). + const slug = slugify( siteName ) || 'site'; + return { path: `${ SANDBOX_SITES_ROOT }/${ slug }`, isEmpty: true, isWordPress: false }; + }, + async selectSiteFolder() { + return null; + }, + async comparePaths() { + return false; + }, + async getAllCustomDomains(): Promise< string[] > { + return []; + }, + + // Featured blueprints — public endpoint, identical to the other connectors. + async getFeaturedBlueprints( locale ) { + const url = new URL( 'https://public-api.wordpress.com/wpcom/v2/studio-app/blueprints' ); + if ( locale ) { + url.searchParams.set( 'locale', locale ); + } + const response = await fetch( url.toString() ); + if ( ! response.ok ) { + throw new Error( `Failed to fetch blueprints: ${ response.status }` ); + } + const body = ( await response.json() ) as { + blueprints?: Array< { + slug?: string; + title?: string; + excerpt?: string; + image?: string; + playground_url?: string; + blueprint?: unknown; + } >; + }; + const list: FeaturedBlueprint[] = []; + for ( const item of body.blueprints ?? [] ) { + if ( + typeof item.slug !== 'string' || + typeof item.title !== 'string' || + typeof item.excerpt !== 'string' || + typeof item.image !== 'string' || + typeof item.playground_url !== 'string' || + ! item.blueprint || + typeof item.blueprint !== 'object' + ) { + continue; + } + list.push( { + slug: item.slug, + title: item.title, + excerpt: item.excerpt, + image: item.image, + playgroundUrl: item.playground_url, + blueprint: item.blueprint as FeaturedBlueprint[ 'blueprint' ], + } ); + } + return list; + }, + + async getFilePath() { + return ''; + }, + async readLocalMediaFile() { + throw new SecexUnsupportedError( 'readLocalMediaFile' ); + }, + async extractBlueprintBundle() { + throw new SecexUnsupportedError( 'extractBlueprintBundle' ); + }, + async cleanupBlueprintTempDir() { + // No-op. + }, + async importSiteFromBackup(): Promise< SiteDetails > { + throw new SecexUnsupportedError( 'importSiteFromBackup' ); + }, + + // Preview / sync — out of PoC scope. + async getSnapshots(): Promise< Snapshot[] > { + return []; + }, + async publishPreviewSite(): Promise< { url: string } > { + throw new SecexUnsupportedError( 'publishPreviewSite' ); + }, + async getConnectedWpcomSites(): Promise< SyncSite[] > { + return []; + }, + async fetchSyncableWpcomSites(): Promise< SyncSite[] > { + return []; + }, + async connectWpcomSite() { + throw new SecexUnsupportedError( 'connectWpcomSite' ); + }, + async disconnectWpcomSite() { + throw new SecexUnsupportedError( 'disconnectWpcomSite' ); + }, + onSyncConnectSite() { + return () => {}; + }, + async pushSiteToLive() { + throw new SecexUnsupportedError( 'pushSiteToLive' ); + }, + async pullSiteFromLive() { + throw new SecexUnsupportedError( 'pullSiteFromLive' ); + }, + getPublishCheckoutUrl() { + return undefined; + }, + + // AI sessions — client-side list; runs stream straight to the endpoint. + async getSessions(): Promise< AiSessionSummary[] > { + return getSessions(); + }, + async getSession( sessionId ): Promise< LoadedAiSession > { + const summary = getSessions().find( ( s ) => s.id === sessionId ); + if ( ! summary ) { + throw new Error( `Unknown session ${ sessionId }` ); + } + // The /run endpoint can't re-serve history, so return the conversation + // we accumulated from the live stream (also survives reloads). + return { summary, entries: getEntries( sessionId ) }; + }, + async deleteSession( sessionId ) { + putSessions( getSessions().filter( ( s ) => s.id !== sessionId ) ); + const map = readJson< Record< string, SessionEntry[] > >( ENTRIES_KEY, {} ); + delete map[ sessionId ]; + writeJson( ENTRIES_KEY, map ); + }, + async updateSessionMetadata( sessionId, patch ): Promise< AiSessionSummary > { + return patchSession( sessionId, patch ); + }, + async createSession( siteId ): Promise< AiSessionSummary > { + const now = nowIso(); + // Bind the session to its owner site (created via site_create) so the + // sidebar groups it and the session view shows the header + preview + // toggle (canTogglePreview needs an ownerSite resolved by ownerSitePath). + const site = siteId ? getStoredSites().find( ( s ) => s.id === siteId ) : undefined; + const summary: AiSessionSummary = { + id: crypto.randomUUID(), + filePath: '', + createdAt: now, + updatedAt: now, + activeEnvironment: 'local', + eventCount: 0, + ownerSiteName: site?.name, + ownerSitePath: site?.path, + selectedSiteName: site?.name, + }; + putSessions( [ summary, ...getSessions() ] ); + // Adopt the site's creation-run CLI session for this first chat, so the + // agent resumes with the new site already active (warm sandbox session). + if ( siteId ) { + const cliSessionId = takeSiteCliSessionId( siteId ); + if ( cliSessionId ) { + setCliSessionId( summary.id, cliSessionId ); + } + } + dbg( + `createSession siteId=${ siteId?.slice( 0, 8 ) ?? 'none' } -> session=${ summary.id.slice( + 0, + 8 + ) }`, + `adoptedCli=${ getCliSessionId( summary.id )?.slice( 0, 8 ) ?? 'none' }` + ); + return summary; + }, + async continueSession( sessionId, prompt, options ): Promise< { runId: string } > { + const runId = crypto.randomUUID(); + dbg( + `continueSession session=${ sessionId.slice( 0, 8 ) } run=${ runId.slice( 0, 8 ) }`, + `activeRuns=${ activeRuns.size }`, + `cliSession=${ getCliSessionId( sessionId )?.slice( 0, 8 ) ?? 'none' }`, + new Error( 'continueSession caller' ).stack?.split( '\n' ).slice( 2, 5 ).join( ' | ' ) + ); + const controller = new AbortController(); + activeRuns.set( runId, { runId, sessionId, startedAt: Date.now(), controller } ); + + const existing = getSessions().find( ( s ) => s.id === sessionId ); + patchSession( sessionId, { + firstPrompt: existing?.firstPrompt ?? ( options?.displayMessage || prompt ), + eventCount: ( existing?.eventCount ?? 0 ) + 1, + } ); + + // Persist the user's prompt so getSession's run-end refetch keeps it. + appendEntry( sessionId, { + type: 'custom', + id: entryId(), + parentId: null, + timestamp: nowIso(), + customType: 'studio.user_prompt', + data: { text: options?.displayMessage ?? prompt, source: 'prompt' }, + } as unknown as SessionEntry ); + + emit( runId, sessionId, { type: 'run.started', timestamp: nowIso() } ); + + void streamRun( runId, sessionId, prompt, controller ).finally( () => { + activeRuns.delete( runId ); + } ); + + return { runId }; + }, + async getActiveAgentRuns(): Promise< ActiveAgentRun[] > { + return Array.from( activeRuns.values() ).map( ( run ) => ( { + runId: run.runId, + sessionId: run.sessionId, + startedAt: run.startedAt, + phase: 'running', + } ) ); + }, + async setSessionModel() { + // The /run endpoint doesn't take a model override; the sandbox CLI uses + // its default. No-op for the PoC. + }, + async interruptAgentRun( runId ) { + const run = activeRuns.get( runId ); + if ( run ) { + run.controller.abort(); + activeRuns.delete( runId ); + emit( runId, run.sessionId, { type: 'run.interrupted', timestamp: nowIso() } ); + } + }, + async answerAgentQuestion() { + // The one-shot /run stream has no back-channel; runs use --auto-approve. + }, + async setSessionEnvironment( _sessionId, environment ) { + return { environment }; + }, + onAgentEvent( listener ) { + agentListeners.add( listener ); + return () => agentListeners.delete( listener ); + }, + onSessionPlacementUpdated() { + return () => {}; + }, + + // Export the session site's edited theme from the sandbox so the live + // client-side Playground preview reflects what the agent actually built + // (not just a clean WordPress). Uses the session's warm CLI session. + async getSiteFiles( sessionId ): Promise< SitePreviewFile[] > { + const session = getSessions().find( ( s ) => s.id === sessionId ); + const sitePath = session?.ownerSitePath; + if ( ! sitePath ) { + return []; + } + return exportSiteFiles( sitePath ); + }, + onPreviewChanged( listener ) { + previewListeners.add( listener ); + return () => previewListeners.delete( listener ); + }, + + // User preferences — browser defaults. + async getUserPreferences(): Promise< UserPreferences > { + return { + editor: null, + terminal: null, + colorScheme: 'system', + locale: undefined, + }; + }, + async setUserPreferences() { + // No-op. + }, + async getInstalledApps(): Promise< InstalledApps > { + return {} as InstalledApps; + }, + + // Desks — defaults so both UI modes mount cleanly. + async getDeskSettings(): Promise< DeskSettings > { + return createDefaultDeskSettings(); + }, + async saveDeskSettings() { + // No-op. + }, + async exportDeskConfig(): Promise< string | null > { + return null; + }, + async importDeskConfig(): Promise< DeskConfig | null > { + return null; + }, + async getUserDeskConfig(): Promise< DeskConfig | undefined > { + return undefined; + }, + async saveUserDeskConfig() { + // No-op. + }, + async getSiteDeskConfig(): Promise< DeskConfig | undefined > { + return undefined; + }, + async saveSiteDeskConfig() { + // No-op. + }, + + async fetchSiteRest() { + throw new SecexUnsupportedError( 'fetchSiteRest' ); + }, + + async openSiteFolder() { + throw new SecexUnsupportedError( 'openSiteFolder' ); + }, + async openSiteInEditor() { + throw new SecexUnsupportedError( 'openSiteInEditor' ); + }, + async openSiteInTerminal() { + throw new SecexUnsupportedError( 'openSiteInTerminal' ); + }, + + async openExternalUrl( url ) { + window.open( url, '_blank', 'noopener,noreferrer' ); + }, + async openSiteUrl() { + throw new SecexUnsupportedError( 'openSiteUrl' ); + }, + + async isFullscreen() { + return false; + }, + onFullscreenChange() { + return () => {}; + }, + onSiteEvent() { + return () => {}; + }, + onToggleSitePreview() { + // No application menu in a browser tab. + return () => {}; + }, + }; +} diff --git a/apps/ui/src/data/core/connectors/secex/preview-blueprint.ts b/apps/ui/src/data/core/connectors/secex/preview-blueprint.ts new file mode 100644 index 0000000000..6b44f24c08 --- /dev/null +++ b/apps/ui/src/data/core/connectors/secex/preview-blueprint.ts @@ -0,0 +1,23 @@ +// Builds a WordPress Playground URL (a different origin) that renders a site +// client-side, Telex-style — no server in the sandbox. For a freshly created +// site this boots a clean WordPress so the preview honestly reflects the new, +// empty site (rather than canned demo content). Feeding the actual sandbox +// files into the preview is a later increment (needs a sandbox export). + +export function buildPreviewUrl( siteName?: string ): string { + const blueprint = { + landingPage: '/', + preferredVersions: { php: '8.3', wp: 'latest' }, + steps: [ + { + step: 'installTheme', + themeData: { resource: 'wordpress.org/themes', slug: 'twentytwentyfour' }, + options: { activate: true }, + }, + ...( siteName ? [ { step: 'setSiteOptions', options: { blogname: siteName } } ] : [] ), + ], + }; + // encodeURIComponent (not encodeURI) so `#`, `&`, `=` in the blueprint don't + // corrupt the URL fragment. + return 'https://playground.wordpress.net/#' + encodeURIComponent( JSON.stringify( blueprint ) ); +} diff --git a/apps/ui/src/data/core/connectors/web/index.ts b/apps/ui/src/data/core/connectors/web/index.ts index bb559cb643..82ac3ae488 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 } }; /** * Connector that talks to the headless `studio web-server` over HTTP + SSE. @@ -61,6 +63,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. @@ -111,6 +114,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 ) ); } }; }, @@ -307,6 +312,18 @@ export function createWebConnector( { apiBaseUrl }: WebConnectorOptions ): Conne return () => placementListeners.delete( listener ); }, + // The session workspace's deployable files, for the client-side Playground + // preview. The web-server reads them from the session's git workspace. + 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..632ff40569 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -51,6 +51,14 @@ export interface AiSessionPlacementUpdatedEvent { placement: AiSessionSitePlacement; } +// A single deployable file from a session's workspace: 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 (Studio Web "Carril A"). +export interface SitePreviewFile { + path: string; + contentBase64: string; +} + export interface SiteDetails { id: string; name: string; @@ -255,6 +263,14 @@ export interface Connector { listener: ( event: AiSessionPlacementUpdatedEvent ) => void ): () => void; + // The session workspace's deployable files, for a client-side Playground + // preview of what the agent built. Empty when the connector has no + // browser-previewable workspace for the session. + getSiteFiles( sessionId: string ): Promise< SitePreviewFile[] >; + // Fires when a session's workspace files change (e.g. after an agent turn), + // so the live preview can re-fetch and re-overlay. + 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/lib/wpcom-web-auth.ts b/apps/ui/src/lib/wpcom-web-auth.ts new file mode 100644 index 0000000000..0cb60f2bf7 --- /dev/null +++ b/apps/ui/src/lib/wpcom-web-auth.ts @@ -0,0 +1,96 @@ +import { DEFAULT_LOCALE } from '@studio/common/lib/locale'; +import { getAuthenticationUrl } from '@studio/common/lib/oauth'; + +// Browser-side WordPress.com OAuth for Studio Web's SecEx backend. +// +// Studio Code's `/studio-code/run` endpoint is called directly from the browser, +// so there's no local web-server to read the desktop's `~/.studio` token. Instead +// we run the same implicit OAuth flow the desktop uses (client 95109, scope +// `global`, `response_type=token`) but redirect back to the web origin and keep +// the resulting token in localStorage. +// +// NOTE: the OAuth app (client 95109) must list the web origin (e.g. +// `http://localhost:5300/`, and the production Studio Web origin) among its +// allowed redirect URIs, or WordPress.com rejects the redirect. + +const TOKEN_KEY = 'studio-web-wpcom-token'; + +interface StoredWebToken { + accessToken: string; + expiresAt: number; +} + +function redirectUri(): string { + return `${ window.location.origin }/`; +} + +function readStored(): StoredWebToken | null { + try { + const raw = window.localStorage.getItem( TOKEN_KEY ); + if ( ! raw ) { + return null; + } + const parsed = JSON.parse( raw ) as Partial< StoredWebToken >; + if ( typeof parsed.accessToken !== 'string' || typeof parsed.expiresAt !== 'number' ) { + return null; + } + return { accessToken: parsed.accessToken, expiresAt: parsed.expiresAt }; + } catch { + return null; + } +} + +// Reads `#access_token=…&expires_in=…` left in the URL after WordPress.com +// redirects back, stores it, and strips the fragment so the token never lingers +// in the address bar or in shareable links. Returns true when a token was found. +export function captureTokenFromHash(): boolean { + const hash = window.location.hash.startsWith( '#' ) + ? window.location.hash.slice( 1 ) + : window.location.hash; + if ( ! hash ) { + return false; + } + const params = new URLSearchParams( hash ); + const accessToken = params.get( 'access_token' ); + if ( ! accessToken ) { + return false; + } + const expiresIn = parseInt( params.get( 'expires_in' ) ?? '0', 10 ); + const expiresAt = Date.now() + ( expiresIn > 0 ? expiresIn : 1209600 ) * 1000; + try { + window.localStorage.setItem( TOKEN_KEY, JSON.stringify( { accessToken, expiresAt } ) ); + } catch { + // Ignore storage failures — the token is still usable for this load. + } + // Drop the fragment from the URL. + window.history.replaceState( null, '', window.location.pathname + window.location.search ); + return true; +} + +// Returns a non-expired token, or null. A one-minute skew keeps a soon-to-expire +// token from being handed to a long-running stream. +export function getStoredToken(): string | null { + const stored = readStored(); + if ( ! stored ) { + return null; + } + if ( stored.expiresAt - Date.now() < 60_000 ) { + clearStoredToken(); + return null; + } + return stored.accessToken; +} + +export function clearStoredToken(): void { + try { + window.localStorage.removeItem( TOKEN_KEY ); + } catch { + // Ignore. + } +} + +// Sends the browser to WordPress.com to authorize; it returns to the web origin +// with the token in the URL fragment (handled by captureTokenFromHash on boot). +export function beginLogin(): void { + window.location.assign( getAuthenticationUrl( DEFAULT_LOCALE, redirectUri() ) ); +} diff --git a/apps/ui/src/main.web.tsx b/apps/ui/src/main.web.tsx index 2ce397fe15..e5c03348fe 100644 --- a/apps/ui/src/main.web.tsx +++ b/apps/ui/src/main.web.tsx @@ -4,13 +4,18 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from '@/app'; import { seedDefaultUiMode } from '@/app/use-ui-mode'; +import { WpcomLoginScreen } from '@/components/wpcom-login-screen'; import { persistPromise } from '@/data/core'; +import { createSecexConnector } from '@/data/core/connectors/secex'; import { createWebConnector } from '@/data/core/connectors/web'; +import { beginLogin, captureTokenFromHash, getStoredToken } from '@/lib/wpcom-web-auth'; import type { Connector } from '@/data/core'; -// Web entry point. Identical to `main.tsx` except it wires the HTTP/SSE web -// connector instead of the Electron IPC connector, so the same React app runs -// in a plain browser tab against the `studio web-server` backend. +// Web entry point. Identical to `main.tsx` except it wires a browser connector +// instead of the Electron IPC connector, so the same React app runs in a plain +// browser tab. Two backends are selectable at build time via VITE_STUDIO_BACKEND: +// the default `studio web-server` (HTTP/SSE), or `secex` (the browser talks +// straight to the hosted wpcom Studio Code endpoint). async function loadTranslations( connector: Connector ) { const { locale } = await connector.getUserPreferences(); @@ -30,14 +35,56 @@ function getDefaultApiBaseUrl(): string { return import.meta.env.DEV ? 'http://localhost:8088' : window.location.origin; } +const isSecexMode = import.meta.env.VITE_STUDIO_BACKEND === 'secex'; + +// SecEx mode talks straight to the hosted endpoint from the browser, so it needs +// a WordPress.com token: prefer a real logged-in token, fall back to the +// build-time env var (handy for scripted runs). +function resolveSecexToken(): string { + return getStoredToken() ?? import.meta.env.VITE_STUDIO_WPCOM_TOKEN ?? ''; +} + +// SecEx mode (`VITE_STUDIO_BACKEND=secex`) talks straight to the hosted wpcom +// Studio Code endpoint from the browser — no local web-server. The default mode +// keeps the localhost web-server connector. +function createConnector( token: string ): Connector { + if ( isSecexMode ) { + return createSecexConnector( { + runUrl: + import.meta.env.VITE_STUDIO_SECEX_RUN_URL ?? + 'https://public-api.wordpress.com/wpcom/v2/studio-code/run', + token, + } ); + } + return createWebConnector( { + apiBaseUrl: import.meta.env.VITE_STUDIO_API_URL ?? getDefaultApiBaseUrl(), + } ); +} + +function renderLogin() { + createRoot( document.getElementById( 'root' )! ).render( + + + + ); +} + async function bootstrap() { // Studio Web defaults to the classic (agentic) UI — it uses real-path // routing that survives reloads/deep links. seedDefaultUiMode( 'classic' ); - const connector = createWebConnector( { - apiBaseUrl: import.meta.env.VITE_STUDIO_API_URL ?? getDefaultApiBaseUrl(), - } ); + // SecEx mode needs a WordPress.com token: pick up one left in the URL + // fragment after a redirect, and gate on login when there's none. + if ( isSecexMode ) { + captureTokenFromHash(); + if ( ! resolveSecexToken() ) { + renderLogin(); + return; + } + } + + const connector = createConnector( resolveSecexToken() ); await Promise.all( [ connector.init?.(), loadTranslations( connector ), persistPromise ] ); diff --git a/apps/ui/src/vite-env.d.ts b/apps/ui/src/vite-env.d.ts index 1abcbf9cae..ab298ef223 100644 --- a/apps/ui/src/vite-env.d.ts +++ b/apps/ui/src/vite-env.d.ts @@ -3,6 +3,13 @@ interface ImportMetaEnv { // Base URL of the `studio web-server` backend the web connector talks to. readonly VITE_STUDIO_API_URL?: string; + // Backend selector: 'secex' wires the hosted Studio Code endpoint connector + // (browser → wpcom /studio-code/run); anything else uses the web-server. + readonly VITE_STUDIO_BACKEND?: string; + // Full URL of the wpcom Studio Code run endpoint (SecEx mode). + readonly VITE_STUDIO_SECEX_RUN_URL?: string; + // WordPress.com OAuth Bearer forwarded to the endpoint (SecEx mode). + readonly VITE_STUDIO_WPCOM_TOKEN?: string; } interface ImportMeta {