dashboard: collapsible context sidebar (research/loops/repos/infra)
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:
@@ -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}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user