import { memo, useEffect, useMemo, useState } from "react"; import { useStyletron } from "baseui"; import { useFoundryTokens } from "../app/theme"; import { isMockFrontendClient } from "../lib/env"; import { subscriptionManager } from "../lib/subscription"; import type { FoundryAppSnapshot, FoundryOrganization, TaskWorkspaceSnapshot, WorkspaceSandboxSummary, WorkspaceSessionSummary, WorkspaceTaskStatus, } from "@sandbox-agent/foundry-shared"; import { useSubscription } from "@sandbox-agent/foundry-client"; import type { DebugSubscriptionTopic } from "@sandbox-agent/foundry-client"; import { describeTaskState } from "../features/tasks/status"; interface DevPanelProps { organizationId: string; snapshot: TaskWorkspaceSnapshot; organization?: FoundryOrganization | null; focusedTask?: DevPanelFocusedTask | null; } export interface DevPanelFocusedTask { id: string; repoId: string; title: string | null; status: WorkspaceTaskStatus; branch?: string | null; activeSandboxId?: string | null; activeSessionId?: string | null; sandboxes?: WorkspaceSandboxSummary[]; sessions?: WorkspaceSessionSummary[]; } interface TopicInfo { label: string; key: string; /** Parsed params portion of the cache key, or empty if none. */ params: string; listenerCount: number; hasConnection: boolean; status: "loading" | "connected" | "error"; lastRefresh: number | null; } function topicLabel(topic: DebugSubscriptionTopic): string { switch (topic.topicKey) { case "app": return "App"; case "organization": return "Organization"; case "task": return "Task"; case "session": return "Session"; case "sandboxProcesses": return "Sandbox"; } } /** Extract the params portion of a cache key (everything after the first `:`) */ function topicParams(topic: DebugSubscriptionTopic): string { const idx = topic.cacheKey.indexOf(":"); return idx >= 0 ? topic.cacheKey.slice(idx + 1) : ""; } function timeAgo(ts: number | null): string { if (!ts) return "never"; const seconds = Math.floor((Date.now() - ts) / 1000); if (seconds < 5) return "now"; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; return `${Math.floor(minutes / 60)}h ago`; } function statusColor(status: string, t: ReturnType): string { if (status.startsWith("init_") || status.startsWith("archive_") || status.startsWith("kill_") || status.startsWith("pending_")) { return t.statusWarning; } switch (status) { case "connected": case "running": case "ready": return t.statusSuccess; case "loading": return t.statusWarning; case "archived": return t.textMuted; case "error": case "failed": return t.statusError; default: return t.textTertiary; } } function syncStatusColor(status: string, t: ReturnType): string { switch (status) { case "synced": return t.statusSuccess; case "syncing": case "pending": return t.statusWarning; case "error": return t.statusError; default: return t.textMuted; } } function installStatusColor(status: string, t: ReturnType): string { switch (status) { case "connected": return t.statusSuccess; case "install_required": return t.statusWarning; case "reconnect_required": return t.statusError; default: return t.textMuted; } } /** Format elapsed thinking time as a compact string. */ function thinkingLabel(sinceMs: number | null, now: number): string | null { if (!sinceMs) return null; const elapsed = Math.floor((now - sinceMs) / 1000); if (elapsed < 1) return "thinking"; return `thinking ${elapsed}s`; } export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organization, focusedTask }: DevPanelProps) { const [css] = useStyletron(); const t = useFoundryTokens(); const [now, setNow] = useState(Date.now()); // Tick every 2s to keep relative timestamps fresh useEffect(() => { const id = setInterval(() => setNow(Date.now()), 2000); return () => clearInterval(id); }, []); const topics = useMemo((): TopicInfo[] => { return subscriptionManager.listDebugTopics().map((topic) => ({ label: topicLabel(topic), key: topic.cacheKey, params: topicParams(topic), listenerCount: topic.listenerCount, hasConnection: topic.status === "connected", status: topic.status, lastRefresh: topic.lastRefreshAt, })); }, [now]); const appState = useSubscription(subscriptionManager, "app", {}); const organizationState = useSubscription(subscriptionManager, "organization", { organizationId }); const appSnapshot: FoundryAppSnapshot | null = appState.data ?? null; const liveGithub = organizationState.data?.github ?? organization?.github ?? null; const repos = snapshot.repos ?? []; const tasks = snapshot.tasks ?? []; const prCount = tasks.filter((task) => task.pullRequest != null).length; const focusedTaskStatus = focusedTask?.status ?? null; const focusedTaskState = describeTaskState(focusedTaskStatus); const lastWebhookAt = liveGithub?.lastWebhookAt ?? null; const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000; const totalOrgs = appSnapshot?.organizations.length ?? 0; const authStatus = appSnapshot?.auth.status ?? "unknown"; const mono = css({ fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace", fontSize: "10px", }); return (
{/* Header */}
Dev {isMockFrontendClient && MOCK} Shift+D
{/* Body */}
{/* Subscription Topics */}
{topics.map((topic) => (
{topic.label} {topic.status} {topic.params && ( {topic.params} )} {timeAgo(topic.lastRefresh)}
))} {topics.length === 0 && No active subscriptions}
{/* App State */}
Auth {authStatus.replace(/_/g, " ")}
app topic: {appState.status}
{/* Snapshot Summary */}
{focusedTask ? (
{focusedTask.title || focusedTask.id.slice(0, 12)} {focusedTaskStatus ?? focusedTask.status}
{focusedTaskState.detail}
task: {focusedTask.id}
repo: {focusedTask.repoId}
branch: {focusedTask.branch ?? "-"}
) : ( No task focused )}
{/* Session — only when a task is focused */} {focusedTask && (
{(focusedTask.sessions?.length ?? 0) > 0 ? ( focusedTask.sessions!.map((session) => { const isActive = session.id === focusedTask.activeSessionId; const thinking = thinkingLabel(session.thinkingSinceMs, now); return (
{session.sessionName || session.id.slice(0, 12)} {isActive ? " *" : ""} {session.status}
{session.agent} {session.model} {!session.created && not created} {session.unread && unread} {thinking && {thinking}}
{session.errorMessage && (
{session.errorMessage}
)} {session.sessionId &&
sid: {session.sessionId}
}
); }) ) : ( No sessions )}
)} {/* Sandbox — only when a task is focused */} {focusedTask && (
{(focusedTask.sandboxes?.length ?? 0) > 0 ? ( focusedTask.sandboxes!.map((sandbox) => { const isActive = sandbox.sandboxId === focusedTask.activeSandboxId; return (
{sandbox.sandboxId.slice(0, 16)} {isActive ? " *" : ""} {sandbox.sandboxProviderId}
{sandbox.cwd &&
cwd: {sandbox.cwd}
}
); }) ) : ( No sandboxes )}
)} {/* GitHub */}
{liveGithub ? (
App Install {liveGithub.installationStatus.replace(/_/g, " ")}
Sync {liveGithub.syncStatus} {liveGithub.lastSyncAt != null && {timeAgo(liveGithub.lastSyncAt)}}
Webhook {lastWebhookAt != null ? ( {liveGithub.lastWebhookEvent} · {timeAgo(lastWebhookAt)} ) : ( never received )}
{liveGithub.connectedAccount &&
@{liveGithub.connectedAccount}
} {liveGithub.lastSyncLabel &&
last sync: {liveGithub.lastSyncLabel}
} {liveGithub.syncPhase && (
phase: {liveGithub.syncPhase.replace(/^syncing_/, "").replace(/_/g, " ")} ({liveGithub.processedRepositoryCount}/ {liveGithub.totalRepositoryCount})
)}
) : ( No organization data loaded )}
{/* Organization */}
{organizationId}
{organization && (
org: {organization.settings.displayName} ({organization.kind})
)}
); }); function Section({ label, t, css: cssFn, children, }: { label: string; t: ReturnType; css: ReturnType[0]; children: React.ReactNode; }) { return (
{label}
{children}
); } function Stat({ label, value, t, css: cssFn, }: { label: string; value: number; t: ReturnType; css: ReturnType[0]; }) { return ( {value} {label} ); } export function useDevPanel() { const [visible, setVisible] = useState(true); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.shiftKey && e.key === "D" && !e.metaKey && !e.ctrlKey && !e.altKey) { const tag = (e.target as HTMLElement)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; e.preventDefault(); setVisible((prev) => !prev); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, []); return visible; }