Add Foundry dev panel

This commit is contained in:
Nathan Flurry 2026-03-12 11:55:25 -07:00
parent 859ad13934
commit f365342dcc
4 changed files with 397 additions and 0 deletions

View file

@ -26,6 +26,12 @@ export interface FoundryAppClient {
resumeSubscription(organizationId: string): Promise<void>;
reconnectGithub(organizationId: string): Promise<void>;
recordSeatUsage(workspaceId: string): Promise<void>;
setMockDebugOrganizationState?(input: {
organizationId: string;
githubSyncStatus?: "pending" | "syncing" | "synced" | "error";
githubInstallationStatus?: "connected" | "install_required" | "reconnect_required";
runtimeStatus?: "healthy" | "error";
}): Promise<void>;
}
export interface CreateFoundryAppClientOptions {

View file

@ -140,6 +140,12 @@ export interface MockFoundryAppClient {
resumeSubscription(organizationId: string): Promise<void>;
reconnectGithub(organizationId: string): Promise<void>;
recordSeatUsage(workspaceId: string): void;
setMockDebugOrganizationState(input: {
organizationId: string;
githubSyncStatus?: MockGithubSyncStatus;
githubInstallationStatus?: MockGithubInstallationStatus;
runtimeStatus?: MockActorRuntimeStatus;
}): Promise<void>;
}
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<void> {
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);

View file

@ -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() {
<>
<RouteContextSync />
<Outlet />
<DevPanel />
</>
);
}

View file

@ -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<boolean>(() => 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 (
<button
type="button"
onClick={() => setVisible(true)}
style={{
position: "fixed",
right: "16px",
bottom: "16px",
zIndex: 1000,
display: "inline-flex",
alignItems: "center",
gap: "8px",
border: `1px solid ${t.borderDefault}`,
background: t.surfacePrimary,
color: t.textPrimary,
borderRadius: "999px",
padding: "10px 14px",
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.28)",
cursor: "pointer",
}}
>
<Bug size={14} />
Dev
</button>
);
}
return (
<div
style={{
position: "fixed",
right: "16px",
bottom: "16px",
width: "360px",
maxHeight: "calc(100vh - 32px)",
overflowY: "auto",
zIndex: 1000,
borderRadius: "18px",
border: `1px solid ${t.borderDefault}`,
background: t.surfacePrimary,
color: t.textPrimary,
boxShadow: "0 24px 60px rgba(0, 0, 0, 0.35)",
}}
>
<div
style={{
position: "sticky",
top: 0,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 16px",
borderBottom: `1px solid ${t.borderDefault}`,
background: t.surfacePrimary,
}}
>
<div style={{ display: "grid", gap: "2px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Bug size={14} />
<strong style={{ fontSize: "13px" }}>Dev Panel</strong>
<span
style={{
fontSize: "10px",
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: t.textMuted,
}}
>
{modeLabel}
</span>
</div>
<div style={{ fontSize: "11px", color: t.textMuted }}>{location.pathname}</div>
</div>
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
Hide
</button>
</div>
<div style={{ display: "grid", gap: "12px", padding: "14px" }}>
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
<div style={labelStyle(t.textMuted)}>Session</div>
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
<div>Auth: {snapshot.auth.status}</div>
<div>User: {user ? `${user.name} (@${user.githubLogin})` : "None"}</div>
<div>Organization: {organization?.settings.displayName ?? "None selected"}</div>
</div>
{isMockFrontendClient ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{snapshot.auth.status === "signed_in" ? (
<button type="button" onClick={() => void client.signOut()} style={pillButtonStyle()}>
Sign out
</button>
) : (
snapshot.users.map((candidate) => (
<button key={candidate.id} type="button" onClick={() => void client.signInWithGithub(candidate.id)} style={pillButtonStyle()}>
Sign in as {candidate.githubLogin}
</button>
))
)}
</div>
) : null}
</div>
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
<div style={labelStyle(t.textMuted)}>GitHub</div>
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
<div>Installation: {github?.installationStatus ?? "n/a"}</div>
<div>Sync: {github?.syncStatus ?? "n/a"}</div>
<div>Repos: {github?.importedRepoCount ?? 0}</div>
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
</div>
{organization ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={pillButtonStyle()}>
<RefreshCw size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
Sync
</button>
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={pillButtonStyle()}>
<Wifi size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
Reconnect
</button>
</div>
) : null}
{isMockFrontendClient && organization && client.setMockDebugOrganizationState ? (
<div style={{ display: "grid", gap: "8px" }}>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{(["pending", "syncing", "synced", "error"] as const).map((status) => (
<button
key={status}
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubSyncStatus: status })}
style={pillButtonStyle(github?.syncStatus === status)}
>
{status}
</button>
))}
</div>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{(["connected", "install_required", "reconnect_required"] as const).map((status) => (
<button
key={status}
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubInstallationStatus: status })}
style={pillButtonStyle(github?.installationStatus === status)}
>
{status}
</button>
))}
</div>
</div>
) : null}
</div>
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
<div style={labelStyle(t.textMuted)}>Runtime</div>
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
<div>Status: {runtime?.status ?? "n/a"}</div>
<div>{runtimeSummary}</div>
{runtime?.issues[0] ? <div>Latest: {runtime.issues[0].message}</div> : null}
</div>
{organization ? (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{isMockFrontendClient && client.setMockDebugOrganizationState ? (
<>
<button
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "error" })}
style={pillButtonStyle(runtime?.status === "error")}
>
<ShieldAlert size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
Show error
</button>
<button
type="button"
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "healthy" })}
style={pillButtonStyle(runtime?.status === "healthy")}
>
Healthy
</button>
</>
) : null}
{runtime?.errorCount ? (
<button type="button" onClick={() => void client.clearOrganizationRuntimeIssues(organization.id)} style={pillButtonStyle()}>
Clear actor errors
</button>
) : null}
</div>
) : null}
</div>
{isMockFrontendClient && organizations.length > 0 ? (
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
<div style={labelStyle(t.textMuted)}>Mock Organization</div>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{organizations.map((candidate) => (
<button
key={candidate.id}
type="button"
onClick={() => void client.selectOrganization(candidate.id)}
style={pillButtonStyle(organization?.id === candidate.id)}
>
{candidate.settings.displayName}
</button>
))}
</div>
</div>
) : null}
</div>
</div>
);
}