diff --git a/foundry/packages/client/src/app-client.ts b/foundry/packages/client/src/app-client.ts index b0229d0..f59e816 100644 --- a/foundry/packages/client/src/app-client.ts +++ b/foundry/packages/client/src/app-client.ts @@ -26,6 +26,12 @@ export interface FoundryAppClient { resumeSubscription(organizationId: string): Promise; reconnectGithub(organizationId: string): Promise; recordSeatUsage(workspaceId: string): Promise; + setMockDebugOrganizationState?(input: { + organizationId: string; + githubSyncStatus?: "pending" | "syncing" | "synced" | "error"; + githubInstallationStatus?: "connected" | "install_required" | "reconnect_required"; + runtimeStatus?: "healthy" | "error"; + }): Promise; } export interface CreateFoundryAppClientOptions { diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts index a5922d9..070d07c 100644 --- a/foundry/packages/client/src/mock-app.ts +++ b/foundry/packages/client/src/mock-app.ts @@ -140,6 +140,12 @@ export interface MockFoundryAppClient { resumeSubscription(organizationId: string): Promise; reconnectGithub(organizationId: string): Promise; recordSeatUsage(workspaceId: string): void; + setMockDebugOrganizationState(input: { + organizationId: string; + githubSyncStatus?: MockGithubSyncStatus; + githubInstallationStatus?: MockGithubInstallationStatus; + runtimeStatus?: MockActorRuntimeStatus; + }): Promise; } const STORAGE_KEY = "sandbox-agent-foundry:mock-app:v1"; @@ -174,6 +180,22 @@ function buildHealthyRuntimeState(): MockFoundryActorRuntimeState { }; } +function buildSampleRuntimeIssue(organization: MockFoundryOrganization): MockFoundryActorRuntimeIssue { + return { + actorId: `${organization.id}:github-state`, + actorType: "organization", + scopeId: organization.id, + scopeLabel: `${organization.settings.displayName} organization`, + message: "GitHub sync failed while refreshing repositories", + workflowId: "github-sync", + stepName: "full-sync", + attempt: 2, + willRetry: true, + retryDelayMs: 5000, + occurredAt: Date.now(), + }; +} + function buildDefaultSnapshot(): MockFoundryAppSnapshot { return { auth: { @@ -671,6 +693,50 @@ class MockFoundryAppStore implements MockFoundryAppClient { })); } + async setMockDebugOrganizationState(input: { + organizationId: string; + githubSyncStatus?: MockGithubSyncStatus; + githubInstallationStatus?: MockGithubInstallationStatus; + runtimeStatus?: MockActorRuntimeStatus; + }): Promise { + await this.injectAsyncLatency(); + this.requireOrganization(input.organizationId); + this.updateOrganization(input.organizationId, (organization) => { + const nextIssues = + input.runtimeStatus === "error" ? (organization.runtime.issues.length > 0 ? organization.runtime.issues : [buildSampleRuntimeIssue(organization)]) : []; + const nextRuntime = + input.runtimeStatus != null + ? { + ...organization.runtime, + status: input.runtimeStatus, + errorCount: nextIssues.length, + lastErrorAt: nextIssues[0]?.occurredAt ?? null, + issues: nextIssues, + } + : organization.runtime; + const nextSyncStatus = input.githubSyncStatus ?? organization.github.syncStatus; + return { + ...organization, + github: { + ...organization.github, + syncStatus: nextSyncStatus, + installationStatus: input.githubInstallationStatus ?? organization.github.installationStatus, + importedRepoCount: nextSyncStatus === "synced" ? organization.repoCatalog.length : organization.github.importedRepoCount, + lastSyncLabel: + nextSyncStatus === "syncing" + ? "Syncing repositories..." + : nextSyncStatus === "error" + ? "GitHub sync failed" + : nextSyncStatus === "pending" + ? "Waiting to sync" + : "Synced just now", + lastSyncAt: nextSyncStatus === "synced" ? Date.now() : organization.github.lastSyncAt, + }, + runtime: nextRuntime, + }; + }); + } + recordSeatUsage(workspaceId: string): void { const org = this.snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId); const currentUser = currentMockUser(this.snapshot); diff --git a/foundry/packages/frontend/src/app/router.tsx b/foundry/packages/frontend/src/app/router.tsx index 0d94406..def2808 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..1d970b6 --- /dev/null +++ b/foundry/packages/frontend/src/components/dev-panel.tsx @@ -0,0 +1,323 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouterState } from "@tanstack/react-router"; +import { Bug, RefreshCw, ShieldAlert, 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, + }; +} + +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 location = useRouterState({ select: (state) => state.location }); + 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 github = organization?.github ?? null; + const runtime = organization?.runtime ?? null; + const runtimeSummary = useMemo(() => { + if (!runtime || runtime.errorCount === 0) { + return "No actor errors"; + } + return runtime.errorCount === 1 ? "1 actor error" : `${runtime.errorCount} actor errors`; + }, [runtime]); + + 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} + +
+
{location.pathname}
+
+ +
+ +
+
+
Session
+
+
Auth: {snapshot.auth.status}
+
User: {user ? `${user.name} (@${user.githubLogin})` : "None"}
+
Organization: {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"}
+
+ {organization ? ( +
+ + +
+ ) : null} + {isMockFrontendClient && organization && client.setMockDebugOrganizationState ? ( +
+
+ {(["pending", "syncing", "synced", "error"] as const).map((status) => ( + + ))} +
+
+ {(["connected", "install_required", "reconnect_required"] as const).map((status) => ( + + ))} +
+
+ ) : null} +
+ +
+
Runtime
+
+
Status: {runtime?.status ?? "n/a"}
+
{runtimeSummary}
+ {runtime?.issues[0] ?
Latest: {runtime.issues[0].message}
: null} +
+ {organization ? ( +
+ {isMockFrontendClient && client.setMockDebugOrganizationState ? ( + <> + + + + ) : null} + {runtime?.errorCount ? ( + + ) : null} +
+ ) : null} +
+ + {isMockFrontendClient && organizations.length > 0 ? ( +
+
Mock Organization
+
+ {organizations.map((candidate) => ( + + ))} +
+
+ ) : null} +
+
+ ); +}