Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/sgai/webapp/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`);
19 changes: 15 additions & 4 deletions cmd/sgai/webapp/src/__tests__/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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() {
Expand Down
81 changes: 81 additions & 0 deletions cmd/sgai/webapp/src/pages/StandaloneIDEPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col h-screen bg-background text-foreground">
<header className="flex items-center gap-3 px-4 py-2 border-b shrink-0">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-5 w-24" />
</header>
<div className="flex-1 p-4">
<Skeleton className="h-full w-full" />
</div>
</div>
);
}

function StandaloneIDEError({ message }: { message: string }) {
return (
<div className="flex flex-col h-screen bg-background text-foreground">
<header className="flex items-center gap-3 px-4 py-2 border-b shrink-0">
<span className="text-sm font-medium">IDE</span>
</header>
<div className="flex-1 p-4 flex items-center justify-center">
<Alert className="max-w-md">
<AlertDescription>{message}</AlertDescription>
</Alert>
</div>
</div>
);
}

export function StandaloneIDEPage(): JSX.Element {
const { name } = useParams<{ name: string }>();
const workspaceName = name ?? "";

const { workspace, fetchStatus } = useWorkspacePageState(workspaceName);

if (!workspaceName) {
return <StandaloneIDEError message="No workspace specified." />;
}

if (!workspace) {
if (fetchStatus === "error") {
return <StandaloneIDEError message="Failed to load workspace." />;
}
return <StandaloneIDELoading />;
}

const workspaceLink = buildWorkspacePath(workspace, "progress");

return (
<TooltipProvider>
<div className="flex flex-col h-screen bg-background text-foreground">
<header className="flex items-center gap-3 px-4 py-2 border-b shrink-0">
<Link
to={workspaceLink}
className="text-sm text-muted-foreground hover:text-foreground no-underline transition-colors"
>
← Back to workspace
</Link>
<span className="text-sm font-medium truncate" title={workspace.name}>
IDE — {workspace.title || workspace.name}
</span>
</header>
<main className="flex-1 overflow-hidden px-2 py-2">
<IDETab
workspaceName={workspaceName}
ideState={workspace.ide}
fullPage
/>
</main>
</div>
</TooltipProvider>
);
}
37 changes: 18 additions & 19 deletions cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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" },
Expand All @@ -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;
Expand Down Expand Up @@ -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;
});

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -444,6 +436,13 @@ export function WorkspaceDetail(): JSX.Element | null {
Open in Editor
</Button>
)}
{ideAvailable && (
<Button asChild size="sm" variant="outline">
<a href={idePageUrl} target="_blank" rel="noopener noreferrer" aria-label="Open IDE (opens in new tab)">
Open IDE
</a>
</Button>
)}
<Button
type="button"
size="sm"
Expand Down Expand Up @@ -585,6 +584,13 @@ export function WorkspaceDetail(): JSX.Element | null {
Open in Editor
</Button>
)}
{ideAvailable && (
<Button asChild size="sm" variant="outline">
<a href={idePageUrl} target="_blank" rel="noopener noreferrer" aria-label="Open IDE (opens in new tab)">
Open IDE
</a>
</Button>
)}
<Button
type="button"
size="sm"
Expand Down Expand Up @@ -660,7 +666,6 @@ export function WorkspaceDetail(): JSX.Element | null {
isRoot={detail.isRoot}
hasForks={hasForks}
showForkTab={showForkTab}
ideAvailable={ideAvailable}
/>

</div>
Expand All @@ -677,7 +682,6 @@ export function WorkspaceDetail(): JSX.Element | null {
onActionClick={onActionClick}
isActionRunning={isActionRunning}
showForkTab={showForkTab}
ideState={detail.ide}
/>
</Suspense>
)}
Expand All @@ -689,7 +693,6 @@ export function WorkspaceDetail(): JSX.Element | null {
activeTab={activeTab}
detail={detail}
showForkTab={showForkTab}
ideState={detail.ide}
/>
</Suspense>
)}
Expand Down Expand Up @@ -785,14 +788,12 @@ function TabContent({
onActionClick,
isActionRunning,
showForkTab,
ideState,
}: {
activeTab: string;
detail: ApiWorkspaceEntry;
onActionClick?: (action: ApiActionEntry, variables: Record<string, string>, targetWorkspaceName: string) => void;
isActionRunning?: boolean;
showForkTab: boolean;
ideState?: ApiWorkspaceIDEState;
}) {
switch (activeTab) {
case "progress":
Expand All @@ -813,8 +814,6 @@ function TabContent({
);
case "fork":
return showForkTab ? <InlineForkEditor key={detail.name} workspaceName={detail.name} /> : <NotYetAvailable pageName="Fork Tab" />;
case "ide":
return <IDETab workspaceName={detail.name} ideState={ideState} />;
case "log":
return <LogTab lines={detail.log ?? []} />;
case "messages":
Expand Down
Loading