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:
@@ -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 ? (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user