From 5e2a22b5cc4f5665b868720627b19627eec00566 Mon Sep 17 00:00:00 2001 From: Hally Maschine Date: Wed, 1 Apr 2026 13:48:39 -0700 Subject: [PATCH] cmd/sgai: open workspace IDE in a standalone page --- cmd/sgai/webapp/dev.ts | 6 +- cmd/sgai/webapp/src/__tests__/router.test.tsx | 19 +- .../webapp/src/pages/StandaloneIDEPage.tsx | 81 ++++++++ cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx | 37 ++-- .../__tests__/StandaloneIDEPage.test.tsx | 188 ++++++++++++++++++ .../pages/__tests__/WorkspaceDetail.test.tsx | 105 +++++++--- cmd/sgai/webapp/src/pages/tabs/IDETab.tsx | 23 ++- cmd/sgai/webapp/src/router.tsx | 8 + 8 files changed, 406 insertions(+), 61 deletions(-) create mode 100644 cmd/sgai/webapp/src/pages/StandaloneIDEPage.tsx create mode 100644 cmd/sgai/webapp/src/pages/__tests__/StandaloneIDEPage.test.tsx diff --git a/cmd/sgai/webapp/dev.ts b/cmd/sgai/webapp/dev.ts index 0aff980..af96be3 100644 --- a/cmd/sgai/webapp/dev.ts +++ b/cmd/sgai/webapp/dev.ts @@ -174,6 +174,10 @@ const server = Bun.serve({ return proxyToAPI(request, pathname); } + if (/^\/workspaces\/[^/]+\/ide-proxy(\/|$)/.test(pathname)) { + return proxyToAPI(request, pathname); + } + if (latestBuildError) { return new Response(latestBuildError, { status: 500, @@ -195,5 +199,5 @@ const server = Bun.serve({ }); console.log(`Dev server running at http://127.0.0.1:${server.port}`); -console.log(`Proxying /api/* to ${API_TARGET}`); +console.log(`Proxying /api/* and /workspaces/*/ide-proxy/* to ${API_TARGET}`); console.log(`Serving bundled assets from ${devDistDir}`); diff --git a/cmd/sgai/webapp/src/__tests__/router.test.tsx b/cmd/sgai/webapp/src/__tests__/router.test.tsx index 17fe5b7..35bf6ad 100644 --- a/cmd/sgai/webapp/src/__tests__/router.test.tsx +++ b/cmd/sgai/webapp/src/__tests__/router.test.tsx @@ -3,9 +3,13 @@ import { render, screen } from "@testing-library/react"; import { Navigate, RouterProvider, createMemoryRouter } from "react-router"; import { router } from "../router"; +function findAppRoute() { + return router.routes.find((route) => route.path === "/")!; +} + describe("router", () => { it("redirects /workspaces/new to the external attachment flow", () => { - const rootRoute = router.routes[0]; + const rootRoute = findAppRoute(); const newWorkspaceRoute = rootRoute.children?.find((route) => route.path === "workspaces/new"); expect(newWorkspaceRoute).toBeTruthy(); @@ -15,22 +19,29 @@ describe("router", () => { }); it("keeps workspace detail on the catch-all workspace route", () => { - const rootRoute = router.routes[0]; + const rootRoute = findAppRoute(); const workspaceRoute = rootRoute.children?.find((route) => route.path === "workspaces/:name/*"); expect(workspaceRoute).toBeTruthy(); }); it("defines custom error boundaries for the app shell and workspace routes", () => { - const rootRoute = router.routes[0]; + const rootRoute = findAppRoute(); const workspaceRoute = rootRoute.children?.find((route) => route.path === "workspaces/:name/*"); expect(rootRoute.errorElement).toBeTruthy(); expect(workspaceRoute?.errorElement).toBeTruthy(); }); + it("defines standalone IDE page route outside the app shell", () => { + const ideRoute = router.routes.find((route) => route.path === "/workspaces/:name/ide"); + + expect(ideRoute).toBeTruthy(); + expect(ideRoute?.errorElement).toBeTruthy(); + }); + it("renders a product-safe recovery UI instead of the default developer error page", async () => { - const rootRoute = router.routes[0]; + const rootRoute = findAppRoute(); const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); function Boom() { diff --git a/cmd/sgai/webapp/src/pages/StandaloneIDEPage.tsx b/cmd/sgai/webapp/src/pages/StandaloneIDEPage.tsx new file mode 100644 index 0000000..5dded09 --- /dev/null +++ b/cmd/sgai/webapp/src/pages/StandaloneIDEPage.tsx @@ -0,0 +1,81 @@ +import { useParams, Link } from "react-router"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { useWorkspacePageState } from "@/lib/workspace-page-state"; +import { IDETab } from "@/pages/tabs/IDETab"; +import { buildWorkspacePath } from "@/lib/workspace-identity"; + +function StandaloneIDELoading() { + return ( +
+
+ + +
+
+ +
+
+ ); +} + +function StandaloneIDEError({ message }: { message: string }) { + return ( +
+
+ IDE +
+
+ + {message} + +
+
+ ); +} + +export function StandaloneIDEPage(): JSX.Element { + const { name } = useParams<{ name: string }>(); + const workspaceName = name ?? ""; + + const { workspace, fetchStatus } = useWorkspacePageState(workspaceName); + + if (!workspaceName) { + return ; + } + + if (!workspace) { + if (fetchStatus === "error") { + return ; + } + return ; + } + + const workspaceLink = buildWorkspacePath(workspace, "progress"); + + return ( + +
+
+ + ← Back to workspace + + + IDE — {workspace.title || workspace.name} + +
+
+ +
+
+
+ ); +} diff --git a/cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx b/cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx index 409d791..c1f6729 100644 --- a/cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx +++ b/cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx @@ -25,7 +25,7 @@ import { canCreateForkFromWorkspace } from "@/lib/workspace-forks"; import { useWorkspacePageState } from "@/lib/workspace-page-state"; import { useAdhocRun } from "@/hooks/useAdhocRun"; import { ChevronRight, Square } from "lucide-react"; -import type { ApiWorkspaceEntry, ApiActionEntry, ApiWorkspaceIDEState } from "@/types"; +import type { ApiWorkspaceEntry, ApiActionEntry } from "@/types"; import { cn } from "@/lib/utils"; import { buildWorkspaceGoalEditPath, @@ -39,8 +39,6 @@ const LogTab = lazy(() => import("./tabs/LogTab").then((m) => ({ default: m.LogT const RunTab = lazy(() => import("./tabs/RunTab").then((m) => ({ default: m.RunTab }))); const EventsTab = lazy(() => import("./tabs/EventsTab").then((m) => ({ default: m.EventsTab }))); const ForksTab = lazy(() => import("./tabs/ForksTab").then((m) => ({ default: m.ForksTab }))); -const IDETab = lazy(() => import("./tabs/IDETab").then((m) => ({ default: m.IDETab }))); - function parseExecTime(value: string | undefined | null): number | null { if (!value) return null; @@ -84,7 +82,6 @@ function WorkspaceDetailSkeleton() { const TABS = [ { key: "progress", label: "Progress" }, { key: "fork", label: "Fork" }, - { key: "ide", label: "IDE" }, { key: "log", label: "Log" }, { key: "messages", label: "Messages" }, { key: "internals", label: "Internals" }, @@ -94,7 +91,6 @@ const TABS = [ const ROOT_TABS = [ { key: "forks", label: "Forks" }, { key: "fork", label: "Fork" }, - { key: "ide", label: "IDE" }, ] as const; const DEFAULT_TAB = TABS[0].key; @@ -128,18 +124,13 @@ interface TabNavProps { isRoot: boolean; hasForks: boolean; showForkTab: boolean; - ideAvailable: boolean; } -function TabNav({ workspace, activeTab, isRoot, hasForks, showForkTab, ideAvailable }: TabNavProps) { +function TabNav({ workspace, activeTab, isRoot, hasForks, showForkTab }: TabNavProps) { const tabs = isRoot && hasForks - ? ROOT_TABS.filter((tab) => { - if (tab.key === "ide" && !ideAvailable) return false; - return true; - }) + ? ROOT_TABS : TABS.filter((tab) => { if (tab.key === "fork" && !showForkTab) return false; - if (tab.key === "ide" && !ideAvailable) return false; return true; }); @@ -234,6 +225,7 @@ export function WorkspaceDetail(): JSX.Element | null { const showForkTab = canCreateForkFromWorkspace(detail); const ideAvailable = detail?.ide?.available ?? false; const redirectTab = resolveRedirectTab({ requestedTab, isForkedRoot, showForkTab }); + const idePageUrl = `/workspaces/${encodeURIComponent(detail?.name ?? "")}/ide`; const activeTab = redirectTab ?? requestedTab; useEffect(() => { @@ -444,6 +436,13 @@ export function WorkspaceDetail(): JSX.Element | null { Open in Editor )} + {ideAvailable && ( + + )} + )}