mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 17:01:06 +00:00
* feat(foundry): checkpoint actor and workspace refactor
* docs(foundry): add agent handoff context
* wip(foundry): continue actor refactor
* wip(foundry): capture remaining local changes
* Complete Foundry refactor checklist
* Fix Foundry validation fallout
* wip
* wip: convert all actors from workflow to plain run handlers
Workaround for RivetKit bug where c.queue.iter() never yields messages
for actors created via getOrCreate from another actor's context. The
queue accepts messages (visible in inspector) but the iterator hangs.
Sleep/wake fixes it, but actors with active connections never sleep.
Converted organization, github-data, task, and user actors from
run: workflow(...) to plain run: async (c) => { for await ... }.
Also fixes:
- Missing auth tables in org migration (auth_verification etc)
- default_model NOT NULL constraint on org profile upsert
- Nested workflow step in github-data (HistoryDivergedError)
- Removed --force from frontend Dockerfile pnpm install
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Convert all actors from queues/workflows to direct actions, lazy task creation
Major refactor replacing all queue-based workflow communication with direct
RivetKit action calls across all actors. This works around a RivetKit bug
where c.queue.iter() deadlocks for actors created from another actor's context.
Key changes:
- All actors (organization, task, user, audit-log, github-data) converted
from run: workflow(...) to actions-only (no run handler, no queues)
- PR sync creates virtual task entries in org local DB instead of spawning
task actors — prevents OOM from 200+ actors created simultaneously
- Task actors created lazily on first user interaction via getOrCreate,
self-initialize from org's getTaskIndexEntry data
- Removed requireRepoExists cross-actor call (caused 500s), replaced with
local resolveTaskRepoId from org's taskIndex table
- Fixed getOrganizationContext to thread overrides through all sync phases
- Fixed sandbox repo path (/home/user/repo for E2B compatibility)
- Fixed buildSessionDetail to skip transcript fetch for pending sessions
- Added process crash protection (uncaughtException/unhandledRejection)
- Fixed React infinite render loop in mock-layout useEffect dependencies
- Added sandbox listProcesses error handling for expired E2B sandboxes
- Set E2B sandbox timeout to 1 hour (was 5 min default)
- Updated CLAUDE.md with lazy task creation rules, no-silent-catch policy,
React hook dependency safety rules
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix E2B sandbox timeout comment, frontend stability, and create-flow improvements
- Add TEMPORARY comment on E2B timeoutMs with pointer to rivetkit sandbox
resilience proposal for when autoPause lands
- Fix React useEffect dependency stability in mock-layout and
organization-dashboard to prevent infinite re-render loops
- Fix terminal-pane ref handling
- Improve create-flow service and tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
593 lines
23 KiB
TypeScript
593 lines
23 KiB
TypeScript
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<typeof useFoundryTokens>): 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<typeof useFoundryTokens>): 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<typeof useFoundryTokens>): 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 (
|
|
<div
|
|
className={css({
|
|
position: "fixed",
|
|
bottom: "8px",
|
|
right: "8px",
|
|
width: "320px",
|
|
maxHeight: "50vh",
|
|
zIndex: 99999,
|
|
backgroundColor: t.surfaceElevated,
|
|
border: `1px solid ${t.borderMedium}`,
|
|
borderRadius: "6px",
|
|
boxShadow: t.shadow,
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
})}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "4px 8px",
|
|
borderBottom: `1px solid ${t.borderSubtle}`,
|
|
backgroundColor: t.surfaceTertiary,
|
|
flexShrink: 0,
|
|
})}
|
|
>
|
|
<span
|
|
className={css({
|
|
fontSize: "10px",
|
|
fontWeight: 600,
|
|
color: t.textSecondary,
|
|
letterSpacing: "0.5px",
|
|
textTransform: "uppercase",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "4px",
|
|
})}
|
|
>
|
|
Dev
|
|
{isMockFrontendClient && <span className={css({ fontSize: "8px", fontWeight: 600, color: t.statusWarning, letterSpacing: "0.3px" })}>MOCK</span>}
|
|
</span>
|
|
<span className={css({ fontSize: "9px", color: t.textMuted })}>Shift+D</span>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className={css({ overflowY: "auto", padding: "6px" })}>
|
|
{/* Subscription Topics */}
|
|
<Section label="Subscription Topics" t={t} css={css}>
|
|
{topics.map((topic) => (
|
|
<div
|
|
key={topic.key}
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "2px 0",
|
|
})}
|
|
>
|
|
<span
|
|
className={css({
|
|
width: "5px",
|
|
height: "5px",
|
|
borderRadius: "50%",
|
|
backgroundColor: topic.hasConnection ? t.statusSuccess : t.textMuted,
|
|
flexShrink: 0,
|
|
})}
|
|
/>
|
|
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
|
{topic.label}
|
|
</span>
|
|
<span className={`${mono} ${css({ color: statusColor(topic.status, t) })}`}>{topic.status}</span>
|
|
{topic.params && (
|
|
<span
|
|
className={`${mono} ${css({ color: t.textMuted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "100px" })}`}
|
|
>
|
|
{topic.params}
|
|
</span>
|
|
)}
|
|
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
|
|
</div>
|
|
))}
|
|
{topics.length === 0 && <span className={css({ fontSize: "10px", color: t.textMuted })}>No active subscriptions</span>}
|
|
</Section>
|
|
|
|
{/* App State */}
|
|
<Section label="App" t={t} css={css}>
|
|
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
|
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
|
<span
|
|
className={css({
|
|
width: "5px",
|
|
height: "5px",
|
|
borderRadius: "50%",
|
|
backgroundColor: authStatus === "signed_in" ? t.statusSuccess : authStatus === "signed_out" ? t.statusError : t.textMuted,
|
|
flexShrink: 0,
|
|
})}
|
|
/>
|
|
<span className={css({ color: t.textPrimary, flex: 1 })}>Auth</span>
|
|
<span className={`${mono} ${css({ color: authStatus === "signed_in" ? t.statusSuccess : t.statusError })}`}>{authStatus.replace(/_/g, " ")}</span>
|
|
</div>
|
|
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
|
<Stat label="orgs" value={totalOrgs} t={t} css={css} />
|
|
<Stat label="users" value={appSnapshot?.users.length ?? 0} t={t} css={css} />
|
|
</div>
|
|
<div className={`${mono} ${css({ color: t.textTertiary })}`}>app topic: {appState.status}</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Snapshot Summary */}
|
|
<Section label="Organization Snapshot" t={t} css={css}>
|
|
<div className={css({ display: "flex", gap: "10px", fontSize: "10px" })}>
|
|
<Stat label="repos" value={repos.length} t={t} css={css} />
|
|
<Stat label="tasks" value={tasks.length} t={t} css={css} />
|
|
<Stat label="PRs" value={prCount} t={t} css={css} />
|
|
</div>
|
|
</Section>
|
|
|
|
<Section label="Focused Task" t={t} css={css}>
|
|
{focusedTask ? (
|
|
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
|
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
|
<span
|
|
className={css({
|
|
width: "5px",
|
|
height: "5px",
|
|
borderRadius: "50%",
|
|
backgroundColor: statusColor(focusedTaskStatus ?? focusedTask.status, t),
|
|
flexShrink: 0,
|
|
})}
|
|
/>
|
|
<span className={css({ color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
|
{focusedTask.title || focusedTask.id.slice(0, 12)}
|
|
</span>
|
|
<span className={`${mono} ${css({ color: statusColor(focusedTaskStatus ?? focusedTask.status, t) })}`}>
|
|
{focusedTaskStatus ?? focusedTask.status}
|
|
</span>
|
|
</div>
|
|
<div className={`${mono} ${css({ color: t.textMuted })}`}>{focusedTaskState.detail}</div>
|
|
<div className={`${mono} ${css({ color: t.textTertiary })}`}>task: {focusedTask.id}</div>
|
|
<div className={`${mono} ${css({ color: t.textTertiary })}`}>repo: {focusedTask.repoId}</div>
|
|
<div className={`${mono} ${css({ color: t.textTertiary })}`}>branch: {focusedTask.branch ?? "-"}</div>
|
|
</div>
|
|
) : (
|
|
<span className={css({ fontSize: "10px", color: t.textMuted })}>No task focused</span>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Session — only when a task is focused */}
|
|
{focusedTask && (
|
|
<Section label="Session" t={t} css={css}>
|
|
{(focusedTask.sessions?.length ?? 0) > 0 ? (
|
|
focusedTask.sessions!.map((session) => {
|
|
const isActive = session.id === focusedTask.activeSessionId;
|
|
const thinking = thinkingLabel(session.thinkingSinceMs, now);
|
|
return (
|
|
<div
|
|
key={session.id}
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "1px",
|
|
padding: "2px 0",
|
|
fontSize: "10px",
|
|
})}
|
|
>
|
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
|
<span
|
|
className={css({
|
|
width: "5px",
|
|
height: "5px",
|
|
borderRadius: "50%",
|
|
backgroundColor: statusColor(session.status, t),
|
|
flexShrink: 0,
|
|
})}
|
|
/>
|
|
<span
|
|
className={css({
|
|
color: isActive ? t.textPrimary : t.textTertiary,
|
|
flex: 1,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
})}
|
|
>
|
|
{session.sessionName || session.id.slice(0, 12)}
|
|
{isActive ? " *" : ""}
|
|
</span>
|
|
<span className={`${mono} ${css({ color: statusColor(session.status, t) })}`}>{session.status}</span>
|
|
</div>
|
|
<div className={css({ display: "flex", gap: "6px", paddingLeft: "11px" })}>
|
|
<span className={`${mono} ${css({ color: t.textMuted })}`}>{session.agent}</span>
|
|
<span className={`${mono} ${css({ color: t.textMuted })}`}>{session.model}</span>
|
|
{!session.created && <span className={`${mono} ${css({ color: t.statusWarning })}`}>not created</span>}
|
|
{session.unread && <span className={`${mono} ${css({ color: t.statusWarning })}`}>unread</span>}
|
|
{thinking && <span className={`${mono} ${css({ color: t.statusWarning })}`}>{thinking}</span>}
|
|
</div>
|
|
{session.errorMessage && (
|
|
<div className={`${mono} ${css({ color: t.statusError, paddingLeft: "11px", wordBreak: "break-word" })}`}>{session.errorMessage}</div>
|
|
)}
|
|
{session.sessionId && <div className={`${mono} ${css({ color: t.textTertiary, paddingLeft: "11px" })}`}>sid: {session.sessionId}</div>}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<span className={css({ fontSize: "10px", color: t.textMuted })}>No sessions</span>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
{/* Sandbox — only when a task is focused */}
|
|
{focusedTask && (
|
|
<Section label="Sandbox" t={t} css={css}>
|
|
{(focusedTask.sandboxes?.length ?? 0) > 0 ? (
|
|
focusedTask.sandboxes!.map((sandbox) => {
|
|
const isActive = sandbox.sandboxId === focusedTask.activeSandboxId;
|
|
return (
|
|
<div
|
|
key={sandbox.sandboxId}
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "1px",
|
|
padding: "2px 0",
|
|
fontSize: "10px",
|
|
})}
|
|
>
|
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
|
<span
|
|
className={css({
|
|
width: "5px",
|
|
height: "5px",
|
|
borderRadius: "50%",
|
|
backgroundColor: isActive ? t.statusSuccess : t.textMuted,
|
|
flexShrink: 0,
|
|
})}
|
|
/>
|
|
<span
|
|
className={css({
|
|
color: isActive ? t.textPrimary : t.textTertiary,
|
|
flex: 1,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
})}
|
|
>
|
|
{sandbox.sandboxId.slice(0, 16)}
|
|
{isActive ? " *" : ""}
|
|
</span>
|
|
<span className={`${mono} ${css({ color: t.textMuted })}`}>{sandbox.sandboxProviderId}</span>
|
|
</div>
|
|
{sandbox.cwd && <div className={`${mono} ${css({ color: t.textTertiary, paddingLeft: "11px" })}`}>cwd: {sandbox.cwd}</div>}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<span className={css({ fontSize: "10px", color: t.textMuted })}>No sandboxes</span>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
{/* GitHub */}
|
|
<Section label="GitHub" t={t} css={css}>
|
|
{liveGithub ? (
|
|
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
|
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
|
<span
|
|
className={css({
|
|
width: "5px",
|
|
height: "5px",
|
|
borderRadius: "50%",
|
|
backgroundColor: installStatusColor(liveGithub.installationStatus, t),
|
|
flexShrink: 0,
|
|
})}
|
|
/>
|
|
<span className={css({ color: t.textPrimary, flex: 1 })}>App Install</span>
|
|
<span className={`${mono} ${css({ color: installStatusColor(liveGithub.installationStatus, t) })}`}>
|
|
{liveGithub.installationStatus.replace(/_/g, " ")}
|
|
</span>
|
|
</div>
|
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
|
<span
|
|
className={css({
|
|
width: "5px",
|
|
height: "5px",
|
|
borderRadius: "50%",
|
|
backgroundColor: syncStatusColor(liveGithub.syncStatus, t),
|
|
flexShrink: 0,
|
|
})}
|
|
/>
|
|
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
|
<span className={`${mono} ${css({ color: syncStatusColor(liveGithub.syncStatus, t) })}`}>{liveGithub.syncStatus}</span>
|
|
{liveGithub.lastSyncAt != null && <span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(liveGithub.lastSyncAt)}</span>}
|
|
</div>
|
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
|
<span
|
|
className={css({
|
|
width: "5px",
|
|
height: "5px",
|
|
borderRadius: "50%",
|
|
backgroundColor: hasRecentWebhook ? t.statusSuccess : t.textMuted,
|
|
flexShrink: 0,
|
|
})}
|
|
/>
|
|
<span className={css({ color: t.textPrimary, flex: 1 })}>Webhook</span>
|
|
{lastWebhookAt != null ? (
|
|
<span className={`${mono} ${css({ color: hasRecentWebhook ? t.textPrimary : t.textMuted })}`}>
|
|
{liveGithub.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
|
|
</span>
|
|
) : (
|
|
<span className={`${mono} ${css({ color: t.statusWarning })}`}>never received</span>
|
|
)}
|
|
</div>
|
|
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
|
<Stat label="imported" value={liveGithub.importedRepoCount} t={t} css={css} />
|
|
<Stat label="catalog" value={organization?.repoCatalog.length ?? repos.length} t={t} css={css} />
|
|
<Stat label="target" value={liveGithub.totalRepositoryCount ?? 0} t={t} css={css} />
|
|
</div>
|
|
{liveGithub.connectedAccount && <div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{liveGithub.connectedAccount}</div>}
|
|
{liveGithub.lastSyncLabel && <div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {liveGithub.lastSyncLabel}</div>}
|
|
{liveGithub.syncPhase && (
|
|
<div className={`${mono} ${css({ color: t.textTertiary })}`}>
|
|
phase: {liveGithub.syncPhase.replace(/^syncing_/, "").replace(/_/g, " ")} ({liveGithub.processedRepositoryCount}/
|
|
{liveGithub.totalRepositoryCount})
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className={css({ fontSize: "10px", color: t.textMuted })}>No organization data loaded</span>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Organization */}
|
|
<Section label="Organization" t={t} css={css}>
|
|
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{organizationId}</div>
|
|
{organization && (
|
|
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "2px" })}`}>
|
|
org: {organization.settings.displayName} ({organization.kind})
|
|
</div>
|
|
)}
|
|
</Section>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
function Section({
|
|
label,
|
|
t,
|
|
css: cssFn,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
t: ReturnType<typeof useFoundryTokens>;
|
|
css: ReturnType<typeof useStyletron>[0];
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={cssFn({ marginBottom: "6px" })}>
|
|
<div
|
|
className={cssFn({
|
|
fontSize: "9px",
|
|
fontWeight: 600,
|
|
color: t.textMuted,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.5px",
|
|
marginBottom: "2px",
|
|
})}
|
|
>
|
|
{label}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Stat({
|
|
label,
|
|
value,
|
|
t,
|
|
css: cssFn,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
t: ReturnType<typeof useFoundryTokens>;
|
|
css: ReturnType<typeof useStyletron>[0];
|
|
}) {
|
|
return (
|
|
<span>
|
|
<span className={cssFn({ fontWeight: 600, color: t.textPrimary })}>{value}</span>
|
|
<span className={cssFn({ color: t.textTertiary, marginLeft: "2px" })}>{label}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|