Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0cf8264
Studio Panels: scaffold @studio/panels workspace package
youknowriad May 4, 2026
3b33eff
Studio Panels: add list and scratch routes
youknowriad May 4, 2026
8458ab7
Studio Panels: add agent tools and CLI installer/builder
youknowriad May 4, 2026
7b8ef5f
Studio Panels: bundle plugin into CLI dist
youknowriad May 4, 2026
fc6df44
Studio Panels: site preview Site/Panel toggle + suppress inspector on…
youknowriad May 4, 2026
e7b096f
Studio Panels: read URL params via @wordpress/route's useSearch in li…
youknowriad May 4, 2026
feebbbf
Studio Panels: add panel tools to the system prompt's tool list
youknowriad May 4, 2026
aa9b23a
Merge trunk into Studio Panels branch
youknowriad May 5, 2026
83764b7
apps/ui: SitePreview consumes SessionUI from context, not props
youknowriad May 5, 2026
31473de
apps/ui: keep both webviews mounted to preserve state across Site/Pan…
youknowriad May 5, 2026
766f960
apps/ui: hide WP admin bar in the site preview pane
youknowriad May 5, 2026
53d253a
apps/ui: cover mobile breakpoints when hiding the WP admin bar
youknowriad May 5, 2026
e1e60df
apps/ui: also reset wp-admin wrappers when hiding the admin bar
youknowriad May 5, 2026
c6aab5c
apps/ui: nuke admin bar via JS + add devtools shortcut for the webview
youknowriad May 5, 2026
ce9a25f
apps/ui: walk the body's top path and zero any top-space contribution
youknowriad May 5, 2026
f9c77f4
apps/ui: also zero top space on @wordpress/boot's single-page layout …
youknowriad May 5, 2026
2a49c19
tools/common: fill in missing tool display names + details
youknowriad May 5, 2026
5d6e527
apps/ui: layer CSS injection + retry-on-RAF for boot's async layout
youknowriad May 5, 2026
7326b91
Studio Panels: drop studio_show_panel; consolidate on studio_generate…
youknowriad May 5, 2026
3b5c5e4
apps/ui: zero positioning offsets too + add Inspect button to preview…
youknowriad May 5, 2026
f4880fa
Studio Panels: cleanup pass — drop debugging scaffolding and verbose …
youknowriad May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ function buildLocalIntro( options: { previewSteering: boolean } ): string {
const previewSteeringTools = options.previewSteering
? `
- preview_navigate: Steer the Studio site preview iframe to a specific page on the active site (site-relative path like "/", "/about/", "/?p=42"). Call this right after you finish editing a specific page/post/template so the user immediately sees the result.
- preview_reload: Reload the preview iframe at its current URL. Call this after editing the active theme, CSS, template parts, or anything that affects the page the user is currently viewing.`
- preview_reload: Reload the preview iframe at its current URL. Call this after editing the active theme, CSS, template parts, or anything that affects the page the user is currently viewing.
- studio_generate_panel: Generates a custom React panel that renders in the preview pane. This is the way to fulfil any request to view, list, edit, or manage the user's WordPress data — posts, pages, comments, users, settings, custom dashboards, anything. You write a complete TSX module with an exported \`stage\`; Studio bundles and ships it. See "Generate panels" below for the conventions.`
: '';

const previewSteeringSection = options.previewSteering
Expand All @@ -125,7 +126,20 @@ Call \`preview_navigate\` / \`preview_reload\` as a side effect of your editing
- After editing the homepage, front page template, or global theme assets (style.css, functions.php, template parts): call \`preview_reload\` (the user is most likely on "/").
- After editing or creating a specific page or post: call \`preview_navigate\` with that page's path (e.g. \`/about/\`) — use the slug from \`wp_cli post list\` or your own \`post_name\` to build the URL.
- After editing a single template like \`single-product.php\` or a CPT page: navigate to an example URL that uses that template.
- Do not call these tools on a remote WordPress.com site.`
- Do not call these tools on a remote WordPress.com site.

## Generate panels

When the user wants to view or interact with their site's data — list posts, edit settings, see a dashboard, anything generative-UI shaped — call \`studio_generate_panel\`. Don't dump a \`wp_cli post list\` table into chat; render the answer as a real screen in the preview pane.

Conventions every generated panel must follow:

1. **Listing entities** (posts, pages, comments, users, CPTs, terms): use \`<DataViews>\` from \`@wordpress/dataviews\`. Drive it with \`useSelect\` + \`getEntityRecords\` from \`@wordpress/core-data\`. Define a \`fields\` array and a \`view\` state; wire \`onChangeView\` so the user can sort, filter, and paginate.
2. **Forms** (settings, single-record edit): use \`<DataForm>\` from \`@wordpress/dataviews\`. Read with \`getEntityRecord\` (e.g. \`("root", "site")\` for site settings, \`("postType", "<slug>", id)\` for a single record). Save through \`dispatch( coreStore ).saveEntityRecord(...)\` or \`saveEditedEntityRecord\`.
3. **Dashboards / multi-source views / bespoke layouts**: compose \`@wordpress/components\` (Card, CardBody, Flex, FlexItem, Notice, Button) with the same core-data fetching pattern.
4. **Client↔server communication always goes through \`@wordpress/core-data\`** — \`useSelect\` + \`getEntityRecord(s)\` for reads, \`dispatch\` + \`saveEntityRecord\` for writes. Do NOT use \`apiFetch\` directly unless the data is genuinely outside the entity model.
5. **Need data not in core REST?** Extend the API: write a small mu-plugin at \`<sitePath>/wp-content/mu-plugins/studio-extend.php\` that registers your endpoint via \`register_rest_route()\` (or registers a CPT with \`show_in_rest: true\`). Then read it via \`getEntityRecords\` against the new route.
6. **Imports must come from \`@wordpress/*\` only** (externalized to \`wp.*\` globals at runtime). Use \`@wordpress/element\` for hooks (\`useState\`/\`useEffect\`/\`useMemo\`) and \`@wordpress/i18n\` (\`__()\`) for translatable strings. No lodash, axios, react-query, etc.`
: '';

return `${ AGENT_IDENTITY } You manage and modify local WordPress sites using your Studio tools and generate content for these sites.
Expand Down
170 changes: 170 additions & 0 deletions apps/cli/ai/tools/generate-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod/v4';
import { emitEvent } from 'cli/ai/json-events';
import { runCommand as runStartSiteCommand } from 'cli/commands/site/start';
import { disconnectFromDaemon } from 'cli/lib/daemon-client';
import { generateAndDeployScratchPanel } from 'cli/lib/studio-panels-builder';
import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager';
import { errorResult, resolveSite, textResult } from './utils';

const PLUGIN_BASENAME = 'studio-panels/studio-panels.php';
const GUTENBERG_BASENAME = 'gutenberg/gutenberg.php';
const SCRATCH_DEEP_LINK = '/scratch';
const PANEL_PAGE_SLUG = 'studio-panels-wp-admin';

// Cheap structural checks so the agent gets a fixable error before paying
// for a wp-build round-trip on broken source.
export function validateScratchSource(
source: string
): { ok: true } | { ok: false; errors: string[] } {
const errors: string[] = [];

if ( ! source || source.trim().length === 0 ) {
return { ok: false, errors: [ 'Source is empty.' ] };
}

if (
! /\bexport\s+(?:function|const|let|var)\s+stage\b/.test( source ) &&
! /\bexport\s*\{\s*[^}]*\bstage\b[^}]*\}/.test( source )
) {
errors.push(
'Source must export a `stage` value (wp-build route convention). ' +
'Example: `export const stage = () => <div>…</div>;`'
);
}

const importLines = source.match( /^\s*import\b.*$/gm ) ?? [];
for ( const line of importLines ) {
const fromMatch = line.match( /from\s+['"]([^'"]+)['"]/ );
if ( ! fromMatch ) continue;
const spec = fromMatch[ 1 ];
if ( ! spec.startsWith( '@wordpress/' ) ) {
errors.push(
`Disallowed import: \`${ spec }\`. Only \`@wordpress/*\` packages are externalized; ` +
`anything else won't resolve at runtime.`
);
}
}

