From d74b9cee297d36ee61130cac1aa878aecc56af8e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:49:10 -0700 Subject: [PATCH 01/30] Add the Artifacts console pages and embed the MCP-Apps shell --- apps/cloud/src/routeTree.gen.ts | 65 ++++++ apps/cloud/src/routes/__root.tsx | 5 +- apps/cloud/vite.config.ts | 4 + apps/host-cloudflare/vite.config.ts | 4 + apps/host-cloudflare/web/routeTree.gen.ts | 61 +++++ apps/host-cloudflare/web/routes/__root.tsx | 9 +- apps/host-selfhost/vite.config.ts | 4 + apps/host-selfhost/web/routeTree.gen.ts | 61 +++++ apps/host-selfhost/web/routes/__root.tsx | 9 +- bun.lock | 1 + packages/app/package.json | 1 + packages/app/src/routeTree.gen.ts | 58 +++++ packages/app/src/routes/__root.tsx | 9 +- packages/app/src/web/shell.tsx | 7 + packages/app/vite.ts | 4 + packages/core/api/src/account/org-slug.ts | 5 +- packages/hosts/mcp-apps-shell/package.json | 8 + .../src/shell/artifact-renderer.tsx | 35 +++ .../mcp-apps-shell/src/shell/shell-app.tsx | 6 + packages/hosts/mcp-apps-shell/src/vite.ts | 77 +++++++ .../hosts/mcp-apps-shell/vite.config.shell.ts | 37 +-- packages/react/package.json | 1 + packages/react/src/api/analytics.tsx | 5 + packages/react/src/api/artifact-renderer.tsx | 56 +++++ packages/react/src/api/atoms.tsx | 59 +++++ packages/react/src/api/reactivity-keys.tsx | 6 + packages/react/src/api/shell-host.ts | 167 ++++++++++++++ packages/react/src/console-routes.ts | 4 + packages/react/src/lib/relative-time.test.ts | 37 +++ packages/react/src/lib/relative-time.ts | 19 ++ packages/react/src/multiplayer/shell.tsx | 1 + packages/react/src/pages/artifact-detail.tsx | 190 ++++++++++++++++ .../src/pages/artifact-rename-dialog.tsx | 109 +++++++++ packages/react/src/pages/artifacts.tsx | 212 ++++++++++++++++++ packages/react/src/routes/artifacts-route.tsx | 15 ++ .../src/routes/artifacts.$artifactId.tsx | 12 + packages/react/src/routes/artifacts.tsx | 15 ++ packages/react/src/routes/routeTree.gen.ts | 53 +++++ packages/react/src/styles/globals.css | 5 + 39 files changed, 1397 insertions(+), 39 deletions(-) create mode 100644 packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/vite.ts create mode 100644 packages/react/src/api/artifact-renderer.tsx create mode 100644 packages/react/src/api/shell-host.ts create mode 100644 packages/react/src/lib/relative-time.test.ts create mode 100644 packages/react/src/lib/relative-time.ts create mode 100644 packages/react/src/pages/artifact-detail.tsx create mode 100644 packages/react/src/pages/artifact-rename-dialog.tsx create mode 100644 packages/react/src/pages/artifacts.tsx create mode 100644 packages/react/src/routes/artifacts-route.tsx create mode 100644 packages/react/src/routes/artifacts.$artifactId.tsx create mode 100644 packages/react/src/routes/artifacts.tsx diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e689e72b7..0711b978b 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -19,11 +19,13 @@ import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as OrgRouteImport } from './routes/app/org' import { Route as BillingRouteImport } from './routes/app/billing' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' const SetupMcpRoute = SetupMcpRouteImport.update({ @@ -80,6 +82,12 @@ const BillingRoute = BillingRouteImport.update({ path: '/{-$orgSlug}/billing', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/{-$orgSlug}/api-keys', path: '/{-$orgSlug}/api-keys', @@ -112,6 +120,15 @@ const Billing_DotplansRoute = Billing_DotplansRouteImport.update({ path: '/{-$orgSlug}/billing/plans', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport.update( { @@ -126,6 +143,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -133,6 +151,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute @@ -144,6 +163,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -151,6 +171,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute @@ -163,6 +184,7 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -170,6 +192,7 @@ export interface FileRoutesById { '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute @@ -183,6 +206,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -190,6 +214,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -201,6 +226,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -208,6 +234,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -219,6 +246,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -226,6 +254,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing_/plans' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -238,6 +267,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute SetupMcpRoute: typeof SetupMcpRoute ApiKeysRoute: typeof ApiKeysRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren BillingRoute: typeof BillingRoute OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -323,6 +353,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BillingRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/api-keys': { id: '/{-$orgSlug}/api-keys' path: '/{-$orgSlug}/api-keys' @@ -358,6 +395,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Billing_DotplansRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/integrations/add/$pluginKey': { id: '/{-$orgSlug}/integrations/add/$pluginKey' path: '/{-$orgSlug}/integrations/add/$pluginKey' @@ -368,6 +412,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -388,6 +447,8 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, SetupMcpRoute: SetupMcpRoute, ApiKeysRoute: ApiKeysRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, BillingRoute: BillingRoute, OrgRoute: OrgRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: @@ -411,11 +472,15 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' + import type { startInstance } from './start.ts' + declare module '@tanstack/react-start' { interface Register { ssr: true + router: Awaited> + config: Awaited> } } diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index f508a316c..58d965e2b 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -20,6 +20,7 @@ import { EXECUTOR_ORG_HEADER } from "@executor-js/react/api/server-connection"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; import { AuthProvider, useAuth } from "../web/auth"; @@ -302,7 +303,9 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { (a foreign slug already 404'd above). The gate only keeps the URL canonical — bare → /. */} - + + + diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 83be48137..b5499014a 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -5,6 +5,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin } from "@executor-js/mcp-apps-shell/vite"; import { workerBundlerArtifact } from "@executor-js/plugin-apps/vite"; import { unstable_readConfig } from "wrangler"; @@ -128,6 +129,9 @@ export default defineConfig(({ command, mode }) => { devCrashGuard(), workerBundlerArtifact(), tailwindcss(), + // The artifact page embeds the MCP-Apps shell, whose sandboxed inner frame + // is inlined from `virtual:executor-inner-renderer`. + innerRendererPlugin(), executorVitePlugin(), cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), tanstackStart({ diff --git a/apps/host-cloudflare/vite.config.ts b/apps/host-cloudflare/vite.config.ts index eff4d12c4..baaacbcd7 100644 --- a/apps/host-cloudflare/vite.config.ts +++ b/apps/host-cloudflare/vite.config.ts @@ -6,6 +6,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes"; @@ -60,6 +61,9 @@ export default defineConfig({ }, plugins: [ tailwindcss(), + // The artifact page embeds the MCP-Apps shell, whose sandboxed inner frame + // is inlined from `virtual:executor-inner-renderer`. + innerRendererPlugin(), executorVitePlugin({ configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), }), diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 4938fe854..1c79193d8 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -14,9 +14,11 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' @@ -50,6 +52,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( { @@ -75,6 +83,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -93,11 +110,13 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -105,11 +124,13 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -118,11 +139,13 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -132,11 +155,13 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -144,11 +169,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -156,11 +183,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -169,6 +198,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -217,6 +247,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -238,6 +275,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -255,6 +299,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -271,6 +330,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx index eaf4e356a..cad2fd5ec 100644 --- a/apps/host-cloudflare/web/routes/__root.tsx +++ b/apps/host-cloudflare/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -78,7 +79,13 @@ function AuthenticatedApp() { wrangler.jsonc run_worker_first), so a slug-pinned URL would fall through to the SPA. */} - {organization ? {gated} : gated} + + {organization ? ( + {gated} + ) : ( + gated + )} + diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 9cf280edd..4949aabc3 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -7,6 +7,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes"; import { MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./src/mcp/org-path"; @@ -230,6 +231,9 @@ export default defineConfig({ plugins: [ executorApiPlugin(), tailwindcss(), + // The artifact page embeds the MCP-Apps shell, whose sandboxed inner frame + // is inlined from `virtual:executor-inner-renderer`. + innerRendererPlugin(), executorVitePlugin({ configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), }), diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index e7c9fbb94..3f601e7f8 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -14,12 +14,14 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' @@ -53,6 +55,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/{-$orgSlug}/api-keys', path: '/{-$orgSlug}/api-keys', @@ -93,6 +101,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -114,11 +131,13 @@ export interface FileRoutesByFullPath { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -129,11 +148,13 @@ export interface FileRoutesByTo { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -145,11 +166,13 @@ export interface FileRoutesById { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -162,11 +185,13 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -177,11 +202,13 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -192,11 +219,13 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -208,6 +237,7 @@ export interface RootRouteChildren { JoinDotcodeRoute: typeof JoinDotcodeRoute AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -256,6 +286,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/api-keys': { id: '/{-$orgSlug}/api-keys' path: '/{-$orgSlug}/api-keys' @@ -298,6 +335,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -315,6 +359,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -334,6 +393,8 @@ const rootRouteChildren: RootRouteChildren = { JoinDotcodeRoute: JoinDotcodeRoute, AdminRoute: AdminRoute, ApiKeysRoute: ApiKeysRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index f80045010..b1fbc6cc7 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -167,7 +168,13 @@ function AuthenticatedApp() { a slug-pinned URL would 404, and a single-org instance has nothing to select anyway. */} - {organization ? {gated} : gated} + + {organization ? ( + {gated} + ) : ( + gated + )} + diff --git a/bun.lock b/bun.lock index b4cadf66d..1d0a336e8 100644 --- a/bun.lock +++ b/bun.lock @@ -428,6 +428,7 @@ "version": "1.4.4", "dependencies": { "@effect/atom-react": "catalog:", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", diff --git a/packages/app/package.json b/packages/app/package.json index a0cc4ad00..b8936d7e6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@effect/atom-react": "catalog:", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index 67b69cc33..2ddc731f1 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -14,9 +14,11 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../ import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' +import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' +import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey' @@ -49,6 +51,12 @@ const DotDotDotDotDotDotReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update({ id: '/$toolkitSlug', @@ -67,6 +75,12 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = path: '/{-$orgSlug}/integrations/$namespace', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport.update({ + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesArtifactsRoute, + } as any) const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({ id: '/{-$orgSlug}/plugins/$pluginId/$', @@ -83,11 +97,13 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -95,11 +111,13 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -108,11 +126,13 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute @@ -122,11 +142,13 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -134,11 +156,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -146,11 +170,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -159,6 +185,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren @@ -207,6 +234,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -228,6 +262,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -245,6 +286,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -261,6 +317,8 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, diff --git a/packages/app/src/routes/__root.tsx b/packages/app/src/routes/__root.tsx index b1995ac3a..020053557 100644 --- a/packages/app/src/routes/__root.tsx +++ b/packages/app/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { ExecutorProvider } from "@executor-js/react/api/provider"; import { LocalAuthGate } from "@executor-js/react/api/local-auth"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { Toaster } from "@executor-js/react/components/sonner"; +import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { Shell } from "../web/shell"; @@ -35,9 +36,11 @@ function RootComponent() { return ( - - - + + + + + diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index 37740ecc4..4085432e1 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -162,6 +162,7 @@ function SidebarContent(props: { const isSecrets = props.pathname === "/secrets"; const isPolicies = props.pathname === "/policies"; const isToolkits = props.pathname === "/toolkits" || props.pathname.startsWith("/toolkits/"); + const isArtifacts = props.pathname === "/artifacts" || props.pathname.startsWith("/artifacts/"); return ( <> @@ -201,6 +202,12 @@ function SidebarContent(props: { active={isToolkits} onNavigate={props.onNavigate} /> + diff --git a/packages/app/vite.ts b/packages/app/vite.ts index 246fe513e..09f5fcad5 100644 --- a/packages/app/vite.ts +++ b/packages/app/vite.ts @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes.ts"; @@ -51,6 +52,9 @@ export default function appPlugin(options: AppPluginOptions = {}): PluginOption[ }, }, tailwindcss(), + // The artifact page embeds the MCP-Apps shell, whose sandboxed inner frame + // is inlined from `virtual:executor-inner-renderer`. + innerRendererPlugin() as PluginOption, executorVitePlugin({ ...(options.executorConfigPath ? { configPath: options.executorConfigPath } : {}), ...(options.executorJsoncPath ? { jsoncPath: options.executorJsoncPath } : {}), diff --git a/packages/core/api/src/account/org-slug.ts b/packages/core/api/src/account/org-slug.ts index aa8557f8c..446893dad 100644 --- a/packages/core/api/src/account/org-slug.ts +++ b/packages/core/api/src/account/org-slug.ts @@ -17,7 +17,8 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; * predictably will): * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost * envelope, cloudflare `run_worker_first`) - * - Console routes: integrations, policies, secrets, tools, resume, + * - Console routes: integrations, policies, secrets, tools, toolkits, + * artifacts, resume, * plugins (the shared contract), plus host extras: * api-keys, org, billing, create-org, setup-mcp (cloud), * admin, join, docs (selfhost), login (Better Auth @@ -45,6 +46,8 @@ export const RESERVED_ORG_SLUGS: ReadonlySet = new Set([ "policies", "secrets", "tools", + "toolkits", + "artifacts", "resume", "plugins", "api-keys", diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index ac2d8453d..867d84d43 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -12,6 +12,10 @@ "types": "./src/shell-html.ts", "default": "./src/shell-html.ts" }, + "./vite": { + "types": "./src/vite.ts", + "default": "./src/vite.ts" + }, "./shell/components": { "types": "./src/shell/components.ts", "default": "./src/shell/components.ts" @@ -20,6 +24,10 @@ "types": "./src/shell/shell-app.tsx", "default": "./src/shell/shell-app.tsx" }, + "./shell/artifact-renderer": { + "types": "./src/shell/artifact-renderer.tsx", + "default": "./src/shell/artifact-renderer.tsx" + }, "./shell/proxy": { "types": "./src/shell/proxy.ts", "default": "./src/shell/proxy.ts" diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx new file mode 100644 index 000000000..308011713 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -0,0 +1,35 @@ +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; +import type { ArtifactRendererProps } from "@executor-js/react/api/artifact-renderer"; + +import { McpAppsShell, type McpAppsShellHost } from "./shell-app"; + +/** + * Binds the MCP-Apps shell to the console's artifact page. + * + * The console declares an artifact-renderer seam it cannot fill itself: this + * package depends on `@executor-js/react` for its component barrel, so the + * reverse import would close a package cycle. App composition roots — which + * already depend on both — mount this provider to complete the wiring. + * + * ```tsx + * import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; + * + * + * + * + * ``` + */ +export function ArtifactShellProvider(props: { readonly children: React.ReactNode }) { + return ( + {props.children} + ); +} + +/** + * The stored source is passed as `initialCode` rather than replayed through a + * synthetic `ontoolresult`: on this page there is no MCP client to deliver a + * tool result, and the shell already treats `initialCode` as the render trigger. + */ +function ArtifactShell(props: ArtifactRendererProps) { + return ; +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx index 470f90d28..0f3180c26 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -1,3 +1,9 @@ +// `virtual:executor-inner-renderer` is supplied by `innerRendererPlugin` +// (`@executor-js/mcp-apps-shell/vite`). Referenced explicitly — and first, as +// triple-slash directives are only honored above the imports — so the ambient +// declaration travels with this file into consumers that bundle the shell; +// a consumer's tsconfig `include` covers only its own sources. +/// import "./globals.css"; import "@tailwindcss/browser"; diff --git a/packages/hosts/mcp-apps-shell/src/vite.ts b/packages/hosts/mcp-apps-shell/src/vite.ts new file mode 100644 index 000000000..de0aedb29 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/vite.ts @@ -0,0 +1,77 @@ +/** + * The Vite plugin that supplies `virtual:executor-inner-renderer`. + * + * The shell renders model-written JSX inside a nested `srcDoc` iframe whose + * whole program is inlined as a string. That string is the inner renderer, + * bundled to an IIFE by esbuild — it cannot be a normal import, because it must + * exist as *source text* to be embedded in the sandboxed frame. + * + * It lives here rather than in `@executor-js/vite-plugin` because that package + * is on the plugin-system kill list, and because esbuild and the renderer entry + * are both this package's own — resolving them from here needs no cross-package + * path guessing. Apps that bundle the shell into their SPA add this plugin to + * their vite config; the standalone shell build uses it too, so there is one + * definition of how the inner renderer is built. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +/** The structural shape of the Vite plugin object, declared locally so this + * module imports no Vite types (apps pin their own Vite version). */ +export interface InnerRendererVitePlugin { + readonly name: string; + readonly enforce?: "pre"; + readonly resolveId: (id: string) => string | undefined; + readonly load: (id: string) => Promise; +} + +const VIRTUAL_ID = "virtual:executor-inner-renderer"; +const RESOLVED_ID = `\0${VIRTUAL_ID}`; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const innerRendererEntry = (): string => + path.resolve(packageRoot, "src/shell/inner-renderer.tsx"); + +/** + * Bundle the inner renderer to a self-contained IIFE string. + * + * Exported on its own so a build script or test can produce the same bytes the + * plugin serves without standing up Vite. + */ +export const bundleInnerRenderer = async (): Promise => { + const result = await build({ + entryPoints: [innerRendererEntry()], + absWorkingDir: packageRoot, + bundle: true, + write: false, + format: "iife", + platform: "browser", + target: "es2022", + jsx: "automatic", + define: { + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), + }, + }); + + const js = result.outputFiles[0]; + if (!js) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Vite plugin hooks report build failures by throwing + throw new Error("Failed to bundle the MCP-Apps inner renderer."); + } + return js.text; +}; + +/** Add to an app's `plugins` array to make the shell bundleable in that app. */ +export const innerRendererPlugin = (): InnerRendererVitePlugin => ({ + name: "executor-inner-renderer-source", + // `pre` so the virtual id resolves before any generic resolver claims it. + enforce: "pre", + resolveId: (id) => (id === VIRTUAL_ID ? RESOLVED_ID : undefined), + load: async (id) => { + if (id !== RESOLVED_ID) return undefined; + return `export default ${JSON.stringify(await bundleInnerRenderer())};`; + }, +}); diff --git a/packages/hosts/mcp-apps-shell/vite.config.shell.ts b/packages/hosts/mcp-apps-shell/vite.config.shell.ts index 9b26a140b..856f79149 100644 --- a/packages/hosts/mcp-apps-shell/vite.config.shell.ts +++ b/packages/hosts/mcp-apps-shell/vite.config.shell.ts @@ -4,40 +4,13 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { viteSingleFile } from "vite-plugin-singlefile"; import path from "node:path"; -import { build } from "esbuild"; -function innerRendererSourcePlugin(): Plugin { - const publicId = "virtual:executor-inner-renderer"; - const resolvedId = `\0${publicId}`; +import { innerRendererPlugin } from "./src/vite"; - return { - name: "executor-inner-renderer-source", - resolveId(id) { - return id === publicId ? resolvedId : undefined; - }, - async load(id) { - if (id !== resolvedId) return undefined; - - const result = await build({ - entryPoints: [path.resolve(__dirname, "src/shell/inner-renderer.tsx")], - absWorkingDir: __dirname, - bundle: true, - write: false, - format: "iife", - platform: "browser", - target: "es2022", - jsx: "automatic", - define: { - "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), - }, - }); - - const js = result.outputFiles[0]; - if (!js) throw new Error("Failed to bundle inner renderer."); - return `export default ${JSON.stringify(js.text)};`; - }, - }; -} +// The standalone shell build and the app builds that embed the shell must +// produce the SAME inner-renderer bytes, so both go through the one plugin +// exported from `./src/vite` (`@executor-js/mcp-apps-shell/vite`). +const innerRendererSourcePlugin = (): Plugin => innerRendererPlugin() as Plugin; export default defineConfig({ plugins: [innerRendererSourcePlugin(), react(), tailwindcss(), viteSingleFile()], diff --git a/packages/react/package.json b/packages/react/package.json index e3342de75..37ab3f5b4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -10,6 +10,7 @@ "default": "./dist/console-routes.js" }, "./api/oauth-popup": "./src/api/oauth-popup.ts", + "./api/shell-host": "./src/api/shell-host.ts", "./api/*": "./src/api/*.tsx", "./plugins/*": "./src/plugins/*.tsx", "./pages/*": "./src/pages/*.tsx", diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 6addc8482..57933903d 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -120,6 +120,11 @@ export interface AnalyticsEvents { success: boolean; }; + // ── Artifacts page ─────────────────────────────────────────────────────── + artifact_opened: { surface: "list" | "deep_link" }; + artifact_renamed: { success: boolean }; + artifact_removed: { success: boolean }; + // ── API keys ───────────────────────────────────────────────────────────── api_key_created: { success: boolean }; api_key_revoked: { success: boolean }; diff --git a/packages/react/src/api/artifact-renderer.tsx b/packages/react/src/api/artifact-renderer.tsx new file mode 100644 index 000000000..68fa17342 --- /dev/null +++ b/packages/react/src/api/artifact-renderer.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +// --------------------------------------------------------------------------- +// The seam between the artifact page and the MCP-Apps shell that renders it. +// +// The shell (`@executor-js/mcp-apps-shell`) compiles model-written JSX and runs +// it inside a sandboxed iframe. It cannot be imported here: the shell already +// depends on THIS package for its shadcn component barrel, and turbo rejects +// the resulting package cycle outright (it errors, it does not warn). So the +// dependency is inverted the way the rest of the console does it — this package +// declares the contract, and the app composition roots (`apps/*`, which already +// depend on both) provide the implementation at startup. +// +// A host that never registers a renderer still gets a working artifacts list +// and a detail page that explains the artifact cannot be rendered here, rather +// than a crash — the same graceful-degradation rule the shared console follows +// for any host-specific capability. +// --------------------------------------------------------------------------- + +/** What the page hands the shell: the stored source, and the live host. */ +export interface ArtifactRendererProps { + /** The artifact's stored JSX source. */ + readonly code: string; + /** HTTP-backed MCP host — see `createHttpShellHost` in `./shell-host`. */ + readonly host: unknown; +} + +export type ArtifactRenderer = React.ComponentType; + +const ArtifactRendererContext = React.createContext(null); + +/** + * Provide the shell implementation. Apps mount this above the console shell: + * + * ```tsx + * import { McpAppsShell } from "@executor-js/mcp-apps-shell/shell/shell-app"; + * + * } + * > + * ``` + */ +export function ArtifactRendererProvider( + props: React.PropsWithChildren<{ readonly renderer: ArtifactRenderer }>, +) { + return ( + + {props.children} + + ); +} + +/** The registered renderer, or `null` on a host that provides none. */ +export function useArtifactRenderer(): ArtifactRenderer | null { + return React.useContext(ArtifactRendererContext); +} diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 0f8ff57f9..cdd8c667a 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -2,6 +2,7 @@ import { ConnectionAddress, PolicyId, ProviderKey, + type ArtifactId, type AuthTemplateSlug, type Connection, type ConnectionName, @@ -150,6 +151,24 @@ export const pausedExecutionAtom = (executionId: string) => timeToLive: "5 seconds", }); +export const artifactsAtom = ExecutorApiClient.query("artifacts", "list", { + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.artifacts], +}); + +/** + * One artifact, with its `code`. `artifacts.list` deliberately omits the source + * so a long list stays cheap, so the detail page must fetch the row itself — + * it cannot derive from the list atom the way `integrationAtom` does. + */ +export const artifactAtom = Atom.family((artifactId: ArtifactId) => + ExecutorApiClient.query("artifacts", "get", { + params: { artifactId }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.artifacts], + }), +); + // --------------------------------------------------------------------------- // Mutation atoms — reactivityKeys must be passed at call site (effect-atom // does not accept them at definition time). See `reactivity-keys.tsx` for the @@ -245,6 +264,10 @@ export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); +export const renameArtifact = ExecutorApiClient.mutation("artifacts", "rename"); + +export const removeArtifact = ExecutorApiClient.mutation("artifacts", "remove"); + export const resumeExecution = ExecutorApiClient.mutation("executions", "resume"); /** Run codemode source (`POST /executions`). Used by the per-tool Run/Test panel @@ -455,6 +478,42 @@ export const removePolicyOptimistic = policiesOptimisticAtom.pipe( }), ); +// --------------------------------------------------------------------------- +// Artifacts — optimistic surface. Rename and delete are the only writes the +// console makes (artifacts are created by the model through `render-ui`), so +// the list is the single optimistic surface both mutations reduce over. +// --------------------------------------------------------------------------- + +export const artifactsOptimisticAtom = Atom.optimistic(artifactsAtom); + +export const renameArtifactOptimistic = artifactsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: ( + current, + arg: { + readonly params: { readonly artifactId: ArtifactId }; + readonly payload: { readonly title: string }; + }, + ) => + AsyncResult.map(current, (rows) => + rows.map((row) => + row.id === arg.params.artifactId + ? { ...row, title: arg.payload.title, updatedAt: Date.now() } + : row, + ), + ), + fn: renameArtifact, + }), +); + +export const removeArtifactOptimistic = artifactsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: (current, arg: { readonly params: { readonly artifactId: ArtifactId } }) => + AsyncResult.map(current, (rows) => rows.filter((row) => row.id !== arg.params.artifactId)), + fn: removeArtifact, + }), +); + // --------------------------------------------------------------------------- // OAuth clients (apps) — optimistic surface. The list reads through // `oauthClientsOptimisticAtom`; the remove mutation drops the matching diff --git a/packages/react/src/api/reactivity-keys.tsx b/packages/react/src/api/reactivity-keys.tsx index 9f3a001ab..3865c932b 100644 --- a/packages/react/src/api/reactivity-keys.tsx +++ b/packages/react/src/api/reactivity-keys.tsx @@ -26,6 +26,8 @@ export const ReactivityKey = { /** Credential-provider discovery. */ providers: "providers", policies: "policies", + /** Saved generative-UI artifacts. */ + artifacts: "artifacts", /** Registered OAuth clients (apps). */ oauthClients: "oauth-clients", /** An integration's declared health check (the operation/identity-field spec). */ @@ -67,6 +69,10 @@ export const healthCheckWriteKeys = [ * policy changes what the tools page shows. */ export const policyWriteKeys = [ReactivityKey.policies, ReactivityKey.tools] as const; +/** Mutations that rename or delete a saved artifact. Artifacts are a leaf + * resource — nothing else reads them — so they invalidate only themselves. */ +export const artifactWriteKeys = [ReactivityKey.artifacts] as const; + /** Cloud-only: org membership mutations. */ export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const; diff --git a/packages/react/src/api/shell-host.ts b/packages/react/src/api/shell-host.ts new file mode 100644 index 000000000..1a49e8f18 --- /dev/null +++ b/packages/react/src/api/shell-host.ts @@ -0,0 +1,167 @@ +/** + * The HTTP-backed host an embedded MCP-Apps shell talks to. + * + * A shell normally runs inside an MCP client, where `callServerTool` is the + * MCP bridge. On the artifact page there is no MCP client — the console renders + * the artifact itself — so this adapter answers the same two tool calls over + * the ordinary executions HTTP API instead: + * + * execute-action -> POST /executions + * execute-action-resume -> POST /executions/:id/resume + * + * The shell's `proxy.ts` already turns `tools.a.b.c(...)` into `execute-action` + * and recurses through `waiting_for_interaction` -> trusted modal -> resume, so + * elicitation approvals work unchanged; this layer only moves bytes. + * + * The type is declared structurally rather than imported from + * `@executor-js/mcp-apps-shell`: that package depends on THIS one for its + * component barrel, so a type import here would close a package cycle that + * turbo rejects outright. The shell's `McpAppsShellHost` is deliberately + * structural for exactly this reason — see its declaration. + */ + +import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection"; + +/** The wire shape of `POST /executions` and `POST /executions/:id/resume`. */ +type ExecutionResponse = + | { + readonly status: "completed"; + readonly text: string; + readonly structured: unknown; + readonly isError: boolean; + } + | { readonly status: "paused"; readonly text: string; readonly structured: unknown }; + +/** The subset of `CallToolResult` the shell reads back. */ +export interface ShellToolResult { + readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }>; + readonly structuredContent?: Record; + readonly isError?: boolean | undefined; +} + +/** Structurally compatible with the shell package's `McpAppsShellHost`. */ +export interface HttpShellHost { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; + readonly getHostContext: () => { readonly theme: "light" | "dark" } | undefined; + readonly openLink: (params: { url: string }) => Promise; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The kernel's envelope travels in `structured`; the shell unwraps it itself. + * A completed-and-failed execution maps to `isError` so the shell's proxy + * throws inside the generated component instead of handing it a bogus value. + */ +const toShellToolResult = (response: ExecutionResponse): ShellToolResult => ({ + content: [{ type: "text", text: response.text }], + structuredContent: isRecord(response.structured) + ? response.structured + : { result: response.structured }, + isError: response.status === "completed" && response.isError ? true : undefined, +}); + +/** The resume action the shell sends back after the user answers the modal. */ +const isResumeAction = (value: unknown): value is "accept" | "decline" | "cancel" => + value === "accept" || value === "decline" || value === "cancel"; + +/** + * `execute-action-resume` carries the elicitation answer as a JSON *string* + * (the MCP tool contract is string-typed), so it is parsed back here. An + * unparseable or non-object body becomes `undefined` rather than throwing: the + * user's decision (accept/decline/cancel) still deserves to reach the server. + */ +const parseResumeContent = (raw: unknown): Record | undefined => { + if (typeof raw !== "string" || raw === "{}") return undefined; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse over a value the shell built; a malformed body must not lose the user's answer + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: the resume content arrives as an opaque JSON string across the shell's postMessage bridge + const parsed: unknown = JSON.parse(raw); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +}; + +const prefersDark = (): boolean => + typeof globalThis.window !== "undefined" && + typeof globalThis.window.matchMedia === "function" && + globalThis.window.matchMedia("(prefers-color-scheme: dark)").matches; + +/** + * Build the host. `fetch` is injectable so the seam can be tested without a + * server; everything else is read at call time from the active server + * connection, matching how the typed API client resolves its base URL and + * bearer (a desktop connection carries no auth and sends none). + */ +export const createHttpShellHost = (options?: { + readonly fetch?: typeof globalThis.fetch; +}): HttpShellHost => { + const doFetch = options?.fetch ?? globalThis.fetch.bind(globalThis); + + const post = async (path: string, payload: Record): Promise => { + const headers: Record = { "content-type": "application/json" }; + const authorization = getExecutorServerAuthorizationHeader(); + if (authorization) headers.authorization = authorization; + + const response = await doFetch(`${getExecutorApiBaseUrl()}${path}`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const text = await response.text(); + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the shell's tool proxy is Promise-based and surfaces rejections as component errors + throw new Error(text || `Executor API request failed with ${response.status}`); + } + return response.json(); + }; + + return { + getHostContext: () => ({ theme: prefersDark() ? "dark" : "light" }), + + openLink: async ({ url }) => { + globalThis.window?.open(url, "_blank", "noopener,noreferrer"); + return {}; + }, + + callServerTool: async ({ name, arguments: args }) => { + const input = args ?? {}; + + if (name === "execute-action") { + const code = input.code; + if (typeof code !== "string") { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above + throw new Error("Missing execute-action code."); + } + return toShellToolResult((await post("/executions", { code })) as ExecutionResponse); + } + + if (name === "execute-action-resume") { + const executionId = input.executionId; + const action = input.action; + if (typeof executionId !== "string") { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above + throw new Error("Missing execution id."); + } + if (!isResumeAction(action)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above + throw new Error("Invalid resume action."); + } + return toShellToolResult( + (await post(`/executions/${encodeURIComponent(executionId)}/resume`, { + action, + content: parseResumeContent(input.content), + })) as ExecutionResponse, + ); + } + + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above + throw new Error(`Unsupported shell tool: ${name}`); + }, + }; +}; diff --git a/packages/react/src/console-routes.ts b/packages/react/src/console-routes.ts index 33176493c..4cfba8e8f 100644 --- a/packages/react/src/console-routes.ts +++ b/packages/react/src/console-routes.ts @@ -41,6 +41,8 @@ export const CONSOLE_ROUTE_PATHS = [ "/tools", "/toolkits", "/toolkits/$toolkitSlug", + "/artifacts", + "/artifacts/$artifactId", "/resume/$executionId", "/plugins/$pluginId/$", ] as const; @@ -75,6 +77,8 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array now - ms; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +describe("formatRelativeTime", () => { + it("collapses anything under a minute to 'just now'", () => { + expect(formatRelativeTime(now, now)).toBe("just now"); + expect(formatRelativeTime(ago(59_000), now)).toBe("just now"); + }); + + it("counts minutes, then hours, then days", () => { + expect(formatRelativeTime(ago(MINUTE), now)).toBe("1m ago"); + expect(formatRelativeTime(ago(59 * MINUTE), now)).toBe("59m ago"); + expect(formatRelativeTime(ago(HOUR), now)).toBe("1h ago"); + expect(formatRelativeTime(ago(23 * HOUR), now)).toBe("23h ago"); + expect(formatRelativeTime(ago(DAY), now)).toBe("1d ago"); + expect(formatRelativeTime(ago(6 * DAY), now)).toBe("6d ago"); + }); + + it("falls back to an absolute date once a week has passed", () => { + // Beyond a week the relative figure stops being useful, so the exact date + // is shown instead of an ever-growing day count. + expect(formatRelativeTime(ago(7 * DAY), now)).not.toContain("ago"); + }); + + it("never renders a future timestamp as negative", () => { + // Clock skew between server and browser must not produce "-3m ago". + expect(formatRelativeTime(now + HOUR, now)).toBe("just now"); + }); +}); diff --git a/packages/react/src/lib/relative-time.ts b/packages/react/src/lib/relative-time.ts new file mode 100644 index 000000000..13dd033b7 --- /dev/null +++ b/packages/react/src/lib/relative-time.ts @@ -0,0 +1,19 @@ +/** + * Compact relative timestamps for list metadata ("5m ago", "3d ago"). + * + * The house format is compact rather than prose ("2h ago", not "2 hours ago"), + * and falls back to an absolute date once the distance stops being useful as a + * relative figure. `now` is injectable so callers can test without freezing the + * clock. + */ +export const formatRelativeTime = (timestamp: number, now = Date.now()): string => { + const diffMs = Math.max(0, now - timestamp); + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +}; diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index dfc525116..9a92cd0ff 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -52,6 +52,7 @@ export const defaultShellNavItems: ReadonlyArray = [ { to: "/secrets", label: "Providers" }, { to: "/policies", label: "Policies" }, { to: "/toolkits", label: "Toolkits" }, + { to: "/artifacts", label: "Artifacts" }, ]; /** Canonical public docs (Mintlify). Same-origin on cloud (executor.sh proxies diff --git a/packages/react/src/pages/artifact-detail.tsx b/packages/react/src/pages/artifact-detail.tsx new file mode 100644 index 000000000..bd8d2b1c2 --- /dev/null +++ b/packages/react/src/pages/artifact-detail.tsx @@ -0,0 +1,190 @@ +import { useMemo } from "react"; +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; +import { toast } from "sonner"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { trackEvent } from "../api/analytics"; +import { useArtifactRenderer } from "../api/artifact-renderer"; +import { artifactAtom, removeArtifactOptimistic, renameArtifactOptimistic } from "../api/atoms"; +import { artifactWriteKeys } from "../api/reactivity-keys"; +import { createHttpShellHost } from "../api/shell-host"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../components/alert-dialog"; +import { Button } from "../components/button"; +import { ErrorState } from "../components/error-state"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; +import { RenameArtifactDialog } from "./artifact-rename-dialog"; + +/** + * The artifact detail page — also the deep-link target `render-ui` hands to MCP + * clients that cannot display MCP Apps, so it must work as a first landing URL. + * Auth is handled by the surrounding console gate, exactly like /policies. + */ +export function ArtifactDetailPage(props: { readonly artifactId: ArtifactId }) { + const artifact = useAtomValue(artifactAtom(props.artifactId)); + const refresh = useAtomRefresh(artifactAtom(props.artifactId)); + const doRename = useAtomSet(renameArtifactOptimistic, { mode: "promiseExit" }); + const doRemove = useAtomSet(removeArtifactOptimistic, { mode: "promiseExit" }); + const navigate = useNavigate(); + + const title = AsyncResult.isSuccess(artifact) ? artifact.value.title : "Artifact"; + useExecutorDocumentTitle(title); + + const handleRename = async (nextTitle: string) => { + const exit = await doRename({ + params: { artifactId: props.artifactId }, + payload: { title: nextTitle }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_renamed", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) toast.error("Couldn't rename the artifact. Try again."); + }; + + const handleRemove = async () => { + const exit = await doRemove({ + params: { artifactId: props.artifactId }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_removed", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) { + toast.error("Couldn't delete the artifact. Try again."); + return; + } + // The row this page reads is gone; the list is the only sensible landing + // place. `params` is omitted so the router keeps the active org slug. + await navigate({ to: "/{-$orgSlug}/artifacts" }); + }; + + return ( +
+
+
+ +

{title}

+ {AsyncResult.isSuccess(artifact) ? ( + + {formatRelativeTime(artifact.value.updatedAt)} + + ) : null} +
+ {AsyncResult.isSuccess(artifact) ? ( +
+ void handleRename(next)} + trigger={ + + } + /> + + + + + + + Delete {artifact.value.title}? + + This removes the artifact for good. Agents will no longer find it by name. + + + + Cancel + void handleRemove()}> + Delete Artifact + + + + +
+ ) : null} +
+ +
+ {isAsyncResultLoading(artifact) ? ( +
+
+

Loading artifact…

+
+ ) : ( + AsyncResult.match(artifact, { + onInitial: () => ( +
+
+

Loading artifact…

+
+ ), + // A deleted or foreign id lands here. The message says which of the + // two it is as far as this viewer can tell, and offers the way back. + onFailure: () => ( +
+ +
+ ), + onSuccess: ({ value }) => , + }) + )} +
+
+ ); +} + +/** + * Mounts the registered MCP-Apps shell around the artifact's stored source. + * + * The shell itself is supplied by the app composition root (see + * `ArtifactRendererProvider`) because it depends on this package and cannot be + * imported back into it. A host that registers none still renders a page that + * explains itself instead of crashing. + */ +function ArtifactStage(props: { readonly code: string }) { + const Renderer = useArtifactRenderer(); + // One host per mount: it holds no state, but a new identity each render would + // retrigger the shell's host effects. + const host = useMemo(() => createHttpShellHost(), []); + + if (!Renderer) { + return ( +
+

+ This build can't render artifacts. Open it in the Executor app to view it. +

+
+ ); + } + + return ; +} diff --git a/packages/react/src/pages/artifact-rename-dialog.tsx b/packages/react/src/pages/artifact-rename-dialog.tsx new file mode 100644 index 000000000..1df46ae4b --- /dev/null +++ b/packages/react/src/pages/artifact-rename-dialog.tsx @@ -0,0 +1,109 @@ +import { useId, useState } from "react"; + +import { Button } from "../components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../components/dialog"; +import { Input } from "../components/input"; +import { Label } from "../components/label"; + +/** + * Rename affordance shared by the artifacts list and the artifact detail page. + * + * The draft title lives inside the dialog, so closing it discards the edit and + * reopening always starts from the artifact's current title — no stale draft + * survives a cancel. The parent owns the write and the failure toast. + */ +export function RenameArtifactDialog(props: { + readonly currentTitle: string; + readonly onRename: (title: string) => void; + /** Rendered as the trigger; defaults to the list row's hover-revealed button. */ + readonly trigger?: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + return ( + + + {props.trigger ?? ( + + )} + + {/* Remounted per open so the draft always starts from the current title. */} + {open ? ( + setOpen(false)} + /> + ) : null} + + ); +} + +function RenameArtifactForm(props: { + readonly currentTitle: string; + readonly onRename: (title: string) => void; + readonly onClose: () => void; +}) { + const titleId = useId(); + const [draft, setDraft] = useState(props.currentTitle); + const trimmed = draft.trim(); + const unchanged = trimmed === props.currentTitle; + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (!trimmed || unchanged) { + props.onClose(); + return; + } + props.onRename(trimmed); + props.onClose(); + }; + + return ( + +
+ + Rename Artifact + + Agents find an artifact by its title, so name it the way you would ask for it. + + +
+ + setDraft((event.target as HTMLInputElement).value)} + className="mt-1.5 h-9 text-sm" + /> +
+ + + + +
+
+ ); +} diff --git a/packages/react/src/pages/artifacts.tsx b/packages/react/src/pages/artifacts.tsx new file mode 100644 index 000000000..26b18d936 --- /dev/null +++ b/packages/react/src/pages/artifacts.tsx @@ -0,0 +1,212 @@ +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Link } from "@tanstack/react-router"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; +import { toast } from "sonner"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { trackEvent } from "../api/analytics"; +import { + artifactsOptimisticAtom, + removeArtifactOptimistic, + renameArtifactOptimistic, +} from "../api/atoms"; +import { artifactWriteKeys } from "../api/reactivity-keys"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../components/alert-dialog"; +import { Button } from "../components/button"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, + CardStackHeader, +} from "../components/card-stack"; +import { ErrorState } from "../components/error-state"; +import { PageContainer, PageHeader } from "../components/page"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; +import { RenameArtifactDialog } from "./artifact-rename-dialog"; + +/** The wire row `artifacts.list` returns — no `code`, so the list stays cheap. */ +interface ArtifactSummary { + readonly id: ArtifactId; + readonly title: string; + readonly description: string | null; + readonly updatedAt: number; +} + +const LoadingState = () => ( +
+
+

