feat(ui): Wave 3 — Sandbox UI (landing + session host + BYOS settings)

Scaffolds the per-Org agent-coding workspace under the chroot Sovereign
Console (issue: sandbox-wave3-ui-scaffold). Three pages mounted under
/sandbox/* sharing the PortalShell chrome with Dashboard / Apps / Jobs /
Settings; no bespoke layout, no hex colours, no new card components —
verbatim SectionCard pattern from SettingsPage and Add-modal shape from
ParentDomainsPage. New sidebar entry sits between Apps and Jobs.

- /sandbox            → SandboxLanding: 6-agent picker (aider, claude-
  code, cursor-agent, little-coder, opencode, qwen-code). Claude Code
  card includes "Connect Claude Max" → /sandbox/settings. Recent-
  sessions rail surfaces existing Sandbox CRs with attach links.
- /sandbox/$id        → SandboxSession: xterm.js terminal host
  (placeholder banner — WebSocket attach to in-pod pty-server lands in
  Wave 2). xterm + @xterm/addon-fit already present in package.json
  (ExecPanel / LogViewer use them).
- /sandbox/settings   → SandboxSettings: BYOS Claude Max OAuth Connect/
  Disconnect, wires to Wave 1b stubs at
  /api/v1/sandbox/byos/claude-code/{status,connect,disconnect}.

Files:
- lib/sandbox.api.ts (new) — getSandboxes, createSandbox, getByosStatus,
  connectByosClaudeCode, disconnectByosClaudeCode, SANDBOX_AGENTS
  catalogue. All endpoints tolerate 404/5xx and surface the "API
  pending" pill mirroring BSS / SettingsPage.
- pages/sovereign/sandbox/{SandboxLanding,SandboxSession,SandboxSettings}.tsx
  (new).
- pages/sovereign/SovereignSidebar.tsx (modified) — Sandbox nav entry
  between Apps and Jobs; deriveActiveSection covers /sandbox/*.
- app/router.tsx (modified) — three console-tree routes; static
  /sandbox/settings registered BEFORE /sandbox/$id so the literal
  segment wins on path match.

Design-token audit: zero hex CSS, zero non-token Tailwind colour
classes. amber-500/* used verbatim for pending-api pill,
rose-500/* used verbatim for the Disconnect destructive button and
form errors — both per the inheritance-block exception list. The two
hex fallbacks in SandboxSession.tsx (`#0a0a0a`, `#e5e7eb`) are runtime
JS theme defaults for xterm.js when `getComputedStyle` returns empty
strings under jsdom; this matches the existing precedent in
widgets/cloud-list/ExecPanel.tsx which hardcodes terminal hex entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-05-18 06:45:34 +02:00
parent 6c19304272
commit 57c0355cf9
6 changed files with 1254 additions and 2 deletions

View File

@ -141,6 +141,14 @@ import { OrdersPage as BssOrdersPage } from '@/pages/sovereign/bss/OrdersPage'
import { RevenuePage as BssRevenuePage } from '@/pages/sovereign/bss/RevenuePage'
import { VouchersPage as BssVouchersPage } from '@/pages/sovereign/bss/VouchersPage'
import { TenantsPage as BssTenantsPage } from '@/pages/sovereign/bss/TenantsPage'
// Wave 3 — Sandbox UI scaffold (branch: sandbox-wave3-ui-scaffold).
// Per-Org agent-coding workspace mounted under /sandbox/* in the chroot
// Sovereign Console. SandboxLanding is the 6-agent picker;
// SandboxSession hosts xterm.js for /sandbox/$id; SandboxSettings is
// the BYOS Claude Max OAuth surface.
import { SandboxLanding } from '@/pages/sovereign/sandbox/SandboxLanding'
import { SandboxSession } from '@/pages/sovereign/sandbox/SandboxSession'
import { SandboxSettings } from '@/pages/sovereign/sandbox/SandboxSettings'
import {
canonicalisePath,
hasCatalystSession,
@ -1564,6 +1572,43 @@ const consoleBssTenantsRoute = createRoute({
component: BssTenantsPage,
})
/* Wave 3 Sandbox UI scaffold (sandbox-wave3-ui-scaffold)
*
* Per-Org agent-coding workspace under the chroot Sovereign Console.
* See products/sandbox/docs/architecture.md §1 for the surface contract
* (xterm.js host in browser in-pod pty-server agent CLI).
*
* /sandbox SandboxLanding (6-agent picker grid + recent
* sessions rail, PortalShell chrome)
* /sandbox/$id SandboxSession (xterm.js terminal host; Wave 2
* wires the WebSocket attach to pty-server)
* /sandbox/settings SandboxSettings (BYOS Claude Max OAuth
* Connect / Disconnect; wires to Wave 1b
* /api/v1/sandbox/byos/claude-code/* stubs)
*
* SandboxSession's path-param is `$id` — TanStack Router's $-syntax
* matched against the Sandbox CR name (sandbox-<slug>). Static children
* (`/sandbox/settings`) MUST be declared BEFORE the dynamic `$id`
* sibling so the literal segment wins on /sandbox/settings TanStack
* matches in registration order. The route array below preserves that
* ordering.
*/
const consoleSandboxIndexRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/sandbox',
component: SandboxLanding,
})
const consoleSandboxSettingsRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/sandbox/settings',
component: SandboxSettings,
})
const consoleSandboxSessionRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/sandbox/$id',
component: SandboxSession,
})
/* ── Sovereign-mode cloud legacy redirects (TC-090..092 / 2026-05-07)
*
* Sister set to LEGACY_CLOUD_REDIRECTS (which is mounted under the
@ -2067,6 +2112,11 @@ const routeTree = rootRoute.addChildren([
consoleBssRevenueRoute,
consoleBssVouchersRoute,
consoleBssTenantsRoute,
// Wave 3 — Sandbox UI scaffold. Static /sandbox/settings registered
// before /sandbox/$id so the literal segment wins on path match.
consoleSandboxIndexRoute,
consoleSandboxSettingsRoute,
consoleSandboxSessionRoute,
]),
])

View File

@ -0,0 +1,326 @@
/**
* lib/sandbox.api.ts typed REST client for the Sovereign-side Sandbox
* surfaces (Wave 3 UI scaffold).
*
* Sandbox = per-Org agent-coding workspace. Each session runs a chosen
* agent CLI (`claude`, `cursor-agent`, `aider`, `qwen-code`, `opencode`,
* `little-coder`) in a pod inside the user's vcluster; the pod's
* `pty-server` shim pipes ANSI stdout to the browser's xterm.js over a
* WebSocket. See `products/sandbox/docs/architecture.md` §1.
*
* Wire paths (Wave 1b backend stubs):
*
* browser /api/v1/sandbox/sessions catalyst-api Sandbox CRD list
* browser /api/v1/sandbox/sessions POST catalyst-api Sandbox CR
* browser /api/v1/sandbox/byos/claude-code/status catalyst-api
*
* All endpoints tolerate 404 / 5xx so the page renders its target-state
* shape on first paint (per docs/INVIOLABLE-PRINCIPLES.md #1) the
* landing flips its "API pending" pill when the backend isn't wired.
*/
import { API_BASE } from '@/shared/config/urls'
import { authedFetch } from '@/shared/lib/authedFetch'
/**
* Canonical agent catalogue. The id is the binary name the pty-server
* spawns; the label / blurb drive the Landing's card grid. Adding a new
* supported agent is a one-line append here no other site touches the
* list (per INVIOLABLE-PRINCIPLES.md #4 never hardcode at call sites).
*/
export interface SandboxAgent {
id:
| 'aider'
| 'claude-code'
| 'cursor-agent'
| 'little-coder'
| 'opencode'
| 'qwen-code'
label: string
blurb: string
}
export const SANDBOX_AGENTS: readonly SandboxAgent[] = [
{
id: 'aider',
label: 'Aider',
blurb: 'AI pair-programming in your terminal. Git-native edits, multi-file refactors.',
},
{
id: 'claude-code',
label: 'Claude Code',
blurb: 'Anthropics official agent CLI. Connect Claude Max to use your subscription.',
},
{
id: 'cursor-agent',
label: 'Cursor Agent',
blurb: 'Cursors background agent CLI for autonomous code changes.',
},
{
id: 'little-coder',
label: 'Little Coder',
blurb: 'Lightweight scoped agent for small, focused edits.',
},
{
id: 'opencode',
label: 'OpenCode',
blurb: 'Open-source agent runtime with a swappable model backend.',
},
{
id: 'qwen-code',
label: 'Qwen Code',
blurb: 'Alibaba Qwen coding agent. OSS-friendly model defaults.',
},
] as const
/* ── Sessions ────────────────────────────────────────────────────── */
export type SandboxStatus = 'pending' | 'running' | 'stopped' | 'failed' | 'unknown'
export interface Sandbox {
/** Stable Sandbox CR name (sandbox-<slug>). */
id: string
/** Operator-facing label (defaults to id when empty). */
name: string
/** Agent binary id chosen at create time. */
agent: SandboxAgent['id']
status: SandboxStatus
/** ISO-8601 creation timestamp. */
createdAt: string
/** Repo path mounted at /repo (e.g. 'org/site'); empty when none. */
repo: string
}
export interface SandboxesResponse {
/** True when the BE returned a non-2xx the page still renders
* its target-state shape with the "API pending" pill. */
pendingApi: boolean
sandboxes: Sandbox[]
}
const EMPTY_SANDBOXES: SandboxesResponse = { pendingApi: true, sandboxes: [] }
const SANDBOX_BASE = `${API_BASE}/v1/sandbox`
/**
* getSandboxes fetch the per-Org sandbox roster for the Landing's
* "Recent sessions" rail.
*
* Returns `{ pendingApi: true, sandboxes: [] }` on 404 / 5xx / network
* error so the Landing renders the agent grid + empty-state rail on
* first paint without crashing the surface (per INVIOLABLE-PRINCIPLES.md
* #1 waterfall, target-state shape first time).
*/
export async function getSandboxes(): Promise<SandboxesResponse> {
let res: Response
try {
res = await authedFetch(`${SANDBOX_BASE}/sessions`, {
headers: { Accept: 'application/json' },
})
} catch {
return EMPTY_SANDBOXES
}
if (!res.ok) {
return EMPTY_SANDBOXES
}
try {
const body = (await res.json()) as { sandboxes?: unknown } | null
if (!body || typeof body !== 'object' || !Array.isArray(body.sandboxes)) {
return { pendingApi: false, sandboxes: [] }
}
const sandboxes: Sandbox[] = body.sandboxes
.map((raw): Sandbox | null => {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const id = typeof r.id === 'string' ? r.id : ''
if (id === '') return null
return {
id,
name: typeof r.name === 'string' && r.name !== '' ? r.name : id,
agent: normalizeAgent(r.agent),
status: normalizeStatus(r.status),
createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
repo: typeof r.repo === 'string' ? r.repo : '',
}
})
.filter((s): s is Sandbox => s !== null)
return { pendingApi: false, sandboxes }
} catch {
return EMPTY_SANDBOXES
}
}
export interface CreateSandboxRequest {
/** Agent binary id (must match a SANDBOX_AGENTS row). */
agent: SandboxAgent['id']
/** Optional human label; defaults server-side to `<agent>-<short-id>`. */
name?: string
/** Optional repo to clone into /repo (e.g. 'org/site'). */
repo?: string
}
/**
* createSandbox POST /v1/sandbox/sessions. Returns the freshly
* provisioned Sandbox row on success. Surfaces the BE's `detail` /
* `error` field on non-2xx so the Landing's create-modal can show the
* actual server-side message.
*/
export async function createSandbox(req: CreateSandboxRequest): Promise<Sandbox> {
const res = await authedFetch(`${SANDBOX_BASE}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(req),
})
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = (await res.json()) as { detail?: string; error?: string }
detail = body.detail ?? body.error ?? detail
} catch {
// non-JSON body — keep the status-line message
}
throw new Error(`create sandbox: ${detail}`)
}
const raw = (await res.json()) as Record<string, unknown>
return {
id: typeof raw.id === 'string' ? raw.id : '',
name: typeof raw.name === 'string' ? raw.name : '',
agent: normalizeAgent(raw.agent),
status: normalizeStatus(raw.status),
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
repo: typeof raw.repo === 'string' ? raw.repo : '',
}
}
/* BYOS (Bring-Your-Own-Subscription) Claude Max OAuth
*
* Wave 1b backend stubs:
* GET /v1/sandbox/byos/claude-code/status
* POST /v1/sandbox/byos/claude-code/connect returns OAuth URL
* DELETE /v1/sandbox/byos/claude-code/disconnect
*
* The Connect button on SandboxSettings opens the OAuth URL in a new tab;
* Anthropic redirects to /v1/sandbox/byos/claude-code/callback which the
* BE persists. Status flips to `connected` on the next status poll.
*/
export type ByosStatus = 'connected' | 'disconnected' | 'pending' | 'error'
export interface ByosClaudeCodeStatus {
/** True when the BE returned a non-2xx the page still renders
* but flags the "API pending" pill. */
pendingApi: boolean
status: ByosStatus
/** Account label shown next to the Disconnect button (e.g. user's
* Anthropic email). Empty when disconnected. */
accountLabel: string
/** ISO-8601 timestamp of the last successful token refresh. */
connectedAt: string
}
const DISCONNECTED_BYOS: ByosClaudeCodeStatus = {
pendingApi: true,
status: 'disconnected',
accountLabel: '',
connectedAt: '',
}
/**
* getByosStatus fetch the BYOS Claude Code connection status for
* the SandboxSettings page. Tolerates 404 / 5xx / network error by
* returning `{ pendingApi: true, status: 'disconnected', ... }` so the
* page renders its full Connect / Disconnect chrome on first paint.
*/
export async function getByosStatus(): Promise<ByosClaudeCodeStatus> {
let res: Response
try {
res = await authedFetch(`${SANDBOX_BASE}/byos/claude-code/status`, {
headers: { Accept: 'application/json' },
})
} catch {
return DISCONNECTED_BYOS
}
if (!res.ok) {
return DISCONNECTED_BYOS
}
try {
const body = (await res.json()) as Partial<ByosClaudeCodeStatus> | null
if (!body || typeof body !== 'object') return DISCONNECTED_BYOS
return {
pendingApi: false,
status: normalizeByosStatus(body.status),
accountLabel: typeof body.accountLabel === 'string' ? body.accountLabel : '',
connectedAt: typeof body.connectedAt === 'string' ? body.connectedAt : '',
}
} catch {
return DISCONNECTED_BYOS
}
}
/**
* connectByosClaudeCode POST /v1/sandbox/byos/claude-code/connect.
* Returns the OAuth authorization URL the SandboxSettings page opens in
* a new tab. The BE persists the returned tokens via the OAuth callback.
*/
export async function connectByosClaudeCode(): Promise<{ authorizeUrl: string }> {
const res = await authedFetch(`${SANDBOX_BASE}/byos/claude-code/connect`, {
method: 'POST',
headers: { Accept: 'application/json' },
})
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = (await res.json()) as { detail?: string; error?: string }
detail = body.detail ?? body.error ?? detail
} catch {
// ignore
}
throw new Error(`connect claude-code: ${detail}`)
}
const body = (await res.json()) as { authorizeUrl?: string }
return { authorizeUrl: typeof body.authorizeUrl === 'string' ? body.authorizeUrl : '' }
}
/**
* disconnectByosClaudeCode DELETE /v1/sandbox/byos/claude-code/
* disconnect. Drops the stored OAuth tokens. Surfaces the BE's `detail` /
* `error` field on non-2xx.
*/
export async function disconnectByosClaudeCode(): Promise<void> {
const res = await authedFetch(`${SANDBOX_BASE}/byos/claude-code/disconnect`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
})
if (!res.ok && res.status !== 204) {
let detail = `HTTP ${res.status}`
try {
const body = (await res.json()) as { detail?: string; error?: string }
detail = body.detail ?? body.error ?? detail
} catch {
// ignore
}
throw new Error(`disconnect claude-code: ${detail}`)
}
}
/* ── Normalisers ─────────────────────────────────────────────────── */
function normalizeAgent(raw: unknown): SandboxAgent['id'] {
if (typeof raw !== 'string') return 'claude-code'
const hit = SANDBOX_AGENTS.find((a) => a.id === raw)
return hit ? hit.id : 'claude-code'
}
function normalizeStatus(raw: unknown): SandboxStatus {
if (typeof raw !== 'string') return 'unknown'
const s = raw.toLowerCase()
if (s === 'pending' || s === 'running' || s === 'stopped' || s === 'failed') return s
return 'unknown'
}
function normalizeByosStatus(raw: unknown): ByosStatus {
if (typeof raw !== 'string') return 'disconnected'
const s = raw.toLowerCase()
if (s === 'connected' || s === 'disconnected' || s === 'pending' || s === 'error') return s
return 'disconnected'
}

View File

@ -35,7 +35,7 @@ const CLOUD_ICON =
'M6.657 18c-2.572 0 -4.657 -2.007 -4.657 -4.483c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 1.927 -1.551 3.487 -3.465 3.487h-11.878'
interface FlatNavItem {
id: 'apps' | 'jobs' | 'dashboard' | 'cloud' | 'users' | 'bss' | 'settings'
id: 'apps' | 'sandbox' | 'jobs' | 'dashboard' | 'cloud' | 'users' | 'bss' | 'settings'
label: string
to: string
icon: string
@ -65,6 +65,22 @@ const FLAT_NAV: FlatNavItem[] = [
to: '/apps',
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
},
// Sandbox (Wave 3, 2026-05-18 — issue: sandbox-wave3-ui-scaffold).
// Per-Org agent-coding workspace — each session runs a chosen agent
// CLI (claude/cursor-agent/aider/qwen/opencode/little-coder) in a pod
// inside the user's vcluster; the in-pod pty-server pipes ANSI to
// xterm.js in the browser. See products/sandbox/docs/architecture.md
// §1. Sits between Apps (workloads catalogue) and Jobs (operations)
// because it shares the "what's running" mental model.
//
// Icon: terminal/monitor single-stroke SVG matching the icon family
// used by Cloud (Tabler cloud) / Apps (grid) / Jobs (clipboard).
{
id: 'sandbox',
label: 'Sandbox',
to: '/sandbox',
icon: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm5 5l3 3-3 3m5 0h4M8 21h8',
},
{
id: 'jobs',
label: 'Jobs',
@ -137,13 +153,16 @@ const SETTINGS_SUB_NAV: SubNavItem[] = [
// ── Active-state derivation ───────────────────────────────────────────────────
type ActiveSection = 'apps' | 'jobs' | 'dashboard' | 'cloud' | 'users' | 'bss' | 'settings'
type ActiveSection = 'apps' | 'sandbox' | 'jobs' | 'dashboard' | 'cloud' | 'users' | 'bss' | 'settings'
const CLOUD_PATH_RE = /^\/(cloud|infrastructure)(\/|$)/
function deriveActiveSection(pathname: string): ActiveSection {
if (CLOUD_PATH_RE.test(pathname)) return 'cloud'
if (/^\/dashboard(\/|$)/.test(pathname)) return 'dashboard'
// /sandbox(/*) → 'sandbox' so the nav highlight covers the landing,
// /sandbox/$id, and /sandbox/settings (Wave 3).
if (/^\/sandbox(\/|$)/.test(pathname)) return 'sandbox'
if (/^\/jobs(\/|$)/.test(pathname)) return 'jobs'
if (/^\/users(\/|$)/.test(pathname)) return 'users'
// /bss(/*) → 'bss' so the BSS nav item highlights for every BSS

View File

@ -0,0 +1,417 @@
/**
* SandboxLanding native /sandbox landing.
*
* Wave 3 UI scaffold (issue: sandbox-wave3-ui-scaffold). Surfaces the
* 6-agent grid (aider / claude-code / cursor-agent / little-coder /
* opencode / qwen-code) the operator picks from to start a fresh
* Sandbox session, plus a "Recent sessions" rail for resume.
*
* Per the founder design-system inheritance ruling (2026-05-17
* feedback_subagents_inherit_design_system.md), the page MUST share the
* same PortalShell chrome + design tokens as Dashboard / Apps / Jobs /
* Settings no bespoke layout, no hex colours, no new card components.
* Card chrome mirrors SettingsPage's SectionCard verbatim (rounded-xl,
* var(--color-bg-2) on var(--color-border), 5-unit padding).
*
* Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall first paint is the
* full surface), every card renders from mount; the Recent sessions
* rail flips from "—" to live rows as `getSandboxes()` resolves and
* surfaces the SettingsPage "API pending" pill when the backend is not
* yet wired (Wave 1b stubs).
*
* Per #4 (never hardcode) the agent grid is derived from `SANDBOX_AGENTS`
* in `@/lib/sandbox.api` adding a new agent is a one-line append on
* that catalogue, no UI changes required.
*/
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
import { PortalShell } from '../PortalShell'
import { useDeploymentEvents } from '../useDeploymentEvents'
import {
SANDBOX_AGENTS,
createSandbox,
getSandboxes,
type SandboxAgent,
type SandboxesResponse,
} from '@/lib/sandbox.api'
const QUERY_STALE_MS = 30_000
export interface SandboxLandingProps {
/** Test seam — disables the live SSE attach. */
disableStream?: boolean
/** Test seam — bypass the React Query fetcher with synthetic data. */
initialSandboxesOverride?: SandboxesResponse
}
export function SandboxLanding({
disableStream = false,
initialSandboxesOverride,
}: SandboxLandingProps = {}) {
const { deploymentId: resolvedId } = useResolvedDeploymentId()
const deploymentId = resolvedId ?? ''
const { snapshot } = useDeploymentEvents({
deploymentId,
applicationIds: [],
disableStream,
})
const sovereignFQDN =
snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
const query = useQuery<SandboxesResponse>({
queryKey: ['sandbox-sessions', deploymentId],
queryFn: getSandboxes,
staleTime: QUERY_STALE_MS,
enabled: !initialSandboxesOverride,
placeholderData: (prev) => prev,
})
const data = initialSandboxesOverride ?? query.data ?? null
const pendingApi = data?.pendingApi ?? true
const sandboxes = data?.sandboxes ?? []
const [modalAgent, setModalAgent] = useState<SandboxAgent | null>(null)
return (
<PortalShell
deploymentId={deploymentId}
sovereignFQDN={sovereignFQDN}
pageTitle="Sandbox"
headerSlotLeft={
<Link
to={'/sandbox/settings' as never}
className="text-[11px] text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
data-testid="sov-sandbox-settings-link"
>
Settings
</Link>
}
>
<div className="mx-auto max-w-7xl" data-testid="sandbox-landing-page">
{/* Agent grid — 6 cards, one per supported agent CLI */}
<section
aria-label="Choose an agent"
data-testid="sandbox-agent-grid"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"
>
{SANDBOX_AGENTS.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
onStart={() => setModalAgent(agent)}
/>
))}
</section>
{/* Recent sessions rail — resume an existing Sandbox */}
<section
aria-label="Recent sessions"
data-testid="sandbox-recent-rail"
data-pending-api={pendingApi ? 'true' : undefined}
className="mt-6 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-5"
>
<header className="mb-4 flex items-start justify-between gap-3">
<div>
<h2
className="text-base font-semibold text-[var(--color-text-strong)]"
data-testid="sandbox-recent-title"
>
Recent sessions
</h2>
<p className="mt-0.5 text-xs text-[var(--color-text-dim)]">
Sandbox sessions persist across browser tabs closing this
tab does not stop the agent. Pick a session to reattach.
</p>
</div>
{pendingApi ? (
<span
data-testid="sandbox-recent-pending-api"
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
title="Backend API not yet wired — display only"
>
API pending
</span>
) : null}
</header>
{sandboxes.length === 0 ? (
<p
className="text-sm text-[var(--color-text-dim)]"
data-testid="sandbox-recent-empty"
>
No sandbox sessions yet. Pick an agent above to start one.
</p>
) : (
<ul className="flex flex-col gap-2" data-testid="sandbox-recent-list">
{sandboxes.map((s) => (
<li
key={s.id}
data-testid={`sandbox-recent-row-${s.id}`}
className="flex items-center justify-between rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--color-text)]">
{s.name}
</p>
<p className="truncate text-xs text-[var(--color-text-dim)]">
<span className="font-mono">{s.agent}</span>
{s.repo ? <> · {s.repo}</> : null}
{' · '}
<span className="uppercase tracking-wide">{s.status}</span>
</p>
</div>
<Link
to={'/sandbox/$id' as never}
params={{ id: s.id } as never}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-1.5 text-xs text-[var(--color-text)] no-underline hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
data-testid={`sandbox-recent-attach-${s.id}`}
>
Attach
</Link>
</li>
))}
</ul>
)}
</section>
</div>
{modalAgent ? (
<StartSandboxModal
agent={modalAgent}
onClose={() => setModalAgent(null)}
/>
) : null}
</PortalShell>
)
}
/* ── Agent card ──────────────────────────────────────────────────── */
interface AgentCardProps {
agent: SandboxAgent
onStart: () => void
}
function AgentCard({ agent, onStart }: AgentCardProps) {
const isClaude = agent.id === 'claude-code'
return (
<article
data-testid={`sandbox-agent-card-${agent.id}`}
className="flex flex-col gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-5"
>
<header className="flex items-start justify-between gap-3">
<div>
<h2
className="text-base font-semibold text-[var(--color-text-strong)]"
data-testid={`sandbox-agent-title-${agent.id}`}
>
{agent.label}
</h2>
<p className="mt-0.5 text-xs font-mono text-[var(--color-text-dim)]">
{agent.id}
</p>
</div>
<AgentIcon agentId={agent.id} />
</header>
<p
className="text-sm text-[var(--color-text-dim)]"
data-testid={`sandbox-agent-blurb-${agent.id}`}
>
{agent.blurb}
</p>
<div className="mt-auto flex items-center gap-2">
<button
type="button"
onClick={onStart}
data-testid={`sandbox-agent-start-${agent.id}`}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-xs text-[var(--color-text)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Start session
</button>
{isClaude ? (
<Link
to={'/sandbox/settings' as never}
data-testid="sandbox-agent-claude-connect"
className="rounded-md border border-[var(--color-accent)]/60 bg-[var(--color-accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] no-underline hover:bg-[var(--color-accent)]/20"
>
Connect Claude Max
</Link>
) : null}
</div>
</article>
)
}
/* Start-session modal Add-modal shape from ParentDomainsPage
*
* Mirrors AddDomainModal's full-screen overlay + click-outside-to-close
* + form layout. Inputs use the same border/bg classes; submit uses the
* SettingsPage accent treatment.
*/
interface StartSandboxModalProps {
agent: SandboxAgent
onClose: () => void
}
function StartSandboxModal({ agent, onClose }: StartSandboxModalProps) {
const qc = useQueryClient()
const [name, setName] = useState('')
const [repo, setRepo] = useState('')
const [error, setError] = useState<string | null>(null)
const create = useMutation({
mutationFn: createSandbox,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['sandbox-sessions'] })
onClose()
},
onError: (err) => setError((err as Error).message),
})
function onSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
create.mutate({
agent: agent.id,
name: name.trim() || undefined,
repo: repo.trim() || undefined,
})
}
return (
<div
data-testid="sandbox-start-modal"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={onClose}
role="presentation"
>
<form
onSubmit={onSubmit}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-6 shadow-xl"
>
<h2 className="mb-1 text-lg font-semibold text-[var(--color-text-strong)]">
Start {agent.label} session
</h2>
<p className="mb-4 text-xs text-[var(--color-text-dim)]">
Provisions a Sandbox pod in your Org vcluster running{' '}
<span className="font-mono">{agent.id}</span>. The pod persists
across browser tabs closing the tab does not stop the agent.
</p>
{error && (
<div
data-testid="sandbox-start-error"
className="mb-3 rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-xs text-rose-300"
>
{error}
</div>
)}
<label className="mb-3 block">
<div className="mb-1 text-xs font-medium text-[var(--color-text-dim)]">
Session name <span className="text-[var(--color-text-dimmer)]">(optional)</span>
</div>
<input
data-testid="sandbox-start-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={`${agent.id}-${shortRand()}`}
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
autoFocus
/>
</label>
<label className="mb-4 block">
<div className="mb-1 text-xs font-medium text-[var(--color-text-dim)]">
Repository <span className="text-[var(--color-text-dimmer)]">(optional)</span>
</div>
<input
data-testid="sandbox-start-repo"
type="text"
value={repo}
onChange={(e) => setRepo(e.target.value)}
placeholder="org/site"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-sm font-mono text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-dim)]">
Cloned into <span className="font-mono">/repo/&lt;name&gt;</span> at start.
</div>
</label>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm text-[var(--color-text-dim)] hover:bg-[var(--color-bg-2)]"
>
Cancel
</button>
<button
type="submit"
data-testid="sandbox-start-submit"
disabled={create.isPending}
className="rounded-md bg-[var(--color-accent)] px-4 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{create.isPending ? 'Starting…' : 'Start session'}
</button>
</div>
</form>
</div>
)
}
/* Icons
*
* Single-stroke SVG path-d glyphs matching the SovereignSidebar icon
* family. No emoji per the design-system inheritance ruling. The
* agent-specific glyph picks something thematically adjacent:
*
* aider square brackets (text edits)
* claude-code chat bubble (Anthropic)
* cursor-agent cursor arrow
* little-coder code chevron
* opencode terminal prompt
* qwen-code sparkle / star (AI)
*/
const AGENT_ICONS: Record<SandboxAgent['id'], string> = {
aider: 'M8 4H6a2 2 0 00-2 2v12a2 2 0 002 2h2M16 4h2a2 2 0 012 2v12a2 2 0 01-2 2h-2',
'claude-code':
'M21 12a8.5 8.5 0 11-3.32-6.74L21 4v6h-6',
'cursor-agent':
'M5 3l14 7-7 2-2 7-5-16z',
'little-coder':
'M9 17l-5-5 5-5M15 7l5 5-5 5',
opencode:
'M4 4h16v16H4z M8 9l3 3-3 3 M13 15h4',
'qwen-code':
'M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z',
}
function AgentIcon({ agentId }: { agentId: SandboxAgent['id'] }) {
return (
<svg
className="h-5 w-5 shrink-0 text-[var(--color-text-dim)]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden
>
<path strokeLinecap="round" strokeLinejoin="round" d={AGENT_ICONS[agentId]} />
</svg>
)
}
function shortRand(): string {
// 4-char base36 placeholder — server generates the canonical id; this
// is only the placeholder text shown in the empty name input.
return Math.random().toString(36).slice(2, 6)
}

View File

@ -0,0 +1,179 @@
/**
* SandboxSession native /sandbox/$id session view.
*
* Wave 3 UI scaffold. Hosts the xterm.js terminal that pipes the
* agent CLI's ANSI stdout from the in-pod pty-server (see
* `products/sandbox/docs/architecture.md` §1).
*
* Wire path (Wave 2):
* browser xterm.js WSS /api/v1/sandbox/sessions/{id}/attach
* pty-server in the Sandbox pod
* <agent> CLI in the same pod
*
* This PR ships the xterm.js HOST surface only the WebSocket adapter
* lands in Wave 2 when the pty-server endpoint is wired. The placeholder
* banner makes the wave gap visible to the operator (per
* INVIOLABLE-PRINCIPLES.md #1 waterfall, first paint is the target-
* state shape with the "API pending" pill where the backend isn't ready).
*
* xterm + @xterm/addon-fit are declared in package.json (already present
* for the FlowCanvas tracer); the import only fires when this route is
* navigated to so the bundle stays out of the Landing / Settings path.
*
* Per the design-system inheritance ruling, the chrome is PortalShell
* (same header band as JobsPage / SettingsPage) with a SectionCard-style
* surface around the terminal no bespoke layout, no hex colours.
*/
import { useEffect, useRef } from 'react'
import { Link, useParams } from '@tanstack/react-router'
import { Terminal } from 'xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
import { PortalShell } from '../PortalShell'
import { useDeploymentEvents } from '../useDeploymentEvents'
export interface SandboxSessionProps {
/** Test seam — disables the live SSE attach. */
disableStream?: boolean
/** Test seam disables the xterm.js mount so jsdom tests don't crash
* on canvas / measureText. Production call sites never set this. */
disableTerminal?: boolean
}
export function SandboxSession({
disableStream = false,
disableTerminal = false,
}: SandboxSessionProps = {}) {
const params = useParams({ strict: false }) as { id?: string }
const sessionId = params.id ?? ''
const { deploymentId: resolvedId } = useResolvedDeploymentId()
const deploymentId = resolvedId ?? ''
const { snapshot } = useDeploymentEvents({
deploymentId,
applicationIds: [],
disableStream,
})
const sovereignFQDN =
snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
const hostRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const fitRef = useRef<FitAddon | null>(null)
useEffect(() => {
if (disableTerminal) return
const host = hostRef.current
if (!host) return
// Theme uses our design tokens — read live so a future tenant theme
// override propagates to the terminal background / foreground.
const styles = getComputedStyle(document.documentElement)
const bg = styles.getPropertyValue('--color-bg').trim() || '#0a0a0a'
const fg = styles.getPropertyValue('--color-text').trim() || '#e5e7eb'
const term = new Terminal({
convertEol: true,
cursorBlink: true,
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 13,
theme: { background: bg, foreground: fg },
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
try {
fit.fit()
} catch {
// jsdom / no-layout — ignore, the WebSocket attach in Wave 2 will
// SIGWINCH again on first resize event.
}
termRef.current = term
fitRef.current = fit
// Placeholder banner — the operator sees the terminal chrome on
// first paint with a clear hint that the WebSocket pipe lands in
// Wave 2. The bytes are ANSI dim so they don't masquerade as agent
// output; pressing keys is a no-op until the socket attaches.
term.write(
'\x1b[2m# Sandbox session ' +
sessionId +
'\r\n# xterm.js host ready. WebSocket attach lands in Wave 2.\r\n# pty-server URL: /api/v1/sandbox/sessions/' +
sessionId +
'/attach\x1b[0m\r\n',
)
function onResize() {
try {
fit.fit()
} catch {
// ignore
}
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
term.dispose()
termRef.current = null
fitRef.current = null
}
}, [sessionId, disableTerminal])
return (
<PortalShell
deploymentId={deploymentId}
sovereignFQDN={sovereignFQDN}
pageTitle={`Sandbox · ${sessionId || 'session'}`}
headerSlotLeft={
<Link
to={'/sandbox' as never}
className="text-[11px] text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
data-testid="sov-sandbox-back-to-landing"
>
Back to sandbox
</Link>
}
>
<div className="mx-auto max-w-7xl" data-testid="sandbox-session-page">
<section
aria-label="Sandbox terminal"
data-testid="sandbox-session-card"
data-pending-api="true"
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-5"
>
<header className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-[var(--color-text-strong)]">
Terminal
</h2>
<p className="mt-0.5 text-xs text-[var(--color-text-dim)]">
Native ANSI surface the agent CLIs output renders here
verbatim over a WebSocket to the in-pod{' '}
<span className="font-mono">pty-server</span>.
</p>
</div>
<span
data-testid="sandbox-session-pending-api"
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
title="WebSocket attach lands in Wave 2"
>
API pending
</span>
</header>
<div
ref={hostRef}
data-testid="sandbox-session-terminal"
className="h-[calc(100vh-14rem)] w-full overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] p-2"
/>
</section>
</div>
</PortalShell>
)
}

View File

@ -0,0 +1,261 @@
/**
* SandboxSettings /sandbox/settings.
*
* Wave 3 UI scaffold. Surfaces BYOS ("Bring-Your-Own-Subscription")
* controls so the operator can connect their Claude Max account to
* Sandbox sessions running `claude-code`.
*
* Wire paths (Wave 1b backend stubs):
* GET /api/v1/sandbox/byos/claude-code/status
* POST /api/v1/sandbox/byos/claude-code/connect returns OAuth URL
* DELETE /api/v1/sandbox/byos/claude-code/disconnect
*
* The Connect button opens the returned OAuth URL in a new tab; the
* BYOS callback handler persists tokens and flips the status pill on
* the next status poll.
*
* Per the design-system inheritance ruling, the page mirrors
* SettingsPage's SectionCard chrome verbatim (rounded-xl,
* var(--color-bg-2) on var(--color-border), h2 + tagline). The
* Disconnect button uses the same rose-500/40 family the Settings
* Danger zone uses (per the inheritance block's exception list).
*/
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
import { PortalShell } from '../PortalShell'
import { useDeploymentEvents } from '../useDeploymentEvents'
import {
connectByosClaudeCode,
disconnectByosClaudeCode,
getByosStatus,
type ByosClaudeCodeStatus,
} from '@/lib/sandbox.api'
const QUERY_STALE_MS = 30_000
export interface SandboxSettingsProps {
/** Test seam — disables the live SSE attach. */
disableStream?: boolean
/** Test seam — bypass the React Query fetcher with synthetic data. */
initialByosOverride?: ByosClaudeCodeStatus
}
export function SandboxSettings({
disableStream = false,
initialByosOverride,
}: SandboxSettingsProps = {}) {
const { deploymentId: resolvedId } = useResolvedDeploymentId()
const deploymentId = resolvedId ?? ''
const { snapshot } = useDeploymentEvents({
deploymentId,
applicationIds: [],
disableStream,
})
const sovereignFQDN =
snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
return (
<PortalShell
deploymentId={deploymentId}
sovereignFQDN={sovereignFQDN}
pageTitle="Sandbox settings"
headerSlotLeft={
<Link
to={'/sandbox' as never}
className="text-[11px] text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
data-testid="sov-sandbox-settings-back"
>
Back to sandbox
</Link>
}
>
<div className="mx-auto max-w-7xl" data-testid="sandbox-settings-page">
<ClaudeCodeByosCard initialByosOverride={initialByosOverride} />
</div>
</PortalShell>
)
}
/* ── BYOS: Claude Code (Claude Max OAuth) ─────────────────────────── */
interface ClaudeCodeByosCardProps {
initialByosOverride?: ByosClaudeCodeStatus
}
function ClaudeCodeByosCard({ initialByosOverride }: ClaudeCodeByosCardProps) {
const qc = useQueryClient()
const [error, setError] = useState<string | null>(null)
const query = useQuery<ByosClaudeCodeStatus>({
queryKey: ['sandbox-byos-claude-code'],
queryFn: getByosStatus,
staleTime: QUERY_STALE_MS,
enabled: !initialByosOverride,
placeholderData: (prev) => prev,
})
const data = initialByosOverride ?? query.data ?? null
const pendingApi = data?.pendingApi ?? true
const status = data?.status ?? 'disconnected'
const accountLabel = data?.accountLabel ?? ''
const connectedAt = data?.connectedAt ?? ''
const isConnected = status === 'connected'
const connect = useMutation({
mutationFn: connectByosClaudeCode,
onSuccess: ({ authorizeUrl }) => {
setError(null)
if (authorizeUrl) {
// Open OAuth flow in a new tab; the callback persists tokens
// server-side, then the next status poll flips the pill.
window.open(authorizeUrl, '_blank', 'noopener,noreferrer')
}
},
onError: (err) => setError((err as Error).message),
})
const disconnect = useMutation({
mutationFn: disconnectByosClaudeCode,
onSuccess: () => {
setError(null)
void qc.invalidateQueries({ queryKey: ['sandbox-byos-claude-code'] })
},
onError: (err) => setError((err as Error).message),
})
return (
<section
id="byos-claude-code"
data-testid="sandbox-byos-claude-code-card"
data-pending-api={pendingApi ? 'true' : undefined}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-5"
>
<header className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-[var(--color-text-strong)]">
Claude Code Claude Max
</h2>
<p className="mt-0.5 text-xs text-[var(--color-text-dim)]">
Bring your own Anthropic subscription. When connected, Sandbox
sessions running <span className="font-mono">claude-code</span>{' '}
use your Claude Max quota instead of platform credits.
</p>
</div>
{pendingApi ? (
<span
data-testid="sandbox-byos-claude-code-pending-api"
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
title="Backend API not yet wired — display only"
>
API pending
</span>
) : null}
</header>
{error ? (
<div
data-testid="sandbox-byos-claude-code-error"
className="mb-3 rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-xs text-rose-300"
>
{error}
</div>
) : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<StatusDot status={status} />
<span
className="text-sm font-medium text-[var(--color-text)]"
data-testid="sandbox-byos-claude-code-status"
>
{humanStatus(status)}
</span>
</div>
{isConnected ? (
<p
className="text-xs text-[var(--color-text-dim)]"
data-testid="sandbox-byos-claude-code-account"
>
{accountLabel || '—'}
{connectedAt ? (
<span className="ml-2 font-mono text-[10px] text-[var(--color-text-dimmer)]">
since {new Date(connectedAt).toLocaleString()}
</span>
) : null}
</p>
) : (
<p className="text-xs text-[var(--color-text-dim)]">
No Anthropic account linked.
</p>
)}
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<button
type="button"
onClick={() => disconnect.mutate()}
disabled={disconnect.isPending}
data-testid="sandbox-byos-claude-code-disconnect"
className="rounded-md border border-rose-500/60 bg-rose-500/5 px-3 py-1.5 text-xs text-rose-300 no-underline hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-50"
>
{disconnect.isPending ? 'Disconnecting…' : 'Disconnect'}
</button>
) : (
<button
type="button"
onClick={() => connect.mutate()}
disabled={connect.isPending}
data-testid="sandbox-byos-claude-code-connect"
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{connect.isPending ? 'Opening…' : 'Connect Claude Max'}
</button>
)}
</div>
</div>
</section>
)
}
function humanStatus(status: ByosClaudeCodeStatus['status']): string {
switch (status) {
case 'connected':
return 'Connected'
case 'pending':
return 'Authorization pending'
case 'error':
return 'Connection error'
case 'disconnected':
default:
return 'Not connected'
}
}
function StatusDot({ status }: { status: ByosClaudeCodeStatus['status'] }) {
// Token-driven dot palette — accent for connected (matches the
// SettingsPage SectionCard accent treatment), rose-500 for error and
// amber-500 for pending (same family the rest of the page uses for
// destructive / pending-api). Disconnected is the dimmer-text token so
// it reads as "no signal" without colour-coding a neutral state.
const cls =
status === 'connected'
? 'bg-[var(--color-accent)]'
: status === 'error'
? 'bg-rose-500'
: status === 'pending'
? 'bg-amber-500'
: 'bg-[var(--color-text-dimmer)]'
return (
<span
aria-hidden
className={`inline-block h-2 w-2 rounded-full ${cls}`}
data-testid="sandbox-byos-claude-code-dot"
/>
)
}