return errors.length === 0 ? { ok: true } : { ok: false, errors };
}

async function ensureSiteRunningAndPluginActive( site: {
id: string;
path: string;
} ): Promise< void > {
if ( ! ( await isServerRunning( site.id ) ) ) {
await runStartSiteCommand( site.path, /* skipBrowser */ true, /* skipLogDetails */ true );
}
try {
await sendWpCliCommand( site.id, [
'plugin',
'install',
'gutenberg',
'--activate',
'--quiet',
] );
} catch {
// Non-fatal — surfaced as an admin notice in the panel page if Gutenberg is truly absent.
}
try {
await sendWpCliCommand( site.id, [
'plugin',
'activate',
PLUGIN_BASENAME,
GUTENBERG_BASENAME,
] );
} catch {
// Non-fatal — manual activation still works.
}
}

export const generatePanelTool = tool(
'studio_generate_panel',
'Generates a custom React panel rendered in the Studio site preview pane. This is the way ' +
'to fulfil any "show me / list / display / edit" request involving the user\'s WordPress ' +
'data — list of posts/pages/comments/users/CPTs, edit forms, settings screens, ' +
'dashboards, anything else. Write a complete TSX module exporting a `stage` value ' +
'(wp-build route convention); Studio bundles it via @wordpress/build (~200ms) and renders ' +
'it inside the studio-panels wp-admin page.\n\n' +
'CONVENTIONS — follow these so panels stay consistent and idiomatic:\n' +
'1. LISTING entities (posts, pages, comments, users, CPTs, terms): use <DataViews> from ' +
'@wordpress/dataviews. Drive it with `useSelect` + `getEntityRecords` from ' +
'@wordpress/core-data. Define `fields` and a `view` state, wire `onChangeView` so the ' +
'user can sort/filter/paginate.\n' +
'2. FORMS (settings, single-record edit): use <DataForm> from @wordpress/dataviews. Read ' +
'the record with `getEntityRecord` and save via `dispatch(coreStore).saveEntityRecord` / ' +
'`saveEditedEntityRecord`. For site settings, the entity is `("root", "site")`.\n' +
'3. DASHBOARDS / multi-source / bespoke layouts: compose @wordpress/components (Card, ' +
'CardBody, Flex, FlexItem, Notice, Button) with the same core-data fetching pattern.\n' +
'4. CLIENT↔SERVER COMMUNICATION: always go through @wordpress/core-data ' +
'(useSelect + getEntityRecord(s) for reads, dispatch + saveEntityRecord for writes). Do ' +
'NOT call `apiFetch` directly unless the data is genuinely outside the entity model.\n' +
'5. NEED DATA NOT IN CORE REST? Extend the API: write a small mu-plugin at ' +
'`<sitePath>/wp-content/mu-plugins/studio-extend.php` that registers your endpoint via ' +
'`register_rest_route` (or registers a custom post type with `show_in_rest: true`). ' +
'Then the panel can read it via `getEntityRecords` against the new route.\n' +
'6. IMPORTS must come from `@wordpress/*` only (externalized to `wp.*` globals). No ' +
'lodash, axios, react-query, etc. Use `useState/useEffect/useMemo` from ' +
'@wordpress/element and `__()` from @wordpress/i18n for translatable strings.',
{
nameOrPath: z
.string()
.describe( 'The site name or file system path of the local site to render the panel into.' ),
source: z
.string()
.min( 1 )
.describe(
'Complete TSX source for the panel. Must export a `stage` value: ' +
'`export const stage = () => <div>…</div>;`. Imports must come from `@wordpress/*` ' +
'packages only.'
),
summary: z
.string()
.optional()
.describe(
'Optional one-line description of what the panel does, included in the response.'
),
},
async ( args ) => {
const validation = validateScratchSource( args.source );
if ( ! validation.ok ) {
return errorResult( 'Source validation failed:\n - ' + validation.errors.join( '\n - ' ) );
}

try {
const site = await resolveSite( args.nameOrPath );
const result = await generateAndDeployScratchPanel( {
source: args.source,
sitePath: site.path,
} );
try {
await ensureSiteRunningAndPluginActive( site );

const url = `/wp-admin/admin.php?page=${ PANEL_PAGE_SLUG }&p=${ encodeURIComponent(
SCRATCH_DEEP_LINK
) }`;
const navigatePath = `/studio-auto-login?redirect_to=${ encodeURIComponent( url ) }`;
emitEvent( {
type: 'preview.command',
timestamp: new Date().toISOString(),
kind: 'navigate',
path: navigatePath,
} );

const summary = args.summary ? ` — ${ args.summary }` : '';
return textResult(
`Generated scratch panel${ summary } and deployed to ${ site.name } in ${ result.durationMs }ms (v${ result.bumpedVersion }).`
);
} finally {
// Daemon bus stays open after `sendWpCliCommand`; close it so
// the agent child can exit and the UI's `winding_down` phase ends.
await disconnectFromDaemon().catch( () => undefined );
}
} catch ( error ) {
return errorResult(
`Failed to generate panel: ${ error instanceof Error ? error.message : String( error ) }`
);
}
}
);
11 changes: 9 additions & 2 deletions apps/cli/ai/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createSiteTool } from './create-site';
import { deletePreviewTool } from './delete-preview';
import { deleteSiteTool } from './delete-site';
import { exportSiteTool } from './export-site';
import { generatePanelTool } from './generate-panel';
import { importSiteTool } from './import-site';
import { installTaxonomyScriptsTool } from './install-taxonomy-scripts';
import { listConnectedRemoteSitesTool } from './list-connected-remote-sites';
Expand All @@ -29,11 +30,17 @@ import { runWpCliTool } from './wp-cli';
import { createWpcomRequestTool } from './wpcom-request';

