level-up UI: inbox + review drawer + per-claw/per-team triggers

Fills the frontend gap left after slice 8.5 shipped the level-up
backend without any UI. Reviewers can now:
  - See pending proposals across the workspace (LevelUpInbox)
  - Trigger a proposal from any claw's ClawCommandCenter header
  - Trigger a team-scoped proposal from TeamObserver's header
  - Review one proposal item-by-item and apply the approved subset
    (or reject all) via LevelUpDrawer

The drawer preselects auto-applicable kinds (identity_refinement,
skill_add, skill_candidate, brain_consolidation) and disables the
manual-only kinds (roster_change, mcp_bundle_change) with an
inline "manual — needs team wizard" hint, matching what the
backend applier does per commit 9b5e63c.

New files:
  - frontend/src/lib/api/level-up.ts — typed client for the 6 endpoints
  - frontend/src/components/dashboard/LevelUpDrawer.tsx — review pane
  - frontend/src/components/dashboard/LevelUpInbox.tsx — pending list

Wired:
  - ClawCommandCenter identity header — "Level up" pill (purple)
  - TeamObserver header — "Level up team" pill (purple)

The inbox is deliberately not yet mounted anywhere; it's a
composable component ready to drop into the missions or agent tier
(follow-up decision on which surface hosts the global list).
This commit is contained in:
Omar Sobh
2026-07-19 18:41:19 -07:00
parent fdb8cfeecc
commit a3d5a5a96d
5 changed files with 737 additions and 2 deletions
@@ -7,7 +7,10 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Activity, Brain, Camera } from "lucide-react"; import { Activity, Brain, Camera, Sparkles } from "lucide-react";
import { proposeForAgent } from "@/lib/api/level-up";
import { LevelUpDrawer } from "./LevelUpDrawer";
import type { DemoAgent } from "@/lib/dashboard-demo"; import type { DemoAgent } from "@/lib/dashboard-demo";
import { useAgentTelemetry, useLiveEvent } from "@/lib/live/useClawmatesLive"; import { useAgentTelemetry, useLiveEvent } from "@/lib/live/useClawmatesLive";
@@ -205,6 +208,22 @@ export function ClawCommandCenter({
const tele = useAgentTelemetry(agent.id); const tele = useAgentTelemetry(agent.id);
const doors = tele?.doorsPending ?? 0; const doors = tele?.doorsPending ?? 0;
const [levelUpBusy, setLevelUpBusy] = useState(false);
const [levelUpOpenId, setLevelUpOpenId] = useState<string | null>(null);
const [levelUpError, setLevelUpError] = useState<string | null>(null);
const proposeLevelUp = async () => {
setLevelUpBusy(true);
setLevelUpError(null);
try {
const { proposal_id } = await proposeForAgent(agent.id);
setLevelUpOpenId(proposal_id);
} catch (e) {
setLevelUpError(e instanceof Error ? e.message : "level-up failed");
} finally {
setLevelUpBusy(false);
}
};
return ( return (
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", background: "#08080a" }}> <div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", background: "#08080a" }}>
{/* Identity header strip — compact. */} {/* Identity header strip — compact. */}
@@ -220,7 +239,41 @@ export function ClawCommandCenter({
<div style={{ fontFamily: mono, fontSize: 11.5, letterSpacing: ".06em", color: "#ff8a7a", marginTop: 2 }}>{agent.role}<span style={{ color: "#5a5a62" }}> · part of {teamName}</span></div> <div style={{ fontFamily: mono, fontSize: 11.5, letterSpacing: ".06em", color: "#ff8a7a", marginTop: 2 }}>{agent.role}<span style={{ color: "#5a5a62" }}> · part of {teamName}</span></div>
</div> </div>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<button
type="button"
onClick={proposeLevelUp}
disabled={levelUpBusy}
title="Ask the proposer to suggest improvements to this claw's identity, skills, and brain"
style={{
padding: "6px 12px",
borderRadius: 8,
border: "1px solid rgba(201,160,255,.45)",
background: "rgba(201,160,255,.1)",
color: "#c9a0ff",
fontFamily: mono,
fontSize: 11,
letterSpacing: ".08em",
textTransform: "uppercase",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
opacity: levelUpBusy ? 0.5 : 1,
}}
>
<Sparkles size={12} />
{levelUpBusy ? "Proposing…" : "Level up"}
</button>
</div> </div>
{levelUpError && (
<div style={{ padding: "6px 22px", color: "#ff8a7a", fontSize: 11 }}>{levelUpError}</div>
)}
{levelUpOpenId && (
<LevelUpDrawer
proposalId={levelUpOpenId}
onClose={() => setLevelUpOpenId(null)}
/>
)}
{/* Metric grid — 2-column, wraps to 3 rows for the 5 tiles. {/* Metric grid — 2-column, wraps to 3 rows for the 5 tiles.
Activity now lives here (top-of-fold quick-glance); the beefier Activity now lives here (top-of-fold quick-glance); the beefier
@@ -0,0 +1,411 @@
"use client";
// LevelUpDrawer — review one pending proposal, pick which suggested
// items to apply, then apply or reject. Loads by proposal_id so it
// can be opened both from the global inbox and from the "just
// proposed" flow on ClawCommandCenter / TeamObserver.
//
// Item-kind → renderer table sits at the bottom. Each kind gets a
// compact card that shows the payload without hiding surprises.
import { useCallback, useEffect, useState } from "react";
import {
applyProposal,
getProposal,
rejectProposal,
type LevelUpProposal,
type SuggestedItem,
type SuggestedItemKind,
} from "@/lib/api/level-up";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const KIND_LABEL: Record<SuggestedItemKind, string> = {
identity_refinement: "Identity refinement",
skill_add: "Skill add",
skill_candidate: "New skill candidate",
brain_consolidation: "Brain consolidation",
roster_change: "Roster change",
mcp_bundle_change: "MCP bundle change",
};
const KIND_COLOR: Record<SuggestedItemKind, string> = {
identity_refinement: "#7cd6e0",
skill_add: "#7fd0a0",
skill_candidate: "#f0c264",
brain_consolidation: "#c9a0ff",
roster_change: "#ff8a7a",
mcp_bundle_change: "#ffb44a",
};
const AUTO_APPLICABLE: SuggestedItemKind[] = [
"identity_refinement",
"skill_add",
"skill_candidate",
"brain_consolidation",
];
export function LevelUpDrawer({
proposalId,
onClose,
onChanged,
}: {
proposalId: string;
onClose: () => void;
onChanged?: () => void;
}) {
const [proposal, setProposal] = useState<LevelUpProposal | null>(null);
const [approved, setApproved] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const p = await getProposal(proposalId);
if (cancelled) return;
setProposal(p);
const preselect = new Set<string>();
(p.payload.suggested_items ?? []).forEach((it) => {
if (AUTO_APPLICABLE.includes(it.kind)) preselect.add(it.id);
});
setApproved(preselect);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "load failed");
}
})();
return () => {
cancelled = true;
};
}, [proposalId]);
const toggle = useCallback((id: string) => {
setApproved((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const doApply = useCallback(async () => {
setBusy(true);
setError(null);
try {
await applyProposal(proposalId, Array.from(approved));
onChanged?.();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "apply failed");
} finally {
setBusy(false);
}
}, [proposalId, approved, onChanged, onClose]);
const doReject = useCallback(async () => {
setBusy(true);
setError(null);
try {
await rejectProposal(proposalId);
onChanged?.();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "reject failed");
} finally {
setBusy(false);
}
}, [proposalId, onChanged, onClose]);
return (
<div
role="dialog"
aria-modal
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.55)",
display: "flex",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(560px, 100vw)",
height: "100vh",
background: "#141419",
borderLeft: "1px solid rgba(255,255,255,.08)",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
padding: "14px 18px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".14em",
color: "#c9a0ff",
textTransform: "uppercase",
}}
>
Level-up proposal
</span>
<button
type="button"
onClick={onClose}
style={{
marginLeft: "auto",
background: "transparent",
border: "1px solid rgba(255,255,255,.1)",
color: "#a0a0a8",
borderRadius: 6,
padding: "4px 10px",
cursor: "pointer",
fontSize: 12,
}}
>
Close
</button>
</div>
{!proposal && !error && (
<div style={{ padding: 20, color: "#5ec8d8", fontFamily: mono }}>
Loading…
</div>
)}
{error && (
<div style={{ padding: 20, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
)}
{proposal && (
<>
<div
style={{
flex: 1,
overflow: "auto",
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div style={{ display: "flex", gap: 12, fontSize: 11, color: "#8a8a92" }}>
<span>
status:{" "}
<b style={{ color: proposal.status === "pending" ? "#f0c264" : "#7fd0a0" }}>
{proposal.status}
</b>
</span>
{proposal.model && (
<span>
model: <b style={{ color: "#cfcfd5" }}>{proposal.model}</b>
</span>
)}
<span style={{ marginLeft: "auto" }}>
{new Date(proposal.created_at).toLocaleString()}
</span>
</div>
{proposal.payload.summary ? (
<p style={{ margin: 0, fontSize: 13, color: "#cfcfd5", lineHeight: 1.55 }}>
{proposal.payload.summary}
</p>
) : null}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{(proposal.payload.suggested_items ?? []).length === 0 && (
<div style={{ color: "#8a8a92", fontSize: 12 }}>
Proposer returned no items — nothing to apply.
</div>
)}
{(proposal.payload.suggested_items ?? []).map((item) => {
const isApproved = approved.has(item.id);
const canApply =
proposal.status === "pending" && AUTO_APPLICABLE.includes(item.kind);
return (
<label
key={item.id}
style={{
display: "flex",
gap: 10,
padding: 12,
borderRadius: 8,
border: `1px solid ${
isApproved ? "rgba(127,208,160,.4)" : "rgba(255,255,255,.08)"
}`,
background: isApproved ? "rgba(127,208,160,.06)" : "rgba(255,255,255,.02)",
cursor: canApply ? "pointer" : "default",
opacity: canApply ? 1 : 0.65,
}}
>
<input
type="checkbox"
checked={isApproved}
disabled={!canApply}
onChange={() => toggle(item.id)}
style={{ marginTop: 3 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: KIND_COLOR[item.kind] ?? "#a0a0a8",
textTransform: "uppercase",
}}
>
{KIND_LABEL[item.kind] ?? item.kind}
</span>
{!canApply && proposal.status === "pending" && (
<span
style={{
fontSize: 10,
color: "#8a8a92",
fontStyle: "italic",
}}
>
manual — needs team wizard
</span>
)}
{proposal.applied_items.includes(item.id) && (
<span style={{ fontSize: 10, color: "#7fd0a0" }}>applied</span>
)}
</div>
{item.title ? (
<div style={{ fontSize: 13, color: "#f3f3f5", marginBottom: 4 }}>
{String(item.title)}
</div>
) : null}
{item.rationale ? (
<div style={{ fontSize: 12, color: "#a0a0a8", lineHeight: 1.5 }}>
{String(item.rationale)}
</div>
) : null}
<PerKindDetails item={item} />
</div>
</label>
);
})}
</div>
</div>
<div
style={{
padding: "12px 18px",
borderTop: "1px solid rgba(255,255,255,.06)",
display: "flex",
gap: 8,
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={doReject}
disabled={busy || proposal.status !== "pending"}
style={{
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(255,138,122,.4)",
background: "transparent",
color: "#ff8a7a",
fontSize: 12,
cursor: proposal.status === "pending" ? "pointer" : "not-allowed",
opacity: busy || proposal.status !== "pending" ? 0.5 : 1,
}}
>
Reject all
</button>
<button
type="button"
onClick={doApply}
disabled={busy || proposal.status !== "pending" || approved.size === 0}
style={{
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,160,.5)",
background: "rgba(127,208,160,.12)",
color: "#7fd0a0",
fontSize: 12,
cursor:
proposal.status === "pending" && approved.size > 0
? "pointer"
: "not-allowed",
opacity:
busy || proposal.status !== "pending" || approved.size === 0 ? 0.5 : 1,
}}
>
{busy ? "Applying…" : `Apply ${approved.size}`}
</button>
</div>
</>
)}
</div>
</div>
);
}
function PerKindDetails({ item }: { item: SuggestedItem }) {
// Show a compact view of the load-bearing fields per kind. Keeps
// surprises visible without dumping the whole json.
const preview = (() => {
switch (item.kind) {
case "identity_refinement":
return item.new_system_prompt ?? item.diff;
case "skill_add":
return item.skill_name ?? item.skill_id;
case "skill_candidate":
return item.skill_body ?? item.markdown;
case "brain_consolidation":
return item.new_agent_md ?? item.consolidated_agent_md;
case "roster_change":
case "mcp_bundle_change":
return item.diff ?? item.description;
default:
return undefined;
}
})();
if (preview === undefined || preview === null) return null;
const text = typeof preview === "string" ? preview : JSON.stringify(preview, null, 2);
const clipped = text.length > 320 ? text.slice(0, 320) + "…" : text;
return (
<pre
style={{
marginTop: 6,
marginBottom: 0,
padding: 8,
borderRadius: 6,
background: "rgba(0,0,0,.35)",
color: "#cfcfd5",
fontSize: 11,
fontFamily: mono,
maxHeight: 160,
overflow: "auto",
whiteSpace: "pre-wrap",
}}
>
{clipped}
</pre>
);
}
@@ -0,0 +1,142 @@
"use client";
// LevelUpInbox — pending proposals list. Small enough to inline into
// any tier tab; opens LevelUpDrawer on click. Auto-refreshes on
// proposal apply/reject.
import { useCallback, useEffect, useState } from "react";
import { Sparkles } from "lucide-react";
import {
listPendingProposals,
type LevelUpProposal,
} from "@/lib/api/level-up";
import { LevelUpDrawer } from "./LevelUpDrawer";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function LevelUpInbox() {
const [rows, setRows] = useState<LevelUpProposal[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const r = await listPendingProposals();
setRows(r);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Sparkles size={13} style={{ color: "#c9a0ff" }} />
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".14em",
color: "#c9a0ff",
textTransform: "uppercase",
}}
>
Pending level-up proposals ({rows.length})
</span>
<button
type="button"
onClick={load}
disabled={loading}
style={{
marginLeft: "auto",
padding: "3px 10px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.1)",
background: "transparent",
color: "#a0a0a8",
fontSize: 11,
cursor: "pointer",
opacity: loading ? 0.5 : 1,
}}
>
{loading ? "…" : "Refresh"}
</button>
</div>
{error && (
<div style={{ padding: 8, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
)}
{!loading && !error && rows.length === 0 && (
<div style={{ padding: 8, color: "#6a6a72", fontSize: 12 }}>
No pending proposals. Run Level-Up on a claw or team to generate one.
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{rows.map((p) => {
const itemCount = (p.payload.suggested_items ?? []).length;
const scope = p.agent_id ? "claw" : p.team_id ? "team" : "?";
return (
<button
key={p.id}
type="button"
onClick={() => setOpenId(p.id)}
style={{
textAlign: "left",
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.08)",
background: "rgba(255,255,255,.02)",
color: "#cfcfd5",
fontSize: 12,
cursor: "pointer",
display: "flex",
gap: 12,
alignItems: "center",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color: scope === "team" ? "#7cd6e0" : "#7fd0a0",
textTransform: "uppercase",
}}
>
{scope}
</span>
<span style={{ flex: 1, minWidth: 0, color: "#f3f3f5" }}>
{p.payload.summary
? String(p.payload.summary).slice(0, 96)
: `Proposal ${p.id.slice(0, 8)}…`}
</span>
<span style={{ color: "#a0a0a8", fontFamily: mono, fontSize: 10 }}>
{itemCount} item{itemCount === 1 ? "" : "s"}
</span>
<span style={{ color: "#6a6a72", fontSize: 10 }}>
{new Date(p.created_at).toLocaleDateString()}
</span>
</button>
);
})}
</div>
{openId && (
<LevelUpDrawer
proposalId={openId}
onClose={() => setOpenId(null)}
onChanged={load}
/>
)}
</div>
);
}
@@ -7,12 +7,14 @@
// A2A invocations among team members as they happen. No composer — observe only. // A2A invocations among team members as they happen. No composer — observe only.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Users, MessagesSquare, Hash, Globe, GitBranch } from "lucide-react"; import { Users, MessagesSquare, Hash, Globe, GitBranch, Sparkles } from "lucide-react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { useLiveEvent } from "@/lib/live/useClawmatesLive"; import { useLiveEvent } from "@/lib/live/useClawmatesLive";
import { relativeTime } from "@/lib/format/relative-time"; import { relativeTime } from "@/lib/format/relative-time";
import { proposeForTeam } from "@/lib/api/level-up";
import { LevelUpDrawer } from "./LevelUpDrawer";
interface Room { interface Room {
id: string; id: string;
@@ -43,6 +45,10 @@ export function TeamObserver({ agent }: { agent: Agent }) {
const [members, setMembers] = useState<Set<string>>(new Set([agent.id])); const [members, setMembers] = useState<Set<string>>(new Set([agent.id]));
const [names, setNames] = useState<Record<string, string>>({}); const [names, setNames] = useState<Record<string, string>>({});
const [teamName, setTeamName] = useState<string | null>(null); const [teamName, setTeamName] = useState<string | null>(null);
const [teamId, setTeamId] = useState<string | null>(null);
const [levelUpBusy, setLevelUpBusy] = useState(false);
const [levelUpOpenId, setLevelUpOpenId] = useState<string | null>(null);
const [levelUpError, setLevelUpError] = useState<string | null>(null);
const [resolving, setResolving] = useState(true); const [resolving, setResolving] = useState(true);
const [feed, setFeed] = useState<FeedItem[]>([]); const [feed, setFeed] = useState<FeedItem[]>([]);
const seq = useRef(0); const seq = useRef(0);
@@ -74,6 +80,7 @@ export function TeamObserver({ agent }: { agent: Agent }) {
if (!alive) return; if (!alive) return;
setMembers(new Set(detail.members.map((m) => m.claw_id))); setMembers(new Set(detail.members.map((m) => m.claw_id)));
setTeamName(detail.name); setTeamName(detail.name);
setTeamId(t.id);
break; break;
} }
} }
@@ -129,7 +136,36 @@ export function TeamObserver({ agent }: { agent: Agent }) {
> >
<Users aria-hidden size={12} className="text-coral" /> <Users aria-hidden size={12} className="text-coral" />
OBSERVING {(teamName ?? "TEAM").toUpperCase()} · {members.size} MEMBER{members.size === 1 ? "" : "S"} · READ-ONLY OBSERVING {(teamName ?? "TEAM").toUpperCase()} · {members.size} MEMBER{members.size === 1 ? "" : "S"} · READ-ONLY
{teamId ? (
<button
type="button"
onClick={async () => {
setLevelUpBusy(true);
setLevelUpError(null);
try {
const { proposal_id } = await proposeForTeam(teamId);
setLevelUpOpenId(proposal_id);
} catch (e) {
setLevelUpError(e instanceof Error ? e.message : "level-up failed");
} finally {
setLevelUpBusy(false);
}
}}
disabled={levelUpBusy}
className="ml-auto inline-flex items-center gap-1 rounded-(--radius) border border-[rgba(201,160,255,0.4)] bg-[rgba(201,160,255,0.08)] px-2 py-0.5 text-xxs uppercase tracking-wide text-[#c9a0ff]"
style={{ opacity: levelUpBusy ? 0.5 : 1 }}
>
<Sparkles aria-hidden size={11} />
{levelUpBusy ? "Proposing…" : "Level up team"}
</button>
) : null}
</div> </div>
{levelUpError ? (
<div className="px-4 py-1 text-xxs text-[#ff8a7a]">{levelUpError}</div>
) : null}
{levelUpOpenId ? (
<LevelUpDrawer proposalId={levelUpOpenId} onClose={() => setLevelUpOpenId(null)} />
) : null}
<div className="min-h-0 flex-1 overflow-auto p-3"> <div className="min-h-0 flex-1 overflow-auto p-3">
{rooms.length > 0 ? ( {rooms.length > 0 ? (
+93
View File
@@ -0,0 +1,93 @@
// Level-up API client — Slice 8.5.
//
// Two entry points create a proposal (per-agent, per-team). The reviewer
// reads back the proposal, picks which suggested_items to apply, and
// posts approve or reject.
export type ProposalStatus =
| "pending"
| "applied"
| "partial"
| "rejected";
export type SuggestedItemKind =
| "identity_refinement"
| "skill_add"
| "skill_candidate"
| "brain_consolidation"
| "roster_change"
| "mcp_bundle_change";
export interface SuggestedItem {
id: string;
kind: SuggestedItemKind;
title?: string;
rationale?: string;
// Freeform per-kind payload — the applier reads only the fields it
// needs. Kept as unknown here; the drawer renders a per-kind view.
[k: string]: unknown;
}
export interface ProposalPayload {
summary?: string;
suggested_items?: SuggestedItem[];
[k: string]: unknown;
}
export interface LevelUpProposal {
id: string;
workspace_id: string;
agent_id: string | null;
team_id: string | null;
status: ProposalStatus;
payload: ProposalPayload;
applied_items: string[];
model: string | null;
created_by: string | null;
approved_by: string | null;
created_at: string;
applied_at: string | null;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`${init?.method ?? "GET"} ${path} → ${r.status} ${text}`);
}
if (r.status === 204) return undefined as T;
return (await r.json()) as T;
}
export const listPendingProposals = () =>
api<LevelUpProposal[]>("/api/level-up-proposals");
export const getProposal = (id: string) =>
api<LevelUpProposal>(`/api/level-up-proposals/${id}`);
export const proposeForAgent = (agentId: string) =>
api<{ proposal_id: string }>(`/api/claws/${agentId}/level-up`, {
method: "POST",
});
export const proposeForTeam = (teamId: string) =>
api<{ proposal_id: string }>(`/api/teams/${teamId}/level-up`, {
method: "POST",
});
export const applyProposal = (id: string, approvedItemIds: string[]) =>
api<LevelUpProposal>(`/api/level-up-proposals/${id}/apply`, {
method: "POST",
body: JSON.stringify({ approved_item_ids: approvedItemIds }),
});
export const rejectProposal = (id: string) =>
api<LevelUpProposal>(`/api/level-up-proposals/${id}/reject`, {
method: "POST",
});