import { appPath } from "@agent-native/core/client/api-path"; import { useActionQuery } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { withSsrHtmlContentType } from "@agent-native/core/shared"; import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking"; import { IconArrowLeft, IconArrowUpRight, IconClockHour4, } from "@tabler/icons-react"; import { useEffect, useMemo, useState } from "react"; import { Link, Navigate, redirect, useParams, type ClientLoaderFunctionArgs, type LoaderFunctionArgs, } from "react-router"; import { ActionQueryError } from "../../components/action-query-error"; import { DispatchShell } from "../../components/dispatch-shell"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Spinner } from "../../components/ui/spinner"; import { resolveServerCatchAllTarget } from "../../lib/catch-all-target"; import { navigateToWorkspaceApp, workspaceAppHref, type WorkspaceAppSummary, } from "../../lib/workspace-apps"; export function meta() { return [{ title: "Workspace app - Dispatch" }]; } /** * Catch-all for `/dispatch/` paths that don't match an explicit * Dispatch route. When `` is the id of a workspace app sibling * (e.g. `/dispatch/todo` after Builder.io routes a "navigate to /todo" * call through Dispatch's mount point), bounce to the absolute `/` * so the user lands on the actual app instead of a 404 inside Dispatch. * * Server-side redirect: we resolve the workspace app manifest via the * shared `loadWorkspaceAppsManifest()` helper, which checks the * `AGENT_NATIVE_WORKSPACE_APPS_JSON` env var, then the * `.agent-native/workspace-apps.json` file written by `workspace-deploy.ts`, * then a live filesystem scan of `apps/` for local dev. We then throw * `redirect("/")`. React Router 7 does not prepend the basename to * absolute paths returned from a loader, so the redirect escapes Dispatch's * `/dispatch` mount cleanly. * * Why a catch-all instead of fixing the agent prompt: Builder.io currently * resolves "navigate to /todo" relative to Dispatch's mount, sending the * user to /dispatch/todo. The same wrong path then gets captured as the * OAuth callbackURL, so Google sign-in completes back at /dispatch/todo * and looks broken. This route fixes both the post-creation navigation * and the OAuth round-trip from a single place. * * Built-in template fallback: when no workspace manifest is available * (framework dev with each template on its own port, hosted dispatch with * no sibling apps), redirect to the matching first-party template's deploy * URL — `http://localhost:` in dev, `https://.agent-native.com` * in production. Without this, a user visiting `/forms` on dispatch is * forced to sign in (auth guard) and then lands on this route's "Page not * found" pane after the post-login reload. * * `appId === "dispatch"` short-circuit: when the segment matches Dispatch * itself (e.g. `/dispatch/dispatch`), we go straight to the overview rather * than chaining through `/dispatch` (which polled `useActionQuery` re-fired * `window.location.assign` against and looped forever in production). */ function dispatchSelfRedirect(appId: string | undefined): string | null { if (appId === "dispatch") return appPath("/overview"); return null; } export async function loader({ params }: LoaderFunctionArgs) { const appId = params.appId; if (!appId) return null; const selfTarget = dispatchSelfRedirect(appId); if (selfTarget) throw withSsrHtmlContentType(redirect(selfTarget)); const target = await resolveServerCatchAllTarget(appId); if (target) throw withSsrHtmlContentType(redirect(target)); return null; } export async function clientLoader({ params, serverLoader, }: ClientLoaderFunctionArgs) { const selfTarget = dispatchSelfRedirect(params.appId); if (selfTarget) throw withSsrHtmlContentType(redirect(selfTarget)); // Defer to the server loader so the built-in template fallback runs on // SPA navigations too (e.g. clicking a `/` link inside // dispatch). Without this the client side would only check the workspace // apps query, which never lists the static first-party templates and so // the user would land on the "Page not found" pane. return serverLoader(); } export default function WorkspaceAppCatchAllRoute() { const t = useT(); const { appId } = useParams(); const appsQuery = useActionQuery("list-workspace-apps", { includeAgentCards: false, }); const { data: apps = [], isLoading } = appsQuery; const app = useMemo( () => (apps as WorkspaceAppSummary[]).find((item) => item.id === appId) ?? null, [appId, apps], ); const href = app ? workspaceAppHref(app) : null; const isSelfReference = appId === "dispatch"; const hasApp = app !== null; const appIsPending = app?.status === "pending"; const [navigationFailed, setNavigationFailed] = useState(false); useEffect(() => { if (isSelfReference) return; if (!hasApp || appIsPending || !href) { setNavigationFailed(false); return; } setNavigationFailed(!navigateToWorkspaceApp(href)); }, [appIsPending, hasApp, href, isSelfReference]); if (isSelfReference) { return ; } if (appsQuery.isError) { return ( void appsQuery.refetch()} /> ); } if (navigationFailed && href) { return ( setNavigationFailed(!navigateToWorkspaceApp(href))} /> ); } if ( (isLoading && !app) || (app && app.status !== "pending" && href && !navigationFailed) ) { return (
); } return (
{app?.status === "pending" ? (

{app.name}

{t("dispatch.pages.building")}

{t("dispatch.pages.appBuildingPrefix")}{" "} {app.path}{" "} {t("dispatch.pages.appBuildingSuffix")}

{app.builderUrl ? ( ) : null}
) : (

{t("dispatch.pages.pageNotFound")}

/{appId} isn't {t("dispatch.pages.notDispatchOrWorkspaceApp")}

)}
); }