Dashboard: Infrastructure tier with the agent computer (cloud apps)
ci / gates (push) Failing after 5s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

Promote Infrastructure to a first-class tier that mirrors the Agent template:
a left rail tab + crumb, an Infra category sidebar, a center stage, and the
right slide-out computer — the SAME device chrome as the agent, driven by a
swappable app catalog.

- Parameterize the computer by a ComputerCatalog (computer/catalog.ts): grid,
  dock, icons, tile, title, render, theme. DevicePanel + HomeScreen are now
  catalog-driven; the agent computer is unchanged via agentCatalog() (today's
  grid/dock/tiles/AppRouter, extracted verbatim into computer/tiles.tsx).
- Infra catalog (computer/catalogs/infra.tsx): grid = AWS/GCP/Azure/Add, dock =
  Hosts/Status/Settings, original colored lettermark tiles (not the trademarked
  logos), placeholder app screens (computer/apps/infra/InfraApp.tsx). APP_IDS
  gains aws/gcp/azure/hosts/status.
- Infra tier in Dashboard.tsx: "infra" Tier + Server rail tab + crumb; an
  InfraSidebar/InfraStage/InfraConsole (InfraStage.tsx) mirroring the claw
  center+chat; the right pull-out is <DevicePanel catalog={INFRA_CATALOG}/> with
  the same launchers, size presets and drag-resize.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-24 07:19:31 -07:00
