From 689d968397140df1a34e8030a7283ad4ddaa1c3f Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Mar 2026 20:46:50 -0700 Subject: [PATCH] Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation - Fix org display name using GitHub description instead of name field - Fix createWorkbenchSession hanging when sandbox is provisioning - Fix auto-session creation retry storm on errors - Fix task creation using wrong repo due to React state race conditions - Remove Bun hot-reload from backend Dockerfile (causes port drift) - Add GitHub sync/install status to dev panel Co-Authored-By: Claude Haiku 4.5 --- foundry/CLAUDE.md | 1 + foundry/docker/backend.dev.Dockerfile | 6 +- .../backend/src/actors/task/workflow/index.ts | 16 +- .../backend/src/actors/workspace/app-shell.ts | 12 + foundry/packages/backend/src/logging.ts | 1 + .../backend/src/services/app-github.ts | 4 +- foundry/packages/client/src/mock-app.ts | 103 +- .../packages/client/src/workbench-model.ts | 89 +- foundry/packages/frontend/src/app/router.tsx | 2 - .../frontend/src/components/dev-panel.tsx | 601 ++++---- .../frontend/src/components/mock-layout.tsx | 227 ++- .../src/components/mock-layout/sidebar.tsx | 212 +-- foundry/packages/shared/src/logging.ts | 123 ++ foundry/packages/shared/test/logging.test.ts | 29 + foundry/screenshots/dev-panel-github.png | Bin 0 -> 687698 bytes foundry/scripts/data/rivet-dev.json | 1332 +++++++++++++++++ foundry/scripts/pull-org-data.ts | 290 ++++ 17 files changed, 2569 insertions(+), 479 deletions(-) create mode 100644 foundry/packages/shared/test/logging.test.ts create mode 100644 foundry/screenshots/dev-panel-github.png create mode 100644 foundry/scripts/data/rivet-dev.json create mode 100644 foundry/scripts/pull-org-data.ts diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index 23314d8..8a57e02 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -50,6 +50,7 @@ Use `pnpm` workspaces and Turborepo. - `compose.dev.yaml` loads `foundry/.env` (optional) for credentials needed by the backend (GitHub OAuth, Stripe, Daytona, API keys, etc.). - The canonical source for these credentials is `~/misc/the-foundry.env`. If `foundry/.env` does not exist, copy it: `cp ~/misc/the-foundry.env foundry/.env` - `foundry/.env` is gitignored and must never be committed. +- The backend does **not** hot reload. Bun's `--hot` flag causes the server to re-bind on a different port (e.g. 6421 instead of 6420), breaking all client connections while the container still exposes the original port. After backend code changes, restart the backend container: `just foundry-dev-down && just foundry-dev`. ## Railway Logs diff --git a/foundry/docker/backend.dev.Dockerfile b/foundry/docker/backend.dev.Dockerfile index cf8580c..0182aa5 100644 --- a/foundry/docker/backend.dev.Dockerfile +++ b/foundry/docker/backend.dev.Dockerfile @@ -39,4 +39,8 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent" WORKDIR /app -CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun --hot foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] +# NOTE: Do NOT use `bun --hot` here. Bun's hot reloading re-initializes the +# server on a new port (e.g. 6421 instead of 6420) while the container still +# exposes the original port, breaking all client connections. Restart the +# backend container instead: `just foundry-dev-down && just foundry-dev` +CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] diff --git a/foundry/packages/backend/src/actors/task/workflow/index.ts b/foundry/packages/backend/src/actors/task/workflow/index.ts index de99ac1..419d36d 100644 --- a/foundry/packages/backend/src/actors/task/workflow/index.ts +++ b/foundry/packages/backend/src/actors/task/workflow/index.ts @@ -168,12 +168,16 @@ const commandHandlers: Record = { }, "task.command.workbench.create_session": async (loopCtx, msg) => { - const created = await loopCtx.step({ - name: "workbench-create-session", - timeout: 30_000, - run: async () => createWorkbenchSession(loopCtx, msg.body?.model), - }); - await msg.complete(created); + try { + const created = await loopCtx.step({ + name: "workbench-create-session", + timeout: 30_000, + run: async () => createWorkbenchSession(loopCtx, msg.body?.model), + }); + await msg.complete(created); + } catch (error) { + await msg.complete({ error: resolveErrorMessage(error) }); + } }, "task.command.workbench.ensure_session": async (loopCtx, msg) => { diff --git a/foundry/packages/backend/src/actors/workspace/app-shell.ts b/foundry/packages/backend/src/actors/workspace/app-shell.ts index c259474..7f6e73f 100644 --- a/foundry/packages/backend/src/actors/workspace/app-shell.ts +++ b/foundry/packages/backend/src/actors/workspace/app-shell.ts @@ -636,6 +636,12 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st installationStatus, lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available", }); + + // Broadcast updated app snapshot so connected clients see the new repos + c.broadcast("appUpdated", { + type: "appUpdated", + snapshot: await buildAppSnapshot(c, input.sessionId), + }); } catch (error) { const installationStatus = error instanceof GitHubAppError && (error.status === 403 || error.status === 404) @@ -645,6 +651,12 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st message: error instanceof Error ? error.message : "GitHub import failed", installationStatus, }); + + // Broadcast sync failure so the client updates status + c.broadcast("appUpdated", { + type: "appUpdated", + snapshot: await buildAppSnapshot(c, input.sessionId), + }); } } diff --git a/foundry/packages/backend/src/logging.ts b/foundry/packages/backend/src/logging.ts index 0bf5170..5b96d92 100644 --- a/foundry/packages/backend/src/logging.ts +++ b/foundry/packages/backend/src/logging.ts @@ -2,4 +2,5 @@ import { createFoundryLogger } from "@sandbox-agent/foundry-shared"; export const logger = createFoundryLogger({ service: "foundry-backend", + format: "logfmt", }); diff --git a/foundry/packages/backend/src/services/app-github.ts b/foundry/packages/backend/src/services/app-github.ts index b5b6706..1f04fe3 100644 --- a/foundry/packages/backend/src/services/app-github.ts +++ b/foundry/packages/backend/src/services/app-github.ts @@ -262,11 +262,11 @@ export class GitHubAppClient { } async listOrganizations(accessToken: string): Promise { - const organizations = await this.paginate<{ id: number; login: string; description?: string | null }>("/user/orgs?per_page=100", accessToken); + const organizations = await this.paginate<{ id: number; login: string; name?: string | null }>("/user/orgs?per_page=100", accessToken); return organizations.map((organization) => ({ id: String(organization.id), login: organization.login, - name: organization.description?.trim() || organization.login, + name: organization.name?.trim() || organization.login, })); } diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts index 61dadd2..0cf499d 100644 --- a/foundry/packages/client/src/mock-app.ts +++ b/foundry/packages/client/src/mock-app.ts @@ -1,4 +1,5 @@ import { injectMockLatency } from "./mock/latency.js"; +import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" }; export type MockBillingPlanId = "free" | "team"; export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; @@ -140,6 +141,69 @@ function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus { } } +/** + * Build the "rivet" mock organization from real public GitHub data. + * Fixture sourced from: scripts/pull-org-data.ts (run against rivet-dev). + * Members that don't exist in the public fixture get synthetic entries + * so the mock still has realistic owner/admin/member role distribution. + */ +function buildRivetOrganization(): MockFoundryOrganization { + const repos = rivetDevFixture.repos.map((r) => r.fullName); + const fixtureMembers: MockFoundryOrganizationMember[] = rivetDevFixture.members.map((m) => ({ + id: `member-rivet-${m.login.toLowerCase()}`, + name: m.login, + email: `${m.login.toLowerCase()}@rivet.dev`, + role: "member" as const, + state: "active" as const, + })); + + // Ensure we have named owner/admin roles for the mock user personas + // that may not appear in the public members list + const knownMembers: MockFoundryOrganizationMember[] = [ + { id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }, + { id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" }, + ]; + + // Merge: known members take priority, then fixture members not already covered + const knownIds = new Set(knownMembers.map((m) => m.id)); + const members = [...knownMembers, ...fixtureMembers.filter((m) => !knownIds.has(m.id))]; + + return { + id: "rivet", + workspaceId: "rivet", + kind: "organization", + settings: { + displayName: rivetDevFixture.name ?? rivetDevFixture.login, + slug: "rivet", + primaryDomain: "rivet.dev", + seatAccrualMode: "first_prompt", + defaultModel: "o3", + autoImportRepos: true, + }, + github: { + connectedAccount: rivetDevFixture.login, + installationStatus: "connected", + syncStatus: "synced", + importedRepoCount: repos.length, + lastSyncLabel: "Synced just now", + lastSyncAt: Date.now() - 60_000, + }, + billing: { + planId: "team", + status: "trialing", + seatsIncluded: 5, + trialEndsAt: isoDate(12), + renewalAt: isoDate(12), + stripeCustomerId: "cus_mock_rivet_team", + paymentMethodLabel: "Visa ending in 4242", + invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }], + }, + members, + seatAssignments: ["jamie@rivet.dev"], + repoCatalog: repos, + }; +} + function buildDefaultSnapshot(): MockFoundryAppSnapshot { return { auth: { @@ -259,44 +323,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { seatAssignments: ["nathan@acme.dev", "maya@acme.dev"], repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"], }, - { - id: "rivet", - workspaceId: "rivet", - kind: "organization", - settings: { - displayName: "Rivet", - slug: "rivet", - primaryDomain: "rivet.dev", - seatAccrualMode: "first_prompt", - defaultModel: "o3", - autoImportRepos: true, - }, - github: { - connectedAccount: "rivet-dev", - installationStatus: "reconnect_required", - syncStatus: "error", - importedRepoCount: 4, - lastSyncLabel: "Sync stalled 2 hours ago", - lastSyncAt: Date.now() - 2 * 60 * 60_000, - }, - billing: { - planId: "team", - status: "trialing", - seatsIncluded: 5, - trialEndsAt: isoDate(12), - renewalAt: isoDate(12), - stripeCustomerId: "cus_mock_rivet_team", - paymentMethodLabel: "Visa ending in 4242", - invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }], - }, - members: [ - { id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }, - { id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" }, - { id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" }, - ], - seatAssignments: ["jamie@rivet.dev"], - repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"], - }, + buildRivetOrganization(), { id: "personal-jamie", workspaceId: "personal-jamie", diff --git a/foundry/packages/client/src/workbench-model.ts b/foundry/packages/client/src/workbench-model.ts index 42cff08..b99f588 100644 --- a/foundry/packages/client/src/workbench-model.ts +++ b/foundry/packages/client/src/workbench-model.ts @@ -13,6 +13,7 @@ import type { WorkbenchRepo, WorkbenchTranscriptEvent as TranscriptEvent, } from "@sandbox-agent/foundry-shared"; +import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" }; export const MODEL_GROUPS: ModelGroup[] = [ { @@ -801,13 +802,13 @@ export function buildInitialTasks(): Task[] { fileTree: [], minutesUsed: 312, }, - // ── rivet-dev/cloud ── + // ── rivet-dev/vbare ── { id: "h6", - repoId: "cloud", + repoId: "vbare", title: "Use full cloud run pool name for routing", status: "idle", - repoName: "rivet-dev/cloud", + repoName: "rivet-dev/vbare", updatedAtMs: minutesAgo(25), branch: "fix-use-full-cloud-run-pool-name", pullRequest: { number: 235, status: "ready" }, @@ -910,13 +911,13 @@ export function buildInitialTasks(): Task[] { ], minutesUsed: 0, }, - // ── rivet-dev/engine-ee ── + // ── rivet-dev/skills ── { id: "h7", - repoId: "engine-ee", + repoId: "skills", title: "Route compute gateway path correctly", status: "idle", - repoName: "rivet-dev/engine-ee", + repoName: "rivet-dev/skills", updatedAtMs: minutesAgo(50), branch: "fix-guard-support-https-targets", pullRequest: { number: 125, status: "ready" }, @@ -1024,13 +1025,13 @@ export function buildInitialTasks(): Task[] { ], minutesUsed: 78, }, - // ── rivet-dev/engine-ee (archived) ── + // ── rivet-dev/skills (archived) ── { id: "h8", - repoId: "engine-ee", + repoId: "skills", title: "Move compute gateway to guard", status: "archived", - repoName: "rivet-dev/engine-ee", + repoName: "rivet-dev/skills", updatedAtMs: minutesAgo(2 * 24 * 60), branch: "chore-move-compute-gateway-to", pullRequest: { number: 123, status: "ready" }, @@ -1066,13 +1067,13 @@ export function buildInitialTasks(): Task[] { fileTree: [], minutesUsed: 15, }, - // ── rivet-dev/secure-exec ── + // ── rivet-dev/deploy-action ── { id: "h9", - repoId: "secure-exec", + repoId: "deploy-action", title: "Harden namespace isolation for nested containers", status: "idle", - repoName: "rivet-dev/secure-exec", + repoName: "rivet-dev/deploy-action", updatedAtMs: minutesAgo(90), branch: "fix/namespace-isolation", pullRequest: null, @@ -1122,15 +1123,63 @@ export function buildInitialTasks(): Task[] { ]; } +/** + * Build repos list from the rivet-dev fixture data (scripts/data/rivet-dev.json). + * Uses real public repos so the mock sidebar matches what an actual rivet-dev + * workspace would show after a GitHub sync. + */ +function buildMockRepos(): WorkbenchRepo[] { + return rivetDevFixture.repos.map((r) => ({ + id: repoIdFromFullName(r.fullName), + label: r.fullName, + })); +} + +/** Derive a stable short id from a "org/repo" full name (e.g. "rivet-dev/rivet" → "rivet"). */ +function repoIdFromFullName(fullName: string): string { + const parts = fullName.split("/"); + return parts[parts.length - 1] ?? fullName; +} + +/** + * Build task entries from open PR fixture data. + * Maps to the backend's PR sync behavior (ProjectPrSyncActor) where PRs + * appear as first-class sidebar items even without an associated task. + * Each open PR gets a lightweight task entry so it shows in the sidebar. + */ +function buildPrTasks(): Task[] { + // Collect branch names already claimed by hand-written tasks so we don't duplicate + const existingBranches = new Set( + buildInitialTasks() + .map((t) => t.branch) + .filter(Boolean), + ); + + return rivetDevFixture.openPullRequests + .filter((pr) => !existingBranches.has(pr.headRefName)) + .map((pr) => { + const repoId = repoIdFromFullName(pr.repoFullName); + return { + id: `pr-${repoId}-${pr.number}`, + repoId, + title: pr.title, + status: "idle" as const, + repoName: pr.repoFullName, + updatedAtMs: new Date(pr.updatedAt).getTime(), + branch: pr.headRefName, + pullRequest: { number: pr.number, status: pr.draft ? ("draft" as const) : ("ready" as const) }, + tabs: [], + fileChanges: [], + diffs: {}, + fileTree: [], + minutesUsed: 0, + }; + }); +} + export function buildInitialMockLayoutViewModel(): TaskWorkbenchSnapshot { - const repos: WorkbenchRepo[] = [ - { id: "sandbox-agent", label: "rivet-dev/sandbox-agent" }, - { id: "rivet", label: "rivet-dev/rivet" }, - { id: "cloud", label: "rivet-dev/cloud" }, - { id: "engine-ee", label: "rivet-dev/engine-ee" }, - { id: "secure-exec", label: "rivet-dev/secure-exec" }, - ]; - const tasks = buildInitialTasks(); + const repos = buildMockRepos(); + const tasks = [...buildInitialTasks(), ...buildPrTasks()]; return { workspaceId: "default", repos, diff --git a/foundry/packages/frontend/src/app/router.tsx b/foundry/packages/frontend/src/app/router.tsx index d6f8161..343f6eb 100644 --- a/foundry/packages/frontend/src/app/router.tsx +++ b/foundry/packages/frontend/src/app/router.tsx @@ -3,7 +3,6 @@ import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/ import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; import { useInterest } from "@sandbox-agent/foundry-client"; 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, @@ -346,7 +345,6 @@ function RootLayout() { <> - ); } diff --git a/foundry/packages/frontend/src/components/dev-panel.tsx b/foundry/packages/frontend/src/components/dev-panel.tsx index a4a2281..f0a176c 100644 --- a/foundry/packages/frontend/src/components/dev-panel.tsx +++ b/foundry/packages/frontend/src/components/dev-panel.tsx @@ -1,304 +1,379 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useRouterState } from "@tanstack/react-router"; -import { Bug, RefreshCw, Wifi } from "lucide-react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { useStyletron } from "baseui"; import { useFoundryTokens } from "../app/theme"; import { isMockFrontendClient } from "../lib/env"; -import { activeMockOrganization, activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; +import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared"; -const DEV_PANEL_STORAGE_KEY = "sandbox-agent-foundry:dev-panel-visible"; +interface DevPanelProps { + workspaceId: string; + snapshot: TaskWorkbenchSnapshot; + organization?: FoundryOrganization | null; +} -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; +interface TopicInfo { + label: string; + key: string; + listenerCount: number; + hasConnection: boolean; + lastRefresh: number | null; +} + +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`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h`; +} + +function taskStatusLabel(task: WorkbenchTask): string { + if (task.status === "archived") return "archived"; + const hasRunning = task.tabs?.some((tab) => tab.status === "running"); + if (hasRunning) return "running"; + return task.status ?? "idle"; +} + +function statusColor(status: string, t: ReturnType): string { + switch (status) { + case "running": + return t.statusSuccess; + case "archived": + return t.textMuted; + case "error": + case "failed": + return t.statusError; + default: + return t.textTertiary; } } -function writeStoredVisibility(value: boolean): void { - if (typeof window === "undefined") { - return; - } - try { - window.localStorage.setItem(DEV_PANEL_STORAGE_KEY, String(value)); - } catch { - // ignore +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 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; +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; } +} - const client = useMockAppClient(); - const snapshot = useMockAppSnapshot(); - const organization = activeMockOrganization(snapshot); - const user = activeMockUser(snapshot); - const organizations = eligibleOrganizations(snapshot); +export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organization }: DevPanelProps) { + const [css] = useStyletron(); const t = useFoundryTokens(); - const routeContext = useRouterState({ - select: (state) => ({ - location: state.location, - params: mergedRouteParams(state.matches as Array<{ params: Record }>), - }), - }); - const [visible, setVisible] = useState(() => readStoredVisibility()); + const [now, setNow] = useState(Date.now()); + // Tick every 2s to keep relative timestamps fresh 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 id = setInterval(() => setNow(Date.now()), 2000); + return () => clearInterval(id); }, []); - 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 topics = useMemo((): TopicInfo[] => { + const items: TopicInfo[] = []; - 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], - ); + // Workbench subscription topic + items.push({ + label: "Workbench", + key: `ws:${workspaceId}`, + listenerCount: 1, + hasConnection: true, + lastRefresh: now, + }); - if (!visible) { - return ( - - ); - } + // Per-task tab subscriptions + for (const task of snapshot.tasks ?? []) { + if (task.status === "archived") continue; + for (const tab of task.tabs ?? []) { + items.push({ + label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`, + key: `${workspaceId}:${task.id}:${tab.id}`, + listenerCount: 1, + hasConnection: tab.status === "running", + lastRefresh: tab.status === "running" ? now : null, + }); + } + } + + return items; + }, [workspaceId, snapshot, now]); + + const tasks = snapshot.tasks ?? []; + const repos = snapshot.repos ?? []; + const projects = snapshot.projects ?? []; + + const mono = css({ + fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace", + fontSize: "10px", + }); return (
+ {/* Header */}
-
-
- - Dev Panel - - {modeLabel} - -
-
{routeContext.location.pathname}
-
- + + Dev + {isMockFrontendClient && MOCK} + + Shift+D
-
-
-
Context
-
-
Organization: {contextOrganization?.settings.displayName ?? "None selected"}
-
Workspace: {selectedWorkspaceId ?? "None selected"}
-
Task: {selectedTaskId ?? "None selected"}
-
Repo: {selectedRepoId ?? "None selected"}
-
Session: {selectedSessionId ?? "None selected"}
-
-
+ {/* Body */} +
+ {/* Interest Topics */} +
+ {topics.map((topic) => ( +
+ + + {topic.label} + + {topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key} + {timeAgo(topic.lastRefresh)} +
+ ))} + {topics.length === 0 && No active subscriptions} +
-
-
Session
-
-
Auth: {snapshot.auth.status}
-
User: {user ? `${user.name} (@${user.githubLogin})` : "None"}
-
Active org: {organization?.settings.displayName ?? "None selected"}
+ {/* Snapshot Summary */} +
+
+ + +
- {isMockFrontendClient ? ( -
- {snapshot.auth.status === "signed_in" ? ( - - ) : ( - snapshot.users.map((candidate) => ( - - )) +
+ + {/* Tasks */} + {tasks.length > 0 && ( +
+ {tasks.slice(0, 10).map((task) => { + const status = taskStatusLabel(task); + return ( +
+ + + {task.title || task.id.slice(0, 12)} + + {status} + {task.tabs?.length ?? 0} tabs +
+ ); + })} +
+ )} + + {/* GitHub */} + {organization && ( +
+
+
+ + App + + {organization.github.installationStatus.replace(/_/g, " ")} + +
+
+ + Sync + {organization.github.syncStatus} +
+
+ +
+ {organization.github.connectedAccount && ( +
@{organization.github.connectedAccount}
+ )} + {organization.github.lastSyncLabel && ( +
last sync: {organization.github.lastSyncLabel}
)}
- ) : null} -
+ + )} -
-
GitHub
-
-
Installation: {github?.installationStatus ?? "n/a"}
-
Sync: {github?.syncStatus ?? "n/a"}
-
Repos: {github?.importedRepoCount ?? 0}
-
Last sync: {github?.lastSyncLabel ?? "n/a"}
-
- {contextOrganization ? ( -
- - + {/* Workspace */} +
+
{workspaceId}
+ {organization && ( +
+ org: {organization.settings.displayName} ({organization.kind})
- ) : null} -
- - {isMockFrontendClient && organizations.length > 0 ? ( -
-
Mock Organization
-
- {organizations.map((candidate) => ( - - ))} -
-
- ) : null} + )} +
); +}); + +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; } diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index f3a2a4f..8bb3d5d 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -1,7 +1,13 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useStyletron } from "baseui"; -import { createErrorContext, type WorkbenchSessionSummary, type WorkbenchTaskDetail, type WorkbenchTaskSummary } from "@sandbox-agent/foundry-shared"; +import { + createErrorContext, + type TaskWorkbenchSnapshot, + type WorkbenchSessionSummary, + type WorkbenchTaskDetail, + type WorkbenchTaskSummary, +} from "@sandbox-agent/foundry-shared"; import { useInterest } from "@sandbox-agent/foundry-client"; import { PanelLeft, PanelRight } from "lucide-react"; @@ -1085,6 +1091,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M const [leftSidebarOpen, setLeftSidebarOpen] = useState(true); const [rightSidebarOpen, setRightSidebarOpen] = useState(true); const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false); + const showDevPanel = useDevPanel(); const peekTimeoutRef = useRef | null>(null); const startPeek = useCallback(() => { @@ -1269,35 +1276,38 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M }, "failed_to_auto_create_workbench_session", ); - } finally { - autoCreatingSessionForTaskRef.current.delete(activeTask.id); + // Keep the guard in the set on error to prevent retry storms. + // The guard is cleared when tabs appear (line above) or the task changes. } })(); }, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]); - const createTask = useCallback(() => { - void (async () => { - const repoId = selectedNewTaskRepoId; - if (!repoId) { - throw new Error("Cannot create a task without an available repo"); - } + const createTask = useCallback( + (overrideRepoId?: string) => { + void (async () => { + const repoId = overrideRepoId || selectedNewTaskRepoId; + if (!repoId) { + throw new Error("Cannot create a task without an available repo"); + } - const { taskId, tabId } = await taskWorkbenchClient.createTask({ - repoId, - task: "New task", - model: "gpt-4o", - title: "New task", - }); - await navigate({ - to: "/workspaces/$workspaceId/tasks/$taskId", - params: { - workspaceId, - taskId, - }, - search: { sessionId: tabId ?? undefined }, - }); - })(); - }, [navigate, selectedNewTaskRepoId, workspaceId]); + const { taskId, tabId } = await taskWorkbenchClient.createTask({ + repoId, + task: "New task", + model: "gpt-4o", + title: "New task", + }); + await navigate({ + to: "/workspaces/$workspaceId/tasks/$taskId", + params: { + workspaceId, + taskId, + }, + search: { sessionId: tabId ?? undefined }, + }); + })(); + }, + [navigate, selectedNewTaskRepoId, workspaceId], + ); const openDiffTab = useCallback( (path: string) => { @@ -1509,7 +1519,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M transition: sidebarTransition, }} > -
+
-

Create your first task

-

- {workspaceRepos.length > 0 - ? "Start from the sidebar to create a task on the first available repo." - : "No repos are available in this workspace yet."} -

- + {activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? ( + <> +
+

Syncing with GitHub

+

+ Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}... + {activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.} +

+ + ) : activeOrg?.github.syncStatus === "error" ? ( + <> +

GitHub sync failed

+

There was a problem syncing repos from GitHub. Check the dev panel for details.

+ + ) : ( + <> +

Create your first task

+

+ {workspaceRepos.length > 0 + ? "Start from the sidebar to create a task on the first available repo." + : "No repos are available in this workspace yet."} +

+ + + )}
@@ -1610,6 +1654,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
+ {activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && ( +
+ + + GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable + +
+ )} + {showDevPanel && ( + + )} ); } @@ -1629,7 +1714,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M transition: sidebarTransition, }} > -
+
- {showDevPanel && } + {activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && ( +
+ + + GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable + +
+ )} + {showDevPanel && ( + + )} ); diff --git a/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx index f2f2e09..0f8f688 100644 --- a/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx @@ -1,8 +1,9 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useStyletron } from "baseui"; import { LabelSmall, LabelXSmall } from "baseui/typography"; +import { Select, type Value } from "baseui/select"; import { ChevronDown, ChevronRight, @@ -26,6 +27,17 @@ import type { FoundryTokens } from "../../styles/tokens"; const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; +/** Strip the org prefix (e.g. "rivet-dev/") when all repos share the same org. */ +function stripCommonOrgPrefix(label: string, repos: Array<{ label: string }>): string { + const slashIdx = label.indexOf("/"); + if (slashIdx < 0) return label; + const prefix = label.slice(0, slashIdx + 1); + if (repos.every((r) => r.label.startsWith(prefix))) { + return label.slice(slashIdx + 1); + } + return label; +} + function projectInitial(label: string): string { const parts = label.split("/"); const name = parts[parts.length - 1] ?? label; @@ -61,7 +73,7 @@ export const Sidebar = memo(function Sidebar({ selectedNewTaskRepoId: string; activeId: string; onSelect: (id: string) => void; - onCreate: () => void; + onCreate: (repoId?: string) => void; onSelectNewTaskRepo: (repoId: string) => void; onMarkUnread: (id: string) => void; onRenameTask: (id: string) => void; @@ -137,19 +149,8 @@ export const Sidebar = memo(function Sidebar({ }; }, [drag, onReorderProjects, onReorderTasks]); - const [createMenuOpen, setCreateMenuOpen] = useState(false); - const createMenuRef = useRef(null); - - useEffect(() => { - if (!createMenuOpen) return; - function handleClick(event: MouseEvent) { - if (createMenuRef.current && !createMenuRef.current.contains(event.target as Node)) { - setCreateMenuOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [createMenuOpen]); + const [createSelectOpen, setCreateSelectOpen] = useState(false); + const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]); return ( @@ -232,7 +233,99 @@ export const Sidebar = memo(function Sidebar({
) : null} -
+ {createSelectOpen ? ( +
+