{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-sidebar",
  "title": "AI Sidebar",
  "description": "Slide-out AI assistant panel with provider, header / content / footer slots, and a standalone trigger.",
  "dependencies": [
    "@vllnt/ui@^0.3.0",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/ai-sidebar/ai-sidebar.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  type ReactNode,\n  use,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { Bot, MessageSquarePlus, X } from \"lucide-react\";\n\nimport { useEscapeKey } from \"@vllnt/ui\";\nimport { cn } from \"@vllnt/ui\";\nimport { Button } from \"@vllnt/ui\";\n\nconst DEFAULT_WIDTH = 400;\nconst MIN_WIDTH = 280;\nconst MAX_WIDTH = 720;\n\n/**\n * Side of the viewport the sidebar attaches to.\n *\n * @public\n */\nexport type AISidebarPosition = \"left\" | \"right\";\n\n/**\n * Localizable strings for {@link AISidebar} subcomponents.\n *\n * @public\n */\nexport type AISidebarLabels = {\n  /** Aria-label for the close control. Defaults to `\"Close assistant\"`. */\n  close?: string;\n  /** Default heading text for {@link AISidebarTitle}. */\n  defaultTitle?: string;\n  /** Aria-label for the open trigger. Defaults to `\"Open AI assistant\"`. */\n  open?: string;\n};\n\nconst DEFAULT_LABELS = {\n  close: \"Close assistant\",\n  defaultTitle: \"AI Assistant\",\n  open: \"Open AI assistant\",\n} as const satisfies Required<AISidebarLabels>;\n\ntype AISidebarContextValue = {\n  close: () => void;\n  labels: Required<AISidebarLabels>;\n  open: () => void;\n  openState: boolean;\n  position: AISidebarPosition;\n  setOpen: (next: boolean) => void;\n  toggle: () => void;\n  width: number;\n};\n\nconst NO_OP = (): void => {\n  return;\n};\n\nconst DEFAULT_CONTEXT: AISidebarContextValue = {\n  close: NO_OP,\n  labels: DEFAULT_LABELS,\n  open: NO_OP,\n  openState: false,\n  position: \"right\",\n  setOpen: NO_OP,\n  toggle: NO_OP,\n  width: DEFAULT_WIDTH,\n};\n\nconst AISidebarContext = createContext<AISidebarContextValue>(DEFAULT_CONTEXT);\n\n/**\n * Hook for reading sidebar state from anywhere inside an\n * {@link AISidebarProvider}.\n *\n * @public\n */\nexport function useAISidebar(): AISidebarContextValue {\n  return use(AISidebarContext);\n}\n\n/**\n * Props for {@link AISidebarProvider}.\n *\n * @public\n */\nexport type AISidebarProviderProps = {\n  children?: ReactNode;\n  /** Initial open state when uncontrolled. Defaults to `false`. */\n  defaultOpen?: boolean;\n  /** Initial position. Defaults to `\"right\"`. */\n  defaultPosition?: AISidebarPosition;\n  /** Initial width in px. Defaults to `400`. */\n  defaultWidth?: number;\n  /** Localizable strings. */\n  labels?: AISidebarLabels;\n  /** Fires when the open state changes (controlled or uncontrolled). */\n  onOpenChange?: (open: boolean) => void;\n  /** Controlled open state. */\n  open?: boolean;\n};\n\nfunction clampWidth(value: number): number {\n  return Math.min(Math.max(value, MIN_WIDTH), MAX_WIDTH);\n}\n\n/**\n * Provider for the AI sidebar context. Wrap your app shell with this so\n * {@link AISidebar}, {@link AISidebarTrigger}, and {@link useAISidebar}\n * share the same state.\n *\n * @public\n */\nexport function AISidebarProvider({\n  children,\n  defaultOpen = false,\n  defaultPosition = \"right\",\n  defaultWidth = DEFAULT_WIDTH,\n  labels,\n  onOpenChange,\n  open: controlledOpen,\n}: AISidebarProviderProps): ReactNode {\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const isControlled = controlledOpen !== undefined;\n  const openState = isControlled ? controlledOpen : uncontrolled;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!isControlled) setUncontrolled(next);\n      onOpenChange?.(next);\n    },\n    [isControlled, onOpenChange],\n  );\n\n  const open = useCallback(() => {\n    setOpen(true);\n  }, [setOpen]);\n  const close = useCallback(() => {\n    setOpen(false);\n  }, [setOpen]);\n  const toggle = useCallback(() => {\n    setOpen(!openState);\n  }, [openState, setOpen]);\n\n  const value = useMemo<AISidebarContextValue>(\n    () => ({\n      close,\n      labels: resolvedLabels,\n      open,\n      openState,\n      position: defaultPosition,\n      setOpen,\n      toggle,\n      width: clampWidth(defaultWidth),\n    }),\n    [\n      close,\n      defaultPosition,\n      defaultWidth,\n      open,\n      openState,\n      resolvedLabels,\n      setOpen,\n      toggle,\n    ],\n  );\n\n  return (\n    <AISidebarContext.Provider value={value}>\n      {children}\n    </AISidebarContext.Provider>\n  );\n}\n\n/**\n * Props for {@link AISidebar}.\n *\n * @public\n */\nexport type AISidebarProps = {\n  /** When true, pressing Escape closes the sidebar. Defaults to `true`. */\n  closeOnEscape?: boolean;\n} & ComponentPropsWithoutRef<\"aside\">;\n\n/**\n * Slide-out AI assistant panel anchored to the left or right edge. Renders\n * an `<aside role=\"complementary\">` so screen readers announce it as a\n * complementary region. Sets `aria-hidden` on close so its content is\n * skipped by assistive tech.\n *\n * @example\n * ```tsx\n * <AISidebarProvider defaultOpen={false}>\n *   <AISidebar>\n *     <AISidebarHeader>\n *       <AISidebarTitle>AI Assistant</AISidebarTitle>\n *       <AISidebarClose />\n *     </AISidebarHeader>\n *     <AISidebarContent>{messages}</AISidebarContent>\n *     <AISidebarFooter>{composer}</AISidebarFooter>\n *   </AISidebar>\n *   <AISidebarTrigger />\n *   <main>{children}</main>\n * </AISidebarProvider>\n * ```\n *\n * @public\n */\nexport const AISidebar = ({\n  ref,\n  ...props\n}: AISidebarProps & { ref?: React.Ref<HTMLElement> }) => {\n  const { children, className, closeOnEscape = true, ...rest } = props;\n  const { close, labels, openState, position, width } = useAISidebar();\n  useEscapeKey(close, {\n    enabled: closeOnEscape && openState,\n    target: \"document\",\n  });\n\n  return (\n    <aside\n      aria-hidden={!openState}\n      aria-label={labels.defaultTitle}\n      className={cn(\n        \"fixed top-0 z-40 flex h-full flex-col border-border bg-background shadow-lg transition-transform duration-200 ease-out\",\n        position === \"right\" ? \"right-0 border-l\" : \"left-0 border-r\",\n        openState\n          ? \"translate-x-0\"\n          : position === \"right\"\n            ? \"translate-x-full\"\n            : \"-translate-x-full\",\n        \"max-w-full\",\n        className,\n      )}\n      data-state={openState ? \"open\" : \"closed\"}\n      inert={!openState}\n      ref={ref}\n      style={{ width: `${width.toString()}px` }}\n      {...rest}\n    >\n      {children}\n    </aside>\n  );\n};\nAISidebar.displayName = \"AISidebar\";\n\n/**\n * Header slot for an {@link AISidebar}. Use to host the title, model\n * selector, and the close control.\n *\n * @public\n */\nexport const AISidebarHeader = ({\n  children,\n  className,\n  ref,\n  ...rest\n}: ComponentPropsWithoutRef<\"header\"> & { ref?: React.Ref<HTMLElement> }) => (\n  <header\n    className={cn(\n      \"flex items-center gap-2 border-b border-border px-4 py-3\",\n      className,\n    )}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </header>\n);\nAISidebarHeader.displayName = \"AISidebarHeader\";\n\n/**\n * Title slot for {@link AISidebarHeader}. Defaults to the localized title\n * from the provider whenever the consumer omits children.\n *\n * @public\n */\nexport const AISidebarTitle = ({\n  children,\n  className,\n  ref,\n  ...rest\n}: ComponentPropsWithoutRef<\"h2\"> & {\n  ref?: React.Ref<HTMLHeadingElement>;\n}) => {\n  const { labels } = useAISidebar();\n  return (\n    <h2\n      className={cn(\n        \"flex flex-1 items-center gap-2 text-sm font-semibold tracking-tight text-foreground\",\n        className,\n      )}\n      ref={ref}\n      {...rest}\n    >\n      <Bot aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n      {children ?? labels.defaultTitle}\n    </h2>\n  );\n};\nAISidebarTitle.displayName = \"AISidebarTitle\";\n\n/**\n * Close-button slot for {@link AISidebarHeader}.\n *\n * @public\n */\nexport const AISidebarClose = ({\n  className,\n  onClick,\n  ref,\n  ...rest\n}: Omit<ComponentPropsWithoutRef<\"button\">, \"type\"> & {\n  ref?: React.Ref<HTMLButtonElement>;\n}) => {\n  const { close, labels } = useAISidebar();\n  const handleCloseSidebar = useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      close();\n    },\n    [close, onClick],\n  );\n  return (\n    <Button\n      aria-label={labels.close}\n      className={cn(\"size-8\", className)}\n      onClick={handleCloseSidebar}\n      ref={ref}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...rest}\n    >\n      <X aria-hidden=\"true\" className=\"size-4\" />\n    </Button>\n  );\n};\nAISidebarClose.displayName = \"AISidebarClose\";\n\n/**\n * Scrollable middle section of {@link AISidebar}.\n *\n * @public\n */\nexport const AISidebarContent = ({\n  children,\n  className,\n  ref,\n  ...rest\n}: ComponentPropsWithoutRef<\"div\"> & { ref?: React.Ref<HTMLDivElement> }) => (\n  <div\n    className={cn(\"flex flex-1 flex-col gap-2 overflow-y-auto p-4\", className)}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </div>\n);\nAISidebarContent.displayName = \"AISidebarContent\";\n\n/**\n * Bottom slot of {@link AISidebar}, typically the chat composer.\n *\n * @public\n */\nexport const AISidebarFooter = ({\n  children,\n  className,\n  ref,\n  ...rest\n}: ComponentPropsWithoutRef<\"footer\"> & { ref?: React.Ref<HTMLElement> }) => (\n  <footer\n    className={cn(\"border-t border-border bg-background px-4 py-3\", className)}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </footer>\n);\nAISidebarFooter.displayName = \"AISidebarFooter\";\n\n/**\n * Props for {@link AISidebarTrigger}.\n *\n * @public\n */\nexport type AISidebarTriggerProps = Omit<\n  ComponentPropsWithoutRef<\"button\">,\n  \"type\"\n>;\n\n/**\n * Standalone control that opens the sidebar. Place anywhere inside an\n * {@link AISidebarProvider}. Falls back to the default icon + label when\n * the consumer omits children.\n *\n * @public\n */\nexport const AISidebarTrigger = ({\n  children,\n  className,\n  onClick,\n  ref,\n  ...rest\n}: AISidebarTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => {\n  const { labels, openState, toggle } = useAISidebar();\n  const handleToggleSidebar = useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      toggle();\n    },\n    [onClick, toggle],\n  );\n  return (\n    <Button\n      aria-expanded={openState}\n      aria-label={children ? undefined : labels.open}\n      className={cn(className)}\n      data-state={openState ? \"open\" : \"closed\"}\n      onClick={handleToggleSidebar}\n      ref={ref}\n      size={children ? \"sm\" : \"icon\"}\n      type=\"button\"\n      variant=\"outline\"\n      {...rest}\n    >\n      {children ?? <MessageSquarePlus aria-hidden=\"true\" className=\"size-4\" />}\n    </Button>\n  );\n};\nAISidebarTrigger.displayName = \"AISidebarTrigger\";\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component",
  "version": "0.3.0",
  "stability": "stable"
}
