import { IconAlertTriangle, IconArrowUpRight, IconBook, IconCheck, IconChevronDown, IconFileText, IconKey, } from "@tabler/icons-react"; import { useEffect, useMemo, useState } from "react"; import { getWorkspaceAppIdValidationError } from "../shared/workspace-app-id.js"; import { sendToAgentChat } from "./agent-chat.js"; import { agentNativePath, appBasePath } from "./api-path.js"; import { isInBuilderFrame } from "./builder-frame.js"; import { PromptComposer } from "./composer/index.js"; import { useBuilderConnectFlow } from "./settings/useBuilderStatus.js"; import { useDevMode } from "./use-dev-mode.js"; export interface VaultSecretOption { id: string; name: string; credentialKey: string; provider?: string | null; description?: string | null; } export interface WorkspaceResourceOption { id: string; kind: "skill" | "instruction" | "agent" | "knowledge"; name: string; description?: string | null; path: string; scope: "all" | "selected"; updatedAt?: number; } type VaultAccessMode = "all-apps" | "manual"; export interface NewWorkspaceAppFlowProps { sourceApp?: string; className?: string; dispatchBasePath?: string | null; } function slugify(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .replace(/^[^a-z]+/, "") .slice(0, 48); } function titleFromPrompt(prompt: string): string { const cleaned = prompt .replace(/\b(build|create|make|an?|the|app|tool|dashboard)\b/gi, " ") .replace(/\s+/g, " ") .trim(); return slugify(cleaned || "new-app") || "new-app"; } function actionUrl(basePath: string | null, action: string): string { const path = `/_agent-native/actions/${action}`; if (basePath === null) return agentNativePath(path); const normalized = basePath.replace(/\/+$/, ""); return `${normalized}${path}`; } function defaultDispatchBasePath(sourceApp?: string): string | null { if (sourceApp === "dispatch") return null; const base = appBasePath(); if (base === "/dispatch") return null; return "/dispatch"; } const ERROR_FAILURE_REASONS = new Set([ "builder-error", "builder-not-connected", "credential-store-unavailable", ]); function isErrorFailureReason(reason: string | null): boolean { return !!reason && ERROR_FAILURE_REASONS.has(reason); } async function fetchJson(url: string, init?: RequestInit): Promise { const res = await fetch(url, init); const data = await res.json().catch(() => null); if (!res.ok) { throw new Error( data?.error || data?.message || `Request failed ${res.status}`, ); } return data; } function buildNewWorkspaceAppPrompt(input: { appId: string; prompt: string; selectedKeys: string[]; selectedResources: WorkspaceResourceOption[]; vaultAccessMode: VaultAccessMode; }): string { const keyList = input.selectedKeys.join(", "); const grantRequest = input.vaultAccessMode === "all-apps" ? `Dispatch vault access: all saved vault keys are available to every workspace app by default. No per-app vault grants are needed.` : keyList ? `Requested Dispatch vault key grants for this app: ${keyList}` : `Requested Dispatch vault key grants for this app: none`; const resourceList = input.selectedResources.length ? input.selectedResources .map( (resource) => `- ${resource.name} (${resource.kind}, ${resource.path})`, ) .join("\n") : "none"; return [ `Create a new agent-native app in this workspace.`, `This is a new workspace app request, not a feature request for the current app.`, ``, `Suggested app name: ${input.appId} (you may adjust the slug if it conflicts)`, `User prompt: ${input.prompt.trim()}`, `Generate a concise one-sentence app description from the user prompt before coding; save it in apps/${input.appId}/package.json "description" so Dispatch and A2A can describe the app.`, `If the user mentions a product or company such as Granola, Loom, Superhuman, Linear, or Notion, treat it as product inspiration unless they explicitly ask to connect to that service. Do not invent or require third-party API keys like GRANOLA_API_KEY just because a product is named.`, grantRequest, `Requested Dispatch workspace resources for this app:\n${resourceList}`, `Dispatch workspace resources with scope=all are inherited workspace context. Do not copy or sync them into the new app; every workspace app reads them at runtime and may override with app shared or personal resources.`, ``, `Pick a UI template that fits the user's prompt — analytics, assets, brain, calendar, chat, content, design, dispatch, forms, mail, slides, or clips when the request needs custom UI but none of the domain templates fit.`, `Use the chat template as the minimal add-UI scaffold. Do not treat Chat as the required default for primitive/headless agent workflows unless the user explicitly asks for a UI app.`, `If you use the chat template, treat it as scaffolding only: the finished app must use the requested app's real name, home screen, navigation, package metadata, and manifest, and it must not leave visible "Chat", "Starter", "Blank app", or "New app" UI behind.`, `Use the workspace app layout: create it under apps/${input.appId}, mount it at /${input.appId}, keep it on the shared workspace database/hosting model, and avoid table-name collisions by namespacing any new domain tables to the app.`, `Important routing rule: from outside the app, link to /${input.appId}; inside apps/${input.appId}, React Router routes are app-local. Use and navigate("/review"), not "/${input.appId}/review"; APP_BASE_PATH supplies the mounted prefix, and hardcoding it causes doubled URLs like /${input.appId}/${input.appId}/review.`, `Action-backed UI is mandatory for normal CRUD. Define reads/writes in actions/ with defineAction, expose read actions with http: { method: "GET" }, and call them from React with useActionQuery/useActionMutation or a named helper that uses callAction. Do not create duplicate JSON CRUD routes under /api/* for data the agent can mutate; those bypass the action cache contract and make agent-created records invisible until reload. If a rare raw non-action fetch is unavoidable, include useChangeVersions(["action", ]) in the query key and wrap framework URLs with named client helpers so mounted apps call the right URL.`, `If the user's prompt mentions sibling apps like Mail, Calendar, Assets, Brain, Dispatch, or other templates, treat them as existing workspace neighbors or integrations. Do not scaffold those sibling apps inside apps/${input.appId} unless the user explicitly asks to create them too.`, `Do not satisfy this by adding a route, page, component, or file inside apps/chat or another existing app unless the user explicitly asks to modify that existing app.`, `Use relative workspace links like /${input.appId}. Do not hardcode localhost, 127.0.0.1, 8080, 8100, or any dev port; the active workspace gateway/browser origin owns the port.`, `Use the framework/template UI stack: shadcn/ui components and @tabler/icons-react. Do not add lucide-react or another icon library for standard UI.`, `Ensure the React Router client entry preserves APP_BASE_PATH/VITE_APP_BASE_PATH via appBasePath().`, input.vaultAccessMode === "all-apps" ? `Do not create per-app Dispatch vault grants unless the workspace switches vault access to manual or the user explicitly asks for manual grants.` : keyList ? `After the app exists, grant the selected Dispatch vault keys to appId "${input.appId}" and sync them once the app server is available. Treat these as requested grants, not active grants before creation succeeds.` : `Do not grant any Dispatch vault keys unless the user asks later.`, input.selectedResources.length ? `After the app exists, grant the selected Dispatch workspace resources to appId "${input.appId}". Do not sync all-app workspace resources; they are inherited.` : `Do not grant any selected-only Dispatch workspace resources unless the user asks later.`, ``, `App readiness requirements before handing off:`, `- Ensure apps/${input.appId}/package.json exists with displayName/name and a concise description so Dispatch and the workspace gateway discover it from the filesystem. There is no separate workspace app registry to edit.`, `- Update the app manifest/package/deploy metadata needed by the existing workspace deployment model; do not leave the app relying only on local discovery.`, `- Verify the app's agent card/A2A metadata is ready so Dispatch can discover and delegate to the app after deployment. Every sibling workspace app is available over A2A by default through call-agent, with names and descriptions from the workspace app registry.`, `- Include a final verification note covering filesystem discovery, manifest/deploy metadata, relative same-origin routing, and agent-card readiness.`, `When it is ready, start or update the workspace dev server and navigate the user to /${input.appId}.`, ].join("\n"); } export function NewWorkspaceAppFlow({ sourceApp = "chat", className = "", dispatchBasePath, }: NewWorkspaceAppFlowProps) { const [selectedSecretIds, setSelectedSecretIds] = useState([]); const [selectedResourceIds, setSelectedResourceIds] = useState([]); const [secrets, setSecrets] = useState([]); const [resources, setResources] = useState([]); const [vaultAccessMode, setVaultAccessMode] = useState("all-apps"); const [secretsError, setSecretsError] = useState(null); const [resourcesError, setResourcesError] = useState(null); const [statusMessage, setStatusMessage] = useState(null); const [branchUrl, setBranchUrl] = useState(null); const [failureReason, setFailureReason] = useState(null); const [lastSubmittedPrompt, setLastSubmittedPrompt] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const { isDevMode } = useDevMode(); const effectiveDispatchBasePath = dispatchBasePath === undefined ? defaultDispatchBasePath(sourceApp) : dispatchBasePath; // Enabled only while the connect CTA is on screen. Left always-on, the hook // would poll Builder status on every mount and fire onConnected on its first // status read for anyone already connected. const connectFlow = useBuilderConnectFlow({ enabled: failureReason === "builder-not-connected", trackingSource: "new_workspace_app_flow", trackingFlow: "create_app", onConnected: () => { setFailureReason(null); setStatusMessage("Builder connected. Press Create app to try again."); }, }); useEffect(() => { let cancelled = false; const secretsUrl = actionUrl( effectiveDispatchBasePath, "list-vault-secret-options", ); const vaultAccessUrl = actionUrl( effectiveDispatchBasePath, "get-vault-access-settings", ); const resourcesUrl = actionUrl( effectiveDispatchBasePath, "list-workspace-resource-options", ); fetchJson(secretsUrl) .then((data) => { if (cancelled) return; setSecrets(Array.isArray(data) ? data : []); setSecretsError(null); }) .catch((err) => { if (cancelled) return; setSecrets([]); setSecretsError(err?.message || "Could not load Dispatch keys"); }); fetchJson(vaultAccessUrl) .then((data) => { if (cancelled) return; setVaultAccessMode(data?.mode === "manual" ? "manual" : "all-apps"); }) .catch(() => { if (cancelled) return; setVaultAccessMode("manual"); }); fetchJson(resourcesUrl) .then((data) => { if (cancelled) return; setResources(Array.isArray(data) ? data : []); setResourcesError(null); }) .catch((err) => { if (cancelled) return; setResources([]); setResourcesError(err?.message || "Could not load Dispatch resources"); }); return () => { cancelled = true; }; }, [effectiveDispatchBasePath]); const selectedSecrets = useMemo( () => secrets.filter((secret) => selectedSecretIds.includes(secret.id)), [secrets, selectedSecretIds], ); const selectedResources = useMemo( () => resources.filter((resource) => selectedResourceIds.includes(resource.id)), [resources, selectedResourceIds], ); const selectedSecretLabel = vaultAccessMode === "all-apps" ? "All keys included" : selectedSecretIds.length === 0 ? "No keys selected" : `${selectedSecretIds.length} key${selectedSecretIds.length === 1 ? "" : "s"} selected`; const selectedResourceLabel = selectedResourceIds.length === 0 ? "No resources selected" : `${selectedResourceIds.length} resource${selectedResourceIds.length === 1 ? "" : "s"} selected`; async function submit(rawPrompt: string) { const prompt = rawPrompt.trim(); if (!prompt || isSubmitting) return; const appId = titleFromPrompt(prompt); const validationError = getWorkspaceAppIdValidationError(appId); if (validationError) { setStatusMessage(validationError); return; } const message = buildNewWorkspaceAppPrompt({ appId, prompt, selectedKeys: vaultAccessMode === "manual" ? selectedSecrets.map((s) => s.credentialKey) : [], selectedResources, vaultAccessMode, }); setLastSubmittedPrompt(prompt); setIsSubmitting(true); setStatusMessage(null); setBranchUrl(null); setFailureReason(null); try { if (isInBuilderFrame()) { sendToAgentChat({ message, submit: true, type: "code" }); setStatusMessage("Sent to Builder chat."); } else if (isDevMode) { sendToAgentChat({ message, submit: true, type: "code", newTab: true }); setStatusMessage("Sent to the local agent."); } else { const result = await fetchJson( actionUrl(effectiveDispatchBasePath, "start-workspace-app-creation"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, appId, secretIds: vaultAccessMode === "manual" ? selectedSecretIds : [], resourceIds: selectedResourceIds, }), }, ); if (result?.mode === "builder") { setBranchUrl(result?.url || null); setStatusMessage("Builder branch created."); } else { setStatusMessage( result?.message || "This requires a code change. Edit locally or use Builder.io to edit this code in the cloud and continue customizing the app any way you like.", ); setFailureReason( result?.mode === "builder-unavailable" ? result.reason : null, ); } } } catch (err: any) { setStatusMessage(err?.message || "Could not start the new app flow."); setFailureReason(null); } finally { setIsSubmitting(false); } } function toggleSecret(id: string) { setSelectedSecretIds((current) => current.includes(id) ? current.filter((existing) => existing !== id) : [...current, id], ); } function toggleResource(id: string) { setSelectedResourceIds((current) => current.includes(id) ? current.filter((existing) => existing !== id) : [...current, id], ); } return (
submit(text)} /> {statusMessage ? (
{isErrorFailureReason(failureReason) ? ( ) : null} {statusMessage} {branchUrl ? ( Open branch ) : null}
{failureReason === "builder-not-connected" ? ( ) : null} {failureReason === "credential-store-unavailable" || failureReason === "builder-error" ? ( ) : null}
) : null}
); }