import { createContext, useContext, type ReactNode } from "react"; import { NavLink } from "@/lib/router"; import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { LucideIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { TruncatedText } from "./TruncatedText"; /** * Forces the full-label (non-rail) presentation for any `SidebarNavItem` * rendered beneath it, regardless of the global `useSidebar().collapsed` state. * * Contextual takeover routes replace the global navigation inside the same * sidebar shell. The user's saved global collapse preference can still be * `collapsed=true`, so this provider keeps contextual navigation readable * without mutating that preference. */ const SidebarNavExpandedContext = createContext(false); export function SidebarNavExpandedProvider({ children }: { children: ReactNode }) { return ( {children} ); } export function useSidebarNavExpanded() { return useContext(SidebarNavExpandedContext); } interface SidebarNavItemProps { to: string; label: string; icon?: LucideIcon; /** * Pre-rendered icon element for rows whose icon isn't a plain Lucide * component (e.g. the agent rows' `AgentIcon`). Takes precedence over `icon`; * the caller owns its sizing/color classes. */ iconNode?: ReactNode; end?: boolean; className?: string; labelClassName?: string; badge?: number; badgeTone?: "default" | "danger" | "warning"; /** * Accessible noun for the numeric badge when collapsed to the rail, where the * count is rendered as a dot (e.g. `badgeLabel="unread"` → "Inbox, 28 unread"). */ badgeLabel?: string; textBadge?: string; textBadgeTone?: "default" | "amber"; alert?: boolean; liveCount?: number; /** * Overrides NavLink's own route matching for rows whose active state is * computed externally (agent rows match `/agents/:ref` across tab suffixes). */ active?: boolean; /** Rendered after the label, before the right-aligned status cluster. */ trailing?: ReactNode; /** Accessible text for `trailing` status content, surfaced in the collapsed rail (where `trailing` is hidden). */ trailingLabel?: string; /** Rendered inside the right-aligned status cluster, before the live dot. */ liveAccessory?: ReactNode; } export function SidebarNavItem({ to, label, icon: Icon, iconNode, end, className, labelClassName, badge, badgeTone = "default", badgeLabel, textBadge, textBadgeTone = "default", alert = false, liveCount, active, trailing, trailingLabel, liveAccessory, }: SidebarNavItemProps) { const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); // A contextual takeover forces full labels even when the saved global app // sidebar preference is collapsed. const forceExpanded = useSidebarNavExpanded(); // The icon-only rail presentation only applies when pinned collapsed and not // peeking; a peek/expanded panel — or a contextual takeover — restores // the full label + badge. const rail = collapsed && !peeking && !forceExpanded; const hasBadge = badge != null && badge > 0; const hasLive = liveCount != null && liveCount > 0; const showIconSlot = Boolean(iconNode || Icon || alert || (rail && (hasLive || hasBadge))); // Accessible text equivalent for the collapsed dot indicator. The visible // label is `sr-only` in the rail, so the count must be surfaced here. const railStatusText = hasLive ? `${liveCount} live` : hasBadge ? `${badge}${badgeLabel ? ` ${badgeLabel}` : ""}` : alert ? "attention needed" : undefined; const railAriaLabel = !rail || (!railStatusText && !trailingLabel) ? undefined : `${label}${railStatusText ? `, ${railStatusText}` : ""}${trailingLabel ? `, ${trailingLabel}` : ""}`; const link = ( { if (isMobile) setSidebarOpen(false); }} className={({ isActive }) => cn( // One rhythm and one inset pill highlight: mx-2 floats the row off // the sidebar edges, rounded-lg matches the card anchor, px-2 gives // the icon breathing room inside the pill. Rows with hover menus // (agents/projects) reserve extra right padding via className. "flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors", (active ?? isActive) ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", className, ) } > {showIconSlot ? ( {iconNode ?? (Icon ? : null)} {alert && ( ); if (!rail) return link; // The tooltip wraps a plain block element rather than the NavLink directly: // Radix `asChild` (Slot) drops React Router's *function* className, which would // strip `flex` off the and render it as a block — the in-flow label would // then stack under the icon and the row would grow. Anchoring the tooltip to a // wrapper keeps the rendering normally (flex), so the row stays 1:1 with // the expanded state and the icon never moves (PAP-10676). return (
{link}
{label}
); }