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:
parent
6c19304272
commit
57c0355cf9
@ -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,
|
||||
]),
|
||||
])
|
||||
|
||||
|
||||
326
products/catalyst/bootstrap/ui/src/lib/sandbox.api.ts
Normal file
326
products/catalyst/bootstrap/ui/src/lib/sandbox.api.ts
Normal 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: 'Anthropic’s official agent CLI. Connect Claude Max to use your subscription.',
|
||||
},
|
||||
{
|
||||
id: 'cursor-agent',
|
||||
label: 'Cursor Agent',
|
||||
blurb: 'Cursor’s 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'
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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/<name></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)
|
||||
}
|
||||
@ -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 CLI’s 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>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user