Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
069486e
feat(page-editor): add Page Editor agent with design systems and live…
vibegui May 15, 2026
185d453
feat(page-editor): agent-driven progress choreography in preview pane
vibegui May 15, 2026
4c18e42
feat(page-editor): section-by-section assembly + richer sections library
vibegui May 15, 2026
2b38afe
feat(page-editor): in-iframe error card with "Ask the agent to fix th…
vibegui May 15, 2026
9429d1d
feat(page-editor): isolate preview to the current chat's build
vibegui May 15, 2026
2072869
fix(page-editor): synthesize Sections namespace in export bundle
vibegui May 15, 2026
aea6e16
feat(page-editor): host-iframe + Studio postMessage bridge
vibegui May 15, 2026
46d56a1
fix(page-editor): stable host iframe + resilient bridge dispatch
vibegui May 15, 2026
939723f
fix(page-editor): auto-derive filesBase in host + clear error card on…
vibegui May 15, 2026
3b5438b
fix(page-editor): dropdown clicks no longer flash to welcome
vibegui May 15, 2026
a54fd5c
feat(page-editor): thinking intermission + PAGE_PREVIEW_SET honors an…
vibegui May 15, 2026
31bcada
fix(page-editor): restore fresh-chat welcome priority over status-fal…
vibegui May 15, 2026
a3ba1a5
fix(page-editor): stop iframe flashing to welcome mid-build
vibegui May 15, 2026
1c1fa23
feat(page-editor): scroll newly-added sections into view
vibegui May 15, 2026
c6b1026
feat(page-editor): persistent shell + outline stepper + DS bridge + t…
vibegui May 15, 2026
07a5767
fix(page-editor): don't show stale server progressLabel on idle chat
vibegui May 15, 2026
b310605
chore(page-editor): drop dead exports flagged by knip
vibegui May 15, 2026
3db82ed
fix(page-editor): outline persists past PAGE_CREATE + stepper in ds-demo
vibegui May 15, 2026
8e4f083
fix(page-editor): forbid scaffold reads + fallback Working pill
vibegui May 15, 2026
6c9350e
refactor(page-editor): unify ds-demo into page mode with inline gallery
vibegui May 15, 2026
aa7622e
fix(page-editor): demo-ready end-to-end flow
vibegui May 15, 2026
719594c
feat(page-editor): seven-question welcome quiz with optional details
vibegui May 15, 2026
0c7c5c2
feat(page-editor): interactive stepper — time-travel preview while bu…
vibegui May 15, 2026
98826df
feat(page-editor): auto-bubble preview runtime errors to the agent
vibegui May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/mesh/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
1 change: 1 addition & 0 deletions apps/mesh/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
280 changes: 280 additions & 0 deletions apps/mesh/scripts/test-page-preview-mcp.ts
Original file line number Diff line number Diff line change
@@ -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/<agentId> 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/<org>/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<string> {
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<string, string> {
const h: Record<string, string> = {
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<string, unknown>,
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 ?? "<none>"}`);

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<string, unknown>,
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);
});
12 changes: 10 additions & 2 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions apps/mesh/src/api/routes/org-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
"/",
Expand Down
Loading
Loading