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
+2 -2
View File
@@ -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();
+8 -12
View File
@@ -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">
<LeftRail
roster={roster} roster={roster}
orgs={orgs} orgs={orgs}
companies={companies} companies={companies}
teams={teams} teams={teams}
creditsBalance={credits.available} creditsBalance={credits.available}
/> stats={stats}
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main> doors={doors}
</div> >
<StatusBar stats={stats} doors={doors} /> {children}
</div> </ShellChrome>
); );
} }
+18 -10
View File
@@ -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>
);
}
+11
View File
@@ -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) {
*, *,