Sidebar
The AI product's sidebar: a shell that morphs between a panel and an icon rail with tooltips, slides in as a sheet on mobile, holds menus with a gliding hover and active pill and staggered submenus, and a resource tree of folders, projects, conversations, files and bookmarks with keyboard and drag-and-drop moves, rename and a row menu.
pnpm exec shadcn add @intelligo/ai-sidebarInside an app made by intelligo create, or any shadcn project that names the registry.
used by these pages
more in what it produces
Code block, Markdown, Artifact, Motion, Citations, Image generation
Source
What shadcn add writes to components/ui/ai-sidebar.tsx. It is yours from then on.
"use client";
/*
* The AI product's sidebar: a shell that morphs between a full panel and
* an icon rail on desktop and slides in as a sheet on mobile; navigation
* menus with a shared hover pill, an active-item glide and staggered
* submenus; and a resource tree — folders, projects, conversations, files,
* bookmarks — with roving focus, drag-and-drop and keyboard moves, inline
* rename and a row menu. In the rail every item explains itself with a
* tooltip.
*/
import * as React from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
type Variants,
} from "motion/react";
import {
ArrowDownIcon,
ArrowUpIcon,
BookmarkIcon,
ChevronRightIcon,
FileTextIcon,
FolderIcon,
FolderInputIcon,
FolderOpenIcon,
MessageSquareIcon,
MoreHorizontalIcon,
PanelLeftIcon,
PencilIcon,
Undo2Icon,
type LucideIcon,
} from "lucide-react";
import {
EASE_DRAWER,
EASE_OUT,
SPRING_LAYOUT,
SPRING_PRESS,
} from "@/components/ui/ai-motion";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------------------------
* Motion
* ------------------------------------------------------------------------- */
const PANEL_TRANSITION = { duration: 0.36, ease: EASE_DRAWER } as const;
// The desktop rail settles at a hard zero-width boundary. Keep the spring
// critically damped so it cannot overshoot, pause against that boundary, and
// then snap back during the final frame.
const SIDEBAR_MORPH_TRANSITION = {
type: "spring",
stiffness: 380,
damping: 35,
mass: 0.75,
} as const;
const LABEL_ENTER_TRANSITION = {
duration: 0.2,
delay: 0.08,
ease: EASE_OUT,
} as const;
const LABEL_EXIT_TRANSITION = { duration: 0.12, ease: EASE_OUT } as const;
const REDUCED_TRANSITION = { duration: 0.16, ease: EASE_OUT } as const;
const ROW_REVEAL = { duration: 0.16, ease: EASE_OUT } as const;
const SUBMENU_VARIANTS: Variants = {
closed: {
opacity: 0,
clipPath: "inset(0 0 100% 0 round 8px)",
transition: {
duration: 0.14,
ease: EASE_OUT,
staggerChildren: 0.025,
staggerDirection: -1,
},
},
open: {
opacity: 1,
clipPath: "inset(0 0 0% 0 round 8px)",
transition: {
duration: 0.2,
delayChildren: 0.035,
ease: EASE_OUT,
staggerChildren: 0.045,
},
},
};
const SUBMENU_ITEM_VARIANTS: Variants = {
closed: { opacity: 0, y: -6, filter: "blur(3px)" },
open: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.18, ease: EASE_OUT },
},
};
const HOVER_PILL_VARIANTS: Variants = {
initial: { opacity: 0, filter: "blur(6px)" },
animate: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: "blur(6px)" },
};
const HOVER_PILL_REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
/* ----------------------------------------------------------------------------
* Environment: viewport and input, read as external stores so the server
* render and the first client render agree.
* ------------------------------------------------------------------------- */
const MOBILE_QUERY = "(max-width: 767px)";
const TOUCH_QUERY = "(any-pointer: coarse)";
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function subscribeToQuery(query: string) {
return (callback: () => void) => {
const list = window.matchMedia(query);
list.addEventListener("change", callback);
return () => list.removeEventListener("change", callback);
};
}
const subscribeToMobile = subscribeToQuery(MOBILE_QUERY);
const subscribeToTouch = subscribeToQuery(TOUCH_QUERY);
const getMobileSnapshot = () => window.matchMedia(MOBILE_QUERY).matches;
// iPadOS disguises its pointer media queries; maxTouchPoints it reports
// honestly, which is what makes it the standard iPad tell.
const getTouchSnapshot = () =>
window.matchMedia(TOUCH_QUERY).matches || navigator.maxTouchPoints > 0;
const getServerSnapshot = () => false;
function useIsMobile() {
return React.useSyncExternalStore(
subscribeToMobile,
getMobileSnapshot,
getServerSnapshot
);
}
/**
* True on devices that can be touched, whatever else they claim. Not the
* inverse of hover-capable: an iPad browses desktop-class and answers
* `(hover: hover)` with true while a finger is the only input there is.
*/
function useTouchCapable() {
return React.useSyncExternalStore(
subscribeToTouch,
getTouchSnapshot,
getServerSnapshot
);
}
function assignRef<T>(ref: React.Ref<T> | undefined, node: T | null) {
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.RefObject<T | null>).current = node;
}
/* ----------------------------------------------------------------------------
* Provider: one open state for the desktop panel, one for the mobile sheet,
* the keyboard shortcut, and the layout id the active pill glides on.
* ------------------------------------------------------------------------- */
export type AISidebarState = "expanded" | "collapsed";
export type AISidebarSide = "left" | "right";
export type AISidebarVariant = "sidebar" | "floating" | "inset";
export type AISidebarCollapsible = "offcanvas" | "icon" | "none";
export interface AISidebarContextValue {
isMobile: boolean;
layoutId: string;
open: boolean;
openMobile: boolean;
reduce: boolean;
setOpen: (open: boolean) => void;
setOpenMobile: (open: boolean) => void;
state: AISidebarState;
toggleSidebar: () => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}
const SidebarContext = React.createContext<AISidebarContextValue | null>(null);
interface PanelContextValue {
collapsed: boolean;
collapsible: AISidebarCollapsible;
side: AISidebarSide;
}
const PanelContext = React.createContext<PanelContextValue | null>(null);
function useAISidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useAISidebar must be used inside <AISidebarProvider>.");
}
return context;
}
/** The nearest sidebar panel: whether it is collapsed to the rail, and its side. */
function useSidebarPanel() {
const context = React.useContext(PanelContext);
if (!context) {
throw new Error("AISidebar parts must be used inside <AISidebar>.");
}
return context;
}
export type AISidebarStyle = React.CSSProperties & {
"--sidebar-width"?: string;
"--sidebar-width-icon"?: string;
"--sidebar-width-mobile"?: string;
};
export interface AISidebarProviderProps
extends Omit<React.ComponentProps<"div">, "style"> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
openMobile?: boolean;
defaultOpenMobile?: boolean;
onOpenMobileChange?: (open: boolean) => void;
/** The key that toggles the sidebar with ⌘ / Ctrl; `null` turns it off. */
shortcut?: string | null;
/** Widths come in as `--sidebar-width`, `--sidebar-width-icon`, `--sidebar-width-mobile`. */
style?: AISidebarStyle;
}
function AISidebarProvider({
children,
open,
defaultOpen = true,
onOpenChange,
openMobile,
defaultOpenMobile = false,
onOpenMobileChange,
shortcut = "b",
className,
style,
...props
}: AISidebarProviderProps) {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const [internalOpenMobile, setInternalOpenMobile] =
React.useState(defaultOpenMobile);
const isMobile = useIsMobile();
const reduce = useReducedMotion() ?? false;
const generatedId = React.useId();
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const desktopOpen = open ?? internalOpen;
const mobileOpen = openMobile ?? internalOpenMobile;
const setOpen = React.useCallback(
(nextOpen: boolean) => {
if (open === undefined) setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[onOpenChange, open]
);
const setOpenMobile = React.useCallback(
(nextOpen: boolean) => {
if (openMobile === undefined) setInternalOpenMobile(nextOpen);
onOpenMobileChange?.(nextOpen);
},
[onOpenMobileChange, openMobile]
);
const toggleSidebar = React.useCallback(() => {
if (isMobile) setOpenMobile(!mobileOpen);
else setOpen(!desktopOpen);
}, [desktopOpen, isMobile, mobileOpen, setOpen, setOpenMobile]);
React.useEffect(() => {
if (!shortcut) return;
const handleShortcut = (event: KeyboardEvent) => {
if (
event.key.toLowerCase() === shortcut.toLowerCase() &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, [shortcut, toggleSidebar]);
const value = React.useMemo<AISidebarContextValue>(
() => ({
isMobile,
layoutId: `${generatedId}-active`,
open: desktopOpen,
openMobile: mobileOpen,
reduce,
setOpen,
setOpenMobile,
state: desktopOpen ? "expanded" : "collapsed",
toggleSidebar,
triggerRef,
}),
[
desktopOpen,
generatedId,
isMobile,
mobileOpen,
reduce,
setOpen,
setOpenMobile,
toggleSidebar,
]
);
return (
<SidebarContext.Provider value={value}>
<TooltipProvider>
<div
{...props}
data-slot="sidebar-wrapper"
data-state={desktopOpen ? "expanded" : "collapsed"}
style={{
"--sidebar-width": "16rem",
"--sidebar-width-icon": "4.25rem",
"--sidebar-width-mobile": "18rem",
...style,
}}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full min-w-0",
className
)}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
/* ----------------------------------------------------------------------------
* The mobile sheet: a scrim and a panel that slides in from its side, with
* a focus trap, Escape, a body scroll lock and focus handed back to the
* trigger on close.
* ------------------------------------------------------------------------- */
function MobileSidebar({
label,
closeLabel,
children,
className,
side,
}: {
label: string;
closeLabel: string;
children: React.ReactNode;
className?: string;
side: AISidebarSide;
}) {
const context = useAISidebar();
const panelRef = React.useRef<HTMLDivElement>(null);
// The sheet is mounted for as long as the viewport is mobile, so it hides
// itself while closed rather than sitting there transparent and interactive.
// Opening shows it in the same commit that starts the slide — a delayed show
// would run the focus effect below against a still-hidden panel, and focus()
// on a hidden element is ignored. Closing waits for the slide to finish, and
// the panel's own exit tells us when that is: no duration to keep in sync.
const [hidden, setHidden] = React.useState(!context.openMobile);
const [wasOpen, setWasOpen] = React.useState(context.openMobile);
if (context.openMobile !== wasOpen) {
setWasOpen(context.openMobile);
if (context.openMobile) setHidden(false);
}
// The completion callback fires for the open slide too, and it reads state
// from whenever motion settles: a ref keeps it on the current one.
const openMobileRef = React.useRef(context.openMobile);
React.useEffect(() => {
openMobileRef.current = context.openMobile;
}, [context.openMobile]);
React.useEffect(() => {
if (!context.openMobile) return;
const body = document.body;
const scrollY = window.scrollY;
const previous = {
left: body.style.left,
overflow: body.style.overflow,
position: body.style.position,
right: body.style.right,
top: body.style.top,
};
body.style.position = "fixed";
body.style.top = `-${scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.overflow = "hidden";
const focusFrame = requestAnimationFrame(() => {
const first =
panelRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
(first ?? panelRef.current)?.focus({ preventScroll: true });
});
return () => {
cancelAnimationFrame(focusFrame);
body.style.position = previous.position;
body.style.top = previous.top;
body.style.left = previous.left;
body.style.right = previous.right;
body.style.overflow = previous.overflow;
window.scrollTo(0, scrollY);
context.triggerRef.current?.focus({ preventScroll: true });
};
}, [context.openMobile, context.triggerRef]);
// This container groups the sheet for hiding and the z-index and carries no
// box: both children are `fixed` and resolve against the viewport themselves.
return (
<div
data-slot="sidebar-mobile"
className={cn(
"pointer-events-none fixed top-0 left-0 z-50 size-0 md:hidden",
hidden && !context.openMobile ? "invisible" : "visible"
)}
>
<motion.button
type="button"
aria-label={closeLabel}
tabIndex={context.openMobile ? 0 : -1}
initial={false}
animate={{ opacity: context.openMobile ? 1 : 0 }}
transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}
onClick={() => context.setOpenMobile(false)}
data-slot="sidebar-scrim"
className={cn(
"fixed inset-0 bg-background/60 backdrop-blur-xs",
context.openMobile ? "pointer-events-auto" : "pointer-events-none"
)}
/>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={label}
aria-hidden={!context.openMobile}
inert={!context.openMobile}
tabIndex={-1}
data-slot="sidebar"
data-mobile="true"
data-state={context.openMobile ? "expanded" : "collapsed"}
data-side={side}
initial={false}
animate={{
opacity: context.reduce ? (context.openMobile ? 1 : 0) : 1,
x: context.reduce
? 0
: context.openMobile
? "0%"
: side === "left"
? "-100%"
: "100%",
}}
transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}
onAnimationComplete={() => {
if (!openMobileRef.current) setHidden(true);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
context.setOpenMobile(false);
return;
}
if (event.key !== "Tab") return;
const focusable = panelRef.current
? Array.from(
panelRef.current.querySelectorAll<HTMLElement>(
FOCUSABLE_SELECTOR
)
)
: [];
if (focusable.length === 0) {
event.preventDefault();
panelRef.current?.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}}
style={{ maxWidth: "88vw" }}
className={cn(
"pointer-events-auto fixed inset-y-0 flex h-dvh w-(--sidebar-width-mobile) flex-col overflow-hidden",
"border-sidebar-border bg-sidebar text-sidebar-foreground shadow-2xl will-change-transform",
side === "left" ? "left-0 border-r" : "right-0 border-l",
!context.openMobile && "pointer-events-none",
className
)}
>
<PanelContext.Provider
value={{ collapsed: false, collapsible: "none", side }}
>
{children}
</PanelContext.Provider>
</motion.div>
</div>
);
}
/* ----------------------------------------------------------------------------
* The sidebar itself: an aside whose width morphs between the panel and
* the icon rail (or off the canvas), and the sheet on mobile.
* ------------------------------------------------------------------------- */
export interface AISidebarProps
extends Omit<HTMLMotionProps<"aside">, "children"> {
children?: React.ReactNode;
side?: AISidebarSide;
variant?: AISidebarVariant;
collapsible?: AISidebarCollapsible;
/** Accessible name of the sidebar. */
label?: string;
/** Accessible name of the scrim that closes the mobile sheet. */
closeLabel?: string;
panelClassName?: string;
}
function AISidebar({
side = "left",
variant = "sidebar",
collapsible = "icon",
label = "Sidebar",
closeLabel = "Close sidebar",
children,
className,
panelClassName,
...props
}: AISidebarProps) {
const context = useAISidebar();
const collapsed = collapsible !== "none" && !context.open;
const offcanvas = collapsed && collapsible === "offcanvas";
const width = offcanvas
? "0px"
: collapsed
? "var(--sidebar-width-icon)"
: "var(--sidebar-width)";
if (context.isMobile) {
return (
<MobileSidebar
label={label}
closeLabel={closeLabel}
className={className}
side={side}
>
{children}
</MobileSidebar>
);
}
const framed = variant === "floating" || variant === "inset";
return (
<motion.aside
{...props}
initial={false}
aria-label={label}
data-slot="sidebar"
data-state={collapsed ? "collapsed" : "expanded"}
data-collapsible={collapsible}
data-variant={variant}
data-side={side}
animate={{ width }}
transition={context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION}
className={cn(
"group/sidebar peer relative hidden h-auto shrink-0 will-change-[width] md:block",
side === "right" && "order-last",
className
)}
>
<motion.div
initial={false}
animate={{
opacity: offcanvas ? 0 : 1,
x: offcanvas ? (side === "left" ? "-100%" : "100%") : "0%",
}}
transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}
data-slot="sidebar-panel"
style={framed ? { height: "calc(100svh - 1rem)" } : undefined}
className={cn(
"sticky top-0 flex h-svh w-full flex-col overflow-hidden bg-sidebar text-sidebar-foreground",
collapsible === "offcanvas" && "w-(--sidebar-width)",
variant === "sidebar" &&
(side === "left"
? "border-r border-sidebar-border"
: "border-l border-sidebar-border"),
variant === "floating" &&
"m-2 rounded-xl border border-sidebar-border shadow-sm",
variant === "inset" && "m-2 rounded-xl",
panelClassName
)}
>
<PanelContext.Provider value={{ collapsed, collapsible, side }}>
{children}
</PanelContext.Provider>
</motion.div>
</motion.aside>
);
}
/* ----------------------------------------------------------------------------
* Controls: the trigger (anywhere in the app), a close button, the thin
* rail on the sidebar's edge, and the main area beside it.
* ------------------------------------------------------------------------- */
export interface AISidebarTriggerProps
extends React.ComponentProps<typeof Button> {
/** Accessible name; the default child is a panel icon. */
label?: string;
}
function AISidebarTrigger({
label = "Toggle sidebar",
className,
onClick,
children,
variant = "ghost",
size = "icon",
...props
}: AISidebarTriggerProps) {
const context = useAISidebar();
const expanded = context.isMobile ? context.openMobile : context.open;
const forwarded = (props as { ref?: React.Ref<HTMLButtonElement> }).ref;
return (
<Button
{...props}
ref={(node: HTMLButtonElement | null) => {
context.triggerRef.current = node;
assignRef(forwarded, node);
}}
variant={variant}
size={size}
aria-label={label}
aria-expanded={expanded}
data-slot="sidebar-trigger"
data-state={expanded ? "expanded" : "collapsed"}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) context.toggleSidebar();
}}
// `aria-expanded` is this toggle's resting state while the panel is
// open, not a pressed look: keep the ghost button flat until hovered.
className={cn(
"shrink-0 aria-expanded:not-hover:bg-transparent aria-expanded:not-hover:text-muted-foreground",
className
)}
>
{children ?? <PanelLeftIcon />}
</Button>
);
}
export interface AISidebarCloseProps
extends React.ComponentProps<typeof Button> {
label?: string;
}
function AISidebarClose({
label = "Close sidebar",
className,
onClick,
variant = "ghost",
size = "icon",
...props
}: AISidebarCloseProps) {
const context = useAISidebar();
return (
<Button
{...props}
variant={variant}
size={size}
aria-label={label}
data-slot="sidebar-close"
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.isMobile) context.setOpenMobile(false);
else context.setOpen(false);
}}
className={cn("shrink-0", className)}
/>
);
}
export interface AISidebarRailProps extends React.ComponentProps<"button"> {
label?: string;
}
/** A thin strip on the sidebar's edge that toggles it — a pointer target, not a tab stop. */
function AISidebarRail({
label = "Toggle sidebar",
className,
onClick,
type = "button",
...props
}: AISidebarRailProps) {
const context = useAISidebar();
const panel = useSidebarPanel();
return (
<button
{...props}
type={type}
data-slot="sidebar-rail"
data-side={panel.side}
aria-label={label}
title={label}
tabIndex={-1}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) context.toggleSidebar();
}}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-none md:block",
"after:absolute after:inset-y-0 after:left-1/2 after:w-px after:bg-transparent after:transition-colors hover:after:bg-sidebar-border",
"data-[side=left]:left-full data-[side=right]:right-0 data-[side=right]:translate-x-1/2",
className
)}
/>
);
}
function AISidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
{...props}
data-slot="sidebar-inset"
className={cn(
"relative flex min-h-svh min-w-0 flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className
)}
/>
);
}
/* ----------------------------------------------------------------------------
* Regions
* ------------------------------------------------------------------------- */
function AISidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
{...props}
data-slot="sidebar-header"
className={cn("flex shrink-0 flex-col gap-2 p-3", className)}
/>
);
}
function AISidebarContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
{...props}
data-slot="sidebar-content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto overscroll-contain px-2 py-2",
className
)}
/>
);
}
function AISidebarFooter({
className,
style,
...props
}: React.ComponentProps<"div">) {
return (
<div
{...props}
data-slot="sidebar-footer"
style={{
paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))",
...style,
}}
className={cn(
"flex shrink-0 flex-col gap-2 border-t border-sidebar-border p-3",
className
)}
/>
);
}
export interface AISidebarSectionProps extends React.ComponentProps<"div"> {
/** A small caps heading; it fades out in the rail. */
label?: React.ReactNode;
/** A control at the end of the heading — "new", "add" — hidden in the rail. */
action?: React.ReactNode;
}
function AISidebarSection({
label,
action,
children,
className,
...props
}: AISidebarSectionProps) {
const { collapsed } = useSidebarPanel();
return (
<div
{...props}
data-slot="sidebar-section"
className={cn("flex w-full min-w-0 flex-col px-1 py-1.5", className)}
>
{label !== undefined && label !== null ? (
<div
aria-hidden={collapsed}
data-slot="sidebar-section-label"
className={cn(
"mb-1 flex h-7 items-center justify-between gap-2 overflow-hidden px-2 text-xs font-medium tracking-widest text-muted-foreground uppercase transition-opacity",
collapsed ? "opacity-0" : "opacity-100"
)}
>
<span className="min-w-0 truncate">{label}</span>
{action && !collapsed ? (
<span
data-slot="sidebar-section-action"
className="flex shrink-0 items-center normal-case tracking-normal"
>
{action}
</span>
) : null}
</div>
) : null}
<div data-slot="sidebar-section-content" className="w-full min-w-0">
{children}
</div>
</div>
);
}
/* ----------------------------------------------------------------------------
* Menus: a list whose hover pill glides between rows, items with an
* active pill that glides too, and submenus that unfold with a stagger.
* ------------------------------------------------------------------------- */
interface MenuContextValue {
hoveredId: string | null;
setHoveredId: (id: string | null) => void;
pillId: string;
}
const MenuContext = React.createContext<MenuContextValue | null>(null);
export interface AISidebarMenuProps
extends Omit<HTMLMotionProps<"ul">, "children"> {
children?: React.ReactNode;
}
function AISidebarMenu({
children,
className,
onMouseLeave,
...props
}: AISidebarMenuProps) {
const [hoveredId, setHoveredId] = React.useState<string | null>(null);
const pillId = React.useId();
const value = React.useMemo<MenuContextValue>(
() => ({ hoveredId, setHoveredId, pillId }),
[hoveredId, pillId]
);
// layoutRoot scopes the pill's layout projection to this list, so fixed or
// scrolled ancestors can't smear scroll offsets into its movement.
return (
<MenuContext.Provider value={value}>
<motion.ul
{...props}
layoutRoot
data-slot="sidebar-menu"
onMouseLeave={(event) => {
setHoveredId(null);
onMouseLeave?.(event);
}}
className={cn(
"flex w-full min-w-0 list-none flex-col gap-0.5",
className
)}
>
{children}
</motion.ul>
</MenuContext.Provider>
);
}
export interface AISidebarMenuItemProps
extends Omit<HTMLMotionProps<"li">, "children"> {
children?: React.ReactNode;
}
function AISidebarMenuItem({
children,
className,
onMouseEnter,
...props
}: AISidebarMenuItemProps) {
const menu = React.useContext(MenuContext);
const reduce = useReducedMotion() ?? false;
const id = React.useId();
const hovered = menu?.hoveredId === id;
return (
<motion.li
{...props}
layout="position"
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
data-slot="sidebar-menu-item"
onMouseEnter={(event) => {
menu?.setHoveredId(id);
onMouseEnter?.(event);
}}
className={cn("group/sidebar-menu-item relative", className)}
>
{menu ? (
<AnimatePresence>
{menu.hoveredId !== null ? (
<motion.div
key="hover-pill"
aria-hidden
variants={reduce ? HOVER_PILL_REDUCED_VARIANTS : HOVER_PILL_VARIANTS}
initial="initial"
animate="animate"
exit="exit"
className="pointer-events-none absolute inset-x-0 top-0 h-9"
>
{hovered ? (
<motion.div
layoutId={menu.pillId}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className="size-full rounded-md bg-sidebar-accent/70"
/>
) : null}
</motion.div>
) : null}
</AnimatePresence>
) : null}
<div className="relative z-10">{children}</div>
</motion.li>
);
}
export interface AISidebarItemProps {
children: React.ReactNode;
icon?: React.ReactNode;
badge?: React.ReactNode;
href?: string;
isActive?: boolean;
/** Set when the item opens a submenu: renders the chevron and `aria-expanded`. */
expanded?: boolean;
disabled?: boolean;
/** Close the mobile sheet on select; defaults to true unless the item opens a submenu. */
closeOnSelect?: boolean;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string;
onSelect?: () => void;
/** What the rail tooltip says; defaults to the label when it is a string. */
tooltip?: string;
/**
* The element to render instead of the default `a`/`button` — a router
* link, say `<Link href="/chat" />`. Its own props and handlers are
* merged with the item's.
*/
render?: React.ReactElement;
/** A control at the row's end — a menu trigger — shown on hover and focus. */
action?: React.ReactNode;
onKeyDown?: React.KeyboardEventHandler<HTMLElement>;
title?: string;
className?: string;
}
function AISidebarItem({
children,
icon,
badge,
href,
isActive = false,
expanded,
disabled = false,
closeOnSelect,
target,
rel,
onSelect,
tooltip,
render,
action,
onKeyDown,
title,
className,
}: AISidebarItemProps) {
const context = useAISidebar();
const panel = useSidebarPanel();
const showAction = Boolean(action) && !panel.collapsed;
const textLabel = typeof children === "string" ? children : undefined;
const tip = tooltip ?? textLabel;
const select = (
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
) => {
if (disabled) {
event.preventDefault();
return;
}
onSelect?.();
const shouldCloseOnSelect = closeOnSelect ?? expanded === undefined;
if (context.isMobile && shouldCloseOnSelect) context.setOpenMobile(false);
// A submenu cannot render in the icon rail, so opening one from there
// leaves its children unreachable — a pointer can still fall back to the
// rail or the shortcut, a finger has nothing. Selecting a group unfolds
// the panel that is about to hold it.
if (expanded !== undefined && panel.collapsed && !context.isMobile) {
context.setOpen(true);
}
};
const content = (
<>
{isActive ? (
<motion.span
layoutId={context.layoutId}
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
data-slot="sidebar-item-active"
className="absolute inset-0 rounded-md bg-sidebar-accent"
/>
) : null}
{icon ? (
<span
aria-hidden="true"
data-slot="sidebar-item-icon"
className="relative z-10 grid size-5 shrink-0 place-items-center [&_svg:not([class*='size-'])]:size-4"
>
{icon}
</span>
) : null}
<motion.span
initial={false}
animate={{
opacity: panel.collapsed ? 0 : 1,
x: context.reduce ? 0 : panel.collapsed ? -4 : 0,
}}
transition={
context.reduce
? REDUCED_TRANSITION
: panel.collapsed
? LABEL_EXIT_TRANSITION
: LABEL_ENTER_TRANSITION
}
aria-hidden={panel.collapsed}
data-slot="sidebar-item-label"
className={cn(
"relative z-10 min-w-0 flex-1 truncate",
panel.collapsed && "pointer-events-none"
)}
>
{children}
</motion.span>
{badge && !panel.collapsed ? (
<span
data-slot="sidebar-item-badge"
className="relative z-10 shrink-0 text-xs text-muted-foreground"
>
{badge}
</span>
) : null}
{expanded !== undefined ? (
<motion.span
aria-hidden="true"
initial={false}
animate={{
opacity: panel.collapsed ? 0 : 1,
rotate: expanded ? 90 : 0,
x: context.reduce ? 0 : panel.collapsed ? 4 : 0,
}}
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
className="relative z-10 grid size-4 shrink-0 place-items-center text-muted-foreground"
>
<ChevronRightIcon className="size-3.5" />
</motion.span>
) : null}
</>
);
const interactiveClassName = cn(
"relative flex min-h-9 w-full min-w-0 items-center gap-2.5 overflow-hidden rounded-md px-3 text-left text-sm font-medium outline-none",
"text-muted-foreground transition-colors hover:text-sidebar-foreground",
"focus-visible:bg-sidebar-accent/70 focus-visible:ring-2 focus-visible:ring-sidebar-ring",
isActive && "text-sidebar-accent-foreground",
disabled && "cursor-not-allowed opacity-40",
showAction && "pr-9",
className
);
const element = render ? (
render
) : href ? (
<motion.a
href={href}
target={target}
rel={rel ?? (target === "_blank" ? "noreferrer noopener" : undefined)}
aria-current={isActive ? "page" : undefined}
aria-expanded={expanded}
aria-disabled={disabled || undefined}
aria-label={panel.collapsed ? tip : undefined}
tabIndex={disabled ? -1 : undefined}
data-slot="sidebar-item"
data-active={isActive || undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
/>
) : (
<motion.button
type="button"
disabled={disabled}
aria-current={isActive ? "page" : undefined}
aria-expanded={expanded}
aria-label={panel.collapsed ? tip : undefined}
data-slot="sidebar-item"
data-active={isActive || undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
/>
);
// A custom element gets the item's props through Base UI's merge; the
// default ones carry them already.
const merged = render
? {
"aria-current": isActive ? ("page" as const) : undefined,
"aria-expanded": expanded,
"aria-disabled": disabled || undefined,
"aria-label": panel.collapsed ? tip : undefined,
"data-slot": "sidebar-item",
"data-active": isActive || undefined,
onClick: select,
className: interactiveClassName,
}
: {};
// The rail shows only the icon; the tooltip carries the label there.
return (
<>
<Tooltip disabled={!panel.collapsed || !tip}>
<TooltipTrigger
render={element}
onKeyDown={onKeyDown}
title={title}
{...merged}
>
{content}
</TooltipTrigger>
<TooltipContent
side={panel.side === "right" ? "left" : "right"}
sideOffset={8}
>
{tip}
</TooltipContent>
</Tooltip>
{showAction ? (
<span
data-slot="sidebar-item-action"
className="absolute top-1/2 right-1 z-20 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-focus-within/sidebar-menu-item:opacity-100 group-hover/sidebar-menu-item:opacity-100 has-data-popup-open:opacity-100 pointer-coarse:opacity-100"
>
{action}
</span>
) : null}
</>
);
}
export interface AISidebarSubmenuProps
extends Omit<HTMLMotionProps<"ul">, "children"> {
open: boolean;
children?: React.ReactNode;
}
function AISidebarSubmenu({
open,
children,
className,
...props
}: AISidebarSubmenuProps) {
const context = useAISidebar();
const panel = useSidebarPanel();
return (
<AnimatePresence initial={false} mode="popLayout">
{open && !panel.collapsed ? (
<motion.ul
{...props}
key="sidebar-submenu"
variants={context.reduce ? undefined : SUBMENU_VARIANTS}
initial={context.reduce ? false : "closed"}
animate={context.reduce ? { opacity: 1 } : "open"}
exit={context.reduce ? { opacity: 0 } : "closed"}
transition={context.reduce ? { duration: 0.12 } : undefined}
data-slot="sidebar-submenu"
className={cn(
"relative mt-1 ml-5 flex min-w-0 list-none flex-col gap-0.5 border-l border-sidebar-border pl-3",
className
)}
>
{children}
</motion.ul>
) : null}
</AnimatePresence>
);
}
export interface AISidebarSubItemProps {
children: React.ReactNode;
icon?: React.ReactNode;
href?: string;
isActive?: boolean;
disabled?: boolean;
closeOnSelect?: boolean;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string;
onSelect?: () => void;
/** The element to render instead of the default `a`/`button`, e.g. a router link. */
render?: React.ReactElement;
className?: string;
}
function AISidebarSubItem({
children,
icon,
href,
isActive = false,
disabled = false,
closeOnSelect = true,
target,
rel,
onSelect,
render,
className,
}: AISidebarSubItemProps) {
const context = useAISidebar();
const select = (
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
) => {
if (disabled) {
event.preventDefault();
return;
}
onSelect?.();
if (context.isMobile && closeOnSelect) context.setOpenMobile(false);
};
const content = (
<>
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center [&_svg:not([class*='size-'])]:size-3.5"
>
{icon ?? <span className="size-1 rounded-full bg-current" />}
</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</>
);
const interactiveClassName = cn(
"flex min-h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-xs outline-none",
"text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
"focus-visible:bg-sidebar-accent/70 focus-visible:ring-2 focus-visible:ring-sidebar-ring",
isActive && "bg-sidebar-accent/70 text-sidebar-accent-foreground",
disabled && "cursor-not-allowed opacity-40",
className
);
return (
<motion.li
variants={SUBMENU_ITEM_VARIANTS}
data-slot="sidebar-sub-item"
className="relative min-w-0"
>
{render ? (
React.cloneElement(
render as React.ReactElement<Record<string, unknown>>,
{
"aria-current": isActive ? "page" : undefined,
"aria-disabled": disabled || undefined,
"data-active": isActive || undefined,
onClick: (
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
) => {
(
render.props as {
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
}
).onClick?.(event);
select(event);
},
className: cn(
interactiveClassName,
(render.props as { className?: string }).className
),
},
content
)
) : href ? (
<motion.a
href={href}
target={target}
rel={rel ?? (target === "_blank" ? "noreferrer noopener" : undefined)}
aria-current={isActive ? "page" : undefined}
aria-disabled={disabled || undefined}
tabIndex={disabled ? -1 : undefined}
data-active={isActive || undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.a>
) : (
<motion.button
type="button"
disabled={disabled}
aria-current={isActive ? "page" : undefined}
data-active={isActive || undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.button>
)}
</motion.li>
);
}
/* ----------------------------------------------------------------------------
* The resource tree: folders and projects that hold conversations, files
* and bookmarks. Roving focus, `Alt+Shift+Arrow` moves, pointer drag, F2
* rename, and a row menu that carries the same moves for a finger.
* ------------------------------------------------------------------------- */
export type AISidebarResourceKind =
| "folder"
| "project"
| "conversation"
| "file"
| "bookmark";
export interface AISidebarResource {
id: string;
label: string;
kind: AISidebarResourceKind;
children?: AISidebarResource[];
disabled?: boolean;
}
export type AISidebarDropPosition = "before" | "inside" | "after";
export interface AISidebarResourceMove {
itemId: string;
targetId: string | null;
position: AISidebarDropPosition;
}
/**
* The moves this row can make right now, the same four the keyboard offers on
* `Alt+Shift+Arrow`. A pointer drag is the fast path for them; a finger has no
* drag to give, so the row menu carries them too. Absent keys are moves this
* row cannot make from where it sits.
*/
export interface AISidebarMoveCommands {
up?: () => void;
down?: () => void;
into?: { label: string; run: () => void };
out?: () => void;
}
export interface AISidebarResourceMenuControls {
close: () => void;
rename: () => void;
moves: AISidebarMoveCommands;
}
/** Every string the tree shows or announces, so a product can translate it. */
export interface AISidebarTreeLabels {
/** Accessible name of the tree. */
tree: string;
rename: string;
moveUp: string;
moveDown: string;
moveInto: (target: string) => string;
moveOut: string;
moveToTopLevel: string;
actionsFor: (item: string) => string;
renameField: (item: string) => string;
movePending: string;
moved: (item: string, position: AISidebarDropPosition, target: string) => string;
movedToTopLevel: (item: string) => string;
moveFailed: (item: string) => string;
renameFailed: (item: string) => string;
/** The name used for an item the tree can no longer find. */
item: string;
}
const DEFAULT_TREE_LABELS: AISidebarTreeLabels = {
tree: "Resources",
rename: "Rename",
moveUp: "Move up",
moveDown: "Move down",
moveInto: (target) => `Move into ${target}`,
moveOut: "Move out",
moveToTopLevel: "Move to top level",
actionsFor: (item) => `Actions for ${item}`,
renameField: (item) => `Rename ${item}`,
movePending: "Wait for the current move to finish.",
moved: (item, position, target) => `Moved ${item} ${position} ${target}.`,
movedToTopLevel: (item) => `Moved ${item} to the top level.`,
moveFailed: (item) => `Move failed. ${item} was restored.`,
renameFailed: (item) => `Rename failed. ${item} was restored.`,
item: "item",
};
export interface AISidebarTreeProps {
items?: AISidebarResource[];
defaultItems?: AISidebarResource[];
onItemsChange?: (items: AISidebarResource[]) => void;
/** Reject the promise to roll the optimistic move back. */
onMove?: (move: AISidebarResourceMove) => void | Promise<void>;
onMoveError?: (error: unknown, move: AISidebarResourceMove) => void;
onRename?: (item: AISidebarResource, label: string) => void | Promise<void>;
activeId?: string | null;
defaultActiveId?: string | null;
onActiveChange?: (id: string) => void;
defaultExpandedIds?: string[];
renderIcon?: (item: AISidebarResource) => React.ReactNode;
renderMenu?: (
item: AISidebarResource,
controls: AISidebarResourceMenuControls
) => React.ReactNode;
labels?: Partial<AISidebarTreeLabels>;
className?: string;
}
interface FlatResource {
item: AISidebarResource;
depth: number;
parentId: string | null;
}
interface DropTarget {
id: string | null;
position: AISidebarDropPosition;
}
function canContain(item: AISidebarResource) {
return item.kind === "folder" || item.kind === "project";
}
function flattenResources(
items: AISidebarResource[],
expanded: Set<string>,
depth = 0,
parentId: string | null = null
): FlatResource[] {
return items.flatMap((item) => {
const row = { item, depth, parentId };
if (!item.children?.length || !expanded.has(item.id)) return [row];
return [
row,
...flattenResources(item.children, expanded, depth + 1, item.id),
];
});
}
function findResource(
items: AISidebarResource[],
id: string
): AISidebarResource | undefined {
for (const item of items) {
if (item.id === id) return item;
const child = item.children ? findResource(item.children, id) : undefined;
if (child) return child;
}
return undefined;
}
function containsResource(item: AISidebarResource, id: string): boolean {
return (
item.id === id ||
item.children?.some((child) => containsResource(child, id)) === true
);
}
function removeResource(
items: AISidebarResource[],
id: string
): { items: AISidebarResource[]; removed?: AISidebarResource } {
let removed: AISidebarResource | undefined;
const next: AISidebarResource[] = [];
for (const item of items) {
if (item.id === id) {
removed = item;
continue;
}
if (item.children?.length) {
const childResult = removeResource(item.children, id);
if (childResult.removed) {
removed = childResult.removed;
next.push({ ...item, children: childResult.items });
continue;
}
}
next.push(item);
}
return { items: next, removed };
}
function insertResource(
items: AISidebarResource[],
resource: AISidebarResource,
targetId: string | null,
position: AISidebarDropPosition
): AISidebarResource[] {
if (targetId === null) return [...items, resource];
const next: AISidebarResource[] = [];
for (const item of items) {
if (item.id === targetId) {
if (position === "before") next.push(resource, item);
else if (position === "after") next.push(item, resource);
else
next.push({ ...item, children: [...(item.children ?? []), resource] });
continue;
}
if (item.children?.length) {
next.push({
...item,
children: insertResource(item.children, resource, targetId, position),
});
} else {
next.push(item);
}
}
return next;
}
function moveResource(
items: AISidebarResource[],
move: AISidebarResourceMove
): AISidebarResource[] | null {
const source = findResource(items, move.itemId);
if (!source || source.disabled) return null;
if (move.targetId && containsResource(source, move.targetId)) return null;
const target = move.targetId ? findResource(items, move.targetId) : undefined;
if (
move.position === "inside" &&
(!target || target.disabled || !canContain(target))
)
return null;
const removed = removeResource(items, move.itemId);
if (!removed.removed) return null;
return insertResource(
removed.items,
removed.removed,
move.targetId,
move.position
);
}
function renameResource(
items: AISidebarResource[],
id: string,
label: string
): AISidebarResource[] {
return items.map((item) => ({
...item,
label: item.id === id ? label : item.label,
children: item.children
? renameResource(item.children, id, label)
: undefined,
}));
}
function defaultIcon(item: AISidebarResource, expanded: boolean) {
const Icon: LucideIcon =
item.kind === "folder" || item.kind === "project"
? expanded
? FolderOpenIcon
: FolderIcon
: item.kind === "bookmark"
? BookmarkIcon
: item.kind === "conversation"
? MessageSquareIcon
: FileTextIcon;
return <Icon className="size-4" />;
}
/** A label too long for its row scrolls past while the row is hovered. */
function MarqueeLabel({
active,
children,
}: {
active: boolean;
children: string;
}) {
const reduce = useReducedMotion() ?? false;
const viewportRef = React.useRef<HTMLSpanElement>(null);
const labelRef = React.useRef<HTMLSpanElement>(null);
const [distance, setDistance] = React.useState(0);
React.useEffect(() => {
const viewport = viewportRef.current;
const label = labelRef.current;
if (!viewport || !label) return;
// ResizeObserver reports once on observe, so this is the first measure too.
const observer = new ResizeObserver(() => {
setDistance(
label.scrollWidth > viewport.clientWidth ? label.scrollWidth + 24 : 0
);
});
observer.observe(viewport);
observer.observe(label);
return () => observer.disconnect();
}, []);
const running = active && distance > 0 && !reduce;
return (
<span
ref={viewportRef}
data-slot="sidebar-tree-label"
className="block min-w-0 flex-1 overflow-hidden"
>
<motion.span
className="flex w-max items-center gap-6 whitespace-nowrap"
animate={{ x: running ? [0, -distance] : 0 }}
transition={
running
? {
duration: Math.max(2.4, distance / 34),
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 2,
}
: ROW_REVEAL
}
>
<span ref={labelRef}>{children}</span>
{running ? <span aria-hidden="true">{children}</span> : null}
</motion.span>
</span>
);
}
function ResourceMenuAction({
icon: Icon,
onSelect,
children,
}: {
icon: LucideIcon;
onSelect: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onSelect}
data-slot="sidebar-tree-menu-action"
className="flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left text-xs text-foreground transition-colors outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{children}</span>
</button>
);
}
interface ResourceRowProps {
row: FlatResource;
active: boolean;
expanded: boolean;
focused: boolean;
draggingId: string | null;
dropTarget: DropTarget | null;
menuOpen: boolean;
moves: AISidebarMoveCommands;
renaming: boolean;
labels: AISidebarTreeLabels;
onDragEnd: () => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>, row: FlatResource) => void;
onDragStart: (event: React.DragEvent<HTMLDivElement>, id: string) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
onFocus: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
onMenuOpenChange: (open: boolean) => void;
onRenameCancel: () => void;
onRenameCommit: (label: string) => void;
onRenameStart: () => void;
onSelect: () => void;
onToggle: () => void;
renderIcon?: (item: AISidebarResource) => React.ReactNode;
renderMenu?: AISidebarTreeProps["renderMenu"];
setRef: (node: HTMLDivElement | null) => void;
}
function ResourceRow({
row,
active,
expanded,
focused,
draggingId,
dropTarget,
menuOpen,
moves,
renaming,
labels,
onDragEnd,
onDragOver,
onDragStart,
onDrop,
onFocus,
onKeyDown,
onMenuOpenChange,
onRenameCancel,
onRenameCommit,
onRenameStart,
onSelect,
onToggle,
renderIcon,
renderMenu,
setRef,
}: ResourceRowProps) {
const reduce = useReducedMotion() ?? false;
const canTouch = useTouchCapable();
const [hovered, setHovered] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const skipRenameBlurRef = React.useRef(false);
const draggedRef = React.useRef(false);
const [draft, setDraft] = React.useState(row.item.label);
const acceptsChildren = canContain(row.item);
const isDragging = draggingId === row.item.id;
const dropPosition =
dropTarget?.id === row.item.id ? dropTarget.position : null;
React.useEffect(() => {
if (!renaming) return;
skipRenameBlurRef.current = false;
setDraft(row.item.label);
const frame = requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
return () => cancelAnimationFrame(frame);
}, [renaming, row.item.label]);
const runFromMenu = (action: () => void) => () => {
onMenuOpenChange(false);
action();
};
const menu = renderMenu?.(row.item, {
close: () => onMenuOpenChange(false),
rename: () => {
onMenuOpenChange(false);
onRenameStart();
},
moves,
}) ?? (
<>
<ResourceMenuAction
icon={PencilIcon}
onSelect={runFromMenu(onRenameStart)}
>
{labels.rename}
</ResourceMenuAction>
{moves.up || moves.down || moves.into || moves.out ? (
<div aria-hidden="true" className="my-1 h-px bg-border" />
) : null}
{moves.up ? (
<ResourceMenuAction icon={ArrowUpIcon} onSelect={runFromMenu(moves.up)}>
{labels.moveUp}
</ResourceMenuAction>
) : null}
{moves.down ? (
<ResourceMenuAction
icon={ArrowDownIcon}
onSelect={runFromMenu(moves.down)}
>
{labels.moveDown}
</ResourceMenuAction>
) : null}
{moves.into ? (
<ResourceMenuAction
icon={FolderInputIcon}
onSelect={runFromMenu(moves.into.run)}
>
{labels.moveInto(moves.into.label)}
</ResourceMenuAction>
) : null}
{moves.out ? (
<ResourceMenuAction icon={Undo2Icon} onSelect={runFromMenu(moves.out)}>
{labels.moveOut}
</ResourceMenuAction>
) : null}
</>
);
return (
<motion.div
ref={setRef}
layout="position"
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
role="treeitem"
aria-level={row.depth + 1}
aria-selected={acceptsChildren ? undefined : active}
aria-expanded={acceptsChildren ? expanded : undefined}
aria-disabled={row.item.disabled || undefined}
tabIndex={focused ? 0 : -1}
draggable={!row.item.disabled && !renaming}
data-slot="sidebar-tree-row"
data-kind={row.item.kind}
data-active={(!acceptsChildren && active) || undefined}
data-menu-open={menuOpen || undefined}
data-drop={dropPosition ?? undefined}
data-dragging={isDragging || undefined}
onFocus={onFocus}
onKeyDown={onKeyDown}
onClick={(event) => {
if (
event.defaultPrevented ||
draggedRef.current ||
renaming ||
row.item.disabled
)
return;
if (acceptsChildren) onToggle();
else onSelect();
}}
onDoubleClick={(event) => {
if (acceptsChildren || row.item.disabled) return;
event.preventDefault();
onRenameStart();
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
// The capture variants reach the DOM; `onDragStart` itself is motion's
// own pan gesture.
onDragStartCapture={(event) => {
draggedRef.current = true;
onDragStart(event, row.item.id);
}}
onDragEndCapture={() => {
onDragEnd();
requestAnimationFrame(() => {
draggedRef.current = false;
});
}}
onDragOver={(event) => onDragOver(event, row)}
onDrop={onDrop}
className={cn(
"group/resource relative flex min-h-9 min-w-0 cursor-pointer items-center gap-2.5 rounded-md pr-3 text-sm outline-none",
"text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:bg-sidebar-accent/70 focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-inset",
"data-[menu-open=true]:bg-sidebar-accent data-[menu-open=true]:text-sidebar-accent-foreground",
"data-[dragging=true]:opacity-40",
"data-[drop=inside]:bg-primary/10 data-[drop=inside]:ring-1 data-[drop=inside]:ring-primary/45",
"data-[drop=before]:before:absolute data-[drop=before]:before:-top-0.5 data-[drop=before]:before:right-2 data-[drop=before]:before:left-2 data-[drop=before]:before:h-0.5 data-[drop=before]:before:rounded-full data-[drop=before]:before:bg-primary",
"data-[drop=after]:after:absolute data-[drop=after]:after:-bottom-0.5 data-[drop=after]:after:right-2 data-[drop=after]:after:left-2 data-[drop=after]:after:h-0.5 data-[drop=after]:after:rounded-full data-[drop=after]:after:bg-primary",
!acceptsChildren &&
active &&
"bg-sidebar-accent text-sidebar-accent-foreground",
row.item.disabled && "cursor-not-allowed opacity-45"
)}
style={{ paddingLeft: `${12 + row.depth * 16}px` }}
>
<span
aria-hidden="true"
data-slot="sidebar-tree-icon"
className="grid size-5 shrink-0 place-items-center"
>
{renderIcon?.(row.item) ?? defaultIcon(row.item, expanded)}
</span>
{renaming ? (
<input
ref={inputRef}
value={draft}
aria-label={labels.renameField(row.item.label)}
data-slot="sidebar-tree-rename"
onChange={(event) => setDraft(event.target.value)}
draggable={false}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onBlur={() => {
if (!skipRenameBlurRef.current) onRenameCommit(draft);
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
skipRenameBlurRef.current = true;
onRenameCommit(draft);
}
if (event.key === "Escape") {
skipRenameBlurRef.current = true;
onRenameCancel();
}
}}
className="mx-1 h-7 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
) : (
<MarqueeLabel active={hovered || menuOpen}>{row.item.label}</MarqueeLabel>
)}
{!renaming && !row.item.disabled ? (
<Popover open={menuOpen} onOpenChange={onMenuOpenChange}>
<PopoverTrigger
render={
<button
type="button"
draggable={false}
tabIndex={-1}
aria-label={labels.actionsFor(row.item.label)}
data-slot="sidebar-tree-menu-trigger"
onClick={(event) => event.stopPropagation()}
className={cn(
"grid size-7 shrink-0 place-items-center rounded-lg transition-opacity outline-none hover:bg-foreground/5 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover/resource:opacity-100 group-data-[menu-open=true]/resource:opacity-100",
// A finger never hovers, and this menu is the only path to
// rename and move without a drag — keep it on screen there.
canTouch ? "opacity-100" : "opacity-0"
)}
/>
}
>
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
</PopoverTrigger>
{/* The row takes focus back itself when the menu closes. */}
<PopoverContent
side="bottom"
align="end"
sideOffset={8}
finalFocus={false}
data-slot="sidebar-tree-menu"
className="w-40 gap-0 rounded-lg p-1.5"
>
{menu}
</PopoverContent>
</Popover>
) : null}
</motion.div>
);
}
function AISidebarTree({
items,
defaultItems = [],
onItemsChange,
onMove,
onMoveError,
onRename,
activeId,
defaultActiveId = null,
onActiveChange,
defaultExpandedIds = [],
renderIcon,
renderMenu,
labels: labelOverrides,
className,
}: AISidebarTreeProps) {
const labels = React.useMemo<AISidebarTreeLabels>(
() => ({ ...DEFAULT_TREE_LABELS, ...labelOverrides }),
[labelOverrides]
);
const [internalItems, setInternalItems] = React.useState(
items ?? defaultItems
);
const [internalActiveId, setInternalActiveId] =
React.useState(defaultActiveId);
const [expandedIds, setExpandedIds] = React.useState(
() => new Set(defaultExpandedIds)
);
const [focusedId, setFocusedId] = React.useState<string | null>(
activeId ?? defaultActiveId
);
const [draggingId, setDraggingId] = React.useState<string | null>(null);
const [dropTarget, setDropTarget] = React.useState<DropTarget | null>(null);
const [menuOpenId, setMenuOpenId] = React.useState<string | null>(null);
const [renamingId, setRenamingId] = React.useState<string | null>(null);
const [announcement, setAnnouncement] = React.useState("");
const rowRefs = React.useRef(new Map<string, HTMLDivElement>());
const movePendingRef = React.useRef(false);
const selectedId = activeId ?? internalActiveId;
// Controlled items replace the optimistic copy the moment they change.
const [syncedItems, setSyncedItems] = React.useState(items);
if (items !== syncedItems) {
setSyncedItems(items);
if (items) setInternalItems(items);
}
const renderedItems = internalItems;
const flat = React.useMemo(
() => flattenResources(renderedItems, expandedIds),
[expandedIds, renderedItems]
);
// Which row carries the roving tabindex is resolved during render, never in
// a passive effect: an effect lands after the browser paints, so the first
// commit — and, on a server-rendered page, the markup itself — would have no
// tabbable row and Tab would skip the whole tree. The same hole opens again
// whenever a collapse or a rolled-back move takes the focused row out of it.
const focusedRow =
focusedId !== null && flat.some((row) => row.item.id === focusedId)
? focusedId
: (flat[0]?.item.id ?? null);
if (focusedId !== focusedRow) setFocusedId(focusedRow);
const updateItems = React.useCallback(
(next: AISidebarResource[]) => {
setInternalItems(next);
onItemsChange?.(next);
},
[onItemsChange]
);
const performMove = React.useCallback(
async (move: AISidebarResourceMove) => {
if (movePendingRef.current) {
setAnnouncement(labels.movePending);
return;
}
const before = renderedItems;
const next = moveResource(before, move);
if (!next || next === before) return;
movePendingRef.current = true;
updateItems(next);
setDropTarget(null);
setDraggingId(null);
const moved = findResource(before, move.itemId);
const movedLabel = moved?.label ?? labels.item;
const target = move.targetId ? findResource(before, move.targetId) : null;
setAnnouncement(
target
? labels.moved(movedLabel, move.position, target.label)
: labels.movedToTopLevel(movedLabel)
);
try {
await onMove?.(move);
} catch (error) {
updateItems(before);
setAnnouncement(labels.moveFailed(movedLabel));
onMoveError?.(error, move);
} finally {
movePendingRef.current = false;
}
},
[labels, onMove, onMoveError, renderedItems, updateItems]
);
const focusRow = React.useCallback((id: string) => {
setFocusedId(id);
requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
}, []);
const select = React.useCallback(
(id: string) => {
if (activeId === undefined) setInternalActiveId(id);
onActiveChange?.(id);
},
[activeId, onActiveChange]
);
const toggle = React.useCallback((id: string) => {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
// The same four moves `Alt+Shift+Arrow` performs, handed to the row menu so
// they survive on a device with no drag and no modifier keys.
const moveCommands = React.useCallback(
(row: FlatResource): AISidebarMoveCommands => {
if (row.item.disabled) return {};
const index = flat.findIndex(({ item }) => item.id === row.item.id);
const previous = flat[index - 1];
const next = flat[index + 1];
const parentId = row.parentId;
const commands: AISidebarMoveCommands = {};
if (previous) {
commands.up = () =>
void performMove({
itemId: row.item.id,
targetId: previous.item.id,
position: "before",
});
}
if (next) {
commands.down = () =>
void performMove({
itemId: row.item.id,
targetId: next.item.id,
position: "after",
});
}
// Only offer the reparent when it lands somewhere new — the row above a
// folder's first child is the folder it already lives in.
if (
previous &&
canContain(previous.item) &&
previous.item.id !== parentId
) {
commands.into = {
label: previous.item.label,
run: () => {
setExpandedIds((current) =>
new Set(current).add(previous.item.id)
);
void performMove({
itemId: row.item.id,
targetId: previous.item.id,
position: "inside",
});
},
};
}
if (parentId) {
commands.out = () =>
void performMove({
itemId: row.item.id,
targetId: parentId,
position: "after",
});
}
return commands;
},
[flat, performMove]
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>, row: FlatResource) => {
const index = flat.findIndex(({ item }) => item.id === row.item.id);
const previous = flat[index - 1];
const next = flat[index + 1];
const last = flat[flat.length - 1];
const moveModifier = event.altKey && event.shiftKey;
if (event.key === "ArrowDown" && !moveModifier && next) {
event.preventDefault();
focusRow(next.item.id);
return;
}
if (event.key === "ArrowUp" && !moveModifier && previous) {
event.preventDefault();
focusRow(previous.item.id);
return;
}
if (event.key === "Home" && flat[0]) {
event.preventDefault();
focusRow(flat[0].item.id);
return;
}
if (event.key === "End" && last) {
event.preventDefault();
focusRow(last.item.id);
return;
}
if (row.item.disabled) {
if (event.key === "ArrowLeft" && row.parentId) {
event.preventDefault();
focusRow(row.parentId);
} else if (
moveModifier ||
["ArrowRight", "Enter", " ", "F2", "ContextMenu"].includes(
event.key
) ||
(event.shiftKey && event.key === "F10")
) {
event.preventDefault();
}
return;
}
if (moveModifier && event.key === "ArrowUp" && previous) {
event.preventDefault();
void performMove({
itemId: row.item.id,
targetId: previous.item.id,
position: "before",
});
return;
}
if (moveModifier && event.key === "ArrowDown" && next) {
event.preventDefault();
void performMove({
itemId: row.item.id,
targetId: next.item.id,
position: "after",
});
return;
}
if (
moveModifier &&
event.key === "ArrowRight" &&
previous &&
canContain(previous.item)
) {
event.preventDefault();
setExpandedIds((current) => new Set(current).add(previous.item.id));
void performMove({
itemId: row.item.id,
targetId: previous.item.id,
position: "inside",
});
return;
}
if (moveModifier && event.key === "ArrowLeft" && row.parentId) {
event.preventDefault();
void performMove({
itemId: row.item.id,
targetId: row.parentId,
position: "after",
});
return;
}
if (event.key === "ArrowRight" && canContain(row.item)) {
event.preventDefault();
if (!expandedIds.has(row.item.id)) toggle(row.item.id);
else if (next?.parentId === row.item.id) focusRow(next.item.id);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
if (expandedIds.has(row.item.id)) toggle(row.item.id);
else if (row.parentId) focusRow(row.parentId);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (canContain(row.item)) toggle(row.item.id);
else select(row.item.id);
} else if (event.key === "F2") {
event.preventDefault();
setRenamingId(row.item.id);
} else if (
event.key === "ContextMenu" ||
(event.shiftKey && event.key === "F10")
) {
event.preventDefault();
setMenuOpenId(row.item.id);
}
},
[expandedIds, flat, focusRow, performMove, select, toggle]
);
return (
<>
<div
role="tree"
aria-label={labels.tree}
aria-multiselectable="false"
data-slot="sidebar-tree"
onDragOver={(event) => {
if (!draggingId || event.target !== event.currentTarget) return;
event.preventDefault();
setDropTarget({ id: null, position: "after" });
}}
onDrop={(event) => {
event.preventDefault();
if (draggingId && dropTarget) {
void performMove({
itemId: draggingId,
targetId: dropTarget.id,
position: dropTarget.position,
});
}
}}
className={cn(
"relative flex min-w-0 flex-col gap-0.5 [overflow-anchor:none] group-data-[state=collapsed]/sidebar:hidden",
draggingId && "select-none pb-9",
className
)}
>
<AnimatePresence initial={false}>
{flat.map((row) => (
<ResourceRow
key={row.item.id}
row={row}
active={selectedId === row.item.id}
expanded={expandedIds.has(row.item.id)}
focused={focusedRow === row.item.id}
draggingId={draggingId}
dropTarget={dropTarget}
menuOpen={menuOpenId === row.item.id}
moves={moveCommands(row)}
renaming={renamingId === row.item.id}
labels={labels}
onFocus={() => setFocusedId(row.item.id)}
onSelect={() => select(row.item.id)}
onToggle={() => toggle(row.item.id)}
onKeyDown={(event) => handleKeyDown(event, row)}
onRenameStart={() => setRenamingId(row.item.id)}
onRenameCancel={() => setRenamingId(null)}
onRenameCommit={(label) => {
const trimmed = label.trim();
setRenamingId(null);
if (!trimmed || trimmed === row.item.label) return;
const before = renderedItems;
updateItems(renameResource(before, row.item.id, trimmed));
void Promise.resolve(onRename?.(row.item, trimmed)).catch(
() => {
updateItems(before);
setAnnouncement(labels.renameFailed(row.item.label));
}
);
}}
onMenuOpenChange={(open) => {
setMenuOpenId(open ? row.item.id : null);
if (!open) focusRow(row.item.id);
}}
onDragStart={(event, id) => {
setDraggingId(id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", id);
}}
onDragEnd={() => {
setDraggingId(null);
setDropTarget(null);
}}
onDragOver={(event, targetRow) => {
if (!draggingId || draggingId === targetRow.item.id) return;
const source = findResource(renderedItems, draggingId);
if (source && containsResource(source, targetRow.item.id))
return;
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
const position: AISidebarDropPosition =
!targetRow.item.disabled &&
canContain(targetRow.item) &&
ratio >= 0.25 &&
ratio <= 0.75
? "inside"
: ratio < 0.5
? "before"
: "after";
setDropTarget({ id: targetRow.item.id, position });
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
if (draggingId && dropTarget) {
void performMove({
itemId: draggingId,
targetId: dropTarget.id,
position: dropTarget.position,
});
}
}}
renderIcon={renderIcon}
renderMenu={renderMenu}
setRef={(node) => {
if (node) rowRefs.current.set(row.item.id, node);
else rowRefs.current.delete(row.item.id);
}}
/>
))}
</AnimatePresence>
{draggingId ? (
<div
aria-hidden="true"
data-active={dropTarget?.id === null || undefined}
data-slot="sidebar-tree-root-drop"
className="absolute inset-x-1 bottom-0 flex h-8 items-center justify-center rounded-lg border border-dashed border-border text-xs text-muted-foreground data-[active=true]:border-primary/50 data-[active=true]:bg-primary/10 data-[active=true]:text-foreground"
>
{labels.moveToTopLevel}
</div>
) : null}
</div>
<span className="sr-only" aria-live="polite">
{announcement}
</span>
</>
);
}
export {
AISidebarProvider,
useAISidebar,
useSidebarPanel as useAISidebarPanel,
AISidebar,
AISidebarTrigger,
AISidebarClose,
AISidebarRail,
AISidebarInset,
AISidebarHeader,
AISidebarContent,
AISidebarFooter,
AISidebarSection,
AISidebarMenu,
AISidebarMenuItem,
AISidebarItem,
AISidebarSubmenu,
AISidebarSubItem,
AISidebarTree,
DEFAULT_TREE_LABELS as AI_SIDEBAR_TREE_LABELS,
};Ready to try it? Three commands and the app runs against a stub model.