Files
clawmates/frontend/src/components/dashboard/Dashboard.tsx
T
Omar SobhandClaude Opus 5 c59cd9c424
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
feat(viz): the World draws missions, never the org chart
The World seeded the Organization → Company → Team → Agent tree whenever
no mission was pinned — "My Workspace → General → Everyone". That tree
describes almost nothing: `agents` has no org, company or team column,
real membership is the `team_members` join, and four of its containers are
fabricated in the browser and exist in no table.

Worse, it did not replace the mission view, it SHARED the canvas with it.
The plan events are only filtered by id when a mission is pinned, so with
nothing pinned a live mission was drawn on top of the org chart: two
unrelated graphs, both parented at the invisible root, reading as one
scene in which they somehow connected. They never did — there is no edge
between them because there is no relationship in the data to draw.

The World now shows exactly one mission, or none. Three parts:

- the org tree is gone from the canvas seed, and `worldCanvasRoots`,
  `narrowRoots` and `stripSynthetics` with it. The sidebar keeps its
  synthetic containers so orphaned agents still have a visible home.
- the default focus prefers a RUNNING mission over the newest one.
  Newest-first picked whatever was created last, which on a workspace with
  history is a finished mission — so starting a run left the World looking
  at an old static map while the new work went unwatched.
- exactly one mission is focused whenever there is any, which is
  load-bearing rather than cosmetic: the plan channel keeps ONE `planRef`,
  so two missions on the wire overwrite each other's title and phases and
  the scene becomes a blend of two runs that never happened.

Seeding "all missions" was the tempting middle ground and is wrong twice:
/api/workforce returns every mission ever with no limit, which puts
hundreds of agents back on one canvas, and the plan channel cannot hold
more than one anyway.

An empty stage now says so. Blank canvas and broken page look identical,
and filling that silence with a hierarchy that meant nothing is how this
started.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 14:24:47 -07:00

1461 lines
88 KiB
TypeScript

