import { AgentSidebar } from "@agent-native/core/client/agent-chat"; import { agentNativePath } from "@agent-native/core/client/api-path"; import { ChatFirstAppPane, defaultChatFirstCopy, type ChatFirstCopy, } from "@agent-native/core/client/chat-first"; import { useFeatureFlag } from "@agent-native/core/client/feature-flags"; import { useActionMutation, useActionQuery, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { AGENT_NATIVE_WORKSPACE_APP_ROUTE_MESSAGE_TYPE } from "@agent-native/core/client/navigation"; import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking"; import { IconAlertTriangle, IconArrowLeft, IconClockHour4, } from "@tabler/icons-react"; import { useTheme } from "next-themes"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { Link } from "react-router"; import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery"; import { mergeChatFirstWorkspaceApps, isWorkspaceSsoApp, navigateToWorkspaceApp, shouldOpenWorkspaceAppInTopWindow, workspaceAppRouteForChildPath, workspaceAppDirectHref, workspaceAppEmbedTarget, workspaceAppHref, type WorkspaceAppSummary, } from "../lib/workspace-apps"; import { DISPATCH_WORKSPACE_SSO_FLAG } from "../shared/feature-flags"; import { workspaceAppChatProxyPath } from "../shared/workspace-app-chat"; import { ActionQueryError } from "./action-query-error"; import { Alert, AlertDescription } from "./ui/alert"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Skeleton } from "./ui/skeleton"; interface EmbedSessionResult { startUrl: string; } interface EmbedSessionInput { app?: string; path?: string; url?: string; chrome: "minimal"; } interface GrantedWorkspaceAppSummary { id: string; name: string; url?: string | null; } interface GrantedWorkspaceAppsResult { apps: GrantedWorkspaceAppSummary[]; } type WorkspaceAppTheme = "light" | "dark"; function buildWorkspaceAppThemeUpdate(theme: WorkspaceAppTheme) { return { type: "agent-native-theme-update" as const, theme, isDark: theme === "dark", }; } export function buildChatFirstEmbedSessionInput( appId: string, path: string, ): EmbedSessionInput { return { app: appId, path, chrome: "minimal" }; } async function readWorkspaceAppChatProxyError( response: Response, ): Promise { let body: string; try { body = await response.text(); } catch { // coercion-ok: an unreadable body is reported as such, not as an empty error. return `Agent chat proxy returned ${response.status} with an unreadable body.`; } try { const parsed = JSON.parse(body) as { error?: unknown }; if (typeof parsed.error === "string" && parsed.error) return parsed.error; } catch { // coercion-ok: a non-JSON body is still reportable as the status line. } return body.trim() || `Agent chat proxy returned ${response.status}.`; } /** * Point the app pane's chat rail at the app's OWN agent through the Dispatch * proxy, and prove the proxy answers before claiming it works. A rail that * quietly fell back to Dispatch's agent would look identical while running the * wrong tools, instructions, and app resources, so a failed probe is a visible * error state instead. */ function useWorkspaceAppChatApi(appId: string) { const apiUrl = useMemo( () => agentNativePath(workspaceAppChatProxyPath(appId)), [appId], ); const [attempt, setAttempt] = useState(0); const [unavailable, setUnavailable] = useState(false); useEffect(() => { let cancelled = false; setUnavailable(false); // `/mode` is the app's own dev-mode surface: reaching it proves the proxy // minted an app session and the app's agent-chat routes answer. void fetch(`${apiUrl}/mode`, { credentials: "include" }) .then(async (response) => { if (response.ok) return; throw new Error(await readWorkspaceAppChatProxyError(response)); }) .catch((cause: unknown) => { if (cancelled) return; console.warn( `[dispatch] app chat proxy unavailable for ${appId}`, cause, ); setUnavailable(true); }); return () => { cancelled = true; }; }, [apiUrl, appId, attempt]); return { apiUrl, unavailable, retry: useCallback(() => setAttempt((value) => value + 1), []), }; } export interface WorkspaceAppChatRailProps { appId: string; appName: string; children: ReactNode; copy?: ChatFirstCopy; agentPageHref?: string; onFullscreenRequest?: () => void; } /** * The chat beside an open workspace app. Every surface that hosts an app pane * must go through here so the rail is always the app's own agent — same tools, * AGENTS.md, skills, app-scoped resources, and dev-mode surface as the app's * native chat — and so an unreachable app is one visible error state rather * than a per-surface silent handoff back to Dispatch's agent. */ export function WorkspaceAppChatRail({ appId, appName, children, copy = defaultChatFirstCopy, agentPageHref, onFullscreenRequest, }: WorkspaceAppChatRailProps) { const t = useT(); const appChat = useWorkspaceAppChatApi(appId); if (appChat.unavailable) { return (
{t("dispatch.pages.appChatUnavailable", { defaultValue: "Dispatch could not connect to {{name}}'s agent, so its chat is unavailable here.", name: appName, })}
{children}
); } return ( {children} ); } export interface WorkspaceAppFrameApp { id: string; name: string; path?: string | null; url?: string | null; } interface WorkspaceAppFrameProps { app: WorkspaceAppFrameApp; navigateToTopWindow?: (href: string) => boolean | void; /** Chat-first app tabs use their own route while standalone hosts use app metadata. */ embedPath?: string; /** Standalone Dispatch routes seed the iframe once from their initial suffix. */ initialPath?: string; /** Standalone Dispatch hosts mirror child route changes into the shell URL. */ onChildRouteChange?: (path: string) => void; /** Chat-first app surfaces own the parent chat rail around the iframe. */ chatSidebar?: boolean; copy?: ChatFirstCopy; } export function WorkspaceAppFrame({ app, navigateToTopWindow = navigateToWorkspaceApp, embedPath, initialPath, onChildRouteChange, chatSidebar = false, copy = defaultChatFirstCopy, }: WorkspaceAppFrameProps) { const { resolvedTheme } = useTheme(); const theme: WorkspaceAppTheme = resolvedTheme === "dark" || resolvedTheme === "light" ? resolvedTheme : typeof document !== "undefined" && document.documentElement.classList.contains("dark") ? "dark" : "light"; const [embedUrl, setEmbedUrl] = useState(null); const [embedError, setEmbedError] = useState(null); const [isDirectFallback, setIsDirectFallback] = useState(false); const [embedAttempt, setEmbedAttempt] = useState(0); const [topWindowNavigationFailed, setTopWindowNavigationFailed] = useState(false); const embedFrameRef = useRef(null); const postThemeToFrame = useCallback(() => { embedFrameRef.current?.contentWindow?.postMessage( buildWorkspaceAppThemeUpdate(theme), "*", ); }, [theme]); const handleFrameLoad = useCallback(() => { postThemeToFrame(); if (isDirectFallback) setEmbedError(null); }, [isDirectFallback, postThemeToFrame]); const workspaceSsoEnabled = useFeatureFlag(DISPATCH_WORKSPACE_SSO_FLAG.key); const useWorkspaceSso = workspaceSsoEnabled && isWorkspaceSsoApp(app); const createEmbedSession = useActionMutation< EmbedSessionResult, EmbedSessionInput >("create_embed_session", { skipActionQueryInvalidation: true, }); const createWorkspaceSsoEmbedSession = useActionMutation< EmbedSessionResult, EmbedSessionInput >("create-workspace-app-embed-session", { skipActionQueryInvalidation: true, }); const appHref = workspaceAppHref({ id: app.id, name: app.name, path: app.path ?? "", url: app.url, }); const topWindowHref = useMemo(() => { if (embedPath !== undefined) { return workspaceAppDirectHref( { path: app.path, url: app.url }, embedPath, ); } if (initialPath !== undefined) { return workspaceAppDirectHref( { path: app.path, url: app.url }, initialPath, ); } const target = workspaceAppEmbedTarget({ path: app.path ?? "", url: app.url, }); return target.url ?? target.path ?? null; }, [app.path, app.url, embedPath, initialPath]); const openInTopWindow = shouldOpenWorkspaceAppInTopWindow(); const topWindowSsoAttemptKey = `${app.id}\u0000${app.path ?? ""}\u0000${app.url ?? ""}\u0000${embedPath ?? ""}\u0000${initialPath ?? ""}\u0000${embedAttempt}`; const topWindowSsoAttemptedRef = useRef(null); const embedInput = useMemo(() => { if (embedPath !== undefined) { return buildChatFirstEmbedSessionInput(app.id, embedPath); } if (initialPath !== undefined) { return buildChatFirstEmbedSessionInput(app.id, initialPath); } if (!appHref) return null; return { app: app.id, ...workspaceAppEmbedTarget({ path: app.path ?? "", url: app.url }), chrome: "minimal", }; }, [app.id, app.path, app.url, appHref, embedPath, initialPath]); useEffect(() => { if (openInTopWindow && useWorkspaceSso && embedInput) { setTopWindowNavigationFailed(false); return; } if (!openInTopWindow) { setTopWindowNavigationFailed(false); return; } if (!topWindowHref) { setTopWindowNavigationFailed(true); return; } let didNavigate = false; try { didNavigate = navigateToTopWindow(topWindowHref) !== false; } catch { didNavigate = false; } setTopWindowNavigationFailed(!didNavigate); }, [ embedInput, navigateToTopWindow, openInTopWindow, topWindowHref, useWorkspaceSso, ]); useEffect(() => { const useTopWindowSso = openInTopWindow && useWorkspaceSso && !!embedInput; if ( !embedInput || (openInTopWindow && !useWorkspaceSso && !topWindowNavigationFailed) ) { return; } if ( useTopWindowSso && topWindowSsoAttemptedRef.current === topWindowSsoAttemptKey ) { return; } if (useTopWindowSso) { topWindowSsoAttemptedRef.current = topWindowSsoAttemptKey; } let cancelled = false; setEmbedUrl(null); setEmbedError(null); setIsDirectFallback(false); const createSession = useWorkspaceSso ? createWorkspaceSsoEmbedSession : createEmbedSession; void createSession .mutateAsync(embedInput) .then((result) => { if (cancelled) return; if (useTopWindowSso) { let didNavigate = false; try { didNavigate = navigateToTopWindow(result.startUrl) !== false; } catch { didNavigate = false; } setTopWindowNavigationFailed(!didNavigate); setEmbedUrl(didNavigate ? null : result.startUrl); return; } setEmbedUrl(result.startUrl); }) .catch((cause: unknown) => { if (cancelled) return; const error = cause instanceof Error ? cause : new Error(String(cause)); if (useWorkspaceSso) { // An SSO-enabled pane must never fall back to the child app's // unauthenticated shell. Keep the parent-owned retry surface in // place so a transient exchange failure cannot expose another // login form. setIsDirectFallback(false); setEmbedUrl(null); setEmbedError(error); if (useTopWindowSso) setTopWindowNavigationFailed(true); return; } setIsDirectFallback(true); setEmbedUrl( workspaceAppDirectHref( { path: app.path ?? "", url: app.url }, initialPath ?? embedPath ?? "/", ), ); setEmbedError(error); }); return () => { cancelled = true; }; }, [ app.id, app.path, app.url, createEmbedSession.mutateAsync, createWorkspaceSsoEmbedSession.mutateAsync, embedInput, embedPath, initialPath, embedAttempt, openInTopWindow, navigateToTopWindow, topWindowSsoAttemptKey, topWindowNavigationFailed, useWorkspaceSso, ]); useEffect(() => { const handleEmbedSessionExpired = (event: MessageEvent) => { if ( !isEmbedSessionExpiredMessage(event, embedFrameRef.current, embedUrl) ) { return; } setEmbedAttempt((attempt) => attempt + 1); }; window.addEventListener("message", handleEmbedSessionExpired); return () => window.removeEventListener("message", handleEmbedSessionExpired); }, [embedUrl]); useEffect(() => { if (!onChildRouteChange || !embedUrl) return; const frame = embedFrameRef.current; if (!frame) return; let expectedOrigin: string; try { expectedOrigin = new URL(embedUrl, window.location.href).origin; } catch { return; } const handleWorkspaceAppRoute = (event: MessageEvent) => { if ( event.source !== frame.contentWindow || event.origin !== expectedOrigin ) { return; } const message = event.data as { type?: unknown; path?: unknown; } | null; if ( message?.type !== AGENT_NATIVE_WORKSPACE_APP_ROUTE_MESSAGE_TYPE || typeof message.path !== "string" ) { return; } const route = workspaceAppRouteForChildPath( { id: app.id, path: app.path ?? "", url: app.url }, message.path, ); if (route) onChildRouteChange(route); }; window.addEventListener("message", handleWorkspaceAppRoute); return () => window.removeEventListener("message", handleWorkspaceAppRoute); }, [app.id, app.path, app.url, embedUrl, onChildRouteChange]); useEffect(() => { postThemeToFrame(); }, [embedUrl, postThemeToFrame]); const appPane = ( setEmbedAttempt((attempt) => attempt + 1) : undefined } renderEmbed={({ url, title }) => (