Loading artifacts…

+
+); + +/** + * Artifacts are never created here — a model makes them by calling `render-ui` + * over MCP — so the empty state teaches that path rather than offering a button + * that cannot exist. + */ +const EmptyState = () => ( + + + + No artifacts yet. Ask an agent to render a UI and it appears here, ready to reopen. + + + +); + +function ArtifactRow(props: { + readonly artifact: ArtifactSummary; + readonly onRename: (title: string) => void; + readonly onRemove: () => void; +}) { + const { artifact } = props; + return ( + + + + trackEvent("artifact_opened", { surface: "list" })} + > + {artifact.title} + + + {artifact.description ? ( + {artifact.description} + ) : null} + + + + {formatRelativeTime(artifact.updatedAt)} + + + + + + + + + Delete {artifact.title}? + + This removes the artifact for good. Agents will no longer find it by name. + + + + Cancel + + Delete Artifact + + + + + + + ); +} + +export function ArtifactsPage() { + useExecutorDocumentTitle("Artifacts"); + const artifacts = useAtomValue(artifactsOptimisticAtom); + const refreshArtifacts = useAtomRefresh(artifactsOptimisticAtom); + const doRename = useAtomSet(renameArtifactOptimistic, { mode: "promiseExit" }); + const doRemove = useAtomSet(removeArtifactOptimistic, { mode: "promiseExit" }); + + const handleRename = async (artifactId: ArtifactId, title: string) => { + const exit = await doRename({ + params: { artifactId }, + payload: { title }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_renamed", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) toast.error("Couldn't rename the artifact. Try again."); + }; + + const handleRemove = async (artifactId: ArtifactId) => { + const exit = await doRemove({ + params: { artifactId }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_removed", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) toast.error("Couldn't delete the artifact. Try again."); + }; + + return ( + + + + {isAsyncResultLoading(artifacts) ? ( + + ) : ( + AsyncResult.match(artifacts, { + onInitial: () => , + onFailure: () => ( + + ), + onSuccess: ({ value }) => { + // Most-recently-updated first: an artifact just generated by an + // agent is the one the user is most likely coming here to open. + const rows = [...value].sort((a, b) => b.updatedAt - a.updatedAt); + return ( + + + Saved artifacts + {rows.length > 0 ? ( + + {rows.length} + + ) : null} + + + {rows.length === 0 ? ( + + ) : ( + rows.map((artifact) => ( + void handleRename(artifact.id, title)} + onRemove={() => void handleRemove(artifact.id)} + /> + )) + )} + + + ); + }, + }) + )} + + ); +} + +export type { ArtifactSummary }; diff --git a/packages/react/src/routes/artifacts-route.tsx b/packages/react/src/routes/artifacts-route.tsx new file mode 100644 index 000000000..7919e3a5a --- /dev/null +++ b/packages/react/src/routes/artifacts-route.tsx @@ -0,0 +1,15 @@ +import { ArtifactId } from "@executor-js/sdk/shared"; + +import { ArtifactDetailPage } from "../pages/artifact-detail"; +import { ArtifactsPage } from "../pages/artifacts"; + +/** + * The one component both artifact routes render. `/artifacts/$artifactId` is a + * child of `/artifacts` in the generated tree and the parent renders no + * ``, so the parent has to resolve which view to show — the shape + * `toolkits-route.tsx` established. + */ +export function ArtifactsRoute(props: { readonly artifactId?: string | undefined }) { + if (props.artifactId === undefined) return ; + return ; +} diff --git a/packages/react/src/routes/artifacts.$artifactId.tsx b/packages/react/src/routes/artifacts.$artifactId.tsx new file mode 100644 index 000000000..b52093059 --- /dev/null +++ b/packages/react/src/routes/artifacts.$artifactId.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ArtifactsRoute } from "./artifacts-route"; + +export const Route = createFileRoute("/{-$orgSlug}/artifacts/$artifactId")({ + component: ArtifactDetailRouteComponent, +}); + +function ArtifactDetailRouteComponent() { + const { artifactId } = Route.useParams(); + return ; +} diff --git a/packages/react/src/routes/artifacts.tsx b/packages/react/src/routes/artifacts.tsx new file mode 100644 index 000000000..b8372ddf1 --- /dev/null +++ b/packages/react/src/routes/artifacts.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, useParams } from "@tanstack/react-router"; + +import { ArtifactsRoute } from "./artifacts-route"; + +export const Route = createFileRoute("/{-$orgSlug}/artifacts")({ + component: ArtifactsRouteComponent, +}); + +function ArtifactsRouteComponent() { + // `/artifacts/$artifactId` generates as a CHILD of this route, and this + // component renders no , so the parent must serve both URLs — the + // same shape `toolkits.tsx` uses for `/toolkits/$toolkitSlug`. + const { artifactId } = useParams({ strict: false }) as { artifactId?: string }; + return ; +} diff --git a/packages/react/src/routes/routeTree.gen.ts b/packages/react/src/routes/routeTree.gen.ts index 878b5ceda..9ef4ceeb1 100644 --- a/packages/react/src/routes/routeTree.gen.ts +++ b/packages/react/src/routes/routeTree.gen.ts @@ -14,9 +14,11 @@ import { Route as DotToolsRouteImport } from './tools' import { Route as DotToolkitsRouteImport } from './toolkits' import { Route as DotSecretsRouteImport } from './secrets' import { Route as DotPoliciesRouteImport } from './policies' +import { Route as DotArtifactsRouteImport } from './artifacts' import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' +import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId' import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$' import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey' @@ -45,6 +47,11 @@ const DotPoliciesRoute = DotPoliciesRouteImport.update({ path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotArtifactsRoute = DotArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, +} as any) const DotToolkitsDottoolkitSlugRoute = DotToolkitsDottoolkitSlugRouteImport.update({ id: '/$toolkitSlug', @@ -62,6 +69,12 @@ const DotIntegrationsDotnamespaceRoute = path: '/{-$orgSlug}/integrations/$namespace', getParentRoute: () => rootRouteImport, } as any) +const DotArtifactsDotartifactIdRoute = + DotArtifactsDotartifactIdRouteImport.update({ + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => DotArtifactsRoute, + } as any) const DotPluginsDotpluginIdDotsplatRoute = DotPluginsDotpluginIdDotsplatRouteImport.update({ id: '/{-$orgSlug}/plugins/$pluginId/$', @@ -76,11 +89,13 @@ const DotIntegrationsDotaddDotpluginKeyRoute = } as any) export interface FileRoutesByFullPath { + '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}/': typeof DotIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute @@ -88,11 +103,13 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}': typeof DotIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute @@ -101,11 +118,13 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}/': typeof DotIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute @@ -115,11 +134,13 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -127,11 +148,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -139,11 +162,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -152,6 +177,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotArtifactsRoute: typeof DotArtifactsRouteWithChildren DotPoliciesRoute: typeof DotPoliciesRoute DotSecretsRoute: typeof DotSecretsRoute DotToolkitsRoute: typeof DotToolkitsRouteWithChildren @@ -200,6 +226,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -221,6 +254,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotArtifactsDotartifactIdRouteImport + parentRoute: typeof DotArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -238,6 +278,18 @@ declare module '@tanstack/react-router' { } } +interface DotArtifactsRouteChildren { + DotArtifactsDotartifactIdRoute: typeof DotArtifactsDotartifactIdRoute +} + +const DotArtifactsRouteChildren: DotArtifactsRouteChildren = { + DotArtifactsDotartifactIdRoute: DotArtifactsDotartifactIdRoute, +} + +const DotArtifactsRouteWithChildren = DotArtifactsRoute._addFileChildren( + DotArtifactsRouteChildren, +) + interface DotToolkitsRouteChildren { DotToolkitsDottoolkitSlugRoute: typeof DotToolkitsDottoolkitSlugRoute } @@ -251,6 +303,7 @@ const DotToolkitsRouteWithChildren = DotToolkitsRoute._addFileChildren( ) const rootRouteChildren: RootRouteChildren = { + DotArtifactsRoute: DotArtifactsRouteWithChildren, DotPoliciesRoute: DotPoliciesRoute, DotSecretsRoute: DotSecretsRoute, DotToolkitsRoute: DotToolkitsRouteWithChildren, diff --git a/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css index 2fe3bcad0..ffb91e305 100644 --- a/packages/react/src/styles/globals.css +++ b/packages/react/src/styles/globals.css @@ -6,6 +6,11 @@ @source "../../../../apps/local/src/**/*.{ts,tsx}"; @source "../../../../apps/cloud/src/**/*.{ts,tsx}"; @source "../../../plugins/*/src/**/*.{ts,tsx}"; +/* The artifact page embeds the MCP-Apps shell, so the shell's own chrome + (loading state, trusted-approval modal) must compile into the app stylesheet. + Classes the MODEL invents at runtime are not scannable at build time — the + shell compiles those in-browser inside its sandboxed frame. */ +@source "../../../hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}"; @theme inline { /* Geist sets UI and prose; Geist Mono sets code, slugs, IDs, counts, and labels. From 43df5209bf32fe3a85a803a383841f572358c1d0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:06:23 -0700 Subject: [PATCH 02/30] Add artifacts e2e scenarios --- .changeset/olive-crabs-repeat.md | 5 + e2e/scenarios/artifacts.test.ts | 293 +++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 .changeset/olive-crabs-repeat.md create mode 100644 e2e/scenarios/artifacts.test.ts diff --git a/.changeset/olive-crabs-repeat.md b/.changeset/olive-crabs-repeat.md new file mode 100644 index 000000000..79f7beeae --- /dev/null +++ b/.changeset/olive-crabs-repeat.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Add an Artifacts tab. Interactive components an agent generates with `render-ui` are saved and listed in the console, and each one has its own page that renders it live — the page an MCP client without MCP Apps support deep-links to. Artifacts can be renamed and deleted from the console, and agents find them again by title. diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts new file mode 100644 index 000000000..32bb4e4a9 --- /dev/null +++ b/e2e/scenarios/artifacts.test.ts @@ -0,0 +1,293 @@ +// Cross-target: the artifacts journey, end to end. +// +// An agent on a client that cannot display MCP Apps calls `render-ui`. The +// product promise under test is vision.md's delivery negotiation: the model +// behaves identically either way, and only DELIVERY changes — a client without +// MCP Apps gets a deep link into the web app instead of an embedded widget. +// Persistence is what makes that possible, so the same artifact must then be +// reachable three ways: the deep link, the Artifacts tab, and back through MCP +// by title. +// +// Two scenarios, split by what they prove: +// 1. render-ui saves + delivers a working deep link, and the page renders +// the component live (the fallback path a non-Apps client actually walks). +// 2. Rename and delete in the console are what MCP reads back afterwards +// (the console and the agent share one store, not two caches). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { AccountHttpApi } from "@executor-js/api"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; + +const api = composePluginApi([] as const); + +/** + * The component `render-ui` persists. + * + * It must declare none of the ~280 globals the shell puts in scope (`Card`, + * `useState`, …) or the server's guard rejects it before it is ever saved, and + * its rendered text must be distinctive enough to assert on inside the shell's + * nested sandbox iframe. + */ +const artifactSource = (marker: string) => ` +function App() { + return ( +
+

Release Readiness

+

${marker}

+
+ ); +} +`; + +/** Selfhost shares one workspace across scenarios, so every title is unique to + * this run and assertions look for "mine", never "the only one". */ +const uniqueSuffix = () => randomBytes(4).toString("hex"); + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +scenario( + "Artifacts · render-ui hands a non-Apps client a deep link that renders the live component", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + const session = mcp.session(identity); + + const suffix = uniqueSuffix(); + const title = `Release Readiness ${suffix}`; + const marker = `artifact-ok-${suffix}`; + + // Tracked so cleanup runs even when an assertion below fails. + let artifactId: ArtifactId | undefined; + + yield* Effect.gen(function* () { + // `mcp.session` advertises no MCP-Apps capability — which is exactly the + // client under test here, so no capability juggling is needed. + const tools = yield* session.listTools(); + expect(tools, "render-ui is advertised regardless of MCP-Apps support").toContain( + "render-ui", + ); + + const rendered = yield* session.call("render-ui", { + code: artifactSource(marker), + title, + description: "Whether the current release is ready to ship", + }); + + expect(rendered.ok, `render-ui succeeded: ${rendered.text}`).toBe(true); + + const structured = structuredOf(rendered); + // `fallback_unavailable` here would mean the host has no webBaseUrl + // wired — a mis-wired deployment, not a passing test. + expect( + structured.status, + `a non-Apps client gets a deep link, not an embedded widget: ${JSON.stringify(structured)}`, + ).toBe("fallback_url"); + + artifactId = structured.artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted and its id returned").toBeTruthy(); + + const url = String(structured.url); + expect(rendered.text, "the model is handed the URL to relay to the user").toContain(url); + + // The link must point at THIS deployment and at the artifact's own page — + // a bare `/artifacts/:id`, which the console canonicalizes onto the + // active org slug after landing. + const parsed = new URL(url); + expect(parsed.origin, `the deep link targets this deployment (${url})`).toBe( + new URL(target.baseUrl).origin, + ); + expect(parsed.pathname, `the deep link addresses the artifact (${url})`).toBe( + `/artifacts/${artifactId}`, + ); + + // The org the console will canonicalize to, so the landing URL can be + // asserted rather than guessed. + const accountClient = yield* apiClient(AccountHttpApi, identity); + const me = yield* accountClient.account.me(); + const orgSlug = me.organization?.slug; + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact link the agent handed over", async () => { + await page.goto(url, { waitUntil: "networkidle" }); + }); + + await step("The artifact page is titled with the artifact's name", async () => { + await page.getByRole("heading", { name: title }).waitFor({ timeout: 20_000 }); + if (orgSlug) { + // A bare deep link canonicalizes onto the active org in place, + // keeping the path — the artifact must not be lost in the rewrite. + await page.waitForURL(`**/${orgSlug}/artifacts/${artifactId}`, { timeout: 20_000 }); + } + // Landing straight on the deep link (no list visit first) must still + // offer the management affordances and the way back to the list. + await page.getByRole("button", { name: "Rename" }).waitFor(); + await page.getByRole("button", { name: "Delete" }).waitFor(); + await page.getByRole("button", { name: "Artifacts" }).waitFor(); + }); + + await step("The saved component renders live inside the shell", async () => { + // Deliberately scoped THROUGH the iframe rather than the page: the + // generated code runs in the shell's sandboxed `srcDoc` frame, so + // finding the marker there proves the stored JSX was compiled and + // executed inside the sandbox — not echoed as text by the page. An + // unscoped lookup would still pass if the sandbox broke. + const rendered = page.frameLocator("iframe").getByTestId("artifact-marker"); + await rendered.waitFor({ timeout: 30_000 }); + expect(await rendered.textContent()).toContain(marker); + }); + + await step("The artifact is listed on the Artifacts tab", async () => { + await page.getByRole("link", { name: "Artifacts" }).first().click(); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + await page.getByRole("link", { name: `Open artifact ${title}` }).waitFor({ + timeout: 20_000, + }); + }); + }); + + // The same row, read back through the agent's own surface. + const listed = yield* session.call("list-artifacts", {}); + expect(listed.text, "the agent can find the artifact by title").toContain(title); + + const shown = yield* session.call("show-artifact", { id: artifactId }); + expect(shown.ok, `show-artifact returned the artifact: ${shown.text}`).toBe(true); + expect( + String(structuredOf(shown).url ?? shown.text), + "show-artifact delivers the same deep link for a non-Apps client", + ).toContain(String(artifactId)); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + artifactId === undefined + ? Effect.void + : client.artifacts.remove({ params: { artifactId } }), + ).pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Artifacts · renaming and deleting in the console is what the agent sees next", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + const session = mcp.session(identity); + + const suffix = uniqueSuffix(); + const originalTitle = `Draft Dashboard ${suffix}`; + const renamedTitle = `Quarterly Dashboard ${suffix}`; + + let artifactId: ArtifactId | undefined; + + yield* Effect.gen(function* () { + const rendered = yield* session.call("render-ui", { + code: artifactSource(`rename-${suffix}`), + title: originalTitle, + description: "A dashboard the user will rename", + }); + expect(rendered.ok, `render-ui succeeded: ${rendered.text}`).toBe(true); + artifactId = structuredOf(rendered).artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted").toBeTruthy(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Artifacts tab", async () => { + await page.goto("/artifacts", { waitUntil: "networkidle" }); + await page.getByRole("link", { name: `Open artifact ${originalTitle}` }).waitFor({ + timeout: 20_000, + }); + }); + + await step("Rename the artifact to something askable", async () => { + // Row actions reveal on hover; the row is the link's enclosing entry. + const row = page.locator('[data-slot="card-stack-entry"]').filter({ + hasText: originalTitle, + }); + await row.hover(); + await row.getByRole("button", { name: "Rename" }).click(); + + const dialog = page.getByRole("dialog"); + await dialog.getByRole("heading", { name: "Rename Artifact" }).waitFor(); + await dialog.getByRole("textbox").fill(renamedTitle); + await dialog.getByRole("button", { name: "Save Title" }).click(); + await dialog.waitFor({ state: "hidden", timeout: 20_000 }); + }); + + await step("The list shows the new name", async () => { + await page.getByRole("link", { name: `Open artifact ${renamedTitle}` }).waitFor({ + timeout: 20_000, + }); + }); + }); + + // The rename is what the agent now matches against — the promise that + // "show me my quarterly dashboard" works after a rename in the console. + const afterRename = yield* session.call("list-artifacts", {}); + expect(afterRename.text, "the agent sees the new title").toContain(renamedTitle); + expect(afterRename.text, "the old title is gone from the agent's view").not.toContain( + originalTitle, + ); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Delete the artifact from the list", async () => { + await page.goto("/artifacts", { waitUntil: "networkidle" }); + const row = page.locator('[data-slot="card-stack-entry"]').filter({ + hasText: renamedTitle, + }); + await row.waitFor({ timeout: 20_000 }); + await row.hover(); + await row.getByRole("button", { name: "Delete" }).click(); + + const confirm = page.getByRole("alertdialog"); + await confirm.getByRole("heading", { name: `Delete ${renamedTitle}?` }).waitFor(); + await confirm.getByRole("button", { name: "Delete Artifact" }).click(); + await confirm.waitFor({ state: "hidden", timeout: 20_000 }); + }); + + await step("The artifact is gone from the list", async () => { + await page + .getByRole("link", { name: `Open artifact ${renamedTitle}` }) + .waitFor({ state: "detached", timeout: 20_000 }); + }); + }); + + const afterDelete = yield* session.call("list-artifacts", {}); + expect(afterDelete.text, "the agent no longer offers the deleted artifact").not.toContain( + renamedTitle, + ); + + const missing = yield* session.call("show-artifact", { id: artifactId }); + expect(missing.ok, "fetching a deleted artifact is an error, not an empty render").toBe( + false, + ); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + artifactId === undefined + ? Effect.void + : client.artifacts.remove({ params: { artifactId } }), + ).pipe(Effect.ignore), + ), + ); + }), +); From 7da3fba9a670331c557b5ef82594f3d386924cf4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:26:17 -0700 Subject: [PATCH 03/30] Load the artifact shell in the browser only The MCP-Apps shell imports @tailwindcss/browser, which touches document at import scope. Importing the shell statically from the root routes put it in cloud's SSR graph, so every document request failed with "document is not defined" while the entry graph loaded. The renderer seam now carries an async loader instead of a component. The composition roots register the shell through a dynamic import that only resolves in the browser, and the artifact page renders a placeholder frame server-side, hydrating the shell behind ClientOnly and Suspense. --- apps/cloud/src/routes/__root.tsx | 13 ++++-- apps/host-cloudflare/web/routes/__root.tsx | 13 ++++-- apps/host-selfhost/web/routes/__root.tsx | 13 ++++-- packages/app/src/routes/__root.tsx | 13 ++++-- .../src/shell/artifact-renderer.tsx | 41 +++++++++-------- packages/react/src/api/artifact-renderer.tsx | 46 +++++++++++++++---- packages/react/src/pages/artifact-detail.tsx | 34 +++++++++++--- 7 files changed, 128 insertions(+), 45 deletions(-) diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 58d965e2b..3614bb14d 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -20,7 +20,7 @@ import { EXECUTOR_ORG_HEADER } from "@executor-js/react/api/server-connection"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; import { AuthProvider, useAuth } from "../web/auth"; @@ -61,6 +61,13 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { }); } +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. A static import here would put it in this +// SSR app's server graph and 500 every document request, so it is registered as +// a dynamic import the artifact page resolves in the browser. Module scope keeps +// the loader identity stable, so the lazy component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + const analyticsClient: AnalyticsClient | undefined = typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY ? (name, properties) => posthog.capture(name, properties) @@ -303,9 +310,9 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { (a foreign slug already 404'd above). The gate only keeps the URL canonical — bare → /. */} - + - + diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx index cad2fd5ec..24f8485c1 100644 --- a/apps/host-cloudflare/web/routes/__root.tsx +++ b/apps/host-cloudflare/web/routes/__root.tsx @@ -3,7 +3,7 @@ import { useEffect, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -25,6 +25,13 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client"; // omits the API-keys nav item and just uses the default set. // --------------------------------------------------------------------------- +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ component: RootComponent, }); @@ -79,13 +86,13 @@ function AuthenticatedApp() { wrangler.jsonc run_worker_first), so a slug-pinned URL would fall through to the SPA. */} - + {organization ? ( {gated} ) : ( gated )} - + diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index b1fbc6cc7..c3edd7664 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -33,6 +33,13 @@ import { fetchNeedsSetup } from "../setup-status"; // Auth), injected here. No billing, Sentry, or PostHog. // --------------------------------------------------------------------------- +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ notFoundComponent: NotFoundPage, component: RootComponent, @@ -168,13 +175,13 @@ function AuthenticatedApp() { a slug-pinned URL would 404, and a single-org instance has nothing to select anyway. */} - + {organization ? ( {gated} ) : ( gated )} - + diff --git a/packages/app/src/routes/__root.tsx b/packages/app/src/routes/__root.tsx index 020053557..348023535 100644 --- a/packages/app/src/routes/__root.tsx +++ b/packages/app/src/routes/__root.tsx @@ -3,10 +3,17 @@ import { ExecutorProvider } from "@executor-js/react/api/provider"; import { LocalAuthGate } from "@executor-js/react/api/local-auth"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { Toaster } from "@executor-js/react/components/sonner"; -import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { Shell } from "../web/shell"; +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ notFoundComponent: NotFoundPage, component: RootComponent, @@ -36,11 +43,11 @@ function RootComponent() { return ( - + - + diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx index 308011713..13b4c51a5 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -1,4 +1,3 @@ -import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import type { ArtifactRendererProps } from "@executor-js/react/api/artifact-renderer"; import { McpAppsShell, type McpAppsShellHost } from "./shell-app"; @@ -6,30 +5,34 @@ import { McpAppsShell, type McpAppsShellHost } from "./shell-app"; /** * Binds the MCP-Apps shell to the console's artifact page. * - * The console declares an artifact-renderer seam it cannot fill itself: this - * package depends on `@executor-js/react` for its component barrel, so the - * reverse import would close a package cycle. App composition roots — which - * already depend on both — mount this provider to complete the wiring. + * This module is BROWSER-ONLY and must never enter a server graph: it pulls in + * `shell-app`, which imports `@tailwindcss/browser`, and that package builds a + * ` + +
@@ -401,14 +436,8 @@ export function McpAppsShell({ if (error) { return ( -
- - - Error - - {error} - - +
+
); } @@ -436,7 +465,11 @@ export function McpAppsShell({ return (
- - Runtime Error - - {this.state.error.message} - {this.state.error.stack && ( -
{this.state.error.stack}
- )} -
- + ); } return this.props.children; diff --git a/packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts b/packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts new file mode 100644 index 000000000..99853c69c --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts @@ -0,0 +1,196 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "@effect/vitest"; + +/** + * The shell's theme must BE executor's theme. + * + * Artifacts render inside the console, so a shell that themes itself + * independently is immediately visible as "not this product" — which is exactly + * what the stock shadcn palette it shipped before did (a teal `--primary` in a + * strictly grayscale app). The values therefore come from + * `packages/react/src/styles/globals.css`, the app's own source of truth, which + * `design.md` serializes. + * + * They are COPIED rather than imported, and this test is the price of that copy. + * Importing is not available: the shell's stylesheet is compiled twice — once by + * `vite.config.shell.ts`, and again in-browser by `@tailwindcss/browser` inside + * the sandboxed frame, where a cross-package `@import` has no resolver and no + * filesystem. So the copy is deliberate, and this pin makes the drift loud: + * change a token in the app and this fails until the shell matches. + * + * Only the shared semantic tokens are pinned. The shell has no sidebar, and the + * app has no chart palette or artifact container, so neither side is forced to + * carry the other's surface. + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +const shellCss = readFileSync(path.join(here, "theme.css"), "utf-8"); +const shellGlobalsCss = readFileSync(path.join(here, "globals.css"), "utf-8"); +const appCss = readFileSync(path.join(here, "../../../../react/src/styles/globals.css"), "utf-8"); + +/** The tokens both stylesheets define, and that a model's UI actually renders + * against. `sidebar-*` and `scrollbar-*` are app chrome the shell has none of. */ +const SHARED_TOKENS = [ + "background", + "foreground", + "card", + "card-foreground", + "popover", + "popover-foreground", + "primary", + "primary-foreground", + "secondary", + "secondary-foreground", + "muted", + "muted-foreground", + "accent", + "accent-foreground", + "destructive", + "border", + "input", + "ring", + "radius", +] as const; + +/** The declarations inside the block a selector opens, by brace matching — + * `:root` and `.dark` both appear more than once across the media query, and a + * regex to the next `}` would stop at the first nested rule. */ +const blockAfter = (css: string, from: number): string => { + const open = css.indexOf("{", from); + if (open === -1) return ""; + let depth = 0; + for (let i = open; i < css.length; i += 1) { + if (css[i] === "{") depth += 1; + else if (css[i] === "}") { + depth -= 1; + if (depth === 0) return css.slice(open + 1, i); + } + } + return ""; +}; + +/** Every `--token: value` in the FIRST top-level block for `selector`, ignoring + * ones nested in a media query (which restate the same values). */ +const tokensIn = (css: string, selector: string): Map => { + // A top-level occurrence is one at the start of a line with no leading + // indentation — the media-query copies are indented. + const match = new RegExp(`^${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{`, "m").exec( + css, + ); + const block = match ? blockAfter(css, match.index) : ""; + const tokens = new Map(); + for (const declaration of block.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) { + const name = declaration[1]; + const value = declaration[2]; + if (name && value) tokens.set(name, value.trim()); + } + return tokens; +}; + +describe("shell theme tokens", () => { + it("match the app's light theme", () => { + const shell = tokensIn(shellCss, ":root"); + const app = tokensIn(appCss, ":root"); + for (const token of SHARED_TOKENS) { + expect(shell.get(token), `--${token} (light)`).toBe(app.get(token)); + } + }); + + it("match the app's dark theme", () => { + const shell = tokensIn(shellCss, ".dark"); + const app = tokensIn(appCss, ".dark"); + for (const token of SHARED_TOKENS) { + if (token === "radius") continue; // declared once, on :root + expect(shell.get(token), `--${token} (dark)`).toBe(app.get(token)); + } + }); + + it("keeps the shell's dark class and prefers-color-scheme block in agreement", () => { + const darkClass = tokensIn(shellCss, ".dark"); + // The media-query copy is indented, so `tokensIn` skips it; find it directly. + const media = /@media \(prefers-color-scheme: dark\) \{\s*:root \{([\s\S]*?)\n {2}\}/.exec( + shellCss, + ); + expect(media, "shell declares a prefers-color-scheme dark block").not.toBeNull(); + const mediaTokens = new Map(); + for (const declaration of (media?.[1] ?? "").matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) { + const name = declaration[1]; + const value = declaration[2]; + if (name && value) mediaTokens.set(name, value.trim()); + } + for (const [name, value] of mediaTokens) { + expect(darkClass.get(name), `--${name}`).toBe(value); + } + }); + + it("carries an ordered eight-slot chart palette in both themes", () => { + const light = tokensIn(shellCss, ":root"); + const dark = tokensIn(shellCss, ".dark"); + for (let slot = 1; slot <= 8; slot += 1) { + expect(light.get(`chart-${slot}`), `--chart-${slot} (light)`).toMatch(/^#[0-9a-f]{6}$/); + expect(dark.get(`chart-${slot}`), `--chart-${slot} (dark)`).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it("holds the chart palette to the system's restraint", () => { + // No neon. A saturated series color competes with the numbers it labels and + // reads as a different product; the ceiling here is what keeps "muted" from + // drifting back to a stock fully-saturated ramp on the next edit. HSL + // saturation, because that is the axis "muted" actually names — for + // reference, the palette tops out around 0.54 where a typical vivid chart + // ramp sits between 0.68 and 1.0. + const saturation = (hex: string): number => { + const [r, g, b] = [1, 3, 5].map((at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255); + const max = Math.max(r!, g!, b!); + const min = Math.min(r!, g!, b!); + const lightness = (max + min) / 2; + if (max === min) return 0; + return lightness > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min); + }; + + for (const selector of [":root", ".dark"]) { + const tokens = tokensIn(shellCss, selector); + for (let slot = 1; slot <= 8; slot += 1) { + const value = tokens.get(`chart-${slot}`) ?? ""; + expect(saturation(value), `--chart-${slot} in ${selector} is desaturated`).toBeLessThan( + 0.6, + ); + } + } + }); + + it("leads the palette with a neutral, so a single-series chart stays grayscale", () => { + for (const selector of [":root", ".dark"]) { + const value = tokensIn(shellCss, selector).get("chart-1") ?? ""; + const [r, g, b] = [1, 3, 5].map((at) => Number.parseInt(value.slice(at, at + 2), 16)); + expect(Math.max(r!, g!, b!) - Math.min(r!, g!, b!), `--chart-1 in ${selector}`).toBe(0); + } + }); + + it("sets Geist as the shell's own type, from inlined faces", () => { + expect(shellCss).toMatch(/--font-sans:\s*"Geist"/); + expect(shellCss).toMatch(/--font-mono:\s*"Geist Mono"/); + // Referenced as a local asset: a remote URL would be blocked by the inner + // frame's `font-src data:` and silently fall back to system-ui. + expect(shellGlobalsCss).toMatch(/url\("\.\/fonts\/geist-sans\.woff2"\)/); + expect(shellGlobalsCss).toMatch(/url\("\.\/fonts\/geist-mono\.woff2"\)/); + expect(shellGlobalsCss).not.toMatch(/fonts\.googleapis\.com|fonts\.gstatic\.com/); + }); + + // The theme is SHIPPED to the inner frame as source, so anything the frame's + // Tailwind compiler cannot resolve has to stay out of it. `@font-face` is the + // trap: its `url()` is rewritten to a `data:` URL by the build, and the frame + // has neither a bundler nor a filesystem to do that with — so the faces live + // in `globals.css` and only the tokens travel. + it("keeps build-resolved CSS out of the shared theme source", () => { + // Comments are stripped first: this file's own header explains the rule by + // naming the at-rules it forbids, which would otherwise match. + const declarations = shellCss.replace(/\/\*[\s\S]*?\*\//g, ""); + expect(declarations).not.toMatch(/@font-face/); + expect(declarations).not.toMatch(/@source/); + expect(declarations).not.toMatch(/url\(/); + expect(shellGlobalsCss).toMatch(/@import "\.\/theme\.css"/); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/theme.css b/packages/hosts/mcp-apps-shell/src/shell/theme.css new file mode 100644 index 000000000..96088907b --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/theme.css @@ -0,0 +1,265 @@ +/* + * Executor's design tokens, as Tailwind theme SOURCE. + * + * Split out of `globals.css` because this file has two consumers: + * + * 1. The shell's own build, which `@import`s it and compiles it ahead of time. + * 2. The sandboxed inner frame, which embeds it VERBATIM in a + * `");\n currentlyRenderingBoundaryHasStylesToHoist = true;\n rules.length = 0;\n hrefs.length = 0;\n }\n }\n function hasStylesToHoist(stylesheet) {\n return 2 !== stylesheet.state ? currentlyRenderingBoundaryHasStylesToHoist = true : false;\n }\n function writeHoistablesForBoundary(destination, hoistableState, renderState) {\n currentlyRenderingBoundaryHasStylesToHoist = false;\n destinationHasCapacity = true;\n currentlyFlushingRenderState = renderState;\n hoistableState.styles.forEach(flushStyleTagsLateForBoundary, destination);\n currentlyFlushingRenderState = null;\n hoistableState.stylesheets.forEach(hasStylesToHoist);\n currentlyRenderingBoundaryHasStylesToHoist && (renderState.stylesToHoist = true);\n return destinationHasCapacity;\n }\n function flushResource(resource) {\n for (var i = 0; i < resource.length; i++) this.push(resource[i]);\n resource.length = 0;\n }\n var stylesheetFlushingQueue = [];\n function flushStyleInPreamble(stylesheet) {\n pushLinkImpl(stylesheetFlushingQueue, stylesheet.props);\n for (var i = 0; i < stylesheetFlushingQueue.length; i++)\n this.push(stylesheetFlushingQueue[i]);\n stylesheetFlushingQueue.length = 0;\n stylesheet.state = 2;\n }\n function flushStylesInPreamble(styleQueue) {\n var hasStylesheets = 0 < styleQueue.sheets.size;\n styleQueue.sheets.forEach(flushStyleInPreamble, this);\n styleQueue.sheets.clear();\n var rules = styleQueue.rules, hrefs = styleQueue.hrefs;\n if (!hasStylesheets || hrefs.length) {\n this.push(currentlyFlushingRenderState.startInlineStyle);\n this.push(\' data-precedence="\');\n this.push(styleQueue.precedence);\n styleQueue = 0;\n if (hrefs.length) {\n for (this.push(\'" data-href="\'); styleQueue < hrefs.length - 1; styleQueue++)\n this.push(hrefs[styleQueue]), this.push(" ");\n this.push(hrefs[styleQueue]);\n }\n this.push(\'">\');\n for (styleQueue = 0; styleQueue < rules.length; styleQueue++)\n this.push(rules[styleQueue]);\n this.push("");\n rules.length = 0;\n hrefs.length = 0;\n }\n }\n function preloadLateStyle(stylesheet) {\n if (0 === stylesheet.state) {\n stylesheet.state = 1;\n var props = stylesheet.props;\n pushLinkImpl(stylesheetFlushingQueue, {\n rel: "preload",\n as: "style",\n href: stylesheet.props.href,\n crossOrigin: props.crossOrigin,\n fetchPriority: props.fetchPriority,\n integrity: props.integrity,\n media: props.media,\n hrefLang: props.hrefLang,\n referrerPolicy: props.referrerPolicy\n });\n for (stylesheet = 0; stylesheet < stylesheetFlushingQueue.length; stylesheet++)\n this.push(stylesheetFlushingQueue[stylesheet]);\n stylesheetFlushingQueue.length = 0;\n }\n }\n function preloadLateStyles(styleQueue) {\n styleQueue.sheets.forEach(preloadLateStyle, this);\n styleQueue.sheets.clear();\n }\n function pushCompletedShellIdAttribute(target, resumableState) {\n 0 === (resumableState.instructions & 32) && (resumableState.instructions |= 32, target.push(\n \' id="\',\n escapeTextForBrowser("_" + resumableState.idPrefix + "R_"),\n \'"\'\n ));\n }\n function writeStyleResourceDependenciesInJS(destination, hoistableState) {\n destination.push("[");\n var nextArrayOpenBrackChunk = "[";\n hoistableState.stylesheets.forEach(function(resource) {\n if (2 !== resource.state)\n if (3 === resource.state)\n destination.push(nextArrayOpenBrackChunk), resource = escapeJSObjectForInstructionScripts(\n "" + resource.props.href\n ), destination.push(resource), destination.push("]"), nextArrayOpenBrackChunk = ",[";\n else {\n destination.push(nextArrayOpenBrackChunk);\n var precedence = resource.props["data-precedence"], props = resource.props, coercedHref = sanitizeURL("" + resource.props.href);\n coercedHref = escapeJSObjectForInstructionScripts(coercedHref);\n destination.push(coercedHref);\n precedence = "" + precedence;\n destination.push(",");\n precedence = escapeJSObjectForInstructionScripts(precedence);\n destination.push(precedence);\n for (var propKey in props)\n if (hasOwnProperty.call(props, propKey) && (precedence = props[propKey], null != precedence))\n switch (propKey) {\n case "href":\n case "rel":\n case "precedence":\n case "data-precedence":\n break;\n case "children":\n case "dangerouslySetInnerHTML":\n throw Error(formatProdErrorMessage3(399, "link"));\n default:\n writeStyleResourceAttributeInJS(\n destination,\n propKey,\n precedence\n );\n }\n destination.push("]");\n nextArrayOpenBrackChunk = ",[";\n resource.state = 3;\n }\n });\n destination.push("]");\n }\n function writeStyleResourceAttributeInJS(destination, name, value) {\n var attributeName = name.toLowerCase();\n switch (typeof value) {\n case "function":\n case "symbol":\n return;\n }\n switch (name) {\n case "innerHTML":\n case "dangerouslySetInnerHTML":\n case "suppressContentEditableWarning":\n case "suppressHydrationWarning":\n case "style":\n case "ref":\n return;\n case "className":\n attributeName = "class";\n name = "" + value;\n break;\n case "hidden":\n if (false === value) return;\n name = "";\n break;\n case "src":\n case "href":\n value = sanitizeURL(value);\n name = "" + value;\n break;\n default:\n if (2 < name.length && ("o" === name[0] || "O" === name[0]) && ("n" === name[1] || "N" === name[1]) || !isAttributeNameSafe(name))\n return;\n name = "" + value;\n }\n destination.push(",");\n attributeName = escapeJSObjectForInstructionScripts(attributeName);\n destination.push(attributeName);\n destination.push(",");\n attributeName = escapeJSObjectForInstructionScripts(name);\n destination.push(attributeName);\n }\n function createHoistableState() {\n return { styles: /* @__PURE__ */ new Set(), stylesheets: /* @__PURE__ */ new Set(), suspenseyImages: false };\n }\n function prefetchDNS(href) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if ("string" === typeof href && href) {\n if (!resumableState.dnsResources.hasOwnProperty(href)) {\n resumableState.dnsResources[href] = null;\n resumableState = renderState.headers;\n var header, JSCompiler_temp;\n if (JSCompiler_temp = resumableState && 0 < resumableState.remainingCapacity)\n JSCompiler_temp = (header = "<" + ("" + href).replace(\n regexForHrefInLinkHeaderURLContext,\n escapeHrefForLinkHeaderURLContextReplacer\n ) + ">; rel=dns-prefetch", 0 <= (resumableState.remainingCapacity -= header.length + 2));\n JSCompiler_temp ? (renderState.resets.dns[href] = null, resumableState.preconnects && (resumableState.preconnects += ", "), resumableState.preconnects += header) : (header = [], pushLinkImpl(header, { href, rel: "dns-prefetch" }), renderState.preconnects.add(header));\n }\n enqueueFlush(request);\n }\n } else previousDispatcher.D(href);\n }\n function preconnect(href, crossOrigin) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if ("string" === typeof href && href) {\n var bucket = "use-credentials" === crossOrigin ? "credentials" : "string" === typeof crossOrigin ? "anonymous" : "default";\n if (!resumableState.connectResources[bucket].hasOwnProperty(href)) {\n resumableState.connectResources[bucket][href] = null;\n resumableState = renderState.headers;\n var header, JSCompiler_temp;\n if (JSCompiler_temp = resumableState && 0 < resumableState.remainingCapacity) {\n JSCompiler_temp = "<" + ("" + href).replace(\n regexForHrefInLinkHeaderURLContext,\n escapeHrefForLinkHeaderURLContextReplacer\n ) + ">; rel=preconnect";\n if ("string" === typeof crossOrigin) {\n var escapedCrossOrigin = ("" + crossOrigin).replace(\n regexForLinkHeaderQuotedParamValueContext,\n escapeStringForLinkHeaderQuotedParamValueContextReplacer\n );\n JSCompiler_temp += \'; crossorigin="\' + escapedCrossOrigin + \'"\';\n }\n JSCompiler_temp = (header = JSCompiler_temp, 0 <= (resumableState.remainingCapacity -= header.length + 2));\n }\n JSCompiler_temp ? (renderState.resets.connect[bucket][href] = null, resumableState.preconnects && (resumableState.preconnects += ", "), resumableState.preconnects += header) : (bucket = [], pushLinkImpl(bucket, {\n rel: "preconnect",\n href,\n crossOrigin\n }), renderState.preconnects.add(bucket));\n }\n enqueueFlush(request);\n }\n } else previousDispatcher.C(href, crossOrigin);\n }\n function preload(href, as, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (as && href) {\n switch (as) {\n case "image":\n if (options2) {\n var imageSrcSet = options2.imageSrcSet;\n var imageSizes = options2.imageSizes;\n var fetchPriority = options2.fetchPriority;\n }\n var key = imageSrcSet ? imageSrcSet + "\\n" + (imageSizes || "") : href;\n if (resumableState.imageResources.hasOwnProperty(key)) return;\n resumableState.imageResources[key] = PRELOAD_NO_CREDS;\n resumableState = renderState.headers;\n var header;\n resumableState && 0 < resumableState.remainingCapacity && "string" !== typeof imageSrcSet && "high" === fetchPriority && (header = getPreloadAsHeader(href, as, options2), 0 <= (resumableState.remainingCapacity -= header.length + 2)) ? (renderState.resets.image[key] = PRELOAD_NO_CREDS, resumableState.highImagePreloads && (resumableState.highImagePreloads += ", "), resumableState.highImagePreloads += header) : (resumableState = [], pushLinkImpl(\n resumableState,\n assign2(\n { rel: "preload", href: imageSrcSet ? void 0 : href, as },\n options2\n )\n ), "high" === fetchPriority ? renderState.highImagePreloads.add(resumableState) : (renderState.bulkPreloads.add(resumableState), renderState.preloads.images.set(key, resumableState)));\n break;\n case "style":\n if (resumableState.styleResources.hasOwnProperty(href)) return;\n imageSrcSet = [];\n pushLinkImpl(\n imageSrcSet,\n assign2({ rel: "preload", href, as }, options2)\n );\n resumableState.styleResources[href] = !options2 || "string" !== typeof options2.crossOrigin && "string" !== typeof options2.integrity ? PRELOAD_NO_CREDS : [options2.crossOrigin, options2.integrity];\n renderState.preloads.stylesheets.set(href, imageSrcSet);\n renderState.bulkPreloads.add(imageSrcSet);\n break;\n case "script":\n if (resumableState.scriptResources.hasOwnProperty(href)) return;\n imageSrcSet = [];\n renderState.preloads.scripts.set(href, imageSrcSet);\n renderState.bulkPreloads.add(imageSrcSet);\n pushLinkImpl(\n imageSrcSet,\n assign2({ rel: "preload", href, as }, options2)\n );\n resumableState.scriptResources[href] = !options2 || "string" !== typeof options2.crossOrigin && "string" !== typeof options2.integrity ? PRELOAD_NO_CREDS : [options2.crossOrigin, options2.integrity];\n break;\n default:\n if (resumableState.unknownResources.hasOwnProperty(as)) {\n if (imageSrcSet = resumableState.unknownResources[as], imageSrcSet.hasOwnProperty(href))\n return;\n } else\n imageSrcSet = {}, resumableState.unknownResources[as] = imageSrcSet;\n imageSrcSet[href] = PRELOAD_NO_CREDS;\n if ((resumableState = renderState.headers) && 0 < resumableState.remainingCapacity && "font" === as && (key = getPreloadAsHeader(href, as, options2), 0 <= (resumableState.remainingCapacity -= key.length + 2)))\n renderState.resets.font[href] = PRELOAD_NO_CREDS, resumableState.fontPreloads && (resumableState.fontPreloads += ", "), resumableState.fontPreloads += key;\n else\n switch (resumableState = [], href = assign2({ rel: "preload", href, as }, options2), pushLinkImpl(resumableState, href), as) {\n case "font":\n renderState.fontPreloads.add(resumableState);\n break;\n default:\n renderState.bulkPreloads.add(resumableState);\n }\n }\n enqueueFlush(request);\n }\n } else previousDispatcher.L(href, as, options2);\n }\n function preloadModule(href, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (href) {\n var as = options2 && "string" === typeof options2.as ? options2.as : "script";\n switch (as) {\n case "script":\n if (resumableState.moduleScriptResources.hasOwnProperty(href)) return;\n as = [];\n resumableState.moduleScriptResources[href] = !options2 || "string" !== typeof options2.crossOrigin && "string" !== typeof options2.integrity ? PRELOAD_NO_CREDS : [options2.crossOrigin, options2.integrity];\n renderState.preloads.moduleScripts.set(href, as);\n break;\n default:\n if (resumableState.moduleUnknownResources.hasOwnProperty(as)) {\n var resources = resumableState.unknownResources[as];\n if (resources.hasOwnProperty(href)) return;\n } else\n resources = {}, resumableState.moduleUnknownResources[as] = resources;\n as = [];\n resources[href] = PRELOAD_NO_CREDS;\n }\n pushLinkImpl(as, assign2({ rel: "modulepreload", href }, options2));\n renderState.bulkPreloads.add(as);\n enqueueFlush(request);\n }\n } else previousDispatcher.m(href, options2);\n }\n function preinitStyle(href, precedence, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (href) {\n precedence = precedence || "default";\n var styleQueue = renderState.styles.get(precedence), resourceState = resumableState.styleResources.hasOwnProperty(href) ? resumableState.styleResources[href] : void 0;\n null !== resourceState && (resumableState.styleResources[href] = null, styleQueue || (styleQueue = {\n precedence: escapeTextForBrowser(precedence),\n rules: [],\n hrefs: [],\n sheets: /* @__PURE__ */ new Map()\n }, renderState.styles.set(precedence, styleQueue)), precedence = {\n state: 0,\n props: assign2(\n { rel: "stylesheet", href, "data-precedence": precedence },\n options2\n )\n }, resourceState && (2 === resourceState.length && adoptPreloadCredentials(precedence.props, resourceState), (renderState = renderState.preloads.stylesheets.get(href)) && 0 < renderState.length ? renderState.length = 0 : precedence.state = 1), styleQueue.sheets.set(href, precedence), enqueueFlush(request));\n }\n } else previousDispatcher.S(href, precedence, options2);\n }\n function preinitScript(src, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (src) {\n var resourceState = resumableState.scriptResources.hasOwnProperty(src) ? resumableState.scriptResources[src] : void 0;\n null !== resourceState && (resumableState.scriptResources[src] = null, options2 = assign2({ src, async: true }, options2), resourceState && (2 === resourceState.length && adoptPreloadCredentials(options2, resourceState), src = renderState.preloads.scripts.get(src)) && (src.length = 0), src = [], renderState.scripts.add(src), pushScriptImpl(src, options2), enqueueFlush(request));\n }\n } else previousDispatcher.X(src, options2);\n }\n function preinitModuleScript(src, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (src) {\n var resourceState = resumableState.moduleScriptResources.hasOwnProperty(\n src\n ) ? resumableState.moduleScriptResources[src] : void 0;\n null !== resourceState && (resumableState.moduleScriptResources[src] = null, options2 = assign2({ src, type: "module", async: true }, options2), resourceState && (2 === resourceState.length && adoptPreloadCredentials(options2, resourceState), src = renderState.preloads.moduleScripts.get(src)) && (src.length = 0), src = [], renderState.scripts.add(src), pushScriptImpl(src, options2), enqueueFlush(request));\n }\n } else previousDispatcher.M(src, options2);\n }\n function adoptPreloadCredentials(target, preloadState) {\n null == target.crossOrigin && (target.crossOrigin = preloadState[0]);\n null == target.integrity && (target.integrity = preloadState[1]);\n }\n function getPreloadAsHeader(href, as, params) {\n href = ("" + href).replace(\n regexForHrefInLinkHeaderURLContext,\n escapeHrefForLinkHeaderURLContextReplacer\n );\n as = ("" + as).replace(\n regexForLinkHeaderQuotedParamValueContext,\n escapeStringForLinkHeaderQuotedParamValueContextReplacer\n );\n as = "<" + href + \'>; rel=preload; as="\' + as + \'"\';\n for (var paramName in params)\n hasOwnProperty.call(params, paramName) && (href = params[paramName], "string" === typeof href && (as += "; " + paramName.toLowerCase() + \'="\' + ("" + href).replace(\n regexForLinkHeaderQuotedParamValueContext,\n escapeStringForLinkHeaderQuotedParamValueContextReplacer\n ) + \'"\'));\n return as;\n }\n var regexForHrefInLinkHeaderURLContext = /[<>\\r\\n]/g;\n function escapeHrefForLinkHeaderURLContextReplacer(match2) {\n switch (match2) {\n case "<":\n return "%3C";\n case ">":\n return "%3E";\n case "\\n":\n return "%0A";\n case "\\r":\n return "%0D";\n default:\n throw Error(\n "escapeLinkHrefForHeaderContextReplacer encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React"\n );\n }\n }\n var regexForLinkHeaderQuotedParamValueContext = /["\';,\\r\\n]/g;\n function escapeStringForLinkHeaderQuotedParamValueContextReplacer(match2) {\n switch (match2) {\n case \'"\':\n return "%22";\n case "\'":\n return "%27";\n case ";":\n return "%3B";\n case ",":\n return "%2C";\n case "\\n":\n return "%0A";\n case "\\r":\n return "%0D";\n default:\n throw Error(\n "escapeStringForLinkHeaderQuotedParamValueContextReplacer encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React"\n );\n }\n }\n function hoistStyleQueueDependency(styleQueue) {\n this.styles.add(styleQueue);\n }\n function hoistStylesheetDependency(stylesheet) {\n this.stylesheets.add(stylesheet);\n }\n function hoistHoistables(parentState, childState) {\n childState.styles.forEach(hoistStyleQueueDependency, parentState);\n childState.stylesheets.forEach(hoistStylesheetDependency, parentState);\n childState.suspenseyImages && (parentState.suspenseyImages = true);\n }\n function createRenderState(resumableState, generateStaticMarkup) {\n var idPrefix = resumableState.idPrefix, bootstrapChunks = [], bootstrapScriptContent = resumableState.bootstrapScriptContent, bootstrapScripts = resumableState.bootstrapScripts, bootstrapModules = resumableState.bootstrapModules;\n void 0 !== bootstrapScriptContent && (bootstrapChunks.push("",\n ("" + bootstrapScriptContent).replace(scriptRegex, scriptReplacer),\n "<\\/script>"\n ));\n bootstrapScriptContent = idPrefix + "P:";\n var JSCompiler_object_inline_segmentPrefix_1673 = idPrefix + "S:";\n idPrefix += "B:";\n var JSCompiler_object_inline_preconnects_1687 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_fontPreloads_1688 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_highImagePreloads_1689 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_styles_1690 = /* @__PURE__ */ new Map(), JSCompiler_object_inline_bootstrapScripts_1691 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_scripts_1692 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_bulkPreloads_1693 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_preloads_1694 = {\n images: /* @__PURE__ */ new Map(),\n stylesheets: /* @__PURE__ */ new Map(),\n scripts: /* @__PURE__ */ new Map(),\n moduleScripts: /* @__PURE__ */ new Map()\n };\n if (void 0 !== bootstrapScripts)\n for (var i = 0; i < bootstrapScripts.length; i++) {\n var scriptConfig = bootstrapScripts[i], src, crossOrigin = void 0, integrity = void 0, props = {\n rel: "preload",\n as: "script",\n fetchPriority: "low",\n nonce: void 0\n };\n "string" === typeof scriptConfig ? props.href = src = scriptConfig : (props.href = src = scriptConfig.src, props.integrity = integrity = "string" === typeof scriptConfig.integrity ? scriptConfig.integrity : void 0, props.crossOrigin = crossOrigin = "string" === typeof scriptConfig || null == scriptConfig.crossOrigin ? void 0 : "use-credentials" === scriptConfig.crossOrigin ? "use-credentials" : "");\n scriptConfig = resumableState;\n var href = src;\n scriptConfig.scriptResources[href] = null;\n scriptConfig.moduleScriptResources[href] = null;\n scriptConfig = [];\n pushLinkImpl(scriptConfig, props);\n JSCompiler_object_inline_bootstrapScripts_1691.add(scriptConfig);\n bootstrapChunks.push(\'