import { sendToAgentChat } from "@agent-native/core/client/agent-chat"; import { useDevMode } from "@agent-native/core/client/agent-chat"; import { agentNativePath, appBasePath, } from "@agent-native/core/client/api-path"; import { PromptComposer } from "@agent-native/core/client/composer"; import { isInBuilderFrame } from "@agent-native/core/client/host"; import { useBuilderConnectFlow } from "@agent-native/core/client/settings/useBuilderStatus"; import { buildChatFirstAppCreationPrompt, docsUrl, getWorkspaceAppIdValidationError, titleFromChatFirstAppPrompt, } from "@agent-native/core/shared"; import { IconAlertTriangle, IconArrowLeft, IconArrowUpRight, IconBook, IconCheck, IconChevronDown, IconFileText, IconKey, IconLoader2, IconPlus, } from "@tabler/icons-react"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Button } from "./ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; interface VaultSecretOption { id: string; name: string; credentialKey: string; provider?: string | null; description?: string | null; } interface WorkspaceResourceOption { id: string; kind: "skill" | "instruction" | "agent" | "knowledge" | "mcp-server"; name: string; description?: string | null; path: string; scope: "all" | "selected"; updatedAt?: number; } type VaultAccessMode = "all-apps" | "manual"; interface CreateAppPopoverProps { /** * Custom trigger element. Defaults to a dashed-border tile that matches the * apps grid empty state. */ trigger?: ReactNode; /** * Override the popover alignment. Defaults to "center" with a 10px offset. */ align?: "start" | "center" | "end"; /** * Called after the server accepts a Builder app creation request. */ onCreated?: () => void; } 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 defaultDispatchBasePath(): string | null { const base = appBasePath(); if (base === "/dispatch") return null; return null; } 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}`; } const ERROR_FAILURE_REASONS = new Set([ "builder-error", "builder-not-connected", "credential-store-unavailable", "settings-management-required", ]); const LOCAL_APP_DOCS_URL = docsUrl("multi-app-workspace", { hash: "adding-a-new-app", }); function isErrorFailureReason(reason: string | null): boolean { return !!reason && ERROR_FAILURE_REASONS.has(reason); } /** * Inline two-step app-creation flow: prompt → optional access picker → submit. * Used both in the popover form and in the dedicated `/new-app` page so the * same UX shows up everywhere a teammate kicks off a new workspace app. */ export function CreateAppFlow({ onClose, onCreated, className = "", }: { onClose?: () => void; onCreated?: () => void; className?: string; }) { const [step, setStep] = useState<"prompt" | "access">("prompt"); const [prompt, setPrompt] = useState(""); 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 [isSubmitting, setIsSubmitting] = useState(false); const { isDevMode } = useDevMode(); const basePath = useMemo(() => defaultDispatchBasePath(), []); // Enabled only while the connect CTA is on screen. Left always-on, the hook // would poll Builder status on every popover mount and fire onConnected on // its first status read for anyone already connected. const connectFlow = useBuilderConnectFlow({ enabled: failureReason === "builder-not-connected", trackingSource: "dispatch_create_app", trackingFlow: "create_app", onConnected: () => { setFailureReason(null); setStatusMessage("Builder connected. Press Create app to try again."); }, }); // Fetch access options eagerly so step 2 has them ready immediately. useEffect(() => { let cancelled = false; fetchJson(actionUrl(basePath, "list-vault-secret-options")) .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(actionUrl(basePath, "get-vault-access-settings")) .then((data) => { if (cancelled) return; setVaultAccessMode(data?.mode === "manual" ? "manual" : "all-apps"); }) .catch(() => { if (cancelled) return; setVaultAccessMode("manual"); }); fetchJson(actionUrl(basePath, "list-workspace-resource-options")) .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; }; }, [basePath]); const selectedSecrets = useMemo( () => secrets.filter((s) => selectedSecretIds.includes(s.id)), [secrets, selectedSecretIds], ); const selectedResources = useMemo( () => resources.filter((r) => selectedResourceIds.includes(r.id)), [resources, selectedResourceIds], ); const selectedSecretLabel = vaultAccessMode === "all-apps" ? "all keys" : selectedSecretIds.length === 0 ? "no keys" : `${selectedSecretIds.length} key${selectedSecretIds.length === 1 ? "" : "s"}`; const selectedResourceLabel = selectedResourceIds.length === 0 ? "no resources" : `${selectedResourceIds.length} resource${selectedResourceIds.length === 1 ? "" : "s"}`; const selectedAccessLabel = [selectedSecretLabel, selectedResourceLabel].join( " · ", ); function toggleSecret(id: string) { setSelectedSecretIds((cur) => cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id], ); } function toggleResource(id: string) { setSelectedResourceIds((cur) => cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id], ); } async function submit(rawPrompt: string) { const trimmed = rawPrompt.trim(); if (!trimmed || isSubmitting) return; const appId = titleFromChatFirstAppPrompt(trimmed); const validationError = getWorkspaceAppIdValidationError(appId); if (validationError) { setStatusMessage(validationError); return; } const message = buildChatFirstAppCreationPrompt({ appId, prompt: trimmed, selectedKeys: vaultAccessMode === "manual" ? selectedSecrets.map((s) => s.credentialKey) : [], selectedResources, vaultAccessMode, }); setIsSubmitting(true); setStatusMessage(null); setBranchUrl(null); setFailureReason(null); try { if (isInBuilderFrame()) { sendToAgentChat({ message, submit: true, type: "code" }); setStatusMessage("Sent to Builder chat."); onClose?.(); } else if (isDevMode) { sendToAgentChat({ message, submit: true, type: "code", newTab: true, reuseEmptyTab: true, }); setStatusMessage("Sent to the local agent."); onClose?.(); } else { const result = await fetchJson( actionUrl(basePath, "start-workspace-app-creation"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: trimmed, appId, secretIds: vaultAccessMode === "manual" && selectedSecretIds.length > 0 ? selectedSecretIds : [], resourceIds: selectedResourceIds.length > 0 ? selectedResourceIds : [], }), }, ); if (result?.mode === "builder") { onCreated?.(); setBranchUrl(result?.url || null); setStatusMessage("Builder branch created."); } else if (result?.mode === "local-agent") { sendToAgentChat({ message: result.prompt ?? message, submit: true, type: "code", newTab: true, reuseEmptyTab: true, }); setStatusMessage("Sent to the local agent."); onClose?.(); } 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); } } const submitWithSelectedAccess = () => submit(prompt); const isCreatingBuilderBranch = isSubmitting && !isInBuilderFrame() && !isDevMode; if (branchUrl) { return (

