"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 = { 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 `:` 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 = { world: (), // Flag on a pole — missions (unified research + loops). missions: (), claw: (), // Branching lines forking off a trunk — repositories. repos: (), infra: (), }; 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) => ( ); // ── 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("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(fallbackOrg.id); const [companyId, setCompanyId] = useState(fallbackOrg.companies[0]?.id ?? ""); const [teamId, setTeamId] = useState(fallbackOrg.companies[0]?.teams[0]?.id ?? ""); const [agentId, setAgentId] = useState(fallbackOrg.companies[0]?.teams[0]?.agents[0]?.id ?? ""); // Large World: the selected node + which nodes are expanded in the graph. const [worldSel, setWorldSel] = useState(null); const [expanded, setExpanded] = useState>(() => 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("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(null); const [repoRefresh, setRepoRefresh] = useState(0); const [repoWizardOpen, setRepoWizardOpen] = useState(false); const [repoEditId, setRepoEditId] = useState(null); // Infrastructure tier: which nav view (local / cloud) + the connect-host wizard. const [infraSel, setInfraSel] = useState("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=; 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(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=; select it and // open the team page once the refreshed workspace data includes it. const teamParam = params.get("team"); const [seenTeam, setSeenTeam] = useState(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=. const companyParam = params.get("company"); const [seenCompany, setSeenCompany] = useState(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=. const orgParam = params.get("org"); const [seenOrg, setSeenOrg] = useState(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>({}); // 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(null); const [resizing, setResizing] = useState(false); const canvasRef = useRef(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(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 `:`; 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(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>({}); 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); const [templateId, setTemplateId] = useState(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>(new Set()); const [reapOpen, setReapOpen] = useState(false); const [toolOpen, setToolOpen] = useState(null); const isWorld = tier === "world", isMissions = tier === "missions", isClaw = tier === "claw", isRepos = tier === "repos", isInfra = tier === "infra"; const [missionsSel, setMissionsSel] = useState(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(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 | 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 `:`, 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 (
{/* TOP BAR */}
Clawmates
setTier("world")}>Visualizations / / setTier("missions")}>Missions / setTier("claw")}>Agents / setTier("infra")}>Infrastructure
{isInfra ? : null}
{/* BODY */}
{/* 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 */}
{TIER_TABS.map((t) => { const on = tier === t.key; return (
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 ? : null} {railIcon[t.key]} {t.label}
); })}
{/* CONTEXT LIST — collapsible via a chevron in the top-right of its header. Persisted per-workspace in localStorage. */} {sidebarCollapsed ? (
) : (
{/* Collapse chevron overlaid at the top-right so it works on every tier's header without editing each one. */} {/* Header: tiers get a Structure/Templates toggle; the claw page gets a plain "Claws" header — its sidebar is a flat list of agents. */} {isWorld ? (
{/* 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. */}
{(workforce?.missions ?? []).length} MISSION{(workforce?.missions ?? []).length === 1 ? "" : "S"} · {distinctAgentCount} AGENT{distinctAgentCount === 1 ? "" : "S"}
Visualizations
) : isInfra || isMissions || isRepos ? null : (
{/* 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. */}
Agents
{clawAgent ? ( ) : null}
)} {isMissions ? (
{ setMissionsSel(id); setMissionsRefresh((n) => n + 1); }} onDeleted={(ids) => { if (missionsSel && ids.includes(missionsSel)) { setMissionsSel(null); } setMissionsRefresh((n) => n + 1); }} />
) : isRepos ? ( setRepoWizardOpen(true)} onEdit={(id) => setRepoEditId(id)} refreshKey={repoRefresh} onRefresh={() => setRepoRefresh((n) => n + 1)} /> ) : isInfra ? ( 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 ? (
NOTHING TO SHOW Your workforce is empty. Hit the + to start a mission — the agents it needs join your workforce.
) : ( <> {/* The collapsible org → company → team → agent tree (World) or the flat agents list. */} { // 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//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 ? (
{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}
) : null} )} )}
)} ) : null} {/* CANVAS */}
{isMissions ? ( 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 ? ( ) : isWorld ? ( <> {/* Graph stage — condensed by the right slide-out's width. */}
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 ? (
no mission to show
the World draws one mission at a time — launch one, or pick one from My Workforce
) : null} {/* Open the slide-out (top-right) when it's closed. */} {!worldPanelOpen ? ( ) : null}
{/* Right slide-out — sized like the agent computer (phone/tablet/full). Content TBD; for now the sized shell + the size toggle. */} {worldPanelOpen ? (
PANEL setWorldSize(d)} />
{(() => { // `worldSel` may be a mission-group id or a composite // `:`; 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 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") => {text}; const hierRow = (col: string, k: string, v: string) => (
{k.toUpperCase()} {v}
); const stat = (n: number, l: string) => (
{n}
{l.toUpperCase()}
); return (
{a.avatar ? null : a.initial}
{a.name}
{a.role}
{chip(a.status, statusCol)} {a.model ? chip(a.model) : null}
BELONGS TO {hierRow("#6fd0c0", "Team", t.name)} {hierRow("#8a9af0", "Company", co.name)} {hierRow("#c98af0", "Org", o.name)}
{stat(a.compartments.skills.length, "Skills")} {stat(a.compartments.tools.length, "Tools")} {stat(a.nowRunning.length, "Running")}
); })()}
) : null} ) : isInfra ? ( <> {/* Center: the operator console (stats → Tailscale → host cards) + a thin status bar, condensed by the computer pull-out's width. */}
{monitorNode ? ( setMonitorNode(null)} /> ) : ( setInfraConnectOpen(true)} onMonitor={(id, name) => setMonitorNode({ id, name })} /> )}
{/* Right: the IDENTICAL computer chrome, with cloud-infra apps. */} {computerOpen ? (
{ 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" }} >
) : null}
{chatMin ? ( ) : null} {computerOpen ? ( <> { setParams({ device: d }); setCustomWidth(null); }} /> ) : ( )}
{infraConnectOpen ? 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" ? (
setEnrichBump((b) => b + 1)} onClose={() => setRegistryOpen(false)} />
) : 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. */}
setEnrichBump((b) => b + 1)} />
{/* Right: the original device computer (phone / tablet / desktop). */} {/* Drag the panel's left edge to a custom width (the size toggle above snaps back to the phone/tablet/full presets). */} {computerOpen ? (
{ 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" }} >
) : null} {/* Top-right launchers: chat shortcut (opens the computer's Chat app) + computer; controls show when the computer is open. */}
{computerOpen ? ( <> { setParams({ device: d }); setCustomWidth(null); }} /> ) : ( )}
) : ( setMissionWizardOpen(true)} onCreateAgent={() => setDeployOpen(true)} /> )}
{/* RIGHT SLIDE-OUT: team group apps (the claw computer is in-canvas now). */}
{/* MASTER PLANNER — the "+" opens a chat with Opus 4.8 that proposes and scaffolds a whole team of agents. */} {deployOpen ? setDeployOpen(false)} /> : null} {/* The mission wizard, behind every "+" on the dashboard. */} {missionWizardOpen ? ( setMissionWizardOpen(false)} onCreated={() => { setMissionWizardOpen(false); setTier("missions"); router.refresh(); }} /> ) : null} {orphanDialogOpen ? ( 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 ? ( setRepoWizardOpen(false)} onCreated={() => { setRepoWizardOpen(false); setRepoRefresh((n) => n + 1); }} /> ) : null} {repoEditId ? ( setRepoEditId(null)} onChanged={() => setRepoRefresh((n) => n + 1)} onDeleted={() => { setRepoSel(null); setRepoRefresh((n) => n + 1); }} /> ) : null} {historyOpen && clawAgent ? setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null} {(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? setRunsOpen(false)} /> : null; })()} {reapOpen ? 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 ? ( 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 ? ( 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 ? setToolOpen(null)} /> : null} {/* STATUS BAR */}
● durable runner ok checkpoint 3s ago §15 sandbox: isolated
); } /** 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 (
YOUR WORKFORCE IS EMPTY
Create your agent workforce
Start a mission and the agents it needs are hired for it — they stay in your workforce afterwards.
{/* 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. */}
); }