import { IconExternalLink, IconLoader2 } from "@tabler/icons-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { withBuilderUtmTrackingParams } from "../shared/builder-link-tracking.js"; import { agentNativePath } from "./api-path.js"; import { BuilderBMark } from "./builder-mark.js"; import { getCallbackOrigin } from "./frame.js"; import { useBuilderConnectFlow } from "./settings/useBuilderStatus.js"; import { cn } from "./utils.js"; const DESKTOP_DOWNLOAD_URL = "https://www.agent-native.com/download"; const CODE_CHANGE_FALLBACK_DETAIL = "Edit locally or use Builder.io to edit this code in the cloud and continue customizing the app any way you like."; const CODE_CHANGE_FALLBACK_TEXT = `This requires a code change. ${CODE_CHANGE_FALLBACK_DETAIL}`; function isLocalBrowserOutsideDesktop() { if (typeof window === "undefined" || typeof navigator === "undefined") { return false; } const hostname = window.location.hostname; const local = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; return local && !/AgentNativeDesktop/i.test(navigator.userAgent || ""); } export interface ConnectBuilderCardProps { configured: boolean; /** * True when the server has a Builder branch project configured for this * request. When false, the card shows a waitlist CTA instead of a Send * button — the /builder/run endpoint would 403 anyway. */ builderEnabled?: boolean; connectUrl: string; orgName?: string | null; /** The user's feature/change request, forwarded to Builder's cloud agent * when they click Send. Empty for generic "connect Builder" prompts. */ prompt?: string; } interface BuilderRunResult { branchName: string; projectId: string; url: string; status: string; } /** * Rich inline card rendered for the `connect-builder` tool call. Shows a * prominent Connect button that opens the Builder CLI auth flow and polls * /_agent-native/builder/status until credentials land. */ export function ConnectBuilderCard({ configured: initialConfigured, builderEnabled: initialBuilderEnabled = true, connectUrl: initialConnectUrl, orgName: initialOrgName, prompt = "", }: ConnectBuilderCardProps) { // The connect-poll state machine is shared — the tool-call result is // frozen at render time, so the hook's mount-time fetch + focus refresh // is what catches a flow the user completed in another tab. const flow = useBuilderConnectFlow({ popupUrl: initialConnectUrl, trackingSource: "connect_builder_card", }); // Only use the server-rendered props until the hook's first status // fetch returns. After that, the hook is authoritative — including for // the disconnect case (where `flow.configured` flips back to `false` // even though `initialConfigured` was `true` at render time). const configured = flow.hasFetchedStatus ? flow.configured : initialConfigured; const builderEnabled = flow.hasFetchedStatus ? flow.builderEnabled : initialBuilderEnabled; const orgName = flow.hasFetchedStatus ? flow.orgName : (initialOrgName ?? null); const connecting = flow.connecting; const [waitlistJoined, setWaitlistJoined] = useState(false); const [joiningWaitlist, setJoiningWaitlist] = useState(false); const [waitlistErr, setWaitlistErr] = useState(null); const [sending, setSending] = useState(false); const [runResult, setRunResult] = useState(null); const [sendErr, setSendErr] = useState(null); const [localBrowser, setLocalBrowser] = useState(false); const mountedRef = useRef(true); // Tracks whether the user clicked "Connect Builder" *this session*. When // the connect-then-poll round-trip lands `configured=true`, we use this // flag to decide whether to retry the user's pending prompt automatically // — the alternative is making them click "Send to Builder" a second time // even though the agent had already captured their original ask. We do // NOT auto-send when the card mounts already-connected (e.g. user // revisits an old thread) — only when the connect just succeeded. const wasConnectingRef = useRef(false); useEffect(() => { mountedRef.current = true; setLocalBrowser(isLocalBrowserOutsideDesktop()); return () => { mountedRef.current = false; }; }, []); const handleSend = useCallback(async () => { if (!prompt.trim()) return; setSending(true); setSendErr(null); try { const origin = getCallbackOrigin() || window.location.origin; const res = await fetch( new URL(agentNativePath("/_agent-native/builder/run"), origin).href, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt }), }, ); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error( typeof data?.error === "string" ? data.error : `Request failed (${res.status})`, ); } if (!mountedRef.current) return; setRunResult(data as BuilderRunResult); setSending(false); } catch (e) { if (!mountedRef.current) return; setSendErr(e instanceof Error ? e.message : "Send failed"); setSending(false); } }, [prompt]); const handleJoinWaitlist = useCallback(async () => { setJoiningWaitlist(true); setWaitlistErr(null); try { const origin = getCallbackOrigin() || window.location.origin; const res = await fetch( new URL( agentNativePath("/_agent-native/builder/branch-waitlist"), origin, ).href, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, orgName, pageUrl: window.location.href, source: "connect_builder_card", useCase: "builder_agent_background_coding", }), }, ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error( typeof data?.error === "string" ? data.error : `Request failed (${res.status})`, ); } if (!mountedRef.current) return; setWaitlistJoined(true); setJoiningWaitlist(false); } catch (e) { if (!mountedRef.current) return; setWaitlistErr(e instanceof Error ? e.message : "Couldn't join waitlist"); setJoiningWaitlist(false); } }, [orgName, prompt]); // Combine connect-flow errors, send errors, and waitlist errors. const err = sendErr ?? waitlistErr ?? flow.error; const hasPrompt = prompt.trim().length > 0; const canSend = configured && builderEnabled && hasPrompt; // Auto-send the user's pending prompt the moment connecting finishes // successfully. Without this, the connect popup closing leaves the user // staring at a "Send to Builder" button — feels like they have to // re-submit even though the prompt is right there in the card. useEffect(() => { if (flow.connecting) { wasConnectingRef.current = true; return; } if (!wasConnectingRef.current) return; if (canSend && !sending && !runResult && !sendErr) { wasConnectingRef.current = false; void handleSend(); } }, [flow.connecting, canSend, sending, runResult, sendErr, handleSend]); // Branch creation is gated by a server-side project id, which may come // from deployment config or org-scoped secrets. const showWaitlist = !builderEnabled && hasPrompt; // Title + subtitle depend on which mode we're in. We compute them up front // so the render tree below stays flat. const connectedCapabilityText = builderEnabled ? "AI credits and cloud code changes are ready to use." : `AI credits are ready to use. ${CODE_CHANGE_FALLBACK_TEXT}`; let title: string; let subtitle: React.ReactNode; if (runResult) { title = "Builder is working on it"; subtitle = ( <> Working on branch{" "} {runResult.branchName} . Click through to watch progress in the Visual Editor. ); } else if (showWaitlist) { title = "This requires a code change"; subtitle = waitlistJoined ? ( <> You're on the waitlist. {CODE_CHANGE_FALLBACK_DETAIL}{" "} {localBrowser ? "Since this project is already running locally, open it in the desktop app for local coding tools or keep editing from your clone." : "You can still clone the project locally and use the desktop app for code changes."} ) : ( <> {CODE_CHANGE_FALLBACK_DETAIL}{" "} {localBrowser ? "Since this project is already running locally, open it in the desktop app for local coding tools or keep editing from your clone." : "You can still clone the project locally and use the desktop app for code changes."} ); } else if (canSend) { title = "Send this to Builder"; subtitle = ( <> Builder's cloud coding agent will make this code change on a fresh branch. ); } else if (configured) { title = "Builder.io connected"; subtitle = flow.envManaged ? ( <> Managed by this deployment — every user of this app uses the same Builder identity. {connectedCapabilityText} ) : orgName ? ( <> Connected to{" "} {orgName}.{" "} {connectedCapabilityText} ) : ( <>{connectedCapabilityText} ); } else { title = "Connect Builder.io"; subtitle = <>Builder.io's free tier includes AI credits.; } return (
{runResult ? ( ) : ( )}
{title}
{subtitle}
{showWaitlist && ( Download desktop app )} {err &&
{err}
}
{runResult ? ( Open branch in Builder ) : canSend ? ( ) : showWaitlist && !waitlistJoined ? ( ) : !configured ? ( ) : null}
); }