From 95f5880a5e93c72c4aef929615b4d342d4758beb Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 11 Jul 2026 00:34:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20user-customizable=20nav=20?= =?UTF-8?q?=E2=80=94=20hide/show=20and=20reorder=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar has grown to ~12 nav items and is getting hard to scan. Add an inline edit mode ("Customize sidebar" in the footer group) with per-item eye toggles and drag/keyboard reordering, persisted per browser in localStorage via the existing Atom.kvs pattern. - Stable `id` on NavItem (href is unstable: the infra gate rewrites it) - mergeOrder drops removed ids and inserts future nav items at their default position so new features never silently vanish - Overview is locked (always visible, pinned first); org feature gating stays authoritative — prefs apply on top of visibleSignalsNavItems - Edit bar pinned outside the sidebar ScrollArea; collapsing the rail mid-edit acts as implicit Done; grips are keyboard-operable Co-Authored-By: Claude Fable 5 --- .../atoms/sidebar-preferences-atoms.test.ts | 89 +++++++ .../src/atoms/sidebar-preferences-atoms.ts | 94 +++++++ .../src/components/dashboard/app-sidebar.tsx | 160 +++++------- .../web/src/components/dashboard/nav-items.ts | 18 +- .../dashboard/sidebar-nav-group.tsx | 245 ++++++++++++++++++ apps/web/src/components/icons/eye-slash.tsx | 38 +++ apps/web/src/components/icons/index.ts | 1 + apps/web/src/hooks/use-sidebar-preferences.ts | 40 +++ 8 files changed, 595 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/atoms/sidebar-preferences-atoms.test.ts create mode 100644 apps/web/src/atoms/sidebar-preferences-atoms.ts create mode 100644 apps/web/src/components/dashboard/sidebar-nav-group.tsx create mode 100644 apps/web/src/components/icons/eye-slash.tsx create mode 100644 apps/web/src/hooks/use-sidebar-preferences.ts diff --git a/apps/web/src/atoms/sidebar-preferences-atoms.test.ts b/apps/web/src/atoms/sidebar-preferences-atoms.test.ts new file mode 100644 index 000000000..f600a9fb6 --- /dev/null +++ b/apps/web/src/atoms/sidebar-preferences-atoms.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest" +import { + LOCKED_NAV_IDS, + applySidebarPrefs, + defaultSidebarPrefs, + mergeOrder, + type SidebarPrefs, +} from "./sidebar-preferences-atoms" +import { visibleSignalsNavItems } from "@/components/dashboard/nav-items" + +const prefs = (overrides: Partial): SidebarPrefs => ({ ...defaultSidebarPrefs, ...overrides }) + +describe("mergeOrder", () => { + it("returns defaults when nothing is stored", () => { + expect(mergeOrder(["a", "b", "c"], [])).toEqual(["a", "b", "c"]) + }) + + it("round-trips a full permutation", () => { + expect(mergeOrder(["a", "b", "c"], ["c", "a", "b"])).toEqual(["c", "a", "b"]) + }) + + it("drops stored ids that no longer exist", () => { + expect(mergeOrder(["a", "b"], ["b", "gone", "a"])).toEqual(["b", "a"]) + }) + + it("inserts new default ids after their nearest preceding default sibling", () => { + // b is new; its default predecessor a is present, so b lands right after a + expect(mergeOrder(["a", "b", "c"], ["c", "a"])).toEqual(["c", "a", "b"]) + }) + + it("inserts a new head-of-defaults id at the front", () => { + expect(mergeOrder(["new", "a", "b"], ["b", "a"])).toEqual(["new", "b", "a"]) + }) +}) + +describe("applySidebarPrefs", () => { + const items = [ + { id: "overview", title: "Overview" }, + { id: "chat", title: "Chat" }, + ] + + it("flags hidden items but keeps them in order", () => { + const rows = applySidebarPrefs("main", items, prefs({ hidden: ["chat"] })) + expect(rows.map((r) => [r.item.id, r.hidden])).toEqual([ + ["overview", false], + ["chat", true], + ]) + }) + + it("never hides locked items even when stored as hidden", () => { + const rows = applySidebarPrefs("main", items, prefs({ hidden: ["overview"] })) + expect(rows[0]).toMatchObject({ item: { id: "overview" }, hidden: false }) + }) + + it("pins locked items back to their default index", () => { + const rows = applySidebarPrefs("main", items, prefs({ order: { main: ["chat", "overview"] } })) + expect(rows.map((r) => r.item.id)).toEqual(["overview", "chat"]) + }) + + it("keeps gated-out items absent even if present in stored order", () => { + const gated = [{ id: "traces", title: "Traces" }] + const rows = applySidebarPrefs("signals", gated, prefs({ order: { signals: ["metrics", "traces"] } })) + expect(rows.map((r) => r.item.id)).toEqual(["traces"]) + }) + + it("orders and hides infra-gated signals items by stable id", () => { + const gatedItems = visibleSignalsNavItems({ infraEnabled: false }) + const infra = gatedItems.find((item) => item.id === "infrastructure") + expect(infra?.href).toBe("/infra/cloudflare") + + const rows = applySidebarPrefs( + "signals", + gatedItems, + prefs({ + order: { signals: ["infrastructure", "logs", "traces", "metrics", "replays"] }, + hidden: ["replays"], + }), + ) + expect(rows.map((r) => r.item.id)).toEqual(["infrastructure", "logs", "traces", "metrics", "replays"]) + expect(rows.find((r) => r.item.id === "replays")?.hidden).toBe(true) + expect(rows.find((r) => r.item.id === "infrastructure")?.item.href).toBe("/infra/cloudflare") + }) + + it("locks overview", () => { + expect(LOCKED_NAV_IDS.has("overview")).toBe(true) + }) +}) diff --git a/apps/web/src/atoms/sidebar-preferences-atoms.ts b/apps/web/src/atoms/sidebar-preferences-atoms.ts new file mode 100644 index 000000000..60e032e23 --- /dev/null +++ b/apps/web/src/atoms/sidebar-preferences-atoms.ts @@ -0,0 +1,94 @@ +import { Atom } from "@/lib/effect-atom" +import { Schema } from "effect" +import { localStorageRuntime } from "@/lib/services/common/storage-runtime" + +export interface SidebarPrefs { + /** Group id -> ordered item ids. Only groups the user has reordered. */ + order: Record + /** Hidden item ids across all groups. */ + hidden: readonly string[] +} + +const SidebarPrefsSchema = Schema.Struct({ + order: Schema.Record(Schema.String, Schema.Array(Schema.String)), + hidden: Schema.Array(Schema.String), +}) as Schema.Codec + +export const defaultSidebarPrefs: SidebarPrefs = { order: {}, hidden: [] } + +// Decode failure falls back to defaultValue; bump the key on incompatible shape changes. +export const sidebarPrefsAtom = Atom.kvs({ + runtime: localStorageRuntime, + key: "maple.sidebar.prefs", + schema: SidebarPrefsSchema, + defaultValue: () => defaultSidebarPrefs, +}) + +/** Items that can never be hidden or displaced from their default position. */ +export const LOCKED_NAV_IDS: ReadonlySet = new Set(["overview"]) + +/** + * Merge a stored order with the current defaults: + * - stored ids not in defaults are dropped (removed/gated-off items) + * - default ids missing from stored are inserted at their default position, + * immediately after the nearest preceding default sibling already present + * (or at the front if none) — so items shipped in future releases show up + * where they belong instead of vanishing or piling up at the end. + */ +export function mergeOrder(defaults: readonly string[], stored: readonly string[]): string[] { + const defaultSet = new Set(defaults) + const result = stored.filter((id) => defaultSet.has(id)) + const present = new Set(result) + for (let i = 0; i < defaults.length; i++) { + const id = defaults[i] + if (present.has(id)) continue + let insertAt = 0 + for (let j = i - 1; j >= 0; j--) { + const idx = result.indexOf(defaults[j]) + if (idx >= 0) { + insertAt = idx + 1 + break + } + } + result.splice(insertAt, 0, id) + present.add(id) + } + return result +} + +export interface SidebarNavRow { + item: T + hidden: boolean +} + +/** + * Apply user prefs to one already-feature-gated group. Returns rows in display + * order, each flagged hidden (edit mode shows hidden rows dimmed; normal mode + * filters them out). Locked ids are never hidden and are pinned back to their + * default index, so drops that displace them self-heal on the next render. + */ +export function applySidebarPrefs( + groupId: string, + items: readonly T[], + prefs: SidebarPrefs, + locked: ReadonlySet = LOCKED_NAV_IDS, +): Array> { + const defaults = items.map((item) => item.id) + const orderIds = mergeOrder(defaults, prefs.order[groupId] ?? []) + for (const id of defaults) { + if (!locked.has(id)) continue + const current = orderIds.indexOf(id) + const target = Math.min(defaults.indexOf(id), orderIds.length - 1) + if (current !== target) { + orderIds.splice(current, 1) + orderIds.splice(target, 0, id) + } + } + const byId = new Map(items.map((item) => [item.id, item])) + const hiddenSet = new Set(prefs.hidden) + return orderIds.flatMap((id) => { + const item = byId.get(id) + if (!item) return [] + return [{ item, hidden: hiddenSet.has(id) && !locked.has(id) }] + }) +} diff --git a/apps/web/src/components/dashboard/app-sidebar.tsx b/apps/web/src/components/dashboard/app-sidebar.tsx index deafece00..a782ab6c4 100644 --- a/apps/web/src/components/dashboard/app-sidebar.tsx +++ b/apps/web/src/components/dashboard/app-sidebar.tsx @@ -1,3 +1,4 @@ +import { useState } from "react" import { Link, useRouterState } from "@tanstack/react-router" import { useUser, useClerk } from "@clerk/clerk-react" import { @@ -9,14 +10,16 @@ import { ChevronUpIcon, ChevronRightIcon, GridSquareCirclePlusIcon, + PencilIcon, } from "@/components/icons" import { investigateNavItems, mainNavItems, topologyNavItems, visibleSignalsNavItems, - type NavSubItem, } from "@/components/dashboard/nav-items" +import { SidebarEditBar, SidebarNavGroup } from "@/components/dashboard/sidebar-nav-group" +import { useSidebarPreferences } from "@/hooks/use-sidebar-preferences" import { showKeyboardShortcuts } from "@/components/command-palette/global-shortcuts" import { KeyboardIcon } from "@/components/icons" import { OrgSwitcher } from "@/components/dashboard/org-switcher" @@ -40,20 +43,20 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, - SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, + useSidebar, } from "@maple/ui/components/ui/sidebar" +import { cn } from "@maple/ui/utils" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@maple/ui/components/ui/collapsible" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { clearSelfHostedSessionToken } from "@/lib/services/common/self-hosted-auth" import { useDashboardsRead } from "@/hooks/use-dashboard-store" import { useDashboardPreferences } from "@/hooks/use-dashboard-preferences" import { useInfraEnabled } from "@/hooks/use-infra-enabled" -import { Badge } from "@maple/ui/components/ui/badge" function UserAvatar({ imageUrl, initials, name }: { imageUrl?: string; initials: string; name: string }) { return imageUrl ? ( @@ -203,95 +206,62 @@ export function AppSidebar() { const infraEnabled = useInfraEnabled() const signalsItems = visibleSignalsNavItems({ infraEnabled }) + const [editing, setEditing] = useState(false) + const { state, setOpen } = useSidebar() + const { resetToDefaults, isCustomized } = useSidebarPreferences() + // Collapsing the rail mid-edit acts as an implicit Done (prefs save live); + // adjust during render instead of an effect. + if (editing && state === "collapsed") { + setEditing(false) + } + const inEdit = editing && state === "expanded" + return ( - + - - - - - {mainNavItems.map((item) => { - const isActive = currentPath === item.href - return ( - - } - tooltip={item.title} - isActive={isActive} - > - - {item.title} - - - ) - })} - - - - - {[topologyNavItems, signalsItems, investigateNavItems].map((group) => ( - - - - {group.map((item) => { - const isActive = currentPath.startsWith(item.href) - const subItems = - "subItems" in item - ? (item.subItems as NavSubItem[] | undefined) - : undefined - return ( - - } - tooltip={item.title} - isActive={isActive} - > - - {item.title} - - {"badge" in item && (item.badge as string) ? ( - - - {item.badge as string} - - - ) : null} - {subItems && isActive ? ( - - {subItems.map((sub) => { - const subActive = - sub.href === item.href - ? currentPath === item.href || - currentPath === `${item.href}/` - : currentPath.startsWith(sub.href) - return ( - - } - isActive={subActive} - > - {sub.icon ? : null} - {sub.title} - - - ) - })} - - ) : null} - - ) - })} - - - - ))} + {inEdit ? ( + setEditing(false)} + /> + ) : null} + { + if (e.key === "Escape") setEditing(false) + }} + > + + + + - + }> @@ -358,7 +328,7 @@ export function AppSidebar() { - + @@ -412,11 +382,23 @@ export function AppSidebar() { Settings + + { + setOpen(true) + setEditing(true) + }} + > + + Customize sidebar + + - + {isClerkAuthEnabled ? : } diff --git a/apps/web/src/components/dashboard/nav-items.ts b/apps/web/src/components/dashboard/nav-items.ts index 8fc79fb9f..8a1badf0c 100644 --- a/apps/web/src/components/dashboard/nav-items.ts +++ b/apps/web/src/components/dashboard/nav-items.ts @@ -14,11 +14,16 @@ import { } from "@/components/icons" export interface NavItem { + /** Stable identity for user sidebar preferences — never derive from href + * (visibleSignalsNavItems rewrites hrefs when features are gated off). */ + id: string title: string href: string icon: typeof PulseIcon } +export type SidebarGroupId = "main" | "topology" | "signals" | "investigate" + export interface NavSubItem { title: string href: string @@ -26,18 +31,20 @@ export interface NavSubItem { icon?: typeof PulseIcon } -interface SignalsNavItem extends NavItem { +export interface SignalsNavItem extends NavItem { badge?: string subItems?: NavSubItem[] } export const mainNavItems: NavItem[] = [ { + id: "overview", title: "Overview", href: "/", icon: HouseIcon, }, { + id: "chat", title: "Chat", href: "/chat", icon: ChatBubbleSparkleIcon, @@ -46,11 +53,13 @@ export const mainNavItems: NavItem[] = [ export const topologyNavItems: NavItem[] = [ { + id: "services", title: "Services", href: "/services", icon: ServerIcon, }, { + id: "service-map", title: "Service Map", href: "/service-map", icon: NetworkNodesIcon, @@ -59,26 +68,31 @@ export const topologyNavItems: NavItem[] = [ const signalsNavItems: SignalsNavItem[] = [ { + id: "traces", title: "Traces", href: "/traces", icon: PulseIcon, }, { + id: "logs", title: "Logs", href: "/logs", icon: FileIcon, }, { + id: "metrics", title: "Metrics", href: "/metrics", icon: ChartLineIcon, }, { + id: "replays", title: "Replays", href: "/replays", icon: PlayRotateClockwiseIcon, }, { + id: "infrastructure", title: "Infrastructure", href: "/infra", icon: ComputerIcon, @@ -94,6 +108,7 @@ const signalsNavItems: SignalsNavItem[] = [ export const investigateNavItems: NavItem[] = [ { + id: "errors", title: "Errors", href: "/errors", icon: CircleWarningIcon, @@ -106,6 +121,7 @@ export const investigateNavItems: NavItem[] = [ // icon: ChartBarTrendUpIcon, // }, { + id: "alerts", title: "Alerts", href: "/alerts", icon: BellIcon, diff --git a/apps/web/src/components/dashboard/sidebar-nav-group.tsx b/apps/web/src/components/dashboard/sidebar-nav-group.tsx new file mode 100644 index 000000000..f04e43d41 --- /dev/null +++ b/apps/web/src/components/dashboard/sidebar-nav-group.tsx @@ -0,0 +1,245 @@ +import type * as React from "react" +import { Link } from "@tanstack/react-router" +import { MotionConfig, Reorder, useDragControls } from "motion/react" +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@maple/ui/components/ui/sidebar" +import { cn } from "@maple/ui/utils" +import { EyeIcon, EyeSlashIcon, GripDotsIcon } from "@/components/icons" +import { LOCKED_NAV_IDS, applySidebarPrefs } from "@/atoms/sidebar-preferences-atoms" +import { useSidebarPreferences } from "@/hooks/use-sidebar-preferences" +import type { SidebarGroupId, SignalsNavItem } from "@/components/dashboard/nav-items" + +export function NavItemRow({ + item, + currentPath, + exactMatch, +}: { + item: SignalsNavItem + currentPath: string + exactMatch: boolean +}) { + const isActive = exactMatch ? currentPath === item.href : currentPath.startsWith(item.href) + return ( + + } tooltip={item.title} isActive={isActive}> + + {item.title} + + {item.badge ? ( + + + {item.badge} + + + ) : null} + {item.subItems && isActive ? ( + + {item.subItems.map((sub) => { + const subActive = + sub.href === item.href + ? currentPath === item.href || currentPath === `${item.href}/` + : currentPath.startsWith(sub.href) + return ( + + } isActive={subActive}> + {sub.icon ? : null} + {sub.title} + + + ) + })} + + ) : null} + + ) +} + +function EditableNavRow({ + item, + hidden, + locked, + index, + orderIds, + onMove, + onToggleHidden, +}: { + item: SignalsNavItem + hidden: boolean + locked: boolean + index: number + orderIds: readonly string[] + onMove: (ids: string[]) => void + onToggleHidden: () => void +}) { + const controls = useDragControls() + + const moveTo = (target: number) => { + if (target < 0 || target >= orderIds.length || target === index) return + const ids = orderIds.filter((id) => id !== item.id) + ids.splice(target, 0, item.id) + onMove(ids) + } + + const handleGripKeyDown = (event: React.KeyboardEvent) => { + const handlers: Record void> = { + ArrowUp: () => moveTo(index - 1), + ArrowDown: () => moveTo(index + 1), + Home: () => moveTo(0), + End: () => moveTo(orderIds.length - 1), + } + const handler = handlers[event.key] + if (handler) { + event.preventDefault() + handler() + } + } + + return ( + + {locked ? ( + + ) : ( + + )} + + {locked ? ( + + ) : ( + + )} + + ) +} + +export function SidebarNavGroup({ + groupId, + items, + currentPath, + editing, +}: { + groupId: SidebarGroupId + items: readonly SignalsNavItem[] + currentPath: string + editing: boolean +}) { + const { prefs, toggleHidden, setGroupOrder } = useSidebarPreferences() + const rows = applySidebarPrefs(groupId, items, prefs) + + if (!editing) { + const visible = rows.filter((row) => !row.hidden) + if (visible.length === 0) return null + return ( + + + + {visible.map(({ item }) => ( + + ))} + + + + ) + } + + const orderIds = rows.map((row) => row.item.id) + return ( + + + + setGroupOrder(groupId, ids)} + as="ul" + className="flex w-full min-w-0 flex-col gap-1" + > + {rows.map(({ item, hidden }, index) => ( + + + + + ) +} + +export function SidebarEditBar({ + isCustomized, + onReset, + onDone, +}: { + isCustomized: boolean + onReset: () => void + onDone: () => void +}) { + return ( +
+ Customize sidebar +
+ {isCustomized ? ( + + ) : null} + +
+
+ ) +} diff --git a/apps/web/src/components/icons/eye-slash.tsx b/apps/web/src/components/icons/eye-slash.tsx new file mode 100644 index 000000000..874227caa --- /dev/null +++ b/apps/web/src/components/icons/eye-slash.tsx @@ -0,0 +1,38 @@ +import type { IconProps } from "./icon" + +// Dotted eye (matches the core EyeIcon) with the center dots dropped where the +// slash crosses, plus a solid diagonal strike. +const paths: ReadonlyArray = [ + "M15 14L15 12", + "M9 14L9 12", + "M20.01 10L20 10", + "M4.01001 10L4.00001 10", + "M22.01 8L22 8", + "M18.01 8L18 8", + "M6.01001 8L6.00001 8", + "M2.01001 8L2.00001 8", + "M16 6L8 6", + "M22 12V14", + "M2 12V14", + "M21 3L3 21", +] + +function EyeSlashIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { EyeSlashIcon } diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts index 9a6f2e2ea..e756a9db3 100644 --- a/apps/web/src/components/icons/index.ts +++ b/apps/web/src/components/icons/index.ts @@ -70,6 +70,7 @@ export { DotsVerticalIcon } from "./dots-vertical" export { DownloadIcon } from "./download" export { EnvelopeIcon } from "./envelope" export { ExternalLinkIcon } from "./external-link" +export { EyeSlashIcon } from "./eye-slash" export { FileIcon } from "./file" export { FloppyDiskIcon } from "./floppy-disk" export { FolderIcon } from "./folder" diff --git a/apps/web/src/hooks/use-sidebar-preferences.ts b/apps/web/src/hooks/use-sidebar-preferences.ts new file mode 100644 index 000000000..8759fb6ce --- /dev/null +++ b/apps/web/src/hooks/use-sidebar-preferences.ts @@ -0,0 +1,40 @@ +import { useCallback } from "react" +import { useAtom } from "@/lib/effect-atom" +import { + LOCKED_NAV_IDS, + defaultSidebarPrefs, + sidebarPrefsAtom, + type SidebarPrefs, +} from "@/atoms/sidebar-preferences-atoms" + +export function useSidebarPreferences() { + const [prefs, setPrefs] = useAtom(sidebarPrefsAtom) + + const toggleHidden = useCallback( + (itemId: string) => { + if (LOCKED_NAV_IDS.has(itemId)) return + setPrefs((prev: SidebarPrefs) => { + const hidden = prev.hidden.includes(itemId) + ? prev.hidden.filter((id) => id !== itemId) + : [...prev.hidden, itemId] + return { ...prev, hidden } + }) + }, + [setPrefs], + ) + + const setGroupOrder = useCallback( + (groupId: string, ids: readonly string[]) => { + setPrefs((prev: SidebarPrefs) => ({ ...prev, order: { ...prev.order, [groupId]: ids } })) + }, + [setPrefs], + ) + + const resetToDefaults = useCallback(() => { + setPrefs(defaultSidebarPrefs) + }, [setPrefs]) + + const isCustomized = prefs.hidden.length > 0 || Object.keys(prefs.order).length > 0 + + return { prefs, toggleHidden, setGroupOrder, resetToDefaults, isCustomized } +}