feat(ui): level up moves to the agents sidebar; repos open collapsed

Level up now sits at the bottom of the Agents sidebar, labelled with the
selected agent's name, and renders only once an agent is selected — it is hidden
during select mode so the reap bar stays the single footer action there. It is
gone from the ClawCommandCenter header.

Repos open with every org folded. Rather than seeding a "collapsed" set with all
keys on load, the state tracks EXPANDED: a smaller change that also stays correct
for orgs that arrive later from a sync, which a seeded set would render open.
With 182 repos across 8 orgs, an all-expanded default buried the org names the
list is meant to be navigated by.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-13 10:47:14 -07:00
co-authored by Claude Opus 5
parent 5db695460f
commit 7cf77a9248
3 changed files with 61 additions and 59 deletions
@@ -7,10 +7,7 @@
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, Sparkles } from "lucide-react"; import { Activity, Brain, Camera } 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";
@@ -208,21 +205,8 @@ 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); // Level up now lives at the bottom of the Agents sidebar (Dashboard), where
const [levelUpOpenId, setLevelUpOpenId] = useState<string | null>(null); // it sits next to the agent you picked rather than in this header.
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" }}>
@@ -239,41 +223,7 @@ 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
@@ -13,7 +13,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties }
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { Bot, Brain, History, MessageSquare, Monitor, PanelLeftClose, PanelLeftOpen, PanelRight, Trash2, Users, Wrench, X } from "lucide-react"; import { Bot, Brain, History, MessageSquare, Monitor, PanelLeftClose, PanelLeftOpen, PanelRight, Sparkles, Trash2, Users, Wrench, X } from "lucide-react";
import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo"; import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
@@ -27,6 +27,8 @@ import { StructureTree, orgNode, type TreeItem, clawNode } from "./StructureTree
import { MissionsList } from "./MissionsList"; import { MissionsList } from "./MissionsList";
import { MissionWizard } from "./MissionWizard"; import { MissionWizard } from "./MissionWizard";
import { LevelUpInbox } from "./LevelUpInbox"; import { LevelUpInbox } from "./LevelUpInbox";
import { LevelUpDrawer } from "./LevelUpDrawer";
import { proposeForAgent } from "@/lib/api/level-up";
import { MissionCanvas } from "./MissionCanvas"; import { MissionCanvas } from "./MissionCanvas";
import { RepoList } from "./RepoList"; import { RepoList } from "./RepoList";
import { RepoCanvas } from "./RepoCanvas"; import { RepoCanvas } from "./RepoCanvas";
@@ -387,6 +389,26 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
(agent ? agentById.get(agent.id) : undefined) ?? (agent ? agentById.get(agent.id) : undefined) ??
(agent ? { id: agent.id, workspace_id: "", name: agent.name, job_title: agent.role, system_prompt: agent.systemPrompt, avatar: "", accent: "#ff6f61", wallpaper: "", managed_by: "", status: "online" } : undefined); (agent ? { id: agent.id, workspace_id: "", name: agent.name, job_title: agent.role, system_prompt: agent.systemPrompt, avatar: "", accent: "#ff6f61", wallpaper: "", managed_by: "", status: "online" } : undefined);
// Level up — moved here from the ClawCommandCenter header so it sits at the
// bottom of the Agents sidebar, next to the agent you picked. Keyed off
// `clawAgent`, so the control simply is not rendered until one is selected.
const [levelUpBusy, setLevelUpBusy] = useState(false);
const [levelUpOpenId, setLevelUpOpenId] = useState<string | null>(null);
const [levelUpError, setLevelUpError] = useState<string | null>(null);
const proposeLevelUp = async () => {
if (!clawAgent) return;
setLevelUpBusy(true);
setLevelUpError(null);
try {
const { proposal_id } = await proposeForAgent(clawAgent.id);
setLevelUpOpenId(proposal_id);
} catch (e) {
setLevelUpError(e instanceof Error ? e.message : "level-up failed");
} finally {
setLevelUpBusy(false);
}
};
// Computer panel state (size + open app), shared with DevicePanel via nuqs. // Computer panel state (size + open app), shared with DevicePanel via nuqs.
const [{ app, device }, setParams] = useQueryStates(panelParsers, { shallow: true }); const [{ app, device }, setParams] = useQueryStates(panelParsers, { shallow: true });
// Custom computer width (px) from dragging the panel's left edge; null = use the // Custom computer width (px) from dragging the panel's left edge; null = use the
@@ -1046,6 +1068,26 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
router.refresh(); router.refresh();
}} }}
/> />
{/* Level up — pinned to the bottom of the Agents sidebar, and only
once an agent is actually selected. Hidden during select mode so
the reap bar below is the single footer action in that flow. */}
{isClaw && clawAgent && !selectMode ? (
<div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 6 }}>
<button
type="button"
onClick={proposeLevelUp}
disabled={levelUpBusy}
title={`Ask the proposer to suggest improvements to ${clawAgent.name}'s identity, skills, and brain`}
style={{ width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 9, 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: levelUpBusy ? "default" : "pointer", opacity: levelUpBusy ? 0.5 : 1 }}
>
<Sparkles aria-hidden size={13} />
{levelUpBusy ? "Proposing…" : `Level up ${clawAgent.name}`}
</button>
{levelUpError ? (
<div style={{ color: "#ff8a7a", fontSize: 11 }}>{levelUpError}</div>
) : null}
</div>
) : null}
{selectMode ? ( {selectMode ? (
<div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontFamily: mono, fontSize: 11, color: selectedItems.length ? "#ff8a7a" : "#6a6a72" }}>{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}</div> <div style={{ fontFamily: mono, fontSize: 11, color: selectedItems.length ? "#ff8a7a" : "#6a6a72" }}>{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}</div>
@@ -1352,6 +1394,9 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
{historyOpen && clawAgent ? <BrainHistoryModal clawId={clawAgent.id} clawName={clawAgent.name} onClose={() => setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null} {historyOpen && clawAgent ? <BrainHistoryModal clawId={clawAgent.id} clawName={clawAgent.name} onClose={() => setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null}
{(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? <TeamRunsModal teamId={sel.id} teamName={sel.label} onClose={() => setRunsOpen(false)} /> : null; })()} {(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? <TeamRunsModal teamId={sel.id} teamName={sel.label} onClose={() => setRunsOpen(false)} /> : null; })()}
{reapOpen ? <ReapProgressModal items={selectedItems} kind={reapKind} onClose={() => setReapOpen(false)} onDone={() => { setReapOpen(false); setSelectMode(false); setSelectedAgents(new Set()); router.refresh(); }} /> : null} {reapOpen ? <ReapProgressModal items={selectedItems} kind={reapKind} onClose={() => setReapOpen(false)} onDone={() => { setReapOpen(false); setSelectMode(false); setSelectedAgents(new Set()); router.refresh(); }} /> : null}
{/* Level-up review drawer. Mounted here rather than inside the sidebar
footer so it is not clipped by the sidebar's own bounds. */}
{levelUpOpenId ? <LevelUpDrawer proposalId={levelUpOpenId} onClose={() => setLevelUpOpenId(null)} /> : null}
{/* Group existing claws into a new named team, then land on its team page. */} {/* Group existing claws into a new named team, then land on its team page. */}
+12 -5
View File
@@ -59,11 +59,18 @@ export function RepoList({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
// Collapsed-org state is per-<connection_id>::<owner> — the same owner // Org fold state is per-<connection_id>::<owner> — the same owner string
// string appearing under two different providers folds independently. // appearing under two different providers folds independently.
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set()); //
// Tracks EXPANDED, not collapsed, so the empty initial set means every org
// starts folded: with 180+ repos across a dozen orgs, an all-expanded default
// buries the org names the list is meant to be navigated by. Inverting the set
// (rather than seeding it with every key on load) also keeps the default
// correct for orgs that arrive later from a sync, which a seeded set would
// render expanded.
const [expandedOrgs, setExpandedOrgs] = useState<Set<string>>(new Set());
const toggleOrg = (key: string) => const toggleOrg = (key: string) =>
setCollapsedOrgs((prev) => { setExpandedOrgs((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(key)) next.delete(key); if (next.has(key)) next.delete(key);
else next.add(key); else next.add(key);
@@ -342,7 +349,7 @@ export function RepoList({
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}> <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{groupByOwner(list).map(([owner, orgRepos]) => { {groupByOwner(list).map(([owner, orgRepos]) => {
const key = `${c.id}::${owner}`; const key = `${c.id}::${owner}`;
const collapsed = collapsedOrgs.has(key); const collapsed = !expandedOrgs.has(key);
return ( return (
<OrgGroup <OrgGroup
key={key} key={key}