"use client";
// The integrated dashboard. Org → company → team → claw drill-down over the
// example workspace (src/lib/dashboard-demo.ts), with left↔main highlight sync:
// clicking a row in the left list HIGHLIGHTS the matching node in the main
// canvas; clicking the node itself DRILLS one tier deeper. The org/company/team
// canvases are React Flow topology graphs (src/components/dashboard/flow) driven
// by the full categorized topology taxonomy (src/lib/topologies.ts); the claw
// tier shows the agent's anatomy. The right slide-out is per-agent (the claw's
// own computer) at claw tier, and the team's shared apps at team tier.
import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryStates } from "nuqs";
import { Bot, Brain, History, MessageSquare, Monitor, PanelLeftClose, PanelLeftOpen, PanelRight, Trash2, Users, Wrench, X } from "lucide-react";
import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo";
import type { Agent } from "@/lib/api/schemas";
import { topologyById } from "@/lib/topologies";
import { panelParsers, type DeviceSize } from "@/lib/url/panel-params";
import { type FlowItem } from "./flow/TopologyFlow";
import { WorldCanvas } from "../world/WorldCanvas";
import type { WorldSeed } from "../world/engine";
import { ObservePanel } from "../observe/ObservePanel";
import { StructureTree, orgNode, type TreeItem, clawNode } from "./StructureTree";
import { MissionsList } from "./MissionsList";
import { MissionWizard } from "./MissionWizard";
import { LevelUpInbox } from "./LevelUpInbox";
import { MissionCanvas } from "./MissionCanvas";
import { RepoList } from "./RepoList";
import { RepoCanvas } from "./RepoCanvas";
import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub";
import { RepoConnectionEditModal } from "./RepoConnectionEditModal";
import { UserMenu } from "./UserMenu";
import { ToolPanel, type ToolKey } from "./ToolPanel";
import { InfraNav, FleetConsole, FleetStatusBar, FleetPill } from "./fleet/FleetConsole";
import { NodeMonitor } from "./fleet/NodeMonitor";
import { ConnectHostWizard } from "./ConnectHostWizard";
import { INFRA_CATALOG } from "@/components/computer/catalogs/infra";
import { ClawCommandCenter } from "./ClawCommandCenter";
import { MasterPlannerModal } from "./MasterPlannerModal";
import { OrphanMigrationDialog } from "./OrphanMigrationDialog";
import { BrainRegistryPanel } from "./BrainRegistryPanel";
import { BrainHistoryModal } from "./BrainHistoryModal";
import { TeamRunsModal } from "./TeamRunsModal";
import { ReapProgressModal, type ReapKind } from "./ReapProgressModal";
import { AddToCompanyModal } from "./AddToCompanyModal";
import { AddToOrgModal } from "./AddToOrgModal";
import { DevicePanel } from "@/components/computer/DevicePanel";
import { DeviceSizeToggle } from "@/components/computer/DeviceSizeToggle";
const COMPUTER_WIDTH: Record<DeviceSize, string> = { phone: "448px", tablet: "67%", full: "100%" };
const SIDEBAR_COLLAPSE_KEY = "cm.dashboard.sidebarCollapsed";
function subscribeSidebarCollapse(cb: () => void) {
const handler = (e: StorageEvent) => {
if (e.key === SIDEBAR_COLLAPSE_KEY) cb();
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}
function readSidebarCollapse(): boolean {
try {
return localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === "1";
} catch {
return false;
}
}
// Slice 9: research + loops folded into missions. Kept in the enum
// (as unused variants) for one release so any URL/history state that
// still references them doesn't hard-error — the switches below just
// have no branches for them, so the fallthrough renders the missions
// tier.
type Tier = "world" | "missions" | "claw" | "repos" | "infra";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// dashboard-data.ts fabricates a few org/company/team nodes so an empty
// workspace still has something to render. These ids aren't UUIDs and
// don't exist in the DB — treat them as non-selectable for reap.
// Placeholder containers `dashboard-data.ts` fabricates for entities that were
// never parented into a real org → company → team chain. Clicking one offers to
// materialise that chain, because that is the only thing you can do with it.
// They exist only in the sidebar tree. The World canvas no longer seeds the
// org hierarchy at all — see `worldSeedRoots`.
const ORPHAN_CONTAINER_IDS = new Set([
"my-workspace",
"ws-teams",
"ungrouped-co",
"ungrouped-team",
]);
// Every id in the tree that is NOT a database row, orphan containers plus the
// "My Workforce" root. These cannot be renamed or selected for reap — the
// backend would 422 on the non-UUID id.
//
// Kept separate from the set above on purpose: they answer different questions.
// Folding the workforce root into the orphan set made clicking "My Workforce"
// offer to parent everything into an org → company → team chain — the exact
// hierarchy that root exists to replace.
const SYNTHETIC_TREE_IDS = new Set([...ORPHAN_CONTAINER_IDS, "my-workforce"]);
// Mission grouping rows under "My Workforce". Like the workforce root these are
// not database rows the claw/team endpoints understand: a mission-group id
// carries a MISSION uuid, so letting it reach `selectTeam` would look valid and
// fetch the wrong thing entirely.
const isGroupingRow = (id: string) =>
id.startsWith("mission-group:") || id === "workforce-unassigned";
// A claw inside a mission group is keyed `<missionId>:<clawId>` so the same
// colleague can appear under several missions without colliding. Everything
// downstream wants the claw id alone.
const clawIdOf = (treeId: string) => {
const i = treeId.lastIndexOf(":");
return i === -1 ? treeId : treeId.slice(i + 1);
};
export interface WorkforceAgent {
id: string;
name: string;
job_title: string;
role_slot: string | null;
accent: string;
status: string;
}
export interface Workforce {
missions: {
mission_id: string;
title: string;
status: string;
templateKind?: string | null;
agents: WorkforceAgent[];
}[];
unassigned: WorkforceAgent[];
}
// Gradient palette for structure nodes that don't carry their own (companies,
// teams). Agents bring their own grad/ink.
const NODE_GRADS: [string, string][] = [
["linear-gradient(135deg,#ff9a6a,#ff6f4a)", "#2a0d05"],
["linear-gradient(135deg,#6fd0c0,#4aa3b8)", "#06201f"],
["linear-gradient(135deg,#8a9af0,#5a6ad8)", "#0a0e2a"],
["linear-gradient(135deg,#e8c46a,#d89a3a)", "#2a1d05"],
["linear-gradient(135deg,#c98af0,#9a5ad8)", "#1a0a2a"],
];
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>),
// Flag on a pole — missions (unified research + loops).
missions: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M6 3 V21" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /><path d="M6 4 L18 4 L15 8 L18 12 L6 12 Z" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinejoin="round" /></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>),
// Branching lines forking off a trunk — repositories.
repos: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="5" cy="5" r="1.6" fill="currentColor" /><circle cx="5" cy="15" r="1.6" fill="currentColor" /><circle cx="15" cy="10" r="1.6" fill="currentColor" /><path d="M5 6.6 V13.4 M5 10 C5 10 8 10 15 10" stroke="currentColor" strokeWidth="1.4" fill="none" /></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 }[] = [
{ key: "world", label: "VIZ" },
{ key: "missions", label: "MISSIONS" },
{ key: "claw", label: "AGENT" },
{ key: "repos", label: "REPOS" },
{ key: "infra", label: "INFRA" },
];
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>
);
// ── Per-claw live enrichment (fetched on selection) ─────────────────────────
// The tree/list carries a claw's name/role/status/system-prompt; its model,
// installed skills, tools/doors, memory, capabilities, safety + apps come from
// the per-claw endpoints, fetched lazily when a claw is opened.
interface Enrich { model: string; apps: DemoAgent["apps"]; compartments: DemoAgent["compartments"]; brain: RawBrain }
type RawCompartment = { key: string; label: string; items: string[]; count?: number };
type RawRuntime = { model?: string | null; provider_alias?: string; sandbox_enabled?: boolean; network_allowed?: boolean } | null;
type RawApp = { id: string; name: string; category?: string };
// `GET /api/claws/{id}/brain` — the claw's .brain (cm-brain / ClawhDF5).
type RawBrain = {
exists: boolean;
system_prompt: string | null;
agent_md: string | null;
personality: string | null;
skills_md: string | null;
skills: { name: string; body: string }[];
tools: { name: string; state: string }[];
memory: string[];
stats: { skills: number; tools: number; memories: number };
} | null;
function appKind(a: RawApp): DemoAgent["apps"][number]["kind"] {
const s = `${a.name} ${a.category ?? ""}`.toLowerCase();
if (s.includes("slack")) return "slack";
if (s.includes("browser") || s.includes("web")) return "browser";
if (s.includes("chat") || s.includes("message")) return "chat";
if (s.includes("wiki") || s.includes("notion") || s.includes("doc")) return "wiki";
if (s.includes("voice") || s.includes("voip") || s.includes("call")) return "voip";
if (s.includes("cluster") || s.includes("k8s") || s.includes("kube")) return "cluster";
return "files";
}
function buildEnrich(rc: RawRuntime, comps: RawCompartment[], apps: RawApp[], brain: RawBrain): Enrich {
const items = (k: string) => comps.find((c) => c.key === k)?.items ?? [];
const mem = items("memory");
// Prefer the .brain as the source for the cards when it carries data; fall
// back to the DB-derived compartments otherwise.
const b = brain && (brain.skills?.length || brain.tools?.length || brain.system_prompt || brain.memory?.length) ? brain : null;
const brainSkills = b?.skills.map((s) => s.name) ?? [];
const brainTools = b?.tools.map((t) => ({ name: t.name, state: /block/i.test(t.state) ? ("blocked" as const) : ("gated" as const) })) ?? [];
const brainPersona = b?.personality ? b.personality.split(/[,;\n·]| - /).map((s) => s.trim()).filter(Boolean) : [];
return {
model: rc?.model || rc?.provider_alias || "",
apps: apps.map((a) => ({ id: a.id, name: a.name, kind: appKind(a) })),
brain,
compartments: {
skills: brainSkills.length ? brainSkills : items("skills"),
personality: brainPersona.length ? brainPersona : items("personality"),
// With a brain, Capabilities visualizes its contents as a quick stat line.
capabilities: b ? [`${b.stats.skills} skills`, `${b.stats.tools} tools`, `${b.stats.memories} memories`] : items("capabilities"),
tools: brainTools.length ? brainTools : items("tools").map((s) => ({ name: String(s).split(/[·:|]/)[0].trim(), state: /block/i.test(s) ? "blocked" : "gated" })),
memory: { long: mem[0] ?? "—", recent: mem[1] ?? "—" },
safety: { sandbox: rc?.sandbox_enabled === false ? "open" : "isolated", network: rc?.network_allowed ? "allowed" : "none" },
},
};
}
const okJson = (r: Response) => (r.ok ? r.json() : null);
// Keeps org/company/team non-null for a brand-new account with no claws yet.
const EMPTY_ORG: DemoOrg = {
id: "", name: "My Workspace", topology: "flat",
companies: [{ id: "", name: "Direct", topology: "flat", meta: "0 agents", teams: [{ id: "", name: "My Agents", topology: "flat", status: "idle", dot: "#3a3a40", agents: [], groupApps: [] }] }],
};
export function Dashboard({ user, orgs, claws }: { user?: { display_name?: string; email?: string; role?: "owner" | "member" }; orgs: DemoOrg[]; claws: Agent[] }) {
const agentById = new Map(claws.map((a) => [a.id, a]));
// Live-data lookups over the workspace passed from the server (real claws +
// structure). All scoped to `orgs` so the dashboard reflects the real account.
const allCompanies = orgs.flatMap((o) => o.companies.map((c) => ({ org: o, company: c })));
const findOrg = (id: string | null) => orgs.find((o) => o.id === id);
const findCompany = (id: string | null) => allCompanies.find((e) => e.company.id === id)?.company;
const orgOf = (id: string | null) => allCompanies.find((e) => e.company.id === id)?.org;
const findTeam = (cid: string | null, tid: string | null) => findCompany(cid)?.teams.find((t) => t.id === tid);
const findAgent = (cid: string | null, tid: string | null, aid: string | null) => findTeam(cid, tid)?.agents.find((a) => a.id === aid);
const locateAgent = (aid: string | null) => {
for (const o of orgs) for (const c of o.companies) for (const t of c.teams) {
const a = t.agents.find((x) => x.id === aid);
if (a) return { org: o, company: c, team: t, agent: a };
}
return undefined;
};
const locateTeam = (tid: string | null) => {
for (const o of orgs) for (const c of o.companies) {
const t = c.teams.find((x) => x.id === tid);
if (t) return { org: o, company: c, team: t };
}
return undefined;
};
const fallbackOrg = orgs[0] ?? EMPTY_ORG;
const [tier, setTier] = useState<Tier>("claw");
// Collapse the ~252px context sidebar (ResearchList / LoopsList /
// etc.) into a thin 32px rail so the canvas gets the extra width.
// Persisted in localStorage via useSyncExternalStore — SSR returns
// false (matches initial client render), subsequent state changes
// flow through the storage event.
const sidebarCollapsed = useSyncExternalStore(
subscribeSidebarCollapse,
readSidebarCollapse,
() => false,
);
function toggleSidebar() {
const next = !readSidebarCollapse();
try {
localStorage.setItem("cm.dashboard.sidebarCollapsed", next ? "1" : "0");
// Same-tab writes don't fire native storage events — nudge subscribers.
window.dispatchEvent(
new StorageEvent("storage", { key: "cm.dashboard.sidebarCollapsed" }),
);
} catch {
/* ignore */
}
}
const [orgId, setOrgId] = useState<string>(fallbackOrg.id);
const [companyId, setCompanyId] = useState<string>(fallbackOrg.companies[0]?.id ?? "");
const [teamId, setTeamId] = useState<string>(fallbackOrg.companies[0]?.teams[0]?.id ?? "");
const [agentId, setAgentId] = useState<string>(fallbackOrg.companies[0]?.teams[0]?.agents[0]?.id ?? "");
// Large World: the selected node + which nodes are expanded in the graph.
const [worldSel, setWorldSel] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(orgs.map((o) => o.id)));
// Large World right slide-out (sized like the agent computer: phone/tablet/full).
const [worldPanelOpen, setWorldPanelOpen] = useState(false);
const [worldSize, setWorldSize] = useState<DeviceSize>("phone");
// Slice 9: research + loops tier state removed with the file deletions.
// Missions state below is the unified surface.
// Repos tier: selected repo + refresh key + wizard modal flag.
const [repoSel, setRepoSel] = useState<string | null>(null);
const [repoRefresh, setRepoRefresh] = useState(0);
const [repoWizardOpen, setRepoWizardOpen] = useState(false);
const [repoEditId, setRepoEditId] = useState<string | null>(null);
// Infrastructure tier: which nav view (local / cloud) + the connect-host wizard.
const [infraSel, setInfraSel] = useState<string | null>("local");
const [infraConnectOpen, setInfraConnectOpen] = useState(false);
// When set, the infra center shows the full-page monitor for this node.
const [monitorNode, setMonitorNode] = useState<{ id: string; name: string } | null>(null);
const org: DemoOrg = findOrg(orgId) ?? fallbackOrg;
const company: DemoCompany = findCompany(companyId) ?? org.companies[0] ?? EMPTY_ORG.companies[0];
const team: DemoTeam = findTeam(companyId, teamId) ?? company.teams[0] ?? EMPTY_ORG.companies[0].teams[0];
const agent: DemoAgent | undefined = findAgent(companyId, teamId, agentId) ?? team.agents[0];
// After creating a claw the wizard sends us to /?claw=<id>; open it here so a
// freshly-created claw lands selected on the claw page in the new interface.
const params = useSearchParams();
const clawParam = params.get("claw");
const [seenClaw, setSeenClaw] = useState<string | null>(null);
if (clawParam && clawParam !== seenClaw) {
setSeenClaw(clawParam);
const loc = locateAgent(clawParam);
if (loc) {
setOrgId(loc.org.id); setCompanyId(loc.company.id); setTeamId(loc.team.id); setAgentId(clawParam); setTier("claw");
}
}
// After creating a team (Add to teams), we land on /?team=<id>; select it and
// open the team page once the refreshed workspace data includes it.
const teamParam = params.get("team");
const [seenTeam, setSeenTeam] = useState<string | null>(null);
if (teamParam && teamParam !== seenTeam) {
const loc = locateTeam(teamParam);
if (loc) {
setSeenTeam(teamParam);
setOrgId(loc.org.id); setCompanyId(loc.company.id); setTeamId(teamParam); setAgentId(loc.team.agents[0]?.id ?? "");
setTier("world"); setWorldSel(teamParam); setExpanded((p) => new Set([...p, loc.org.id, loc.company.id, teamParam]));
}
}
// After creating a company (Add to company), land on /?company=<id>.
const companyParam = params.get("company");
const [seenCompany, setSeenCompany] = useState<string | null>(null);
if (companyParam && companyParam !== seenCompany) {
const e = allCompanies.find((x) => x.company.id === companyParam);
if (e) {
setSeenCompany(companyParam);
setOrgId(e.org.id); setCompanyId(companyParam); setTeamId(e.company.teams[0]?.id ?? "");
setTier("world"); setWorldSel(companyParam); setExpanded((p) => new Set([...p, e.org.id, companyParam]));
}
}
// After creating an org (Add to organization), land on /?org=<id>.
const orgParam = params.get("org");
const [seenOrg, setSeenOrg] = useState<string | null>(null);
if (orgParam && orgParam !== seenOrg) {
const o = findOrg(orgParam);
if (o) {
setSeenOrg(orgParam);
setOrgId(orgParam); setCompanyId(o.companies[0]?.id ?? "");
setTier("world"); setWorldSel(orgParam); setExpanded((p) => new Set([...p, orgParam]));
}
}
// Grouping modals: claws→team, teams→company, companies→org.
const [addCompanyOpen, setAddCompanyOpen] = useState(false);
const [addOrgOpen, setAddOrgOpen] = useState(false);
// Lazily enrich the open claw with its real model / skills / tools / apps.
const [enrichById, setEnrichById] = useState<Record<string, Enrich>>({});
// Bump to force a re-fetch (e.g. after adding a tool/skill to the claw).
const [enrichBump, setEnrichBump] = useState(0);
const enrichId = tier === "claw" && agent ? agent.id : null;
useEffect(() => {
if (!enrichId) return;
let alive = true;
Promise.all([
fetch(`/api/claws/${enrichId}/runtime-config`, { cache: "no-store" }).then(okJson).catch(() => null),
fetch(`/api/claws/${enrichId}/compartments`, { cache: "no-store" }).then(okJson).catch(() => null),
fetch(`/api/apps?clawId=${enrichId}`, { cache: "no-store" }).then(okJson).catch(() => null),
fetch(`/api/claws/${enrichId}/brain`, { cache: "no-store" }).then(okJson).catch(() => null),
]).then(([rc, comps, apps, brain]) => {
if (alive) setEnrichById((prev) => ({ ...prev, [enrichId]: buildEnrich(rc as RawRuntime, (comps as RawCompartment[]) ?? [], (apps as RawApp[]) ?? [], brain as RawBrain) }));
});
return () => { alive = false; };
}, [enrichId, enrichBump]);
// The claw shown at the claw tier, merged with any fetched enrichment.
const richAgent: DemoAgent | undefined = agent
? (() => { const e = enrichById[agent.id]; return e ? { ...agent, model: e.model || agent.model, apps: e.apps, compartments: e.compartments, systemPrompt: e.brain?.system_prompt || agent.systemPrompt } : agent; })()
: undefined;
// The selected claw's .brain payload (stats + recent memory) for the cards.
const richBrain: RawBrain = agent ? (enrichById[agent.id]?.brain ?? null) : null;
// The REAL backend Agent for the selected claw — drives the computer + chat.
const clawAgent: Agent | undefined =
(agent ? agentById.get(agent.id) : undefined) ??
(agent ? { id: agent.id, workspace_id: "", name: agent.name, job_title: agent.role, system_prompt: agent.systemPrompt, avatar: "", accent: "#ff6f61", wallpaper: "", managed_by: "", status: "online" } : undefined);
// Computer panel state (size + open app), shared with DevicePanel via nuqs.
const [{ app, device }, setParams] = useQueryStates(panelParsers, { shallow: true });
// Custom computer width (px) from dragging the panel's left edge; null = use the
// preset (phone/tablet/full). A size-toggle click clears it back to the preset.
const [customWidth, setCustomWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// Claw-page UI preferences persist across navigation AND reloads (localStorage):
// the chat's collapsed state and the computer's open/size. The dashboard stays
// mounted while you move between tiers, so in-memory state already survives
// navigation; localStorage extends that across reloads and is the source of
// truth applied on first entry to the claw tier.
const [chatMin, setChatMin] = useState(false);
const prefsLoadedRef = useRef(false);
const computerInitRef = useRef(false);
useEffect(() => {
// One-time sync from the persisted store (render default first, then adopt
// the saved value after mount — avoids an SSR/hydration mismatch).
let v: string | null = null;
try { v = localStorage.getItem("cm.claw.chatMin"); } catch { /* no storage */ }
prefsLoadedRef.current = true;
if (v != null) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setChatMin(v === "1");
}
}, []);
// First time we land on the claw tier, apply the saved computer open/size.
useEffect(() => {
if (tier !== "claw" || computerInitRef.current) return;
computerInitRef.current = true;
let open = true;
let dev: DeviceSize = "phone";
try {
const o = localStorage.getItem("cm.claw.computerOpen"); if (o != null) open = o === "1";
const d = localStorage.getItem("cm.claw.device"); if (d === "phone" || d === "tablet" || d === "full") dev = d;
} catch { /* no storage */ }
setParams({ app: open ? "home" : null, device: dev });
}, [tier, setParams]);
// Persist on change.
useEffect(() => { if (prefsLoadedRef.current) try { localStorage.setItem("cm.claw.chatMin", chatMin ? "1" : "0"); } catch { /* no storage */ } }, [chatMin]);
useEffect(() => {
if (tier !== "claw" || !computerInitRef.current) return;
try { localStorage.setItem("cm.claw.computerOpen", app !== null ? "1" : "0"); localStorage.setItem("cm.claw.device", device); } catch { /* no storage */ }
}, [tier, app, device]);
const computerOpen = app !== null;
const clawFull = (tier === "claw" || tier === "infra") && computerOpen && device === "full";
const [orgTopo, setOrgTopo] = useState<string>(org.topology);
const [topoOrg, setTopoOrg] = useState(orgId);
if (topoOrg !== orgId) { setTopoOrg(orgId); setOrgTopo((findOrg(orgId) ?? org).topology); }
// Selecting anywhere in the tree keeps the whole org→company→team→claw path
// coherent (breadcrumb, graphs, and the drilled views all point at real
// ancestors), no matter which tier the tree is rooted at.
const selectOrg = (id: string) => {
const o = findOrg(id) ?? org;
const c = o.companies[0];
setOrgId(id); setCompanyId(c?.id ?? ""); setTeamId(c?.teams[0]?.id ?? ""); setAgentId(c?.teams[0]?.agents[0]?.id ?? "");
};
const selectCompany = (id: string) => {
const co = findCompany(id) ?? company;
setOrgId(orgOf(id)?.id ?? orgId); setCompanyId(id); setTeamId(co.teams[0]?.id ?? ""); setAgentId(co.teams[0]?.agents[0]?.id ?? "");
};
const selectTeam = (id: string) => {
const loc = locateTeam(id);
if (loc) { setOrgId(loc.org.id); setCompanyId(loc.company.id); }
setTeamId(id); setAgentId((loc?.team ?? team).agents[0]?.id ?? "");
};
// Open a claw from anywhere in the tree: resolve + set its full path, drill in.
const openClaw = (id: string) => {
const loc = locateAgent(id);
if (loc) { setOrgId(loc.org.id); setCompanyId(loc.company.id); setTeamId(loc.team.id); }
setAgentId(id); setTier("claw");
};
// Large World graph helpers.
const toggleExpand = (id: string) => setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
// Expand a node + all its ancestors so it's visible in the graph.
const expandPathTo = (id: string) => {
const path: string[] = [];
const find = (n: TreeItem, trail: string[]): boolean => {
const t = [...trail, n.id];
if (n.id === id) { path.push(...t); return true; }
return (n.children ?? []).some((c) => find(c, t));
};
worldRoots.some((r) => find(r, []));
if (path.length) setExpanded((prev) => new Set([...prev, ...path]));
};
// Selecting a non-agent node in the graph: highlight + keep the path ids coherent.
// Empty string is treated as "clear the focus" so ESC in WorldCanvas can
// exit repo detail mode without needing a separate callback.
const onWorldSelect = (id: string) => {
if (!id) { setWorldSel(null); return; }
setWorldSel(id);
// A mission grouping row carries no level and is not a row in any of the
// org tables — it only sets the focus, which `focusedMissionId` derives
// from `worldSel`.
if (isGroupingRow(id)) return;
const lv = nodeLevel.get(id);
if (lv === "claw") {
// Agents open a summary in the side panel (no redirect — the panel's robot
// button drills in). Keep the path ids coherent for the summary + drill.
// The tree id may be `<missionId>:<clawId>`; every consumer below wants
// the claw id alone.
const clawId = clawIdOf(id);
const loc = locateAgent(clawId);
if (loc) { setOrgId(loc.org.id); setCompanyId(loc.company.id); setTeamId(loc.team.id); }
setAgentId(clawId);
setWorldPanelOpen(true);
} else if (lv === "org") selectOrg(id);
else if (lv === "company") selectCompany(id);
else if (lv === "team") selectTeam(id);
};
// Clicking a synthetic scaffolding node ("my-workspace" / "ws-teams" /
// "ungrouped-co" / "ungrouped-team") opens the migration dialog instead of
// navigating — those nodes have no real DB row to select. The "My Workforce"
// root is synthetic too but is NOT one of those: it is the flat tree's
// heading, and offering to parent everything into an org chain from it would
// rebuild the hierarchy it replaced. Real nodes fall through to the normal
// selection path below.
const [orphanDialogOpen, setOrphanDialogOpen] = useState(false);
// The tree's unified node handler (world tree + the flat agents list). On the
// flat agents page a claw click opens the agent; in the World tree it selects.
const onTreeSelect = (item: TreeItem) => {
// Only an orphan container offers the migration. The workforce root is a
// heading: the row click above it has already toggled the branch, and there
// is nothing else to do with it.
if (ORPHAN_CONTAINER_IDS.has(item.id)) { setOrphanDialogOpen(true); return; }
if (SYNTHETIC_TREE_IDS.has(item.id)) return;
// On the World tier a mission grouping row is the whole point: selecting it
// focuses the scene on that mission. `worldSel` is the single source of
// that focus, so it is set here rather than in a second state.
if (isWorld && isGroupingRow(item.id)) { onWorldSelect(item.id); return; }
// Everywhere else a grouping row is a heading, like the workforce root: the
// row click has already toggled the branch. Falling through would hand a
// MISSION uuid to selectTeam, which is a real-looking id for the wrong
// table.
if (isGroupingRow(item.id)) return;
if (isClaw && item.level === "claw") { openClaw(clawIdOf(item.id)); return; }
onWorldSelect(item.id);
expandPathTo(item.id);
};
// Per-tier topology overrides, reset when the drilled entity changes.
const [companyTopo, setCompanyTopo] = useState<string>(company.topology);
const [topoCo, setTopoCo] = useState(companyId);
if (topoCo !== companyId) { setTopoCo(companyId); setCompanyTopo((findCompany(companyId) ?? company).topology); }
const router = useRouter();
// The team page's selector edits the SELECTED team's OWN topology (the value
// shown on its node), persisted to the backend. Local overrides reflect it
// instantly; falls back to the team's stored kind.
const [teamTopo, setTeamTopo] = useState<Record<string, string>>({});
const teamTopoOf = (t: DemoTeam) => teamTopo[t.id] ?? t.topology;
async function changeTeamTopo(kind: string) {
if (!teamId) return;
setTeamTopo((m) => ({ ...m, [teamId]: kind }));
try { await fetch(`/api/teams/${encodeURIComponent(teamId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind }) }); } catch { /* keep the optimistic label */ }
}
async function deleteTeam() {
if (!teamId || !window.confirm(`Delete team "${team.name}"? Its agents stay in your workspace.`)) return;
try {
const res = await fetch(`/api/teams/${encodeURIComponent(teamId)}`, { method: "DELETE" });
if (res.ok) { router.push("/"); router.refresh(); }
} catch { /* no-op */ }
}
// The company page's selector edits the SELECTED company's own topology
// (persisted); delete removes the company (its teams stay).
async function changeCompanyTopoPersist(kind: string) {
setCompanyTopo(kind);
if (!companyId) return;
try { await fetch(`/api/companies/${encodeURIComponent(companyId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind }) }); } catch { /* keep the optimistic label */ }
}
async function deleteCompany() {
if (!companyId || !window.confirm(`Delete company "${company.name}"? Its teams stay in your workspace.`)) return;
try {
const res = await fetch(`/api/companies/${encodeURIComponent(companyId)}`, { method: "DELETE" });
if (res.ok) { router.push("/"); router.refresh(); }
} catch { /* no-op */ }
}
// Left tab (items vs templates) + selected template, reset on tier change.
const [tab, setTab] = useState<"items" | "templates">("items");
const [tabTier, setTabTier] = useState<Tier>(tier);
const [templateId, setTemplateId] = useState<string | null>(null);
if (tabTier !== tier) { setTabTier(tier); setTab("items"); setTemplateId(null); }
const [teamDrawer, setTeamDrawer] = useState(false);
const [deployOpen, setDeployOpen] = useState(false);
// The "+" starts a MISSION, not an agent.
//
// Both "+" affordances used to open the deploy wizard — while the copy beside
// them said "deploy wizard" and the rail's tooltip said "Deploy a new agent",
// none of which is what someone arriving here wants to do first. You get a
// workforce BY running missions; hand-staffing one is the advanced path and
// keeps its own entry point below.
const [missionWizardOpen, setMissionWizardOpen] = useState(false);
// Brain registry rail (claw page) — collapsed by default, toggled by the brain
// icon in the Agents sidebar header.
const [registryOpen, setRegistryOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [runsOpen, setRunsOpen] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
const [reapOpen, setReapOpen] = useState(false);
const [toolOpen, setToolOpen] = useState<ToolKey | null>(null);
const isWorld = tier === "world",
isMissions = tier === "missions",
isClaw = tier === "claw",
isRepos = tier === "repos",
isInfra = tier === "infra";
const [missionsSel, setMissionsSel] = useState<string | null>(null);
const [missionsRefresh, setMissionsRefresh] = useState(0);
// The roster grouped by mission. Refetched when a mission changes so a newly
// staffed mission appears without a reload.
const [workforce, setWorkforce] = useState<Workforce | null>(null);
useEffect(() => {
let alive = true;
fetch("/api/workforce", { cache: "no-store" })
.then(okJson)
.then((d) => { if (alive) setWorkforce(d as Workforce); })
// Leave `workforce` null so the tree falls back to the flat list. An
// empty sidebar would look like "you have no agents", which is a worse
// lie than showing them ungrouped.
.catch(() => { if (alive) setWorkforce(null); });
return () => { alive = false; };
}, [missionsRefresh]);
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.
const worldRoots: TreeItem[] = orgs.map(orgNode);
// One flat root: "My Workforce", and the agents directly under it.
//
// The tree used to be Organization → Company → Team → Agent, and on a real
// workspace that read "My Workspace → General → Everyone" — three levels of
// placeholder wrapping five agents. None of it is load-bearing in the UI:
// `agents` has no org/company/team column at all (membership is only the
// `team_members` join, which the mission executor uses to map graph nodes to
// claws), and the /orgs, /companies and /teams pages already redirect here.
//
// The World tier keeps the full forest — that visualisation is ABOUT
// structure, so flattening it would remove its subject.
// Grouped by mission, with the flat list as the fallback.
//
// The flat version rendered `orgs → companies → teams → agents`, which shows
// a claw once per TEAM it belongs to. Claws are reused across missions now,
// so a crew of five that had run five missions appeared as twenty-five rows
// of the same five people and read as the roster multiplying. Grouping by
// mission makes the repetition mean something: the same colleague shows up
// under each mission they staffed.
//
// `/api/workforce` is the source; until it answers (or if it fails) we fall
// back to the flat list rather than rendering an empty sidebar.
const missionGroups: TreeItem[] = (workforce?.missions ?? []).map((m) => ({
// Prefixed so a mission id can never be mistaken for a claw id by the
// selection handler — clicking a group must expand it, not try to open a
// claw page for a mission uuid.
id: `mission-group:${m.mission_id}`,
level: "team",
label: m.title?.trim() || "Untitled mission",
meta: `${m.status} · ${m.agents.length} agent${m.agents.length === 1 ? "" : "s"}`,
status: m.status,
children: m.agents.map((a, i) => ({
// Same claw under two missions would otherwise collide on React keys
// AND on the tree's active-id comparison.
id: `${m.mission_id}:${a.id}`,
level: "claw" as const,
label: a.name,
meta: a.role_slot || a.job_title,
status: a.status,
grad: NODE_GRADS[i % NODE_GRADS.length][0],
ink: NODE_GRADS[i % NODE_GRADS.length][1],
initial: (a.name || "?").trim().charAt(0).toUpperCase(),
})),
}));
const unassignedAgents: TreeItem[] = (workforce?.unassigned ?? []).map((a, i) => ({
id: a.id,
level: "claw" as const,
label: a.name,
meta: a.job_title,
status: a.status,
grad: NODE_GRADS[i % NODE_GRADS.length][0],
ink: NODE_GRADS[i % NODE_GRADS.length][1],
initial: (a.name || "?").trim().charAt(0).toUpperCase(),
}));
const groupedChildren: TreeItem[] = [
...missionGroups,
// Hand-created claws, and claws whose missions were deleted. Shown as a
// peer group so they cannot silently disappear from the sidebar.
...(unassignedAgents.length
? [{
id: "workforce-unassigned",
level: "team" as const,
label: "Not on a mission",
meta: `${unassignedAgents.length} agent${unassignedAgents.length === 1 ? "" : "s"}`,
children: unassignedAgents,
}]
: []),
];
const useGrouped = groupedChildren.length > 0;
// Distinct people, not rows: the same claw on three missions is one colleague.
const distinctAgentCount = useGrouped
? new Set([
...(workforce?.missions ?? []).flatMap((m) => m.agents.map((a) => a.id)),
...(workforce?.unassigned ?? []).map((a) => a.id),
]).size
: allAgents.length;
const workforceRoot: TreeItem = {
id: "my-workforce",
level: "org",
label: "My Workforce",
meta: `${distinctAgentCount} agent${distinctAgentCount === 1 ? "" : "s"}`,
children: useGrouped ? groupedChildren : allAgents.map(clawNode),
};
// The World tier gets the SAME workforce sidebar as the agents page.
//
// It used to show the org → company → team → agent forest, which is the
// hierarchy the agents page stopped rendering — so the two pages disagreed
// about what the workspace looks like, and the World offered no way to ask
// "show me just this mission". Missions are the unit people think in, so the
// sidebar is missions here too, and picking one scopes the scene.
const treeRoots: TreeItem[] = [workforceRoot];
const treeActiveId = isClaw ? agentId : worldSel;
// Open by default. A workforce collapsed behind one disclosure is a workforce
// the user has to discover they own.
const treeAutoExpand: string[] = ["my-workforce"];
// Which mission the World is focused on, derived from the tree selection so
// there is ONE selection state rather than a second one to keep in sync.
// Selecting a mission group focuses it; selecting an agent inside a group
// keeps that mission focused, which is what makes clicking around inside a
// mission feel stable.
const defaultMissionId: string | null =
(workforce?.missions ?? []).find((m) => m.status === "running")?.mission_id ??
(workforce?.missions ?? [])[0]?.mission_id ??
null;
const focusedMissionId: string | null = (() => {
if (!isWorld) return null;
// Default to the most recent mission rather than the whole workspace.
//
// The RUNNING mission first, then the newest of any status.
//
// Newest-first alone picked whatever was created last, which on a workspace
// with history is a finished mission — so starting a run left the World
// looking at an old static map while the new work went unwatched.
//
// There is always exactly one focused mission when there is any mission at
// all, and that is load-bearing: the plan channel keeps ONE `planRef`, so
// two missions on the wire overwrite each other's title and phases and the
// scene becomes a blend of two runs that never happened.
// The unfocused view drew every agent of every mission into one space,
// which is not a picture of anything that happens: missions do not share a
// stage, and at tens of agents the scene says less the more it shows.
// `/api/workforce` orders missions newest-first, so [0] is the one someone
// opening this page is most likely asking about.
if (!worldSel) return defaultMissionId;
if (worldSel.startsWith("mission-group:")) return worldSel.slice("mission-group:".length);
const grouped = (workforce?.missions ?? []).find((m) =>
m.agents.some((a) => `${m.mission_id}:${a.id}` === worldSel),
);
// An agent selected from somewhere other than a mission group (the World
// graph itself) keeps whatever mission was already pinned, instead of
// silently reverting to the default.
return grouped?.mission_id ?? defaultMissionId;
})();
const focusedMission = (workforce?.missions ?? []).find((m) => m.mission_id === focusedMissionId) ?? null;
// Palette input. Read from the plan channel rather than the SSE because the
// engine takes its palette at construction, before any event has arrived.
const focusedMissionTemplate = focusedMission?.templateKind ?? null;
const focusAgentIds: Set<string> | null = focusedMission
? new Set(focusedMission.agents.map((a) => a.id))
: null;
// Seed the scene with just this mission's crew when one is focused. The seed
// alone does not scope the view (the engine materialises any agent an event
// mentions) — WorldCanvas filters the feed — but it means the mission's own
// people are on stage immediately rather than fading in with their first
// event.
const missionSeed = (m: { mission_id: string; title?: string; status?: string; agents: { id: string; name: string; status?: string }[] }): WorldSeed => ({
id: `mission:${m.mission_id}`,
// "mission", not "team". `seed()` casts this straight to a Tier and
// `ensureNode` is first-write-wins, so seeding it as a team created a
// small teal team-sized dot that the later `node.activity` could never
// upgrade — the generic dot at the centre of the scene was this line.
level: "mission",
label: m.title?.trim() || "Untitled mission",
status: m.status,
children: m.agents.map((a) => ({
id: a.id,
level: "claw",
label: a.name,
status: a.status,
})),
});
// The World is about MISSIONS. It is never seeded with the org chart.
//
// It used to fall back to `worldCanvasRoots` — the Organization → Company →
// Team → Agent tree — whenever no mission was pinned. That tree describes
// almost nothing: `agents` has no org/company/team column, real membership is
// the `team_members` join, and four of its containers are fabricated in the
// browser and exist in no table. Worse, it did not REPLACE the mission view,
// it shared the canvas with it: the mission plan events are only filtered by
// id when a mission is pinned, so with nothing pinned a live mission was
// drawn on top of the org chart. Two unrelated graphs, both parented at the
// invisible root, reading as one scene in which they somehow connected.
//
// One mission, or none. `focusedMissionId` always resolves to a mission when
// the workspace has any, so the empty case means the workspace has never run
// one — and an empty stage is the truth about that.
//
// Seeding "all missions" was the tempting alternative and it is wrong twice:
// `/api/workforce` returns every mission ever with no limit, which puts
// hundreds of agents back on one canvas, and the plan channel cannot hold
// more than one mission anyway (see `defaultMissionId`).
const worldSeedRoots: WorldSeed[] = focusedMission ? [missionSeed(focusedMission)] : [];
// Every node in the active tree (flattened) — the wrench multi-select looks up
// selected nodes here regardless of depth, and derives the delete kind from the
// selected level (selection is kept homogeneous on toggle).
const allNodes: { id: string; label: string; level: string }[] = (() => {
const out: { id: string; label: string; level: string }[] = [];
const walk = (n: TreeItem) => { out.push({ id: n.id, label: n.label, level: n.level }); (n.children ?? []).forEach(walk); };
treeRoots.forEach(walk);
return out;
})();
const nodeLevel = new Map(allNodes.map((n) => [n.id, n.level] as const));
const levelToKind = (lv?: string): ReapKind => (lv === "team" ? "teams" : lv === "company" ? "companies" : lv === "org" ? "orgs" : "agents");
// Selection is keyed by TREE id, but the reap endpoints want the row id. A
// claw inside a mission group is keyed `<missionId>:<clawId>`, and the same
// colleague can be selected under two missions — send one id, once, or the
// purge would be handed a composite key and a duplicate.
const selectedItems = Array.from(
new Map(
allNodes
.filter((n) => selectedAgents.has(n.id))
.map((n) => [clawIdOf(n.id), { id: clawIdOf(n.id), name: n.label }] as const),
).values(),
);
const reapKind: ReapKind = selectedItems.length ? levelToKind(nodeLevel.get(selectedItems[0].id)) : "agents";
const crumbStyle = (on: boolean): CSSProperties =>
on
? { color: "#fff", background: "rgba(255,111,97,.14)", border: "1px solid rgba(255,111,97,.3)", padding: "3px 8px", borderRadius: 6, cursor: "pointer" }
: { color: "#6a6a72", padding: "3px 5px", cursor: "pointer" };
// Flow items per tier.
const companyItems: FlowItem[] = org.companies.map((c, i) => ({
id: c.id, label: c.name, role: c.meta.split(" · ")[0],
grad: NODE_GRADS[i % NODE_GRADS.length][0], ink: NODE_GRADS[i % NODE_GRADS.length][1],
status: c.teams.some((t) => t.status === "running") ? "running" : "online",
}));
const teamItems: FlowItem[] = company.teams.map((t, i) => ({
id: t.id, label: t.name, role: `${topologyById(teamTopoOf(t))?.label ?? teamTopoOf(t)} · ${t.agents.length}`,
grad: NODE_GRADS[i % NODE_GRADS.length][0], ink: NODE_GRADS[i % NODE_GRADS.length][1], status: t.status,
}));
return (
<div style={{ width: "100%", height: "100dvh", minHeight: 640, background: "#08080a", display: "flex", flexDirection: "column", color: "#f3f3f5", overflow: "hidden" }}>
{/* TOP BAR */}
<div style={{ height: 54, flex: "none", display: "flex", alignItems: "center", gap: 14, padding: "0 18px", borderBottom: "1px solid rgba(255,255,255,.06)", background: "linear-gradient(180deg,#0d0d10,#0a0a0c)" }}>
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 10, textDecoration: "none" }}>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M11 3 L18.5 17 L3.5 17 Z" stroke="#ff6f61" strokeWidth="1.3" strokeLinejoin="round" opacity="0.55" /><circle cx="11" cy="3.5" r="2.4" fill="#ff6f61" /><circle cx="18" cy="17" r="2.4" fill="#ff6f61" /><circle cx="4" cy="17" r="2.4" fill="#ff6f61" /></svg>
<span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>Clawmates</span>
</Link>
<div style={{ width: 1, height: 22, background: "rgba(255,255,255,.08)" }} />
<div style={{ display: "flex", alignItems: "center", gap: 6, fontFamily: mono, fontSize: 12 }}>
<span style={crumbStyle(isWorld)} onClick={() => setTier("world")}>Visualizations</span>
<span style={{ color: "#3a3a40" }}>/</span>
<span style={{ color: "#3a3a40" }}>/</span>
<span style={crumbStyle(isMissions)} onClick={() => setTier("missions")}>Missions</span>
<span style={{ color: "#3a3a40" }}>/</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 style={{ flex: 1 }} />
{isInfra ? <FleetPill /> : null}
<UserMenu onOpen={setToolOpen} />
</div>
{/* BODY */}
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{/* Rail + context column hide while the computer is fullscreen so it
truly fills the screen (nothing peeks out to the left). */}
{!clawFull ? (
<>
{/* STRUCTURE RAIL — tiers, then a divider, then the "+" create affordance */}
<div style={{ width: 76, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0a0a0c", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "center" }}>
{TIER_TABS.map((t) => {
const on = tier === t.key;
return (
<div key={t.key} onClick={() => setTier(t.key)} style={{ position: "relative", width: 58, height: 50, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, borderRadius: 10, cursor: "pointer", color: on ? "#ff6f61" : "#5a5a62", background: on ? "rgba(255,111,97,.1)" : "transparent", paddingLeft: 5 }}>
{on ? <span style={{ position: "absolute", left: 0, top: 8, bottom: 8, width: 3, borderRadius: "0 3px 3px 0", background: "#ff6f61" }} /> : null}
{railIcon[t.key]}
<span style={{ fontFamily: mono, fontSize: 8.5, letterSpacing: ".02em" }}>{t.label}</span>
</div>
);
})}
<div style={{ width: 28, height: 1, background: "rgba(255,255,255,.08)", margin: "6px 0" }} />
<button type="button" onClick={() => setMissionWizardOpen(true)} title="New mission" style={{ width: 36, height: 36, borderRadius: 9, border: "1px dashed rgba(255,111,97,.4)", background: "rgba(255,111,97,.06)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff6f61", fontSize: 19, fontWeight: 300, cursor: "pointer" }}>+</button>
</div>
<div style={{ flex: 1 }} />
</div>
{/* CONTEXT LIST — collapsible via a chevron in the top-right of
its header. Persisted per-workspace in localStorage. */}
{sidebarCollapsed ? (
<div style={{ width: 32, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", alignItems: "center", padding: "10px 0" }}>
<button
type="button"
onClick={toggleSidebar}
title="Expand sidebar"
aria-label="Expand sidebar"
style={{ width: 28, height: 28, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#cfcfd5", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
>
<PanelLeftOpen aria-hidden size={14} />
</button>
</div>
) : (
<div style={{ position: "relative", width: 252, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", minHeight: 0 }}>
{/* Collapse chevron overlaid at the top-right so it works on
every tier's header without editing each one. */}
<button
type="button"
onClick={toggleSidebar}
title="Collapse sidebar"
aria-label="Collapse sidebar"
style={{ position: "absolute", top: 4, right: 4, zIndex: 5, width: 22, height: 22, borderRadius: 6, border: "1px solid rgba(255,255,255,.1)", background: "rgba(0,0,0,.4)", color: "#cfcfd5", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
>
<PanelLeftClose aria-hidden size={13} />
</button>
{/* Header: tiers get a Structure/Templates toggle; the claw page gets a
plain "Claws" header — its sidebar is a flat list of agents. */}
{isWorld ? (
<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 }}>
{/* Counts the things this sidebar actually lists. It used to
read "N ORGS", which described the org→company→team forest
this tier no longer shows. */}
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{(workforce?.missions ?? []).length} MISSION{(workforce?.missions ?? []).length === 1 ? "" : "S"} · {distinctAgentCount} AGENT{distinctAgentCount === 1 ? "" : "S"}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>Visualizations</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>
</div>
) : isInfra || isMissions || isRepos ? null : (
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}>
{/* Title on the left; toolbar (wrench / history / brain) sits
flush right at the same baseline. The prior "N orgs · N co
· N teams · N agents" caption was retired — the same info
is one click away in the World tier so the header stays
clean. */}
<div style={{ flex: 1, minWidth: 0, fontSize: 18, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>
Agents
</div>
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select agents to manage" aria-label="Select agents" 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>
{clawAgent ? (
<button type="button" onClick={() => setHistoryOpen(true)} title="Brain history" aria-label="Brain history" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><History aria-hidden size={17} /></button>
) : null}
<button type="button" onClick={() => setRegistryOpen((v) => !v)} title="Brain registry" aria-label="Toggle brain registry" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${registryOpen ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: registryOpen ? "rgba(255,111,97,.12)" : "transparent", color: registryOpen ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Brain aria-hidden size={17} /></button>
</div>
)}
{isMissions ? (
<div style={{ display: "flex", flexDirection: "column", minHeight: 0, flex: 1 }}>
<div style={{ flex: "1 1 0", minHeight: 0, overflow: "auto" }}>
<MissionsList
selectedId={missionsSel}
onSelect={setMissionsSel}
refreshKey={missionsRefresh}
onCreated={(id) => {
setMissionsSel(id);
setMissionsRefresh((n) => n + 1);
}}
onDeleted={(ids) => {
if (missionsSel && ids.includes(missionsSel)) {
setMissionsSel(null);
}
setMissionsRefresh((n) => n + 1);
}}
/>
</div>
<div
style={{
flex: "none",
borderTop: "1px solid rgba(255,255,255,.06)",
padding: "10px 12px",
maxHeight: "38%",
overflow: "auto",
}}
>
<LevelUpInbox />
</div>
</div>
) : isRepos ? (
<RepoList
selectedId={repoSel}
onSelect={setRepoSel}
onAdd={() => setRepoWizardOpen(true)}
onEdit={(id) => setRepoEditId(id)}
refreshKey={repoRefresh}
onRefresh={() => setRepoRefresh((n) => n + 1)}
/>
) : isInfra ? (
<InfraNav view={infraSel ?? "local"} onSelect={setInfraSel} onConnectHost={() => setInfraConnectOpen(true)} />
) : (
<>
{/* Empty roster — no real orgs/companies/teams/agents. Rather than
render an empty tree (or fake ones), point the user at the same
"+" deploy wizard the rail exposes. */}
{orgs.length === 0 ? (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "24px 20px", gap: 10, textAlign: "center" }}>
<span style={{ fontFamily: mono, fontSize: 10.5, letterSpacing: ".14em", color: "#5a5a62" }}>NOTHING TO SHOW</span>
<span style={{ fontSize: 13, color: "#cfcfd5", lineHeight: 1.55 }}>Your workforce is empty.</span>
<span style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.6, maxWidth: 200 }}>Hit the <span style={{ color: "#ff8a7a" }}>+</span> to start a mission — the agents it needs join your workforce.</span>
</div>
) : (
<>
{/* 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={"*"}
selectedIds={selectedAgents}
onToggleSelect={(id) => {
// Synthetic UI-only containers ("my-workspace", "ws-teams",
// "ungrouped-co", "ungrouped-team") aren't real DB rows — the
// backend would 422 on the non-UUID id. Silently ignore taps.
if (SYNTHETIC_TREE_IDS.has(id)) return;
// Mission groups are headings, not rows. Their id carries a
// MISSION uuid, so a reap would target the wrong table with a
// perfectly valid-looking id.
if (isGroupingRow(id)) return;
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; });
}}
// Only real (non-synthetic, non-claw) nodes accept an inline
// rename. Synthetic scaffolding gets swapped for real rows in the
// next commit (wizard auto-materialize + migration dialog).
// Mission groups render at team level but are NOT teams — renaming
// one would PATCH /api/teams/<missionId>/name: a well-formed uuid
// pointing at the wrong table, which fails as a silent no-op rather
// than an error.
canRename={(it) => it.level !== "claw" && !SYNTHETIC_TREE_IDS.has(it.id) && !isGroupingRow(it.id)}
onRename={async (id, level, newLabel) => {
const path = level === "org" ? "orgs" : level === "company" ? "companies" : level === "team" ? "teams" : null;
if (!path) return;
const res = await fetch(`/api/${path}/${id}/name`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newLabel }),
});
if (!res.ok) throw new Error(`PATCH /api/${path}/${id}/name → ${res.status}`);
// Force a re-fetch of the workspace tree so the new label lands
// everywhere (sidebar + viz + breadcrumbs).
router.refresh();
}}
/>
{selectMode ? (
<div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontFamily: mono, fontSize: 11, color: selectedItems.length ? "#ff8a7a" : "#6a6a72" }}>{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" disabled={!selectedItems.length} onClick={() => setReapOpen(true)} style={{ flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "9px 0", borderRadius: 9, border: 0, background: selectedItems.length ? "#ff6f61" : "rgba(255,111,97,.2)", color: "#1a0d0b", fontSize: 12.5, fontWeight: 700, cursor: selectedItems.length ? "pointer" : "default" }}><Trash2 aria-hidden size={14} /> Delete</button>
<button type="button" onClick={() => { setSelectMode(false); setSelectedAgents(new Set()); }} style={{ flex: "none", padding: "9px 14px", borderRadius: 9, border: "1px solid rgba(255,255,255,.14)", background: "transparent", color: "#9a9aa2", fontSize: 12.5, fontWeight: 600, cursor: "pointer" }}>Cancel</button>
</div>
</div>
) : null}
</>
)}
</>
)}
</div>
)}
</>
) : null}
{/* CANVAS */}
<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" } : {}) }}>
{isMissions ? (
<MissionCanvas
selectedId={missionsSel}
refreshKey={missionsRefresh}
onChanged={() => setMissionsRefresh((n) => n + 1)}
onSelect={(id) => {
setMissionsSel(id);
setMissionsRefresh((n) => n + 1);
}}
onDeleted={() => {
setMissionsSel(null);
setMissionsRefresh((n) => n + 1);
}}
onOpenClaw={(clawId) => {
setAgentId(clawId);
setTier("claw");
}}
/>
) : isRepos ? (
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
) : isWorld ? (
<>
{/* Graph stage — condensed by the right slide-out's width. */}
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--world-width, 0px)", background: "radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)", transition: "right var(--duration-normal) var(--ease-app)" }}>
<WorldCanvas
roots={worldSeedRoots}
expanded={expanded}
onToggleExpand={toggleExpand}
selectedId={worldSel}
onSelect={onWorldSelect}
onOpenRuns={() => setRunsOpen(true)}
focusAgents={focusAgentIds}
focusMissionId={focusedMissionId}
focusLabel={focusedMission?.title ?? null}
templateKind={focusedMissionTemplate}
/>
{/* No mission to show. Said out loud, because an empty stage
and a broken page look identical — and this tier used to
fill that silence with an org chart that described nothing. */}
{!focusedMissionId ? (
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none" }}>
<div style={{ fontFamily: mono, fontSize: 12.5, color: "#6a6a72", textAlign: "center", lineHeight: 1.7 }}>
no mission to show
<br />
<span style={{ fontSize: 11, color: "#4e4e56" }}>
the World draws one mission at a time — launch one, or pick one from My Workforce
</span>
</div>
</div>
) : null}
{/* Open the slide-out (top-right) when it's closed. */}
{!worldPanelOpen ? (
<button type="button" aria-label="Open panel" title="Panel" onClick={() => setWorldPanelOpen(true)} style={{ position: "absolute", top: 14, right: 16, zIndex: 50, width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><PanelRight aria-hidden size={19} /></button>
) : null}
</div>
{/* Right slide-out — sized like the agent computer (phone/tablet/full).
Content TBD; for now the sized shell + the size toggle. */}
{worldPanelOpen ? (
<div style={{ position: "absolute", top: 0, right: 0, bottom: 0, width: "var(--world-width, 0px)", borderLeft: "1px solid rgba(255,255,255,.08)", background: "#0b0b0e", display: "flex", flexDirection: "column", zIndex: 8, animation: "cm-fade .2s ease" }}>
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 8, padding: "12px 14px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<span style={{ fontFamily: mono, fontSize: 11, letterSpacing: ".1em", color: "#5a5a62", flex: 1 }}>PANEL</span>
<DeviceSizeToggle value={worldSize} onChange={(d) => setWorldSize(d)} />
<span style={{ width: 1, height: 18, background: "rgba(255,255,255,.14)" }} />
<button type="button" aria-label="Close panel" onClick={() => setWorldPanelOpen(false)} style={{ width: 30, height: 30, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#cfcfd5", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X aria-hidden size={16} /></button>
</div>
{(() => {
// `worldSel` may be a mission-group id or a composite
// `<missionId>:<clawId>`; the panel wants a claw id, and a
// grouping row has none.
const selClawId = worldSel && !isGroupingRow(worldSel) ? clawIdOf(worldSel) : null;
const loc = locateAgent(selClawId);
if (!loc) return <ObservePanel focusAgent={selClawId} onClearFocus={() => setWorldSel(null)} />;
const { org: o, company: co, team: t, agent: a } = loc;
const statusCol = a.status === "running" ? "#5ec8d8" : a.status === "online" ? "#5fd08a" : "#5a5a62";
const chip = (text: string, col = "#9a9aa2") => <span style={{ fontFamily: mono, fontSize: 10, color: col, padding: "3px 9px", borderRadius: 999, background: `${col}1a`, border: `1px solid ${col}33` }}>{text}</span>;
const hierRow = (col: string, k: string, v: string) => (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 11px", borderRadius: 10, background: "#101014", border: "1px solid rgba(255,255,255,.06)" }}>
<span style={{ width: 9, height: 9, flex: "none", borderRadius: 3, background: col }} />
<span style={{ flex: "none", fontFamily: mono, fontSize: 9.5, letterSpacing: ".08em", color: "#6a6a72", width: 58 }}>{k.toUpperCase()}</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, color: "#eaeaee", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{v}</span>
</div>
);
const stat = (n: number, l: string) => (
<div style={{ borderRadius: 10, background: "#101014", border: "1px solid rgba(255,255,255,.06)", padding: "10px 6px", textAlign: "center" }}>
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5" }}>{n}</div>
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".06em", color: "#6a6a72", marginTop: 2 }}>{l.toUpperCase()}</div>
</div>
);
return (
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 10, paddingTop: 4 }}>
<div style={{ position: "relative", width: 72, height: 72 }}>
<div style={{ width: 72, height: 72, borderRadius: "50%", background: a.avatar ? `center/cover no-repeat url(${a.avatar})` : a.grad, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 26, fontWeight: 700, color: a.ink, boxShadow: "0 0 36px rgba(255,111,97,.3)" }}>{a.avatar ? null : a.initial}</div>
<span style={{ position: "absolute", right: 1, bottom: 1, width: 16, height: 16, borderRadius: "50%", background: statusCol, border: "2.5px solid #0b0b0e" }} />
</div>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>{a.name}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", marginTop: 2 }}>{a.role}</div>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "center" }}>
{chip(a.status, statusCol)}
{a.model ? chip(a.model) : null}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
<span style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".12em", color: "#5a5a62" }}>BELONGS TO</span>
{hierRow("#6fd0c0", "Team", t.name)}
{hierRow("#8a9af0", "Company", co.name)}
{hierRow("#c98af0", "Org", o.name)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
{stat(a.compartments.skills.length, "Skills")}
{stat(a.compartments.tools.length, "Tools")}
{stat(a.nowRunning.length, "Running")}
</div>
<button type="button" onClick={() => openClaw(a.id)} style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8, padding: "11px 0", borderRadius: 11, border: 0, background: "linear-gradient(135deg,#ff8a7a,#ff5f57)", color: "#2a0d0a", fontSize: 13.5, fontWeight: 700, cursor: "pointer", boxShadow: "0 8px 22px rgba(255,111,97,.28)" }}><Bot aria-hidden size={18} /> More details</button>
</div>
);
})()}
</div>
) : null}
</>
) : isInfra ? (
<>
{/* Center: the operator console (stats → Tailscale → host cards) +
a thin status bar, condensed by the computer pull-out's width. */}
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--computer-width)", display: "flex", flexDirection: "column", transition: "right var(--duration-normal) var(--ease-app)" }}>
{monitorNode ? (
<NodeMonitor nodeId={monitorNode.id} name={monitorNode.name} onBack={() => setMonitorNode(null)} />
) : (
<FleetConsole view={infraSel ?? "local"} onConnectHost={() => setInfraConnectOpen(true)} onMonitor={(id, name) => setMonitorNode({ id, name })} />
)}
<FleetStatusBar />
</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>
{infraConnectOpen ? <ConnectHostWizard onClose={() => setInfraConnectOpen(false)} /> : null}
</>
) : richAgent && clawAgent ? (
<>
{/* Far-left: Brain Registry — search panel + results panel + detail
slide-out. Toggled by the brain icon in the Agents sidebar; hidden
when the computer is fullscreen. */}
{registryOpen && device !== "full" ? (
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, width: 510, zIndex: 6 }}>
<BrainRegistryPanel clawId={clawAgent.id} clawName={clawAgent.name} onApplied={() => setEnrichBump((b) => b + 1)} onClose={() => setRegistryOpen(false)} />
</div>
) : null}
{/* Left region: the full-height agent command center (identity strip →
metrics band → LIVE/BRAIN/SURFACE columns). Chat lives in the computer's
Chat app now. Condensed by the computer's width + the registry rail. */}
<div style={{ position: "absolute", top: 0, bottom: 0, left: registryOpen && device !== "full" ? 510 : 0, right: "var(--computer-width)", transition: "left var(--duration-normal) var(--ease-app), right var(--duration-normal) var(--ease-app)" }}>
<ClawCommandCenter agent={richAgent} brain={richBrain} teamName={team.name} avatarUrl={clawAgent.avatar || undefined} onToolsChanged={() => setEnrichBump((b) => b + 1)} />
</div>
{/* Right: the original device computer (phone / tablet / desktop). */}
<DevicePanel agent={clawAgent} />
{/* Drag the panel's left edge to a custom width (the size toggle
above snaps back to the phone/tablet/full presets). */}
{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;
// Don't allow narrower than the phone preset (448px).
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}
{/* Top-right launchers: chat shortcut (opens the computer's Chat app) +
computer; controls show when the computer is open. */}
<div style={{ position: "absolute", top: 14, right: 16, zIndex: 50, display: "flex", alignItems: "center", gap: 8 }}>
<button type="button" aria-label="Open chat" title="Chat" onClick={() => setParams({ app: "chat", device: device === "full" || device === "tablet" ? 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" }}><MessageSquare aria-hidden size={19} /></button>
{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>
</>
) : (
<EmptyRosterStage
onAdd={() => setMissionWizardOpen(true)}
onCreateAgent={() => setDeployOpen(true)}
/>
)}
</div>
{/* RIGHT SLIDE-OUT: team group apps (the claw computer is in-canvas now). */}
</div>
{/* MASTER PLANNER — the "+" opens a chat with Opus 4.8 that proposes and
scaffolds a whole team of agents. */}
{deployOpen ? <MasterPlannerModal onClose={() => setDeployOpen(false)} /> : null}
{/* The mission wizard, behind every "+" on the dashboard. */}
{missionWizardOpen ? (
<MissionWizard
onClose={() => setMissionWizardOpen(false)}
onCreated={() => {
setMissionWizardOpen(false);
setTier("missions");
router.refresh();
}}
/>
) : null}
{orphanDialogOpen ? (
<OrphanMigrationDialog
onClose={() => setOrphanDialogOpen(false)}
onReified={(result) => {
setOrphanDialogOpen(false);
// Land the user right on the freshly-materialized team so they
// see where their agents just moved. router.refresh() reloads
// the workspace tree so the sidebar reflects the new chain.
router.push(`/?team=${encodeURIComponent(result.team_id)}`);
router.refresh();
}}
/>
) : null}
{repoWizardOpen ? (
<RepoConnectionWizardStub
onClose={() => setRepoWizardOpen(false)}
onCreated={() => {
setRepoWizardOpen(false);
setRepoRefresh((n) => n + 1);
}}
/>
) : null}
{repoEditId ? (
<RepoConnectionEditModal
connectionId={repoEditId}
onClose={() => setRepoEditId(null)}
onChanged={() => setRepoRefresh((n) => n + 1)}
onDeleted={() => {
setRepoSel(null);
setRepoRefresh((n) => n + 1);
}}
/>
) : null}
{historyOpen && clawAgent ? <BrainHistoryModal clawId={clawAgent.id} clawName={clawAgent.name} onClose={() => setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null}
{(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? <TeamRunsModal teamId={sel.id} teamName={sel.label} onClose={() => setRunsOpen(false)} /> : null; })()}
{reapOpen ? <ReapProgressModal items={selectedItems} kind={reapKind} onClose={() => setReapOpen(false)} onDone={() => { setReapOpen(false); setSelectMode(false); setSelectedAgents(new Set()); router.refresh(); }} /> : null}
{/* Group existing claws into a new named team, then land on its team page. */}
{/* Group existing teams into a new named company, then land on its page. */}
{addCompanyOpen ? (
<AddToCompanyModal
teams={orgs.flatMap((o) => o.companies.flatMap((c) => c.teams)).filter((t) => t.id !== "ungrouped-team").map((t) => ({ id: t.id, name: t.name, claws: t.agents.length, topology: t.topology }))}
onClose={() => setAddCompanyOpen(false)}
/>
) : null}
{/* Group existing companies into a new named organization, then land on it. */}
{addOrgOpen ? (
<AddToOrgModal
companies={orgs.flatMap((o) => o.companies).filter((c) => c.id && c.id !== "ws-teams" && c.id !== "ungrouped-co").map((c) => ({ id: c.id, name: c.name, teams: c.teams.length }))}
onClose={() => setAddOrgOpen(false)}
/>
) : null}
{/* In-dashboard tool panels (Skills/Apps/Topologies/Approvals/Team/Credits). */}
{toolOpen ? <ToolPanel tool={toolOpen} user={user} onClose={() => setToolOpen(null)} /> : null}
{/* STATUS BAR */}
<div style={{ height: 28, flex: "none", display: "flex", alignItems: "center", gap: 18, padding: "0 16px", borderTop: "1px solid rgba(255,255,255,.06)", background: "#0a0a0c", fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>
<span style={{ color: "#5fd08a" }}>● durable runner ok</span>
<span>checkpoint 3s ago</span>
<span style={{ flex: 1 }} />
<span>§15 sandbox: isolated</span>
<button type="button" onClick={() => setToolOpen("approvals")} style={{ color: "#5ec8d8", background: "transparent", border: 0, cursor: "pointer", fontFamily: mono, fontSize: 10, padding: 0 }}>doors awaiting approval</button>
</div>
</div>
);
}
/** Canvas empty state for a workspace with no agents yet.
*
* The primary action starts a MISSION, because that is how a workforce comes
* to exist — a mission mints the agents it needs and they stay. Hand-staffing
* one is the advanced path and gets a quieter secondary link. */
function EmptyRosterStage({
onAdd,
onCreateAgent,
}: {
onAdd: () => void;
onCreateAgent: () => void;
}) {
return (
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 22, background: "radial-gradient(120% 90% at 50% 42%, #100e13 0%, #08080a 70%)" }}>
<div style={{ fontFamily: mono, fontSize: 11, letterSpacing: ".18em", color: "#5a5a62" }}>YOUR WORKFORCE IS EMPTY</div>
<div style={{ fontSize: 22, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.015em", maxWidth: 480, textAlign: "center", lineHeight: 1.25 }}>
Create your agent workforce
</div>
<div style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92", maxWidth: 460, textAlign: "center", lineHeight: 1.6 }}>
Start a mission and the agents it needs are hired for it — they stay in
your workforce afterwards.
</div>
<button
type="button"
onClick={onAdd}
aria-label="Start a mission"
title="Start a mission"
style={{
width: 74,
height: 74,
borderRadius: 20,
border: "1px dashed rgba(255,111,97,.5)",
background: "rgba(255,111,97,.09)",
color: "#ff6f61",
fontSize: 34,
fontWeight: 200,
lineHeight: 1,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
transition: "transform .18s ease, background .18s ease, border-color .18s ease",
}}
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,111,97,.16)"; e.currentTarget.style.borderColor = "rgba(255,111,97,.8)"; e.currentTarget.style.transform = "scale(1.04)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,111,97,.09)"; e.currentTarget.style.borderColor = "rgba(255,111,97,.5)"; e.currentTarget.style.transform = "scale(1)"; }}
>
+
</button>
{/* The old primary action, demoted rather than removed: staffing and
upskilling a workforce by hand is a real thing to want, just not the
first thing. */}
<button
type="button"
onClick={onCreateAgent}
style={{
background: "transparent",
border: 0,
color: "#8a8a92",
fontFamily: mono,
fontSize: 11.5,
cursor: "pointer",
textDecoration: "underline",
textUnderlineOffset: 3,
}}
>
or create an agent yourself
</button>
</div>
);
}