co-authored by Claude Opus 4.8
parent 98a9ec62ab
commit b853aab6fd
10 changed files with 558 additions and 111 deletions
@@ -3,6 +3,7 @@
import { ChevronLeft, X } from "lucide-react"; import { ChevronLeft, X } from "lucide-react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { import {
useMemo,
useRef, useRef,
useState, useState,
type CSSProperties, type CSSProperties,
@@ -11,9 +12,10 @@ import {
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { panelParsers, type AppId } from "@/lib/url/panel-params"; import { panelParsers, type AppId } from "@/lib/url/panel-params";
import { clawThemeStyle, WallpaperSurface } from "./ClawTheme"; import type { ComputerCatalog } from "./catalog";
import { agentCatalog } from "./catalogs/agent";
import { WallpaperSurface } from "./ClawTheme";
import { HomeScreen } from "./HomeScreen"; import { HomeScreen } from "./HomeScreen";
import { AppRouter, appTitle } from "./AppRouter";
import { SubHeaderContext, type SubHeader } from "./apps/AppShell"; import { SubHeaderContext, type SubHeader } from "./apps/AppShell";
/* The panel is ALWAYS an absolute, right-anchored overlay (never an in-flow /* The panel is ALWAYS an absolute, right-anchored overlay (never an in-flow
@@ -28,10 +30,12 @@ const OVERLAY =
/** The right-hand "Computer" slide-out (§7): a per-agent themed screen card /** The right-hand "Computer" slide-out (§7): a per-agent themed screen card
* hosting every sub-app behind ?app=. The size toggles and close live in * hosting every sub-app behind ?app=. The size toggles and close live in
* the chat header; this owns the card. */ * the chat header; this owns the card. */
export function DevicePanel({ agent }: { agent: Agent }) { export function DevicePanel({ agent, catalog: catalogProp }: { agent?: Agent; catalog?: ComputerCatalog }) {
const [{ app, device }, setParams] = useQueryStates(panelParsers, { const [{ app, device }, setParams] = useQueryStates(panelParsers, {
shallow: true, shallow: true,
}); });
const agentCat = useMemo(() => (agent ? agentCatalog(agent) : null), [agent]);
const catalog = catalogProp ?? agentCat;
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState<CSSProperties>({}); const [zoom, setZoom] = useState<CSSProperties>({});
const [mounted, setMounted] = useState(app !== null); const [mounted, setMounted] = useState(app !== null);
@@ -78,6 +82,8 @@ export function DevicePanel({ agent }: { agent: Agent }) {
setParams({ app: next }); setParams({ app: next });
} }
if (!catalog) return null;
return ( return (
<aside <aside
role="complementary" role="complementary"
@@ -91,7 +97,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
className={`flex h-full flex-col pt-[88px] pb-6 max-md:bg-background max-md:p-0 ${ className={`flex h-full flex-col pt-[88px] pb-6 max-md:bg-background max-md:p-0 ${
device === "full" ? "" : "px-6" device === "full" ? "" : "px-6"
}`} }`}
style={clawThemeStyle(agent)} style={catalog.theme}
> >
{/* The card fills the column at every size (dock anchors to its {/* The card fills the column at every size (dock anchors to its
bottom). Full bleeds edge-to-edge (no inset/radius) so it meets the bottom). Full bleeds edge-to-edge (no inset/radius) so it meets the
@@ -111,12 +117,13 @@ export function DevicePanel({ agent }: { agent: Agent }) {
<span className="flex items-center gap-1.5 text-xs font-medium text-white/90"> <span className="flex items-center gap-1.5 text-xs font-medium text-white/90">
<span <span
aria-hidden aria-hidden
className="size-2 rounded-full bg-rose-500" className="size-2 rounded-full"
style={{ background: catalog.statusColor ?? "#f43f5e" }}
/> />
{`${agent.name}'s Computer`} {catalog.homeLabel}
</span> </span>
</header> </header>
<HomeScreen agentName={agent.name} onOpen={openApp} /> <HomeScreen catalog={catalog} homeLabel={catalog.homeLabel} onOpen={openApp} />
</> </>
) : ( ) : (
<div <div
@@ -146,7 +153,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
) : ( ) : (
<> <>
<span className="flex-1 truncate text-lg font-semibold"> <span className="flex-1 truncate text-lg font-semibold">
{appTitle(app)} {catalog.title(app)}
</span> </span>
<button <button
type="button" type="button"
@@ -160,7 +167,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
)} )}
</header> </header>
<SubHeaderContext.Provider value={setSubHeader}> <SubHeaderContext.Provider value={setSubHeader}>
<AppRouter app={app} agent={agent} /> {catalog.render(app)}
</SubHeaderContext.Provider> </SubHeaderContext.Provider>
</div> </div>
)} )}
+26 -97
View File
@@ -1,104 +1,42 @@
"use client"; "use client";
import Image from "next/image"; import { Plus } from "lucide-react";
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import type { AppId } from "@/lib/url/panel-params"; import type { AppId } from "@/lib/url/panel-params";
import { GradientGlyph } from "@/components/ui/GradientGlyph";
import { APP_ICON, HOME_DOCK, HOME_GRID } from "./appCatalog"; import type { ComputerCatalog } from "./catalog";
import { DashedPlusTile, GlyphTile } from "./tiles";
interface HomeScreenProps { interface HomeScreenProps {
agentName: string; catalog: ComputerCatalog;
homeLabel: string;
/** Receives the tapped tile's rect so the app window can zoom from it. */ /** Receives the tapped tile's rect so the app window can zoom from it. */
onOpen: (app: AppId, origin: DOMRect) => void; onOpen: (app: AppId, origin: DOMRect) => void;
} }
/* WorkClaw's measured white-tile "physical icon" shadow (verified from prod): /* The default tile when the catalog doesn't supply a custom one: the dashed plus
dark hairline + two soft drops + a bright top inset. Reads on the white for "apps", otherwise the coral-gradient glyph on a dark squircle. */
brand tiles. Dark tiles use --shadow-dock-tile (white hairline + grounding function defaultTile(app: AppId, catalog: ComputerCatalog) {
drop + top inset). */ if (app === "apps") return <DashedPlusTile icon={catalog.icon.apps ?? Plus} />;
const TILE_SHADOW = const Icon = catalog.icon[app];
"0 0 0 1px rgba(0,0,0,0.04), 0 1px 1.5px rgba(0,0,0,0.08), 0 4.65px 9.35px rgba(0,0,0,0.07), inset 0 1px 0 rgba(255,255,255,0.5)"; return Icon ? <GlyphTile icon={Icon} /> : <div className="size-14 rounded-2xl bg-[#1f1f1f] shadow-dock-tile" />;
/* The innermost visual tile (56px, 16px radius). Brand tiles use the official
service mark on a white squircle; the rest get the coral-gradient glyph on a
#1f1f1f tile. Icons are 34px (measured). */
function tileSurface(app: AppId) {
if (app === "browser" || app === "slack") {
const src = app === "browser" ? "/services/chrome.svg" : "/services/slack.svg";
return (
<div
className="relative flex size-14 items-center justify-center rounded-2xl bg-white select-none"
style={{ boxShadow: TILE_SHADOW }}
>
<Image src={src} alt="" width={34} height={34} className="pointer-events-none" />
</div>
);
}
if (app === "obsidian") {
// A purple gem tile for the agent's Obsidian-style vault (a faceted-crystal
// glyph on the brand-purple squircle).
return (
<div
className="relative flex size-14 items-center justify-center rounded-2xl select-none"
style={{
background: "linear-gradient(150deg,#a78bfa 0%,#7c3aed 55%,#4c1d95 100%)",
boxShadow: TILE_SHADOW,
}}
>
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M12 2.6 L18.4 8.2 L14.8 21 L9.2 21 L5.6 8.2 Z"
fill="rgba(255,255,255,0.95)"
/>
<path
d="M12 2.6 L12 21 M5.6 8.2 L18.4 8.2 M12 2.6 L9.2 21 M12 2.6 L14.8 21"
stroke="#6d28d9"
strokeWidth="0.7"
opacity="0.5"
/>
</svg>
</div>
);
}
if (app === "apps") {
// Plain plus in the dashed tile (currentColor = white/65), not the coral
// gradient glyph — matches WorkClaw's Add Apps tile.
const Plus = APP_ICON.apps;
return (
<div className="relative flex size-14 items-center justify-center rounded-2xl border border-dashed border-white/40 text-white/65 select-none">
<Plus size={19} strokeWidth={2} />
</div>
);
}
// The Terminal glyph reads bright green (vs the default coral) so it stands out.
// Glyphs are the colored gradient OUTLINE style (not solid-filled).
const green = app === "terminal";
return (
<div className="relative flex size-14 items-center justify-center rounded-2xl bg-[#1f1f1f] shadow-dock-tile select-none">
<GradientGlyph
icon={APP_ICON[app]}
size={34}
from={green ? "#86efac" : undefined}
to={green ? "#22c55e" : undefined}
/>
</div>
);
} }
/* WorkClaw's AppIcon: a 68px cell (8px gap to label) → button (press target, /* WorkClaw's AppIcon: a 68px cell (8px gap to label) → button → fade layer →
scales to 0.92) → positioning context → opacity-fade layer → visual tile. visual tile. The button wraps only the tile; the label sits outside it. */
The button wraps only the tile; the label sits outside it. Used for both the
grid and the dock. */
function Tile({ function Tile({
app, app,
label, label,
onOpen, onOpen,
catalog,
}: { }: {
app: AppId; app: AppId;
label: string; label: string;
onOpen: (app: AppId, origin: DOMRect) => void; onOpen: (app: AppId, origin: DOMRect) => void;
catalog: ComputerCatalog;
}) { }) {
const surface = catalog.tile?.(app) ?? defaultTile(app, catalog);
return ( return (
<div className="group relative z-0 flex w-[68px] flex-col items-center gap-2 hover:z-20 focus-within:z-20"> <div className="group relative z-0 flex w-[68px] flex-col items-center gap-2 hover:z-20 focus-within:z-20">
<button <button
@@ -110,30 +48,21 @@ function Tile({
className="cursor-pointer transition-transform duration-100 ease-out active:scale-[0.92]" className="cursor-pointer transition-transform duration-100 ease-out active:scale-[0.92]"
> >
<div className="relative size-14 rounded-2xl"> <div className="relative size-14 rounded-2xl">
<div className="absolute inset-0 transition-opacity duration-[120ms] ease-linear"> <div className="absolute inset-0 transition-opacity duration-[120ms] ease-linear">{surface}</div>
{tileSurface(app)}
</div>
</div> </div>
</button> </button>
<span className="block w-full truncate text-center text-[11px] text-white/80"> <span className="block w-full truncate text-center text-[11px] text-white/80">{label}</span>
{label}
</span>
</div> </div>
); );
} }
/** The measured home screen: app grid over the wallpaper, frosted dock. The /** The measured home screen: app grid over the wallpaper, frosted dock. */
* dock is content-width (≈380px) and centered by its w-full wrapper — not export function HomeScreen({ catalog, homeLabel, onOpen }: HomeScreenProps) {
* stretched (verified against prod). */
export function HomeScreen({ agentName, onOpen }: HomeScreenProps) {
return ( return (
<div className="relative flex flex-1 flex-col justify-between pt-5"> <div className="relative flex flex-1 flex-col justify-between pt-5">
<div <div className="grid grid-cols-[repeat(4,72px)] gap-x-4 gap-y-[34px] px-8" aria-label={`${homeLabel} apps`}>
className="grid grid-cols-[repeat(4,72px)] gap-x-4 gap-y-[34px] px-8" {catalog.grid.map((tile) => (
aria-label={`${agentName}'s apps`} <Tile key={tile.app} app={tile.app} label={tile.label} onOpen={onOpen} catalog={catalog} />
>
{HOME_GRID.map((tile) => (
<Tile key={tile.app} app={tile.app} label={tile.label} onOpen={onOpen} />
))} ))}
</div> </div>
<div className="flex w-full items-end justify-center px-3 pb-8"> <div className="flex w-full items-end justify-center px-3 pb-8">
@@ -141,8 +70,8 @@ export function HomeScreen({ agentName, onOpen }: HomeScreenProps) {
aria-label="Dock" aria-label="Dock"
className="flex items-end justify-center gap-5 rounded-3xl bg-white/[0.07] px-6 pt-4 pb-3 shadow-dock-capsule ring-1 ring-white/[0.04] ring-inset backdrop-blur-[40px] backdrop-saturate-150" className="flex items-end justify-center gap-5 rounded-3xl bg-white/[0.07] px-6 pt-4 pb-3 shadow-dock-capsule ring-1 ring-white/[0.04] ring-inset backdrop-blur-[40px] backdrop-saturate-150"
> >
{HOME_DOCK.map((tile) => ( {catalog.dock.map((tile) => (
<Tile key={tile.app} app={tile.app} label={tile.label} onOpen={onOpen} /> <Tile key={tile.app} app={tile.app} label={tile.label} onOpen={onOpen} catalog={catalog} />
))} ))}
</div> </div>
</div> </div>
@@ -0,0 +1,84 @@
"use client";
// A reusable placeholder screen for the infra computer's apps (AWS/GCP/Azure/…)
// — provider header, blurb, a stub detail list, and a connect CTA. Real provider
// consoles get whittled in later; for now every infra app is one of these.
import type { LucideIcon } from "lucide-react";
const mono = "'Geist Mono', ui-monospace, monospace";
export function InfraApp({
icon: Icon,
accent,
title,
blurb,
rows,
cta,
}: {
icon: LucideIcon;
accent: string;
title: string;
blurb: string;
rows?: { k: string; v: string }[];
cta?: string;
}) {
return (
<div style={{ padding: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 14 }}>
<span
style={{
width: 42,
height: 42,
borderRadius: 12,
background: `${accent}22`,
border: `1px solid ${accent}55`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: accent,
}}
>
<Icon size={21} />
</span>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>{title}</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#6a6a72", marginTop: 2 }}>not connected · coming soon</div>
</div>
</div>
<p style={{ fontSize: 13, color: "#9a9aa2", lineHeight: 1.55, marginBottom: 16 }}>{blurb}</p>
{rows?.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{rows.map((r) => (
<div
key={r.k}
style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 11px", borderRadius: 10, background: "#101014", border: "1px solid rgba(255,255,255,.06)" }}
>
<span style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".08em", color: "#6a6a72", width: 88 }}>{r.k}</span>
<span style={{ fontSize: 13, color: "#cfcfd5" }}>{r.v}</span>
</div>
))}
</div>
) : null}
<button
type="button"
style={{
marginTop: 18,
width: "100%",
padding: "11px 0",
borderRadius: 11,
border: `1px solid ${accent}66`,
background: `${accent}16`,
color: accent,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
{cta ?? `Connect ${title}`}
</button>
</div>
);
}
export default InfraApp;
@@ -0,0 +1,31 @@
// A swappable app catalog for the computer pull-out. The chrome (DevicePanel +
// HomeScreen + the app-window header) is identical across catalogs; only the app
// set + their rendering differ — so the agent computer and the infra computer
// share one device, with different apps inside.
import type { LucideIcon } from "lucide-react";
import type { CSSProperties, ReactNode } from "react";
import type { AppId } from "@/lib/url/panel-params";
export interface ComputerCatalog {
/** Home-screen grid tiles (top), in order. */
grid: { app: AppId; label: string }[];
/** Dock tiles (bottom), in order. */
dock: { app: AppId; label: string }[];
/** Lucide glyph per app id (the default home tile). */
icon: Record<string, LucideIcon>;
/** Optional custom home tile for an app (brand mark, lettermark, …); null →
* fall back to the default gradient-glyph tile. */
tile?: (app: AppId) => ReactNode | null;
/** The app-window title. */
title: (app: AppId) => string;
/** The app body for an open app. */
render: (app: AppId) => ReactNode;
/** Label shown in the home header (e.g. "Ada's Computer"). */
homeLabel: string;
/** Theme CSS vars for the device card (wallpaper, accents). */
theme?: CSSProperties;
/** Status dot color on the home header (defaults to rose). */
statusColor?: string;
}
@@ -0,0 +1,34 @@
// The agent computer's app catalog — today's behaviour, expressed as a catalog.
import { Terminal } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import type { AppId } from "@/lib/url/panel-params";
import { APP_ICON, HOME_DOCK, HOME_GRID } from "../appCatalog";
import { AppRouter, appTitle } from "../AppRouter";
import type { ComputerCatalog } from "../catalog";
import { clawThemeStyle } from "../ClawTheme";
import { BrandTile, GemTile, GlyphTile } from "../tiles";
function agentTile(app: AppId) {
if (app === "browser") return <BrandTile src="/services/chrome.svg" />;
if (app === "slack") return <BrandTile src="/services/slack.svg" />;
if (app === "obsidian") return <GemTile />;
if (app === "terminal") return <GlyphTile icon={Terminal} from="#86efac" to="#22c55e" />;
return null; // everything else → HomeScreen's default tile
}
export function agentCatalog(agent: Agent): ComputerCatalog {
return {
grid: HOME_GRID,
dock: HOME_DOCK,
icon: APP_ICON,
tile: agentTile,
title: appTitle,
render: (app) => <AppRouter app={app} agent={agent} />,
homeLabel: `${agent.name}'s Computer`,
theme: clawThemeStyle(agent),
statusColor: "#f43f5e", // rose-500
};
}
@@ -0,0 +1,84 @@
// The infra computer's app catalog — the IDENTICAL device chrome as the agent
// computer, but its apps are cloud providers + infra system apps. Tiles are
// original colored lettermarks (not the providers' trademarked logos).
import { Activity, Cloud, Plus, Server, Settings } from "lucide-react";
import type { CSSProperties } from "react";
import type { AppId } from "@/lib/url/panel-params";
import { InfraApp } from "../apps/infra/InfraApp";
import type { ComputerCatalog } from "../catalog";
import { LetterTile } from "../tiles";
const TITLES: Record<string, string> = {
aws: "AWS",
gcp: "Google Cloud",
azure: "Microsoft Azure",
hosts: "Hosts",
status: "Fleet Status",
settings: "Settings",
apps: "Add Cloud",
};
const notConnected = [
{ k: "ACCOUNT", v: "— not connected" },
{ k: "REGIONS", v: "—" },
{ k: "INSTANCES", v: "—" },
];
function render(app: AppId) {
switch (app) {
case "aws":
return <InfraApp icon={Cloud} accent="#ec7211" title="AWS" blurb="Connect an AWS account to run agents on EC2, ECS / Fargate and Lambda across your regions." rows={notConnected} />;
case "gcp":
return <InfraApp icon={Cloud} accent="#1a73e8" title="Google Cloud" blurb="Connect a GCP project to run agents on Compute Engine, GKE and Cloud Run." rows={notConnected} />;
case "azure":
return <InfraApp icon={Cloud} accent="#0078d4" title="Microsoft Azure" blurb="Connect an Azure subscription to run agents on VMs, AKS and Container Apps." rows={notConnected} />;
case "hosts":
return (
<InfraApp
icon={Server}
accent="#5ec8d8"
title="Hosts"
blurb="The machines connected to your fleet. Agent containers are placed onto these nodes."
rows={[{ k: "local", v: "gw-04 · online" }]}
cta="Connect a host"
/>
);
case "status":
return <InfraApp icon={Activity} accent="#5fd08a" title="Fleet Status" blurb="Capacity, health and live container placement across all of your nodes." cta="Open status" />;
case "settings":
return <InfraApp icon={Settings} accent="#9a9aa2" title="Settings" blurb="Infrastructure preferences — default placement, quotas and provider credentials." cta="Edit settings" />;
case "apps":
return <InfraApp icon={Plus} accent="#ff8a7a" title="Add Cloud" blurb="Connect another provider — DigitalOcean, on-prem hardware, a Kubernetes cluster, and more." cta="Browse providers" />;
default:
return null;
}
}
export const INFRA_CATALOG: ComputerCatalog = {
grid: [
{ app: "aws", label: "AWS" },
{ app: "gcp", label: "GCP" },
{ app: "azure", label: "Azure" },
{ app: "apps", label: "Add Cloud" },
],
dock: [
{ app: "hosts", label: "Hosts" },
{ app: "status", label: "Status" },
{ app: "settings", label: "Settings" },
],
icon: { hosts: Server, status: Activity, settings: Settings, apps: Plus, aws: Cloud, gcp: Cloud, azure: Cloud },
tile: (app) => {
if (app === "aws") return <LetterTile text="AWS" gradient="linear-gradient(150deg,#ff9d3c 0%,#ec7211 55%,#a8430a 100%)" />;
if (app === "gcp") return <LetterTile text="GCP" gradient="linear-gradient(150deg,#5b9bff 0%,#1a73e8 55%,#0b3d91 100%)" />;
if (app === "azure") return <LetterTile text="Az" gradient="linear-gradient(150deg,#46b6ff 0%,#0078d4 55%,#004e8c 100%)" />;
return null; // hosts/status/settings/apps → default glyph / dashed plus
},
title: (app) => TITLES[app] ?? "Cloud",
render,
homeLabel: "Cloud Infrastructure",
theme: { ["--agent-wallpaper" as string]: "linear-gradient(135deg, #0f1830 0%, #18243f 100%)" } as CSSProperties,
statusColor: "#5ec8d8",
};
@@ -0,0 +1,79 @@
// Home-screen app tiles, shared by every computer catalog (agent + infra) so the
// device chrome is identical across them. Extracted verbatim from HomeScreen.
import Image from "next/image";
import type { LucideIcon } from "lucide-react";
import { GradientGlyph } from "@/components/ui/GradientGlyph";
/* WorkClaw's measured white-tile "physical icon" shadow (verified from prod). */
export const TILE_SHADOW =
"0 0 0 1px rgba(0,0,0,0.04), 0 1px 1.5px rgba(0,0,0,0.08), 0 4.65px 9.35px rgba(0,0,0,0.07), inset 0 1px 0 rgba(255,255,255,0.5)";
/** A brand mark on a white squircle (browser/slack). */
export function BrandTile({ src }: { src: string }) {
return (
<div
className="relative flex size-14 items-center justify-center rounded-2xl bg-white select-none"
style={{ boxShadow: TILE_SHADOW }}
>
<Image src={src} alt="" width={34} height={34} className="pointer-events-none" />
</div>
);
}
/** The coral- (or custom-) gradient glyph on a dark squircle. */
export function GlyphTile({ icon, from, to }: { icon: LucideIcon; from?: string; to?: string }) {
return (
<div className="relative flex size-14 items-center justify-center rounded-2xl bg-[#1f1f1f] shadow-dock-tile select-none">
<GradientGlyph icon={icon} size={34} from={from} to={to} />
</div>
);
}
/** Plain plus in a dashed tile (the "Add Apps" tile). */
export function DashedPlusTile({ icon: Plus }: { icon: LucideIcon }) {
return (
<div className="relative flex size-14 items-center justify-center rounded-2xl border border-dashed border-white/40 text-white/65 select-none">
<Plus size={19} strokeWidth={2} />
</div>
);
}
/** The Obsidian vault's purple faceted-gem tile. */
export function GemTile() {
return (
<div
className="relative flex size-14 items-center justify-center rounded-2xl select-none"
style={{ background: "linear-gradient(150deg,#a78bfa 0%,#7c3aed 55%,#4c1d95 100%)", boxShadow: TILE_SHADOW }}
>
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M12 2.6 L18.4 8.2 L14.8 21 L9.2 21 L5.6 8.2 Z" fill="rgba(255,255,255,0.95)" />
<path
d="M12 2.6 L12 21 M5.6 8.2 L18.4 8.2 M12 2.6 L9.2 21 M12 2.6 L14.8 21"
stroke="#6d28d9"
strokeWidth="0.7"
opacity="0.5"
/>
</svg>
</div>
);
}
/** A colored squircle with a short lettermark — for brands we don't ship a logo
* for (e.g. cloud providers; original, not a trademarked logo). */
export function LetterTile({ text, gradient }: { text: string; gradient: string }) {
return (
<div
className="relative flex size-14 items-center justify-center rounded-2xl select-none"
style={{ background: gradient, boxShadow: TILE_SHADOW }}
>
<span
className="font-bold text-white"
style={{ fontSize: text.length > 2 ? 12 : 17, letterSpacing: ".02em" }}
>
{text}
</span>
</div>
);
}
@@ -25,6 +25,8 @@ import { ObservePanel } from "../observe/ObservePanel";
import { StructureTree, orgNode, clawNode, type TreeItem } from "./StructureTree"; import { StructureTree, orgNode, clawNode, type TreeItem } from "./StructureTree";
import { UserMenu } from "./UserMenu"; import { UserMenu } from "./UserMenu";
import { ToolPanel, type ToolKey } from "./ToolPanel"; import { ToolPanel, type ToolKey } from "./ToolPanel";
import { InfraSidebar, InfraStage, InfraConsole, INFRA_CATS } from "./InfraStage";
import { INFRA_CATALOG } from "@/components/computer/catalogs/infra";
import { ClawChatSection } from "./ClawChatSection"; import { ClawChatSection } from "./ClawChatSection";
import { ResizableSplit } from "./ResizableSplit"; import { ResizableSplit } from "./ResizableSplit";
import { VitalsCard } from "./VitalsCard"; import { VitalsCard } from "./VitalsCard";
@@ -43,7 +45,7 @@ import { DeviceSizeToggle } from "@/components/computer/DeviceSizeToggle";
const COMPUTER_WIDTH: Record<DeviceSize, string> = { phone: "448px", tablet: "67%", full: "100%" }; const COMPUTER_WIDTH: Record<DeviceSize, string> = { phone: "448px", tablet: "67%", full: "100%" };
type Tier = "world" | "claw"; type Tier = "world" | "claw" | "infra";
const mono = "'JetBrains Mono', ui-monospace, monospace"; const mono = "'JetBrains Mono', ui-monospace, monospace";
// Gradient palette for structure nodes that don't carry their own (companies, // Gradient palette for structure nodes that don't carry their own (companies,
@@ -80,9 +82,10 @@ const COMPANY_TEMPLATES: Template[] = [
const railIcon: Record<Tier, React.ReactNode> = { const railIcon: Record<Tier, React.ReactNode> = {
world: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="3.5" r="1.9" fill="currentColor" /><circle cx="3.8" cy="11" r="1.9" fill="currentColor" /><circle cx="16.2" cy="11" r="1.9" fill="currentColor" /><circle cx="10" cy="16.5" r="1.9" fill="currentColor" /><path d="M10 3.5 L3.8 11 M10 3.5 L16.2 11 M3.8 11 L10 16.5 M16.2 11 L10 16.5" stroke="currentColor" strokeWidth="1.1" opacity=".5" /></svg>), world: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="3.5" r="1.9" fill="currentColor" /><circle cx="3.8" cy="11" r="1.9" fill="currentColor" /><circle cx="16.2" cy="11" r="1.9" fill="currentColor" /><circle cx="10" cy="16.5" r="1.9" fill="currentColor" /><path d="M10 3.5 L3.8 11 M10 3.5 L16.2 11 M3.8 11 L10 16.5 M16.2 11 L10 16.5" stroke="currentColor" strokeWidth="1.1" opacity=".5" /></svg>),
claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>), claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>),
infra: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="3.5" y="4" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><rect x="3.5" y="11" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="6.4" cy="6.5" r="1" fill="currentColor" /><circle cx="6.4" cy="13.5" r="1" fill="currentColor" /></svg>),
}; };
const TIER_TABS: { key: Tier; label: string }[] = [ const TIER_TABS: { key: Tier; label: string }[] = [
{ key: "world", label: "WORLD" }, { key: "claw", label: "AGENT" }, { key: "world", label: "WORLD" }, { key: "claw", label: "AGENT" }, { key: "infra", label: "INFRA" },
]; ];
const companyIcon = (size: number) => ( const companyIcon = (size: number) => (
<svg width={size} height={size} viewBox="0 0 20 20"><rect x="3.5" y="5" width="6" height="11" rx="1" stroke="currentColor" strokeWidth="1.4" fill="none" /><rect x="10.5" y="2.5" width="6" height="13.5" rx="1" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg> <svg width={size} height={size} viewBox="0 0 20 20"><rect x="3.5" y="5" width="6" height="11" rx="1" stroke="currentColor" strokeWidth="1.4" fill="none" /><rect x="10.5" y="2.5" width="6" height="13.5" rx="1" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>
@@ -188,6 +191,8 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
// Large World right slide-out (sized like the agent computer: phone/tablet/full). // Large World right slide-out (sized like the agent computer: phone/tablet/full).
const [worldPanelOpen, setWorldPanelOpen] = useState(false); const [worldPanelOpen, setWorldPanelOpen] = useState(false);
const [worldSize, setWorldSize] = useState<DeviceSize>("phone"); const [worldSize, setWorldSize] = useState<DeviceSize>("phone");
// Infrastructure tier: which category is selected in the left list.
const [infraSel, setInfraSel] = useState<string | null>("local");
const org: DemoOrg = findOrg(orgId) ?? fallbackOrg; const org: DemoOrg = findOrg(orgId) ?? fallbackOrg;
const company: DemoCompany = findCompany(companyId) ?? org.companies[0] ?? EMPTY_ORG.companies[0]; const company: DemoCompany = findCompany(companyId) ?? org.companies[0] ?? EMPTY_ORG.companies[0];
@@ -320,7 +325,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
try { localStorage.setItem("cm.claw.computerOpen", app !== null ? "1" : "0"); localStorage.setItem("cm.claw.device", device); } catch { /* no storage */ } try { localStorage.setItem("cm.claw.computerOpen", app !== null ? "1" : "0"); localStorage.setItem("cm.claw.device", device); } catch { /* no storage */ }
}, [tier, app, device]); }, [tier, app, device]);
const computerOpen = app !== null; const computerOpen = app !== null;
const clawFull = tier === "claw" && computerOpen && device === "full"; const clawFull = (tier === "claw" || tier === "infra") && computerOpen && device === "full";
const [orgTopo, setOrgTopo] = useState<string>(org.topology); const [orgTopo, setOrgTopo] = useState<string>(org.topology);
const [topoOrg, setTopoOrg] = useState(orgId); const [topoOrg, setTopoOrg] = useState(orgId);
if (topoOrg !== orgId) { setTopoOrg(orgId); setOrgTopo((findOrg(orgId) ?? org).topology); } if (topoOrg !== orgId) { setTopoOrg(orgId); setOrgTopo((findOrg(orgId) ?? org).topology); }
@@ -439,7 +444,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
const [reapOpen, setReapOpen] = useState(false); const [reapOpen, setReapOpen] = useState(false);
const [toolOpen, setToolOpen] = useState<ToolKey | null>(null); const [toolOpen, setToolOpen] = useState<ToolKey | null>(null);
const isWorld = tier === "world", isClaw = tier === "claw"; const isWorld = tier === "world", isClaw = tier === "claw", isInfra = tier === "infra";
const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents))); const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents)));
// World: the full expandable org→company→team→agent forest. Agents page: a flat list. // World: the full expandable org→company→team→agent forest. Agents page: a flat list.
@@ -491,6 +496,8 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
<span style={crumbStyle(isWorld)} onClick={() => setTier("world")}>Large World</span> <span style={crumbStyle(isWorld)} onClick={() => setTier("world")}>Large World</span>
<span style={{ color: "#3a3a40" }}>/</span> <span style={{ color: "#3a3a40" }}>/</span>
<span style={crumbStyle(isClaw)} onClick={() => setTier("claw")}>Agents</span> <span style={crumbStyle(isClaw)} onClick={() => setTier("claw")}>Agents</span>
<span style={{ color: "#3a3a40" }}>/</span>
<span style={crumbStyle(isInfra)} onClick={() => setTier("infra")}>Infrastructure</span>
</div> </div>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<UserMenu onOpen={setToolOpen} /> <UserMenu onOpen={setToolOpen} />
@@ -533,6 +540,11 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button> <button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
</div> </div>
) : isInfra ? (
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{INFRA_CATS.length} CATEGORIES</div>
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>Infrastructure</div>
</div>
) : ( ) : (
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "flex-start", gap: 8 }}> <div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "flex-start", gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -547,6 +559,10 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
)} )}
{isInfra ? (
<InfraSidebar selected={infraSel} onSelect={setInfraSel} />
) : (
<>
{/* The collapsible org → company → team → agent tree (World) or the flat agents list. */} {/* The collapsible org → company → team → agent tree (World) or the flat agents list. */}
<StructureTree roots={treeRoots} activeId={treeActiveId} autoExpand={treeAutoExpand} onSelectNode={onTreeSelect} selectMode={selectMode} selectLevel={isClaw ? "claw" : "*"} selectedIds={selectedAgents} onToggleSelect={(id) => setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; })} /> <StructureTree roots={treeRoots} activeId={treeActiveId} autoExpand={treeAutoExpand} onSelectNode={onTreeSelect} selectMode={selectMode} selectLevel={isClaw ? "claw" : "*"} selectedIds={selectedAgents} onToggleSelect={(id) => setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; })} />
{selectMode ? ( {selectMode ? (
@@ -562,12 +578,14 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
<button type="button" onClick={() => setAddTeamOpen(true)} style={{ width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 9, border: "1px solid rgba(94,200,216,.35)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", fontSize: 12.5, fontWeight: 600, cursor: "pointer" }}><Users aria-hidden size={14} /> Add to teams</button> <button type="button" onClick={() => setAddTeamOpen(true)} style={{ width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 9, border: "1px solid rgba(94,200,216,.35)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", fontSize: 12.5, fontWeight: 600, cursor: "pointer" }}><Users aria-hidden size={14} /> Add to teams</button>
</div> </div>
) : null} ) : null}
</>
)}
</div> </div>
</> </>
) : null} ) : null}
{/* CANVAS */} {/* CANVAS */}
<div ref={canvasRef} style={{ flex: 1, position: "relative", minWidth: 0, overflow: "hidden", ...(resizing ? { ["--duration-normal" as string]: "0ms" } : {}), ...(isClaw ? { ["--computer-width" as string]: computerOpen ? (customWidth != null ? `${customWidth}px` : COMPUTER_WIDTH[device]) : "0px" } : isWorld ? { ["--world-width" as string]: worldPanelOpen ? COMPUTER_WIDTH[worldSize] : "0px" } : {}) }}> <div ref={canvasRef} style={{ flex: 1, position: "relative", minWidth: 0, overflow: "hidden", ...(resizing ? { ["--duration-normal" as string]: "0ms" } : {}), ...(isClaw || isInfra ? { ["--computer-width" as string]: computerOpen ? (customWidth != null ? `${customWidth}px` : COMPUTER_WIDTH[device]) : "0px" } : isWorld ? { ["--world-width" as string]: worldPanelOpen ? COMPUTER_WIDTH[worldSize] : "0px" } : {}) }}>
{isWorld ? ( {isWorld ? (
<> <>
{/* Graph stage — condensed by the right slide-out's width. */} {/* Graph stage — condensed by the right slide-out's width. */}
@@ -641,6 +659,58 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
) : null} ) : null}
</> </>
) : isInfra ? (
<>
{/* Center: infra overview + console, condensed by the computer's
width — the same template as an agent (anatomy + chat). */}
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--computer-width)", transition: "left var(--duration-normal) var(--ease-app), right var(--duration-normal) var(--ease-app)" }}>
{chatMin ? (
<InfraStage selected={infraSel} />
) : (
<ResizableSplit
defaultRatio={0.62}
minTop={120}
minBottom={120}
top={<InfraStage selected={infraSel} />}
bottom={<InfraConsole onMinimize={() => setChatMin(true)} />}
/>
)}
</div>
{/* Right: the IDENTICAL computer chrome, with cloud-infra apps. */}
<DevicePanel catalog={INFRA_CATALOG} />
{computerOpen ? (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize computer width"
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); setResizing(true); }}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
setCustomWidth(Math.round(Math.max(448, Math.min(rect.width, rect.right - e.clientX))));
}}
onPointerUp={(e) => { e.currentTarget.releasePointerCapture(e.pointerId); setResizing(false); }}
style={{ position: "absolute", top: 0, bottom: 0, right: "var(--computer-width)", width: 10, marginRight: -5, zIndex: 41, cursor: "col-resize", touchAction: "none" }}
>
<div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)", width: 4, height: 46, borderRadius: 3, background: resizing ? "#ff8a7a" : "rgba(255,255,255,.22)" }} />
</div>
) : null}
<div style={{ position: "absolute", top: 14, right: 16, zIndex: 50, display: "flex", alignItems: "center", gap: 8 }}>
{chatMin ? (
<button type="button" aria-label="Open console" title="Console" onClick={() => setChatMin(false)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer" }}><MessageSquare aria-hidden size={19} /></button>
) : null}
{computerOpen ? (
<>
<DeviceSizeToggle value={device} onChange={(d) => { setParams({ device: d }); setCustomWidth(null); }} />
<span style={{ width: 1, height: 18, background: "rgba(255,255,255,.14)" }} />
<button type="button" aria-label="Close computer" onClick={() => setParams({ app: null })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "rgba(8,8,10,.6)", color: "#cfcfd5", cursor: "pointer" }}><X aria-hidden size={16} /></button>
</>
) : (
<button type="button" aria-label="Computer" title="Computer" onClick={() => setParams({ app: "home", device: "phone" })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer" }}><Monitor aria-hidden size={20} /></button>
)}
</div>
</>
) : richAgent && clawAgent ? ( ) : richAgent && clawAgent ? (
<> <>
{/* Far-left: Brain Registry — search panel + results panel + detail {/* Far-left: Brain Registry — search panel + results panel + detail
@@ -0,0 +1,123 @@
"use client";
// The infrastructure tier's content — a left category list, a center overview of
// the infra categories, and a console placeholder (the bottom-split slot mirrors
// the agent chat). Placeholders for now; whittled into real fleet/host UI later.
import { Box, ChevronDown, Cloud, HardDrive, Server, Terminal, type LucideIcon } from "lucide-react";
const mono = "'Geist Mono', ui-monospace, monospace";
export interface InfraCat {
id: string;
icon: LucideIcon;
label: string;
desc: string;
}
export const INFRA_CATS: InfraCat[] = [
{ id: "local", icon: HardDrive, label: "Local hardware", desc: "Run agents on your own machines — Macs, Linux boxes, edge devices." },
{ id: "containers", icon: Box, label: "Containers", desc: "Deploy agent runtimes as Docker / OCI containers." },
{ id: "vms", icon: Server, label: "Virtual machines", desc: "Provision agents on VMs across your fleet." },
{ id: "clouds", icon: Cloud, label: "Clouds", desc: "Connect AWS, GCP and Azure and run at scale." },
];
/** Left sidebar list of infra categories (mirrors the agents list). */
export function InfraSidebar({ selected, onSelect }: { selected: string | null; onSelect: (id: string) => void }) {
return (
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 8, display: "flex", flexDirection: "column", gap: 2 }}>
{INFRA_CATS.map((c) => {
const on = selected === c.id;
return (
<button
key={c.id}
type="button"
onClick={() => onSelect(c.id)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 11px",
borderRadius: 10,
border: `1px solid ${on ? "rgba(255,111,97,.4)" : "transparent"}`,
background: on ? "rgba(255,111,97,.1)" : "transparent",
color: on ? "#f3f3f5" : "#cfcfd5",
cursor: "pointer",
textAlign: "left",
}}
>
<span style={{ width: 30, height: 30, flex: "none", borderRadius: 8, background: "rgba(255,111,97,.1)", border: "1px solid rgba(255,111,97,.2)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff8a7a" }}>
<c.icon size={15} />
</span>
<span style={{ fontSize: 13.5, fontWeight: 600 }}>{c.label}</span>
</button>
);
})}
</div>
);
}
/** Center overview — the infra category cards, highlighting the selected one. */
export function InfraStage({ selected }: { selected: string | null }) {
return (
<div style={{ height: "100%", overflow: "auto", padding: "28px 32px" }}>
<div style={{ maxWidth: 920, margin: "0 auto" }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>INFRASTRUCTURE</div>
<div style={{ fontSize: 24, fontWeight: 800, color: "#f3f3f5", letterSpacing: "-.02em" }}>Your fleet</div>
<p style={{ fontSize: 13.5, color: "#8a8a92", marginTop: 8, marginBottom: 22, lineHeight: 1.5 }}>
Wire your own infrastructure to the platform and run agents wherever you need them — open the computer (top-right) to connect a cloud provider.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", gap: 14 }}>
{INFRA_CATS.map((c) => {
const on = selected === c.id;
return (
<div
key={c.id}
style={{
borderRadius: 14,
background: "#0f0f13",
border: `1px solid ${on ? "rgba(255,111,97,.45)" : "rgba(255,255,255,.08)"}`,
padding: 16,
display: "flex",
flexDirection: "column",
gap: 9,
minHeight: 150,
boxShadow: on ? "0 8px 24px rgba(255,111,97,.12)" : "none",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 38, height: 38, borderRadius: 10, background: "rgba(255,111,97,.1)", border: "1px solid rgba(255,111,97,.25)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff8a7a" }}>
<c.icon size={19} />
</span>
<span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5" }}>{c.label}</span>
</div>
<p style={{ fontSize: 12.5, color: "#9a9aa2", lineHeight: 1.5, margin: 0 }}>{c.desc}</p>
<div style={{ marginTop: "auto", paddingTop: 6 }}>
<span style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".1em", color: "#e8b465", border: "1px solid rgba(232,180,101,.35)", background: "rgba(232,180,101,.08)", padding: "2px 7px", borderRadius: 5 }}>COMING SOON</span>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
/** Bottom-split console placeholder (mirrors the agent chat section). */
export function InfraConsole({ onMinimize }: { onMinimize: () => void }) {
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column", background: "#0b0b0e", borderTop: "1px solid rgba(255,255,255,.07)" }}>
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 8, padding: "10px 14px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
<Terminal aria-hidden size={15} style={{ color: "#5ec8d8" }} />
<span style={{ fontFamily: mono, fontSize: 11, letterSpacing: ".08em", color: "#cfcfd5", flex: 1 }}>INFRA CONSOLE</span>
<button type="button" onClick={onMinimize} title="Minimize" aria-label="Minimize console" style={{ width: 28, height: 28, borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ChevronDown aria-hidden size={16} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
<span style={{ fontFamily: mono, fontSize: 12, color: "#3a3a40", textAlign: "center", lineHeight: 1.6 }}>Provisioning + fleet commands will run here.</span>
</div>
</div>
);
}
+6
View File
@@ -32,6 +32,12 @@ export const APP_IDS = [
"routines", "routines",
"settings", "settings",
"apps", "apps",
// Infra computer apps (cloud providers + infra system apps).
"aws",
"gcp",
"azure",
"hosts",
"status",
] as const; ] as const;
export type AppId = (typeof APP_IDS)[number]; export type AppId = (typeof APP_IDS)[number];