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

This commit is contained in:
Matthew Meszaros
2026-09-29 01:33:20 -07:00
parent 032eb04fba
commit 77194fb460
5 changed files with 125 additions and 65 deletions
+1 -1
View File
@@ -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.
+108 -37
View File
@@ -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<NonNullable<NavItem["requires"]>, 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 (
<span
title={label}
className={cn("size-1.5 shrink-0 rounded-full", critical > 0 ? "bg-rose-500" : "bg-orange-500")}
>
<span className="sr-only">{label}</span>
</span>
);
}
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 (
<div className={first ? "" : "mt-4 pt-4 border-t border-slate-200/50"}>
{/* Collapsed, the hairline above the group carries the grouping on
its own — a tracked-uppercase label does not fit in 56px. */}
{!collapsed && (
<div className="px-4 mb-1.5">
<button
type="button"
aria-expanded={!hidden}
aria-controls={id}
onClick={() => toggleNavSection(section.label)}
className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium inline-flex items-center gap-1.5 rounded-sm hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
>
{section.label}
{hidden && active && (
<>
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" aria-hidden />
<span className="sr-only">(contains current page)</span>
</>
<button
type="button"
aria-expanded={!folded}
aria-controls={id}
onClick={() => toggleNavSection(section.id)}
className="group/section mx-2 mb-1 flex h-6 w-[calc(100%-1rem)] items-center gap-1.5 rounded-md px-2 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400 transition-colors duration-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
>
<span>{section.label}</span>
{/* Always shown while folded so the state reads at a glance;
expanded it appears on hover (always on touch). */}
<ChevronDownIcon
aria-hidden
strokeWidth={2}
className={cn(
"size-3 shrink-0 transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none",
folded
? "-rotate-90 opacity-100"
: "opacity-100 md:opacity-0 md:group-hover/section:opacity-100 md:group-focus-visible/section:opacity-100",
)}
<ChevronDownIcon
className={cn("w-3 h-3 transition-transform", hidden && "-rotate-90")}
aria-hidden
/>
</button>
</div>
/>
<span className="ml-auto flex items-center">
<AnimatePresence initial={false}>
{folded && hiddenSurfaces.length > 0 && (
<motion.span
key="attention"
className="flex"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={transition}
>
<FoldedAdvisorDot surfaces={hiddenSurfaces} />
</motion.span>
)}
</AnimatePresence>
</span>
</button>
)}
<div id={id} hidden={hidden} className="space-y-px">
{visibleItems.map((it) => (
<NavRow key={it.url} item={it} collapsed={collapsed} />
))}
<div id={id} className="space-y-px">
{/* Each row folds its own height, so the rows around the one
you are on close in on it instead of the block snapping. */}
<AnimatePresence initial={false}>
{shown.map((it) => (
<motion.div
key={it.url}
initial={{ height: 0, opacity: 0, overflow: "hidden" }}
animate={{ height: "auto", opacity: 1, transitionEnd: { overflow: "visible" } }}
exit={{ height: 0, opacity: 0, overflow: "hidden" }}
transition={transition}
>
<NavRow item={it} collapsed={collapsed} />
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
@@ -1179,7 +1250,7 @@ export function AppNav({ open = false, onClose }: { open?: boolean; onClose?: ()
</div>
{sections.map((s, i) => (
<Section
key={s.label}
key={s.id}
section={s}
first={i === 0 && topItems.length === 0}
collapsed={iconOnly}
+1 -1
View File
@@ -18,7 +18,7 @@ export function checkPermission(key: PermissionKey): boolean {
return orgHasPermission(useAppStore.getState().currentOrganization, key);
}
function orgHasPermission(
export function orgHasPermission(
org: { role?: string; permissions?: number } | null | undefined,
key: PermissionKey,
): boolean {
+12 -25
View File
@@ -30,6 +30,7 @@ export interface UISlice {
// and every store written before this had no version field at all.
navCollapsed: boolean
sidebarMobileOpen: boolean
// Folded sidebar sections, keyed by the section's stable id (not its label).
navCollapsedSections: Record<string, boolean>
// 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<string, boolean> => {
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<string, boolean> => {
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<UISlice, [], [], UISlice> = (set, get)
// Sidebar
navCollapsed: false,
sidebarMobileOpen: false,
navCollapsedSections: getInitialNavCollapsedSections(),
navCollapsedSections: {},
// Theme
theme: getInitialTheme(),
@@ -129,20 +126,10 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (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) => {
+3 -1
View File
@@ -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<AppStore>()(
...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,