diff --git a/apps/hosted/web/src/contracts/dashboard-bindings.ts b/apps/hosted/web/src/contracts/dashboard-bindings.ts index 233b73ccb3..65fb27a18f 100644 --- a/apps/hosted/web/src/contracts/dashboard-bindings.ts +++ b/apps/hosted/web/src/contracts/dashboard-bindings.ts @@ -5,6 +5,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import type { AppId } from "@executor-js/sdk"; import type { OrganizationReference } from "@executor-js/hosted-server/organization"; import { providerDisplayUrl, type InstallApp } from "@executor-js/ui/contracts/dashboard"; +import { blankAppFiles } from "@executor-js/ui/contracts/blank-app"; import { HostedClient, catalogAtom } from "./api.ts"; import { inventoryAtom } from "./organization.ts"; import { acknowledgeApp, toolsAtom } from "./apps.ts"; @@ -53,4 +54,20 @@ export const dashboardAtoms = Atom.family((organization: OrganizationReference) client.apps.importCustom({ params: { organization }, payload: { source: input } }), ).pipe(Effect.tap((saved) => Effect.sync(() => acknowledgeApp(get, organization, saved)))), ), + /** + * Hosted `apps.deploy` creates the named app, builds it, and stores the working source. + * `package.json` pins the release `framework.release` reports, which the build requires. + */ + createBlank: HostedClient.runtime.fn((input: { name: string }, get) => + Effect.gen(function* () { + const client = yield* HostedClient; + const { version } = yield* client.framework.release({ params: { organization } }); + const saved = yield* client.apps.deploy({ + params: { organization }, + payload: { name: input.name, files: blankAppFiles(input.name, version) }, + }); + yield* Effect.sync(() => acknowledgeApp(get, organization, saved)); + return saved; + }), + ), })); diff --git a/apps/hosted/web/src/implementation/pages/add-app.tsx b/apps/hosted/web/src/implementation/pages/add-app.tsx index 07840eeb77..3c2a7bbc2d 100644 --- a/apps/hosted/web/src/implementation/pages/add-app.tsx +++ b/apps/hosted/web/src/implementation/pages/add-app.tsx @@ -5,7 +5,11 @@ import { useAtomMount } from "@effect/atom-react"; import { HostedFailure, useDashboardAtoms } from "../components/dashboard-bindings.tsx"; import { Link, useNavigate } from "@tanstack/react-router"; import { Button } from "@executor-js/ui/components/button"; -import { CatalogPage as Catalog, CatalogInstall } from "@executor-js/ui/dashboard/catalog"; +import { + BlankAppSetup, + CatalogPage as Catalog, + CatalogInstall, +} from "@executor-js/ui/dashboard/catalog"; import { InstallPublication } from "@executor-js/ui/dashboard/install-publication"; import { type AppAcknowledgement } from "@executor-js/ui/contracts/app-management"; import type { CatalogEntry } from "@executor-js/catalog/contracts"; @@ -19,7 +23,9 @@ import { acknowledgeApp } from "../../contracts/apps.ts"; type Selection = | { readonly kind: "catalog"; readonly entry: CatalogEntry } - | { readonly kind: "publication"; readonly publication: typeof Publication.Type }; + | { readonly kind: "publication"; readonly publication: typeof Publication.Type } + | { readonly kind: "blank" }; + /** Public publications and integration templates share Add app and the same organization-owned app records. */ export function AddAppPage() { const mcpOrigin = useMcpOrigin(); @@ -64,6 +70,24 @@ export function AddAppPage() { onInstalled={installed} /> ); + if (selection?.kind === "blank") + return ( + { + reportBrowserUsage({ area: "apps", action: "blank_app", outcome: "cancelled" }); + setSelection(undefined); + }} + onCreated={(app) => + navigate({ + to: "/org/$organizationSlug/apps/$appId", + search: { view: "source" }, + params: { organizationSlug, appId: app.id }, + }) + } + /> + ); return ( + +
+
+

+ Create blank app +

+

Start from an empty app and build with your agent.

+
+
+ ({ name })} + onCreated={onCreated} + onCancel={onBack} + label="Create app" + /> + + ); +} /** * MCP servers and built-in apps are added directly; the server confirms how an MCP server * connects. Every other service is set up by the user's agent from a copied prompt.