diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 9287bf3f23..c0c81c4701 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -179,7 +179,7 @@ export const login = ( mutationFn: async (credentials: { email: string; password: string }) => loginFn({ ...credentials, authorization }), onSuccess: async (data: Awaited>) => { - queryClient.setQueryData(["me"], data.user); + queryClient.setQueryData(meKey, data.user); queryClient.setQueryData( getAuthorizationKey(authorization), data.permissions, diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx index af8151223a..2c897c1ed6 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx @@ -25,6 +25,7 @@ import { import type { FC } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; +import { useEmbedContext } from "../EmbedContext"; interface SidebarPanelState { showSidebarPanel: boolean; @@ -70,19 +71,22 @@ export const AgentDetailTopBar: FC = ({ onToggleSidebarCollapsed, }) => { const navigate = useNavigate(); + const { isEmbedded } = useEmbedContext(); return (
{/* Mobile back button */} - + {!isEmbedded && ( + + )} {/* Desktop expand button: visible when sidebar is manually collapsed. */} {isSidebarCollapsed && (
{/* Actions area */}
- - - - - - { - workspace.onOpenInEditor("cursor"); - }} - > - - Open in Cursor - - { - workspace.onOpenInEditor("vscode"); - }} - > - - Open in VS Code - - - - Open Terminal - - { - if (!workspace.sshCommand) return; - try { - await navigator.clipboard.writeText(workspace.sshCommand); - toast.success("SSH command copied to clipboard"); - } catch { - toast.error("Failed to copy SSH command"); - } - }} - > - - Copy SSH Command - - - - - View Workspace - - - {isArchived ? ( - - - Unarchive Agent + {!isEmbedded && ( + + + + + + { + workspace.onOpenInEditor("cursor"); + }} + > + + Open in Cursor - ) : ( - <> - - - Archive Agent + { + workspace.onOpenInEditor("vscode"); + }} + > + + Open in VS Code + + + + Open Terminal + + { + if (!workspace.sshCommand) return; + try { + await navigator.clipboard.writeText(workspace.sshCommand); + toast.success("SSH command copied to clipboard"); + } catch { + toast.error("Failed to copy SSH command"); + } + }} + > + + Copy SSH Command + + + + + View Workspace + + + {isArchived ? ( + + + Unarchive Agent - {hasWorkspace && ( + ) : ( + <> - - Archive & Delete Workspace + + Archive Agent - )} - + {hasWorkspace && ( + + + Archive & Delete Workspace + + )} + + )} + + + )} + {!isEmbedded && ( + + + )}
); diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx new file mode 100644 index 0000000000..5c65c864e6 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -0,0 +1,241 @@ +import { getErrorMessage } from "api/errors"; +import { Button } from "components/Button/Button"; +import { Loader } from "components/Loader/Loader"; +import { useAuthContext } from "contexts/auth/AuthProvider"; +import { ProxyProvider } from "contexts/ProxyContext"; +import { DashboardProvider } from "modules/dashboard/DashboardProvider"; +import { permissionChecks } from "modules/permissions"; +import { + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { Outlet, useParams } from "react-router"; +import type { AgentsOutletContext } from "./AgentsPage"; +import { bootstrapChatEmbedSession, EmbedProvider } from "./EmbedContext"; + +type BootstrapMessage = { + type: "coder:vscode-auth-bootstrap"; + payload: { + token: string; + }; +}; + +const getBootstrapToken = (data: unknown): string | undefined => { + if (typeof data !== "object" || data === null) { + return undefined; + } + + const message = data as Partial; + if (message.type !== "coder:vscode-auth-bootstrap") { + return undefined; + } + + if (typeof message.payload !== "object" || message.payload === null) { + return undefined; + } + + const payload = message.payload as { token?: unknown }; + if (typeof payload.token !== "string") { + return undefined; + } + + const token = payload.token.trim(); + return token.length > 0 ? token : undefined; +}; + +const AgentEmbedPage: FC = () => { + const { agentId } = useParams<{ agentId: string }>(); + if (!agentId) { + throw new Error("AgentEmbedPage requires an agentId route parameter."); + } + + const auth = useAuthContext(); + const queryClient = useQueryClient(); + const embedSessionMutation = useMutation( + bootstrapChatEmbedSession({ checks: permissionChecks }, queryClient), + ); + const latestEmbedSessionMutationRef = useRef(embedSessionMutation); + latestEmbedSessionMutationRef.current = embedSessionMutation; + const inFlightBootstrapRef = useRef | null>(null); + + const [chatErrorReasons, setChatErrorReasons] = useState< + Record + >({}); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + + const setChatErrorReason = useCallback((chatId: string, reason: string) => { + const trimmedReason = reason.trim(); + if (!chatId || !trimmedReason) { + return; + } + setChatErrorReasons((current) => { + if (current[chatId] === trimmedReason) { + return current; + } + return { + ...current, + [chatId]: trimmedReason, + }; + }); + }, []); + + const clearChatErrorReason = useCallback((chatId: string) => { + if (!chatId) { + return; + } + setChatErrorReasons((current) => { + if (!(chatId in current)) { + return current; + } + const next = { ...current }; + delete next[chatId]; + return next; + }); + }, []); + + const requestArchiveAgent = useCallback((_chatId: string) => {}, []); + + const requestUnarchiveAgent = useCallback((_chatId: string) => {}, []); + + const requestArchiveAndDeleteWorkspace = useCallback( + (_chatId: string, _workspaceId: string) => {}, + [], + ); + + const onToggleSidebarCollapsed = useCallback(() => { + setIsSidebarCollapsed((current) => !current); + }, []); + + const outletContext = useMemo( + () => ({ + chatErrorReasons, + setChatErrorReason, + clearChatErrorReason, + requestArchiveAgent, + requestUnarchiveAgent, + requestArchiveAndDeleteWorkspace, + isSidebarCollapsed, + onToggleSidebarCollapsed, + }), + [ + chatErrorReasons, + setChatErrorReason, + clearChatErrorReason, + requestArchiveAgent, + requestUnarchiveAgent, + requestArchiveAndDeleteWorkspace, + isSidebarCollapsed, + onToggleSidebarCollapsed, + ], + ); + + // When signed out and not already bootstrapping, listen for the + // postMessage from the parent frame carrying the session token. + const isAwaitingBootstrapMessage = + auth.isSignedOut && + !embedSessionMutation.isPending && + !embedSessionMutation.isError; + + useEffect(() => { + if (!isAwaitingBootstrapMessage) { + return; + } + + const parentWindow = window.parent; + + const handleMessage = (event: MessageEvent) => { + if (event.source !== parentWindow) { + return; + } + + const token = getBootstrapToken(event.data); + if (!token || inFlightBootstrapRef.current) { + return; + } + + const bootstrapPromise = latestEmbedSessionMutationRef.current + .mutateAsync(token) + .catch(() => undefined) + .finally(() => { + inFlightBootstrapRef.current = null; + }); + inFlightBootstrapRef.current = bootstrapPromise; + }; + + // Register the listener before notifying the parent so an + // immediate bootstrap response is never missed. + window.addEventListener("message", handleMessage); + parentWindow.postMessage( + { type: "coder:vscode-ready", payload: { agentId } }, + "*", + ); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, [agentId, isAwaitingBootstrapMessage]); + + const handleBootstrapRetry = useCallback(() => { + inFlightBootstrapRef.current = null; + embedSessionMutation.reset(); + }, [embedSessionMutation]); + + if (auth.isSignedIn) { + return ( + + + + + + + + ); + } + + if (embedSessionMutation.isError) { + return ( +
+
+

+ Unable to start embedded agent. +

+

+ {getErrorMessage( + embedSessionMutation.error, + "We couldn't exchange the VS Code bootstrap token for a session.", + )} +

+
+ +
+ ); + } + + if (embedSessionMutation.isPending) { + return ( +
+ +

+ Signing in to the embedded agent… +

+
+ ); + } + + // Either auth is loading or we're waiting for the bootstrap + // postMessage from the parent frame. + return ( +
+ +

+ {auth.isLoading ? "Loading…" : "Waiting for VS Code authentication…"} +

+
+ ); +}; + +export default AgentEmbedPage; diff --git a/site/src/pages/AgentsPage/EmbedContext.tsx b/site/src/pages/AgentsPage/EmbedContext.tsx new file mode 100644 index 0000000000..e0ae15c819 --- /dev/null +++ b/site/src/pages/AgentsPage/EmbedContext.tsx @@ -0,0 +1,66 @@ +import { API } from "api/api"; +import { getAuthorizationKey } from "api/queries/authCheck"; +import { meKey } from "api/queries/users"; +import type { AuthorizationRequest } from "api/typesGenerated"; +import { createContext, useContext } from "react"; +import type { QueryClient } from "react-query"; + +interface EmbedContextValue { + isEmbedded: boolean; +} + +const EmbedContext = createContext({ + isEmbedded: false, +}); + +export const EmbedProvider = EmbedContext.Provider; + +export const useEmbedContext = () => useContext(EmbedContext); + +export const bootstrapChatEmbedSession = ( + authorization: AuthorizationRequest, + queryClient: QueryClient, +) => { + return { + mutationFn: async (token: string) => + bootstrapChatEmbedSessionFn({ + token, + authorization, + queryClient, + }), + onSuccess: ( + data: Awaited>, + ) => { + queryClient.setQueryData(meKey, data.user); + queryClient.setQueryData( + getAuthorizationKey(authorization), + data.permissions, + ); + }, + }; +}; + +const bootstrapChatEmbedSessionFn = async ({ + token, + authorization, + queryClient, +}: { + token: string; + authorization: AuthorizationRequest; + queryClient: QueryClient; +}) => { + API.setSessionToken(token); + // Fetch user and permissions first, then set them in the cache + // atomically. This avoids a race where invalidating the "me" + // query causes isSignedIn to flip before permissions are ready. + const [user, permissions] = await Promise.all([ + API.getAuthenticatedUser(), + API.checkAuthorization(authorization), + ]); + queryClient.setQueryData(meKey, user); + queryClient.setQueryData(getAuthorizationKey(authorization), permissions); + return { + user, + permissions, + }; +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 4d50c07bf3..601149775c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -348,6 +348,7 @@ const ProvisionerJobsPage = lazy( ); const AgentsPage = lazy(() => import("./pages/AgentsPage/AgentsPage")); const AgentDetail = lazy(() => import("./pages/AgentsPage/AgentDetail")); +const AgentEmbedPage = lazy(() => import("./pages/AgentsPage/AgentEmbedPage")); import { AgentDetailSkeleton, @@ -654,6 +655,24 @@ export const router = createBrowserRouter( /> + + }> + + + } + > + }> + + + } + /> + , ), );