Three connected fixes from a single session's feedback:
- Structure rail widened from 60→76 px, tabs 42→58 px wide with a
bit of left padding so labels ("Research", "Visualizations") no
longer bump against the active-tab indicator strip.
- Research canvas: each stage now shows a small "STAGE · <status>"
card explaining what state the topic is in and a "Next → …" hint
describing what the primary button will do. No more guessing
which of standby/processing/reviewing/publishing means what.
- Request-publish 409 fix:
- Backend TopicDetail now includes has_pending_publish_request
(SELECTs pending_for_topic when the topic loads). Frontend
TopicDetail interface + ResearchCanvas honor the flag: when
an approval is already pending the "Request publish" button
is replaced with an amber "Awaiting reviewer approval" pill,
so double-clicks can't 409 in the first place.
- runAction() also catches 409 as a signal-of-success (the
user's intent — "queue for approval" — is satisfied by the
first attempt), refetches the topic, and lets the new
awaiting-approval card render instead of surfacing a scary
error to the user.
1011 lines
68 KiB
TypeScript
1011 lines
68 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, 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, 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 { ObservePanel } from "../observe/ObservePanel";
|
|
import { StructureTree, orgNode, type TreeItem } from "./StructureTree";
|
|
import { ResearchList } from "./ResearchList";
|
|
import { ResearchCanvas } from "./ResearchCanvas";
|
|
import { LoopsList } from "./LoopsList";
|
|
import { LoopsCanvas } from "./LoopsCanvas";
|
|
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 { 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%" };
|
|
|
|
type Tier = "world" | "research" | "loops" | "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.
|
|
const SYNTHETIC_TREE_IDS = new Set([
|
|
"my-workspace",
|
|
"ws-teams",
|
|
"ungrouped-co",
|
|
"ungrouped-team",
|
|
]);
|
|
|
|
// 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"],
|
|
];
|
|
|
|
// A few starter templates surfaced in the Templates tab (deploy + visualize).
|
|
interface Template {
|
|
id: string;
|
|
name: string;
|
|
topo: string;
|
|
blurb: string;
|
|
roles: string[];
|
|
}
|
|
const TEAM_TEMPLATES: Template[] = [
|
|
{ id: "t-research", name: "Research Pod", topo: "blackboard", blurb: "A lead curates a shared blackboard while researchers and a critic read/write findings in parallel.", roles: ["lead", "researcher", "researcher", "critic", "writer"] },
|
|
{ id: "t-growth", name: "Growth Squad", topo: "hub_spoke", blurb: "A coordinator routes work to specialists and aggregates their output back.", roles: ["lead", "researcher", "writer", "analyst", "critic"] },
|
|
{ id: "t-pipeline", name: "Content Pipeline", topo: "pipeline", blurb: "Linear stages: intake → draft → edit → publish, each agent feeding the next.", roles: ["intake", "drafter", "editor", "publisher"] },
|
|
{ id: "t-debate", name: "Debate Room", topo: "debate", blurb: "A proposer and a critic argue; a judge resolves. Good for high-stakes decisions.", roles: ["proposer", "critic", "judge"] },
|
|
{ id: "t-swarm", name: "Swarm Recon", topo: "swarm", blurb: "Many autonomous peers attack a problem in parallel; consensus emerges.", roles: ["scout", "scout", "scout", "scout", "synthesizer"] },
|
|
];
|
|
const COMPANY_TEMPLATES: Template[] = [
|
|
{ id: "c-pipeline", name: "Pipeline Co", topo: "pipeline", blurb: "Teams arranged as a value chain — intake feeds growth feeds research feeds ops.", roles: ["Intake", "Growth", "Research", "Ops"] },
|
|
{ id: "c-federated", name: "Federated Co", topo: "federated", blurb: "Semi-autonomous teams with a light coordination layer between them.", roles: ["Team A", "Team B", "Team C"] },
|
|
{ id: "c-holacratic", name: "Holacratic Co", topo: "holacratic", blurb: "Self-organizing circles with distributed authority and no fixed hierarchy.", roles: ["Circle 1", "Circle 2", "Circle 3"] },
|
|
];
|
|
|
|
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>),
|
|
// Book with a magnifying-lens tucked into the lower-right — research.
|
|
research: (<svg width="20" height="20" viewBox="0 0 24 24"><rect x="4" y="3.5" width="12" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><path d="M4 15.5 H16" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="17.5" cy="17.5" r="3.2" stroke="currentColor" strokeWidth="1.6" fill="none" /><path d="M19.9 19.9 L22 22" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>),
|
|
// Circular arrow ⟳ with an inner dot — loops.
|
|
loops: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 12 A8 8 0 1 1 12 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /><path d="M13 3 L20 4 L18.5 10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinejoin="round" strokeLinecap="round" /><circle cx="12" cy="12" r="1.8" fill="currentColor" /></svg>),
|
|
claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>),
|
|
// 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: "research", label: "RESEARCH" },
|
|
{ key: "loops", label: "LOOPS" },
|
|
{ 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 }; 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");
|
|
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");
|
|
// Research tier: currently-selected topic + a refresh key mutations bump so
|
|
// list + canvas re-fetch after start/submit/publish etc.
|
|
const [researchSel, setResearchSel] = useState<string | null>(null);
|
|
const [researchRefresh, setResearchRefresh] = useState(0);
|
|
// Loops tier: same pattern.
|
|
const [loopsSel, setLoopsSel] = useState<string | null>(null);
|
|
const [loopsRefresh, setLoopsRefresh] = useState(0);
|
|
// 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.
|
|
const onWorldSelect = (id: string) => {
|
|
setWorldSel(id);
|
|
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.
|
|
const loc = locateAgent(id);
|
|
if (loc) { setOrgId(loc.org.id); setCompanyId(loc.company.id); setTeamId(loc.team.id); }
|
|
setAgentId(id);
|
|
setWorldPanelOpen(true);
|
|
} else if (lv === "org") selectOrg(id);
|
|
else if (lv === "company") selectCompany(id);
|
|
else if (lv === "team") selectTeam(id);
|
|
};
|
|
// 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) => {
|
|
if (isClaw && item.level === "claw") { openClaw(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);
|
|
// 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",
|
|
isResearch = tier === "research",
|
|
isLoops = tier === "loops",
|
|
isClaw = tier === "claw",
|
|
isRepos = tier === "repos",
|
|
isInfra = tier === "infra";
|
|
|
|
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);
|
|
// World canvas: when a node is selected, prune to just the branch that
|
|
// contains it — sibling orgs/companies/teams disappear so the visualization
|
|
// frames the selection alone. The left tree keeps the full forest.
|
|
const narrowRoots = (roots: TreeItem[], sel: string | null): TreeItem[] => {
|
|
if (!sel) return roots;
|
|
const prune = (n: TreeItem): TreeItem | null => {
|
|
if (n.id === sel) return n;
|
|
for (const c of n.children ?? []) {
|
|
const found = prune(c);
|
|
if (found) return { ...n, children: [found] };
|
|
}
|
|
return null;
|
|
};
|
|
for (const r of roots) {
|
|
const found = prune(r);
|
|
if (found) return [found];
|
|
}
|
|
return roots;
|
|
};
|
|
const worldCanvasRoots: TreeItem[] = narrowRoots(worldRoots, worldSel);
|
|
// Both the World and Agents tiers now share the same collapsible org →
|
|
// company → team → agent tree — a single pane onto the whole workforce.
|
|
// `selectLevel` below still constrains selection to agents on the Agents
|
|
// tier so bulk-delete stays claw-scoped.
|
|
const treeRoots: TreeItem[] = worldRoots;
|
|
const treeActiveId = isClaw ? agentId : worldSel;
|
|
const treeAutoExpand: string[] = [];
|
|
|
|
// 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");
|
|
const selectedItems = allNodes.filter((n) => selectedAgents.has(n.id)).map((n) => ({ id: n.id, name: n.label }));
|
|
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={crumbStyle(isResearch)} onClick={() => setTier("research")}>Research</span>
|
|
<span style={{ color: "#3a3a40" }}>/</span>
|
|
<span style={crumbStyle(isLoops)} onClick={() => setTier("loops")}>Loops</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={() => setDeployOpen(true)} title="Deploy a new agent" 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 */}
|
|
<div style={{ width: 252, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", minHeight: 0 }}>
|
|
{/* 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 }}>
|
|
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{orgs.length} ORG{orgs.length === 1 ? "" : "S"} · {allAgents.length} AGENTS</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 || isResearch || isLoops || isRepos ? null : (
|
|
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "flex-start", gap: 8 }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{orgs.length} ORG{orgs.length === 1 ? "" : "S"} · {allCompanies.length} CO · {orgs.flatMap((o) => o.companies).reduce((n, c) => n + c.teams.length, 0)} TEAM{orgs.flatMap((o) => o.companies).reduce((n, c) => n + c.teams.length, 0) === 1 ? "" : "S"} · {allAgents.length} AGENT{allAgents.length === 1 ? "" : "S"}</div>
|
|
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>Agents</div>
|
|
</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>
|
|
)}
|
|
|
|
{isResearch ? (
|
|
<ResearchList
|
|
agents={claws}
|
|
selectedId={researchSel}
|
|
onSelect={setResearchSel}
|
|
refreshKey={researchRefresh}
|
|
onCreated={(id) => {
|
|
setResearchSel(id);
|
|
setResearchRefresh((n) => n + 1);
|
|
}}
|
|
/>
|
|
) : isLoops ? (
|
|
<LoopsList
|
|
agents={claws}
|
|
selectedId={loopsSel}
|
|
onSelect={setLoopsSel}
|
|
refreshKey={loopsRefresh}
|
|
onCreated={(id) => {
|
|
setLoopsSel(id);
|
|
setLoopsRefresh((n) => n + 1);
|
|
}}
|
|
/>
|
|
) : 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> on the rail (or in the canvas) to add a new agent, team, company, or organization.</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;
|
|
setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; });
|
|
}} />
|
|
{selectMode ? (
|
|
<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" } : {}) }}>
|
|
{isResearch ? (
|
|
<ResearchCanvas
|
|
selectedId={researchSel}
|
|
agents={claws}
|
|
refreshKey={researchRefresh}
|
|
onChanged={() => setResearchRefresh((n) => n + 1)}
|
|
/>
|
|
) : isLoops ? (
|
|
<LoopsCanvas
|
|
selectedId={loopsSel}
|
|
refreshKey={loopsRefresh}
|
|
onChanged={() => setLoopsRefresh((n) => n + 1)}
|
|
onDeleted={() => {
|
|
setLoopsSel(null);
|
|
setLoopsRefresh((n) => n + 1);
|
|
}}
|
|
/>
|
|
) : 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={worldCanvasRoots} expanded={expanded} onToggleExpand={toggleExpand} selectedId={worldSel} onSelect={onWorldSelect} onOpenRuns={() => setRunsOpen(true)} />
|
|
{/* 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>
|
|
{(() => {
|
|
const loc = locateAgent(worldSel);
|
|
if (!loc) return <ObservePanel focusAgent={worldSel} 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={() => 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}
|
|
{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 orgs/companies/teams/agents.
|
|
* The + button opens the same MasterPlannerModal the rail's + does. */
|
|
function EmptyRosterStage({ onAdd }: { onAdd: () => 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 }}>
|
|
Add a new agent, team, company, or organization
|
|
</div>
|
|
<div style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92", maxWidth: 460, textAlign: "center", lineHeight: 1.6 }}>
|
|
The <span style={{ color: "#ff8a7a" }}>+</span> below opens the deploy wizard — pitch it a team spec and it scaffolds a whole workforce in one pass.
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onAdd}
|
|
aria-label="Deploy a new agent"
|
|
title="Deploy — opens the same wizard as the rail's +"
|
|
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>
|
|
</div>
|
|
);
|
|
}
|
|
|