Dashboard: integrated post-login screen + service-worker auto-update
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

Login landed on an empty "Pick a claw" stub — the integrated dashboard from the
design comp was never assembled as the home. Now `/` IS the dashboard.

- public/sw.js: cache v2→v3; RegisterServiceWorker reloads once when a new
  worker activates, so deploys are picked up without a manual hard-refresh
- (workspace)/layout: ShellChrome renders the dashboard bare on "/" (it's
  self-contained) and the shared TopBar/LeftRail/StatusBar on every other route
- components/dashboard: Dashboard (state machine + data) — top bar w/ breadcrumb,
  ORG/CO/TEAM/CLAW tier rail, tier-aware context list, TopologyCanvas (6-mode
  view-as selector, generalized layouts), and the agent "computer" slide-out
  (cm-fade) wrapping the existing ComputerPanel; status bar
- motion.css: cm-fade keyframe

Wired to existing endpoints (/api/teams|companies|orgs, /api/structure/*,
/api/team/claws, /api/structure/stats, + ComputerPanel's apps/runtime-config/
routines). Defaults to the most-recent team; claw click opens the slide-out.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-19 06:17:19 -07:00
co-authored by Claude Opus 4.8
parent d2354e1f72
commit bd6f48c2b7
8 changed files with 781 additions and 33 deletions
@@ -0,0 +1,401 @@
"use client";
// The integrated post-login dashboard (design comp: Clawmates Dashboard.html).
// One screen: top bar (breadcrumb) · ORG/CO/TEAM/CLAW tier rail · context list ·
// topology canvas · agent "computer" slide-out · status bar. Self-contained
// in-memory state machine, wired to the existing API.
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Plus } from "lucide-react";
import { MeshMark } from "@/components/brand/MeshMark";
import { ComputerPanel } from "@/components/computer/ComputerPanel";
import type { Agent } from "@/lib/api/schemas";
import type { GroupSummary, StructureNode, StructureStats } from "@/lib/api/structure";
import {
TopologyCanvas,
modeForKind,
type CanvasNode,
type TopologyMode,
} from "./TopologyCanvas";
type Tier = "org" | "company" | "team" | "claw";
async function getJSON<T>(url: string, fallback: T): Promise<T> {
try {
const r = await fetch(url);
return r.ok ? ((await r.json()) as T) : fallback;
} catch {
return fallback;
}
}
const railIcon: Record<Tier, React.ReactNode> = {
org: (
<svg width="20" height="20" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.4" fill="none" />
<circle cx="10" cy="10" r="2.6" fill="currentColor" />
</svg>
),
company: (
<svg width="20" height="20" 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>
),
team: (
<svg width="20" height="20" viewBox="0 0 20 20">
<circle cx="10" cy="5" r="2.2" fill="currentColor" />
<circle cx="5" cy="13.5" r="2.2" fill="currentColor" />
<circle cx="15" cy="13.5" r="2.2" fill="currentColor" />
<path d="M10 5 L5 13.5 M10 5 L15 13.5 M5 13.5 L15 13.5" stroke="currentColor" strokeWidth="1.1" opacity=".5" />
</svg>
),
claw: (
<svg width="20" height="20" viewBox="0 0 20 20">
<rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" />
<circle cx="10" cy="10" r="2.4" fill="currentColor" />
</svg>
),
};
const TIERS: { key: Tier; label: string }[] = [
{ key: "org", label: "ORG" },
{ key: "company", label: "CO" },
{ key: "team", label: "TEAM" },
{ key: "claw", label: "CLAW" },
];
export function Dashboard({ user }: { user: { display_name?: string; email?: string } }) {
const [tier, setTier] = useState<Tier>("team");
const [orgId, setOrgId] = useState<string | null>(null);
const [companyId, setCompanyId] = useState<string | null>(null);
const [teamId, setTeamId] = useState<string | null>(null);
const [clawId, setClawId] = useState<string | null>(null);
const [mode, setMode] = useState<TopologyMode>("hub-spoke");
const [orgs, setOrgs] = useState<GroupSummary[]>([]);
const [companies, setCompanies] = useState<GroupSummary[]>([]);
const [teams, setTeams] = useState<GroupSummary[]>([]);
const [roster, setRoster] = useState<Agent[]>([]);
const [stats, setStats] = useState<StructureStats | null>(null);
const [node, setNode] = useState<StructureNode | null>(null);
const [loaded, setLoaded] = useState(false);
const modeKey = useRef<string>("");
// Load the workspace + default to the most-recent team.
useEffect(() => {
let live = true;
void (async () => {
const [t, c, o, r, s] = await Promise.all([
getJSON<GroupSummary[]>("/api/teams", []),
getJSON<GroupSummary[]>("/api/companies", []),
getJSON<GroupSummary[]>("/api/orgs", []),
getJSON<Agent[]>("/api/team/claws", []),
getJSON<StructureStats | null>("/api/structure/stats", null),
]);
if (!live) return;
setTeams(t);
setCompanies(c);
setOrgs(o);
setRoster(r);
setStats(s);
if (t[0]) {
setTier("team");
setTeamId(t[0].id);
} else if (c[0]) {
setTier("company");
setCompanyId(c[0].id);
} else if (o[0]) {
setTier("org");
setOrgId(o[0].id);
}
setLoaded(true);
})();
return () => {
live = false;
};
}, []);
// The canvas always renders a group's children; a claw selection keeps the
// team canvas and opens the slide-out.
const canvasLevel: "org" | "company" | "team" | null =
tier === "org" ? "org" : tier === "company" ? "company" : "team";
const canvasId = tier === "org" ? orgId : tier === "company" ? companyId : teamId;
// Clear the canvas when its target changes (render-phase reset — avoids a
// synchronous setState inside the effect).
const ckey = `${canvasLevel ?? ""}:${canvasId ?? ""}`;
const [seenCanvas, setSeenCanvas] = useState(ckey);
if (seenCanvas !== ckey) {
setSeenCanvas(ckey);
setNode(null);
}
useEffect(() => {
if (!canvasLevel || !canvasId) return;
let live = true;
void (async () => {
const n = await getJSON<StructureNode | null>(
`/api/structure/${canvasLevel}/${canvasId}`,
null,
);
if (!live) return;
setNode(n);
// Reset the view-mode to the group's real kind only when the group changes.
const key = `${canvasLevel}:${canvasId}`;
if (n && modeKey.current !== key) {
modeKey.current = key;
setMode(modeForKind(n.kind));
}
})();
return () => {
live = false;
};
}, [canvasLevel, canvasId]);
const rosterMap = new Map(roster.map((a) => [a.id, a]));
const nodes: CanvasNode[] = (node?.children ?? []).map((c) => ({
id: c.child_id,
label: c.child_name || rosterMap.get(c.child_id)?.name || c.role,
role: c.role,
level: c.child_level,
status: rosterMap.get(c.child_id)?.status,
}));
function onNodeClick(n: CanvasNode) {
if (n.level === "claw") {
setClawId(n.id);
setTier("claw");
} else if (n.level === "team") {
setTeamId(n.id);
setClawId(null);
setTier("team");
} else {
setCompanyId(n.id);
setTeamId(null);
setClawId(null);
setTier("company");
}
}
function gotoTier(t: Tier) {
if (t === "org" && !orgId && orgs[0]) setOrgId(orgs[0].id);
if (t === "company" && !companyId && companies[0]) setCompanyId(companies[0].id);
if (t === "team" && !teamId && teams[0]) setTeamId(teams[0].id);
if (t === "claw" && !clawId) return; // nothing selected yet
setTier(t);
}
const teamName = teams.find((x) => x.id === teamId)?.name;
const companyName = companies.find((x) => x.id === companyId)?.name;
const orgName = orgs.find((x) => x.id === orgId)?.name;
const clawName = clawId ? (rosterMap.get(clawId)?.name ?? "Claw") : null;
const crumbs = [orgName, companyName, teamName, tier === "claw" ? clawName : null].filter(
Boolean,
) as string[];
const initial = (user.display_name || user.email || "?").trim().charAt(0).toUpperCase();
const running = stats?.running_now ?? 0;
const clawCount = stats?.claw_count ?? roster.length;
const empty = loaded && teams.length === 0 && companies.length === 0 && orgs.length === 0;
return (
<div className="flex h-dvh flex-col bg-background text-foreground">
{/* TOP BAR */}
<header className="flex h-[54px] flex-none items-center gap-3.5 border-b border-white/[0.06] bg-gradient-to-b from-[#0d0d10] to-[#0a0a0c] px-4">
<Link href="/" className="flex items-center gap-2.5 text-coral">
<MeshMark size={22} title="" />
<span className="text-[15px] font-bold tracking-[-0.01em] text-foreground">Clawmates</span>
</Link>
<span className="h-[22px] w-px bg-white/[0.08]" />
<nav className="flex items-center gap-1.5 font-mono text-xs">
{crumbs.length === 0 ? (
<span className="text-[#6a6a72]">Workspace</span>
) : (
crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 ? <span className="text-[#3a3a40]">/</span> : null}
<span className={i === crumbs.length - 1 ? "text-coral-light" : "text-[#9a9aa2]"}>
{c}
</span>
</span>
))
)}
</nav>
<div className="flex-1" />
<div
className="hidden items-center gap-1.5 rounded-[7px] border px-2.5 py-[5px] font-mono text-[11px] text-online sm:flex"
style={{ borderColor: "rgba(95,208,138,.25)", background: "rgba(95,208,138,.06)" }}
>
<span className={`size-[7px] rounded-full bg-online ${running > 0 ? "cm-blink" : ""}`} />
{clawCount} claws{running > 0 ? " · running" : ""}
</div>
<Link
href="/claws/new"
className="flex items-center gap-1.5 rounded-lg bg-gradient-to-br from-coral-light to-coral-dark px-3 py-1.5 text-xs font-bold text-[#2a0d0a] transition-[filter] hover:brightness-110"
>
<span className="text-[15px] leading-none">+</span> Deploy
</Link>
<span
className="flex size-8 items-center justify-center rounded-lg text-[13px] font-bold text-[#2a0d0a]"
style={{ background: "linear-gradient(135deg,#ff8a7a,#ff5f57)" }}
title={user.display_name || user.email}
>
{initial}
</span>
</header>
{/* BODY */}
<div className="flex min-h-0 flex-1">
{/* TIER RAIL */}
<div className="flex w-[60px] flex-none flex-col items-center border-r border-white/[0.06] bg-[#0a0a0c] py-3.5">
<div className="flex flex-col items-center gap-1.5">
{TIERS.map((t) => {
const active = tier === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => gotoTier(t.key)}
className={`relative flex h-12 w-[42px] flex-col items-center justify-center gap-1 rounded-[10px] transition-colors ${
active ? "bg-coral/[0.12] text-coral" : "text-[#6a6a72] hover:text-foreground"
}`}
>
{active ? (
<span className="absolute left-0 top-2 bottom-2 w-[3px] rounded-r bg-coral" />
) : null}
{railIcon[t.key]}
<span className="font-mono text-[8px]">{t.label}</span>
</button>
);
})}
</div>
<div className="flex-1" />
<Link
href="/claws/new"
title="Deploy"
className="flex size-[30px] items-center justify-center rounded-lg border border-dashed border-white/[0.16] text-coral"
>
<Plus size={16} />
</Link>
</div>
{/* CONTEXT LIST */}
<div className="flex w-[252px] flex-none flex-col border-r border-white/[0.06] bg-[#0b0b0e]">
<div className="px-4 pb-2.5 pt-4">
<div className="font-mono text-[10px] tracking-[0.12em] text-[#5a5a62]">
{tier === "org" || tier === "company" || tier === "team"
? `${tier.toUpperCase()} · ${nodes.length}`
: "CLAW"}
</div>
<div className="text-[18px] font-bold tracking-[-0.01em]">{node?.name ?? "…"}</div>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2 [&::-webkit-scrollbar]:hidden">
{nodes.length === 0 ? (
<p className="px-2 text-xs text-muted-foreground">
{loaded ? "Empty" : "Loading…"}
</p>
) : (
<ul className="flex flex-col gap-0.5">
{nodes.map((n) => {
const sel = n.level === "claw" && n.id === clawId;
const grad = rosterMap.get(n.id)?.accent;
return (
<li key={n.id}>
<button
type="button"
onClick={() => onNodeClick(n)}
className={`relative flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 text-left transition-colors ${
sel ? "bg-coral/[0.12]" : "hover:bg-hover-bg"
}`}
>
{sel ? (
<span className="absolute left-0 top-2 bottom-2 w-[3px] rounded-r bg-coral" />
) : null}
<span
className="flex size-[30px] shrink-0 items-center justify-center rounded-lg text-xs font-bold text-[#08080a]"
style={{ background: grad || "var(--color-coral)" }}
>
{(n.label || n.role).charAt(0).toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">{n.label}</span>
<span className="block truncate font-mono text-[10px] text-[#6a6a72]">
{n.role}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
{/* CANVAS */}
<div className="min-w-0 flex-1">
{empty ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<MeshMark size={40} title="" />
<h1 className="text-xl font-semibold">No teams yet</h1>
<p className="text-sm text-muted-foreground">Deploy a team to populate your dashboard.</p>
<Link
href="/claws/new"
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white"
>
Deploy a team
</Link>
</div>
) : (
<TopologyCanvas
nodes={nodes}
mode={mode}
onModeChange={setMode}
onNodeClick={onNodeClick}
selectedId={clawId}
/>
)}
</div>
{/* COMPUTER SLIDE-OUT */}
{clawId ? (
<div className="cm-fade flex w-[330px] flex-none flex-col border-l border-white/[0.06] bg-[#0a0a0c] p-3">
<div className="mb-2 flex items-center justify-between">
<span className="font-mono text-[10px] tracking-[0.12em] text-[#5a5a62]">COMPUTER</span>
<button
type="button"
onClick={() => {
setClawId(null);
if (tier === "claw") setTier("team");
}}
className="text-muted-foreground hover:text-foreground"
aria-label="Close computer"
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden">
<ComputerPanel clawId={clawId} clawName={clawName ?? "Claw"} />
</div>
</div>
) : null}
</div>
{/* STATUS BAR */}
<footer className="flex h-7 flex-none items-center gap-4 border-t border-white/[0.06] bg-[#0a0a0c] px-4 font-mono text-[10px] text-[#6a6a72]">
<span className="flex items-center gap-1.5">
<span className={`size-[6px] rounded-full ${running > 0 ? "bg-running cm-blink" : "bg-[#3a3a40]"}`} />
durable runner {running > 0 ? `· ${running} active` : "· idle"}
</span>
<span className="text-[#3a3a40]">·</span>
<span className="text-online">§15 sandbox: isolated</span>
<div className="flex-1" />
<Link href="/approvals" className="hover:text-foreground">
doors awaiting approval
</Link>
</footer>
</div>
);
}