diff --git a/foundry/packages/frontend/src/app/router.tsx b/foundry/packages/frontend/src/app/router.tsx index cc33ba7..af8de1d 100644 --- a/foundry/packages/frontend/src/app/router.tsx +++ b/foundry/packages/frontend/src/app/router.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useEffect } from "react"; import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client"; import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router"; +import { DevPanel } from "../components/dev-panel"; import { MockLayout } from "../components/mock-layout"; import { MockAccountSettingsPage, @@ -344,6 +345,7 @@ function RootLayout() { <> + ); } diff --git a/foundry/packages/frontend/src/components/dev-panel.tsx b/foundry/packages/frontend/src/components/dev-panel.tsx new file mode 100644 index 0000000..a4a2281 --- /dev/null +++ b/foundry/packages/frontend/src/components/dev-panel.tsx @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouterState } from "@tanstack/react-router"; +import { Bug, RefreshCw, Wifi } from "lucide-react"; +import { useFoundryTokens } from "../app/theme"; +import { isMockFrontendClient } from "../lib/env"; +import { activeMockOrganization, activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; + +const DEV_PANEL_STORAGE_KEY = "sandbox-agent-foundry:dev-panel-visible"; + +function readStoredVisibility(): boolean { + if (typeof window === "undefined") { + return true; + } + try { + const stored = window.localStorage.getItem(DEV_PANEL_STORAGE_KEY); + return stored == null ? true : stored === "true"; + } catch { + return true; + } +} + +function writeStoredVisibility(value: boolean): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem(DEV_PANEL_STORAGE_KEY, String(value)); + } catch { + // ignore + } +} + +function sectionStyle(borderColor: string, background: string) { + return { + display: "grid", + gap: "10px", + padding: "12px", + borderRadius: "12px", + border: `1px solid ${borderColor}`, + background, + } as const; +} + +function labelStyle(color: string) { + return { + fontSize: "11px", + fontWeight: 600, + letterSpacing: "0.04em", + textTransform: "uppercase" as const, + color, + }; +} + +function mergedRouteParams(matches: Array<{ params: Record }>): Record { + return matches.reduce>((acc, match) => { + for (const [key, value] of Object.entries(match.params)) { + if (typeof value === "string" && value.length > 0) { + acc[key] = value; + } + } + return acc; + }, {}); +} + +export function DevPanel() { + if (!import.meta.env.DEV) { + return null; + } + + const client = useMockAppClient(); + const snapshot = useMockAppSnapshot(); + const organization = activeMockOrganization(snapshot); + const user = activeMockUser(snapshot); + const organizations = eligibleOrganizations(snapshot); + const t = useFoundryTokens(); + const routeContext = useRouterState({ + select: (state) => ({ + location: state.location, + params: mergedRouteParams(state.matches as Array<{ params: Record }>), + }), + }); + const [visible, setVisible] = useState(() => readStoredVisibility()); + + useEffect(() => { + writeStoredVisibility(visible); + }, [visible]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.shiftKey && event.key.toLowerCase() === "d") { + event.preventDefault(); + setVisible((current) => !current); + } + if (event.key === "Escape") { + setVisible(false); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const modeLabel = isMockFrontendClient ? "Mock" : "Live"; + const selectedWorkspaceId = routeContext.params.workspaceId ?? null; + const selectedTaskId = routeContext.params.taskId ?? null; + const selectedRepoId = routeContext.params.repoId ?? null; + const selectedSessionId = + routeContext.location.search && typeof routeContext.location.search === "object" && "sessionId" in routeContext.location.search + ? (((routeContext.location.search as Record).sessionId as string | undefined) ?? null) + : null; + const contextOrganization = + (routeContext.params.organizationId ? (snapshot.organizations.find((candidate) => candidate.id === routeContext.params.organizationId) ?? null) : null) ?? + (selectedWorkspaceId ? (snapshot.organizations.find((candidate) => candidate.workspaceId === selectedWorkspaceId) ?? null) : null) ?? + organization; + const github = contextOrganization?.github ?? null; + + const pillButtonStyle = useCallback( + (active = false) => + ({ + border: `1px solid ${active ? t.accent : t.borderDefault}`, + background: active ? t.surfacePrimary : t.surfaceSecondary, + color: t.textPrimary, + borderRadius: "999px", + padding: "6px 10px", + fontSize: "11px", + fontWeight: 600, + cursor: "pointer", + }) as const, + [t], + ); + + if (!visible) { + return ( + + ); + } + + return ( +
+
+
+
+ + Dev Panel + + {modeLabel} + +
+
{routeContext.location.pathname}
+
+ +
+ +
+
+
Context
+
+
Organization: {contextOrganization?.settings.displayName ?? "None selected"}
+
Workspace: {selectedWorkspaceId ?? "None selected"}
+
Task: {selectedTaskId ?? "None selected"}
+
Repo: {selectedRepoId ?? "None selected"}
+
Session: {selectedSessionId ?? "None selected"}
+
+
+ +
+
Session
+
+
Auth: {snapshot.auth.status}
+
User: {user ? `${user.name} (@${user.githubLogin})` : "None"}
+
Active org: {organization?.settings.displayName ?? "None selected"}
+
+ {isMockFrontendClient ? ( +
+ {snapshot.auth.status === "signed_in" ? ( + + ) : ( + snapshot.users.map((candidate) => ( + + )) + )} +
+ ) : null} +
+ +
+
GitHub
+
+
Installation: {github?.installationStatus ?? "n/a"}
+
Sync: {github?.syncStatus ?? "n/a"}
+
Repos: {github?.importedRepoCount ?? 0}
+
Last sync: {github?.lastSyncLabel ?? "n/a"}
+
+ {contextOrganization ? ( +
+ + +
+ ) : null} +
+ + {isMockFrontendClient && organizations.length > 0 ? ( +
+
Mock Organization
+
+ {organizations.map((candidate) => ( + + ))} +
+
+ ) : null} +
+
+ ); +}