Dashboard: Infrastructure tier with the agent computer (cloud apps)
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:
co-authored by
Claude Opus 4.8
parent
98a9ec62ab
commit
b853aab6fd
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user