dashboard: collapsible context sidebar (research/loops/repos/infra)
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 43s
ci / rust (push) Successful in 3m33s
ci / e2e (push) Skipped
ci / publish (push) Successful in 4m55s

The earlier PR added a chevron to RosterColumn but the research /
loops / repos / infra tiers render a completely different sidebar
(ResearchList/LoopsList/…) inside a fixed 252px div in Dashboard.tsx
— the RosterColumn toggle never appeared on those pages.

Add a proper collapse on the Dashboard's own context-list wrapper:
- Overlaid PanelLeftClose chevron top-right (top: 10, right: 8) so
  it doesn't fight the per-tier header content.
- When collapsed, wrapper shrinks to 32px with a PanelLeftOpen
  button; canvas gets the reclaimed ~220px.
- State via useSyncExternalStore on localStorage
  ('cm.dashboard.sidebarCollapsed'), same pattern I used on
  RosterColumn to satisfy react-hooks/set-state-in-effect.
This commit is contained in:
Omar Sobh
2026-07-15 12:06:35 -07:00
parent 7e043bcfd9
commit 1876641415
@@ -9,11 +9,11 @@
// tier shows the agent's anatomy. The right slide-out is per-agent (the claw's // tier shows the agent's anatomy. The right slide-out is per-agent (the claw's
// own computer) at claw tier, and the team's shared apps at team tier. // own computer) at claw tier, and the team's shared apps at team tier.
import { useEffect, useRef, useState, type CSSProperties } from "react"; import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
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, PanelRight, Trash2, Users, Wrench, X } from "lucide-react"; import { Bot, Brain, History, MessageSquare, Monitor, PanelLeftClose, PanelLeftOpen, PanelRight, 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";
@@ -51,6 +51,22 @@ import { DeviceSizeToggle } from "@/components/computer/DeviceSizeToggle";
const COMPUTER_WIDTH: Record<DeviceSize, string> = { phone: "448px", tablet: "67%", full: "100%" }; const COMPUTER_WIDTH: Record<DeviceSize, string> = { phone: "448px", tablet: "67%", full: "100%" };
const SIDEBAR_COLLAPSE_KEY = "cm.dashboard.sidebarCollapsed";
function subscribeSidebarCollapse(cb: () => void) {
const handler = (e: StorageEvent) => {
if (e.key === SIDEBAR_COLLAPSE_KEY) cb();
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}
function readSidebarCollapse(): boolean {
try {
return localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === "1";
} catch {
return false;
}
}
type Tier = "world" | "research" | "loops" | "claw" | "repos" | "infra"; type Tier = "world" | "research" | "loops" | "claw" | "repos" | "infra";
const mono = "'JetBrains Mono', ui-monospace, monospace"; const mono = "'JetBrains Mono', ui-monospace, monospace";
@@ -210,6 +226,28 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
const fallbackOrg = orgs[0] ?? EMPTY_ORG; const fallbackOrg = orgs[0] ?? EMPTY_ORG;
const [tier, setTier] = useState<Tier>("claw"); const [tier, setTier] = useState<Tier>("claw");
// Collapse the ~252px context sidebar (ResearchList / LoopsList /
// etc.) into a thin 32px rail so the canvas gets the extra width.
// Persisted in localStorage via useSyncExternalStore — SSR returns
// false (matches initial client render), subsequent state changes
// flow through the storage event.
const sidebarCollapsed = useSyncExternalStore(
subscribeSidebarCollapse,
readSidebarCollapse,
() => false,
);
function toggleSidebar() {
const next = !readSidebarCollapse();
try {
localStorage.setItem("cm.dashboard.sidebarCollapsed", next ? "1" : "0");
// Same-tab writes don't fire native storage events — nudge subscribers.
window.dispatchEvent(
new StorageEvent("storage", { key: "cm.dashboard.sidebarCollapsed" }),
);
} catch {
/* ignore */
}
}
const [orgId, setOrgId] = useState<string>(fallbackOrg.id); const [orgId, setOrgId] = useState<string>(fallbackOrg.id);
const [companyId, setCompanyId] = useState<string>(fallbackOrg.companies[0]?.id ?? ""); const [companyId, setCompanyId] = useState<string>(fallbackOrg.companies[0]?.id ?? "");
const [teamId, setTeamId] = useState<string>(fallbackOrg.companies[0]?.teams[0]?.id ?? ""); const [teamId, setTeamId] = useState<string>(fallbackOrg.companies[0]?.teams[0]?.id ?? "");
@@ -631,8 +669,33 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
</div> </div>
{/* CONTEXT LIST */} {/* CONTEXT LIST — collapsible via a chevron in the top-right of
<div style={{ width: 252, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", minHeight: 0 }}> its header. Persisted per-workspace in localStorage. */}
{sidebarCollapsed ? (
<div style={{ width: 32, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", alignItems: "center", padding: "10px 0" }}>
<button
type="button"
onClick={toggleSidebar}
title="Expand sidebar"
aria-label="Expand sidebar"
style={{ width: 28, height: 28, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#cfcfd5", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
>
<PanelLeftOpen aria-hidden size={14} />
</button>
</div>
) : (
<div style={{ position: "relative", width: 252, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", minHeight: 0 }}>
{/* Collapse chevron overlaid at the top-right so it works on
every tier's header without editing each one. */}
<button
type="button"
onClick={toggleSidebar}
title="Collapse sidebar"
aria-label="Collapse sidebar"
style={{ position: "absolute", top: 10, right: 8, zIndex: 5, width: 24, height: 24, borderRadius: 6, border: "1px solid rgba(255,255,255,.12)", background: "rgba(0,0,0,.35)", color: "#cfcfd5", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
>
<PanelLeftClose aria-hidden size={13} />
</button>
{/* Header: tiers get a Structure/Templates toggle; the claw page gets a {/* Header: tiers get a Structure/Templates toggle; the claw page gets a
plain "Claws" header — its sidebar is a flat list of agents. */} plain "Claws" header — its sidebar is a flat list of agents. */}
{isWorld ? ( {isWorld ? (
@@ -751,6 +814,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</> </>
)} )}
</div> </div>
)}
</> </>
) : null} ) : null}