Merge remote-tracking branch 'origin/main' into fix/warmup-removal-verification

This commit is contained in:
Matthew Meszaros
2026-09-29 04:57:45 -07:00
7 changed files with 324 additions and 145 deletions
+1
View File
@@ -15,6 +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, 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.
+6 -6
View File
@@ -175,14 +175,14 @@ describe("unibox desktop layout (#473)", SUITE, () => {
expect(useAppStore.getState().navCollapsed).toBe(true);
expect(aside.className).toContain("md:w-14");
// Same destination, no VISIBLE label: the name moves into a
// visually hidden span, because lucide marks its svg aria-hidden
// and the link would otherwise announce as nothing at all. It must
// not become an aria-label: that would override the whole subtree
// and silence the unread count nested in the same link.
// Same destination, no VISIBLE label: the label column is faded and
// clipped but stays in the link, because lucide marks its svg
// aria-hidden and the link would otherwise announce as nothing at
// all. It must not become an aria-label: that would override the
// whole subtree and silence the unread count nested in the same link.
expect(settingsLink().getAttribute("aria-label")).toBeNull();
expect(settingsLink().textContent).toBe("Settings");
expect(settingsLink().querySelector("span")?.className).toContain("sr-only");
expect(settingsLink().querySelector("span")?.className).toContain("opacity-0");
// `b` is the documented shortcut for the same thing. It was wired to
// the store while nothing rendered from it; this is what makes it
+279 -125
View File
@@ -13,6 +13,7 @@ import {
CableIcon,
CalendarClockIcon,
CheckSquareIcon,
ChevronDownIcon,
CircleDollarSignIcon,
FileTextIcon,
FlameIcon,
@@ -33,10 +34,11 @@ import {
XIcon,
ZapIcon,
} from "lucide-react";
import { type ReactElement, type ReactNode, useMemo, useState } from "react";
import { type ReactElement, type ReactNode, useId, useLayoutEffect, useMemo, useRef, 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";
@@ -58,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";
@@ -126,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[];
}
@@ -144,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" },
@@ -156,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" },
@@ -165,6 +172,7 @@ const sections: NavSection[] = [
],
},
{
id: "resources",
label: "Resources",
items: [
{ title: "Templates", requires: "subscription", url: "/app/templates", icon: FileTextIcon, indicator: "templates" },
@@ -176,8 +184,9 @@ const sections: NavSection[] = [
},
];
// NavTip wraps a collapsed rail row in the themed tooltip, so an icon-only
// row still says what it is. Expanded rows carry their label and need none.
// NavTip wraps a rail row in the themed tooltip, so an icon-only row still
// says what it is. The trigger stays mounted in both modes (only the content
// is conditional) so a row is never remounted and can animate between them.
function NavTip({
collapsed,
label,
@@ -187,26 +196,44 @@ function NavTip({
label: string;
children: ReactElement;
}) {
if (!collapsed) return children;
// Controlled, so a hover in the expanded sidebar never opens a tip there.
const [open, setOpen] = useState(false);
return (
// Rooted in the rail's shared provider: after the first tip, moving to
// the next row shows its name at once instead of waiting again.
<TooltipGroupRoot>
<TooltipGroupRoot open={collapsed && open} onOpenChange={setOpen}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
{collapsed && (
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
)}
</TooltipGroupRoot>
);
}
// The two row shapes. Collapsed, the rail is 56px wide, so a row is a centred
// 32px icon target; expanded it is a full-width label row. Both are constants
// because four call sites branch between them.
const ICON_ROW = "group relative mx-auto flex size-8 items-center justify-center rounded-md transition-colors duration-100";
const LABEL_ROW = "group relative mx-2 w-[calc(100%-1rem)] flex items-center gap-2.5 px-2.5 h-7 rounded-md text-[12.5px] transition-colors duration-100";
// The two row shapes share one element and transition between each other in
// step with the sidebar's width: the icon holds its place (it drifts 3px into
// the rail's centre) while the label column fades and is clipped.
const ROW_BASE = "group relative flex items-center rounded-md text-[12.5px] transition-[margin,width,height,padding,gap,background-color,color] duration-200 ease-out motion-reduce:transition-none";
const ICON_ROW = `${ROW_BASE} mx-3 w-8 h-8 px-[9px] gap-0`;
const LABEL_ROW = `${ROW_BASE} mx-2 w-[calc(100%-1rem)] h-7 px-2.5 gap-2.5`;
const rowClass = (collapsed: boolean) => (collapsed ? ICON_ROW : LABEL_ROW);
// Fades out fast on collapse, and back in once the column has room again.
const labelFade = (collapsed: boolean) =>
cn(
"flex min-w-0 flex-1 items-center gap-2.5 overflow-hidden whitespace-nowrap transition-opacity ease-out motion-reduce:transition-none",
collapsed ? "opacity-0 duration-100" : "opacity-100 duration-200 delay-75",
);
// Rail-only marks (the unread count, an Advisor dot) fade in as the rail settles.
const RAIL_MARK_IN = "animate-in fade-in-0 zoom-in-50 duration-200 delay-100 fill-mode-both motion-reduce:animate-none";
function isNavItemActive(pathname: string, item: NavItem): boolean {
return pathname === item.url || pathname.startsWith(item.url + "/");
}
function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolean }) {
const { pathname } = useLocation();
const unseen = useAppStore((s) => s.unseenCount);
@@ -214,8 +241,7 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
const hasItemPermission = usePermission(item.permission ?? "VIEW_CAMPAIGNS");
const [deniedOpen, setDeniedOpen] = useState(false);
const upgradeDialog = useUpgradeDialog();
const active =
pathname === item.url || pathname.startsWith(item.url + "/");
const active = isNavItemActive(pathname, item);
const badge = item.badgeStoreKey === "unseenCount" ? unseen : undefined;
// Role-gated items disappear from the sidebar for users that
@@ -240,12 +266,11 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
)}
>
<LockIcon className="w-[13px] h-[13px] shrink-0 text-slate-300 group-hover:text-slate-500" strokeWidth={1.8} />
{/* Collapsed, lucide marks its svg aria-hidden, so the name
comes from a visually hidden span. NOT aria-label: that
would override the whole subtree, silencing the badges
the collapsed rail exists to keep. */}
<span className={collapsed ? "sr-only" : "truncate flex-1 min-w-0 text-left"}>
{collapsed ? `${item.title} · no access` : item.title}
{/* The label stays in the tree when collapsed, so it names
the row for a screen reader while the tooltip shows it. */}
<span className={labelFade(collapsed)}>
<span className="truncate flex-1 min-w-0 text-left">{item.title}</span>
{collapsed && <span className="sr-only"> · no access</span>}
</span>
</button>
</NavTip>
@@ -286,10 +311,8 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
)}
>
<LockIcon className="w-[13px] h-[13px] shrink-0 text-slate-300 group-hover:text-slate-500" strokeWidth={1.8} />
<span className={collapsed ? "sr-only" : "truncate flex-1 min-w-0 text-left"}>
{collapsed ? `${item.title} · ${planBadge.label} plan` : item.title}
</span>
{!collapsed && (
<span className={labelFade(collapsed)}>
<span className="truncate flex-1 min-w-0 text-left">{item.title}</span>
<span
className={cn(
"h-4 px-1.5 rounded text-[9.5px] font-semibold uppercase tracking-[0.06em] border inline-flex items-center",
@@ -297,8 +320,9 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
)}
>
{planBadge.label}
{collapsed && <span className="sr-only"> plan</span>}
</span>
)}
</span>
</button>
</NavTip>
);
@@ -318,90 +342,72 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
/>
);
// Collapsed rail: the label and the ambient count clusters have nowhere to
// go, so the row keeps its icon plus the two signals worth interrupting
// for: the unread count, and an Advisor finding as a severity dot.
if (collapsed) {
return (
<NavTip collapsed label={item.title}>
<Link
to={item.url}
className={cn(
ICON_ROW,
active
? "bg-slate-200/70 text-slate-900"
: locked
? "text-slate-400 hover:text-slate-700 hover:bg-slate-200/40"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40",
)}
>
{icon}
{/* The name is a hidden span rather than an aria-label so it
composes with the count below it: an aria-label on the
link would replace the whole subtree and announce
"Inbox" where the expanded row announces "Inbox 12". */}
<span className="sr-only">{item.title}</span>
{item.advisorSurface && !locked && (
<AdvisorNavBadge surface={item.advisorSurface} dot />
)}
{badge != null && badge > 0 && (
<span className="absolute -right-0.5 -top-0.5 min-w-[15px] h-[15px] px-1 rounded-full bg-red-500 text-white text-[9px] font-medium leading-none flex items-center justify-center tabular-nums ring-2 ring-white">
<span className="sr-only">{badge} unread</span>
<span aria-hidden>{badge > 9 ? "9+" : badge}</span>
</span>
)}
</Link>
</NavTip>
);
}
// One element for both shapes. Collapsed, the label column (and the
// ambient count clusters in it) is clipped away, and the row keeps the two
// signals worth interrupting for: the unread count and an Advisor dot.
return (
<Link
to={item.url}
title={planBadge ? `${item.title} · ${planBadge.label} plan` : undefined}
className={cn(
LABEL_ROW,
active
? "bg-slate-200/70 text-slate-900 font-medium"
: locked
? "text-slate-400 hover:text-slate-700 hover:bg-slate-200/40"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40",
)}
>
{icon}
{/* min-w-0 lets the label shrink/truncate so the count cluster (and its
separator) is never pushed off the row — longer labels like
"Campaigns"/"Accounts" used to clip it at narrower widths. */}
<span className="truncate flex-1 min-w-0">{item.title}</span>
{item.advisorSurface && !locked && <AdvisorNavBadge surface={item.advisorSurface} />}
{item.indicator === "campaigns" && !locked && <CampaignActivity />}
{item.indicator === "accounts" && !locked && <MailboxActivity />}
{item.indicator === "tasks" && !locked && <TasksActivity />}
{item.indicator === "meetings" && !locked && <MeetingsActivity />}
{item.indicator === "contacts" && !locked && <ContactsActivity />}
{item.indicator === "deals" && !locked && <DealsActivity />}
{item.indicator === "pipelines" && !locked && <PipelinesActivity />}
{item.indicator === "templates" && !locked && <TemplatesActivity />}
{item.indicator === "analytics" && !locked && <AnalyticsActivity />}
{item.indicator === "apikeys" && !locked && <ApiKeysActivity />}
{item.indicator === "integrations" && !locked && <IntegrationsActivity />}
{planBadge ? (
<span
className={cn(
"h-4 px-1.5 rounded text-[9.5px] font-semibold uppercase tracking-[0.06em] border inline-flex items-center",
planBadge.classes,
<NavTip collapsed={collapsed} label={item.title}>
<Link
to={item.url}
aria-current={active ? "page" : undefined}
title={!collapsed && planBadge ? `${item.title} · ${planBadge.label} plan` : undefined}
className={cn(
rowClass(collapsed),
active
? cn("bg-slate-200/70 text-slate-900", !collapsed && "font-medium")
: locked
? "text-slate-400 hover:text-slate-700 hover:bg-slate-200/40"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40",
)}
>
{icon}
{/* min-w-0 lets the label shrink/truncate so the count cluster (and its
separator) is never pushed off the row — longer labels like
"Campaigns"/"Accounts" used to clip it at narrower widths. */}
<span className={labelFade(collapsed)}>
<span className="truncate flex-1 min-w-0">{item.title}</span>
{item.advisorSurface && !locked && !collapsed && <AdvisorNavBadge surface={item.advisorSurface} />}
{item.indicator === "campaigns" && !locked && <CampaignActivity />}
{item.indicator === "accounts" && !locked && <MailboxActivity />}
{item.indicator === "tasks" && !locked && <TasksActivity />}
{item.indicator === "meetings" && !locked && <MeetingsActivity />}
{item.indicator === "contacts" && !locked && <ContactsActivity />}
{item.indicator === "deals" && !locked && <DealsActivity />}
{item.indicator === "pipelines" && !locked && <PipelinesActivity />}
{item.indicator === "templates" && !locked && <TemplatesActivity />}
{item.indicator === "analytics" && !locked && <AnalyticsActivity />}
{item.indicator === "apikeys" && !locked && <ApiKeysActivity />}
{item.indicator === "integrations" && !locked && <IntegrationsActivity />}
{planBadge ? (
<span
className={cn(
"h-4 px-1.5 rounded text-[9.5px] font-semibold uppercase tracking-[0.06em] border inline-flex items-center",
planBadge.classes,
)}
>
{planBadge.label}
</span>
) : (
!collapsed && badge != null && badge > 0 && (
<span className="text-[10px] font-medium bg-red-500 text-white rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 tabular-nums">
{badge > 99 ? "99+" : badge}
</span>
)
)}
>
{planBadge.label}
</span>
) : (
badge != null && badge > 0 && (
<span className="text-[10px] font-medium bg-red-500 text-white rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 tabular-nums">
{badge > 99 ? "99+" : badge}
{collapsed && item.advisorSurface && !locked && (
<span className={cn("pointer-events-none absolute inset-0", RAIL_MARK_IN)}>
<AdvisorNavBadge surface={item.advisorSurface} dot />
</span>
)
)}
</Link>
)}
{collapsed && badge != null && badge > 0 && (
<span className={cn("absolute -right-0.5 -top-0.5 min-w-[15px] h-[15px] px-1 rounded-full bg-red-500 text-white text-[9px] font-medium leading-none flex items-center justify-center tabular-nums ring-2 ring-white", RAIL_MARK_IN)}>
<span className="sr-only">{badge} unread</span>
<span aria-hidden>{badge > 9 ? "9+" : badge}</span>
</span>
)}
</Link>
</NavTip>
);
}
@@ -676,6 +682,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,
@@ -685,23 +716,112 @@ function Section({
first?: boolean;
collapsed?: boolean;
}) {
const id = useId();
const { pathname } = useLocation();
const folded = useAppStore((s) => s.navCollapsedSections[section.id] ?? false);
const toggleNavSection = useAppStore((s) => s.toggleNavSection);
const org = useAppStore((s) => s.currentOrganization);
const access = useFeatureAccess();
const reduceMotion = useReducedMotion();
// 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]
: [],
)
: [];
// In the rail a folded section with nothing left to show goes, divider and all.
const gone = collapsed && shown.length === 0;
const transition = reduceMotion ? { duration: 0 } : { duration: 0.22, ease: FOLD_EASE };
// The gap above the divider is animated padding rather than a margin, so
// it folds away with the section instead of collapsing through it.
const gap = first ? 0 : 16;
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">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
{section.label}
<motion.div
initial={false}
animate={
gone
? { height: 0, paddingTop: 0, opacity: 0, overflow: "hidden" }
: { height: "auto", paddingTop: gap, opacity: 1, transitionEnd: { overflow: "visible" } }
}
transition={transition}
inert={gone}
>
<div className={first ? "" : "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,
so the header shrinks away with the sidebar's width. */}
<button
type="button"
aria-expanded={!folded}
aria-controls={id}
onClick={() => toggleNavSection(section.id)}
inert={collapsed}
className={cn(
"group/section mx-2 flex w-[calc(100%-1rem)] items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-md px-2 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400 transition-[height,margin,opacity,color] duration-200 ease-out hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 motion-reduce:transition-none",
collapsed ? "mb-0 h-0 opacity-0" : "mb-1 h-6 opacity-100",
)}
>
<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",
)}
/>
<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} 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 className="space-y-px">
{section.items.map((it) => (
<NavRow key={it.url} item={it} collapsed={collapsed} />
))}
</div>
</div>
</motion.div>
);
}
@@ -733,6 +853,40 @@ function Section({
* the mailboxes can send today under the scheduler's clamps, not their caps
* added up.
*/
// The panel's two shapes share nothing, so they cross-fade while the slot
// eases to the incoming one's height instead of snapping to it.
function LivePanelSlot({ collapsed }: { collapsed: boolean }) {
const reduceMotion = useReducedMotion();
const inner = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | "auto">("auto");
useLayoutEffect(() => {
const el = inner.current;
if (!el) return;
const observer = new ResizeObserver(() => setHeight(el.offsetHeight));
observer.observe(el);
return () => observer.disconnect();
}, []);
const ease = reduceMotion ? { duration: 0 } : { duration: 0.2, ease: FOLD_EASE };
return (
<motion.div initial={false} animate={{ height }} transition={ease} className="shrink-0 overflow-hidden">
<div ref={inner} className="relative flow-root">
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={collapsed ? "rail" : "full"}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: reduceMotion ? { duration: 0 } : { duration: 0.15, delay: 0.08 } }}
exit={{ opacity: 0, transition: reduceMotion ? { duration: 0 } : { duration: 0.1 } }}
>
<LivePanel collapsed={collapsed} />
</motion.div>
</AnimatePresence>
</div>
</motion.div>
);
}
function LivePanel({ collapsed = false }: { collapsed?: boolean }) {
const emails = useAppStore((s) => s.emails);
const unseenCount = useAppStore((s) => s.unseenCount);
@@ -1128,14 +1282,14 @@ export function AppNav({ open = false, onClose }: { open?: boolean; onClose?: ()
</button>
</div>
<LivePanel collapsed={iconOnly} />
<LivePanelSlot collapsed={iconOnly} />
{/* overflow-x-hidden: mid-animation the rail is narrower than the
expanded rows still laid out inside it, and without this the
column grows a horizontal scrollbar for those 200ms. */}
{/* Collapsed, pt-1 leaves room for the unread badge that sits
above the first row's corner, which the scroller would clip. */}
<nav className={cn("flex-1 overflow-y-auto overflow-x-hidden pb-3", iconOnly && "pt-1")}>
<nav className={cn("flex-1 overflow-y-auto overflow-x-hidden pb-3 transition-[padding] duration-200 ease-out", iconOnly && "pt-1")}>
<div className="space-y-px">
{topItems.map((it) => (
<NavRow key={it.url + it.title} item={it} collapsed={iconOnly} />
@@ -1143,7 +1297,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}
@@ -1205,9 +1359,9 @@ function CollapseToggle({
) : (
<PanelLeftCloseIcon className="w-[14px] h-[14px] shrink-0 text-slate-400 group-hover:text-slate-600" strokeWidth={1.6} />
)}
{!collapsed && (
<span className={labelFade(collapsed)}>
<span className="truncate flex-1 min-w-0 text-left">Collapse</span>
)}
</span>
</button>
</ShortcutTooltip>
);
+18 -12
View File
@@ -46,11 +46,13 @@ export function UserNav({ collapsed = false }: { collapsed?: boolean }) {
<PopoverMenuTrigger asChild>
<button
aria-label={collapsed ? displayName : undefined}
// One element in both shapes so it eases with the sidebar's
// width: the avatar drifts to the rail's centre, the name fades.
className={cn(
"flex items-center rounded-md hover:bg-slate-200/40 transition-colors cursor-pointer",
"flex items-center mx-3 my-2 rounded-md hover:bg-slate-200/40 cursor-pointer transition-[width,padding,gap,background-color] duration-200 ease-out motion-reduce:transition-none",
collapsed
? "mx-auto my-2 size-8 justify-center"
: "gap-2.5 mx-3 my-2 px-1.5 py-1 w-[calc(100%-1.5rem)]",
? "w-8 gap-0 p-0.5"
: "w-[calc(100%-1.5rem)] gap-2.5 px-1.5 py-1",
)}
>
<div className="w-7 h-7 rounded-full bg-slate-900 flex items-center justify-center shrink-0 overflow-hidden">
@@ -66,16 +68,20 @@ export function UserNav({ collapsed = false }: { collapsed?: boolean }) {
</span>
)}
</div>
{!collapsed && (
<div className="flex-1 min-w-0 text-left">
<div className="text-[13px] text-slate-900 truncate">
{displayName}
</div>
<div className="text-[10.5px] text-slate-500 truncate">
{user.email}
</div>
<div
aria-hidden={collapsed || undefined}
className={cn(
"flex-1 min-w-0 overflow-hidden whitespace-nowrap text-left transition-opacity ease-out motion-reduce:transition-none",
collapsed ? "opacity-0 duration-100" : "opacity-100 duration-200 delay-75",
)}
>
<div className="text-[13px] text-slate-900 truncate">
{displayName}
</div>
)}
<div className="text-[10.5px] text-slate-500 truncate">
{user.email}
</div>
</div>
</button>
</PopoverMenuTrigger>
+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 {
+16
View File
@@ -30,6 +30,8 @@ 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
theme: Theme
@@ -55,6 +57,7 @@ export interface UISlice {
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
setSidebarMobileOpen: (open: boolean) => void
toggleNavSection: (id: string) => void
// Actions - Theme
setTheme: (theme: Theme) => void
@@ -79,6 +82,13 @@ const getInitialTheme = (): Theme => {
return (localStorage.getItem('theme') as Theme) || 'system'
}
// 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
// resolved dark theme would flip only the CSS-variable components (command
// palette, toasts) and look broken. 'dark'/'system' are accepted but resolve
@@ -91,6 +101,7 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
// Sidebar
navCollapsed: false,
sidebarMobileOpen: false,
navCollapsedSections: {},
// Theme
theme: getInitialTheme(),
@@ -115,6 +126,11 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
setSidebarMobileOpen: (sidebarMobileOpen) =>
set((state) => (state.sidebarMobileOpen === sidebarMobileOpen ? state : { sidebarMobileOpen })),
toggleNavSection: (id) =>
set((state) => ({
navCollapsedSections: { ...state.navCollapsedSections, [id]: !state.navCollapsedSections[id] },
})),
// Actions - Theme
setTheme: (theme) => {
if (get().theme === theme) return
+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,