Teams UI: deploy scope selector + TeamWizard + Team page
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

The "+" deploy flow now opens a scope selector (Single / Team / Company-soon /
Org-soon). Single = the existing CreateClawForm. Team = TeamWizard: pick a
baseline topology (from the catalog + role distribution), set size, auto-staff
editable claw cards (name / role / model / persona), preview the topology
graph, then POST /api/teams → land on the Team page.

Team page (/teams/[id]): topology SVG + claw roster (each links to its chat) +
a Run panel that drives the team on the durable runner with live SSE progress
(reuses the topology-run streaming). /teams list page + a Teams nav entry.

lint + typecheck + next build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-18 13:17:30 -07:00
co-authored by Claude Opus 4.8
parent 8123a27bcf
commit bba18a4687
7 changed files with 575 additions and 7 deletions
@@ -1,17 +1,15 @@
import { CreateClawForm } from "@/components/wizard/CreateClawForm"; import { DeployWizard } from "@/components/wizard/DeployWizard";
export default function NewClawPage() { export default function NewClawPage() {
return ( return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-6 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]"> <section className="flex min-h-dvh flex-col items-center justify-center gap-6 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
<div className="text-center"> <div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">Deploy</h1>
Create your Claw
</h1>
<p className="pt-1 text-sm text-muted-foreground"> <p className="pt-1 text-sm text-muted-foreground">
Claws help get your work done. A single claw, or a whole team — pick your scale.
</p> </p>
</div> </div>
<CreateClawForm /> <DeployWizard />
</section> </section>
); );
} }
@@ -0,0 +1,6 @@
import { TeamView } from "@/components/team/TeamView";
export default async function TeamPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <TeamView teamId={id} />;
}
@@ -0,0 +1,64 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface TeamSummary {
id: string;
name: string;
kind: string;
status: string;
created_at: string;
}
export default function TeamsPage() {
const [teams, setTeams] = useState<TeamSummary[]>([]);
useEffect(() => {
void (async () => {
try {
const r = await fetch("/api/teams");
if (r.ok) setTeams((await r.json()) as TeamSummary[]);
} catch {
/* ignore */
}
})();
}, []);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold tracking-tight">Teams</h1>
<Link
href="/claws/new"
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white"
>
Deploy a team
</Link>
</div>
{teams.length === 0 ? (
<p className="text-sm text-muted-foreground">
No teams yet — deploy a baseline topology staffed with claws.
</p>
) : (
<ul className="flex flex-col gap-2">
{teams.map((t) => (
<li key={t.id}>
<Link
href={`/teams/${t.id}`}
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-muted/30"
>
<span className="text-sm font-medium text-foreground">{t.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs capitalize text-muted-foreground">
{t.kind}
</span>
<span className="text-xs text-muted-foreground">{t.status}</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+2 -1
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Blocks, CreditCard, Share2, ShieldCheck, Users, Zap } from "lucide-react"; import { Blocks, Boxes, CreditCard, Share2, ShieldCheck, Users, Zap } from "lucide-react";
import { GlobalNavItem } from "./GlobalNavItem"; import { GlobalNavItem } from "./GlobalNavItem";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/teams", label: "Teams", icon: Boxes },
{ href: "/skills", label: "Skills", icon: Zap }, { href: "/skills", label: "Skills", icon: Zap },
{ href: "/apps", label: "Apps", icon: Blocks }, { href: "/apps", label: "Apps", icon: Blocks },
{ href: "/topologies", label: "Topologies", icon: Share2 }, { href: "/topologies", label: "Topologies", icon: Share2 },
+187
View File
@@ -0,0 +1,187 @@
"use client";
// A deployed team: its topology (SVG), its claws (node→claw roster), and a Run
// panel that drives the team on the durable runner with live SSE progress.
import { useEffect, useRef, useState } from "react";
import { TopologyGraphView } from "@/components/topology/TopologyGraphView";
import type { TopologyGraph } from "@/lib/api/topology";
interface Member {
node_id: string;
claw_id: string;
role: string;
}
interface TeamDetail {
id: string;
name: string;
kind: string;
status: string;
graph: TopologyGraph;
members: Member[];
}
interface StepRecord {
node_id: string;
role: string;
phase: string;
output: string;
}
export function TeamView({ teamId }: { teamId: string }) {
const [team, setTeam] = useState<TeamDetail | null>(null);
const [task, setTask] = useState("Draft a one-paragraph plan for a product launch.");
const [steps, setSteps] = useState<StepRecord[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [finalOutput, setFinalOutput] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
void (async () => {
try {
const r = await fetch(`/api/teams/${teamId}`);
if (r.ok) setTeam((await r.json()) as TeamDetail);
} catch {
/* ignore */
}
})();
return () => esRef.current?.close();
}, [teamId]);
async function run() {
setBusy(true);
setError(null);
setSteps([]);
setFinalOutput(null);
setStatus("queued");
esRef.current?.close();
try {
const res = await fetch(`/api/teams/${teamId}/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task }),
});
if (res.status !== 202) throw new Error(`Run failed (${res.status})`);
const { run_id } = (await res.json()) as { run_id: string };
setStatus("running");
const es = new EventSource(`/api/topology-runs/${run_id}/events`);
esRef.current = es;
es.addEventListener("step", (e) => {
try {
setSteps((s) => [...s, JSON.parse((e as MessageEvent).data) as StepRecord]);
} catch {
/* ignore */
}
});
es.addEventListener("done", (e) => {
try {
const d = JSON.parse((e as MessageEvent).data) as {
status: string;
error: string | null;
final_output: string | null;
};
setStatus(d.status);
if (d.final_output) setFinalOutput(d.final_output);
if (d.error) setError(d.error);
} catch {
/* ignore */
}
es.close();
esRef.current = null;
setBusy(false);
});
es.onerror = () => {
es.close();
esRef.current = null;
setBusy(false);
};
} catch (e) {
setError(e instanceof Error ? e.message : "Run failed");
setBusy(false);
}
}
if (!team) {
return <div className="p-8 text-sm text-muted-foreground">Loading team…</div>;
}
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">{team.name}</h1>
<p className="text-xs text-muted-foreground">
<span className="capitalize">{team.kind}</span> · {team.members.length} claws · {team.status}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-border">
<TopologyGraphView graph={team.graph} />
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">Members</p>
<ul className="flex flex-wrap gap-2">
{team.members.map((m) => (
<li key={m.node_id}>
<a
href={`/claws/${m.claw_id}`}
className="flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs text-foreground hover:bg-muted/30"
>
<span className="font-medium capitalize">{m.role}</span>
<span className="text-muted-foreground">{m.node_id}</span>
</a>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">Run this team</p>
<textarea
value={task}
onChange={(e) => setTask(e.target.value)}
rows={2}
className="w-full rounded-lg border border-input bg-subtle px-3 py-2 text-sm text-foreground"
/>
<div>
<button
type="button"
onClick={run}
disabled={busy}
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? "Running…" : "Run"}
</button>
{status ? (
<span className="ml-3 text-xs text-muted-foreground">{status}</span>
) : null}
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{steps.length > 0 ? (
<ol className="flex flex-col gap-2">
{steps.map((s, i) => (
<li key={`${s.node_id}-${i}`} className="rounded-md border border-border/60 p-3">
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium capitalize text-foreground">{s.role}</span>
<span>· {s.phase}</span>
</div>
<p className="whitespace-pre-wrap text-sm text-foreground">{s.output}</p>
</li>
))}
</ol>
) : null}
{finalOutput ? (
<div className="rounded-md bg-muted/40 p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">Final output</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{finalOutput}</p>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
// The deploy ladder entry: choose a scale (single / team / company / org) then
// branch into the matching wizard. Single = max fidelity; Team = a baseline
// topology staffed with templated claws. Company/Org are next rungs.
import { useState } from "react";
import { CreateClawForm } from "./CreateClawForm";
import { TeamWizard } from "./TeamWizard";
type Scope = "choose" | "single" | "team";
const TIERS: { key: Scope; title: string; blurb: string; soon?: boolean }[] = [
{ key: "single", title: "Single claw", blurb: "One agent, crafted by you — maximum control & fidelity." },
{ key: "team", title: "Team", blurb: "A baseline topology staffed with templated claws." },
{ key: "company" as Scope, title: "Company", blurb: "A topology of teams.", soon: true },
{ key: "org" as Scope, title: "Organization", blurb: "Multiple company templates.", soon: true },
];
export function DeployWizard() {
const [scope, setScope] = useState<Scope>("choose");
if (scope === "single") return <CreateClawForm />;
if (scope === "team") return <TeamWizard />;
return (
<div className="grid w-full max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
{TIERS.map((t) => (
<button
key={t.title}
type="button"
disabled={t.soon}
onClick={() => !t.soon && setScope(t.key)}
className={`rounded-xl border p-4 text-left transition-colors ${
t.soon
? "border-border/60 opacity-50"
: "border-border hover:border-coral hover:bg-surface-warm"
}`}
>
<p className="text-sm font-semibold text-foreground">
{t.title}
{t.soon ? <span className="ml-2 text-xxs text-muted-foreground">soon</span> : null}
</p>
<p className="mt-1 text-xs text-muted-foreground">{t.blurb}</p>
</button>
))}
</div>
);
}
@@ -0,0 +1,262 @@
"use client";
// Deploy a TEAM: pick a baseline topology → auto-staff it with templated claws
// (editable name/model/persona) → preview the graph → create. The team becomes
// real claws wired into a runnable topology.
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { TopologyGraphView } from "@/components/topology/TopologyGraphView";
import type { TopologyGraph } from "@/lib/api/topology";
interface RoleWeight {
role: string;
weight: number;
}
interface CatalogEntry {
kind: string;
name: string;
description: string;
role_distribution: RoleWeight[];
}
interface Member {
role: string;
name: string;
model: string;
system_prompt: string;
}
const MODELS = ["claude", "glm", "gemini", "kimi", "groq"] as const;
const ACCENTS = ["#f96565", "#65a8f9", "#65f9a8", "#f9d965", "#c465f9"];
const NAME_POOL = [
"Scout", "Drafter", "Ledger", "Atlas", "Quill", "Beacon", "Harbor", "Vesper",
"Sage", "Pilot", "Forge", "Echo", "Nova", "Catalyst", "Compass", "Relay",
];
/** Largest-remainder apportionment of `size` across the role distribution. */
function staff(dist: RoleWeight[], size: number): Member[] {
if (dist.length === 0) return [];
const raw = dist.map((d) => ({ role: d.role, exact: d.weight * size }));
const base = raw.map((r) => ({ role: r.role, n: Math.floor(r.exact), rem: r.exact - Math.floor(r.exact) }));
let used = base.reduce((s, b) => s + b.n, 0);
base.sort((a, b) => b.rem - a.rem);
for (let i = 0; used < size && i < base.length * 4; i++) {
base[i % base.length].n += 1;
used += 1;
}
const members: Member[] = [];
let k = 0;
for (const b of base) {
for (let i = 0; i < b.n; i++) {
members.push({
role: b.role,
name: NAME_POOL[k % NAME_POOL.length],
model: "claude",
system_prompt: "",
});
k += 1;
}
}
return members;
}
export function TeamWizard() {
const router = useRouter();
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
const [step, setStep] = useState<"pick" | "staff">("pick");
const [kind, setKind] = useState<string>("hierarchical");
const [size, setSize] = useState(4);
const [name, setName] = useState("New team");
const [members, setMembers] = useState<Member[]>([]);
const [graph, setGraph] = useState<TopologyGraph | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const r = await fetch("/api/topologies");
if (r.ok) setCatalog((await r.json()) as CatalogEntry[]);
} catch {
/* ignore */
}
})();
}, []);
const selected = useMemo(() => catalog.find((c) => c.kind === kind), [catalog, kind]);
async function toStaff() {
const dist = selected?.role_distribution ?? [{ role: "worker", weight: 1 }];
const staffed = staff(dist, Math.max(1, size));
setMembers(staffed);
setStep("staff");
// build a preview graph
try {
const r = await fetch("/api/topologies/build", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind, roles: staffed.map((m) => m.role) }),
});
if (r.ok) setGraph((await r.json()) as TopologyGraph);
} catch {
/* preview is best-effort */
}
}
function patch(i: number, p: Partial<Member>) {
setMembers((ms) => ms.map((m, j) => (j === i ? { ...m, ...p } : m)));
}
async function create() {
setBusy(true);
setError(null);
try {
const res = await fetch("/api/teams", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, kind, members }),
});
if (res.status !== 201) throw new Error(`Create failed (${res.status})`);
const { team_id } = (await res.json()) as { team_id: string };
router.push(`/teams/${team_id}`);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Create failed");
setBusy(false);
}
}
if (step === "pick") {
return (
<div className="flex w-full max-w-2xl flex-col gap-4">
<label className="text-xs text-muted-foreground">
Team name
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground"
/>
</label>
<p className="text-sm font-medium">Pick a baseline topology</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{catalog.map((c) => (
<button
key={c.kind}
type="button"
onClick={() => setKind(c.kind)}
className={`rounded-lg border p-3 text-left transition-colors ${
kind === c.kind ? "border-coral bg-surface-warm" : "border-border hover:bg-muted/30"
}`}
>
<p className="text-sm font-medium capitalize text-foreground">{c.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
<p className="mt-1 text-xxs text-muted-foreground">
{c.role_distribution.map((r) => `${Math.round(r.weight * 100)}% ${r.role}`).join(" · ")}
</p>
</button>
))}
</div>
<label className="text-xs text-muted-foreground">
Team size
<input
type="number"
min={1}
max={12}
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="mt-1 w-24 rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground"
/>
</label>
<div>
<button
type="button"
onClick={toStaff}
disabled={!kind}
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white disabled:opacity-40"
>
Staff the team →
</button>
</div>
</div>
);
}
// step === "staff"
return (
<div className="flex w-full max-w-3xl flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{name} · <span className="capitalize text-muted-foreground">{kind}</span> · {members.length} claws
</p>
<button
type="button"
onClick={() => setStep("pick")}
className="text-xs text-muted-foreground hover:text-foreground"
>
‹ Back
</button>
</div>
{graph ? (
<div className="overflow-hidden rounded-lg border border-border">
<TopologyGraphView graph={graph} />
</div>
) : null}
<div className="flex flex-col gap-2">
{members.map((m, i) => (
<div
key={i}
className="flex flex-wrap items-center gap-2 rounded-lg border border-border p-2"
>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: ACCENTS[i % ACCENTS.length] }}
/>
<input
value={m.name}
onChange={(e) => patch(i, { name: e.target.value })}
className="w-28 rounded-md border border-input bg-subtle px-2 py-1 text-sm text-foreground"
/>
<input
value={m.role}
onChange={(e) => patch(i, { role: e.target.value })}
className="w-32 rounded-md border border-input bg-subtle px-2 py-1 text-xs text-muted-foreground"
/>
<select
value={m.model}
onChange={(e) => patch(i, { model: e.target.value })}
className="rounded-md border border-input bg-subtle px-2 py-1 text-xs text-foreground"
>
{MODELS.map((mm) => (
<option key={mm} value={mm}>
{mm}
</option>
))}
</select>
<input
value={m.system_prompt}
onChange={(e) => patch(i, { system_prompt: e.target.value })}
placeholder="persona / instructions (optional)"
className="min-w-0 flex-1 rounded-md border border-input bg-subtle px-2 py-1 text-xs text-foreground"
/>
</div>
))}
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div>
<button
type="button"
onClick={create}
disabled={busy || members.length === 0}
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? "Deploying team…" : `Deploy ${members.length} claws`}
</button>
</div>
</div>
);
}