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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user