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
@@ -3,8 +3,8 @@
|
|||||||
// /api/** -> NEVER touched (approvals + SSE must be live)
|
// /api/** -> NEVER touched (approvals + SSE must be live)
|
||||||
// /_next/static/** -> cache-first (content-hashed, immutable)
|
// /_next/static/** -> cache-first (content-hashed, immutable)
|
||||||
// navigations -> network-first, cache fallback for offline shell
|
// navigations -> network-first, cache fallback for offline shell
|
||||||
const STATIC_CACHE = "tc-static-v2";
|
const STATIC_CACHE = "tc-static-v3";
|
||||||
const PAGE_CACHE = "tc-pages-v2";
|
const PAGE_CACHE = "tc-pages-v3";
|
||||||
|
|
||||||
self.addEventListener("install", () => {
|
self.addEventListener("install", () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { LeftRail } from "@/components/shell/LeftRail";
|
import { ShellChrome } from "@/components/shell/ShellChrome";
|
||||||
import { StatusBar } from "@/components/shell/StatusBar";
|
|
||||||
import { TopBar } from "@/components/shell/TopBar";
|
|
||||||
import { ApiAuthError, apiFetch } from "@/lib/api/http";
|
import { ApiAuthError, apiFetch } from "@/lib/api/http";
|
||||||
import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team";
|
import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team";
|
||||||
import {
|
import {
|
||||||
@@ -53,19 +51,17 @@ export default async function WorkspaceLayout({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col">
|
<ShellChrome
|
||||||
<TopBar user={user} stats={stats} />
|
user={user}
|
||||||
<div className="flex min-h-0 flex-1">
|
roster={roster}
|
||||||
<LeftRail
|
orgs={orgs}
|
||||||
roster={roster}
|
companies={companies}
|
||||||
orgs={orgs}
|
teams={teams}
|
||||||
companies={companies}
|
creditsBalance={credits.available}
|
||||||
teams={teams}
|
stats={stats}
|
||||||
creditsBalance={credits.available}
|
doors={doors}
|
||||||
/>
|
>
|
||||||
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
{children}
|
||||||
</div>
|
</ShellChrome>
|
||||||
<StatusBar stats={stats} doors={doors} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
// Workspace home: the empty-shell landing until agent chat arrives (P1).
|
import { redirect } from "next/navigation";
|
||||||
export default function WorkspaceHome() {
|
|
||||||
return (
|
import { Dashboard } from "@/components/dashboard/Dashboard";
|
||||||
<section className="flex h-dvh flex-col items-center justify-center gap-2 motion-safe:animate-[route-fade-in_var(--duration-normal)_var(--ease-app)]">
|
import { ApiAuthError } from "@/lib/api/http";
|
||||||
<h1 className="text-xxxl font-semibold tracking-tight">Clawmates</h1>
|
import { fetchMe } from "@/lib/api/team";
|
||||||
<p className="text-sm text-muted-foreground">
|
import type { User } from "@/lib/api/schemas";
|
||||||
Pick a claw from the rail, or create one to get started.
|
|
||||||
</p>
|
// Workspace home = the integrated dashboard (tier rail → topology canvas →
|
||||||
</section>
|
// agent computer slide-out).
|
||||||
);
|
export default async function WorkspaceHome() {
|
||||||
|
let user: User;
|
||||||
|
try {
|
||||||
|
user = await fetchMe();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiAuthError) redirect("/login");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return <Dashboard user={{ display_name: user.display_name, email: user.email }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The dashboard's topology canvas (design comp): the current group's children
|
||||||
|
// laid out as a node graph, with a 6-pattern "view-as" selector
|
||||||
|
// (hub-spoke / pipeline / ring / mesh / swarm / debate). Clicking a node
|
||||||
|
// selects it (a claw opens the computer slide-out; a company/team drills down).
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface CanvasNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
role: string;
|
||||||
|
level: "company" | "team" | "claw";
|
||||||
|
/** online | offline | provisioning | running */
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODES = ["hub-spoke", "pipeline", "ring", "mesh", "swarm", "debate"] as const;
|
||||||
|
export type TopologyMode = (typeof MODES)[number];
|
||||||
|
|
||||||
|
// Map a TopologyKind (from the team graph) onto one of the 6 view modes.
|
||||||
|
export function modeForKind(kind: string | null | undefined): TopologyMode {
|
||||||
|
switch ((kind ?? "").toLowerCase()) {
|
||||||
|
case "pipeline":
|
||||||
|
case "ring":
|
||||||
|
return "pipeline";
|
||||||
|
case "mesh":
|
||||||
|
case "blackboard":
|
||||||
|
return "mesh";
|
||||||
|
case "swarm":
|
||||||
|
case "flat":
|
||||||
|
case "holacratic":
|
||||||
|
return "swarm";
|
||||||
|
case "debate":
|
||||||
|
return "debate";
|
||||||
|
default:
|
||||||
|
return "hub-spoke"; // hierarchical / hub_spoke / star_moe / market
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRADS: [string, string, string][] = [
|
||||||
|
["#ff9a6a", "#ff6f4a", "#2a0d05"],
|
||||||
|
["#6fd0c0", "#4aa3b8", "#06201f"],
|
||||||
|
["#e8c46a", "#d89a3a", "#2a1d05"],
|
||||||
|
["#8a9af0", "#5a6ad8", "#0a0e2a"],
|
||||||
|
["#c98af0", "#9a5ad8", "#1a0a2a"],
|
||||||
|
["#5ec8d8", "#3a8aa0", "#06222a"],
|
||||||
|
];
|
||||||
|
const STATUS: Record<string, string> = {
|
||||||
|
running: "#5ec8d8",
|
||||||
|
online: "#5fd08a",
|
||||||
|
provisioning: "#e8b465",
|
||||||
|
offline: "#3a3a40",
|
||||||
|
};
|
||||||
|
const ini = (s: string) => (s || "?").trim().charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
/** Node positions (percent) for a mode + count. Generalizes the comp's POS. */
|
||||||
|
function layout(mode: TopologyMode, n: number): { x: number; y: number }[] {
|
||||||
|
if (n <= 0) return [];
|
||||||
|
const ring = (count: number, r: number, offset = 0, cx = 50, cy = 50) =>
|
||||||
|
Array.from({ length: count }, (_, i) => {
|
||||||
|
const a = (i / count) * Math.PI * 2 - Math.PI / 2 + offset;
|
||||||
|
return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
|
||||||
|
});
|
||||||
|
switch (mode) {
|
||||||
|
case "pipeline":
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
x: n === 1 ? 50 : 12 + (i * 76) / (n - 1),
|
||||||
|
y: 50,
|
||||||
|
}));
|
||||||
|
case "ring":
|
||||||
|
case "mesh":
|
||||||
|
return ring(n, 36);
|
||||||
|
case "swarm":
|
||||||
|
return Array.from({ length: n }, (_, i) => {
|
||||||
|
const a = (i / n) * Math.PI * 2;
|
||||||
|
const r = 14 + (i % 3) * 7;
|
||||||
|
return { x: 50 + r * Math.cos(a), y: 50 + r * Math.sin(a) };
|
||||||
|
});
|
||||||
|
case "debate": {
|
||||||
|
const half = Math.ceil(n / 2);
|
||||||
|
return Array.from({ length: n }, (_, i) => {
|
||||||
|
const left = i < half;
|
||||||
|
const col = left ? i : i - half;
|
||||||
|
const count = left ? half : n - half;
|
||||||
|
return { x: left ? 28 : 72, y: count === 1 ? 50 : 18 + (col * 64) / (count - 1) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "hub-spoke":
|
||||||
|
default:
|
||||||
|
if (n === 1) return [{ x: 50, y: 50 }];
|
||||||
|
return [{ x: 50, y: 50 }, ...ring(n - 1, 34)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edge index pairs for a mode + count. */
|
||||||
|
function links(mode: TopologyMode, n: number): [number, number][] {
|
||||||
|
const e: [number, number][] = [];
|
||||||
|
switch (mode) {
|
||||||
|
case "pipeline":
|
||||||
|
for (let i = 0; i < n - 1; i++) e.push([i, i + 1]);
|
||||||
|
break;
|
||||||
|
case "ring":
|
||||||
|
for (let i = 0; i < n; i++) e.push([i, (i + 1) % n]);
|
||||||
|
break;
|
||||||
|
case "mesh":
|
||||||
|
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) e.push([i, j]);
|
||||||
|
break;
|
||||||
|
case "swarm":
|
||||||
|
for (let i = 0; i < n; i++) e.push([i, (i + 1) % n]);
|
||||||
|
break;
|
||||||
|
case "debate": {
|
||||||
|
const half = Math.ceil(n / 2);
|
||||||
|
for (let i = 0; i < half; i++)
|
||||||
|
for (let j = half; j < n; j++) if ((i + j) % 2 === 0) e.push([i, j]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "hub-spoke":
|
||||||
|
default:
|
||||||
|
for (let i = 1; i < n; i++) e.push([0, i]);
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopologyCanvas({
|
||||||
|
nodes,
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
onNodeClick,
|
||||||
|
selectedId,
|
||||||
|
header,
|
||||||
|
}: {
|
||||||
|
nodes: CanvasNode[];
|
||||||
|
mode: TopologyMode;
|
||||||
|
onModeChange: (m: TopologyMode) => void;
|
||||||
|
onNodeClick: (n: CanvasNode) => void;
|
||||||
|
selectedId?: string | null;
|
||||||
|
header?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const pos = layout(mode, nodes.length);
|
||||||
|
const edges = links(mode, nodes.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full flex-col">
|
||||||
|
{/* mode selector */}
|
||||||
|
<div className="flex items-center gap-2 px-4 pb-3 pt-3">
|
||||||
|
{header}
|
||||||
|
<div className="flex flex-1 flex-wrap gap-1.5">
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onModeChange(m)}
|
||||||
|
className={`rounded-md px-2.5 py-1 font-mono text-[11px] capitalize transition-colors ${
|
||||||
|
m === mode
|
||||||
|
? "border border-coral/45 bg-coral/[0.14] text-coral-light"
|
||||||
|
: "border border-white/[0.08] bg-[#101014] text-[#9a9aa2] hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* graph */}
|
||||||
|
<div
|
||||||
|
className="relative flex-1"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(120% 100% at 50% 40%, #0f0f14, #0a0a0c), repeating-linear-gradient(0deg, transparent 0 27px, rgba(255,255,255,.02) 27px 28px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
|
||||||
|
{edges.map(([a, b], i) => {
|
||||||
|
const p = pos[a];
|
||||||
|
const q = pos[b];
|
||||||
|
if (!p || !q) return null;
|
||||||
|
const live = selectedId && (nodes[a]?.id === selectedId || nodes[b]?.id === selectedId);
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
d={`M${p.x},${p.y} L${q.x},${q.y}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={live ? "rgba(255,111,97,.6)" : "rgba(94,200,216,.4)"}
|
||||||
|
strokeWidth={live ? 1.6 : 1.2}
|
||||||
|
strokeDasharray="3 4"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
className="cm-flow"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{nodes.map((node, i) => {
|
||||||
|
const p = pos[i];
|
||||||
|
if (!p) return null;
|
||||||
|
const [from, to, ink] = GRADS[i % GRADS.length];
|
||||||
|
const selected = node.id === selectedId;
|
||||||
|
const dot = STATUS[node.status ?? "online"] ?? STATUS.online;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={node.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNodeClick(node)}
|
||||||
|
aria-label={node.label}
|
||||||
|
className="absolute flex -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center gap-1.5 outline-none"
|
||||||
|
style={{ left: `${p.x}%`, top: `${p.y}%` }}
|
||||||
|
>
|
||||||
|
<div className="relative" style={{ width: 50, height: 50 }}>
|
||||||
|
{node.status === "running" ? (
|
||||||
|
<span
|
||||||
|
className="cm-halo absolute inset-0 rounded-full"
|
||||||
|
style={{ background: "rgba(94,200,216,.35)" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="relative flex size-[50px] items-center justify-center rounded-full text-base font-bold transition-transform hover:scale-105"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${from}, ${to})`,
|
||||||
|
color: ink,
|
||||||
|
border: selected ? "2px solid #ff6f61" : undefined,
|
||||||
|
boxShadow: selected ? "0 0 20px rgba(255,111,97,.5)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ini(node.label || node.role)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[#0a0a0c]"
|
||||||
|
style={{ background: dot }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="max-w-[96px] truncate text-[11px] font-medium">
|
||||||
|
{node.label || node.role}
|
||||||
|
</span>
|
||||||
|
<span className="-mt-1 font-mono text-[9px] text-[#6a6a72]">{node.role}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{nodes.length === 0 ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No members yet.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,36 @@ import { useEffect } from "react";
|
|||||||
export function RegisterServiceWorker() {
|
export function RegisterServiceWorker() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
process.env.NODE_ENV === "production" &&
|
process.env.NODE_ENV !== "production" ||
|
||||||
"serviceWorker" in navigator
|
!("serviceWorker" in navigator)
|
||||||
) {
|
) {
|
||||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
return;
|
||||||
|
}
|
||||||
|
let reloaded = false;
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/sw.js")
|
||||||
|
.then((reg) => {
|
||||||
|
// When a new worker takes over after a deploy, reload once so the
|
||||||
|
// user gets the fresh build instead of stale cached assets.
|
||||||
|
const onUpdate = () => {
|
||||||
|
const next = reg.installing;
|
||||||
|
if (!next) return;
|
||||||
|
next.addEventListener("statechange", () => {
|
||||||
|
if (
|
||||||
|
next.state === "activated" &&
|
||||||
|
navigator.serviceWorker.controller &&
|
||||||
|
!reloaded
|
||||||
|
) {
|
||||||
|
reloaded = true;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reg.addEventListener("updatefound", onUpdate);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
// Browsers without SW support (or private modes) degrade fine.
|
// Browsers without SW support (or private modes) degrade fine.
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Picks the shell for a workspace route: the integrated dashboard at "/" is
|
||||||
|
// self-contained (its own top bar / rail / status bar), so render it bare;
|
||||||
|
// every other route gets the shared TopBar + LeftRail + StatusBar chrome.
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { Agent, User } from "@/lib/api/schemas";
|
||||||
|
import type { GroupSummary, StructureStats } from "@/lib/api/structure";
|
||||||
|
import { LeftRail } from "./LeftRail";
|
||||||
|
import { StatusBar } from "./StatusBar";
|
||||||
|
import { TopBar } from "./TopBar";
|
||||||
|
|
||||||
|
interface ShellChromeProps {
|
||||||
|
user: User;
|
||||||
|
roster: Agent[];
|
||||||
|
orgs: GroupSummary[];
|
||||||
|
companies: GroupSummary[];
|
||||||
|
teams: GroupSummary[];
|
||||||
|
creditsBalance?: number;
|
||||||
|
stats: StructureStats | null;
|
||||||
|
doors: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShellChrome({
|
||||||
|
user,
|
||||||
|
roster,
|
||||||
|
orgs,
|
||||||
|
companies,
|
||||||
|
teams,
|
||||||
|
creditsBalance,
|
||||||
|
stats,
|
||||||
|
doors,
|
||||||
|
children,
|
||||||
|
}: ShellChromeProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
// The dashboard owns the whole screen — no shared chrome around it.
|
||||||
|
if (pathname === "/") return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh flex-col">
|
||||||
|
<TopBar user={user} stats={stats} />
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<LeftRail
|
||||||
|
roster={roster}
|
||||||
|
orgs={orgs}
|
||||||
|
companies={companies}
|
||||||
|
teams={teams}
|
||||||
|
creditsBalance={creditsBalance}
|
||||||
|
/>
|
||||||
|
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
<StatusBar stats={stats} doors={doors} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -283,6 +283,17 @@
|
|||||||
.cm-drift {
|
.cm-drift {
|
||||||
animation: cm-drift 4s var(--ease-app) infinite;
|
animation: cm-drift 4s var(--ease-app) infinite;
|
||||||
}
|
}
|
||||||
|
@keyframes cm-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cm-fade {
|
||||||
|
animation: cm-fade var(--duration-normal) var(--ease-app);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user