export { captureCommandOutput } from './utils';
export { generatePanelTool, validateScratchSource } from './generate-panel';

// Preview-steering tools only belong in the toolset when the Studio desktop UI
// is on the other end of the IPC channel — outside of that, navigate/reload
// calls render as noise in the terminal transcript.
const previewSteeringToolDefinitions = [ previewNavigateTool, previewReloadTool ];
// calls render as noise in the terminal transcript. studio_generate_panel
// also emits `preview.command` events, so it belongs here too.
const previewSteeringToolDefinitions = [
previewNavigateTool,
previewReloadTool,
generatePanelTool,
];

export const studioToolDefinitions = [
createSiteTool,
Expand Down
111 changes: 111 additions & 0 deletions apps/cli/lib/studio-panels-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { exec } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';
import { promisify } from 'util';
import { ensureStudioPanelsInstalled } from './studio-panels-installer';

const execAsync = promisify( exec );

// On-the-fly panel generation: the agent writes TSX into the scratch route's
// source file, this module rebuilds the plugin via wp-build, and the
// installer pushes the rebuilt artifact to the site. Dev-mode only — needs
// the apps/studio-panels/ source tree on disk and writable; a packaged CLI
// build does not include it.

const SCRATCH_RELATIVE_SOURCE = 'routes/scratch/stage.tsx';

interface BuildResult {
bumpedVersion: string;
durationMs: number;
}

// Walks up from the runtime location until it finds `apps/studio-panels/`'s
// package.json. Returns null if the source tree isn't present.
function findStudioPanelsSourceDir( startDir: string = import.meta.dirname ): string | null {
let current = path.resolve( startDir );
const root = path.parse( current ).root;
while ( current !== root ) {
const candidate = path.join( current, 'apps', 'studio-panels', 'package.json' );
if ( existsSync( candidate ) ) {
try {
const pkg = JSON.parse( readFileSync( candidate, 'utf8' ) ) as { name?: string };
if ( pkg.name === '@studio/panels' ) {
return path.dirname( candidate );
}
} catch {
// fall through and keep walking
}
}
current = path.dirname( current );
}
return null;
}

export class ScratchSourceTreeMissingError extends Error {
constructor() {
super(
'Could not find apps/studio-panels/ source tree. ' +
'On-the-fly panel generation requires the monorepo source to be writable. ' +
'This tool only works in development.'
);
this.name = 'ScratchSourceTreeMissingError';
}
}

// Append `-scratch.<n>` to the base version, incrementing on each regen so
// the installer always sees a newer version.
function bumpScratchVersion( base: string ): string {
const match = base.match( /^(.+?)-scratch\.(\d+)$/ );
if ( match ) {
return `${ match[ 1 ] }-scratch.${ Number( match[ 2 ] ) + 1 }`;
}
return `${ base }-scratch.1`;
}

function readVersion( file: string ): string | null {
try {
return readFileSync( file, 'utf8' ).trim() || null;
} catch {
return null;
}
}

export async function generateAndDeployScratchPanel( {
source,
sitePath,
}: {
source: string;
sitePath: string;
} ): Promise< BuildResult > {
const panelsDir = findStudioPanelsSourceDir();
if ( ! panelsDir ) {
throw new ScratchSourceTreeMissingError();
}

const sourceFile = path.join( panelsDir, SCRATCH_RELATIVE_SOURCE );
if ( ! existsSync( path.dirname( sourceFile ) ) ) {
throw new Error( `Scratch source directory not found: ${ path.dirname( sourceFile ) }` );
}

await writeFile( sourceFile, source, 'utf8' );

const versionFile = path.join( panelsDir, 'version.txt' );
const bumpedVersion = bumpScratchVersion( readVersion( versionFile ) || '0.1.0' );

const t0 = Date.now();
try {
await execAsync( 'npm run build', { cwd: panelsDir, env: process.env } );
} catch ( err: unknown ) {
const message = err instanceof Error ? err.message : String( err );
throw new Error( `wp-build failed:\n${ message }` );
}

// Overwrite the version.txt that wp-build emitted so the installer sees a
// new release and replaces the on-site copy.
await writeFile( versionFile, bumpedVersion, 'utf8' );

await ensureStudioPanelsInstalled( sitePath, panelsDir );

return { bumpedVersion, durationMs: Date.now() - t0 };
}
51 changes: 51 additions & 0 deletions apps/cli/lib/studio-panels-installer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { cp, mkdir, readFile, rm } from 'fs/promises';
import path from 'path';

const PLUGIN_DIR_NAME = 'studio-panels';

// Bundled by `viteStaticCopy` (see vite.config.dev/prod/npm) as a sibling of
// the CLI modules in dist/.
const BUNDLED_PLUGIN_DIR = path.resolve( import.meta.dirname, PLUGIN_DIR_NAME );

interface InstallResult {
installed: boolean;
previousVersion: string | null;
currentVersion: string;
}

async function readVersion( versionFile: string ): Promise< string | null > {
try {
const contents = await readFile( versionFile, 'utf8' );
return contents.trim() || null;
} catch {
return null;
}
}

// `sourceDir` defaults to the bundled CLI dist; `studio-panels-builder` passes
// the monorepo source root for the on-the-fly path.
export async function ensureStudioPanelsInstalled(
sitePath: string,
sourceDir: string = BUNDLED_PLUGIN_DIR
): Promise< InstallResult > {
const targetDir = path.join( sitePath, 'wp-content', 'plugins', PLUGIN_DIR_NAME );
const currentVersion = await readVersion( path.join( sourceDir, 'version.txt' ) );

if ( ! currentVersion ) {
throw new Error(
`Studio Panels plugin missing version.txt at ${ sourceDir }. Did the build complete?`
);
}

const previousVersion = await readVersion( path.join( targetDir, 'version.txt' ) );
if ( previousVersion === currentVersion ) {
return { installed: false, previousVersion, currentVersion };
}

// Replace wholesale so removed routes from a previous version don't linger.
await rm( targetDir, { recursive: true, force: true } );
await mkdir( path.dirname( targetDir ), { recursive: true } );
await cp( sourceDir, targetDir, { recursive: true } );

return { installed: true, previousVersion, currentVersion };
}
Loading
Loading