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,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>
);
}