Components / What it produces

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.

Home

Collapse the panel into an icon rail, unfold Documents, or drag a row in the tree.

pnpm exec shadcn add @intelligo/ai-sidebar

Inside an app made by intelligo create, or any shadcn project that names the registry.

built on

ai-motion, button, popover, tooltip

npm packages it adds

motion · lucide-react

used by these pages

App Shell, Chat

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 "@showcase/components/ui/ai-motion";
import { Button } from "@showcase/components/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@showcase/components/ui/popover";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@showcase/components/ui/tooltip";
import { cn } from "@showcase/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.