Dashboard: integrated post-login screen + service-worker auto-update
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:
co-authored by
Claude Opus 4.8
parent
d2354e1f72
commit
bd6f48c2b7
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user