Your Builder branch is ready

Continue building and editing your app in Builder.

); } if (isCreatingBuilderBranch) { return (
); } return (
{step === "prompt" ? ( <>

Create app

{ setPrompt(text); submit(text); }} /> ) : ( <>
{selectedAccessLabel}
Dispatch keys
{vaultAccessMode === "all-apps" ? (

Every saved Dispatch vault key is available to new apps.

) : secretsError ? (

{secretsError}

) : secrets.length === 0 ? (

No Dispatch vault keys found yet.

) : ( secrets.map((secret) => { const selected = selectedSecretIds.includes(secret.id); return (
{(secret.provider || secret.name) && (
Details
Provider: {secret.provider || "Not specified"}
Name: {secret.name}
)}
); }) )}
Resource packs
{resourcesError ? (

{resourcesError}

) : resources.length === 0 ? (

No Dispatch resource packs found yet.

) : ( resources.map((resource) => { const selected = selectedResourceIds.includes(resource.id); return (
Details
Scope:{" "} {resource.scope === "all" ? "All apps" : "Selected apps"}
{resource.description ? (
{resource.description}
) : null}
); }) )}
{!prompt.trim() ? (

Add a prompt on the previous step before creating the app.

) : null} )} {statusMessage ? (
{isErrorFailureReason(failureReason) ? ( ) : null} {statusMessage} {branchUrl ? ( Open in Builder ) : null}
{failureReason === "builder-not-connected" ? (
Create locally
) : null} {failureReason === "credential-store-unavailable" || failureReason === "builder-error" ? ( ) : null}
) : null}
); } export function CreateAppPopover({ trigger, align = "center", onCreated, }: CreateAppPopoverProps) { const [open, setOpen] = useState(false); return ( {trigger ?? ( )} setOpen(false)} onCreated={onCreated} /> ); }