From 77194fb46049ec47ffc8a3c32b7e6e53b024a41f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 29 Sep 2026 01:33:20 -0700 Subject: [PATCH] feat: animate folding the sidebar's Email, CRM and Resources sections row by row, keep the current page visible in a folded section, flag an Advisor finding on the rows a section hides with a dot on its header, widen the header into a full-width target with a hover chevron, key the fold state by stable section ids inside the persisted warmbly-storage, and update the unibox layout guide --- docs/content/docs/guides/unibox.mdx | 2 +- web/src/components/layout/AppNav.tsx | 145 ++++++++++++++++++++------- web/src/hooks/usePermission.ts | 2 +- web/src/stores/slices/uiSlice.ts | 37 +++---- web/src/stores/useAppStore.ts | 4 +- 5 files changed, 125 insertions(+), 65 deletions(-) diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 8db0b6ba6..a2735f1bc 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -15,7 +15,7 @@ The columns are yours to size. Every choice here is remembered in the browser yo | --- | --- | | The divider between the list and the thread | Drag it to widen the conversation list so long subjects fit, or to give the space back to the thread. It also takes the keyboard once it has focus: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, `Enter` (or a double-click) puts it back to the default. | | **Collapse** at the bottom of the left navigation, or `b` | Shrinks Warmbly's own navigation to an icon rail and gives the width to the page. Row labels become tooltips; unread mail keeps its count badge and an open [Advisor](/guides/advisor/) finding shows as a coloured dot on the icon. The same control expands it again. | -| The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away. Like the other layout choices, it is remembered in this browser. While a section is folded and you are on one of its pages, a small dot beside the header shows where you are. In the icon rail, folded sections stay hidden except the page you are on. | +| The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away, and again to bring it back. Like the other layout choices, it is remembered in this browser. A folded section still shows the page you are on, and a coloured dot beside its header flags an open [Advisor](/guides/advisor/) finding on one of the pages it hides. The icon rail follows the same choice. | | The contact button in the thread header | Shows or hides the contact panel on the right. It starts closed when no preference is saved. Opening or closing it sticks across conversations, and existing preferences are preserved. | How wide the list can get depends on the window: the thread always keeps enough room to read a message, and the contact panel counts toward that when it is open, so the widest setting is narrower on a laptop than on a large monitor. diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index 67995feb4..d697f03d0 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -35,9 +35,10 @@ import { ZapIcon, } from "lucide-react"; import { type ReactElement, type ReactNode, useId, useMemo, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { useAppStore } from "@/stores"; import useFeatureAccess from "@/hooks/useFeatureAccess"; -import { usePermission, type PermissionKey } from "@/hooks/usePermission"; +import { orgHasPermission, usePermission, type PermissionKey } from "@/hooks/usePermission"; import { useUpgradeDialog } from "@/hooks/context/upgrade"; import { PLAN_ACCENT_CLASSES, getPlan, type PlanID } from "@/lib/plans"; import AccessLockedDialog from "./AccessLockedDialog"; @@ -59,6 +60,7 @@ import useAPIKeys from "@/lib/api/hooks/app/api-keys/useAPIKeys"; import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections"; import AnimatedNumber from "@/components/ui/AnimatedNumber"; import AdvisorNavBadge from "@/components/app/advisor/AdvisorNavBadge"; +import { useAdvisorSummary } from "@/lib/api/hooks/app/advisor/useAdvisor"; import type { AdvisorSurface } from "@/lib/api/models/app/advisor/Advisor"; import { UserNav } from "./UserNav"; import { Logo } from "@/components/svg"; @@ -127,6 +129,8 @@ const REQUIRES_TO_MIN_PLAN: Record, PlanID> = { }; interface NavSection { + /** Stable key for the persisted fold state, so renaming a label keeps it. */ + id: string; label: string; items: NavItem[]; } @@ -145,6 +149,7 @@ const topItems: NavItem[] = [ const sections: NavSection[] = [ { + id: "email", label: "Email", items: [ { title: "Accounts", url: "/app/emails", icon: MailIcon, indicator: "accounts", advisorSurface: "emails", permission: "MANAGE_EMAILS", permissionLabel: "Manage mailboxes" }, @@ -157,6 +162,7 @@ const sections: NavSection[] = [ ], }, { + id: "crm", label: "CRM", items: [ { title: "Pipelines", requires: "subscription", url: "/app/crm/pipelines", icon: GitBranchIcon, indicator: "pipelines", permission: "VIEW_CONTACTS", permissionLabel: "View contacts" }, @@ -166,6 +172,7 @@ const sections: NavSection[] = [ ], }, { + id: "resources", label: "Resources", items: [ { title: "Templates", requires: "subscription", url: "/app/templates", icon: FileTextIcon, indicator: "templates" }, @@ -680,6 +687,31 @@ function IntegrationsActivity() { ); } +// The same urgency the rows badge, summed over the rows a folded section hides, +// so folding Email cannot bury a critical deliverability finding. +function FoldedAdvisorDot({ surfaces }: { surfaces: AdvisorSurface[] }) { + const { data } = useAdvisorSummary(surfaces.length > 0); + let critical = 0; + let urgent = 0; + for (const entry of data?.surfaces ?? []) { + if (!surfaces.includes(entry.surface)) continue; + critical += entry.critical; + urgent += entry.critical + entry.high; + } + if (urgent === 0) return null; + const label = `${urgent} ${urgent === 1 ? "issue" : "issues"} needing attention in this section`; + return ( + 0 ? "bg-rose-500" : "bg-orange-500")} + > + {label} + + ); +} + +const FOLD_EASE = [0.2, 0, 0, 1] as const; + function Section({ section, first = false, @@ -691,51 +723,90 @@ function Section({ }) { const id = useId(); const { pathname } = useLocation(); - const sectionCollapsed = useAppStore((s) => s.navCollapsedSections[section.label] ?? false); + const folded = useAppStore((s) => s.navCollapsedSections[section.id] ?? false); const toggleNavSection = useAppStore((s) => s.toggleNavSection); - const { canManage } = useFeatureAccess(); - const active = section.items.some((item) => isNavItemActive(pathname, item)); - const hidden = !collapsed && sectionCollapsed; - const visibleItems = collapsed - ? section.items.filter((item) => - (!sectionCollapsed || isNavItemActive(pathname, item)) && - (item.rolesAllowed !== "manage" || canManage), - ) - : section.items; + const org = useAppStore((s) => s.currentOrganization); + const access = useFeatureAccess(); + const reduceMotion = useReducedMotion(); - if (collapsed && visibleItems.length === 0) return null; + // Folded, a section keeps only the row you are on, in the rail and the + // full sidebar alike, so where you are never folds away with the rest. + const permitted = section.items.filter((item) => item.rolesAllowed !== "manage" || access.canManage); + const shown = folded ? permitted.filter((item) => isNavItemActive(pathname, item)) : permitted; + const hiddenSurfaces = folded + ? permitted.flatMap((item) => + !isNavItemActive(pathname, item) && + item.advisorSurface && + !(item.requires === "subscription" && access.locked) && + (!item.permission || orgHasPermission(org, item.permission)) + ? [item.advisorSurface] + : [], + ) + : []; + + if (collapsed && shown.length === 0) return null; + + const transition = reduceMotion ? { duration: 0 } : { duration: 0.22, ease: FOLD_EASE }; return (
{/* Collapsed, the hairline above the group carries the grouping on its own — a tracked-uppercase label does not fit in 56px. */} {!collapsed && ( -
- -
+ /> + + + {folded && hiddenSurfaces.length > 0 && ( + + + + )} + + + )} - ); @@ -1179,7 +1250,7 @@ export function AppNav({ open = false, onClose }: { open?: boolean; onClose?: ()
{sections.map((s, i) => (
// Theme @@ -56,7 +57,7 @@ export interface UISlice { toggleSidebar: () => void setSidebarCollapsed: (collapsed: boolean) => void setSidebarMobileOpen: (open: boolean) => void - toggleNavSection: (label: string) => void + toggleNavSection: (id: string) => void // Actions - Theme setTheme: (theme: Theme) => void @@ -81,15 +82,11 @@ const getInitialTheme = (): Theme => { return (localStorage.getItem('theme') as Theme) || 'system' } -const getInitialNavCollapsedSections = (): Record => { - if (typeof window === 'undefined') return {} - try { - const stored: unknown = JSON.parse(localStorage.getItem('nav.collapsedSections') || '{}') - if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {} - return Object.fromEntries(Object.entries(stored).filter(([, value]) => typeof value === 'boolean')) - } catch { - return {} - } +// Rehydration bypasses the setter, so a stored value that is not a map of +// booleans (older build, hand edit) falls back to everything expanded. +export const sanitizeNavCollapsedSections = (v: unknown): Record => { + if (!v || typeof v !== 'object' || Array.isArray(v)) return {} + return Object.fromEntries(Object.entries(v).filter(([, folded]) => typeof folded === 'boolean')) } // The dashboard is light-only today: every surface is styled on white, so a @@ -104,7 +101,7 @@ export const createUISlice: StateCreator = (set, get) // Sidebar navCollapsed: false, sidebarMobileOpen: false, - navCollapsedSections: getInitialNavCollapsedSections(), + navCollapsedSections: {}, // Theme theme: getInitialTheme(), @@ -129,20 +126,10 @@ export const createUISlice: StateCreator = (set, get) setSidebarMobileOpen: (sidebarMobileOpen) => set((state) => (state.sidebarMobileOpen === sidebarMobileOpen ? state : { sidebarMobileOpen })), - toggleNavSection: (label) => { - const navCollapsedSections = { - ...get().navCollapsedSections, - [label]: !get().navCollapsedSections[label], - } - if (typeof window !== 'undefined') { - try { - localStorage.setItem('nav.collapsedSections', JSON.stringify(navCollapsedSections)) - } catch { - // Keep toggling available when browser storage is unavailable. - } - } - set({ navCollapsedSections }) - }, + toggleNavSection: (id) => + set((state) => ({ + navCollapsedSections: { ...state.navCollapsedSections, [id]: !state.navCollapsedSections[id] }, + })), // Actions - Theme setTheme: (theme) => { diff --git a/web/src/stores/useAppStore.ts b/web/src/stores/useAppStore.ts index 775208629..ef2f290df 100644 --- a/web/src/stores/useAppStore.ts +++ b/web/src/stores/useAppStore.ts @@ -3,7 +3,7 @@ import { devtools, persist } from 'zustand/middleware' import { useShallow } from 'zustand/react/shallow' import { createUserSlice, type UserSlice } from './slices/userSlice' import { createOrganizationSlice, type OrganizationSlice } from './slices/organizationSlice' -import { createUISlice, clampUniboxListWidth, type UISlice } from './slices/uiSlice' +import { createUISlice, clampUniboxListWidth, sanitizeNavCollapsedSections, type UISlice } from './slices/uiSlice' import { createShortcutSlice, type ShortcutSlice } from './slices/shortcutSlice' import { createDataSlice, type DataSlice } from './slices/dataSlice' import { createRealtimeSlice, type RealtimeSlice } from './slices/realtimeSlice' @@ -47,12 +47,14 @@ export const useAppStore = create()( ...current, ...p, uniboxListWidth: clampUniboxListWidth(p.uniboxListWidth), + navCollapsedSections: sanitizeNavCollapsedSections(p.navCollapsedSections), } }, partialize: (state) => ({ // Only persist UI preferences theme: state.theme, navCollapsed: state.navCollapsed, + navCollapsedSections: state.navCollapsedSections, // Assistant panel layout (edge + width + floating window geometry) agentSide: state.agentSide, agentWidth: state.agentWidth,