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 (
+
+ );
+}
+
+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 && (
+
+ )}