Teams UI: deploy scope selector + TeamWizard + Team page
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:
co-authored by
Claude Opus 4.8
parent
8123a27bcf
commit
bba18a4687
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user