diff --git a/apps/mesh/index.css b/apps/mesh/index.css index b62e6cde0c..c957c64d1e 100644 --- a/apps/mesh/index.css +++ b/apps/mesh/index.css @@ -145,4 +145,27 @@ transform: translateY(0); } } + + @keyframes mise-breathe { + 0%, + 100% { + transform: scale(1) translate3d(0, 0, 0); + opacity: 0.8; + } + 50% { + transform: scale(1.04) translate3d(0, -10px, 0); + opacity: 1; + } + } + + @keyframes mise-panel-in { + 0% { + opacity: 0; + transform: translateY(18px) scale(0.985); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } + } } diff --git a/apps/mesh/package.json b/apps/mesh/package.json index a9a5bfd92c..b9aec634e8 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -65,6 +65,7 @@ "ai-sdk-provider-claude-code": "^3.4.4", "ai-sdk-provider-codex-cli": "^1.1.0", "embedded-postgres": "^18.3.0-beta.16", + "fflate": "^0.8.0", "ink": "^6.8.0", "kysely": "^0.28.12", "nats": "^2.29.3", diff --git a/apps/mesh/scripts/test-page-preview-mcp.ts b/apps/mesh/scripts/test-page-preview-mcp.ts new file mode 100644 index 0000000000..747e5074ed --- /dev/null +++ b/apps/mesh/scripts/test-page-preview-mcp.ts @@ -0,0 +1,280 @@ +/** + * Closed-loop test for the Page Editor virtual MCP, full flow. + * + * Mints a fresh API key (the same way dispatch-run does), then drives the + * live dev server's /mcp/virtual-mcp/ endpoint to: + * 1. initialize + * 2. tools/list — assert design-system + page-create tools exist + * 3. DESIGN_SYSTEM_CREATE — scaffold a design system instantly + * 4. PAGE_PREVIEW_PAGE_CREATE — scaffold a page bound to it + * 5. PAGE_PREVIEW_REFRESH — bump version + * 6. GET /api//page-preview/export?kind=page&slug=... — validate zip + * + * Run with the dev server already up: + * bun run apps/mesh/scripts/test-page-preview-mcp.ts + */ + +import { auth } from "../src/auth/index"; + +const SERVER = process.env.MCP_SERVER ?? "http://localhost:3001"; +const ORG_ID = process.env.ORG_ID ?? "qI79UDgd5ine21jyRaNFZb2XxR4jknBd"; +const ORG_SLUG = process.env.ORG_SLUG ?? "guilherme-local"; +const AGENT_ID = process.env.AGENT_ID ?? "vir_mi8wsIteDBpzmeRaNa4gO"; +const USER_ID = process.env.USER_ID ?? "RTM5qUesVqB30TH8Ey2crCTTTuqfM1xH"; + +async function mintApiKey(): Promise { + const result = await auth.api.createApiKey({ + body: { + name: "closed-loop-test", + expiresIn: 600, + userId: USER_ID, + permissions: { self: ["*"] }, + metadata: { + organization: { id: ORG_ID, slug: ORG_SLUG, name: "Guilherme Local" }, + }, + } as never, + }); + // biome-ignore lint/suspicious/noExplicitAny: SDK type is loose + const key = (result as any).key as string; + if (!key) throw new Error("createApiKey returned no key"); + return key; +} + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: number | string; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}; + +function mcpHeaders( + apiKey: string, + sessionId?: string, +): Record { + const h: Record = { + Authorization: `Bearer ${apiKey}`, + "x-org-id": ORG_ID, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (sessionId) h["Mcp-Session-Id"] = sessionId; + return h; +} + +async function mcpCall( + apiKey: string, + body: Record, + sessionId?: string, +): Promise<{ body: JsonRpcResponse; sessionId: string | null }> { + const res = await fetch(`${SERVER}/mcp/virtual-mcp/${AGENT_ID}`, { + method: "POST", + headers: mcpHeaders(apiKey, sessionId), + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error( + `HTTP ${res.status} ${res.statusText} for ${JSON.stringify(body).slice(0, 80)}`, + ); + } + const sid = res.headers.get("Mcp-Session-Id"); + const ct = res.headers.get("content-type") ?? ""; + let parsed: JsonRpcResponse; + if (ct.includes("text/event-stream")) { + const text = await res.text(); + const match = text.match(/^data:\s*(\{.*\})\s*$/m); + if (!match) throw new Error(`No SSE data frame in response:\n${text}`); + parsed = JSON.parse(match[1]!) as JsonRpcResponse; + } else { + parsed = (await res.json()) as JsonRpcResponse; + } + return { body: parsed, sessionId: sid }; +} + +async function main() { + console.log(`[test] Server=${SERVER} agent=${AGENT_ID} org=${ORG_ID}`); + + const apiKey = await mintApiKey(); + console.log(`[test] Minted apiKey prefix=${apiKey.slice(0, 8)}...`); + + // 1. initialize + const init = await mcpCall(apiKey, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + clientInfo: { name: "closed-loop-test", version: "1.0.0" }, + }, + }); + if (init.body.error) + throw new Error(`initialize failed: ${init.body.error.message}`); + const sessionId = init.sessionId; + console.log(`[test] initialize OK; session=${sessionId ?? ""}`); + + await fetch(`${SERVER}/mcp/virtual-mcp/${AGENT_ID}`, { + method: "POST", + headers: mcpHeaders(apiKey, sessionId ?? undefined), + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + }); + + // 2. tools/list — find the namespaced tool names we need + const list = await mcpCall( + apiKey, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + sessionId ?? undefined, + ); + if (list.body.error) + throw new Error(`tools/list failed: ${list.body.error.message}`); + // biome-ignore lint/suspicious/noExplicitAny: ad-hoc JSON-RPC payload + const tools = ((list.body.result as any)?.tools ?? []) as Array<{ + name: string; + }>; + console.log(`[test] tools/list returned ${tools.length} tools`); + + function resolveTool(suffix: string): string { + const exact = tools.find((t) => t.name === suffix); + if (exact) return exact.name; + const suff = tools.find((t) => t.name.endsWith(`_${suffix}`)); + if (suff) return suff.name; + throw new Error(`tool not exposed: ${suffix}`); + } + + const T = { + DESIGN_SYSTEM_CREATE: resolveTool("DESIGN_SYSTEM_CREATE"), + PAGE_PREVIEW_PAGE_CREATE: resolveTool("PAGE_PREVIEW_PAGE_CREATE"), + PAGE_PREVIEW_REFRESH: resolveTool("PAGE_PREVIEW_REFRESH"), + PAGE_PREVIEW_STATUS: resolveTool("PAGE_PREVIEW_STATUS"), + }; + console.log("[test] Tool name resolution:"); + for (const [k, v] of Object.entries(T)) console.log(` ${k} -> ${v}`); + + async function callTool( + name: string, + args: Record, + id: number, + ) { + const res = await mcpCall( + apiKey, + { + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name, arguments: args }, + }, + sessionId ?? undefined, + ); + if (res.body.error) + throw new Error( + `${name} failed: ${res.body.error.message}\n full=${JSON.stringify(res.body.error)}`, + ); + // biome-ignore lint/suspicious/noExplicitAny: ad-hoc JSON-RPC payload + const result = res.body.result as any; + if (result?.isError) { + throw new Error( + `${name} returned isError: ${JSON.stringify(result.content).slice(0, 200)}`, + ); + } + return result?.structuredContent ?? result; + } + + const dsSlug = `closedloop-${Date.now().toString(36)}`; + const dsResult = await callTool( + T.DESIGN_SYSTEM_CREATE, + { + slug: dsSlug, + name: "Closed Loop DS", + brand: { primary: "#22D3EE", accent: "#F472B6", name: "Closed Loop" }, + }, + 3, + ); + if (dsResult?.slug !== dsSlug) + throw new Error(`DESIGN_SYSTEM_CREATE slug mismatch: ${dsResult?.slug}`); + if (dsResult?.status?.activeKind !== "design-system") + throw new Error( + `DESIGN_SYSTEM_CREATE did not activate as design-system: ${dsResult?.status?.activeKind}`, + ); + console.log(`[test] DESIGN_SYSTEM_CREATE OK slug=${dsSlug}`); + + const pageSlug = `closedloop-page-${Date.now().toString(36)}`; + const pageResult = await callTool( + T.PAGE_PREVIEW_PAGE_CREATE, + { + slug: pageSlug, + designSystem: dsSlug, + title: "Closed Loop Page", + description: "scaffolded by closed-loop test", + // Verify the new behavior: page exists but preview stays on the DS. + }, + 4, + ); + if (pageResult?.slug !== pageSlug) + throw new Error(`PAGE_CREATE slug mismatch: ${pageResult?.slug}`); + if (pageResult?.status?.activeKind !== "design-system") + throw new Error( + `PAGE_CREATE should leave preview on design system; got ${pageResult?.status?.activeKind}`, + ); + const created = pageResult?.status?.pages?.find( + (p: { slug: string }) => p.slug === pageSlug, + ); + if (!created) + throw new Error(`PAGE_CREATE did not record the page in status.pages`); + if (created.designSystem !== dsSlug) + throw new Error(`PAGE_CREATE binding mismatch: ${created.designSystem}`); + console.log( + `[test] PAGE_PREVIEW_PAGE_CREATE OK slug=${pageSlug} (preview stays on DS)`, + ); + + const refreshBefore = pageResult.status.refreshVersion; + const refreshResult = await callTool(T.PAGE_PREVIEW_REFRESH, {}, 5); + if (refreshResult?.refreshVersion <= refreshBefore) + throw new Error( + `REFRESH did not bump version (${refreshBefore} -> ${refreshResult?.refreshVersion})`, + ); + console.log( + `[test] PAGE_PREVIEW_REFRESH OK ${refreshBefore} -> ${refreshResult?.refreshVersion}`, + ); + + // Verify export endpoint returns a zip + const exportRes = await fetch( + `${SERVER}/api/${ORG_SLUG}/page-preview/export?kind=page&slug=${pageSlug}`, + { headers: { Authorization: `Bearer ${apiKey}`, "x-org-id": ORG_ID } }, + ); + if (!exportRes.ok) + throw new Error(`export endpoint returned HTTP ${exportRes.status}`); + const ct = exportRes.headers.get("content-type"); + if (!ct?.includes("application/zip")) + throw new Error(`export content-type unexpected: ${ct}`); + const buf = new Uint8Array(await exportRes.arrayBuffer()); + // ZIP files start with PK\x03\x04 + if (buf[0] !== 0x50 || buf[1] !== 0x4b || buf[2] !== 0x03 || buf[3] !== 0x04) + throw new Error( + `export bytes are not a valid zip header: ${buf.slice(0, 4).join(",")}`, + ); + console.log(`[test] export endpoint OK; ${buf.byteLength} bytes`); + + // Status reflects the new page + design system + const statusResult = await callTool(T.PAGE_PREVIEW_STATUS, {}, 6); + const hasDs = statusResult?.designSystems?.some( + (d: { slug: string }) => d.slug === dsSlug, + ); + const hasPage = statusResult?.pages?.some( + (p: { slug: string }) => p.slug === pageSlug, + ); + if (!hasDs || !hasPage) + throw new Error( + `STATUS missing entries (ds=${hasDs} page=${hasPage}): ${JSON.stringify(statusResult).slice(0, 300)}`, + ); + console.log(`[test] PAGE_PREVIEW_STATUS reflects both entries`); + + console.log("\n[test] PASS — full Page Editor flow works end-to-end"); + process.exit(0); +} + +main().catch((err) => { + console.error("[test] ERROR:", err); + process.exit(1); +}); diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index b24218678d..be494fbf16 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -925,8 +925,16 @@ export async function createApp(options: CreateAppOptions = {}) { app.use("*", async (c, next) => { await next(); - c.header("X-Frame-Options", "DENY"); - c.header("Content-Security-Policy", "frame-ancestors 'none'"); + const path = c.req.path; + const isPagePreviewFramable = + /^\/api\/[^/]+\/page-preview\/(files|host)(\/|$)/.test(path); + if (isPagePreviewFramable) { + c.header("X-Frame-Options", "SAMEORIGIN"); + c.header("Content-Security-Policy", "frame-ancestors 'self'"); + } else { + c.header("X-Frame-Options", "DENY"); + c.header("Content-Security-Policy", "frame-ancestors 'none'"); + } }); if (!getSettings().noTui) { diff --git a/apps/mesh/src/api/routes/org-scoped.ts b/apps/mesh/src/api/routes/org-scoped.ts index 172627b59e..e6657d979f 100644 --- a/apps/mesh/src/api/routes/org-scoped.ts +++ b/apps/mesh/src/api/routes/org-scoped.ts @@ -11,6 +11,7 @@ import { createDevAssetsRoutes } from "./dev-assets"; import { createDownstreamTokenRoutes } from "./downstream-token"; import { createKVRoutes } from "./kv"; import { createOrgScopedWellKnownProtectedResourceRoutes } from "./oauth-proxy"; +import { createPagePreviewRoutes } from "./page-preview"; import { createSsoRoutes } from "./org-sso"; import { createProxyRoutes } from "./proxy"; import { createSelfRoutes } from "./self"; @@ -68,6 +69,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { app.route("/vm-exec", createVmExecRoutes()); // /api/:org/vm-exec/{exec,kill}/:script app.route("/vm-setup", createVmSetupRoutes()); // /api/:org/vm-setup/:step app.route("/deco-sites", createDecoSitesOrgRoutes()); // /api/:org/deco-sites + app.route("/page-preview", createPagePreviewRoutes()); // /api/:org/page-preview app.route("/sso", createSsoRoutes()); // /api/:org/sso/* (renamed from /api/org-sso) app.route( "/", diff --git a/apps/mesh/src/api/routes/page-preview.ts b/apps/mesh/src/api/routes/page-preview.ts new file mode 100644 index 0000000000..988974ece5 --- /dev/null +++ b/apps/mesh/src/api/routes/page-preview.ts @@ -0,0 +1,166 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { zip } from "fflate"; +import type { MeshContext } from "@/core/mesh-context"; +import { + buildDesignSystemExportBundle, + buildPageExportBundle, + getPagePreviewStatus, + resolvePagePreviewAsset, +} from "@/page-preview/service"; +import { PAGE_PREVIEW_HOST_HTML } from "@/page-preview/host-html"; + +type Variables = { meshContext: MeshContext }; + +function getContentType(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + const types: Record = { + html: "text/html; charset=utf-8", + htm: "text/html; charset=utf-8", + js: "application/javascript; charset=utf-8", + mjs: "application/javascript; charset=utf-8", + css: "text/css; charset=utf-8", + json: "application/json; charset=utf-8", + svg: "image/svg+xml", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + ico: "image/x-icon", + woff: "font/woff", + woff2: "font/woff2", + ttf: "font/ttf", + otf: "font/otf", + }; + return types[ext] ?? "application/octet-stream"; +} + +function routeBaseUrl(reqUrl: string): string { + const url = new URL(reqUrl); + return `${url.protocol}//${url.host}`; +} + +export function createPagePreviewRoutes() { + const app = new Hono<{ Variables: Variables }>(); + + // The Studio-controlled host iframe. The preview pane loads this once + // and drives transitions via postMessage; the host dynamically imports + // the page's chunks from /files/... to render in place. + app.get("/host", () => { + return new Response(PAGE_PREVIEW_HOST_HTML, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + }); + + app.get("/state", async (c) => { + const ctx = c.get("meshContext"); + const org = ctx.organization; + if (!org?.id) { + throw new HTTPException(401, { + message: "Organization context required", + }); + } + + return c.json( + await getPagePreviewStatus({ + orgId: org.id, + orgSlug: org.slug ?? c.req.param("org"), + baseUrl: routeBaseUrl(c.req.url), + }), + ); + }); + + app.get("/files/*", async (c) => { + const ctx = c.get("meshContext"); + const org = ctx.organization; + if (!org?.id) { + throw new HTTPException(401, { + message: "Organization context required", + }); + } + + const prefix = `/api/${c.req.param("org") ?? ""}/page-preview/files/`; + const rawPath = c.req.path.replace(prefix, ""); + let filePath: string; + try { + filePath = decodeURIComponent(rawPath); + } catch { + throw new HTTPException(400, { message: "Invalid file path" }); + } + + try { + const resolved = await resolvePagePreviewAsset({ + orgId: org.id, + path: filePath, + }); + const file = Bun.file(resolved.absolutePath); + return new Response(file.stream(), { + headers: { + "Content-Type": getContentType(resolved.absolutePath), + "Content-Length": file.size.toString(), + "Cache-Control": "no-store", + }, + }); + } catch { + throw new HTTPException(404, { message: "File not found" }); + } + }); + + app.get("/export", async (c) => { + const ctx = c.get("meshContext"); + const org = ctx.organization; + if (!org?.id) { + throw new HTTPException(401, { + message: "Organization context required", + }); + } + + const kind = c.req.query("kind"); + const slug = c.req.query("slug"); + if (kind !== "page" && kind !== "design-system") { + throw new HTTPException(400, { + message: "kind must be 'page' or 'design-system'", + }); + } + if (!slug) { + throw new HTTPException(400, { message: "slug query param required" }); + } + + let bundle: Awaited>; + try { + bundle = + kind === "page" + ? await buildPageExportBundle({ orgId: org.id, slug }) + : await buildDesignSystemExportBundle({ orgId: org.id, slug }); + } catch (err) { + throw new HTTPException(404, { message: (err as Error).message }); + } + const { bundleName, files } = bundle; + const zipInput: Record = {}; + for (const file of files) { + zipInput[`${bundleName}/${file.relativePath}`] = file.data; + } + + const archive = await new Promise((resolve, reject) => { + zip(zipInput, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + return new Response(archive as BodyInit, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${bundleName}.zip"`, + "Content-Length": archive.byteLength.toString(), + "Cache-Control": "no-store", + }, + }); + }); + + return app; +} diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index 57d5534016..4eb2539af6 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -18,8 +18,10 @@ import { Context, Hono } from "hono"; import { endTime, startTime } from "hono/timing"; import type { MeshContext } from "../../core/mesh-context"; import { managementMCP } from "../../tools"; +import { usesLocalObjectStorage } from "../../tools/connection/dev-assets"; import { guardResponseStream } from "../utils/stream-guard"; import { handleAuthError } from "./oauth-proxy"; +import { handleDevAssetsMcpRequest } from "./dev-assets-mcp"; import { handleVirtualMcpRequest } from "./virtual-mcp"; export { toServerClient, type MCPProxyClient } from "./mcp-proxy-factory"; @@ -88,6 +90,27 @@ export const createProxyRoutes = () => { return guardResponseStream(selfResponse, `mcp:self:${connectionId}`); } + // Dev-assets pseudo-connection ({orgId}_dev-assets) — only active when + // object storage is the local filesystem fallback. Mirrors the + // unscoped /mcp/{connectionId}_dev-assets route registered in dev-only.ts + // so frontend code using the canonical /api/:org/mcp/ URL still + // reaches the dev-assets MCP server in dev mode. + if (connectionId.endsWith("_dev-assets")) { + const devOrgId = connectionId.slice(0, -"_dev-assets".length); + if (!ctx.organization || ctx.organization.id !== devOrgId) { + return c.json({ error: "Connection not found" }, 404); + } + if (!usesLocalObjectStorage()) { + return c.json( + { error: "dev-assets is only available in local mode" }, + 404, + ); + } + const url = new URL(c.req.url); + const baseUrl = `${url.protocol}//${url.host}`; + return handleDevAssetsMcpRequest(c.req.raw, ctx, baseUrl); + } + try { try { // Organization context is required — without it the ownership diff --git a/apps/mesh/src/mcp-clients/client.ts b/apps/mesh/src/mcp-clients/client.ts index 1007f6ca5e..e498b0417f 100644 --- a/apps/mesh/src/mcp-clients/client.ts +++ b/apps/mesh/src/mcp-clients/client.ts @@ -8,13 +8,35 @@ import type { MeshContext } from "@/core/mesh-context"; import type { ConnectionEntity } from "@/tools/connection/schema"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { managementMCP } from "@/tools"; import { createOutboundClient } from "./outbound"; import { createVirtualClient } from "./virtual-mcp"; +/** + * Build an in-process MCP client backed by an MCP Server (already + * registered with tools). Avoids an HTTP roundtrip for pseudo-connections + * that are hosted in this same process — notably the SELF management MCP, + * whose stored URL points at the configured BASE_URL (e.g. a `*.localhost` + * proxy hostname in dev) that Bun's fetch on macOS cannot resolve. + */ +async function connectInProcess( + server: Awaited>, + name: string, +): Promise { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name, version: "1.0.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + /** * Create an MCP client from a connection entity * * Routes to the appropriate factory based on connection type: + * - SELF pseudo-connections (`_self`): In-process management MCP * - VIRTUAL: Creates a virtual MCP aggregator client * - STDIO, HTTP, Websocket, SSE: Creates an outbound client * @@ -28,6 +50,9 @@ export async function clientFromConnection( ctx: MeshContext, superUser = false, ): Promise { + if (connection.id.endsWith("_self")) { + return connectInProcess(await managementMCP(ctx), "self-in-process"); + } if (connection.connection_type === "VIRTUAL") { return createVirtualClient(connection, ctx, superUser); } diff --git a/apps/mesh/src/mcp-clients/lazy-client.ts b/apps/mesh/src/mcp-clients/lazy-client.ts index 8af44efa4b..a7920df1a4 100644 --- a/apps/mesh/src/mcp-clients/lazy-client.ts +++ b/apps/mesh/src/mcp-clients/lazy-client.ts @@ -77,8 +77,17 @@ export function createLazyClient( const shouldBypassCache = (params?: unknown, options?: unknown) => params !== undefined || options !== undefined; + // In-process MCP servers (dev-assets, SELF management MCP) have tool + // lists determined at compile time. There's no latency benefit to + // caching them, and the cache keeps biting when the tool list evolves + // (e.g. PAGE_PREVIEW_* added to management MCP but virtual MCPs still + // see the stale cached list and filter them out of selected_tools). + const isInProcessConn = + connection.id.endsWith("_dev-assets") || connection.id.endsWith("_self"); + // SWR helper: delegates to fetchWithCache for cache-hit/miss logic. - // VIRTUAL connections and paginated requests bypass the cache entirely. + // VIRTUAL connections, in-process servers, and paginated requests + // bypass the cache entirely. const swrList = ( type: "tools" | "resources" | "prompts", listFn: ( @@ -90,9 +99,11 @@ export function createLazyClient( buildCachedResult: (cached: unknown[]) => T, ) => { return async (params?: unknown, options?: RequestOptions): Promise => { - // Bypass cache for VIRTUAL connections or paginated requests + // Bypass cache for VIRTUAL connections, in-process MCP servers, or + // paginated requests if ( connection.connection_type === "VIRTUAL" || + isInProcessConn || !cache || shouldBypassCache(params, options) ) { diff --git a/apps/mesh/src/page-preview/contrast.test.ts b/apps/mesh/src/page-preview/contrast.test.ts new file mode 100644 index 0000000000..949be06517 --- /dev/null +++ b/apps/mesh/src/page-preview/contrast.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { + contrastRatio, + enforceContrast, + ensureSurfaceDistinct, + parseHex, +} from "./contrast"; + +const hex = (h: string) => parseHex(h)!; + +describe("contrast utilities", () => { + test("WCAG ratios match known anchors", () => { + // Black on white = 21:1 + expect(contrastRatio(hex("#000000"), hex("#FFFFFF"))).toBeCloseTo(21, 0); + // Same color = 1:1 + expect(contrastRatio(hex("#777777"), hex("#777777"))).toBeCloseTo(1, 5); + }); + + test("enforceContrast leaves passing colors alone", () => { + const fg = "#0A0A0A"; + expect(enforceContrast(fg, "#FFFFFF", { minRatio: 4.5 })).toBe(fg); + }); + + test("enforceContrast pulls a too-light muted toward fg on a light bg", () => { + // muted is pastel pink ~ #FFB6C1; bg cream ~ #FFFAF0 → low contrast + const muted = "#FFB6C1"; + const bg = "#FFFAF0"; + const corrected = enforceContrast(muted, bg, { + minRatio: 4.5, + toward: "#1A1A1A", + }); + expect(contrastRatio(hex(corrected), hex(bg))).toBeGreaterThanOrEqual(4.5); + // Should not be a totally different color — luminance has moved, but + // it shouldn't be pure black. + expect(corrected).not.toBe("#000000"); + }); + + test("enforceContrast pulls a too-dark muted toward fg on a dark bg", () => { + // muted ~ slightly darker than bg + const muted = "#1A1A22"; + const bg = "#0B0B12"; + const corrected = enforceContrast(muted, bg, { + minRatio: 4.5, + toward: "#F6F6F8", + }); + expect(contrastRatio(hex(corrected), hex(bg))).toBeGreaterThanOrEqual(4.5); + }); + + test("border threshold is lower (1.5:1) — keeps subtle dividers", () => { + const border = "#2A2A36"; + const bg = "#0B0B12"; + const corrected = enforceContrast(border, bg, { minRatio: 1.5 }); + expect(contrastRatio(hex(corrected), hex(bg))).toBeGreaterThanOrEqual(1.5); + }); + + test("ensureSurfaceDistinct nudges identical surface/bg", () => { + const bg = "#0B0B12"; + const fixed = ensureSurfaceDistinct(bg, bg); + expect(fixed).not.toBe(bg); + // The nudge should be small — not radically different. + const dist = contrastRatio(hex(fixed), hex(bg)); + expect(dist).toBeGreaterThan(1); + expect(dist).toBeLessThan(2); + }); + + test("parses 3-char, 6-char, and 8-char hex", () => { + expect(parseHex("#F00")).toEqual({ r: 255, g: 0, b: 0 }); + expect(parseHex("#FF0000")).toEqual({ r: 255, g: 0, b: 0 }); + expect(parseHex("#FF0000AA")).toEqual({ r: 255, g: 0, b: 0 }); + expect(parseHex("nope")).toBeNull(); + }); +}); diff --git a/apps/mesh/src/page-preview/contrast.ts b/apps/mesh/src/page-preview/contrast.ts new file mode 100644 index 0000000000..e137553c3f --- /dev/null +++ b/apps/mesh/src/page-preview/contrast.ts @@ -0,0 +1,146 @@ +/** + * Color contrast utilities. The Page Editor agent regularly produces brand + * palettes where `muted` (used for secondary body text) or `fg` ends up at + * < 2:1 contrast against `bg` — illegible. Rather than hoping the model + * follows the system-prompt rules, we normalize tokens before writing them + * so the produced design system always reads. + * + * Implementation: WCAG 2.x relative-luminance contrast. When a token fails + * its minimum ratio against the background, we mix it toward the + * page foreground color (or pure black / pure white if no fg is available) + * until it meets the threshold — preserving hue as much as possible. + */ + +export type Rgb = { r: number; g: number; b: number }; + +const HEX_RE = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +export function parseHex(input: string): Rgb | null { + const m = input.trim().match(HEX_RE); + if (!m) return null; + let hex = m[1]!; + if (hex.length === 3) { + hex = hex + .split("") + .map((c) => c + c) + .join(""); + } + // 8-char hex carries alpha — drop the last 2 chars. + if (hex.length === 8) hex = hex.slice(0, 6); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null; + return { r, g, b }; +} + +function toHex(c: Rgb): string { + const toC = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${toC(c.r)}${toC(c.g)}${toC(c.b)}`; +} + +function channelLinear(n: number): number { + const v = n / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); +} + +function relativeLuminance(c: Rgb): number { + return ( + 0.2126 * channelLinear(c.r) + + 0.7152 * channelLinear(c.g) + + 0.0722 * channelLinear(c.b) + ); +} + +export function contrastRatio(a: Rgb, b: Rgb): number { + const la = relativeLuminance(a) + 0.05; + const lb = relativeLuminance(b) + 0.05; + return la > lb ? la / lb : lb / la; +} + +function isLight(c: Rgb): boolean { + return relativeLuminance(c) > 0.5; +} + +function mix(a: Rgb, b: Rgb, t: number): Rgb { + return { + r: a.r + (b.r - a.r) * t, + g: a.g + (b.g - a.g) * t, + b: a.b + (b.b - a.b) * t, + }; +} + +/** + * Push `from` toward `toward` (a high-contrast anchor) until the result + * meets `minRatio` against `bg`. Returns the smallest blended color that + * passes — preserves hue better than snapping straight to fg. + */ +export function enforceContrast( + fromHex: string, + bgHex: string, + options: { minRatio: number; toward?: string }, +): string { + const from = parseHex(fromHex); + const bg = parseHex(bgHex); + if (!from || !bg) return fromHex; + if (contrastRatio(from, bg) >= options.minRatio) return fromHex; + + const towardHex = options.toward + ? (parseHex(options.toward) ?? + (isLight(bg) ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 })) + : isLight(bg) + ? { r: 0, g: 0, b: 0 } + : { r: 255, g: 255, b: 255 }; + + // Binary search the blend factor t for the smallest t whose mix passes. + let lo = 0; + let hi = 1; + let best = towardHex; + for (let i = 0; i < 18; i++) { + const t = (lo + hi) / 2; + const candidate = mix(from, towardHex, t); + if (contrastRatio(candidate, bg) >= options.minRatio) { + best = candidate; + hi = t; + } else { + lo = t; + } + } + // Rounding to int channels can knock the contrast back below the + // threshold; nudge t up in 8-bit-channel-sized steps until the rounded + // hex actually passes. + let result = toHex(best); + let t = hi; + for (let i = 0; i < 32 && t <= 1; i++) { + const rounded = parseHex(result)!; + if (contrastRatio(rounded, bg) >= options.minRatio) return result; + t = Math.min(1, t + 1 / 255); + result = toHex(mix(from, towardHex, t)); + } + return result; +} + +/** + * Ensure `surface` is *visually distinct* from `bg` without going so far + * it competes with content. Aims for a small but perceptible lightness + * difference (about 6% in linear luminance terms). + */ +export function ensureSurfaceDistinct( + surfaceHex: string, + bgHex: string, +): string { + const surface = parseHex(surfaceHex); + const bg = parseHex(bgHex); + if (!surface || !bg) return surfaceHex; + if (contrastRatio(surface, bg) >= 1.1) return surfaceHex; + // Nudge toward the opposite end of the luminance scale. + const anchor: Rgb = isLight(bg) + ? { r: 0, g: 0, b: 0 } + : { r: 255, g: 255, b: 255 }; + // ~5% toward anchor. + return toHex(mix(surface, anchor, 0.06)); +} diff --git a/apps/mesh/src/page-preview/host-html.ts b/apps/mesh/src/page-preview/host-html.ts new file mode 100644 index 0000000000..1d564776da --- /dev/null +++ b/apps/mesh/src/page-preview/host-html.ts @@ -0,0 +1,2011 @@ +/** + * The Studio-controlled "host" iframe. + * + * Instead of loading each page's `index.html` directly, the preview pane + * loads this host once. The host runs a preact render loop, dynamically + * imports the page's tokens.js / sections.js / page.js from the + * `/api//page-preview/files/...` file server, and exposes a + * postMessage bridge for Studio to drive transitions in-place: + * + * host:welcome show the welcome quiz + * host:set-page load and render a page (slug + ds slug) + * host:show-design-system render the design-system gallery inline + * host:show-design-system-grid render a card grid of all design systems + * host:retheme apply a different DS's brand to current page + * host:refresh re-fetch + re-render current view + * host:set-page-progress set or clear the status overlay (synced + * with the Studio-side overlay) + * + * The host emits back: + * host:ready initial handshake + * page-editor:prompt user clicked a welcome card or generate + * page-editor:runtime-error captured window.error / unhandledrejection + * page-editor:host-select-ds user clicked a DS card in the grid + */ +const PAGE_PREVIEW_HOST_MARKER = "DECO_PAGE_EDITOR_HOST_V1"; + +export const PAGE_PREVIEW_HOST_HTML = ` + + + + + + Page Editor preview + + + + + + + + + + +
+ + + +`; diff --git a/apps/mesh/src/page-preview/service.test.ts b/apps/mesh/src/page-preview/service.test.ts new file mode 100644 index 0000000000..35d8cfd8fd --- /dev/null +++ b/apps/mesh/src/page-preview/service.test.ts @@ -0,0 +1,500 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { + buildDesignSystemExportBundle, + buildPageExportBundle, + createDesignSystem, + createPage, + defaultBrand, + discoverHtmlPages, + getPagePreviewPaths, + getPagePreviewStatus, + refreshPagePreview, + setActiveDesignSystem, + setPagePreviewActive, +} from "./service"; +import { contrastRatio, parseHex } from "./contrast"; + +const ORG_ID = "org/test"; +const ORG_SLUG = "acme"; + +let dataDir: string; + +beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "page-preview-")); +}); + +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +async function writePage(relativePath: string, body = "") { + const { pagesDir } = getPagePreviewPaths({ orgId: ORG_ID, dataDir }); + const absolutePath = join(pagesDir, relativePath); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, body, "utf8"); + return absolutePath; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("page preview service", () => { + test("uses one well-known local pages directory for every org", () => { + const first = getPagePreviewPaths({ orgId: "org-one", dataDir }); + const second = getPagePreviewPaths({ orgId: "org-two", dataDir }); + + expect(first.pagesDir).toBe(join(dataDir, "page-editor", "pages")); + expect(second.pagesDir).toBe(first.pagesDir); + expect(second.statePath).toBe(first.statePath); + expect(first.designSystemsDir).toBe( + join(dataDir, "page-editor", "design-systems"), + ); + }); + + test("normalizes page slug to index.html and sets active preview", async () => { + const absolutePath = await writePage("pricing/index.html"); + + const status = await setPagePreviewActive({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + path: "pricing", + }); + + expect(status.activePath).toBe(absolutePath); + expect(status.activeRelativePath).toBe("pages/pricing/index.html"); + expect(status.activeKind).toBe("page"); + expect(status.refreshVersion).toBe(1); + expect(status.activeUrl).toBe( + "http://localhost:3000/api/acme/page-preview/files/pages/pricing/index.html?v=1", + ); + }); + + test("accepts absolute HTML paths inside the pages directory", async () => { + const absolutePath = await writePage("absolute/index.html"); + + const status = await setPagePreviewActive({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + path: absolutePath, + }); + + expect(status.activePath).toBe(absolutePath); + expect(status.activeRelativePath).toBe("pages/absolute/index.html"); + }); + + test("rejects relative traversal outside the pages directory", async () => { + await expect( + setPagePreviewActive({ + orgId: ORG_ID, + dataDir, + path: "../escape/index.html", + }), + ).rejects.toThrow(); + }); + + test("rejects absolute paths outside the pages directory", async () => { + const outsidePath = join(dataDir, "outside.html"); + await writeFile(outsidePath, "", "utf8"); + + await expect( + setPagePreviewActive({ + orgId: ORG_ID, + dataDir, + path: outsidePath, + }), + ).rejects.toThrow(); + }); + + test("discovers HTML pages under the local pages directory", async () => { + await writePage("landing/index.html"); + await writePage("pricing/index.html"); + await writePage("pricing/app.js", "console.log('ignored')"); + + const pages = await discoverHtmlPages({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + + expect(pages.map((p) => p.slug).sort()).toEqual(["landing", "pricing"]); + expect(pages.every((p) => p.relativePath.endsWith("/index.html"))).toBe( + true, + ); + }); + + test("refresh increments version and preserves the active page", async () => { + await writePage("launch/index.html"); + await setPagePreviewActive({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + path: "launch/index.html", + }); + + const refreshed = await refreshPagePreview({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + + expect(refreshed.activeRelativePath).toBe("pages/launch/index.html"); + expect(refreshed.refreshVersion).toBe(2); + expect(refreshed.activeUrl).toContain("?v=2"); + }); + + test("status falls back to the newest discovered page when no active page is set", async () => { + await writePage("fallback/index.html"); + + const status = await getPagePreviewStatus({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + + expect(status.activeRelativePath).toBe("pages/fallback/index.html"); + expect(status.activeKind).toBe("page"); + expect(status.refreshVersion).toBe(0); + }); + + test("status switches to a newer page written after the active page was set", async () => { + await writePage("first/index.html"); + await setPagePreviewActive({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + path: "first/index.html", + }); + + await sleep(10); + await writePage("second/index.html"); + + const status = await getPagePreviewStatus({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + + expect(status.activeRelativePath).toBe("pages/second/index.html"); + }); +}); + +describe("design system scaffolding", () => { + test("creates a design system with tokens, demo and meta", async () => { + const brand = { ...defaultBrand(), primary: "#FF00AA" }; + const { slug, status } = await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "Pristine!", + name: "Pristine", + brand, + }); + + expect(slug).toBe("pristine"); + expect(status.activeKind).toBe("design-system"); + expect(status.activeDesignSystem).toBe("pristine"); + expect(status.designSystems).toHaveLength(1); + expect(status.designSystems[0]?.brand.primary).toBe("#FF00AA"); + + const root = join(dataDir, "page-editor", "design-systems", "pristine"); + const tokensCss = await readFile(join(root, "tokens.css"), "utf8"); + expect(tokensCss).toContain("--brand-primary: #FF00AA"); + const meta = JSON.parse(await readFile(join(root, "meta.json"), "utf8")); + expect(meta.brand.primary).toBe("#FF00AA"); + }); + + test("setActiveDesignSystem requires the design system to exist", async () => { + await expect( + setActiveDesignSystem({ + orgId: ORG_ID, + dataDir, + slug: "ghost", + }), + ).rejects.toThrow(); + }); + + test("progress label is set by setPageProgress and cleared by scaffold/refresh", async () => { + const { setPageProgress } = await import("./service"); + const set = await setPageProgress({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + label: "Picking a design system…", + }); + expect(set.progressLabel).toBe("Picking a design system…"); + + const created = await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "pristine", + brand: defaultBrand(), + }); + expect(created.status.progressLabel).toBeNull(); + + const set2 = await setPageProgress({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + label: "Building the hero", + }); + expect(set2.progressLabel).toBe("Building the hero"); + + const refreshed = await refreshPagePreview({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + expect(refreshed.progressLabel).toBeNull(); + }); + + test("auto-corrects illegible muted/border on a light bg", async () => { + const { status } = await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "lavender", + brand: { + ...defaultBrand(), + bg: "#F3EBFF", + surface: "#FFFFFF", + fg: "#1A1A1A", + // Agent supplied an illegible pastel for muted and a vivid yellow + // for border — exactly the kind of mistake we want to correct. + muted: "#E5DDF3", + border: "#FFE600", + }, + }); + const ds = status.designSystems.find((d) => d.slug === "lavender")!; + const bg = parseHex(ds.brand.bg)!; + const muted = parseHex(ds.brand.muted)!; + expect(contrastRatio(muted, bg)).toBeGreaterThanOrEqual(5.5); + // border has a softer threshold but must still be visible. + const border = parseHex(ds.brand.border)!; + expect(contrastRatio(border, bg)).toBeGreaterThanOrEqual(1.5); + // fg must hit AAA. + const fg = parseHex(ds.brand.fg)!; + expect(contrastRatio(fg, bg)).toBeGreaterThanOrEqual(7); + }); + + test("auto-corrects illegible muted on a dark bg", async () => { + const { status } = await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "deepnight", + brand: { + ...defaultBrand(), + bg: "#0B0B12", + surface: "#15151F", + fg: "#F6F6F8", + muted: "#1A1A22", // way too dark on dark bg + border: "#181820", + }, + }); + const ds = status.designSystems.find((d) => d.slug === "deepnight")!; + const bg = parseHex(ds.brand.bg)!; + const muted = parseHex(ds.brand.muted)!; + expect(contrastRatio(muted, bg)).toBeGreaterThanOrEqual(5.5); + }); +}); + +describe("page scaffolding", () => { + test("creates a page bound to an existing design system", async () => { + await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "pristine", + brand: defaultBrand(), + }); + + const { slug, status } = await createPage({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "Landing!", + designSystem: "pristine", + title: "Landing", + description: "A new landing page", + }); + + expect(slug).toBe("landing"); + // Default: do NOT switch preview to the new (empty) page; keep design + // system visible until the agent edits page.js + calls PAGE_PREVIEW_SET. + expect(status.activeKind).toBe("design-system"); + expect(status.activeDesignSystem).toBe("pristine"); + + const pageDir = join(dataDir, "page-editor", "pages", "landing"); + const index = await readFile(join(pageDir, "index.html"), "utf8"); + expect(index).toContain("Landing"); + expect(index).toContain("../../design-systems/pristine/tokens.css"); + const meta = JSON.parse(await readFile(join(pageDir, "meta.json"), "utf8")); + expect(meta.designSystem).toBe("pristine"); + // page.js ships empty so the page renders the EmptyPageState until the + // agent populates it. + const pageJs = await readFile(join(pageDir, "page.js"), "utf8"); + expect(pageJs).toMatch(/export const PAGE = \[\];?\s*$/); + }); + + test("createPage with activate=true switches the preview immediately", async () => { + await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "pristine", + brand: defaultBrand(), + }); + const { status } = await createPage({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "landing", + designSystem: "pristine", + activate: true, + }); + expect(status.activeKind).toBe("page"); + expect(status.activeRelativePath).toBe("pages/landing/index.html"); + }); + + test("rejects page creation when the design system does not exist", async () => { + await expect( + createPage({ + orgId: ORG_ID, + dataDir, + slug: "landing", + designSystem: "ghost", + }), + ).rejects.toThrow(); + }); +}); + +describe("export", () => { + test("page export bundles a self-contained index.html plus raw src/", async () => { + await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "pristine", + brand: defaultBrand(), + }); + await createPage({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "landing", + designSystem: "pristine", + }); + + const { bundleName, files } = await buildPageExportBundle({ + orgId: ORG_ID, + dataDir, + slug: "landing", + }); + expect(bundleName).toBe("page-landing"); + + const names = files.map((f) => f.relativePath).sort(); + expect(names).toContain("index.html"); + expect(names).toContain("README.txt"); + expect(names).toContain("src/index.html"); + expect(names).toContain("src/app.js"); + expect(names).toContain("src/tokens.css"); + expect(names).toContain("src/tokens.js"); + + const dec = new TextDecoder(); + const indexHtml = dec.decode( + files.find((f) => f.relativePath === "index.html")!.data, + ); + // Stylesheet must be inlined; no remaining ../../design-systems ref. + expect(indexHtml).not.toContain("../../design-systems"); + expect(indexHtml).toContain("`, + ); + html = html.replace( + /]*?src=["']\.\/app\.js["'][^>]*?>\s*<\/script>/g, + ``, + ); + + const enc = new TextEncoder(); + const files: Array<{ relativePath: string; data: Uint8Array }> = [ + { relativePath: "index.html", data: enc.encode(html) }, + { + relativePath: "README.txt", + data: enc.encode( + `${pageMeta.name ?? slug} — exported from Page Editor\n\n` + + `Open index.html in a browser to view the page.\n` + + `Everything is self-contained except CDN-hosted preact + htm.\n\n` + + `If you'd rather edit the original multi-file source, see ./src/.\n`, + ), + }, + ]; + + // Also include the raw source files under ./src/ for advanced users. + const enc2 = new TextEncoder(); + const includeRaw = async (name: string, content?: string) => { + if (typeof content === "string") { + files.push({ + relativePath: `src/${name}`, + data: enc2.encode(content), + }); + } + }; + await includeRaw("index.html", indexHtml); + await includeRaw("app.js", appJs); + if (sectionsJs) await includeRaw("sections.js", sectionsJs); + if (pageJs) await includeRaw("page.js", pageJs); + if (tokensCss) await includeRaw("tokens.css", tokensCss); + if (tokensJs) await includeRaw("tokens.js", tokensJs); + const metaSrc = await readUtf8(join(pageDir, PAGE_META_FILE)).catch(() => ""); + if (metaSrc) await includeRaw("meta.json", metaSrc); + + return { bundleName: `page-${slug}`, files }; +} + +/** + * Build the file set for a design-system export. The demo page already + * loads `./tokens.css` (sibling) so it works from `file://` — but `tokens.js` + * is imported via ES module and would be blocked by file:// CORS. We inline + * tokens.js into demo.html the same way as for pages. + */ +export async function buildDesignSystemExportBundle( + options: PagePreviewOptions & { slug: string }, +): Promise<{ + bundleName: string; + files: Array<{ relativePath: string; data: Uint8Array }>; +}> { + const slug = slugify(options.slug); + if (!slug) throw new Error("Invalid slug"); + const { designSystemsDir } = getPagePreviewPaths(options); + const dsDir = join(designSystemsDir, slug); + const dsStat = await stat(dsDir).catch(() => null); + if (!dsStat?.isDirectory()) { + throw new Error(`design system "${slug}" not found`); + } + + const [demoHtml, demoJs, tokensJs, tokensCss] = await Promise.all([ + readUtf8(join(dsDir, "demo.html")), + readUtf8(join(dsDir, "demo.js")).catch(() => ""), + readUtf8(join(dsDir, "tokens.js")).catch(() => ""), + readUtf8(join(dsDir, "tokens.css")).catch(() => ""), + ]); + + const stripExports = (src: string) => + src.replace(/^\s*export\s+default\s+/gm, "").replace(/^\s*export\s+/gm, ""); + + // demo.js is plain DOM code (no preact / htm), but tokens.js exports + // BRAND which demo.js imports. Strip all imports from both chunks so we + // can concatenate safely; tokens.js's BRAND becomes a top-level binding. + const normalize = (src: string) => stripAllImports(stripExports(src)); + + const inlineModule = [ + "// === tokens.js ===", + normalize(tokensJs), + "// === demo.js ===", + normalize(demoJs), + ].join("\n\n"); + + let html = demoHtml; + html = html.replace( + /]*?src=["']\.\/demo\.js["'][^>]*?>\s*<\/script>/g, + ``, + ); + + const metaSrc = await readUtf8(join(dsDir, DESIGN_SYSTEM_META_FILE)).catch( + () => "", + ); + + const enc = new TextEncoder(); + const files: Array<{ relativePath: string; data: Uint8Array }> = [ + { relativePath: "demo.html", data: enc.encode(html) }, + { relativePath: "tokens.css", data: enc.encode(tokensCss) }, + { + relativePath: "README.txt", + data: enc.encode( + `${slug} — design system exported from Page Editor\n\n` + + `Open demo.html in a browser to view the design system gallery.\n` + + `tokens.css carries the brand variables (CSS custom properties).\n`, + ), + }, + ]; + if (metaSrc) { + files.push({ relativePath: "meta.json", data: enc.encode(metaSrc) }); + } + if (tokensJs) { + files.push({ relativePath: "tokens.js", data: enc.encode(tokensJs) }); + } + + return { bundleName: `design-system-${slug}`, files }; +} diff --git a/apps/mesh/src/page-preview/templates.ts b/apps/mesh/src/page-preview/templates.ts new file mode 100644 index 0000000000..c7857655e7 --- /dev/null +++ b/apps/mesh/src/page-preview/templates.ts @@ -0,0 +1,917 @@ +/** + * Pre-built templates for instant scaffolding. + * + * The agent provides brand tokens; we render these templates with simple + * `{{TOKEN}}` substitution to produce design systems and page shells + * without round-tripping through the LLM. + */ + +export interface BrandTokens { + name: string; + primary: string; + secondary: string; + accent: string; + bg: string; + surface: string; + fg: string; + muted: string; + border: string; + headingFont: string; + bodyFont: string; + radius: string; +} + +/** + * Normalize a font-family value into a CSS-valid `font-family` stack. + * + * Agents pass either a single family name (`"Inter"`, `"Press Start 2P"`) or + * a full stack (`"Impact, 'Arial Black', sans-serif"`). We want both forms + * to produce valid CSS when interpolated into a `font-family` declaration. + * + * - Stacks (containing a comma) pass through verbatim: the agent is + * responsible for proper quoting inside their stack. + * - Single names get quoted iff they contain whitespace. + * - Already-quoted names pass through. + */ +function normalizeFont(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + if (trimmed.includes(",")) return trimmed; + const isQuoted = + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")); + if (isQuoted) return trimmed; + if (/\s/.test(trimmed)) return `'${trimmed.replace(/'/g, "")}'`; + return trimmed; +} + +export function renderTemplate( + template: string, + brand: BrandTokens, + extra: Record = {}, +): string { + const vars: Record = { + BRAND_NAME: brand.name, + BRAND_PRIMARY: brand.primary, + BRAND_SECONDARY: brand.secondary, + BRAND_ACCENT: brand.accent, + BRAND_BG: brand.bg, + BRAND_SURFACE: brand.surface, + BRAND_FG: brand.fg, + BRAND_MUTED: brand.muted, + BRAND_BORDER: brand.border, + BRAND_HEADING_FONT: normalizeFont(brand.headingFont), + BRAND_BODY_FONT: normalizeFont(brand.bodyFont), + BRAND_RADIUS: brand.radius, + ...extra, + }; + return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => { + const value = vars[key]; + return value !== undefined ? value : `{{${key}}}`; + }); +} + +/* --------------------------------------------------------------------------- + * Design system: tokens.css + * ------------------------------------------------------------------------- */ + +export const DESIGN_SYSTEM_TOKENS_CSS = `:root { + --brand-primary: {{BRAND_PRIMARY}}; + --brand-secondary: {{BRAND_SECONDARY}}; + --brand-accent: {{BRAND_ACCENT}}; + --brand-bg: {{BRAND_BG}}; + --brand-surface: {{BRAND_SURFACE}}; + --brand-fg: {{BRAND_FG}}; + --brand-muted: {{BRAND_MUTED}}; + --brand-border: {{BRAND_BORDER}}; + --brand-radius: {{BRAND_RADIUS}}; + + --font-heading: {{BRAND_HEADING_FONT}}, 'Instrument Serif', Georgia, serif; + --font-body: {{BRAND_BODY_FONT}}, Inter, system-ui, sans-serif; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --space-16: 64px; + --space-24: 96px; + + --text-xs: 12px; + --text-sm: 14px; + --text-base: 16px; + --text-lg: 18px; + --text-xl: 22px; + --text-2xl: 28px; + --text-3xl: 36px; + --text-4xl: 48px; + --text-5xl: 64px; +} + +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font-body); + background: var(--brand-bg); + color: var(--brand-fg); + -webkit-font-smoothing: antialiased; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-6); + border-radius: var(--brand-radius); + font-weight: 600; + font-size: var(--text-base); + border: 1px solid transparent; + cursor: pointer; + transition: transform .08s ease, opacity .15s ease, background .15s ease; +} +.btn:active { transform: translateY(1px); } +.btn-primary { background: var(--brand-primary); color: white; } +.btn-primary:hover { opacity: .9; } +.btn-secondary { background: var(--brand-surface); color: var(--brand-fg); border-color: var(--brand-border); } +.btn-ghost { background: transparent; color: var(--brand-fg); border-color: var(--brand-border); } +.btn-disabled { opacity: .4; cursor: not-allowed; } + +.card { + background: var(--brand-surface); + border: 1px solid var(--brand-border); + border-radius: var(--brand-radius); + padding: var(--space-6); +} + +.input, .select, .textarea { + width: 100%; + background: var(--brand-bg); + color: var(--brand-fg); + border: 1px solid var(--brand-border); + border-radius: var(--brand-radius); + padding: var(--space-3) var(--space-4); + font-family: var(--font-body); + font-size: var(--text-base); +} +.input:focus, .select:focus, .textarea:focus { + outline: 2px solid var(--brand-primary); + outline-offset: 2px; +} + +.heading { font-family: var(--font-heading); font-weight: 500; letter-spacing: -0.01em; } + +.container { + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--space-6); +} + +/* --------------------------------------------------------------------------- + * Reveal animation + * + * Every top-level child of
(i.e. every section in pages built from + * the template) and every in the design-system demo + * fades + slides in with a per-child stagger. Re-renders on file change + * replay the animation, so the preview always feels alive when content + * appears. Respects \`prefers-reduced-motion\`. + * ------------------------------------------------------------------------- */ + +@keyframes deco-reveal { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +main > *, +.ds-section, +.ds-hero { + animation: deco-reveal 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: 0ms; +} +main > *:nth-child(1) { animation-delay: 40ms; } +main > *:nth-child(2) { animation-delay: 140ms; } +main > *:nth-child(3) { animation-delay: 240ms; } +main > *:nth-child(4) { animation-delay: 340ms; } +main > *:nth-child(5) { animation-delay: 440ms; } +main > *:nth-child(6) { animation-delay: 540ms; } +main > *:nth-child(7) { animation-delay: 640ms; } +main > *:nth-child(8) { animation-delay: 740ms; } +main > *:nth-child(n+9) { animation-delay: 800ms; } + +.ds-hero { animation-delay: 60ms; } +.ds-section:nth-of-type(1) { animation-delay: 180ms; } +.ds-section:nth-of-type(2) { animation-delay: 280ms; } +.ds-section:nth-of-type(3) { animation-delay: 380ms; } +.ds-section:nth-of-type(4) { animation-delay: 480ms; } +.ds-section:nth-of-type(5) { animation-delay: 580ms; } +.ds-section:nth-of-type(6) { animation-delay: 680ms; } + +@media (prefers-reduced-motion: reduce) { + main > *, .ds-section, .ds-hero { animation: none; } +} +`; + +/* --------------------------------------------------------------------------- + * Design system: tokens.js is generated programmatically in service.ts + * via `JSON.stringify(brand)` — emitting JS strings safely with brand + * values that may contain quotes or commas (font stacks). + * ------------------------------------------------------------------------- */ + +/* --------------------------------------------------------------------------- + * Design system: demo.html + * ------------------------------------------------------------------------- */ + +export const DESIGN_SYSTEM_DEMO_HTML = ` + + + + + {{DESIGN_SYSTEM_NAME}} — Design System + + + + + + + +
+
+

Design System

+

{{DESIGN_SYSTEM_NAME}}

+

The visual language for pages built on this design system. Edit tokens.css or meta.json to evolve it; every bound page reskins automatically.

+
+
+ +
+
+

Color

+
+
+ +
+

Typography

+
+
Display heading set in {{BRAND_HEADING_FONT}}
+
Section heading
+
Lead paragraph set in {{BRAND_BODY_FONT}}
+
Body copy reads at 16px with comfortable line height. This is the default reading size for paragraphs across pages bound to this design system.
+
Caption / muted text at 14px.
+
+
+ +
+

Buttons

+
+ + + + +
+
+ +
+

Cards

+
+
+
Feature card
+
Short supporting copy that fits within a card.
+
+
+
Pricing card
+
$29/mo
+ +
+
+
Accent card
+
Use sparingly to draw attention.
+
+
+
+ +
+

Form controls

+
+ + + +
+
+ +
+

Spacing

+
+
+
+ + + + +`; + +/* --------------------------------------------------------------------------- + * Design system: demo.js (renders color swatches + spacing scale) + * ------------------------------------------------------------------------- */ + +export const DESIGN_SYSTEM_DEMO_JS = `import { BRAND } from './tokens.js'; + +const colors = [ + ['primary', BRAND.primary], + ['secondary', BRAND.secondary], + ['accent', BRAND.accent], + ['bg', BRAND.bg], + ['surface', BRAND.surface], + ['fg', BRAND.fg], + ['muted', BRAND.muted], + ['border', BRAND.border], +]; + +const colorsHost = document.getElementById('ds-colors'); +if (colorsHost) { + colorsHost.innerHTML = colors.map(([name, value]) => \` +
+
+
\${name}\${value}
+
+ \`).join(''); +} + +const spacings = [4, 8, 12, 16, 24, 32, 48, 64]; +const spacingHost = document.getElementById('ds-spacing'); +if (spacingHost) { + spacingHost.innerHTML = spacings.map(n => \` +
+
+ \${n}px +
+ \`).join(''); +} +`; + +/* --------------------------------------------------------------------------- + * Page template: index.html + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_INDEX_HTML = ` + + + + + {{PAGE_TITLE}} + + + + + + + + + + +
+ + + +`; + +/* --------------------------------------------------------------------------- + * Page template: app.js + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_APP_JS = `import { h, render, Component } from 'preact'; +import htm from 'htm'; +import { BRAND } from '{{TOKENS_JS_MODULE}}'; +import * as Sections from './sections.js'; +import { PAGE } from './page.js'; + +const html = htm.bind(h); + +/** + * Per-section error boundary so a broken section (e.g. inline string event + * handler that preact rejects) doesn't blank the entire page. The user can + * still see surrounding sections plus a visible hint about which one failed. + */ +class SectionBoundary extends Component { + constructor(props) { + super(props); + this.state = { err: null }; + } + static getDerivedStateFromError(err) { + return { err }; + } + componentDidCatch(err) { + console.error('[page-editor] section error', this.props.name, err); + } + render() { + if (this.state.err) { + return html\` +
+ Section "\${this.props.name}" failed: \${String(this.state.err && this.state.err.message || this.state.err)} +
+ \`; + } + return this.props.children; + } +} + +function EmptyPageState() { + // Rendered when PAGE = []. Soft "waiting" state in the brand's own + // palette so the eventual reveal feels intentional, not jarring. + return html\` +
+
+
+ +
+
Page is ready.
+

Sections will appear here as the agent builds them.

+
+ +
+ \`; +} + +function App() { + if (!PAGE || PAGE.length === 0) { + return html\`<\${EmptyPageState} />\`; + } + return html\` +
+ \${PAGE.map((block, i) => { + const Section = Sections[block.section]; + if (!Section) { + return html\`
Unknown section: \${block.section}
\`; + } + return html\` + <\${SectionBoundary} key=\${i} name=\${block.section}> + <\${Section} brand=\${BRAND} ...\${block.props || {}} /> + + \`; + })} +
+ \`; +} + +render(html\`<\${App} />\`, document.getElementById('root')); +`; + +/* --------------------------------------------------------------------------- + * Page template: sections.js (nav, hero, features, footer scaffolds) + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_SECTIONS_JS = `import { h } from 'preact'; +import htm from 'htm'; + +const html = htm.bind(h); + +/** + * Section library. Each export is a pure function of props that renders one + * section of the page. \`page.js\` lists which sections to render, in order, + * and supplies their props. + * + * Add to this library by exporting new named functions. Compose pages by + * appending block entries to \`PAGE\` in page.js — one block per Edit. + */ + +export function Nav({ brand, title, links }) { + const items = Array.isArray(links) && links.length > 0 + ? links + : [ + { label: 'Features', href: '#features' }, + { label: 'Pricing', href: '#pricing' }, + { label: 'FAQ', href: '#faq' }, + ]; + return html\` + + \`; +} + +export function Hero({ eyebrow, title, subtitle, ctaPrimary, ctaSecondary }) { + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} +

\${title || 'Build a beautiful page.'}

+ \${subtitle && html\`

\${subtitle}

\`} + \${(ctaPrimary || ctaSecondary) && html\` +
+ \${ctaPrimary && html\`\`} + \${ctaSecondary && html\`\`} +
+ \`} +
+
+ \`; +} + +/** + * A 3-column (or auto-fit) grid of feature cards. + * props: { eyebrow?, title?, intro?, items: [{ icon?, title, body }] } + */ +export function FeatureGrid({ eyebrow, title, intro, items }) { + const features = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
+ \${features.map(f => html\` +
+ \${f.icon && html\`
\${f.icon}
\`} +
\${f.title}
+ \${f.body && html\`
\${f.body}
\`} +
+ \`)} +
+
+
+ \`; +} + +/** + * Pricing cards row, optionally with one highlighted plan. + * props: { eyebrow?, title?, intro?, plans: [{ name, price, period?, features: string[], cta?, highlight?: boolean }] } + */ +export function PricingCards({ eyebrow, title, intro, plans }) { + const tiers = Array.isArray(plans) ? plans : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
+ \${tiers.map(p => html\` +
+
\${p.name}
+
+ \${p.price} + \${p.period && html\`/\${p.period}\`} +
+ \${Array.isArray(p.features) && p.features.length > 0 && html\` +
    + \${p.features.map(f => html\`
  • \${f}
  • \`)} +
+ \`} + +
+ \`)} +
+
+
+ \`; +} + +/** + * A single large pull-quote with attribution. + * props: { quote, author, role? } + */ +export function TestimonialQuote({ quote, author, role }) { + return html\` +
+
+

\“\${quote}\”

+
— \${author}\${role ? html\`, \${role}\` : null}
+
+
+ \`; +} + +/** + * Logo strip / social proof. + * props: { eyebrow?, items: string[] } // items can be text labels or image URLs + */ +export function LogoStrip({ eyebrow, items }) { + const logos = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} +
+ \${logos.map(it => /^https?:\\/\\//.test(String(it)) + ? html\`\` + : html\`\${it}\`)} +
+
+
+ \`; +} + +/** + * FAQ list — accordion-free for simplicity. + * props: { eyebrow?, title?, items: [{ q, a }] } + */ +export function FAQ({ eyebrow, title, items }) { + const list = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} +
+ \${list.map(item => html\` +
+
\${item.q}
+
\${item.a}
+
+ \`)} +
+
+
+ \`; +} + +/** + * Email-capture form for waitlists / newsletters. + * props: { eyebrow?, title?, body?, cta?, placeholder? } + */ +export function EmailCapture({ eyebrow, title, body, cta, placeholder }) { + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${body && html\`

\${body}

\`} +
e.preventDefault()}> + + +
+
+
+ \`; +} + +/** + * Final CTA strip. + * props: { eyebrow?, title, body?, ctaPrimary?, ctaSecondary? } + */ +export function CTASection({ eyebrow, title, body, ctaPrimary, ctaSecondary }) { + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} +

\${title || 'Ready to ship?'}

+ \${body && html\`

\${body}

\`} + \${(ctaPrimary || ctaSecondary) && html\` +
+ \${ctaPrimary && html\`\`} + \${ctaSecondary && html\`\`} +
+ \`} +
+
+ \`; +} + +export function Footer({ brand }) { + return html\` +
+
+ © \${new Date().getFullYear()} \${brand?.name || 'Brand'} + Built with Page Editor +
+
+ \`; +} + +/** + * Lightweight escape hatch for one-off sections the library doesn't cover. + * props: { title?, body? } — replace via the agent when needed. + */ +export function PlaceholderSection({ title, body }) { + return html\` +
+
+

\${title || 'Section'}

+

\${body || 'Placeholder content — replace via the agent.'}

+
+
+ \`; +} +`; + +/* --------------------------------------------------------------------------- + * Page template: page.js (declarative section list) + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_PAGE_JS = `/** + * Ordered list of section blocks rendered by app.js. + * + * The page starts empty on purpose — the agent appends ONE block at a time + * via Edit, then calls PAGE_PREVIEW_REFRESH. Each refresh adds one section + * to the preview with a staggered fade-in. + * + * Each block is { section: '', props: { ... } }. + * + * Available sections (see sections.js): + * Nav, Hero, FeatureGrid, PricingCards, TestimonialQuote, LogoStrip, + * FAQ, EmailCapture, CTASection, Footer, PlaceholderSection + * + * Example, after editing: + * export const PAGE = [ + * { section: 'Nav', props: { title: 'Acme' } }, + * { section: 'Hero', props: { title: 'Ship faster', subtitle: '…', ctaPrimary: 'Start' } }, + * { section: 'FeatureGrid', props: { title: 'Features', items: [{ title: '…', body: '…' }] } }, + * { section: 'Footer', props: {} }, + * ]; + */ +export const PAGE = []; +`; diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 4c5fd15b8e..ba8d5959e8 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -32,6 +32,7 @@ import * as UserTools from "./user"; import * as AiProvidersTools from "./ai-providers"; import { getPrompts, getResources } from "./guides"; import * as ObjectStorageTools from "./object-storage"; +import * as PagePreviewTools from "./page-preview"; import * as RegistryTools from "./registry/index"; import * as VmTools from "./vm"; import * as GitHubTools from "./github"; @@ -156,6 +157,16 @@ const CORE_TOOLS = [ ObjectStorageTools.DELETE_OBJECT, ObjectStorageTools.DELETE_OBJECTS, + // Page Preview tools + PagePreviewTools.PAGE_PREVIEW_STATUS, + PagePreviewTools.PAGE_PREVIEW_SET, + PagePreviewTools.PAGE_PREVIEW_REFRESH, + PagePreviewTools.PAGE_PREVIEW_PAGE_CREATE, + PagePreviewTools.PAGE_PREVIEW_PROGRESS, + PagePreviewTools.DESIGN_SYSTEM_CREATE, + PagePreviewTools.DESIGN_SYSTEM_LIST, + PagePreviewTools.DESIGN_SYSTEM_SET, + // Registry tools ...RegistryTools.tools, diff --git a/apps/mesh/src/tools/page-preview/index.ts b/apps/mesh/src/tools/page-preview/index.ts new file mode 100644 index 0000000000..6233a2392d --- /dev/null +++ b/apps/mesh/src/tools/page-preview/index.ts @@ -0,0 +1,361 @@ +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { requireAuth, requireOrganization } from "@/core/mesh-context"; +import { + createDesignSystem, + createPage, + defaultBrand, + getPagePreviewStatus, + listDesignSystems, + refreshPagePreview, + setActiveDesignSystem, + setPagePreviewActive, + setPageProgress, +} from "@/page-preview/service"; + +const BrandTokensInputSchema = z.object({ + name: z.string().optional(), + primary: z.string().optional(), + secondary: z.string().optional(), + accent: z.string().optional(), + bg: z.string().optional(), + surface: z.string().optional(), + fg: z.string().optional(), + muted: z.string().optional(), + border: z.string().optional(), + headingFont: z.string().optional(), + bodyFont: z.string().optional(), + radius: z.string().optional(), +}); + +const BrandTokensOutputSchema = z.object({ + name: z.string(), + primary: z.string(), + secondary: z.string(), + accent: z.string(), + bg: z.string(), + surface: z.string(), + fg: z.string(), + muted: z.string(), + border: z.string(), + headingFont: z.string(), + bodyFont: z.string(), + radius: z.string(), +}); + +const PagePreviewPageSchema = z.object({ + slug: z.string(), + name: z.string(), + designSystem: z.string().nullable(), + path: z.string(), + relativePath: z.string(), + url: z.string(), + lastModified: z.string(), +}); + +const DesignSystemEntrySchema = z.object({ + slug: z.string(), + name: z.string(), + brand: BrandTokensOutputSchema, + path: z.string(), + relativePath: z.string(), + url: z.string(), + lastModified: z.string(), +}); + +const PagePreviewStatusOutputSchema = z.object({ + pagesDir: z.string(), + activeKind: z.enum(["page", "design-system"]).nullable(), + activePath: z.string().nullable(), + activeRelativePath: z.string().nullable(), + activeUrl: z.string().nullable(), + activeDesignSystem: z.string().nullable(), + refreshVersion: z.number(), + pages: z.array(PagePreviewPageSchema), + designSystems: z.array(DesignSystemEntrySchema), + progressLabel: z.string().nullable(), + progressUpdatedAt: z.string().nullable(), + outline: z.array(z.string()).nullable(), + outlineUpdatedAt: z.string().nullable(), + nextStep: z.string().optional(), +}); + +function pageSlugFromStatus( + status: z.infer, +): string | null { + if (status.activeKind !== "page") return null; + const rel = status.activeRelativePath ?? ""; + const match = rel.match(/^pages\/([^/]+)\//); + return match?.[1] ?? null; +} + +function orgArgs(ctx: Parameters[0]) { + const org = requireOrganization(ctx); + return { + orgId: org.id, + orgSlug: org.slug ?? org.id, + baseUrl: ctx.baseUrl, + }; +} + +export const PAGE_PREVIEW_STATUS = defineTool({ + name: "PAGE_PREVIEW_STATUS", + description: + "Return the local Page Editor pages directory, active preview, refresh version, design systems and discovered pages.", + annotations: { + title: "Page Preview Status", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (_input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + return getPagePreviewStatus(args); + }, +}); + +export const PAGE_PREVIEW_SET = defineTool({ + name: "PAGE_PREVIEW_SET", + description: + "Set the Page Editor preview to a page (by slug, e.g. 'pricing', or path under pages/).", + annotations: { + title: "Set Page Preview", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ + path: z + .string() + .describe( + "Page slug (e.g. 'pricing'), relative path (e.g. 'pages/pricing/index.html'), or absolute path inside the Page Editor root.", + ), + }), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const status = await setPagePreviewActive({ ...args, path: input.path }); + const slug = pageSlugFromStatus(status) ?? input.path; + return { + ...status, + nextStep: `Page "${slug}" is now live in the preview with its first section. Do NOT write a prose plan. Continue immediately with the NEXT outline section: (1) PAGE_PREVIEW_PROGRESS({ label: "…" }) (2) Edit pages/${slug}/page.js to APPEND ONE block to the PAGE array (do not rewrite the whole array; one section per Edit) (3) PAGE_PREVIEW_REFRESH({}). Repeat for each remaining outline section, one PROGRESS+Edit+REFRESH per section. Never batch.`, + }; + }, +}); + +export const PAGE_PREVIEW_REFRESH = defineTool({ + name: "PAGE_PREVIEW_REFRESH", + description: + "Reload the Page Editor iframe by incrementing the local preview refresh version.", + annotations: { + title: "Refresh Page Preview", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (_input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const status = await refreshPagePreview(args); + const slug = pageSlugFromStatus(status); + const pageSlug = slug ?? ""; + return { + ...status, + nextStep: `Preview reloaded. Do NOT write a prose recap. If outline sections remain unbuilt: (1) PAGE_PREVIEW_PROGRESS({ label: "…" }) (2) Edit pages/${pageSlug}/page.js appending ONE more block to PAGE (3) PAGE_PREVIEW_REFRESH({}). If the outline is fully built: PAGE_PREVIEW_PROGRESS({ label: "Polishing details…" }) and then tighten copy or props with one Edit + REFRESH per change. Never batch sections.`, + }; + }, +}); + +export const DESIGN_SYSTEM_CREATE = defineTool({ + name: "DESIGN_SYSTEM_CREATE", + description: + "Instantly scaffold a design system from brand tokens. Renders a ready-made demo page (typography, colors, buttons, cards, forms, spacing) and persists tokens.css/tokens.js + meta.json. Pages bound to this design system reskin automatically. CRITICAL: You MUST pass ALL brand fields (primary, secondary, accent, bg, surface, fg, muted, border, headingFont, bodyFont, radius) derived from the user's brief on the FIRST call. Omitted fields fall back to a generic dark-neon default — calling this with a sparse brand and then re-calling it with the real tokens flashes the wrong colors in the preview. Derive the palette once, pass it complete.", + annotations: { + title: "Create Design System", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + slug: z + .string() + .describe( + "URL-safe slug for the design system (e.g. 'pristine', 'glassmorphism').", + ), + name: z.string().optional().describe("Human-readable display name."), + brand: BrandTokensInputSchema.describe( + "Brand tokens. ALL fields should be passed — the preview iframe renders whatever tokens you commit. Missing fields fall back to a generic dark-neon palette (NOT a smart default for your brief).", + ), + }), + outputSchema: z.object({ + slug: z.string(), + status: PagePreviewStatusOutputSchema, + nextStep: z.string().optional(), + }), + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const brand = { ...defaultBrand(), ...input.brand }; + if (input.name) brand.name = input.name; + const result = await createDesignSystem({ + ...args, + slug: input.slug, + name: input.name, + brand, + }); + return { + ...result, + nextStep: `Design system "${result.slug}" is created and showing in the preview. Do NOT write a prose plan. Your next two tool calls, in order: (1) PAGE_PREVIEW_PROGRESS({ label: "Designing the hero…" }) (2) PAGE_PREVIEW_PAGE_CREATE({ slug: "", designSystem: "${result.slug}" }). Pick the page slug from the user's brief (e.g. "mise", "pricing"); add a discriminator like "-v2" if you suspect a collision.`, + }; + }, +}); + +export const DESIGN_SYSTEM_LIST = defineTool({ + name: "DESIGN_SYSTEM_LIST", + description: "List all design systems available in the Page Editor.", + annotations: { + title: "List Design Systems", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: z.object({ designSystems: z.array(DesignSystemEntrySchema) }), + handler: async (_input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const designSystems = await listDesignSystems(args); + return { designSystems }; + }, +}); + +export const DESIGN_SYSTEM_SET = defineTool({ + name: "DESIGN_SYSTEM_SET", + description: + "Activate a design system in the preview pane (shows its demo page).", + annotations: { + title: "Set Active Design System", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ + slug: z.string().describe("Design system slug to activate."), + }), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + return setActiveDesignSystem({ ...args, slug: input.slug }); + }, +}); + +export const PAGE_PREVIEW_PAGE_CREATE = defineTool({ + name: "PAGE_PREVIEW_PAGE_CREATE", + description: + "Scaffold a new page bound to a design system: writes index.html, app.js, sections.js, page.js and meta.json. sections.js ships a full library (Nav, Hero, FeatureGrid, PricingCards, TestimonialQuote, LogoStrip, FAQ, EmailCapture, CTASection, Footer); page.js starts EMPTY. By default the preview pane keeps showing the design system — the agent appends one section block to page.js per edit and calls PAGE_PREVIEW_REFRESH so each section reveals one at a time. Call PAGE_PREVIEW_SET when the first section is in place to promote the page into the preview.", + annotations: { + title: "Create Page", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + slug: z.string().describe("URL-safe page slug (e.g. 'pricing')."), + designSystem: z + .string() + .describe("Slug of the design system this page binds to."), + name: z.string().optional().describe("Human-readable name."), + title: z.string().optional().describe("HTML for the page."), + description: z.string().optional().describe("Meta description."), + activate: z + .boolean() + .optional() + .describe( + "If true, immediately switch the preview pane to the new (empty) page. Default false — design-system preview stays visible.", + ), + }), + outputSchema: z.object({ + slug: z.string(), + status: PagePreviewStatusOutputSchema, + nextStep: z.string().optional(), + }), + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const result = await createPage({ + ...args, + slug: input.slug, + designSystem: input.designSystem, + name: input.name, + title: input.title, + description: input.description, + activate: input.activate, + }); + return { + ...result, + nextStep: `Page scaffolded at pages/${result.slug}/ with page.js containing PAGE = []. The preview still shows the DS gallery — expected. DO NOT write a prose section list, DO NOT summarize the brief, DO NOT call PAGE_PREVIEW_STATUS, DO NOT Read page.js or sections.js. Your very next THREE tool calls, in this exact order: (1) PAGE_PREVIEW_PROGRESS({ label: "Designing the hero…" }) (2) Edit pages/${result.slug}/page.js — replace "export const PAGE = [];" with "export const PAGE = [{ section: 'Hero', props: { eyebrow: '<short uppercase eyebrow>', title: '<real headline copy>', subtitle: '<one-sentence supporting line>', ctaPrimary: '<primary button label>', ctaSecondary: '<optional secondary label>' } }];" — EXACT prop names eyebrow/title/subtitle/ctaPrimary/ctaSecondary (any other name is silently ignored and the template default "Build a beautiful page." renders). ONE Hero block only (3) PAGE_PREVIEW_SET({ path: "${result.slug}" }) to promote the preview. After SET, append remaining outline sections one block at a time with PROGRESS + Edit + PAGE_PREVIEW_REFRESH. AUTHORITATIVE prop contracts (do not invent prop names — wrong names render template defaults): Nav { brand?, title?, links: [{ label, href }] }; Hero { eyebrow?, title, subtitle?, ctaPrimary?, ctaSecondary? }; FeatureGrid { eyebrow?, title?, intro?, items: [{ icon?, title, body }] }; PricingCards { eyebrow?, title?, intro?, plans: [{ name, price, period?, features: string[], cta?, highlight?: boolean }] }; TestimonialQuote { quote, author, role? }; LogoStrip { eyebrow?, items: string[] }; FAQ { eyebrow?, title?, items: [{ q, a }] }; EmailCapture { eyebrow?, title?, body?, cta?, placeholder? }; CTASection { eyebrow?, title, body?, ctaPrimary?, ctaSecondary? }; Footer { brand? } — reads page brand automatically.`, + }; + }, +}); + +export const PAGE_PREVIEW_PROGRESS = defineTool({ + name: "PAGE_PREVIEW_PROGRESS", + description: + "Set a short, user-facing label describing the step the agent is about to perform (e.g. 'Picking a design system…', 'Building the hero', 'Polishing the footer'). The Studio preview pane renders this as an animated status overlay until the next scaffold tool (DESIGN_SYSTEM_CREATE / PAGE_PREVIEW_PAGE_CREATE / PAGE_PREVIEW_SET / PAGE_PREVIEW_REFRESH) clears it. Call this BEFORE every major action so the user sees deliberate, step-by-step progress. ALSO: on the FIRST call of a page build, pass `outline` — an ordered list of the section labels you plan to add (e.g. ['Nav', 'Hero', 'Features', 'Pricing', 'FAQ', 'CTA', 'Footer']). The preview renders a sticky stepper at the top of the page so the user can see the full plan and watch each step light up as it's completed. Subsequent PROGRESS calls don't need to repeat the outline; just keep the label fresh.", + annotations: { + title: "Page Preview Progress", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + label: z + .string() + .min(1) + .max(120) + .describe( + "Short imperative phrase, ideally 3-6 words, ending in '…'. Examples: 'Picking a design system…', 'Building the page shell…', 'Designing the hero', 'Building features section'.", + ), + outline: z + .array(z.string().min(1).max(40)) + .min(1) + .max(12) + .optional() + .describe( + "Ordered list of section labels you plan to build (3-10 short labels, e.g. ['Nav','Hero','Features','Pricing','FAQ','CTA','Footer']). Pass once at the start of the build to render a sticky stepper above the page. Omit on subsequent calls to keep the previous outline.", + ), + }), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + return setPageProgress({ + ...args, + label: input.label, + outline: input.outline ?? null, + }); + }, +}); diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index 817a8f6d5b..3b0f1862a2 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -31,6 +31,7 @@ export type ToolCategory = | "AI Providers" | "Automations" | "Object Storage" + | "Page Preview" | "Registry" | "GitHub" | "VM"; @@ -147,6 +148,16 @@ const ALL_TOOL_NAMES = [ "DELETE_OBJECT", "DELETE_OBJECTS", + // Page Preview tools + "PAGE_PREVIEW_STATUS", + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "PAGE_PREVIEW_PROGRESS", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_LIST", + "DESIGN_SYSTEM_SET", + // Registry tools "COLLECTION_REGISTRY_APP_LIST", "COLLECTION_REGISTRY_APP_GET", @@ -707,6 +718,47 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ category: "Object Storage", dangerous: true, }, + // Page Preview tools + { + name: "PAGE_PREVIEW_STATUS", + description: "Get local Page Editor preview status", + category: "Page Preview", + }, + { + name: "PAGE_PREVIEW_SET", + description: "Set the active local Page Editor preview", + category: "Page Preview", + }, + { + name: "PAGE_PREVIEW_REFRESH", + description: "Refresh the local Page Editor preview", + category: "Page Preview", + }, + { + name: "PAGE_PREVIEW_PAGE_CREATE", + description: "Scaffold a new page from a layout template", + category: "Page Preview", + }, + { + name: "DESIGN_SYSTEM_CREATE", + description: "Scaffold a design system from brand tokens", + category: "Page Preview", + }, + { + name: "DESIGN_SYSTEM_LIST", + description: "List design systems", + category: "Page Preview", + }, + { + name: "DESIGN_SYSTEM_SET", + description: "Set active design system in preview", + category: "Page Preview", + }, + { + name: "PAGE_PREVIEW_PROGRESS", + description: "Set a status label for the preview pane overlay", + category: "Page Preview", + }, // Registry tools { name: "COLLECTION_REGISTRY_APP_LIST", @@ -936,6 +988,15 @@ const PERMISSION_CAPABILITIES: PermissionCapability[] = [ "GET_OBJECT_METADATA", "GET_PRESIGNED_URL", "PUT_PRESIGNED_URL", + // Page Editor preview control + "PAGE_PREVIEW_STATUS", + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "PAGE_PREVIEW_PROGRESS", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_LIST", + "DESIGN_SYSTEM_SET", // VM previews "VM_START", "VM_DELETE", diff --git a/apps/mesh/src/tools/virtual/create.ts b/apps/mesh/src/tools/virtual/create.ts index 5fb12ffd54..0718bfb828 100644 --- a/apps/mesh/src/tools/virtual/create.ts +++ b/apps/mesh/src/tools/virtual/create.ts @@ -13,6 +13,16 @@ import { requireAuth, requireOrganization, } from "../../core/mesh-context"; +import { getBaseUrl } from "../../core/server-constants"; +import { + getWellKnownDevAssetsConnection, + getWellKnownSelfConnection, + WellKnownOrgMCPId, +} from "@decocms/mesh-sdk"; +import { + isDevAssetsConnection, + usesLocalObjectStorage, +} from "../connection/dev-assets"; import { VirtualMCPCreateDataSchema, VirtualMCPEntitySchema } from "./schema"; /** * Random icon+color for new agents (server-side, no React deps). @@ -109,6 +119,44 @@ export const COLLECTION_VIRTUAL_MCP_CREATE = defineTool({ throw new Error("User ID required to create virtual MCP"); } + // Materialize well-known pseudo-connections (dev-assets, SELF) that + // the caller may reference in `connections[]`. These are normally + // auto-injected at list time, so they have no DB row — the FK in + // `connection_aggregations.child_connection_id` would reject them + // otherwise. Mirrors the on-demand creation pattern in + // `plugin-config-update.ts`. + const selfId = WellKnownOrgMCPId.SELF(organization.id); + const referenced = new Set( + (input.data.connections ?? []).map((c) => c.connection_id), + ); + if (referenced.size > 0) { + const baseUrl = getBaseUrl(); + const ensure = async (id: string) => { + const existing = await ctx.storage.connections.findById(id); + if (existing) return; + + let seed: ReturnType<typeof getWellKnownDevAssetsConnection> | null = + null; + if ( + isDevAssetsConnection(id, organization.id) && + usesLocalObjectStorage() + ) { + seed = getWellKnownDevAssetsConnection(baseUrl, organization.id); + } else if (id === selfId) { + seed = getWellKnownSelfConnection(baseUrl, organization.id); + } + if (!seed) return; + + await ctx.storage.connections.create({ + ...seed, + id: seed.id!, + organization_id: organization.id, + created_by: userId, + }); + }; + await Promise.all([...referenced].map(ensure)); + } + // Create the virtual MCP (input.data is already in the correct format) // Note: The facade creates a VIRTUAL connection in the connections table // Use a random icon+color if no icon is provided diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index 7a1cce829e..38a8631e7c 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -50,6 +50,7 @@ import { import { isTiptapDocEmpty } from "./tiptap/utils"; import { ToolsPopover } from "./tools-popover"; import { SessionStats } from "./usage-stats"; +import { setActiveChatInputHandleRef } from "@/web/lib/chat-input-bridge"; import { authClient } from "@/web/lib/auth-client.ts"; import { track } from "@/web/lib/posthog-client"; import { useSound } from "@/web/hooks/use-sound.ts"; @@ -322,6 +323,17 @@ export function ChatInput({ const tiptapRef = useRef<TiptapInputHandle | null>(null); + // Publish the *ref object* (not its current value) to the bridge so + // callers dereference at click time, after `useImperativeHandle` in + // TiptapInput has populated `tiptapRef.current`. Registering + // `.current` from `useEffect([])` race-loses against the child's + // imperative-handle effect. + // oxlint-disable-next-line ban-use-effect/ban-use-effect — registers a module-level singleton; cleanup runs on unmount + useEffect(() => { + setActiveChatInputHandleRef(tiptapRef); + return () => setActiveChatInputHandleRef(null); + }, []); + const isPlanMode = chatMode === "plan"; // Focus chat input on Cmd+L, toggle plan mode on Cmd+Shift+L diff --git a/apps/mesh/src/web/components/home/agents-list.tsx b/apps/mesh/src/web/components/home/agents-list.tsx index 4bb644d54e..7bef16d221 100644 --- a/apps/mesh/src/web/components/home/agents-list.tsx +++ b/apps/mesh/src/web/components/home/agents-list.tsx @@ -33,6 +33,7 @@ import { ImportFromDecoDialog } from "@/web/components/import-from-deco-dialog.t import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal.tsx"; import { AiImageRecruitModal } from "@/web/components/home/ai-image-recruit-modal.tsx"; import { AiResearchRecruitModal } from "@/web/components/home/ai-research-recruit-modal.tsx"; +import { PageEditorRecruitModal } from "@/web/components/home/page-editor-recruit-modal.tsx"; import { LeanCanvasRecruitModal } from "@/web/components/home/lean-canvas-recruit-modal.tsx"; import { StudioPackRecruitModal } from "@/web/components/home/studio-pack-recruit-modal.tsx"; import { SelfHealingRepoFlow } from "@/web/components/self-healing-repo/self-healing-repo-flow.tsx"; @@ -199,6 +200,7 @@ type RecruitModalKey = | "diagnostics" | "ai-image" | "ai-research" + | "page-editor" | "lean-canvas" | "studio-pack" | "self-healing"; @@ -212,6 +214,7 @@ type HomeTile = | "site-diagnostics" | "ai-image" | "ai-research" + | "page-editor" | "lean-canvas" | "studio-pack" | "self-healing-storefront"; @@ -249,6 +252,7 @@ function AgentsListContent() { const [diagnosticsModalOpen, setDiagnosticsModalOpen] = useState(false); const [aiImageModalOpen, setAiImageModalOpen] = useState(false); const [aiResearchModalOpen, setAiResearchModalOpen] = useState(false); + const [pageEditorModalOpen, setPageEditorModalOpen] = useState(false); const [leanCanvasModalOpen, setLeanCanvasModalOpen] = useState(false); const [studioPackModalOpen, setStudioPackModalOpen] = useState(false); const [selfHealingOpen, setSelfHealingOpen] = useState(false); @@ -267,6 +271,9 @@ function AgentsListContent() { const aiResearchAgent = WELL_KNOWN_AGENT_TEMPLATES.find( (t) => t.id === "ai-research", )!; + const pageEditorAgent = WELL_KNOWN_AGENT_TEMPLATES.find( + (t) => t.id === "page-editor", + )!; const leanCanvasAgent = WELL_KNOWN_AGENT_TEMPLATES.find( (t) => t.id === "lean-canvas", )!; @@ -292,6 +299,11 @@ function AgentsListContent() { aiResearchAgent.id, aiResearchAgent.title, ); + const existingPageEditor = findExistingForTemplate( + virtualMcps, + pageEditorAgent.id, + pageEditorAgent.title, + ); const existingLeanCanvas = findExistingForTemplate( virtualMcps, leanCanvasAgent.id, @@ -401,6 +413,23 @@ function AgentsListContent() { onClick: "ai-research", }; } + if (id === pageEditorAgent.id) { + if (existingPageEditor) { + return { + key: existingPageEditor.id, + kind: "existing", + templateId: "page-editor", + agent: existingPageEditor, + }; + } + return { + key: id, + kind: "template-recruit", + templateId: "page-editor", + agent: pageEditorAgent, + onClick: "page-editor", + }; + } const custom = virtualMcps.find( (a): a is typeof a & { id: string } => a.id !== null && a.id === id && !isDecopilot(a.id), @@ -430,6 +459,7 @@ function AgentsListContent() { // opted into the experimental flag (resolveTile returns null otherwise). const templateIds = [ siteEditorAgent.id, + pageEditorAgent.id, selfHealingStorefrontAgent.id, siteDiagnosticsAgent.id, aiImageAgent.id, @@ -449,7 +479,8 @@ function AgentsListContent() { (a) => a.id !== existingDiagnostics?.id && a.id !== existingAiImage?.id && - a.id !== existingAiResearch?.id, + a.id !== existingAiResearch?.id && + a.id !== existingPageEditor?.id, ) .sort((a, b) => { const aIdx = recentIds.indexOf(a.id); @@ -485,6 +516,7 @@ function AgentsListContent() { diagnostics: () => setDiagnosticsModalOpen(true), "ai-image": () => setAiImageModalOpen(true), "ai-research": () => setAiResearchModalOpen(true), + "page-editor": () => setPageEditorModalOpen(true), "lean-canvas": () => setLeanCanvasModalOpen(true), "studio-pack": () => setStudioPackModalOpen(true), "self-healing": () => setSelfHealingOpen(true), @@ -502,6 +534,20 @@ function AgentsListContent() { /> ); } + if (tile.templateId === "page-editor") { + return ( + <AgentPreview + key={tile.key} + agent={tile.agent} + onSpecialClick={() => setPageEditorModalOpen(true)} + tracking={{ + template_id: tile.templateId, + tile_kind: "existing", + action: "open_modal", + }} + /> + ); + } return ( <AgentPreview key={tile.key} @@ -549,6 +595,12 @@ function AgentsListContent() { existingAgent={existingAiResearch} /> + <PageEditorRecruitModal + open={pageEditorModalOpen} + onOpenChange={setPageEditorModalOpen} + existingAgent={existingPageEditor} + /> + <LeanCanvasRecruitModal open={leanCanvasModalOpen} onOpenChange={setLeanCanvasModalOpen} diff --git a/apps/mesh/src/web/components/home/page-editor-recruit-modal.tsx b/apps/mesh/src/web/components/home/page-editor-recruit-modal.tsx new file mode 100644 index 0000000000..b971d6c8ae --- /dev/null +++ b/apps/mesh/src/web/components/home/page-editor-recruit-modal.tsx @@ -0,0 +1,497 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@deco/ui/components/drawer.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts"; +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { + WELL_KNOWN_AGENT_TEMPLATES, + WellKnownOrgMCPId, + useProjectContext, + useVirtualMCPActions, +} from "@decocms/mesh-sdk"; +import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; +import { track } from "@/web/lib/posthog-client"; + +function buildPageEditorSystemPrompt(pagesDir: string | null) { + const resolvedRoot = pagesDir ?? "<decoHome>/page-editor"; + return `You are Page Editor, a local-first deco Studio agent that builds zero-build landing pages with the available coding tools. + +# THE ONE RULE — read this first + +**You communicate through tool calls, not chat.** Between any two tool calls, your assistant message MUST be either empty or a single short sentence. NEVER: + +- Dump a numbered list of sections you plan to build into chat. +- Restate, summarize, paraphrase, or analyze the user's brief in chat. +- Describe what page.js will look like in chat. +- Write a "plan" or "outline" message before tool calls — the outline goes into \`PAGE_PREVIEW_PROGRESS({ outline })\`, not into prose. + +After every scaffold tool call (\`DESIGN_SYSTEM_CREATE\`, \`PAGE_PREVIEW_PAGE_CREATE\`, \`PAGE_PREVIEW_SET\`, \`PAGE_PREVIEW_REFRESH\`) the tool's response contains a \`nextStep\` field naming the next 1–3 tool calls. **Follow it literally.** Do not pause to narrate, do not draft section copy in chat, do not stop your turn. The user is staring at the preview; every second you spend writing prose is a second the page is not assembling. + +You may write a one-sentence wrap-up only after the final \`PAGE_PREVIEW_REFRESH\` of the polish pass — and only then. + +# First-paint contract — latency matters + +For any concrete page request, **visual progress outranks exhaustive planning**: + +- Your first two tool calls are always \`PAGE_PREVIEW_PROGRESS\` and \`DESIGN_SYSTEM_CREATE\`. +- Your first four tool calls must include \`PAGE_PREVIEW_PAGE_CREATE\`. +- Your first six tool calls must include \`PAGE_PREVIEW_SET\` (promotes preview off the DS gallery). +- Do **not** restate, summarize, or fully analyze a long brief before first paint. Skim enough to choose a credible design direction and section outline, scaffold immediately, then refine the copy after the page is alive. +- If the user sends a very long prompt, treat it as source material for later section edits — not as permission to spend minutes planning before anything appears. + +# Session isolation — non-negotiable + +**Each chat is a clean room.** The data directory may contain pages and design systems from earlier sessions (other users, other prompts, your own previous runs). They are **not yours**. + +- **Do not call \`PAGE_PREVIEW_SET\`** on a page slug that you didn't just create via \`PAGE_PREVIEW_PAGE_CREATE\` in *this* response. +- **Do not call \`PAGE_PREVIEW_STATUS\`** at the start of a build. There is no signal in there for you. Use it only if the user explicitly asks "what exists". +- **Do not \`Read\`** \`pages/<some-slug>/...\` files for any slug other than the page you just created. You will see scaffolding bytes that look familiar but they belong to a different page. +- **Do not derive your design tokens or section structure from a pre-existing page.** Pick fresh tokens for the current brief. +- **Always pick a fresh slug** for the page you're about to build. Derive it from the user's prompt and add a discriminator if you suspect a collision (\`pricing\`, \`pricing-v2\`, etc.). \`PAGE_PREVIEW_PAGE_CREATE\` is idempotent at the slug level — it'll happily clobber an old page if you reuse a slug. Pick a new one to be safe. + +The Studio preview pane filters to items created in *this* chat. If you SET an unrelated slug, the iframe will keep showing what *you* built; the only thing you'll have done is confused yourself. Build the new page, then SET / REFRESH it. + +# CRITICAL RULE — read this first, every time + +When a user asks for a concrete page, you run a **narrated, step-by-step build**. The preview pane shows a status overlay driven by \`PAGE_PREVIEW_PROGRESS\` between every action — the user watches deliberate, visible progress instead of waiting for a single big drop. + +**The choreography pattern, repeated for every step:** + +1. Call \`PAGE_PREVIEW_PROGRESS({ label })\` with a short user-facing label (e.g. \`"Picking a design system…"\`). +2. Perform the action — usually a single MCP tool call (\`DESIGN_SYSTEM_CREATE\`, \`PAGE_PREVIEW_PAGE_CREATE\`, \`PAGE_PREVIEW_REFRESH\`) or a single Write/Edit followed by \`PAGE_PREVIEW_REFRESH\`. +3. The scaffold/refresh tool implicitly clears the label and the user sees the new preview state. +4. Move to the next step — call \`PAGE_PREVIEW_PROGRESS\` again with the next label. + +For every concrete page request, the **first response** must follow this opening sequence verbatim: + +1. \`PAGE_PREVIEW_PROGRESS({ label: "Picking a design system…", outline: [...] })\` — the **very first** \`PROGRESS\` call MUST include \`outline\`: an ordered list of 4–8 short section labels you plan to build, e.g. \`["Nav", "Hero", "Features", "Pricing", "FAQ", "CTA", "Footer"]\`. The preview renders this as a sticky stepper at the top of the page; each label lights up as the matching section lands. Pick the outline once, then **build in that order** — the stepper assumes section N+1 corresponds to outline[N]. Subsequent \`PROGRESS\` calls don't need to repeat \`outline\` (it persists). +2. (Optional) one \`BRAND_CONTEXT_*\` call if brand tools are available and the user didn't already supply a brand inline. +3. \`DESIGN_SYSTEM_CREATE({ slug, name, brand })\` — the preview shows the design-system demo, overlay clears. +4. \`PAGE_PREVIEW_PROGRESS({ label: "Designing the hero…" })\` +5. \`PAGE_PREVIEW_PAGE_CREATE({ slug, designSystem })\` — scaffolds the page directory **but the preview stays on the design system**. \`page.js\` starts as \`export const PAGE = [];\`. \`sections.js\` already contains a full library (Nav, Hero, FeatureGrid, PricingCards, TestimonialQuote, LogoStrip, FAQ, EmailCapture, CTASection, Footer). **You do not edit sections.js** unless you genuinely need a section the library doesn't cover. **You also do not READ page.js or sections.js after creating them** — their contents are documented in this prompt and are authoritative. Reading them after \`PAGE_CREATE\` adds 10-30 seconds of dead air to the build while the user stares at a frozen DS demo. Trust the prompt and go straight to \`PROGRESS\` → \`Edit\` page.js with the first section. +6. \`Edit\` \`page.js\` to set \`PAGE = [{ section: 'Hero', props: { … } }]\` with the real hero copy. +7. \`PAGE_PREVIEW_SET({ path: "<slug>" })\` — swaps the inline design-system gallery for the page's first real section. The hero appears alongside the persistent Nav + Footer shell. +8. \`PAGE_PREVIEW_PROGRESS({ label: "Adding navigation…" })\` → \`Edit\` \`page.js\` to prepend a Nav block → \`PAGE_PREVIEW_REFRESH({})\` → the Nav fades in above the hero. +9. \`PAGE_PREVIEW_PROGRESS({ label: "Building the features section" })\` → \`Edit\` \`page.js\` to append a FeatureGrid block with real items → \`REFRESH\` → features fade in. +10. Continue **one block per Edit, one Edit per REFRESH**. Each section is announced with its own PROGRESS call beforehand. +11. \`PAGE_PREVIEW_PROGRESS({ label: "Polishing details…" })\` for the final pass. + +**Before those scaffold tool calls, you may not call \`Write\`, \`Edit\`, \`Bash\`, \`Grep\`, or \`Read\`.** No exploration, no listing the data dir, no checking what already exists, no inspecting templates. The scaffold tools are idempotent at the slug level — if the slug already exists, pick a new one. There is nothing to investigate. + +**Narrate during reads too.** Between scaffold tool calls you sometimes need to \`Read\` \`page.js\` or \`sections.js\` to know what's there. The preview pane is silent during those reads if you don't narrate — the user just stares at the DS demo and assumes you're frozen. **Before any \`Read\`, \`Glob\`, or \`Edit\` that follows a \`PAGE_CREATE\` and precedes the first \`SET\`, call \`PAGE_PREVIEW_PROGRESS\` with a short narrating label** ("Reading the scaffold…", "Writing the hero…"). The pill overlay at the bottom of the preview is the user's only window into your work between visible state changes. + +## The ONE BLOCK PER EDIT rule (non-negotiable) + +The user experience is built around watching the page assemble. To get that, you **must** append exactly one \`PAGE\` entry per \`Edit\` to \`page.js\`, then call \`PAGE_PREVIEW_REFRESH\`. Do not: + +- Edit \`page.js\` once with the whole \`PAGE\` array filled out. +- \`Write\` a fully-finished \`sections.js\` in a single shot. +- \`Read\` \`app.js\`, \`sections.js\`, or \`page.js\` before editing — you already know their shape (it's documented in this prompt). \`Read\` is for inspecting changes the user made, not for orientation. +- Batch multiple PROGRESS announcements followed by one big Edit. + +Concretely, every section in the final page corresponds to **one PROGRESS call + one Edit of \`page.js\` + one REFRESH call**. Six sections = six PROGRESS / Edit / REFRESH triples. Anything else cheats the choreography and the user notices. + +**Label-writing rules:** + +- 3–6 words, sentence case, present-tense gerund or imperative. +- End with \`"…"\` (U+2026 ellipsis) to signal in-flight work. +- Concrete and specific: \`"Designing the hero"\` not \`"Working on page"\`. +- One \`PROGRESS\` call per **visible** unit of work. Don't spam it for every tiny tool call (you don't need a label before each \`Read\`). +- Bad: \`"Doing some stuff"\`, \`"Working…"\`, \`"Let me think"\`. +- Good: \`"Picking a design system…"\`, \`"Building the features grid"\`, \`"Polishing the footer"\`, \`"Adding the FAQ"\`. + +# Why this matters + +Every \`Read\`/\`Bash\` before the scaffold tools is dead time the user stares at a blank preview. The templates already produce a working starting point. There is no boilerplate worth writing by hand. + +# Local file contract + +Your runtime is Claude Code. Studio scaffolds files for you from templates via MCP tools — you should call those tools instead of writing the boilerplate yourself. Use Claude Code's native Write and Edit tools only for Stage 3 edits to \`sections.js\` / \`page.js\` inside an already-scaffolded page folder. + +Everything Page Editor manages lives under: + +\`\`\` +${resolvedRoot} + design-systems/<slug>/ # tokens.css, tokens.js, demo.html, meta.json + pages/<slug>/ # index.html, app.js, sections.js, page.js, meta.json +\`\`\` + +Studio serves this directory in the preview pane. Never ask the user to manually reload the preview. + +# MCP tools + +- \`PAGE_PREVIEW_PROGRESS({ label, outline? })\`: set the in-flight status overlay AND (on the first call of a build) declare the section outline that drives the sticky stepper. Pass \`outline: ["Nav", "Hero", ...]\` on the FIRST call only; subsequent calls keep the previous outline. Call before every visible unit of work. The next scaffold/refresh tool clears the label automatically (but keeps the outline). +- \`DESIGN_SYSTEM_CREATE({ slug, name, brand })\`: instantly scaffolds \`design-systems/<slug>/\` from a template (tokens.css/tokens.js/demo.html) using the brand tokens you pass. Activates the design system in the preview pane immediately. +- \`DESIGN_SYSTEM_LIST({})\` / \`DESIGN_SYSTEM_SET({ slug })\`: list available design systems / make one active in the preview. +- \`PAGE_PREVIEW_PAGE_CREATE({ slug, designSystem, name?, title?, description? })\`: instantly scaffolds \`pages/<slug>/\` from a layout template (nav + hero + sections + footer) bound to the named design system. Activates the page in the preview. +- \`PAGE_PREVIEW_SET({ path })\`: select an existing page (slug or path). Use after Stage-3 edits when switching pages. +- \`PAGE_PREVIEW_REFRESH({})\`: bump the preview iframe. Call after every Stage-3 Edit/Write to files under \`pages/<slug>/\`. +- \`PAGE_PREVIEW_STATUS({})\`: list pages, design systems and active selection. **Don't call this at the start of a response — it just delays Stage 1.** Use it only when the user explicitly asks what exists. + +Runtimes may prefix MCP tool names (e.g. \`mcp__cms__DESIGN_SYSTEM_CREATE\`). Use whatever form is available. + +# The brand argument — token roles and contrast rules + +**Pass ALL brand fields on the first \`DESIGN_SYSTEM_CREATE\` call**, derived from the user's brief in one pass. Missing fields fall back to a generic dark-neon default (indigo + cyan + pink on near-black) — that default is NOT a smart guess for your brief; it will flash on screen as the wrong design system before you can correct it. Decide the full palette once, before calling, and commit it whole. + +Token roles — many are NOT for accent color and getting that wrong yields illegible pages: + +| token | role | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| \`bg\` | Page background. Pick a calm, **low-saturation** base — near-white, near-black, or near-cream. Never a vivid hue. | +| \`surface\` | Card / elevated panel background. Slightly differentiated from \`bg\` (a few shades lighter on dark, lighter on light, or pure white). | +| \`fg\` | **Primary body text and headings.** Must hit ≥ 4.5:1 contrast with \`bg\`. Usually near-black on light bg, near-white on dark. | +| \`muted\` | Secondary text, captions, eyebrow labels, hints. Must still read clearly — **≥ 4.5:1 with \`bg\`** for body copy. A desaturated mid-tone, **never** a saturated hue like hot pink. | +| \`border\` | Subtle dividers and card outlines. Low contrast — a near-\`bg\` tone (e.g. \`#1f1f29\` on a near-black bg). **Never** a saturated color. | +| \`primary\` | The single dominant brand color. Used for primary buttons, key highlights. Saturated is fine; this is where the page gets its personality. | +| \`secondary\` | Optional second saturated color. Coordinated with \`primary\` (analogous or complementary, not random). | +| \`accent\` | Rare 3rd hit color for callouts. Use sparingly. If unsure, set it equal to \`primary\`. | + +**Hard rules:** + +- Saturated colors are limited to \`primary\`, \`secondary\`, \`accent\`. Everything else is low-saturation. +- \`muted\` and \`border\` must NEVER be the same value as \`primary\`/\`secondary\`/\`accent\`. +- Pick \`fg\` such that \`fg\` on \`bg\` passes WCAG AA (≥ 4.5:1). Test mentally — if \`bg\` is cream and you set \`fg\` to mid-gray, that fails. +- Headings inherit \`fg\` by default. If you want a colored heading effect, do it locally in a section, not by changing \`fg\`. +- \`headingFont\` and \`bodyFont\` are **Google Font family names** — pass a single name (e.g. \`"Inter"\`, \`"Space Grotesk"\`, \`"Press Start 2P"\`). Do not pass a CSS fallback stack; the template already adds appropriate fallbacks. +- Radius is a CSS length (\`"4px"\`, \`"12px"\`, \`"0px"\` for sharp/brutalist). + +## Design-language anchors + +Pick **one** of these or stay close to a real brand. Don't free-style a chaotic palette: + +- **Minimal mono** — bg \`#FFFFFF\`/\`#FAFAFA\`, fg \`#0A0A0A\`, single primary (deep blue or black), no secondary, radius 4–8px. +- **Dark neon** — bg \`#0A0A0F\`, surface \`#15151F\`, fg \`#F6F6F8\`, primary one neon (\`#A595FF\` violet / \`#22D3EE\` cyan / \`#22C55E\` green), radius 12px. +- **Editorial** — bg \`#FAF7F2\` (warm white), fg \`#171717\`, primary a deep accent (oxblood / forest), serif heading + sans body, radius 0–4px. +- **Soft pastel** — bg \`#FFF7F2\` (peach), fg \`#1A1A1A\`, primary a desaturated mid-tone (\`#7C9CFF\`, \`#FFB3C6\`), generous radius 16–24px. +- **Retro 80s/90s** — bg \`#0B0024\` (deep purple/black), primary \`#FF006E\`/\`#FFE600\`, accent neon, chunky display font, radius 0–4px. +- **Brutalist** — bg \`#F0F0F0\`, fg \`#000\`, primary \`#000\` or \`#FF3B00\`, hard 2–4px borders in \`fg\`, monospace headings, radius 0. + +When the user requests something close to one of these, pin most tokens to the anchor and only personalize \`primary\` + fonts. + +**Anti-examples** (do NOT do this): + +- \`muted: "#FF1493"\` ← hot pink for "secondary text" makes body copy unreadable on most backgrounds. +- \`border: "#FFD700"\` ← saturated yellow dividers look broken. +- \`fg: "#888888"\` on \`bg: "#FFFFFF"\` ← contrast too low (about 3.5:1), fails AA. +- All five of \`primary/secondary/accent/border/muted\` saturated ← no visual hierarchy; the page screams. + +# Stage 3 — assemble the page section by section (PROGRESS + Edit page.js + REFRESH) + +After \`PAGE_PREVIEW_PAGE_CREATE\`, \`page.js\` is \`PAGE = []\`. The preview is still showing the design system. You build the page by repeatedly appending to \`PAGE\`. For each section: + +1. \`PAGE_PREVIEW_PROGRESS({ label: "Designing the hero" })\` +2. \`Edit\` \`page.js\` to append exactly one block (or, for the first one, set the array to a single-element array). +3. For the **first** section: \`PAGE_PREVIEW_SET({ path: "<slug>" })\` — the preview promotes to the page and the first section animates in. + For **every later** section: \`PAGE_PREVIEW_REFRESH({})\` — the page reloads with the new section, which fades in via the template's staggered reveal animation. + +Sections come from the library in \`sections.js\`. **Do not rewrite \`sections.js\`** unless you genuinely need a new section type. Customize via props. + +Library sections (in suggested order for a landing page): +\`Nav\`, \`Hero\`, \`LogoStrip\`, \`FeatureGrid\`, \`TestimonialQuote\`, \`PricingCards\`, \`FAQ\`, \`EmailCapture\`, \`CTASection\`, \`Footer\`. + +**Authoritative prop contracts** — use these prop names verbatim. Any other prop name is silently dropped and the section renders its template default: + +- \`Nav\` — \`{ brand?, title?, links: [{ label, href }] }\` +- \`Hero\` — \`{ eyebrow?, title, subtitle?, ctaPrimary?, ctaSecondary? }\` +- \`FeatureGrid\` — \`{ eyebrow?, title?, intro?, items: [{ icon?, title, body }] }\` +- \`PricingCards\` — \`{ eyebrow?, title?, intro?, plans: [{ name, price, period?, features: string[], cta?, highlight?: boolean }] }\` +- \`TestimonialQuote\`— \`{ quote, author, role? }\` +- \`LogoStrip\` — \`{ eyebrow?, items: string[] }\` (each item is text or an image URL) +- \`FAQ\` — \`{ eyebrow?, title?, items: [{ q, a }] }\` +- \`EmailCapture\` — \`{ eyebrow?, title?, body?, cta?, placeholder? }\` +- \`CTASection\` — \`{ eyebrow?, title, body?, ctaPrimary?, ctaSecondary? }\` +- \`Footer\` — \`{ brand? }\` — reads page brand automatically; usually no props needed. + +If you ever see "Build a beautiful page." or "Ready to ship?" rendered in the preview, that means you passed a wrong prop name (e.g. \`headline\` instead of \`title\`, \`sub\` instead of \`subtitle\`, \`ctas\` instead of \`ctaPrimary\`/\`ctaSecondary\`). Fix the Edit with the correct names and PAGE_PREVIEW_REFRESH. + +A typical landing page is **6–8 blocks**, which means **6–8 PROGRESS / Edit / REFRESH triples** in Stage 3. Aim to have the hero up after Stage 2 + 1 triple, not 6. + +Other Stage-3 rules: +- To re-skin the page mid-flow: \`PROGRESS({ label: "Reskinning…" })\`, then \`DESIGN_SYSTEM_CREATE\` with a different slug, update the page's \`meta.json\` \`designSystem\` field and the \`<link rel="stylesheet">\` href in \`index.html\` to point at the new design system, then \`PAGE_PREVIEW_REFRESH\`. +- Final pass: \`PROGRESS({ label: "Polishing details…" })\` — then make targeted edits to copy / props for sections that need it. Still one Edit per REFRESH. +- If you need a section the library doesn't have, add one new export to \`sections.js\` (one Edit), then reference it from \`page.js\` (separate Edit + REFRESH). + +# Authoring rules + +- Stages 1 and 2 are tool calls. Period. Don't hand-roll \`index.html\`, \`tokens.css\`, etc. +- Stage 3 edits stay inside \`${resolvedRoot}/pages/<slug>/\`. Don't write outside that directory. +- Keep sections as pure functions of props; no DOM side effects. +- After every Stage-3 edit, call \`PAGE_PREVIEW_REFRESH\`. +- Never claim the page is done before Stage 3's polish pass. +- Don't paste full file contents into chat after writing files. The preview is the source of truth. + +# Tech stack (already wired by the templates) + +- Preact + htm via an importmap. +- Brand tokens as CSS custom properties (\`--brand-primary\`, \`--font-heading\`, etc.) plus a \`BRAND\` JS module. +- No build step, no package.json, no Tailwind CDN. + +## htm rules that break naïve HTML copy-paste + +These look like HTML but they aren't. **Event handlers must be functions, not strings**, or preact crashes with "Cannot create property 'u' on string": + +- **Wrong:** \`<button onclick="alert('hi')">\` or \`<div onmouseover="this.style.transform='...'">\` +- **Right:** \`<button onClick=\${() => alert('hi')}>\` and \`<div onMouseOver=\${(e) => { e.currentTarget.style.transform = '...' }}>\` + +Other gotchas: +- Use camelCase JSX-style event names (\`onClick\`, \`onMouseOver\`, \`onInput\`), not lowercase HTML. +- For hover effects, **prefer CSS \`:hover\`** over JS handlers. Add a class and a CSS rule — it's shorter and won't crash. +- Use \`class\` (works in htm) or \`className\`. + +If the user asks for something requiring a bundler or server runtime, offer a zero-build equivalent. + +For vague prompts, ask what page the user wants. For concrete prompts, run Stage 1 + Stage 2 **as your first two tool calls**, then start Stage 3.`; +} + +interface PageEditorRecruitModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + existingAgent?: { id: string } | null; +} + +const CAPABILITIES = [ + "Generate a complete landing page in seconds", + "Pulls your brand colors, fonts, and logo automatically", + "Writes local zero-build files that Claude Code can edit directly", + "Studio auto-detects local HTML files for preview", + "React-ready output you can port to a full site later", +]; + +const PAGE_EDITOR_SELECTED_TOOLS = [ + "PAGE_PREVIEW_STATUS", + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "PAGE_PREVIEW_PROGRESS", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_LIST", + "DESIGN_SYSTEM_SET", + "BRAND_CONTEXT_LIST", + "BRAND_CONTEXT_GET", + "BRAND_CONTEXT_EXTRACT", +]; + +async function fetchPagePreviewPagesDir( + orgSlug: string, +): Promise<string | null> { + try { + const response = await fetch( + `/api/${encodeURIComponent(orgSlug)}/page-preview/state`, + ); + if (!response.ok) return null; + const body = (await response.json()) as { pagesDir?: unknown }; + return typeof body.pagesDir === "string" ? body.pagesDir : null; + } catch { + return null; + } +} + +function RecruitContent({ + onRecruit, + isRecruiting, + existingAgent, +}: { + onRecruit: () => void; + isRecruiting: boolean; + existingAgent: boolean; +}) { + return ( + <div className="flex flex-col gap-6"> + <p className="text-sm text-muted-foreground"> + Add a Page Editor agent that writes local zero-build page files and lets + Studio auto-detect them for preview. No bundler, no dev server, no + object storage upload flow. + </p> + + <div className="space-y-2"> + <p className="text-sm font-medium text-foreground">Capabilities</p> + <ul className="space-y-1.5"> + {CAPABILITIES.map((cap) => ( + <li + key={cap} + className="text-sm text-muted-foreground flex items-start gap-2" + > + <span className="text-violet-500 mt-0.5 shrink-0">+</span> + {cap} + </li> + ))} + </ul> + </div> + + <Button + onClick={onRecruit} + disabled={isRecruiting} + className="w-full cursor-pointer" + > + {isRecruiting + ? "Setting up..." + : existingAgent + ? "Update and open Page Editor" + : "Add Page Editor"} + </Button> + </div> + ); +} + +export function PageEditorRecruitModal({ + open, + onOpenChange, + existingAgent, +}: PageEditorRecruitModalProps) { + const isMobile = useIsMobile(); + const navigateToAgent = useNavigateToAgent(); + const virtualMcpActions = useVirtualMCPActions(); + const { org } = useProjectContext(); + const [isRecruiting, setIsRecruiting] = useState(false); + + const template = WELL_KNOWN_AGENT_TEMPLATES.find( + (t) => t.id === "page-editor", + )!; + + const headerIcon = ( + <IntegrationIcon icon={template.icon} name={template.title} size="sm" /> + ); + + const handleRecruit = async () => { + if (existingAgent) { + setIsRecruiting(true); + try { + const pagesDir = await fetchPagePreviewPagesDir(org.slug); + const instructions = buildPageEditorSystemPrompt(pagesDir); + await virtualMcpActions.update.mutateAsync({ + id: existingAgent.id, + data: { + description: + "Local zero-build landing page authoring with auto preview", + connections: [ + { + connection_id: WellKnownOrgMCPId.SELF(org.id), + selected_tools: PAGE_EDITOR_SELECTED_TOOLS, + selected_resources: null, + selected_prompts: null, + }, + ], + metadata: { + type: "page-editor", + instructions, + ui: { + layout: { + defaultMainView: { type: "page-preview" }, + chatDefaultOpen: true, + }, + }, + }, + }, + }); + onOpenChange(false); + navigateToAgent(existingAgent.id); + } catch (error) { + track("agent_recruit_failed", { + template_id: "page-editor", + agent_id: existingAgent.id, + error: error instanceof Error ? error.message : String(error), + }); + console.error("Failed to update Page Editor agent:", error); + } finally { + setIsRecruiting(false); + } + return; + } + + setIsRecruiting(true); + try { + const pagesDir = await fetchPagePreviewPagesDir(org.slug); + const instructions = buildPageEditorSystemPrompt(pagesDir); + const virtualMcp = await virtualMcpActions.create.mutateAsync({ + title: template.title, + description: + "Local zero-build landing page authoring with auto preview", + icon: template.icon, + status: "active", + connections: [ + { + connection_id: WellKnownOrgMCPId.SELF(org.id), + selected_tools: PAGE_EDITOR_SELECTED_TOOLS, + selected_resources: null, + selected_prompts: null, + }, + ], + metadata: { + type: "page-editor", + instructions, + ui: { + layout: { + defaultMainView: { type: "page-preview" }, + chatDefaultOpen: true, + }, + }, + }, + }); + + track("agent_recruit_confirmed", { + template_id: "page-editor", + agent_id: virtualMcp.id!, + }); + onOpenChange(false); + navigateToAgent(virtualMcp.id!); + } catch (error) { + track("agent_recruit_failed", { + template_id: "page-editor", + error: error instanceof Error ? error.message : String(error), + }); + console.error("Failed to create Page Editor agent:", error); + } finally { + setIsRecruiting(false); + } + }; + + const title = `${existingAgent ? "Update" : "Add"} ${template.title}`; + + return isMobile ? ( + <Drawer open={open} onOpenChange={onOpenChange}> + <DrawerContent className="h-[70dvh]"> + <DrawerHeader className="px-4 pt-4 pb-4 shrink-0"> + <div className="flex items-center gap-3"> + {headerIcon} + <DrawerTitle className="text-xl font-semibold">{title}</DrawerTitle> + </div> + </DrawerHeader> + <div className="flex flex-col flex-1 min-h-0 px-4 pb-8"> + <RecruitContent + onRecruit={handleRecruit} + isRecruiting={isRecruiting} + existingAgent={Boolean(existingAgent)} + /> + </div> + </DrawerContent> + </Drawer> + ) : ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px] p-8"> + <DialogHeader className="mb-4"> + <div className="flex items-center gap-3"> + {headerIcon} + <DialogTitle className="text-xl font-semibold">{title}</DialogTitle> + </div> + </DialogHeader> + <RecruitContent + onRecruit={handleRecruit} + isRecruiting={isRecruiting} + existingAgent={Boolean(existingAgent)} + /> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/index.tsx b/apps/mesh/src/web/layouts/main-panel-tabs/index.tsx index 3f71c491b1..642e274cda 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/index.tsx +++ b/apps/mesh/src/web/layouts/main-panel-tabs/index.tsx @@ -14,6 +14,7 @@ import { useMainPanelTabs } from "./use-main-panel-tabs"; import { SettingsTab } from "./settings-tab"; import { GitTab } from "@/web/components/thread/github/git-tab"; import { PreviewTab } from "./preview-tab"; +import { PagePreviewTab } from "./page-preview-tab"; import { AutomationTab } from "./automation-tab"; import { AutomationsListTab } from "./automations-list-tab"; import { isLegacySettingsTab, parsePinnedViewTabId } from "./tab-id"; @@ -49,6 +50,9 @@ export function MainPanelContent({ if (activeTab === "preview") { return <PreviewTab virtualMcpId={virtualMcpId} />; } + if (activeTab === "page-preview") { + return <PagePreviewTab />; + } if (automationTabParsed) { return <AutomationTab tabId={activeTab} />; } diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx b/apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx new file mode 100644 index 0000000000..abd3df4004 --- /dev/null +++ b/apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx @@ -0,0 +1,1475 @@ +import { useEffect, useRef, useState } from "react"; +import { + Check, + ChevronDown, + Download01, + FileCode01, + Loading01, + Palette, + Plus, + Settings02, +} from "@untitledui/icons"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { useQuery } from "@tanstack/react-query"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@deco/ui/components/dropdown-menu.tsx"; +import { composeChatInput } from "@/web/lib/chat-input-bridge"; +import { KEYS } from "@/web/lib/query-keys"; +import { useChatTask } from "@/web/components/chat"; +import { useOptionalChatStream } from "@/web/components/chat/context"; + +const PROMPT_MESSAGE_TYPE = "page-editor:prompt"; +const RUNTIME_ERROR_MESSAGE_TYPE = "page-editor:runtime-error"; +const HOST_SELECT_DS_MESSAGE_TYPE = "page-editor:host-select-ds"; +const HOST_CLOSE_DS_GRID_MESSAGE_TYPE = "page-editor:host-close-ds-grid"; +const HOST_REQUEST_REFRESH_MESSAGE_TYPE = "page-editor:host-request-refresh"; +const HOST_READY_MESSAGE_TYPE = "host:ready"; +// Every preview-related tool — used to detect "the agent has started doing +// preview work in this chat" (exit fresh-chat / exit thinking-intermission). +const PREVIEW_STATE_TOOLS = new Set([ + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "PAGE_PREVIEW_PROGRESS", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_SET", +]); + +// Tools that change on-disk state (the listing returned by /state). Used to +// gate the refetch trigger so PAGE_PREVIEW_PROGRESS (in-memory only) +// doesn't cause a refetch — refetching with each progress label briefly +// flips `status` to undefined, which collapses `pages = []` and yanks the +// intent back to the welcome screen until the new fetch resolves. +const DISK_MUTATING_TOOLS = new Set([ + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_SET", +]); + +// Tools that, when observed in the stream, *clear* an active progress label +// (because the new state is now live and visible). +const PROGRESS_CLEARING_TOOLS = new Set([ + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_SET", +]); + +type PageEntry = { + slug: string; + name: string; + designSystem: string | null; + path: string; + relativePath: string; + url: string; + lastModified: string; +}; + +type DesignSystemEntry = { + slug: string; + name: string; + brand: Record<string, string>; + path: string; + relativePath: string; + url: string; + lastModified: string; +}; + +type PreviewKind = "page" | "design-system"; + +type PagePreviewStatus = { + pagesDir: string; + activeKind: PreviewKind | null; + activePath: string | null; + activeRelativePath: string | null; + activeUrl: string | null; + activeDesignSystem: string | null; + refreshVersion: number; + pages: PageEntry[]; + designSystems: DesignSystemEntry[]; + progressLabel: string | null; + progressUpdatedAt: string | null; + outline: string[] | null; + outlineUpdatedAt: string | null; +}; + +/** + * Aggregate "what did the agent build in *this chat*?" by walking the + * stream for tool calls. The iframe is restricted to items that appear + * here so the agent can't drag the preview onto an unrelated page from a + * prior session via PAGE_PREVIEW_SET on a stale slug. + * + * Returns: + * designSystemSlug slug of the latest DESIGN_SYSTEM_CREATE call + * pageSlug slug of the latest PAGE_PREVIEW_PAGE_CREATE call + * pageActivated true once the agent has SET / REFRESHED the page, + * meaning it's ready to be the preview (otherwise + * the design system keeps showing) + */ +type SessionItems = { + designSystemSlug: string | null; + pageSlug: string | null; + pageActivated: boolean; +}; + +type ManualArtifacts = { + designSystemSlug: string | null; + pageSlug: string | null; +}; + +/** + * Pull a page slug out of whatever PAGE_PREVIEW_SET's `path` arg looks + * like. The agent passes any of: + * "pricing" + * "pricing/index.html" + * "pages/pricing/index.html" + * "/abs/.deco/page-editor/pages/pricing/index.html" + */ +function extractPageSlugFromPath(path: string): string | null { + if (!path) return null; + const m = + path.match(/(?:^|\/)pages\/([^/?#]+)/) ?? + path.match(/^([A-Za-z0-9][\w-]*?)(?:\/|$)/); + return m?.[1] ?? null; +} + +function deriveSessionItems( + messages: Array<{ parts?: unknown[] }>, +): SessionItems { + let designSystemSlug: string | null = null; + let pageSlug: string | null = null; + let pageActivated = false; + for (const message of messages) { + for (const part of message.parts ?? []) { + const toolName = partToolName(part); + if (!toolName) continue; + const record = part as Record<string, unknown>; + const state = + typeof record.state === "string" ? record.state : "output-available"; + // Honor only *successful* tool results. `output-error` carries the + // same prefix but means the call failed — using its args would + // chase a slug that was never written to disk. + if (state !== "output-available") continue; + if (/write|edit/i.test(toolName)) { + const manualArtifacts = extractManualArtifacts(record); + if (manualArtifacts.designSystemSlug) { + designSystemSlug = manualArtifacts.designSystemSlug; + } + if (manualArtifacts.pageSlug) { + pageSlug = manualArtifacts.pageSlug; + pageActivated = true; + } + } + if (toolName === "DESIGN_SYSTEM_CREATE") { + const slug = extractToolArgString(record, "slug"); + if (slug) designSystemSlug = slug; + } else if (toolName === "PAGE_PREVIEW_PAGE_CREATE") { + const slug = extractToolArgString(record, "slug"); + if (slug) { + pageSlug = slug; + // CREATE doesn't activate by default (see service.ts: + // activate defaults to false; the page exists but the preview + // stays on the DS demo until SET / REFRESH). + pageActivated = false; + } + } else if (toolName === "PAGE_PREVIEW_SET") { + // SET to ANY slug counts. Studio side filters against the + // on-disk pages list, so a typo / stale slug still won't be + // shown — but a valid SET to an existing page will be honored. + const target = extractToolArgString(record, "path") ?? ""; + const slugFromPath = extractPageSlugFromPath(target); + if (slugFromPath) { + pageSlug = slugFromPath; + pageActivated = true; + } + } else if (toolName === "PAGE_PREVIEW_REFRESH") { + if (pageSlug) pageActivated = true; + } + } + } + return { designSystemSlug, pageSlug, pageActivated }; +} + +/** + * Pull the most recent user-authored prompt from the chat stream so the + * preview can echo it back during the "thinking" intermission (the + * window between the user hitting Send and the agent firing its first + * preview-state tool — which can be 30s–2min while the agent reads + * files, does brand-context lookups, plans, etc.). + * + * The stream's message shapes differ by transport; we probe several: + * - { role: 'user', content: '…' | [{ type: 'text', text: '…' }] } + * - { role: 'user', parts: [{ type: 'text', text: '…' } | { text: '…' }] } + */ +function deriveLatestUserPrompt( + messages: Array<Record<string, unknown>>, +): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] ?? {}; + const role = m.role; + if (role !== "user") continue; + const content = m.content; + if (typeof content === "string" && content.trim()) { + return content.trim(); + } + if (Array.isArray(content)) { + const text = content + .map((c) => { + if (typeof c === "string") return c; + if (c && typeof c === "object") { + const t = (c as Record<string, unknown>).text; + if (typeof t === "string") return t; + } + return ""; + }) + .join(" ") + .trim(); + if (text) return text; + } + const parts = (m as { parts?: unknown[] }).parts ?? []; + const partText = (parts as Array<Record<string, unknown>>) + .map((p) => { + if (typeof p.text === "string") return p.text; + if (p.type === "text" && typeof p.text === "string") return p.text; + return ""; + }) + .join(" ") + .trim(); + if (partText) return partText; + } + return null; +} + +/** + * True iff *any* preview-state tool call has fired this session — used + * to know when the "thinking" intermission should end (we have real + * progress to show now). + */ +function hasAnyPreviewToolFired( + messages: Array<{ parts?: unknown[] }>, +): boolean { + for (const message of messages) { + for (const part of message.parts ?? []) { + const toolName = partToolName(part); + if (!toolName) continue; + if (!PREVIEW_STATE_TOOLS.has(toolName)) continue; + const record = part as Record<string, unknown>; + const state = + typeof record.state === "string" ? record.state : "output-available"; + if (state === "output-available") return true; + } + } + return false; +} + +/** + * Walk the chat stream and return the latest agent-declared section outline + * for the page being built. PAGE_PREVIEW_PROGRESS takes an optional + * outline:string[] field that the host renders as a sticky stepper. + * + * Outline persists for the entire build — the agent declares it once on + * the first PROGRESS call, and that plan applies through DS_CREATE, + * PAGE_CREATE, every SET / REFRESH. A subsequent PROGRESS that passes a + * new outline replaces it. Nothing else clears it. + */ +function deriveLiveOutline( + messages: Array<{ parts?: unknown[] }>, +): string[] | null { + let outline: string[] | null = null; + for (const message of messages) { + for (const part of message.parts ?? []) { + const toolName = partToolName(part); + if (!toolName) continue; + const record = part as Record<string, unknown>; + const state = + typeof record.state === "string" ? record.state : "output-available"; + if (state !== "output-available") continue; + if (toolName === "PAGE_PREVIEW_PROGRESS") { + const candidate = extractToolArgArray(record, "outline"); + if (candidate) outline = candidate; + } + } + } + return outline; +} + +/** + * Pull an array-of-strings argument from a tool-call record. Same probing + * strategy as `extractToolArgString` but for array values. + */ +function extractToolArgArray( + record: Record<string, unknown>, + key: string, +): string[] | null { + const buckets: unknown[] = [ + record.input, + record.args, + (record.params as Record<string, unknown> | undefined)?.arguments, + ]; + for (const bucket of buckets) { + if (!bucket || typeof bucket !== "object") continue; + const value = (bucket as Record<string, unknown>)[key]; + if (Array.isArray(value)) { + const cleaned = value + .filter((v): v is string => typeof v === "string" && v.trim() !== "") + .map((s) => s.trim()); + if (cleaned.length > 0) return cleaned; + } + } + return null; +} + +/** + * Walk the chat stream in order and return the active in-flight progress + * label, if any. A PAGE_PREVIEW_PROGRESS call sets the label; any + * PROGRESS_CLEARING_TOOLS call after it clears the label (because the new + * state is now live in the iframe). + * + * Tool-call args live in different shapes depending on Claude Code's + * streaming protocol. We probe the common locations. + */ +function deriveLiveProgress( + messages: Array<{ parts?: unknown[] }>, +): string | null { + let label: string | null = null; + for (const message of messages) { + for (const part of message.parts ?? []) { + const toolName = partToolName(part); + if (!toolName) continue; + const record = part as Record<string, unknown>; + const state = + typeof record.state === "string" ? record.state : "output-available"; + // Honor only *successful* tool results. `output-error` carries the + // same prefix but means the call failed — using its args would + // chase a slug that was never written to disk. + if (state !== "output-available") continue; + if (toolName === "PAGE_PREVIEW_PROGRESS") { + const candidate = + extractToolArgString(record, "label") ?? + extractToolArgString(record, "input.label"); + if (candidate) label = candidate; + } else if (PROGRESS_CLEARING_TOOLS.has(toolName)) { + label = null; + } + } + } + return label; +} + +function extractToolArgString( + record: Record<string, unknown>, + path: string, +): string | null { + const parts = path.split("."); + // Common shapes from the Claude Code streaming protocol: + // { input: { label: "…" } } + // { args: { label: "…" } } + // { params:{ arguments: { label: "…" } } } + const buckets: unknown[] = [ + record.input, + record.args, + (record.params as Record<string, unknown> | undefined)?.arguments, + ]; + for (const bucket of buckets) { + let cur: unknown = bucket; + for (const key of parts) { + if (!cur || typeof cur !== "object") { + cur = undefined; + break; + } + cur = (cur as Record<string, unknown>)[key]; + } + if (typeof cur === "string" && cur.trim().length > 0) return cur.trim(); + } + return null; +} + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return ""; + const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (seconds < 60) return "just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86_400)}d ago`; +} + +function partToolName(part: unknown): string | null { + if (!part || typeof part !== "object") return null; + const record = part as Record<string, unknown>; + if (typeof record.toolName === "string") { + for (const toolName of PREVIEW_STATE_TOOLS) { + if (record.toolName.includes(toolName)) return toolName; + } + return record.toolName; + } + if (typeof record.name === "string") { + for (const toolName of PREVIEW_STATE_TOOLS) { + if (record.name.includes(toolName)) return toolName; + } + return record.name; + } + if (typeof record.type === "string") { + for (const toolName of PREVIEW_STATE_TOOLS) { + if ( + record.type === `tool-${toolName}` || + record.type.includes(toolName) + ) { + return toolName; + } + } + } + return null; +} + +function previewStateToolKey(messages: Array<{ parts?: unknown[] }>): string { + const keys: string[] = []; + for (const message of messages) { + for (const part of message.parts ?? []) { + const toolName = partToolName(part); + if (!toolName) continue; + const record = part as Record<string, unknown>; + const state = + typeof record.state === "string" ? record.state : "output-available"; + // Honor only *successful* tool results. `output-error` carries the + // same prefix but means the call failed — using its args would + // chase a slug that was never written to disk. + if (state !== "output-available") continue; + const manualArtifacts = extractManualArtifacts(record); + const isManualArtifactMutation = + /write|edit/i.test(toolName) && + (manualArtifacts.designSystemSlug !== null || + manualArtifacts.pageSlug !== null); + if (!DISK_MUTATING_TOOLS.has(toolName) && !isManualArtifactMutation) { + continue; + } + keys.push( + typeof record.toolCallId === "string" + ? `${toolName}:${record.toolCallId}` + : `${toolName}:${keys.length}`, + ); + } + } + return keys.join("|"); +} + +/** + * Direct file writes are not the happy path, but some agents still author + * page-editor files manually instead of calling the scaffold tools. When that + * happens we can still keep the preview alive by inferring the latest touched + * page / design-system slug from the tool payload. + */ +function extractManualArtifacts( + record: Record<string, unknown>, +): ManualArtifacts { + const haystack = JSON.stringify(record); + const designSystemMatch = haystack.match( + /(?:^|[\\/])page-editor[\\/]design-systems[\\/]([^\\/"']+)/, + ); + const pageMatch = haystack.match( + /(?:^|[\\/])page-editor[\\/]pages[\\/]([^\\/"']+)/, + ); + return { + designSystemSlug: designSystemMatch?.[1] ?? null, + pageSlug: pageMatch?.[1] ?? null, + }; +} + +export function PagePreviewTab() { + const { org } = useProjectContext(); + const { taskId, tasks } = useChatTask(); + const stream = useOptionalChatStream(); + const [refreshNonce, setRefreshNonce] = useState(0); + const [forceWelcome, setForceWelcome] = useState(false); + const [pageMenuOpen, setPageMenuOpen] = useState(false); + const [dsMenuOpen, setDsMenuOpen] = useState(false); + const [override, setOverride] = useState< + | { kind: "page"; slug: string } + | { kind: "design-system"; slug: string } + | null + >(null); + // Manage-design-systems grid view (top-level mode override). + const [gridOpen, setGridOpen] = useState(false); + // When the user toggles a different design system in the dropdown while + // a page is showing, we *retheme* the page instead of replacing it. + // This slug overrides the page's bound DS for the current view. + const [themeOverrideSlug, setThemeOverrideSlug] = useState<string | null>( + null, + ); + // Tracks whether the host iframe finished its handshake. We hold off on + // sending intent messages until the host signals readiness. + const [hostReady, setHostReady] = useState(false); + const iframeRef = useRef<HTMLIFrameElement | null>(null); + const hostUrl = `/api/${encodeURIComponent(org.slug)}/page-preview/host`; + const filesBase = `/api/${encodeURIComponent(org.slug)}/page-preview`; + const taskStatus = tasks.find((task) => task.id === taskId)?.status ?? null; + const [lastTaskStatus, setLastTaskStatus] = useState<string | null>( + taskStatus, + ); + const [lastPreviewRefreshKey, setLastPreviewRefreshKey] = useState(() => + previewStateToolKey(stream?.messages ?? []), + ); + + // Latest-value refs the message-port handler reads at fire time. The + // window message listener registers once on mount (empty deps), so it + // can't read live `stream` / `taskStatus` directly without a ref bridge. + // Re-assigning during render is safe here because the values are only + // consumed by the *external* event callback, never by this render's JSX. + const liveStreamRef = useRef(stream); + liveStreamRef.current = stream; + const liveTaskStatusRef = useRef<string | null>(taskStatus); + liveTaskStatusRef.current = taskStatus; + // Dedupe signature for the last error we auto-reported to the agent. + // Cleared when the chat task transitions back to idle so a recurring + // error on the *next* run gets re-reported. Without this, every + // PAGE_PREVIEW_REFRESH that fails the same way would fire another + // user message. + const lastAutoReportedErrorRef = useRef<string | null>(null); + + const { + data: status, + isLoading, + refetch, + } = useQuery({ + // Keep the queryKey STABLE across refetches. If we bumped a nonce into + // the key, every refetch would be a new cache entry and `status` would + // briefly be `undefined` during the new fetch — that collapses + // `pages = []`, makes `sessionPage = null`, makes `hasStreamSignal = + // false`, and the intent falls through to the welcome screen until + // the new fetch resolves. With a stable key, TanStack Query retains + // the previous `data` during `refetch()` so the iframe never flashes + // back to the welcome quiz mid-build. + queryKey: KEYS.pagePreviewStatus(org.slug), + queryFn: async () => { + const response = await fetch( + `/api/${encodeURIComponent(org.slug)}/page-preview/state`, + ); + if (!response.ok) { + throw new Error(`Failed to load page preview: ${response.status}`); + } + return (await response.json()) as PagePreviewStatus; + }, + staleTime: 0, + retry: false, + }); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect — DOM event listener; cleanup runs on unmount + useEffect(() => { + const handler = (event: MessageEvent) => { + const data = event.data; + if (!data || typeof data !== "object") return; + const type = (data as { type?: unknown }).type; + + // Host handshake. Until we see this, intent-dispatch effects buffer + // their messages (the host wouldn't have its listener installed yet). + if (type === HOST_READY_MESSAGE_TYPE) { + setHostReady(true); + return; + } + + // Welcome-quiz button posts a composed prompt to drop into the chat + // input. + if (type === PROMPT_MESSAGE_TYPE) { + const text = (data as { text?: unknown }).text; + if (typeof text !== "string" || !text.trim()) return; + composeChatInput(text); + return; + } + + // User picked a card from the host's design-system grid view. + // Apply that DS to the current page (retheme), or open its demo + // when no page is active. Then close the grid. + if (type === HOST_SELECT_DS_MESSAGE_TYPE) { + const slug = (data as { slug?: unknown }).slug; + if (typeof slug === "string" && slug) { + setThemeOverrideSlug(slug); + setGridOpen(false); + } + return; + } + + // User clicked Close in the host's design-system grid. + if (type === HOST_CLOSE_DS_GRID_MESSAGE_TYPE) { + setGridOpen(false); + return; + } + + // User clicked "Reload preview" on the host's error card. Force a + // real iframe reload — and clear host-ready so the next dispatch + // round happens only after the new host handshake. + if (type === HOST_REQUEST_REFRESH_MESSAGE_TYPE) { + setHostReady(false); + lastDispatchRef.current = null; + const f = iframeRef.current; + if (f) { + // Re-assign src to force a fresh load without remounting the + // <iframe> element (so the React ref stays valid). + // eslint-disable-next-line no-self-assign + f.src = f.src; + } + return; + } + + // Iframe runtime-error card. Two paths: + // 1. Auto-bubble: if the chat task is idle (agent has finished its + // turn) and we haven't already auto-reported this exact error, + // directly call chatStream.sendMessage with a structured user + // message. The agent picks it up like any other prompt and + // starts fixing without the user clicking anything. + // 2. Fallback: if there's no active stream OR the agent is still + // running OR we already auto-reported, compose the same prompt + // into the input so the user can hit Send themselves. + // The in-iframe error card stays mounted in both cases — it's the + // visual signal that something broke; auto-bubble just shortcuts + // the "Ask the agent" button click. + if (type === RUNTIME_ERROR_MESSAGE_TYPE) { + const payload = (data as { payload?: unknown }).payload as + | { + headline?: unknown; + location?: unknown; + message?: unknown; + } + | undefined; + if (!payload) return; + const location = + typeof payload.location === "string" && payload.location + ? payload.location + : "the preview"; + const message = + typeof payload.message === "string" + ? payload.message.slice(0, 1200) + : "(no message)"; + const headline = + typeof payload.headline === "string" ? payload.headline : "Error"; + const prompt = [ + `The preview is showing a runtime error: **${headline}** at \`${location}\`.`, + "", + "```", + message, + "```", + "", + "Please open the file referenced above, find and fix the bug, then call PAGE_PREVIEW_REFRESH so the preview reloads. Use one Edit, then refresh.", + ].join("\n"); + + const errKey = `${headline}\n${location}\n${message.slice(0, 240)}`; + const liveStream = liveStreamRef.current; + const liveStatus = liveTaskStatusRef.current; + // Agent-idle gate: status flips back to "ready" once the agent's + // turn finishes. While "in_progress" or "expired" (which the chat + // bridge treats as actively-running for our purposes) we don't + // pile on another user message — let the current turn finish. + const agentIdle = + liveStream != null && + !liveStream.isStreaming && + liveStatus !== "in_progress" && + liveStatus !== "expired"; + const alreadyReported = lastAutoReportedErrorRef.current === errKey; + if (agentIdle && !alreadyReported) { + lastAutoReportedErrorRef.current = errKey; + const doc = { + type: "doc" as const, + content: [ + { type: "paragraph", content: [{ type: "text", text: prompt }] }, + ], + }; + void liveStream.sendMessage(doc); + return; + } + // Fallback: compose into the input. User decides when to send. + composeChatInput(prompt); + return; + } + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect — reload when the agent calls preview-state tools + useEffect(() => { + const nextKey = previewStateToolKey(stream?.messages ?? []); + if (nextKey === lastPreviewRefreshKey) return; + setLastPreviewRefreshKey(nextKey); + setForceWelcome(false); + setOverride(null); + setRefreshNonce((k) => k + 1); + void refetch(); + }, [stream?.messages, lastPreviewRefreshKey, refetch]); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect — reload preview once when a task run finishes + useEffect(() => { + if (lastTaskStatus === taskStatus) return; + const wasRunning = + lastTaskStatus === "in_progress" || lastTaskStatus === "expired"; + const isRunning = taskStatus === "in_progress" || taskStatus === "expired"; + setLastTaskStatus(taskStatus); + if (wasRunning && !isRunning) { + setForceWelcome(false); + setOverride(null); + setRefreshNonce((k) => k + 1); + void refetch(); + // Agent's turn just finished — clear the auto-report dedupe so a + // recurring error on the *next* refresh can re-bubble to the agent. + // If the same error keeps firing, the agent keeps getting nudged + // until it actually fixes the bug (or the user intervenes). + lastAutoReportedErrorRef.current = null; + } + }, [lastTaskStatus, taskStatus, refetch]); + + const pages = status?.pages ?? []; + const designSystems = status?.designSystems ?? []; + const isTaskRunning = + taskStatus === "in_progress" || taskStatus === "expired"; + // The live progress label drives an animated status overlay above the + // iframe. Prefer the chat stream (instantaneous), fall back to the + // server-side state (survives tab reopens) ONLY while a task is + // actively running — otherwise a stale progressLabel from a previous + // build's last step keeps floating over a brand new chat's welcome + // screen ("Laying out the features grid…" on a fresh recruit). + const streamProgress = deriveLiveProgress(stream?.messages ?? []); + const streamOutline = deriveLiveOutline(stream?.messages ?? []); + const canUseServerFallback = isTaskRunning && !stream?.messages?.length; + const effectiveOutline = + streamOutline ?? (canUseServerFallback ? (status?.outline ?? null) : null); + const progressLabel = + streamProgress ?? + (canUseServerFallback ? (status?.progressLabel ?? null) : null); + // What did the agent build in THIS chat? The preview iframe should + // only follow these — never an old slug the agent happens to + // PAGE_PREVIEW_SET to during exploration. + const sessionItems = deriveSessionItems(stream?.messages ?? []); + + // Dropdown click handlers — these mutate UI state only. They explicitly + // do NOT bump refreshNonce / refetch, because: + // + // - The dropdown items came from the already-loaded `status`, so a + // refetch wouldn't change the list. + // - Re-keying the useQuery briefly returns `undefined` for `status` + // while the new query fetches, which makes `pages = []`, + // `overridePage = pages.find(...) === undefined`, and the intent + // transitions through `welcome` before settling. The host sees + // host:welcome → host:set-page in quick succession and the user + // ends up staring at the welcome screen. + // + // The right model: dropdown clicks are pure local-state. Status only + // refetches when the chat stream signals a meaningful change. + const handleSelectPage = (page: PageEntry) => { + setOverride({ kind: "page", slug: page.slug }); + setForceWelcome(false); + setGridOpen(false); + setThemeOverrideSlug(null); + setPageMenuOpen(false); + }; + + // Design-system dropdown click: **retheme** the current page instead of + // replacing it. When the host is on the welcome / grid view, this also + // pulls the user back to a page view (or DS demo if no page). + const handleSelectDesignSystem = (ds: DesignSystemEntry) => { + setThemeOverrideSlug(ds.slug); + setForceWelcome(false); + setGridOpen(false); + setDsMenuOpen(false); + }; + + const handleManageDesignSystems = () => { + setGridOpen(true); + setForceWelcome(false); + setDsMenuOpen(false); + }; + + const handleNewPage = () => { + setForceWelcome(true); + setGridOpen(false); + setPageMenuOpen(false); + setDsMenuOpen(false); + }; + + // Determine what to render. + const overridePage = + override?.kind === "page" + ? (pages.find((p) => p.slug === override.slug) ?? null) + : null; + const overrideDs = + override?.kind === "design-system" + ? (designSystems.find((d) => d.slug === override.slug) ?? null) + : null; + + // What goes in the iframe? Priority: + // 1. User override (dropdown click) — always wins. + // 2. Session-page (created by PAGE_PREVIEW_PAGE_CREATE in this chat) + // once the agent has SET / REFRESHED it. + // 3. Session design system (created by DESIGN_SYSTEM_CREATE in this + // chat). Default once the design system exists but the page isn't + // promoted yet. + // 4. Nothing — show the welcome quiz. + // We deliberately ignore status.activeKind / activePath when stream + // data is present: state.json persists across chats and the agent's + // PAGE_PREVIEW_SET to a stale slug should NOT yank an old page into + // the preview. + // Resolve session slugs against the loaded on-disk listings. A session + // slug that isn't represented in `pages` / `designSystems` is treated as + // non-existent and ignored — this handles the case where a tool call + // referenced a slug that didn't actually land on disk (failed call, + // typo, mid-stream output, manual cleanup, etc.). The dropdown's + // available items are always real, so manual picks keep working. + const sessionPage = sessionItems.pageSlug + ? (pages.find((p) => p.slug === sessionItems.pageSlug) ?? null) + : null; + const sessionDs = sessionItems.designSystemSlug + ? (designSystems.find((d) => d.slug === sessionItems.designSystemSlug) ?? + null) + : null; + + // Only count as a stream signal if the slug resolves to a real on-disk + // item. Otherwise we'd suppress the welcome fallback and try to load a + // page/DS that doesn't exist. + const hasStreamSignal = sessionPage !== null || sessionDs !== null; + + // Pre-compute whether any preview-state tool has fired in this stream + // (PROGRESS counts). Used below to gate the server-state fallback so it + // ONLY fires on true tab reloads (no stream activity yet) — not on a + // brand-new chat where the agent has called PROGRESS but hasn't yet + // created a DS / page. Without this gate the new chat falls back to the + // PREVIOUS chat's active page, which is what the user sees on screen. + const previewToolFiredEarly = hasAnyPreviewToolFired(stream?.messages ?? []); + + const activePage = + overridePage ?? + (sessionPage && sessionItems.pageActivated ? sessionPage : null) ?? + // Server-side state fallback — ONLY for a true cold load: + // - nothing in this session's stream (no preview tool fired yet) + // - and no override + // Covers tab reload AFTER a previous build completed. Does NOT cover + // "new chat in flight" — the agent's first PROGRESS call exits this + // fallback so we don't yank a stale page in. + (!previewToolFiredEarly && !hasStreamSignal && status?.activeKind === "page" + ? (pages.find((p) => p.relativePath === status?.activeRelativePath) ?? + null) + : null); + + const showKind: PreviewKind | null = override + ? override.kind + : activePage + ? "page" + : sessionDs + ? "design-system" + : !previewToolFiredEarly && + !hasStreamSignal && + status?.activeKind === "design-system" + ? "design-system" + : null; + + // The design-system selector should always reflect *something*: when a + // page is showing, it shows the design system that page is bound to; + // when a design system is the live preview, it shows that one. + const dsByPageBinding = activePage?.designSystem + ? (designSystems.find((d) => d.slug === activePage.designSystem) ?? null) + : null; + const activeDs = + overrideDs ?? + (showKind === "design-system" ? sessionDs : null) ?? + dsByPageBinding ?? + (showKind === "design-system" && + !hasStreamSignal && + status?.activeDesignSystem + ? (designSystems.find((d) => d.slug === status.activeDesignSystem) ?? + null) + : null); + + // "Build room" intermission. From submit until a *real page* exists, + // the preview would otherwise bounce between the welcome quiz and a + // bare design-system demo. Keep one stable shell alive instead: + // - before artifacts: rows + speculative sketch + // - once the DS exists: same shell, but the right rail becomes the + // actual design system + // - once a page is activated: hand off to the real page renderer + // + // This keeps early progress visible without pretending a page exists + // before one actually does. + const userPrompt = deriveLatestUserPrompt( + (stream?.messages ?? []) as unknown as Array<Record<string, unknown>>, + ); + const previewToolFired = hasAnyPreviewToolFired(stream?.messages ?? []); + const isBuilding = + isTaskRunning && !activePage && userPrompt !== null && !override; + + // A "fresh chat" is one where no preview-state tool has fired in the + // current stream. We default to the welcome quiz in that case, even + // when pages/design-systems already exist on disk from previous chats — + // so opening a new conversation starts at "what do you want to build?" + // not at the previous run's output. Clicking a page in the dropdown + // (sets `override`) takes the user out of fresh-chat mode. + // + // NOTE: we gate on `hasAnyPreviewToolFired` (includes PAGE_PREVIEW_PROGRESS) + // rather than `lastPreviewRefreshKey` (disk-mutating tools only) so the + // first PROGRESS call breaks the welcome state even before any disk + // write — otherwise the agent's reading/planning phase keeps the user + // staring at the quiz. + const isFreshChat = !previewToolFired && !override; + + // The effective design system for retheming: explicit override wins, + // else the page's bound DS, else the session DS, else nothing. + const effectiveDsSlug = + themeOverrideSlug ?? activePage?.designSystem ?? activeDs?.slug ?? null; + + // What does the user want the host to show? + type Intent = + | { kind: "welcome" } + | { kind: "thinking"; prompt: string } + | { kind: "page"; slug: string; dsSlug: string } + | { kind: "ds-demo"; dsSlug: string } + | { kind: "ds-grid" }; + + let intent: Intent; + if (gridOpen) { + intent = { kind: "ds-grid" }; + } else if (forceWelcome) { + intent = { kind: "welcome" }; + } else if (isBuilding) { + intent = { kind: "thinking", prompt: userPrompt! }; + } else if (isFreshChat && !hasStreamSignal) { + // Fresh chat (no preview-state tools fired, no user override) → + // welcome quiz. This must come BEFORE the status-fallback page + // branch — otherwise state.json from a previous chat's last + // edited page would yank us straight into that page instead of + // showing the quiz. + intent = { kind: "welcome" }; + } else if (activePage && effectiveDsSlug) { + intent = { + kind: "page", + slug: activePage.slug, + dsSlug: effectiveDsSlug, + }; + } else if (effectiveDsSlug) { + intent = { kind: "ds-demo", dsSlug: effectiveDsSlug }; + } else { + intent = { kind: "welcome" }; + } + + // Stable JSON key — every intent change triggers a postMessage cascade. + const intentKey = JSON.stringify(intent); + + const pageLabel = activePage ? activePage.name : "no page"; + // The dropdown label shows the EFFECTIVE design system (theme override + // first, then the binding) so the user sees what's currently applied. + const dsLabelTarget = + designSystems.find((d) => d.slug === effectiveDsSlug) ?? activeDs; + const dsLabel = dsLabelTarget ? dsLabelTarget.name : "no design system"; + + // Bridge: dispatch the right host message for the current intent. + // Differentiates "the page stayed but the theme changed" from "the + // page or mode actually changed" so a DS dropdown click triggers a + // smooth retheme (CSS-variable transition only) instead of a full + // crossfade + module reload. + const lastDispatchRef = useRef<Intent | null>(null); + // oxlint-disable-next-line ban-use-effect/ban-use-effect — message bus to iframe; needs host readiness gate + useEffect(() => { + if (!hostReady) return; + const win = iframeRef.current?.contentWindow; + if (!win) return; + const prev = lastDispatchRef.current; + lastDispatchRef.current = intent; + + // Page → page with same slug, only DS changed → retheme in place. + if ( + prev && + prev.kind === "page" && + intent.kind === "page" && + prev.slug === intent.slug && + prev.dsSlug !== intent.dsSlug + ) { + win.postMessage({ type: "host:retheme", slug: intent.dsSlug }, "*"); + return; + } + // DS-demo → DS-demo (different slug) → retheme. + if ( + prev && + prev.kind === "ds-demo" && + intent.kind === "ds-demo" && + prev.dsSlug !== intent.dsSlug + ) { + win.postMessage( + { type: "host:show-design-system", slug: intent.dsSlug }, + "*", + ); + return; + } + + if (intent.kind === "welcome") { + win.postMessage({ type: "host:welcome" }, "*"); + } else if (intent.kind === "thinking") { + win.postMessage({ type: "host:thinking", prompt: intent.prompt }, "*"); + } else if (intent.kind === "page") { + win.postMessage( + { + type: "host:set-page", + slug: intent.slug, + designSystem: intent.dsSlug, + }, + "*", + ); + } else if (intent.kind === "ds-demo") { + win.postMessage( + { type: "host:show-design-system", slug: intent.dsSlug }, + "*", + ); + } else if (intent.kind === "ds-grid") { + win.postMessage( + { + type: "host:show-design-system-grid", + designSystems: designSystems.map((d) => ({ + slug: d.slug, + name: d.name, + brand: d.brand, + })), + }, + "*", + ); + } + }, [hostReady, intentKey]); + + // When the agent triggers a refresh AND we're already on a page intent, + // tell the host to re-fetch its modules and re-render in-place (so only + // new sections animate in). + // oxlint-disable-next-line ban-use-effect/ban-use-effect — listens for chat-stream refresh signals + useEffect(() => { + if (!hostReady) return; + if (intent.kind !== "page") return; + const win = iframeRef.current?.contentWindow; + if (!win) return; + win.postMessage({ type: "host:refresh-page" }, "*"); + // intentKey is intentionally not a dep — this fires on every + // refreshNonce / refresh-tool event, even when intent stays the same. + }, [hostReady, refreshNonce, intent.kind]); + + // Keep the host's design-systems list (used by the grid view) in sync. + // oxlint-disable-next-line ban-use-effect/ban-use-effect — keeps the host's local DS cache fresh + useEffect(() => { + if (!hostReady) return; + const win = iframeRef.current?.contentWindow; + if (!win) return; + win.postMessage( + { + type: "host:update-design-systems", + designSystems: designSystems.map((d) => ({ + slug: d.slug, + name: d.name, + brand: d.brand, + })), + }, + "*", + ); + // Send filesBase once we're ready so dynamic-import URLs resolve. + win.postMessage({ type: "host:hello", filesBase }, "*"); + }, [hostReady, designSystems.length, filesBase]); + + // Pipe the live progress label, isRunning state, and agent-declared + // outline to the host. The host renders the "drafting next…" slot and + // the sticky stepper from these. We deliberately keep the Studio-side + // floating ProgressOverlay (system chrome) AND the host-side inline + // affordances (content chrome) — they serve different roles. + const outlineKey = effectiveOutline ? effectiveOutline.join("|") : ""; + // oxlint-disable-next-line ban-use-effect/ban-use-effect — bridge handshake + useEffect(() => { + if (!hostReady) return; + const win = iframeRef.current?.contentWindow; + if (!win) return; + win.postMessage( + { + type: "host:set-page-progress", + label: progressLabel, + isRunning: isTaskRunning, + outline: effectiveOutline, + buildState: { + designSystem: sessionDs + ? { + slug: sessionDs.slug, + name: sessionDs.name, + brand: sessionDs.brand, + } + : null, + pageCreated: sessionPage !== null, + pageActivated: activePage !== null, + }, + }, + "*", + ); + }, [ + hostReady, + progressLabel, + isTaskRunning, + outlineKey, + sessionDs?.slug, + sessionPage?.slug, + activePage?.slug, + ]); + + const handleExport = () => { + if (!status) return; + let target: { kind: "page" | "design-system"; slug: string } | null = null; + if (intent.kind === "page") { + target = { kind: "page", slug: intent.slug }; + } else if (intent.kind === "ds-demo") { + target = { kind: "design-system", slug: intent.dsSlug }; + } + if (!target) return; + const url = `/api/${encodeURIComponent(org.slug)}/page-preview/export?kind=${encodeURIComponent(target.kind)}&slug=${encodeURIComponent(target.slug)}`; + window.open(url, "_blank", "noopener,noreferrer"); + }; + + if (isLoading && !status) { + return ( + <div className="h-full flex items-center justify-center"> + <Loading01 size={20} className="animate-spin text-muted-foreground" /> + </div> + ); + } + + const exportDisabled = + !status || + intent.kind === "welcome" || + intent.kind === "thinking" || + intent.kind === "ds-grid" || + (intent.kind === "page" && !activePage); + + return ( + <div className="h-full flex flex-col bg-muted/20"> + <div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/50 text-xs text-muted-foreground bg-background/40"> + <div className="flex items-center gap-1 min-w-0"> + {/* Page selector */} + <DropdownMenu open={pageMenuOpen} onOpenChange={setPageMenuOpen}> + <DropdownMenuTrigger asChild> + <button + type="button" + className={cn( + "flex items-center gap-1.5 min-w-0 px-2 py-1 rounded hover:bg-foreground/5 transition cursor-pointer focus:outline-none focus:ring-1 focus:ring-foreground/20", + intent.kind === "page" + ? "text-foreground" + : "text-muted-foreground", + )} + title="Pages" + > + <FileCode01 size={12} className="shrink-0 opacity-70" /> + <span className="truncate font-mono">{pageLabel}</span> + <ChevronDown size={12} className="shrink-0 opacity-60" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-80"> + <DropdownMenuLabel className="text-xs">Pages</DropdownMenuLabel> + {pages.length === 0 ? ( + <div className="px-2 py-3 text-xs text-muted-foreground"> + No pages yet. Ask the agent to build one. + </div> + ) : ( + pages.map((p) => { + const selected = + intent.kind === "page" && activePage?.slug === p.slug; + return ( + <DropdownMenuItem + key={p.path} + onSelect={() => void handleSelectPage(p)} + className="flex items-center justify-between gap-2" + > + <span className="flex items-center gap-1.5 min-w-0"> + <Check + size={12} + className={cn( + "shrink-0", + selected ? "opacity-100" : "opacity-0", + )} + /> + <span className="truncate font-mono text-xs"> + {p.slug} + </span> + {p.designSystem && ( + <span className="text-[10px] text-muted-foreground/70 font-mono"> + · {p.designSystem} + </span> + )} + </span> + <span className="text-[10px] text-muted-foreground/70 shrink-0"> + {formatRelative(p.lastModified)} + </span> + </DropdownMenuItem> + ); + }) + )} + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={handleNewPage} className="gap-2"> + <Plus size={14} /> + New page + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + <span className="text-muted-foreground/40 px-0.5">·</span> + + {/* Design system selector */} + <DropdownMenu open={dsMenuOpen} onOpenChange={setDsMenuOpen}> + <DropdownMenuTrigger asChild> + <button + type="button" + className={cn( + "flex items-center gap-1.5 min-w-0 px-2 py-1 rounded hover:bg-foreground/5 transition cursor-pointer focus:outline-none focus:ring-1 focus:ring-foreground/20", + intent.kind === "ds-demo" || intent.kind === "ds-grid" + ? "text-foreground" + : "text-muted-foreground", + )} + title="Design systems — click to apply to the current page" + > + <Palette size={12} className="shrink-0 opacity-70" /> + <span className="truncate">{dsLabel}</span> + <ChevronDown size={12} className="shrink-0 opacity-60" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-80"> + <DropdownMenuLabel className="text-xs"> + Design systems + </DropdownMenuLabel> + {designSystems.length === 0 ? ( + <div className="px-2 py-3 text-xs text-muted-foreground"> + No design systems yet. Ask the agent to create one. + </div> + ) : ( + designSystems.map((d) => { + const selected = effectiveDsSlug === d.slug; + return ( + <DropdownMenuItem + key={d.slug} + onSelect={() => handleSelectDesignSystem(d)} + className="flex items-center justify-between gap-2" + > + <span className="flex items-center gap-1.5 min-w-0"> + <Check + size={12} + className={cn( + "shrink-0", + selected ? "opacity-100" : "opacity-0", + )} + /> + <span + className="size-3 rounded-sm shrink-0 border border-border/40" + style={{ background: d.brand?.primary ?? "#888" }} + /> + <span className="truncate text-xs">{d.name}</span> + </span> + <span className="text-[10px] text-muted-foreground/70 shrink-0"> + {formatRelative(d.lastModified)} + </span> + </DropdownMenuItem> + ); + }) + )} + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={handleManageDesignSystems} + className="gap-2" + > + <Settings02 size={14} /> + Manage design systems + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {intent.kind === "welcome" && ( + <span className="text-muted-foreground/70 truncate ml-2"> + (welcome) + </span> + )} + {intent.kind === "ds-grid" && ( + <span className="text-muted-foreground/70 truncate ml-2"> + (manage) + </span> + )} + </div> + + <button + type="button" + onClick={handleExport} + disabled={exportDisabled} + className={cn( + "flex items-center gap-1.5 px-2 py-1 rounded transition cursor-pointer focus:outline-none focus:ring-1 focus:ring-foreground/20", + exportDisabled + ? "opacity-40 cursor-not-allowed" + : "hover:bg-foreground/5", + )} + title="Download current item as .zip" + > + <Download01 size={12} className="shrink-0" /> + <span>Export</span> + </button> + </div> + <div className="relative flex-1 min-h-0"> + {/* + The host iframe is mounted ONCE per chat session. Re-keying it + would tear down the host's preact tree, lose its ready state, + and drop in-flight bridge messages — which broke "switch page in + the dropdown" since handleSelectPage bumped refreshNonce. The + host now stays mounted across every state change; explicit + reloads happen via location.reload() inside the host (Reload + preview button on the error card) and bubble back as + page-editor:host-request-refresh, NOT via remount. + */} + <iframe + ref={iframeRef} + key="page-preview-host" + title="Page preview" + src={hostUrl} + className="absolute inset-0 w-full h-full border-0 bg-white" + sandbox="allow-scripts allow-same-origin allow-forms allow-popups" + /> + <ProgressOverlay + // When the task is running but no progress label is set (the gap + // between PROGRESS calls, e.g. while the agent does Read/Edit), + // fall back to a generic "Working…" so the user can see the + // agent is alive. Without this fallback the floating pill + // disappears entirely and the DS demo looks frozen. + label={progressLabel ?? (isTaskRunning ? "Working…" : null)} + showSpinner={isTaskRunning} + // When the host is in welcome / ds-grid mode there's no + // live page underneath — show the full overlay. For page/demo + // modes use the small floating pill so the user can see the + // content take shape behind it. + variant={ + intent.kind === "welcome" || intent.kind === "ds-grid" + ? "full" + : "floating" + } + /> + </div> + </div> + ); +} + +/* --------------------------------------------------------------------------- + * Progress overlay + * + * Driven by PAGE_PREVIEW_PROGRESS tool calls in the chat stream. Renders an + * animated card with a soft spinner and the agent's current step label. + * Smoothly fades in when a new label arrives and fades out when a + * scaffold/refresh tool clears it (the iframe content beneath then takes + * over with its own staggered-reveal CSS). + * + * variant="full" — full-bleed dark glass card centered, used when the + * iframe is the welcome quiz (no useful content + * underneath). + * variant="floating" — small bottom-center pill over the live preview so + * the user can see the page taking shape behind it. + * ------------------------------------------------------------------------- */ + +function ProgressOverlay({ + label, + showSpinner, + variant, +}: { + label: string | null; + showSpinner: boolean; + variant: "full" | "floating"; +}) { + // Track the displayed label separately so we can keep rendering it + // during the fade-out animation after `label` becomes null. + const [displayed, setDisplayed] = useState<string | null>(label); + // oxlint-disable-next-line ban-use-effect/ban-use-effect — drives crossfade timing for the label text + useEffect(() => { + if (label) { + setDisplayed(label); + return; + } + const id = setTimeout(() => setDisplayed(null), 320); + return () => clearTimeout(id); + }, [label]); + + const visible = !!label; + if (!displayed && !visible) return null; + + if (variant === "full") { + return ( + <div + className={cn( + "pointer-events-none absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-300 ease-out", + visible ? "opacity-100" : "opacity-0", + )} + aria-live="polite" + > + <div + className="absolute inset-0" + style={{ + background: + "radial-gradient(60% 50% at 50% 35%, rgba(10,10,15,0.78) 0%, rgba(10,10,15,0.55) 50%, rgba(10,10,15,0.0) 100%)", + backdropFilter: "blur(4px)", + WebkitBackdropFilter: "blur(4px)", + }} + /> + <div className="relative flex items-center gap-3 px-5 py-3 rounded-full bg-black/55 border border-white/10 text-white shadow-[0_8px_30px_rgba(0,0,0,0.35)]"> + {showSpinner && <Spinner />} + <span + key={displayed ?? ""} + className="text-sm font-medium tracking-wide animate-in fade-in slide-in-from-bottom-1 duration-300" + > + {displayed} + </span> + </div> + </div> + ); + } + + // Floating pill at the bottom center. + return ( + <div + className={cn( + "pointer-events-none absolute inset-x-0 bottom-5 z-10 flex justify-center transition-all duration-300 ease-out", + visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2", + )} + aria-live="polite" + > + <div className="flex items-center gap-2.5 px-4 py-2 rounded-full bg-black/72 border border-white/10 text-white shadow-[0_10px_30px_rgba(0,0,0,0.35)] backdrop-blur-md"> + {showSpinner && <Spinner small />} + <span + key={displayed ?? ""} + className="text-[13px] font-medium tracking-wide animate-in fade-in slide-in-from-bottom-1 duration-300" + > + {displayed} + </span> + </div> + </div> + ); +} + +function Spinner({ small }: { small?: boolean }) { + const size = small ? 12 : 14; + return ( + <svg + width={size} + height={size} + viewBox="0 0 24 24" + className="animate-spin shrink-0 text-white" + aria-hidden="true" + > + <circle + cx="12" + cy="12" + r="9" + fill="none" + stroke="currentColor" + strokeOpacity="0.25" + strokeWidth="3" + /> + <path + d="M21 12a9 9 0 0 0-9-9" + fill="none" + stroke="currentColor" + strokeWidth="3" + strokeLinecap="round" + /> + </svg> + ); +} diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/resolve-tab-icon.ts b/apps/mesh/src/web/layouts/main-panel-tabs/resolve-tab-icon.ts index 6ded045df3..f52e9dc1b4 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/resolve-tab-icon.ts +++ b/apps/mesh/src/web/layouts/main-panel-tabs/resolve-tab-icon.ts @@ -2,6 +2,7 @@ import type { ComponentType, SVGProps } from "react"; import { GitBranch01, Globe01, + LayoutAlt03, LayoutAlt04, Lightning01, } from "@untitledui/icons"; @@ -16,13 +17,19 @@ export type TabIcon = | { kind: "url"; src: string } | { kind: "fallback" }; -export type SystemTabId = "settings" | "automations" | "preview" | "git"; +export type SystemTabId = + | "settings" + | "automations" + | "preview" + | "git" + | "page-preview"; export const SYSTEM_TAB_ICONS: Record<SystemTabId, IconComponent> = { settings: LayoutAlt04, automations: Lightning01, preview: Globe01, git: GitBranch01, + "page-preview": LayoutAlt03, }; type ConnectionLike = { id: string; icon: string | null }; diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts b/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts index 2af5f02686..ab18b6f9b0 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts +++ b/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts @@ -72,6 +72,7 @@ export const FIXED_SYSTEM_TABS = [ "automations", "preview", "git", + "page-preview", ] as const; const FIXED_SYSTEM_TAB_SET = new Set<string>(FIXED_SYSTEM_TABS); diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/use-main-panel-tabs.ts b/apps/mesh/src/web/layouts/main-panel-tabs/use-main-panel-tabs.ts index 955cdb6cf7..7759d7cc51 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/use-main-panel-tabs.ts +++ b/apps/mesh/src/web/layouts/main-panel-tabs/use-main-panel-tabs.ts @@ -148,8 +148,15 @@ export function useMainPanelTabs(ctx: { // Unified "settings" tab bundles instructions, connections, and layout // into a single detail view. On GitHub-linked vMCPs the contextual // work tabs (Preview, git) come first so they're closest to the panel; - // Settings + Automations stay anchored at the right. + // Settings + Automations stay anchored at the right. The Page Editor's + // own preview tab follows the same "closest to the panel" rule when its + // agent opts in via defaultMainView.type === "page-preview". + const wantsPagePreview = + entityLayout?.defaultMainView?.type === "page-preview"; const systemTabs: Array<{ id: string; title: string }> = []; + if (wantsPagePreview) { + systemTabs.push({ id: "page-preview", title: "Page Preview" }); + } if (hasActiveGithubRepo) { systemTabs.push({ id: "preview", title: "Preview" }); systemTabs.push({ id: "git", title: currentBranch ?? "git" }); diff --git a/apps/mesh/src/web/lib/chat-input-bridge.ts b/apps/mesh/src/web/lib/chat-input-bridge.ts new file mode 100644 index 0000000000..dbf4b884c4 --- /dev/null +++ b/apps/mesh/src/web/lib/chat-input-bridge.ts @@ -0,0 +1,40 @@ +/** + * Chat input bridge — lets non-input components (today: the Page Editor + * preview iframe handler) compose text into the active chat input + * without coupling them to ChatInput's local state. + * + * ChatInput hands us the *ref object* it uses to talk to its tiptap + * editor — not the value of `ref.current`. We dereference at call time + * (when someone clicks a prompt card), which happens long after the + * editor's `useImperativeHandle` has populated the ref. Registering + * `ref.current` from a `useEffect([])` race-loses because the parent + * effect can fire before the child's `useImperativeHandle`. + * + * Module-level singleton: only one chat input is mounted at a time in + * the agent shell, so this is safe. + */ + +import type { RefObject } from "react"; +import type { TiptapInputHandle } from "@/web/components/chat/tiptap/input"; + +let activeHandleRef: RefObject<TiptapInputHandle | null> | null = null; + +export function setActiveChatInputHandleRef( + ref: RefObject<TiptapInputHandle | null> | null, +) { + activeHandleRef = ref; +} + +/** + * Replace the current chat input text with `text` and focus it. + * Returns true when the bridge had a registered handle, false otherwise + * (caller can fall back to a notification or no-op). + */ +export function composeChatInput(text: string): boolean { + const handle = activeHandleRef?.current; + if (!handle) return false; + handle.clear(); + handle.appendText(text); + handle.focus(); + return true; +} diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 0dc0eafb9e..a072e2e433 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -327,6 +327,9 @@ export const KEYS = { defaultBrand: (organizationId: string) => ["brand-context", organizationId, "default"] as const, + // Page Editor local preview + pagePreviewStatus: (orgSlug: string) => ["page-preview", orgSlug] as const, + // Deco profile (scoped by user email) decoProfile: (email: string | undefined) => ["deco-profile", email] as const, diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 188399a055..38fdc68e1a 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -833,6 +833,12 @@ function LayoutTabContent({ if (hasGithubRepo) { defaultMainOptions.push({ value: "preview", label: "Preview" }); } + // Surface "Page preview" only when this agent already uses it — keeps the + // dropdown clean for non-Page-Editor agents that wouldn't know what it + // points to. + if (currentDefaultMain?.type === "page-preview") { + defaultMainOptions.push({ value: "page-preview", label: "Page preview" }); + } for (const pv of pinnedViews) { defaultMainOptions.push({ value: `ext-apps:${pv.connectionId}:${pv.toolName}`, diff --git a/bun.lock b/bun.lock index 37697efadc..870b8d11ff 100644 --- a/bun.lock +++ b/bun.lock @@ -53,7 +53,7 @@ }, "apps/mesh": { "name": "decocms", - "version": "2.326.3", + "version": "2.327.0", "bin": { "deco": "./dist/server/cli.js", }, @@ -80,6 +80,7 @@ "ai-sdk-provider-claude-code": "^3.4.4", "ai-sdk-provider-codex-cli": "^1.1.0", "embedded-postgres": "^18.3.0-beta.16", + "fflate": "^0.8.0", "ink": "^6.8.0", "kysely": "^0.28.12", "nats": "^2.29.3", @@ -2150,7 +2151,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], @@ -3814,6 +3815,8 @@ "posthog-js/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], + "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="], diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index fa5b7b6843..dad8571af1 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -353,6 +353,12 @@ export const WELL_KNOWN_AGENT_TEMPLATES = [ icon: "icon://SearchMd?color=green", type: "builtin-agent" as const, }, + { + id: "page-editor", + title: "Page Editor", + icon: "icon://LayoutAlt03?color=violet", + type: "builtin-agent" as const, + }, ] as const; export type WellKnownAgentTemplate =