@intelligo-dev/cli 1.0.0-beta.13 → 1.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/README.md +41 -2
  2. package/dist/bin.js +37 -0
  3. package/dist/bin.js.map +1 -1
  4. package/dist/commands/add.d.ts +4 -0
  5. package/dist/commands/add.d.ts.map +1 -1
  6. package/dist/commands/add.js +9 -0
  7. package/dist/commands/add.js.map +1 -1
  8. package/dist/commands/create-flow.d.ts +4 -1
  9. package/dist/commands/create-flow.d.ts.map +1 -1
  10. package/dist/commands/create-flow.js +63 -31
  11. package/dist/commands/create-flow.js.map +1 -1
  12. package/dist/commands/create.d.ts +8 -4
  13. package/dist/commands/create.d.ts.map +1 -1
  14. package/dist/commands/create.js +15 -7
  15. package/dist/commands/create.js.map +1 -1
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +21 -19
  18. package/dist/commands/doctor.js.map +1 -1
  19. package/dist/commands/migrate-check.d.ts +20 -1
  20. package/dist/commands/migrate-check.d.ts.map +1 -1
  21. package/dist/commands/migrate-check.js +35 -6
  22. package/dist/commands/migrate-check.js.map +1 -1
  23. package/dist/commands/migrate.d.ts.map +1 -1
  24. package/dist/commands/migrate.js +2 -8
  25. package/dist/commands/migrate.js.map +1 -1
  26. package/dist/commands/sync-messages.d.ts +37 -0
  27. package/dist/commands/sync-messages.d.ts.map +1 -0
  28. package/dist/commands/sync-messages.js +88 -0
  29. package/dist/commands/sync-messages.js.map +1 -0
  30. package/dist/commands/sync-scaffold.d.ts +36 -0
  31. package/dist/commands/sync-scaffold.d.ts.map +1 -0
  32. package/dist/commands/sync-scaffold.js +53 -0
  33. package/dist/commands/sync-scaffold.js.map +1 -0
  34. package/dist/commands/sync.d.ts +103 -0
  35. package/dist/commands/sync.d.ts.map +1 -0
  36. package/dist/commands/sync.js +361 -0
  37. package/dist/commands/sync.js.map +1 -0
  38. package/dist/commands/upgrade-check.d.ts.map +1 -1
  39. package/dist/commands/upgrade-check.js +4 -1
  40. package/dist/commands/upgrade-check.js.map +1 -1
  41. package/dist/manifest.d.ts +27 -0
  42. package/dist/manifest.d.ts.map +1 -1
  43. package/dist/manifest.js +30 -3
  44. package/dist/manifest.js.map +1 -1
  45. package/dist/module-exports.d.ts +63 -0
  46. package/dist/module-exports.d.ts.map +1 -0
  47. package/dist/module-exports.js +231 -0
  48. package/dist/module-exports.js.map +1 -0
  49. package/dist/registry-bundle.d.ts +43 -0
  50. package/dist/registry-bundle.d.ts.map +1 -0
  51. package/dist/registry-bundle.js +74 -0
  52. package/dist/registry-bundle.js.map +1 -0
  53. package/dist/registry-items.d.ts +41 -13
  54. package/dist/registry-items.d.ts.map +1 -1
  55. package/dist/registry-items.js +117 -33
  56. package/dist/registry-items.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/bin.ts +52 -0
  59. package/src/commands/add.ts +11 -0
  60. package/src/commands/create-flow.ts +82 -26
  61. package/src/commands/create.ts +17 -7
  62. package/src/commands/doctor.ts +27 -27
  63. package/src/commands/migrate-check.ts +51 -10
  64. package/src/commands/migrate.ts +2 -8
  65. package/src/commands/sync-messages.ts +114 -0
  66. package/src/commands/sync-scaffold.ts +83 -0
  67. package/src/commands/sync.ts +534 -0
  68. package/src/commands/upgrade-check.ts +4 -1
  69. package/src/manifest.ts +57 -3
  70. package/src/module-exports.ts +266 -0
  71. package/src/registry-bundle.ts +103 -0
  72. package/src/registry-items.ts +141 -36
  73. package/templates/app-scaffold/intelligo.ts.tpl +6 -47
  74. package/templates/manifest.json +19 -0
  75. package/templates/registry/ai-agent-activity.json +23 -0
  76. package/templates/registry/ai-agent-progress.json +21 -0
  77. package/templates/registry/ai-approval-card.json +27 -0
  78. package/templates/registry/ai-artifact.json +22 -0
  79. package/templates/registry/ai-branch.json +21 -0
  80. package/templates/registry/ai-citations.json +24 -0
  81. package/templates/registry/ai-code-block.json +23 -0
  82. package/templates/registry/ai-composer-menu.json +19 -0
  83. package/templates/registry/ai-file-diff.json +25 -0
  84. package/templates/registry/ai-image-generation.json +23 -0
  85. package/templates/registry/ai-markdown.json +23 -0
  86. package/templates/registry/ai-message-bubble.json +23 -0
  87. package/templates/registry/ai-message-scroller.json +22 -0
  88. package/templates/registry/ai-message.json +21 -0
  89. package/templates/registry/ai-motion.json +19 -0
  90. package/templates/registry/ai-prompt-input.json +28 -0
  91. package/templates/registry/ai-reasoning-text.json +22 -0
  92. package/templates/registry/ai-reasoning.json +22 -0
  93. package/templates/registry/ai-shimmer-text.json +19 -0
  94. package/templates/registry/ai-sidebar.json +25 -0
  95. package/templates/registry/ai-speech-input.json +21 -0
  96. package/templates/registry/ai-streaming-response.json +24 -0
  97. package/templates/registry/ai-suggestion.json +19 -0
  98. package/templates/registry/ai-todo-list.json +22 -0
  99. package/templates/registry/ai-tool-approval.json +26 -0
  100. package/templates/registry/ai-tool-result.json +25 -0
  101. package/templates/registry/alert-dialog.json +23 -0
  102. package/templates/registry/animated-list.json +21 -0
  103. package/templates/registry/app-shell.json +93 -0
  104. package/templates/registry/artifacts.json +82 -0
  105. package/templates/registry/attachment.json +22 -0
  106. package/templates/registry/auth-email-verification.json +39 -0
  107. package/templates/registry/auth-login.json +72 -0
  108. package/templates/registry/auth-password-reset.json +53 -0
  109. package/templates/registry/auth-signup.json +41 -0
  110. package/templates/registry/billing-settings.json +60 -0
  111. package/templates/registry/button.json +22 -0
  112. package/templates/registry/chat-eve.json +19 -0
  113. package/templates/registry/chat-panel.json +30 -0
  114. package/templates/registry/chat-share.json +42 -0
  115. package/templates/registry/chat-widget.json +34 -0
  116. package/templates/registry/chat.json +326 -0
  117. package/templates/registry/checkbox.json +22 -0
  118. package/templates/registry/checkout.json +40 -0
  119. package/templates/registry/collapsible.json +19 -0
  120. package/templates/registry/command.json +23 -0
  121. package/templates/registry/copy-button.json +21 -0
  122. package/templates/registry/dashboard.json +69 -0
  123. package/templates/registry/dialog.json +24 -0
  124. package/templates/registry/document-viewer.json +22 -0
  125. package/templates/registry/dropdown-menu.json +23 -0
  126. package/templates/registry/expandable-tabs.json +22 -0
  127. package/templates/registry/feature-gating.json +73 -0
  128. package/templates/registry/file-upload.json +24 -0
  129. package/templates/registry/hold-action-button.json +22 -0
  130. package/templates/registry/input-group.json +23 -0
  131. package/templates/registry/input.json +19 -0
  132. package/templates/registry/intelligo.json +187 -0
  133. package/templates/registry/invitation-accept.json +64 -0
  134. package/templates/registry/language-switcher.json +29 -0
  135. package/templates/registry/morphing-modal.json +24 -0
  136. package/templates/registry/notification-stack.json +24 -0
  137. package/templates/registry/notifications.json +87 -0
  138. package/templates/registry/onboarding.json +83 -0
  139. package/templates/registry/otp-input.json +22 -0
  140. package/templates/registry/page-header.json +15 -0
  141. package/templates/registry/payment-poll.json +52 -0
  142. package/templates/registry/popover-morph.json +22 -0
  143. package/templates/registry/popover.json +22 -0
  144. package/templates/registry/pricing.json +111 -0
  145. package/templates/registry/privacy-settings.json +65 -0
  146. package/templates/registry/profile-settings.json +68 -0
  147. package/templates/registry/progress.json +19 -0
  148. package/templates/registry/radio-group.json +22 -0
  149. package/templates/registry/registry.json +2945 -0
  150. package/templates/registry/route-error.json +65 -0
  151. package/templates/registry/select-morph.json +23 -0
  152. package/templates/registry/select.json +23 -0
  153. package/templates/registry/settings-shell.json +47 -0
  154. package/templates/registry/sheet.json +24 -0
  155. package/templates/registry/sidebar.json +28 -0
  156. package/templates/registry/smoke.json +34 -0
  157. package/templates/registry/spinner.json +16 -0
  158. package/templates/registry/stat-card.json +18 -0
  159. package/templates/registry/status-badge.json +18 -0
  160. package/templates/registry/switch.json +20 -0
  161. package/templates/registry/tabs.json +23 -0
  162. package/templates/registry/team-settings.json +97 -0
  163. package/templates/registry/textarea.json +16 -0
  164. package/templates/registry/tooltip.json +22 -0
  165. package/templates/registry/trial-banner.json +43 -0
  166. package/templates/registry/usage.json +84 -0
  167. package/templates/registry/workspace-settings.json +71 -0
  168. package/templates/registry-items.json +1 -1
  169. package/templates/registry-requires.json +26 -2
  170. package/templates/vitest/server-only.ts.tpl +7 -0
  171. package/templates/vitest/vitest.config.ts.tpl +37 -0
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "ai-sidebar",
4
+ "title": "AI Sidebar",
5
+ "description": "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.",
6
+ "dependencies": [
7
+ "motion",
8
+ "lucide-react"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-motion",
12
+ "@intelligo/button",
13
+ "@intelligo/popover",
14
+ "@intelligo/tooltip"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "base/ui/ai-sidebar/ai-sidebar.tsx",
19
+ "content": "\"use client\";\n\n/*\n * The AI product's sidebar: a shell that morphs between a full panel and\n * an icon rail on desktop and slides in as a sheet on mobile; navigation\n * menus with a shared hover pill, an active-item glide and staggered\n * submenus; and a resource tree — folders, projects, conversations, files,\n * bookmarks — with roving focus, drag-and-drop and keyboard moves, inline\n * rename and a row menu. In the rail every item explains itself with a\n * tooltip.\n */\n\nimport * as React from \"react\";\nimport {\n AnimatePresence,\n motion,\n useReducedMotion,\n type HTMLMotionProps,\n type Variants,\n} from \"motion/react\";\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n BookmarkIcon,\n ChevronRightIcon,\n FileTextIcon,\n FolderIcon,\n FolderInputIcon,\n FolderOpenIcon,\n MessageSquareIcon,\n MoreHorizontalIcon,\n PanelLeftIcon,\n PencilIcon,\n Undo2Icon,\n type LucideIcon,\n} from \"lucide-react\";\n\nimport {\n EASE_DRAWER,\n EASE_OUT,\n SPRING_LAYOUT,\n SPRING_PRESS,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\n/* ----------------------------------------------------------------------------\n * Motion\n * ------------------------------------------------------------------------- */\n\nconst PANEL_TRANSITION = { duration: 0.36, ease: EASE_DRAWER } as const;\n\n// The desktop rail settles at a hard zero-width boundary. Keep the spring\n// critically damped so it cannot overshoot, pause against that boundary, and\n// then snap back during the final frame.\nconst SIDEBAR_MORPH_TRANSITION = {\n type: \"spring\",\n stiffness: 380,\n damping: 35,\n mass: 0.75,\n} as const;\n\nconst LABEL_ENTER_TRANSITION = {\n duration: 0.2,\n delay: 0.08,\n ease: EASE_OUT,\n} as const;\nconst LABEL_EXIT_TRANSITION = { duration: 0.12, ease: EASE_OUT } as const;\nconst REDUCED_TRANSITION = { duration: 0.16, ease: EASE_OUT } as const;\nconst ROW_REVEAL = { duration: 0.16, ease: EASE_OUT } as const;\n\nconst SUBMENU_VARIANTS: Variants = {\n closed: {\n opacity: 0,\n clipPath: \"inset(0 0 100% 0 round 8px)\",\n transition: {\n duration: 0.14,\n ease: EASE_OUT,\n staggerChildren: 0.025,\n staggerDirection: -1,\n },\n },\n open: {\n opacity: 1,\n clipPath: \"inset(0 0 0% 0 round 8px)\",\n transition: {\n duration: 0.2,\n delayChildren: 0.035,\n ease: EASE_OUT,\n staggerChildren: 0.045,\n },\n },\n};\n\nconst SUBMENU_ITEM_VARIANTS: Variants = {\n closed: { opacity: 0, y: -6, filter: \"blur(3px)\" },\n open: {\n opacity: 1,\n y: 0,\n filter: \"blur(0px)\",\n transition: { duration: 0.18, ease: EASE_OUT },\n },\n};\n\nconst HOVER_PILL_VARIANTS: Variants = {\n initial: { opacity: 0, filter: \"blur(6px)\" },\n animate: { opacity: 1, filter: \"blur(0px)\" },\n exit: { opacity: 0, filter: \"blur(6px)\" },\n};\n\nconst HOVER_PILL_REDUCED_VARIANTS: Variants = {\n initial: { opacity: 0 },\n animate: { opacity: 1 },\n exit: { opacity: 0 },\n};\n\n/* ----------------------------------------------------------------------------\n * Environment: viewport and input, read as external stores so the server\n * render and the first client render agree.\n * ------------------------------------------------------------------------- */\n\nconst MOBILE_QUERY = \"(max-width: 767px)\";\nconst TOUCH_QUERY = \"(any-pointer: coarse)\";\n\nconst FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n \"[tabindex]:not([tabindex='-1'])\",\n].join(\",\");\n\nfunction subscribeToQuery(query: string) {\n return (callback: () => void) => {\n const list = window.matchMedia(query);\n list.addEventListener(\"change\", callback);\n return () => list.removeEventListener(\"change\", callback);\n };\n}\n\nconst subscribeToMobile = subscribeToQuery(MOBILE_QUERY);\nconst subscribeToTouch = subscribeToQuery(TOUCH_QUERY);\nconst getMobileSnapshot = () => window.matchMedia(MOBILE_QUERY).matches;\n// iPadOS disguises its pointer media queries; maxTouchPoints it reports\n// honestly, which is what makes it the standard iPad tell.\nconst getTouchSnapshot = () =>\n window.matchMedia(TOUCH_QUERY).matches || navigator.maxTouchPoints > 0;\nconst getServerSnapshot = () => false;\n\nfunction useIsMobile() {\n return React.useSyncExternalStore(\n subscribeToMobile,\n getMobileSnapshot,\n getServerSnapshot\n );\n}\n\n/**\n * True on devices that can be touched, whatever else they claim. Not the\n * inverse of hover-capable: an iPad browses desktop-class and answers\n * `(hover: hover)` with true while a finger is the only input there is.\n */\nfunction useTouchCapable() {\n return React.useSyncExternalStore(\n subscribeToTouch,\n getTouchSnapshot,\n getServerSnapshot\n );\n}\n\nfunction assignRef<T>(ref: React.Ref<T> | undefined, node: T | null) {\n if (typeof ref === \"function\") ref(node);\n else if (ref) (ref as React.RefObject<T | null>).current = node;\n}\n\n/* ----------------------------------------------------------------------------\n * Provider: one open state for the desktop panel, one for the mobile sheet,\n * the keyboard shortcut, and the layout id the active pill glides on.\n * ------------------------------------------------------------------------- */\n\nexport type AISidebarState = \"expanded\" | \"collapsed\";\nexport type AISidebarSide = \"left\" | \"right\";\nexport type AISidebarVariant = \"sidebar\" | \"floating\" | \"inset\";\nexport type AISidebarCollapsible = \"offcanvas\" | \"icon\" | \"none\";\n\nexport interface AISidebarContextValue {\n isMobile: boolean;\n layoutId: string;\n open: boolean;\n openMobile: boolean;\n reduce: boolean;\n setOpen: (open: boolean) => void;\n setOpenMobile: (open: boolean) => void;\n state: AISidebarState;\n toggleSidebar: () => void;\n triggerRef: React.RefObject<HTMLButtonElement | null>;\n}\n\nconst SidebarContext = React.createContext<AISidebarContextValue | null>(null);\n\ninterface PanelContextValue {\n collapsed: boolean;\n collapsible: AISidebarCollapsible;\n side: AISidebarSide;\n}\n\nconst PanelContext = React.createContext<PanelContextValue | null>(null);\n\nfunction useAISidebar() {\n const context = React.useContext(SidebarContext);\n if (!context) {\n throw new Error(\"useAISidebar must be used inside <AISidebarProvider>.\");\n }\n return context;\n}\n\n/** The nearest sidebar panel: whether it is collapsed to the rail, and its side. */\nfunction useSidebarPanel() {\n const context = React.useContext(PanelContext);\n if (!context) {\n throw new Error(\"AISidebar parts must be used inside <AISidebar>.\");\n }\n return context;\n}\n\nexport type AISidebarStyle = React.CSSProperties & {\n \"--sidebar-width\"?: string;\n \"--sidebar-width-icon\"?: string;\n \"--sidebar-width-mobile\"?: string;\n};\n\nexport interface AISidebarProviderProps\n extends Omit<React.ComponentProps<\"div\">, \"style\"> {\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n openMobile?: boolean;\n defaultOpenMobile?: boolean;\n onOpenMobileChange?: (open: boolean) => void;\n /** The key that toggles the sidebar with ⌘ / Ctrl; `null` turns it off. */\n shortcut?: string | null;\n /** Widths come in as `--sidebar-width`, `--sidebar-width-icon`, `--sidebar-width-mobile`. */\n style?: AISidebarStyle;\n}\n\nfunction AISidebarProvider({\n children,\n open,\n defaultOpen = true,\n onOpenChange,\n openMobile,\n defaultOpenMobile = false,\n onOpenMobileChange,\n shortcut = \"b\",\n className,\n style,\n ...props\n}: AISidebarProviderProps) {\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const [internalOpenMobile, setInternalOpenMobile] =\n React.useState(defaultOpenMobile);\n const isMobile = useIsMobile();\n const reduce = useReducedMotion() ?? false;\n const generatedId = React.useId();\n const triggerRef = React.useRef<HTMLButtonElement | null>(null);\n const desktopOpen = open ?? internalOpen;\n const mobileOpen = openMobile ?? internalOpenMobile;\n\n const setOpen = React.useCallback(\n (nextOpen: boolean) => {\n if (open === undefined) setInternalOpen(nextOpen);\n onOpenChange?.(nextOpen);\n },\n [onOpenChange, open]\n );\n\n const setOpenMobile = React.useCallback(\n (nextOpen: boolean) => {\n if (openMobile === undefined) setInternalOpenMobile(nextOpen);\n onOpenMobileChange?.(nextOpen);\n },\n [onOpenMobileChange, openMobile]\n );\n\n const toggleSidebar = React.useCallback(() => {\n if (isMobile) setOpenMobile(!mobileOpen);\n else setOpen(!desktopOpen);\n }, [desktopOpen, isMobile, mobileOpen, setOpen, setOpenMobile]);\n\n React.useEffect(() => {\n if (!shortcut) return;\n const handleShortcut = (event: KeyboardEvent) => {\n if (\n event.key.toLowerCase() === shortcut.toLowerCase() &&\n (event.metaKey || event.ctrlKey)\n ) {\n event.preventDefault();\n toggleSidebar();\n }\n };\n window.addEventListener(\"keydown\", handleShortcut);\n return () => window.removeEventListener(\"keydown\", handleShortcut);\n }, [shortcut, toggleSidebar]);\n\n const value = React.useMemo<AISidebarContextValue>(\n () => ({\n isMobile,\n layoutId: `${generatedId}-active`,\n open: desktopOpen,\n openMobile: mobileOpen,\n reduce,\n setOpen,\n setOpenMobile,\n state: desktopOpen ? \"expanded\" : \"collapsed\",\n toggleSidebar,\n triggerRef,\n }),\n [\n desktopOpen,\n generatedId,\n isMobile,\n mobileOpen,\n reduce,\n setOpen,\n setOpenMobile,\n toggleSidebar,\n ]\n );\n\n return (\n <SidebarContext.Provider value={value}>\n <TooltipProvider>\n <div\n {...props}\n data-slot=\"sidebar-wrapper\"\n data-state={desktopOpen ? \"expanded\" : \"collapsed\"}\n style={{\n \"--sidebar-width\": \"16rem\",\n \"--sidebar-width-icon\": \"4.25rem\",\n \"--sidebar-width-mobile\": \"18rem\",\n ...style,\n }}\n className={cn(\n \"group/sidebar-wrapper flex min-h-svh w-full min-w-0\",\n className\n )}\n >\n {children}\n </div>\n </TooltipProvider>\n </SidebarContext.Provider>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * The mobile sheet: a scrim and a panel that slides in from its side, with\n * a focus trap, Escape, a body scroll lock and focus handed back to the\n * trigger on close.\n * ------------------------------------------------------------------------- */\n\nfunction MobileSidebar({\n label,\n closeLabel,\n children,\n className,\n side,\n}: {\n label: string;\n closeLabel: string;\n children: React.ReactNode;\n className?: string;\n side: AISidebarSide;\n}) {\n const context = useAISidebar();\n const panelRef = React.useRef<HTMLDivElement>(null);\n // The sheet is mounted for as long as the viewport is mobile, so it hides\n // itself while closed rather than sitting there transparent and interactive.\n // Opening shows it in the same commit that starts the slide — a delayed show\n // would run the focus effect below against a still-hidden panel, and focus()\n // on a hidden element is ignored. Closing waits for the slide to finish, and\n // the panel's own exit tells us when that is: no duration to keep in sync.\n const [hidden, setHidden] = React.useState(!context.openMobile);\n const [wasOpen, setWasOpen] = React.useState(context.openMobile);\n if (context.openMobile !== wasOpen) {\n setWasOpen(context.openMobile);\n if (context.openMobile) setHidden(false);\n }\n // The completion callback fires for the open slide too, and it reads state\n // from whenever motion settles: a ref keeps it on the current one.\n const openMobileRef = React.useRef(context.openMobile);\n React.useEffect(() => {\n openMobileRef.current = context.openMobile;\n }, [context.openMobile]);\n\n React.useEffect(() => {\n if (!context.openMobile) return;\n\n const body = document.body;\n const scrollY = window.scrollY;\n const previous = {\n left: body.style.left,\n overflow: body.style.overflow,\n position: body.style.position,\n right: body.style.right,\n top: body.style.top,\n };\n\n body.style.position = \"fixed\";\n body.style.top = `-${scrollY}px`;\n body.style.left = \"0\";\n body.style.right = \"0\";\n body.style.overflow = \"hidden\";\n\n const focusFrame = requestAnimationFrame(() => {\n const first =\n panelRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);\n (first ?? panelRef.current)?.focus({ preventScroll: true });\n });\n\n return () => {\n cancelAnimationFrame(focusFrame);\n body.style.position = previous.position;\n body.style.top = previous.top;\n body.style.left = previous.left;\n body.style.right = previous.right;\n body.style.overflow = previous.overflow;\n window.scrollTo(0, scrollY);\n context.triggerRef.current?.focus({ preventScroll: true });\n };\n }, [context.openMobile, context.triggerRef]);\n\n // This container groups the sheet for hiding and the z-index and carries no\n // box: both children are `fixed` and resolve against the viewport themselves.\n return (\n <div\n data-slot=\"sidebar-mobile\"\n className={cn(\n \"pointer-events-none fixed top-0 left-0 z-50 size-0 md:hidden\",\n hidden && !context.openMobile ? \"invisible\" : \"visible\"\n )}\n >\n <motion.button\n type=\"button\"\n aria-label={closeLabel}\n tabIndex={context.openMobile ? 0 : -1}\n initial={false}\n animate={{ opacity: context.openMobile ? 1 : 0 }}\n transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}\n onClick={() => context.setOpenMobile(false)}\n data-slot=\"sidebar-scrim\"\n className={cn(\n \"fixed inset-0 bg-background/60 backdrop-blur-xs\",\n context.openMobile ? \"pointer-events-auto\" : \"pointer-events-none\"\n )}\n />\n\n <motion.div\n ref={panelRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={label}\n aria-hidden={!context.openMobile}\n inert={!context.openMobile}\n tabIndex={-1}\n data-slot=\"sidebar\"\n data-mobile=\"true\"\n data-state={context.openMobile ? \"expanded\" : \"collapsed\"}\n data-side={side}\n initial={false}\n animate={{\n opacity: context.reduce ? (context.openMobile ? 1 : 0) : 1,\n x: context.reduce\n ? 0\n : context.openMobile\n ? \"0%\"\n : side === \"left\"\n ? \"-100%\"\n : \"100%\",\n }}\n transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}\n onAnimationComplete={() => {\n if (!openMobileRef.current) setHidden(true);\n }}\n onKeyDown={(event) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n context.setOpenMobile(false);\n return;\n }\n\n if (event.key !== \"Tab\") return;\n const focusable = panelRef.current\n ? Array.from(\n panelRef.current.querySelectorAll<HTMLElement>(\n FOCUSABLE_SELECTOR\n )\n )\n : [];\n\n if (focusable.length === 0) {\n event.preventDefault();\n panelRef.current?.focus();\n return;\n }\n\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n if (event.shiftKey && document.activeElement === first) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && document.activeElement === last) {\n event.preventDefault();\n first?.focus();\n }\n }}\n style={{ maxWidth: \"88vw\" }}\n className={cn(\n \"pointer-events-auto fixed inset-y-0 flex h-dvh w-(--sidebar-width-mobile) flex-col overflow-hidden\",\n \"border-sidebar-border bg-sidebar text-sidebar-foreground shadow-2xl will-change-transform\",\n side === \"left\" ? \"left-0 border-r\" : \"right-0 border-l\",\n !context.openMobile && \"pointer-events-none\",\n className\n )}\n >\n <PanelContext.Provider\n value={{ collapsed: false, collapsible: \"none\", side }}\n >\n {children}\n </PanelContext.Provider>\n </motion.div>\n </div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * The sidebar itself: an aside whose width morphs between the panel and\n * the icon rail (or off the canvas), and the sheet on mobile.\n * ------------------------------------------------------------------------- */\n\nexport interface AISidebarProps\n extends Omit<HTMLMotionProps<\"aside\">, \"children\"> {\n children?: React.ReactNode;\n side?: AISidebarSide;\n variant?: AISidebarVariant;\n collapsible?: AISidebarCollapsible;\n /** Accessible name of the sidebar. */\n label?: string;\n /** Accessible name of the scrim that closes the mobile sheet. */\n closeLabel?: string;\n panelClassName?: string;\n}\n\nfunction AISidebar({\n side = \"left\",\n variant = \"sidebar\",\n collapsible = \"icon\",\n label = \"Sidebar\",\n closeLabel = \"Close sidebar\",\n children,\n className,\n panelClassName,\n ...props\n}: AISidebarProps) {\n const context = useAISidebar();\n const collapsed = collapsible !== \"none\" && !context.open;\n const offcanvas = collapsed && collapsible === \"offcanvas\";\n const width = offcanvas\n ? \"0px\"\n : collapsed\n ? \"var(--sidebar-width-icon)\"\n : \"var(--sidebar-width)\";\n\n if (context.isMobile) {\n return (\n <MobileSidebar\n label={label}\n closeLabel={closeLabel}\n className={className}\n side={side}\n >\n {children}\n </MobileSidebar>\n );\n }\n\n const framed = variant === \"floating\" || variant === \"inset\";\n\n return (\n <motion.aside\n {...props}\n initial={false}\n aria-label={label}\n data-slot=\"sidebar\"\n data-state={collapsed ? \"collapsed\" : \"expanded\"}\n data-collapsible={collapsible}\n data-variant={variant}\n data-side={side}\n animate={{ width }}\n transition={context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION}\n className={cn(\n \"group/sidebar peer relative hidden h-auto shrink-0 will-change-[width] md:block\",\n side === \"right\" && \"order-last\",\n className\n )}\n >\n <motion.div\n initial={false}\n animate={{\n opacity: offcanvas ? 0 : 1,\n x: offcanvas ? (side === \"left\" ? \"-100%\" : \"100%\") : \"0%\",\n }}\n transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}\n data-slot=\"sidebar-panel\"\n style={framed ? { height: \"calc(100svh - 1rem)\" } : undefined}\n className={cn(\n \"sticky top-0 flex h-svh w-full flex-col overflow-hidden bg-sidebar text-sidebar-foreground\",\n collapsible === \"offcanvas\" && \"w-(--sidebar-width)\",\n variant === \"sidebar\" &&\n (side === \"left\"\n ? \"border-r border-sidebar-border\"\n : \"border-l border-sidebar-border\"),\n variant === \"floating\" &&\n \"m-2 rounded-xl border border-sidebar-border shadow-sm\",\n variant === \"inset\" && \"m-2 rounded-xl\",\n panelClassName\n )}\n >\n <PanelContext.Provider value={{ collapsed, collapsible, side }}>\n {children}\n </PanelContext.Provider>\n </motion.div>\n </motion.aside>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Controls: the trigger (anywhere in the app), a close button, the thin\n * rail on the sidebar's edge, and the main area beside it.\n * ------------------------------------------------------------------------- */\n\nexport interface AISidebarTriggerProps\n extends React.ComponentProps<typeof Button> {\n /** Accessible name; the default child is a panel icon. */\n label?: string;\n}\n\nfunction AISidebarTrigger({\n label = \"Toggle sidebar\",\n className,\n onClick,\n children,\n variant = \"ghost\",\n size = \"icon\",\n ...props\n}: AISidebarTriggerProps) {\n const context = useAISidebar();\n const expanded = context.isMobile ? context.openMobile : context.open;\n const forwarded = (props as { ref?: React.Ref<HTMLButtonElement> }).ref;\n\n return (\n <Button\n {...props}\n ref={(node: HTMLButtonElement | null) => {\n context.triggerRef.current = node;\n assignRef(forwarded, node);\n }}\n variant={variant}\n size={size}\n aria-label={label}\n aria-expanded={expanded}\n data-slot=\"sidebar-trigger\"\n data-state={expanded ? \"expanded\" : \"collapsed\"}\n onClick={(event) => {\n onClick?.(event);\n if (!event.defaultPrevented) context.toggleSidebar();\n }}\n // `aria-expanded` is this toggle's resting state while the panel is\n // open, not a pressed look: keep the ghost button flat until hovered.\n className={cn(\n \"shrink-0 aria-expanded:not-hover:bg-transparent aria-expanded:not-hover:text-muted-foreground\",\n className\n )}\n >\n {children ?? <PanelLeftIcon />}\n </Button>\n );\n}\n\nexport interface AISidebarCloseProps\n extends React.ComponentProps<typeof Button> {\n label?: string;\n}\n\nfunction AISidebarClose({\n label = \"Close sidebar\",\n className,\n onClick,\n variant = \"ghost\",\n size = \"icon\",\n ...props\n}: AISidebarCloseProps) {\n const context = useAISidebar();\n\n return (\n <Button\n {...props}\n variant={variant}\n size={size}\n aria-label={label}\n data-slot=\"sidebar-close\"\n onClick={(event) => {\n onClick?.(event);\n if (event.defaultPrevented) return;\n if (context.isMobile) context.setOpenMobile(false);\n else context.setOpen(false);\n }}\n className={cn(\"shrink-0\", className)}\n />\n );\n}\n\nexport interface AISidebarRailProps extends React.ComponentProps<\"button\"> {\n label?: string;\n}\n\n/** A thin strip on the sidebar's edge that toggles it — a pointer target, not a tab stop. */\nfunction AISidebarRail({\n label = \"Toggle sidebar\",\n className,\n onClick,\n type = \"button\",\n ...props\n}: AISidebarRailProps) {\n const context = useAISidebar();\n const panel = useSidebarPanel();\n\n return (\n <button\n {...props}\n type={type}\n data-slot=\"sidebar-rail\"\n data-side={panel.side}\n aria-label={label}\n title={label}\n tabIndex={-1}\n onClick={(event) => {\n onClick?.(event);\n if (!event.defaultPrevented) context.toggleSidebar();\n }}\n className={cn(\n \"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-none md:block\",\n \"after:absolute after:inset-y-0 after:left-1/2 after:w-px after:bg-transparent after:transition-colors hover:after:bg-sidebar-border\",\n \"data-[side=left]:left-full data-[side=right]:right-0 data-[side=right]:translate-x-1/2\",\n className\n )}\n />\n );\n}\n\nfunction AISidebarInset({ className, ...props }: React.ComponentProps<\"main\">) {\n return (\n <main\n {...props}\n data-slot=\"sidebar-inset\"\n className={cn(\n \"relative flex min-h-svh min-w-0 flex-1 flex-col bg-background\",\n \"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\",\n className\n )}\n />\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Regions\n * ------------------------------------------------------------------------- */\n\nfunction AISidebarHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n {...props}\n data-slot=\"sidebar-header\"\n className={cn(\"flex shrink-0 flex-col gap-2 p-3\", className)}\n />\n );\n}\n\nfunction AISidebarContent({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n {...props}\n data-slot=\"sidebar-content\"\n className={cn(\n \"flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto overscroll-contain px-2 py-2\",\n className\n )}\n />\n );\n}\n\nfunction AISidebarFooter({\n className,\n style,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n {...props}\n data-slot=\"sidebar-footer\"\n style={{\n paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\",\n ...style,\n }}\n className={cn(\n \"flex shrink-0 flex-col gap-2 border-t border-sidebar-border p-3\",\n className\n )}\n />\n );\n}\n\nexport interface AISidebarSectionProps extends React.ComponentProps<\"div\"> {\n /** A small caps heading; it fades out in the rail. */\n label?: React.ReactNode;\n /** A control at the end of the heading — \"new\", \"add\" — hidden in the rail. */\n action?: React.ReactNode;\n}\n\nfunction AISidebarSection({\n label,\n action,\n children,\n className,\n ...props\n}: AISidebarSectionProps) {\n const { collapsed } = useSidebarPanel();\n\n return (\n <div\n {...props}\n data-slot=\"sidebar-section\"\n className={cn(\"flex w-full min-w-0 flex-col px-1 py-1.5\", className)}\n >\n {label !== undefined && label !== null ? (\n <div\n aria-hidden={collapsed}\n data-slot=\"sidebar-section-label\"\n className={cn(\n \"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\",\n collapsed ? \"opacity-0\" : \"opacity-100\"\n )}\n >\n <span className=\"min-w-0 truncate\">{label}</span>\n {action && !collapsed ? (\n <span\n data-slot=\"sidebar-section-action\"\n className=\"flex shrink-0 items-center normal-case tracking-normal\"\n >\n {action}\n </span>\n ) : null}\n </div>\n ) : null}\n <div data-slot=\"sidebar-section-content\" className=\"w-full min-w-0\">\n {children}\n </div>\n </div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Menus: a list whose hover pill glides between rows, items with an\n * active pill that glides too, and submenus that unfold with a stagger.\n * ------------------------------------------------------------------------- */\n\ninterface MenuContextValue {\n hoveredId: string | null;\n setHoveredId: (id: string | null) => void;\n pillId: string;\n}\n\nconst MenuContext = React.createContext<MenuContextValue | null>(null);\n\nexport interface AISidebarMenuProps\n extends Omit<HTMLMotionProps<\"ul\">, \"children\"> {\n children?: React.ReactNode;\n}\n\nfunction AISidebarMenu({\n children,\n className,\n onMouseLeave,\n ...props\n}: AISidebarMenuProps) {\n const [hoveredId, setHoveredId] = React.useState<string | null>(null);\n const pillId = React.useId();\n const value = React.useMemo<MenuContextValue>(\n () => ({ hoveredId, setHoveredId, pillId }),\n [hoveredId, pillId]\n );\n\n // layoutRoot scopes the pill's layout projection to this list, so fixed or\n // scrolled ancestors can't smear scroll offsets into its movement.\n return (\n <MenuContext.Provider value={value}>\n <motion.ul\n {...props}\n layoutRoot\n data-slot=\"sidebar-menu\"\n onMouseLeave={(event) => {\n setHoveredId(null);\n onMouseLeave?.(event);\n }}\n className={cn(\n \"flex w-full min-w-0 list-none flex-col gap-0.5\",\n className\n )}\n >\n {children}\n </motion.ul>\n </MenuContext.Provider>\n );\n}\n\nexport interface AISidebarMenuItemProps\n extends Omit<HTMLMotionProps<\"li\">, \"children\"> {\n children?: React.ReactNode;\n}\n\nfunction AISidebarMenuItem({\n children,\n className,\n onMouseEnter,\n ...props\n}: AISidebarMenuItemProps) {\n const menu = React.useContext(MenuContext);\n const reduce = useReducedMotion() ?? false;\n const id = React.useId();\n const hovered = menu?.hoveredId === id;\n\n return (\n <motion.li\n {...props}\n layout=\"position\"\n transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n data-slot=\"sidebar-menu-item\"\n onMouseEnter={(event) => {\n menu?.setHoveredId(id);\n onMouseEnter?.(event);\n }}\n className={cn(\"group/sidebar-menu-item relative\", className)}\n >\n {menu ? (\n <AnimatePresence>\n {menu.hoveredId !== null ? (\n <motion.div\n key=\"hover-pill\"\n aria-hidden\n variants={reduce ? HOVER_PILL_REDUCED_VARIANTS : HOVER_PILL_VARIANTS}\n initial=\"initial\"\n animate=\"animate\"\n exit=\"exit\"\n className=\"pointer-events-none absolute inset-x-0 top-0 h-9\"\n >\n {hovered ? (\n <motion.div\n layoutId={menu.pillId}\n transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n className=\"size-full rounded-md bg-sidebar-accent/70\"\n />\n ) : null}\n </motion.div>\n ) : null}\n </AnimatePresence>\n ) : null}\n <div className=\"relative z-10\">{children}</div>\n </motion.li>\n );\n}\n\nexport interface AISidebarItemProps {\n children: React.ReactNode;\n icon?: React.ReactNode;\n badge?: React.ReactNode;\n href?: string;\n isActive?: boolean;\n /** Set when the item opens a submenu: renders the chevron and `aria-expanded`. */\n expanded?: boolean;\n disabled?: boolean;\n /** Close the mobile sheet on select; defaults to true unless the item opens a submenu. */\n closeOnSelect?: boolean;\n target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n rel?: string;\n onSelect?: () => void;\n /** What the rail tooltip says; defaults to the label when it is a string. */\n tooltip?: string;\n /**\n * The element to render instead of the default `a`/`button` — a router\n * link, say `<Link href=\"/chat\" />`. Its own props and handlers are\n * merged with the item's.\n */\n render?: React.ReactElement;\n /** A control at the row's end — a menu trigger — shown on hover and focus. */\n action?: React.ReactNode;\n onKeyDown?: React.KeyboardEventHandler<HTMLElement>;\n title?: string;\n className?: string;\n}\n\nfunction AISidebarItem({\n children,\n icon,\n badge,\n href,\n isActive = false,\n expanded,\n disabled = false,\n closeOnSelect,\n target,\n rel,\n onSelect,\n tooltip,\n render,\n action,\n onKeyDown,\n title,\n className,\n}: AISidebarItemProps) {\n const context = useAISidebar();\n const panel = useSidebarPanel();\n const showAction = Boolean(action) && !panel.collapsed;\n const textLabel = typeof children === \"string\" ? children : undefined;\n const tip = tooltip ?? textLabel;\n\n const select = (\n event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>\n ) => {\n if (disabled) {\n event.preventDefault();\n return;\n }\n onSelect?.();\n const shouldCloseOnSelect = closeOnSelect ?? expanded === undefined;\n if (context.isMobile && shouldCloseOnSelect) context.setOpenMobile(false);\n // A submenu cannot render in the icon rail, so opening one from there\n // leaves its children unreachable — a pointer can still fall back to the\n // rail or the shortcut, a finger has nothing. Selecting a group unfolds\n // the panel that is about to hold it.\n if (expanded !== undefined && panel.collapsed && !context.isMobile) {\n context.setOpen(true);\n }\n };\n\n const content = (\n <>\n {isActive ? (\n <motion.span\n layoutId={context.layoutId}\n transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}\n data-slot=\"sidebar-item-active\"\n className=\"absolute inset-0 rounded-md bg-sidebar-accent\"\n />\n ) : null}\n {icon ? (\n <span\n aria-hidden=\"true\"\n data-slot=\"sidebar-item-icon\"\n className=\"relative z-10 grid size-5 shrink-0 place-items-center [&_svg:not([class*='size-'])]:size-4\"\n >\n {icon}\n </span>\n ) : null}\n <motion.span\n initial={false}\n animate={{\n opacity: panel.collapsed ? 0 : 1,\n x: context.reduce ? 0 : panel.collapsed ? -4 : 0,\n }}\n transition={\n context.reduce\n ? REDUCED_TRANSITION\n : panel.collapsed\n ? LABEL_EXIT_TRANSITION\n : LABEL_ENTER_TRANSITION\n }\n aria-hidden={panel.collapsed}\n data-slot=\"sidebar-item-label\"\n className={cn(\n \"relative z-10 min-w-0 flex-1 truncate\",\n panel.collapsed && \"pointer-events-none\"\n )}\n >\n {children}\n </motion.span>\n {badge && !panel.collapsed ? (\n <span\n data-slot=\"sidebar-item-badge\"\n className=\"relative z-10 shrink-0 text-xs text-muted-foreground\"\n >\n {badge}\n </span>\n ) : null}\n {expanded !== undefined ? (\n <motion.span\n aria-hidden=\"true\"\n initial={false}\n animate={{\n opacity: panel.collapsed ? 0 : 1,\n rotate: expanded ? 90 : 0,\n x: context.reduce ? 0 : panel.collapsed ? 4 : 0,\n }}\n transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}\n className=\"relative z-10 grid size-4 shrink-0 place-items-center text-muted-foreground\"\n >\n <ChevronRightIcon className=\"size-3.5\" />\n </motion.span>\n ) : null}\n </>\n );\n\n const interactiveClassName = cn(\n \"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\",\n \"text-muted-foreground transition-colors hover:text-sidebar-foreground\",\n \"focus-visible:bg-sidebar-accent/70 focus-visible:ring-2 focus-visible:ring-sidebar-ring\",\n isActive && \"text-sidebar-accent-foreground\",\n disabled && \"cursor-not-allowed opacity-40\",\n showAction && \"pr-9\",\n className\n );\n\n const element = render ? (\n render\n ) : href ? (\n <motion.a\n href={href}\n target={target}\n rel={rel ?? (target === \"_blank\" ? \"noreferrer noopener\" : undefined)}\n aria-current={isActive ? \"page\" : undefined}\n aria-expanded={expanded}\n aria-disabled={disabled || undefined}\n aria-label={panel.collapsed ? tip : undefined}\n tabIndex={disabled ? -1 : undefined}\n data-slot=\"sidebar-item\"\n data-active={isActive || undefined}\n onClick={select}\n whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}\n transition={SPRING_PRESS}\n className={interactiveClassName}\n />\n ) : (\n <motion.button\n type=\"button\"\n disabled={disabled}\n aria-current={isActive ? \"page\" : undefined}\n aria-expanded={expanded}\n aria-label={panel.collapsed ? tip : undefined}\n data-slot=\"sidebar-item\"\n data-active={isActive || undefined}\n onClick={select}\n whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}\n transition={SPRING_PRESS}\n className={interactiveClassName}\n />\n );\n\n // A custom element gets the item's props through Base UI's merge; the\n // default ones carry them already.\n const merged = render\n ? {\n \"aria-current\": isActive ? (\"page\" as const) : undefined,\n \"aria-expanded\": expanded,\n \"aria-disabled\": disabled || undefined,\n \"aria-label\": panel.collapsed ? tip : undefined,\n \"data-slot\": \"sidebar-item\",\n \"data-active\": isActive || undefined,\n onClick: select,\n className: interactiveClassName,\n }\n : {};\n\n // The rail shows only the icon; the tooltip carries the label there.\n return (\n <>\n <Tooltip disabled={!panel.collapsed || !tip}>\n <TooltipTrigger\n render={element}\n onKeyDown={onKeyDown}\n title={title}\n {...merged}\n >\n {content}\n </TooltipTrigger>\n <TooltipContent\n side={panel.side === \"right\" ? \"left\" : \"right\"}\n sideOffset={8}\n >\n {tip}\n </TooltipContent>\n </Tooltip>\n {showAction ? (\n <span\n data-slot=\"sidebar-item-action\"\n 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\"\n >\n {action}\n </span>\n ) : null}\n </>\n );\n}\n\nexport interface AISidebarSubmenuProps\n extends Omit<HTMLMotionProps<\"ul\">, \"children\"> {\n open: boolean;\n children?: React.ReactNode;\n}\n\nfunction AISidebarSubmenu({\n open,\n children,\n className,\n ...props\n}: AISidebarSubmenuProps) {\n const context = useAISidebar();\n const panel = useSidebarPanel();\n\n return (\n <AnimatePresence initial={false} mode=\"popLayout\">\n {open && !panel.collapsed ? (\n <motion.ul\n {...props}\n key=\"sidebar-submenu\"\n variants={context.reduce ? undefined : SUBMENU_VARIANTS}\n initial={context.reduce ? false : \"closed\"}\n animate={context.reduce ? { opacity: 1 } : \"open\"}\n exit={context.reduce ? { opacity: 0 } : \"closed\"}\n transition={context.reduce ? { duration: 0.12 } : undefined}\n data-slot=\"sidebar-submenu\"\n className={cn(\n \"relative mt-1 ml-5 flex min-w-0 list-none flex-col gap-0.5 border-l border-sidebar-border pl-3\",\n className\n )}\n >\n {children}\n </motion.ul>\n ) : null}\n </AnimatePresence>\n );\n}\n\nexport interface AISidebarSubItemProps {\n children: React.ReactNode;\n icon?: React.ReactNode;\n href?: string;\n isActive?: boolean;\n disabled?: boolean;\n closeOnSelect?: boolean;\n target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n rel?: string;\n onSelect?: () => void;\n /** The element to render instead of the default `a`/`button`, e.g. a router link. */\n render?: React.ReactElement;\n className?: string;\n}\n\nfunction AISidebarSubItem({\n children,\n icon,\n href,\n isActive = false,\n disabled = false,\n closeOnSelect = true,\n target,\n rel,\n onSelect,\n render,\n className,\n}: AISidebarSubItemProps) {\n const context = useAISidebar();\n\n const select = (\n event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>\n ) => {\n if (disabled) {\n event.preventDefault();\n return;\n }\n onSelect?.();\n if (context.isMobile && closeOnSelect) context.setOpenMobile(false);\n };\n\n const content = (\n <>\n <span\n aria-hidden=\"true\"\n className=\"grid size-4 shrink-0 place-items-center [&_svg:not([class*='size-'])]:size-3.5\"\n >\n {icon ?? <span className=\"size-1 rounded-full bg-current\" />}\n </span>\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n </>\n );\n\n const interactiveClassName = cn(\n \"flex min-h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-xs outline-none\",\n \"text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n \"focus-visible:bg-sidebar-accent/70 focus-visible:ring-2 focus-visible:ring-sidebar-ring\",\n isActive && \"bg-sidebar-accent/70 text-sidebar-accent-foreground\",\n disabled && \"cursor-not-allowed opacity-40\",\n className\n );\n\n return (\n <motion.li\n variants={SUBMENU_ITEM_VARIANTS}\n data-slot=\"sidebar-sub-item\"\n className=\"relative min-w-0\"\n >\n {render ? (\n React.cloneElement(\n render as React.ReactElement<Record<string, unknown>>,\n {\n \"aria-current\": isActive ? \"page\" : undefined,\n \"aria-disabled\": disabled || undefined,\n \"data-active\": isActive || undefined,\n onClick: (\n event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>\n ) => {\n (\n render.props as {\n onClick?: (e: React.MouseEvent<HTMLElement>) => void;\n }\n ).onClick?.(event);\n select(event);\n },\n className: cn(\n interactiveClassName,\n (render.props as { className?: string }).className\n ),\n },\n content\n )\n ) : href ? (\n <motion.a\n href={href}\n target={target}\n rel={rel ?? (target === \"_blank\" ? \"noreferrer noopener\" : undefined)}\n aria-current={isActive ? \"page\" : undefined}\n aria-disabled={disabled || undefined}\n tabIndex={disabled ? -1 : undefined}\n data-active={isActive || undefined}\n onClick={select}\n whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}\n transition={SPRING_PRESS}\n className={interactiveClassName}\n >\n {content}\n </motion.a>\n ) : (\n <motion.button\n type=\"button\"\n disabled={disabled}\n aria-current={isActive ? \"page\" : undefined}\n data-active={isActive || undefined}\n onClick={select}\n whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}\n transition={SPRING_PRESS}\n className={interactiveClassName}\n >\n {content}\n </motion.button>\n )}\n </motion.li>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * The resource tree: folders and projects that hold conversations, files\n * and bookmarks. Roving focus, `Alt+Shift+Arrow` moves, pointer drag, F2\n * rename, and a row menu that carries the same moves for a finger.\n * ------------------------------------------------------------------------- */\n\nexport type AISidebarResourceKind =\n | \"folder\"\n | \"project\"\n | \"conversation\"\n | \"file\"\n | \"bookmark\";\n\nexport interface AISidebarResource {\n id: string;\n label: string;\n kind: AISidebarResourceKind;\n children?: AISidebarResource[];\n disabled?: boolean;\n}\n\nexport type AISidebarDropPosition = \"before\" | \"inside\" | \"after\";\n\nexport interface AISidebarResourceMove {\n itemId: string;\n targetId: string | null;\n position: AISidebarDropPosition;\n}\n\n/**\n * The moves this row can make right now, the same four the keyboard offers on\n * `Alt+Shift+Arrow`. A pointer drag is the fast path for them; a finger has no\n * drag to give, so the row menu carries them too. Absent keys are moves this\n * row cannot make from where it sits.\n */\nexport interface AISidebarMoveCommands {\n up?: () => void;\n down?: () => void;\n into?: { label: string; run: () => void };\n out?: () => void;\n}\n\nexport interface AISidebarResourceMenuControls {\n close: () => void;\n rename: () => void;\n moves: AISidebarMoveCommands;\n}\n\n/** Every string the tree shows or announces, so a product can translate it. */\nexport interface AISidebarTreeLabels {\n /** Accessible name of the tree. */\n tree: string;\n rename: string;\n moveUp: string;\n moveDown: string;\n moveInto: (target: string) => string;\n moveOut: string;\n moveToTopLevel: string;\n actionsFor: (item: string) => string;\n renameField: (item: string) => string;\n movePending: string;\n moved: (item: string, position: AISidebarDropPosition, target: string) => string;\n movedToTopLevel: (item: string) => string;\n moveFailed: (item: string) => string;\n renameFailed: (item: string) => string;\n /** The name used for an item the tree can no longer find. */\n item: string;\n}\n\nconst DEFAULT_TREE_LABELS: AISidebarTreeLabels = {\n tree: \"Resources\",\n rename: \"Rename\",\n moveUp: \"Move up\",\n moveDown: \"Move down\",\n moveInto: (target) => `Move into ${target}`,\n moveOut: \"Move out\",\n moveToTopLevel: \"Move to top level\",\n actionsFor: (item) => `Actions for ${item}`,\n renameField: (item) => `Rename ${item}`,\n movePending: \"Wait for the current move to finish.\",\n moved: (item, position, target) => `Moved ${item} ${position} ${target}.`,\n movedToTopLevel: (item) => `Moved ${item} to the top level.`,\n moveFailed: (item) => `Move failed. ${item} was restored.`,\n renameFailed: (item) => `Rename failed. ${item} was restored.`,\n item: \"item\",\n};\n\nexport interface AISidebarTreeProps {\n items?: AISidebarResource[];\n defaultItems?: AISidebarResource[];\n onItemsChange?: (items: AISidebarResource[]) => void;\n /** Reject the promise to roll the optimistic move back. */\n onMove?: (move: AISidebarResourceMove) => void | Promise<void>;\n onMoveError?: (error: unknown, move: AISidebarResourceMove) => void;\n onRename?: (item: AISidebarResource, label: string) => void | Promise<void>;\n activeId?: string | null;\n defaultActiveId?: string | null;\n onActiveChange?: (id: string) => void;\n defaultExpandedIds?: string[];\n renderIcon?: (item: AISidebarResource) => React.ReactNode;\n renderMenu?: (\n item: AISidebarResource,\n controls: AISidebarResourceMenuControls\n ) => React.ReactNode;\n labels?: Partial<AISidebarTreeLabels>;\n className?: string;\n}\n\ninterface FlatResource {\n item: AISidebarResource;\n depth: number;\n parentId: string | null;\n}\n\ninterface DropTarget {\n id: string | null;\n position: AISidebarDropPosition;\n}\n\nfunction canContain(item: AISidebarResource) {\n return item.kind === \"folder\" || item.kind === \"project\";\n}\n\nfunction flattenResources(\n items: AISidebarResource[],\n expanded: Set<string>,\n depth = 0,\n parentId: string | null = null\n): FlatResource[] {\n return items.flatMap((item) => {\n const row = { item, depth, parentId };\n if (!item.children?.length || !expanded.has(item.id)) return [row];\n return [\n row,\n ...flattenResources(item.children, expanded, depth + 1, item.id),\n ];\n });\n}\n\nfunction findResource(\n items: AISidebarResource[],\n id: string\n): AISidebarResource | undefined {\n for (const item of items) {\n if (item.id === id) return item;\n const child = item.children ? findResource(item.children, id) : undefined;\n if (child) return child;\n }\n return undefined;\n}\n\nfunction containsResource(item: AISidebarResource, id: string): boolean {\n return (\n item.id === id ||\n item.children?.some((child) => containsResource(child, id)) === true\n );\n}\n\nfunction removeResource(\n items: AISidebarResource[],\n id: string\n): { items: AISidebarResource[]; removed?: AISidebarResource } {\n let removed: AISidebarResource | undefined;\n const next: AISidebarResource[] = [];\n\n for (const item of items) {\n if (item.id === id) {\n removed = item;\n continue;\n }\n\n if (item.children?.length) {\n const childResult = removeResource(item.children, id);\n if (childResult.removed) {\n removed = childResult.removed;\n next.push({ ...item, children: childResult.items });\n continue;\n }\n }\n\n next.push(item);\n }\n\n return { items: next, removed };\n}\n\nfunction insertResource(\n items: AISidebarResource[],\n resource: AISidebarResource,\n targetId: string | null,\n position: AISidebarDropPosition\n): AISidebarResource[] {\n if (targetId === null) return [...items, resource];\n\n const next: AISidebarResource[] = [];\n for (const item of items) {\n if (item.id === targetId) {\n if (position === \"before\") next.push(resource, item);\n else if (position === \"after\") next.push(item, resource);\n else\n next.push({ ...item, children: [...(item.children ?? []), resource] });\n continue;\n }\n\n if (item.children?.length) {\n next.push({\n ...item,\n children: insertResource(item.children, resource, targetId, position),\n });\n } else {\n next.push(item);\n }\n }\n return next;\n}\n\nfunction moveResource(\n items: AISidebarResource[],\n move: AISidebarResourceMove\n): AISidebarResource[] | null {\n const source = findResource(items, move.itemId);\n if (!source || source.disabled) return null;\n if (move.targetId && containsResource(source, move.targetId)) return null;\n\n const target = move.targetId ? findResource(items, move.targetId) : undefined;\n if (\n move.position === \"inside\" &&\n (!target || target.disabled || !canContain(target))\n )\n return null;\n\n const removed = removeResource(items, move.itemId);\n if (!removed.removed) return null;\n return insertResource(\n removed.items,\n removed.removed,\n move.targetId,\n move.position\n );\n}\n\nfunction renameResource(\n items: AISidebarResource[],\n id: string,\n label: string\n): AISidebarResource[] {\n return items.map((item) => ({\n ...item,\n label: item.id === id ? label : item.label,\n children: item.children\n ? renameResource(item.children, id, label)\n : undefined,\n }));\n}\n\nfunction defaultIcon(item: AISidebarResource, expanded: boolean) {\n const Icon: LucideIcon =\n item.kind === \"folder\" || item.kind === \"project\"\n ? expanded\n ? FolderOpenIcon\n : FolderIcon\n : item.kind === \"bookmark\"\n ? BookmarkIcon\n : item.kind === \"conversation\"\n ? MessageSquareIcon\n : FileTextIcon;\n return <Icon className=\"size-4\" />;\n}\n\n/** A label too long for its row scrolls past while the row is hovered. */\nfunction MarqueeLabel({\n active,\n children,\n}: {\n active: boolean;\n children: string;\n}) {\n const reduce = useReducedMotion() ?? false;\n const viewportRef = React.useRef<HTMLSpanElement>(null);\n const labelRef = React.useRef<HTMLSpanElement>(null);\n const [distance, setDistance] = React.useState(0);\n\n React.useEffect(() => {\n const viewport = viewportRef.current;\n const label = labelRef.current;\n if (!viewport || !label) return;\n // ResizeObserver reports once on observe, so this is the first measure too.\n const observer = new ResizeObserver(() => {\n setDistance(\n label.scrollWidth > viewport.clientWidth ? label.scrollWidth + 24 : 0\n );\n });\n observer.observe(viewport);\n observer.observe(label);\n return () => observer.disconnect();\n }, []);\n\n const running = active && distance > 0 && !reduce;\n\n return (\n <span\n ref={viewportRef}\n data-slot=\"sidebar-tree-label\"\n className=\"block min-w-0 flex-1 overflow-hidden\"\n >\n <motion.span\n className=\"flex w-max items-center gap-6 whitespace-nowrap\"\n animate={{ x: running ? [0, -distance] : 0 }}\n transition={\n running\n ? {\n duration: Math.max(2.4, distance / 34),\n ease: \"linear\",\n repeat: Number.POSITIVE_INFINITY,\n repeatDelay: 2,\n }\n : ROW_REVEAL\n }\n >\n <span ref={labelRef}>{children}</span>\n {running ? <span aria-hidden=\"true\">{children}</span> : null}\n </motion.span>\n </span>\n );\n}\n\nfunction ResourceMenuAction({\n icon: Icon,\n onSelect,\n children,\n}: {\n icon: LucideIcon;\n onSelect: () => void;\n children: React.ReactNode;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onSelect}\n data-slot=\"sidebar-tree-menu-action\"\n 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\"\n >\n <Icon aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n <span className=\"min-w-0 truncate\">{children}</span>\n </button>\n );\n}\n\ninterface ResourceRowProps {\n row: FlatResource;\n active: boolean;\n expanded: boolean;\n focused: boolean;\n draggingId: string | null;\n dropTarget: DropTarget | null;\n menuOpen: boolean;\n moves: AISidebarMoveCommands;\n renaming: boolean;\n labels: AISidebarTreeLabels;\n onDragEnd: () => void;\n onDragOver: (event: React.DragEvent<HTMLDivElement>, row: FlatResource) => void;\n onDragStart: (event: React.DragEvent<HTMLDivElement>, id: string) => void;\n onDrop: (event: React.DragEvent<HTMLDivElement>) => void;\n onFocus: () => void;\n onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;\n onMenuOpenChange: (open: boolean) => void;\n onRenameCancel: () => void;\n onRenameCommit: (label: string) => void;\n onRenameStart: () => void;\n onSelect: () => void;\n onToggle: () => void;\n renderIcon?: (item: AISidebarResource) => React.ReactNode;\n renderMenu?: AISidebarTreeProps[\"renderMenu\"];\n setRef: (node: HTMLDivElement | null) => void;\n}\n\nfunction ResourceRow({\n row,\n active,\n expanded,\n focused,\n draggingId,\n dropTarget,\n menuOpen,\n moves,\n renaming,\n labels,\n onDragEnd,\n onDragOver,\n onDragStart,\n onDrop,\n onFocus,\n onKeyDown,\n onMenuOpenChange,\n onRenameCancel,\n onRenameCommit,\n onRenameStart,\n onSelect,\n onToggle,\n renderIcon,\n renderMenu,\n setRef,\n}: ResourceRowProps) {\n const reduce = useReducedMotion() ?? false;\n const canTouch = useTouchCapable();\n const [hovered, setHovered] = React.useState(false);\n const inputRef = React.useRef<HTMLInputElement>(null);\n const skipRenameBlurRef = React.useRef(false);\n const draggedRef = React.useRef(false);\n const [draft, setDraft] = React.useState(row.item.label);\n const acceptsChildren = canContain(row.item);\n const isDragging = draggingId === row.item.id;\n const dropPosition =\n dropTarget?.id === row.item.id ? dropTarget.position : null;\n\n React.useEffect(() => {\n if (!renaming) return;\n skipRenameBlurRef.current = false;\n setDraft(row.item.label);\n const frame = requestAnimationFrame(() => {\n inputRef.current?.focus();\n inputRef.current?.select();\n });\n return () => cancelAnimationFrame(frame);\n }, [renaming, row.item.label]);\n\n const runFromMenu = (action: () => void) => () => {\n onMenuOpenChange(false);\n action();\n };\n\n const menu = renderMenu?.(row.item, {\n close: () => onMenuOpenChange(false),\n rename: () => {\n onMenuOpenChange(false);\n onRenameStart();\n },\n moves,\n }) ?? (\n <>\n <ResourceMenuAction\n icon={PencilIcon}\n onSelect={runFromMenu(onRenameStart)}\n >\n {labels.rename}\n </ResourceMenuAction>\n {moves.up || moves.down || moves.into || moves.out ? (\n <div aria-hidden=\"true\" className=\"my-1 h-px bg-border\" />\n ) : null}\n {moves.up ? (\n <ResourceMenuAction icon={ArrowUpIcon} onSelect={runFromMenu(moves.up)}>\n {labels.moveUp}\n </ResourceMenuAction>\n ) : null}\n {moves.down ? (\n <ResourceMenuAction\n icon={ArrowDownIcon}\n onSelect={runFromMenu(moves.down)}\n >\n {labels.moveDown}\n </ResourceMenuAction>\n ) : null}\n {moves.into ? (\n <ResourceMenuAction\n icon={FolderInputIcon}\n onSelect={runFromMenu(moves.into.run)}\n >\n {labels.moveInto(moves.into.label)}\n </ResourceMenuAction>\n ) : null}\n {moves.out ? (\n <ResourceMenuAction icon={Undo2Icon} onSelect={runFromMenu(moves.out)}>\n {labels.moveOut}\n </ResourceMenuAction>\n ) : null}\n </>\n );\n\n return (\n <motion.div\n ref={setRef}\n layout=\"position\"\n transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n role=\"treeitem\"\n aria-level={row.depth + 1}\n aria-selected={acceptsChildren ? undefined : active}\n aria-expanded={acceptsChildren ? expanded : undefined}\n aria-disabled={row.item.disabled || undefined}\n tabIndex={focused ? 0 : -1}\n draggable={!row.item.disabled && !renaming}\n data-slot=\"sidebar-tree-row\"\n data-kind={row.item.kind}\n data-active={(!acceptsChildren && active) || undefined}\n data-menu-open={menuOpen || undefined}\n data-drop={dropPosition ?? undefined}\n data-dragging={isDragging || undefined}\n onFocus={onFocus}\n onKeyDown={onKeyDown}\n onClick={(event) => {\n if (\n event.defaultPrevented ||\n draggedRef.current ||\n renaming ||\n row.item.disabled\n )\n return;\n if (acceptsChildren) onToggle();\n else onSelect();\n }}\n onDoubleClick={(event) => {\n if (acceptsChildren || row.item.disabled) return;\n event.preventDefault();\n onRenameStart();\n }}\n onMouseEnter={() => setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n // The capture variants reach the DOM; `onDragStart` itself is motion's\n // own pan gesture.\n onDragStartCapture={(event) => {\n draggedRef.current = true;\n onDragStart(event, row.item.id);\n }}\n onDragEndCapture={() => {\n onDragEnd();\n requestAnimationFrame(() => {\n draggedRef.current = false;\n });\n }}\n onDragOver={(event) => onDragOver(event, row)}\n onDrop={onDrop}\n className={cn(\n \"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\",\n \"text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n \"focus-visible:bg-sidebar-accent/70 focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-inset\",\n \"data-[menu-open=true]:bg-sidebar-accent data-[menu-open=true]:text-sidebar-accent-foreground\",\n \"data-[dragging=true]:opacity-40\",\n \"data-[drop=inside]:bg-primary/10 data-[drop=inside]:ring-1 data-[drop=inside]:ring-primary/45\",\n \"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\",\n \"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\",\n !acceptsChildren &&\n active &&\n \"bg-sidebar-accent text-sidebar-accent-foreground\",\n row.item.disabled && \"cursor-not-allowed opacity-45\"\n )}\n style={{ paddingLeft: `${12 + row.depth * 16}px` }}\n >\n <span\n aria-hidden=\"true\"\n data-slot=\"sidebar-tree-icon\"\n className=\"grid size-5 shrink-0 place-items-center\"\n >\n {renderIcon?.(row.item) ?? defaultIcon(row.item, expanded)}\n </span>\n\n {renaming ? (\n <input\n ref={inputRef}\n value={draft}\n aria-label={labels.renameField(row.item.label)}\n data-slot=\"sidebar-tree-rename\"\n onChange={(event) => setDraft(event.target.value)}\n draggable={false}\n onClick={(event) => event.stopPropagation()}\n onDoubleClick={(event) => event.stopPropagation()}\n onBlur={() => {\n if (!skipRenameBlurRef.current) onRenameCommit(draft);\n }}\n onKeyDown={(event) => {\n event.stopPropagation();\n if (event.key === \"Enter\") {\n skipRenameBlurRef.current = true;\n onRenameCommit(draft);\n }\n if (event.key === \"Escape\") {\n skipRenameBlurRef.current = true;\n onRenameCancel();\n }\n }}\n 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\"\n />\n ) : (\n <MarqueeLabel active={hovered || menuOpen}>{row.item.label}</MarqueeLabel>\n )}\n\n {!renaming && !row.item.disabled ? (\n <Popover open={menuOpen} onOpenChange={onMenuOpenChange}>\n <PopoverTrigger\n render={\n <button\n type=\"button\"\n draggable={false}\n tabIndex={-1}\n aria-label={labels.actionsFor(row.item.label)}\n data-slot=\"sidebar-tree-menu-trigger\"\n onClick={(event) => event.stopPropagation()}\n className={cn(\n \"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\",\n // A finger never hovers, and this menu is the only path to\n // rename and move without a drag — keep it on screen there.\n canTouch ? \"opacity-100\" : \"opacity-0\"\n )}\n />\n }\n >\n <MoreHorizontalIcon aria-hidden=\"true\" className=\"size-4\" />\n </PopoverTrigger>\n {/* The row takes focus back itself when the menu closes. */}\n <PopoverContent\n side=\"bottom\"\n align=\"end\"\n sideOffset={8}\n finalFocus={false}\n data-slot=\"sidebar-tree-menu\"\n className=\"w-40 gap-0 rounded-lg p-1.5\"\n >\n {menu}\n </PopoverContent>\n </Popover>\n ) : null}\n </motion.div>\n );\n}\n\nfunction AISidebarTree({\n items,\n defaultItems = [],\n onItemsChange,\n onMove,\n onMoveError,\n onRename,\n activeId,\n defaultActiveId = null,\n onActiveChange,\n defaultExpandedIds = [],\n renderIcon,\n renderMenu,\n labels: labelOverrides,\n className,\n}: AISidebarTreeProps) {\n const labels = React.useMemo<AISidebarTreeLabels>(\n () => ({ ...DEFAULT_TREE_LABELS, ...labelOverrides }),\n [labelOverrides]\n );\n const [internalItems, setInternalItems] = React.useState(\n items ?? defaultItems\n );\n const [internalActiveId, setInternalActiveId] =\n React.useState(defaultActiveId);\n const [expandedIds, setExpandedIds] = React.useState(\n () => new Set(defaultExpandedIds)\n );\n const [focusedId, setFocusedId] = React.useState<string | null>(\n activeId ?? defaultActiveId\n );\n const [draggingId, setDraggingId] = React.useState<string | null>(null);\n const [dropTarget, setDropTarget] = React.useState<DropTarget | null>(null);\n const [menuOpenId, setMenuOpenId] = React.useState<string | null>(null);\n const [renamingId, setRenamingId] = React.useState<string | null>(null);\n const [announcement, setAnnouncement] = React.useState(\"\");\n const rowRefs = React.useRef(new Map<string, HTMLDivElement>());\n const movePendingRef = React.useRef(false);\n const selectedId = activeId ?? internalActiveId;\n\n // Controlled items replace the optimistic copy the moment they change.\n const [syncedItems, setSyncedItems] = React.useState(items);\n if (items !== syncedItems) {\n setSyncedItems(items);\n if (items) setInternalItems(items);\n }\n const renderedItems = internalItems;\n\n const flat = React.useMemo(\n () => flattenResources(renderedItems, expandedIds),\n [expandedIds, renderedItems]\n );\n\n // Which row carries the roving tabindex is resolved during render, never in\n // a passive effect: an effect lands after the browser paints, so the first\n // commit — and, on a server-rendered page, the markup itself — would have no\n // tabbable row and Tab would skip the whole tree. The same hole opens again\n // whenever a collapse or a rolled-back move takes the focused row out of it.\n const focusedRow =\n focusedId !== null && flat.some((row) => row.item.id === focusedId)\n ? focusedId\n : (flat[0]?.item.id ?? null);\n if (focusedId !== focusedRow) setFocusedId(focusedRow);\n\n const updateItems = React.useCallback(\n (next: AISidebarResource[]) => {\n setInternalItems(next);\n onItemsChange?.(next);\n },\n [onItemsChange]\n );\n\n const performMove = React.useCallback(\n async (move: AISidebarResourceMove) => {\n if (movePendingRef.current) {\n setAnnouncement(labels.movePending);\n return;\n }\n const before = renderedItems;\n const next = moveResource(before, move);\n if (!next || next === before) return;\n\n movePendingRef.current = true;\n updateItems(next);\n setDropTarget(null);\n setDraggingId(null);\n const moved = findResource(before, move.itemId);\n const movedLabel = moved?.label ?? labels.item;\n const target = move.targetId ? findResource(before, move.targetId) : null;\n setAnnouncement(\n target\n ? labels.moved(movedLabel, move.position, target.label)\n : labels.movedToTopLevel(movedLabel)\n );\n\n try {\n await onMove?.(move);\n } catch (error) {\n updateItems(before);\n setAnnouncement(labels.moveFailed(movedLabel));\n onMoveError?.(error, move);\n } finally {\n movePendingRef.current = false;\n }\n },\n [labels, onMove, onMoveError, renderedItems, updateItems]\n );\n\n const focusRow = React.useCallback((id: string) => {\n setFocusedId(id);\n requestAnimationFrame(() => rowRefs.current.get(id)?.focus());\n }, []);\n\n const select = React.useCallback(\n (id: string) => {\n if (activeId === undefined) setInternalActiveId(id);\n onActiveChange?.(id);\n },\n [activeId, onActiveChange]\n );\n\n const toggle = React.useCallback((id: string) => {\n setExpandedIds((current) => {\n const next = new Set(current);\n if (next.has(id)) next.delete(id);\n else next.add(id);\n return next;\n });\n }, []);\n\n // The same four moves `Alt+Shift+Arrow` performs, handed to the row menu so\n // they survive on a device with no drag and no modifier keys.\n const moveCommands = React.useCallback(\n (row: FlatResource): AISidebarMoveCommands => {\n if (row.item.disabled) return {};\n const index = flat.findIndex(({ item }) => item.id === row.item.id);\n const previous = flat[index - 1];\n const next = flat[index + 1];\n const parentId = row.parentId;\n const commands: AISidebarMoveCommands = {};\n\n if (previous) {\n commands.up = () =>\n void performMove({\n itemId: row.item.id,\n targetId: previous.item.id,\n position: \"before\",\n });\n }\n if (next) {\n commands.down = () =>\n void performMove({\n itemId: row.item.id,\n targetId: next.item.id,\n position: \"after\",\n });\n }\n // Only offer the reparent when it lands somewhere new — the row above a\n // folder's first child is the folder it already lives in.\n if (\n previous &&\n canContain(previous.item) &&\n previous.item.id !== parentId\n ) {\n commands.into = {\n label: previous.item.label,\n run: () => {\n setExpandedIds((current) =>\n new Set(current).add(previous.item.id)\n );\n void performMove({\n itemId: row.item.id,\n targetId: previous.item.id,\n position: \"inside\",\n });\n },\n };\n }\n if (parentId) {\n commands.out = () =>\n void performMove({\n itemId: row.item.id,\n targetId: parentId,\n position: \"after\",\n });\n }\n\n return commands;\n },\n [flat, performMove]\n );\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>, row: FlatResource) => {\n const index = flat.findIndex(({ item }) => item.id === row.item.id);\n const previous = flat[index - 1];\n const next = flat[index + 1];\n const last = flat[flat.length - 1];\n const moveModifier = event.altKey && event.shiftKey;\n\n if (event.key === \"ArrowDown\" && !moveModifier && next) {\n event.preventDefault();\n focusRow(next.item.id);\n return;\n }\n if (event.key === \"ArrowUp\" && !moveModifier && previous) {\n event.preventDefault();\n focusRow(previous.item.id);\n return;\n }\n if (event.key === \"Home\" && flat[0]) {\n event.preventDefault();\n focusRow(flat[0].item.id);\n return;\n }\n if (event.key === \"End\" && last) {\n event.preventDefault();\n focusRow(last.item.id);\n return;\n }\n\n if (row.item.disabled) {\n if (event.key === \"ArrowLeft\" && row.parentId) {\n event.preventDefault();\n focusRow(row.parentId);\n } else if (\n moveModifier ||\n [\"ArrowRight\", \"Enter\", \" \", \"F2\", \"ContextMenu\"].includes(\n event.key\n ) ||\n (event.shiftKey && event.key === \"F10\")\n ) {\n event.preventDefault();\n }\n return;\n }\n\n if (moveModifier && event.key === \"ArrowUp\" && previous) {\n event.preventDefault();\n void performMove({\n itemId: row.item.id,\n targetId: previous.item.id,\n position: \"before\",\n });\n return;\n }\n if (moveModifier && event.key === \"ArrowDown\" && next) {\n event.preventDefault();\n void performMove({\n itemId: row.item.id,\n targetId: next.item.id,\n position: \"after\",\n });\n return;\n }\n if (\n moveModifier &&\n event.key === \"ArrowRight\" &&\n previous &&\n canContain(previous.item)\n ) {\n event.preventDefault();\n setExpandedIds((current) => new Set(current).add(previous.item.id));\n void performMove({\n itemId: row.item.id,\n targetId: previous.item.id,\n position: \"inside\",\n });\n return;\n }\n if (moveModifier && event.key === \"ArrowLeft\" && row.parentId) {\n event.preventDefault();\n void performMove({\n itemId: row.item.id,\n targetId: row.parentId,\n position: \"after\",\n });\n return;\n }\n\n if (event.key === \"ArrowRight\" && canContain(row.item)) {\n event.preventDefault();\n if (!expandedIds.has(row.item.id)) toggle(row.item.id);\n else if (next?.parentId === row.item.id) focusRow(next.item.id);\n } else if (event.key === \"ArrowLeft\") {\n event.preventDefault();\n if (expandedIds.has(row.item.id)) toggle(row.item.id);\n else if (row.parentId) focusRow(row.parentId);\n } else if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n if (canContain(row.item)) toggle(row.item.id);\n else select(row.item.id);\n } else if (event.key === \"F2\") {\n event.preventDefault();\n setRenamingId(row.item.id);\n } else if (\n event.key === \"ContextMenu\" ||\n (event.shiftKey && event.key === \"F10\")\n ) {\n event.preventDefault();\n setMenuOpenId(row.item.id);\n }\n },\n [expandedIds, flat, focusRow, performMove, select, toggle]\n );\n\n return (\n <>\n <div\n role=\"tree\"\n aria-label={labels.tree}\n aria-multiselectable=\"false\"\n data-slot=\"sidebar-tree\"\n onDragOver={(event) => {\n if (!draggingId || event.target !== event.currentTarget) return;\n event.preventDefault();\n setDropTarget({ id: null, position: \"after\" });\n }}\n onDrop={(event) => {\n event.preventDefault();\n if (draggingId && dropTarget) {\n void performMove({\n itemId: draggingId,\n targetId: dropTarget.id,\n position: dropTarget.position,\n });\n }\n }}\n className={cn(\n \"relative flex min-w-0 flex-col gap-0.5 [overflow-anchor:none] group-data-[state=collapsed]/sidebar:hidden\",\n draggingId && \"select-none pb-9\",\n className\n )}\n >\n <AnimatePresence initial={false}>\n {flat.map((row) => (\n <ResourceRow\n key={row.item.id}\n row={row}\n active={selectedId === row.item.id}\n expanded={expandedIds.has(row.item.id)}\n focused={focusedRow === row.item.id}\n draggingId={draggingId}\n dropTarget={dropTarget}\n menuOpen={menuOpenId === row.item.id}\n moves={moveCommands(row)}\n renaming={renamingId === row.item.id}\n labels={labels}\n onFocus={() => setFocusedId(row.item.id)}\n onSelect={() => select(row.item.id)}\n onToggle={() => toggle(row.item.id)}\n onKeyDown={(event) => handleKeyDown(event, row)}\n onRenameStart={() => setRenamingId(row.item.id)}\n onRenameCancel={() => setRenamingId(null)}\n onRenameCommit={(label) => {\n const trimmed = label.trim();\n setRenamingId(null);\n if (!trimmed || trimmed === row.item.label) return;\n const before = renderedItems;\n updateItems(renameResource(before, row.item.id, trimmed));\n void Promise.resolve(onRename?.(row.item, trimmed)).catch(\n () => {\n updateItems(before);\n setAnnouncement(labels.renameFailed(row.item.label));\n }\n );\n }}\n onMenuOpenChange={(open) => {\n setMenuOpenId(open ? row.item.id : null);\n if (!open) focusRow(row.item.id);\n }}\n onDragStart={(event, id) => {\n setDraggingId(id);\n event.dataTransfer.effectAllowed = \"move\";\n event.dataTransfer.setData(\"text/plain\", id);\n }}\n onDragEnd={() => {\n setDraggingId(null);\n setDropTarget(null);\n }}\n onDragOver={(event, targetRow) => {\n if (!draggingId || draggingId === targetRow.item.id) return;\n const source = findResource(renderedItems, draggingId);\n if (source && containsResource(source, targetRow.item.id))\n return;\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const ratio = (event.clientY - rect.top) / rect.height;\n const position: AISidebarDropPosition =\n !targetRow.item.disabled &&\n canContain(targetRow.item) &&\n ratio >= 0.25 &&\n ratio <= 0.75\n ? \"inside\"\n : ratio < 0.5\n ? \"before\"\n : \"after\";\n setDropTarget({ id: targetRow.item.id, position });\n }}\n onDrop={(event) => {\n event.preventDefault();\n event.stopPropagation();\n if (draggingId && dropTarget) {\n void performMove({\n itemId: draggingId,\n targetId: dropTarget.id,\n position: dropTarget.position,\n });\n }\n }}\n renderIcon={renderIcon}\n renderMenu={renderMenu}\n setRef={(node) => {\n if (node) rowRefs.current.set(row.item.id, node);\n else rowRefs.current.delete(row.item.id);\n }}\n />\n ))}\n </AnimatePresence>\n\n {draggingId ? (\n <div\n aria-hidden=\"true\"\n data-active={dropTarget?.id === null || undefined}\n data-slot=\"sidebar-tree-root-drop\"\n 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\"\n >\n {labels.moveToTopLevel}\n </div>\n ) : null}\n </div>\n <span className=\"sr-only\" aria-live=\"polite\">\n {announcement}\n </span>\n </>\n );\n}\n\nexport {\n AISidebarProvider,\n useAISidebar,\n useSidebarPanel as useAISidebarPanel,\n AISidebar,\n AISidebarTrigger,\n AISidebarClose,\n AISidebarRail,\n AISidebarInset,\n AISidebarHeader,\n AISidebarContent,\n AISidebarFooter,\n AISidebarSection,\n AISidebarMenu,\n AISidebarMenuItem,\n AISidebarItem,\n AISidebarSubmenu,\n AISidebarSubItem,\n AISidebarTree,\n DEFAULT_TREE_LABELS as AI_SIDEBAR_TREE_LABELS,\n};\n",
20
+ "type": "registry:ui",
21
+ "target": "components/ui/ai-sidebar.tsx"
22
+ }
23
+ ],
24
+ "type": "registry:ui"
25
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "ai-speech-input",
4
+ "title": "AI Speech Input",
5
+ "description": "Voice into the composer on the browser's speech recognition; renders nothing where it is absent.",
6
+ "dependencies": [
7
+ "lucide-react"
8
+ ],
9
+ "registryDependencies": [
10
+ "@intelligo/button"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "base/ui/ai-speech-input/ai-speech-input.tsx",
15
+ "content": "\"use client\";\n\n/*\n * Voice into the composer on the browser's own speech recognition. Renders\n * nothing where the API is absent (Firefox, some WebViews): a button that\n * cannot work is worse than no button.\n */\n\nimport * as React from \"react\";\nimport { MicIcon, SquareIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype RecognitionResultEvent = {\n resultIndex: number;\n results: ArrayLike<{\n isFinal: boolean;\n 0: { transcript: string };\n }>;\n};\n\ntype Recognition = {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n onresult: ((event: RecognitionResultEvent) => void) | null;\n onend: (() => void) | null;\n onerror: (() => void) | null;\n start: () => void;\n stop: () => void;\n};\n\nfunction recognitionConstructor(): (new () => Recognition) | null {\n if (typeof window === \"undefined\") return null;\n const w = window as unknown as {\n SpeechRecognition?: new () => Recognition;\n webkitSpeechRecognition?: new () => Recognition;\n };\n return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;\n}\n\nfunction SpeechInput({\n onTranscript,\n lang,\n startLabel,\n stopLabel,\n className,\n size = \"icon-sm\",\n variant = \"ghost\",\n ...props\n}: Omit<React.ComponentProps<typeof Button>, \"onClick\" | \"children\"> & {\n /** Called with recognised text; `isFinal` once a phrase settles. */\n onTranscript: (text: string, isFinal: boolean) => void;\n /** BCP 47, e.g. \"en-US\". The document's language when omitted. */\n lang?: string;\n /** Accessible names. */\n startLabel: string;\n stopLabel: string;\n}) {\n const [supported, setSupported] = React.useState(false);\n const [listening, setListening] = React.useState(false);\n const recognition = React.useRef<Recognition | null>(null);\n\n React.useEffect(() => {\n setSupported(recognitionConstructor() !== null);\n }, []);\n\n React.useEffect(\n () => () => {\n recognition.current?.stop();\n },\n []\n );\n\n function start() {\n const Ctor = recognitionConstructor();\n if (!Ctor) return;\n const instance = new Ctor();\n const documentLang =\n typeof document !== \"undefined\" ? document.documentElement.lang : \"\";\n instance.lang = lang ?? (documentLang || \"en-US\");\n instance.continuous = true;\n instance.interimResults = true;\n instance.onresult = (event) => {\n for (let i = event.resultIndex; i < event.results.length; i++) {\n const result = event.results[i]!;\n onTranscript(result[0].transcript, result.isFinal);\n }\n };\n instance.onend = () => setListening(false);\n instance.onerror = () => setListening(false);\n recognition.current = instance;\n instance.start();\n setListening(true);\n }\n\n function stop() {\n recognition.current?.stop();\n recognition.current = null;\n setListening(false);\n }\n\n if (!supported) return null;\n\n return (\n <Button\n data-slot=\"speech-input\"\n data-state={listening ? \"listening\" : \"idle\"}\n type=\"button\"\n size={size}\n variant={variant}\n aria-label={listening ? stopLabel : startLabel}\n aria-pressed={listening}\n onClick={listening ? stop : start}\n className={cn(listening && \"text-destructive\", className)}\n {...props}\n >\n {listening ? <SquareIcon className=\"animate-pulse\" /> : <MicIcon />}\n </Button>\n );\n}\n\nexport { SpeechInput };\n",
16
+ "type": "registry:ui",
17
+ "target": "components/ui/ai-speech-input.tsx"
18
+ }
19
+ ],
20
+ "type": "registry:ui"
21
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "ai-streaming-response",
4
+ "title": "AI Streaming Response",
5
+ "description": "An assistant reply as it streams and once it lands: content, copy, retry and feedback actions, and a sources disclosure.",
6
+ "dependencies": [
7
+ "lucide-react",
8
+ "motion"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-citations",
12
+ "@intelligo/ai-motion",
13
+ "@intelligo/button"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "base/ui/ai-streaming-response/ai-streaming-response.tsx",
18
+ "content": "\"use client\";\n\n/*\n * An assistant reply as it streams and once it lands: the rendered\n * content, then the completion actions (copy, regenerate, feedback) and a\n * compact sources disclosure.\n */\n\nimport * as React from \"react\";\nimport {\n CheckIcon,\n ChevronDownIcon,\n CopyIcon,\n RotateCcwIcon,\n ThumbsDownIcon,\n ThumbsUpIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n type CitationItem,\n CitationList,\n CitationStack,\n} from \"@/components/ui/ai-citations\";\nimport {\n Disclosure,\n EASE_OUT,\n SPRING_PRESS,\n SPRING_SWAP,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StreamingResponseStatus = \"streaming\" | \"complete\" | \"error\";\nexport type StreamingResponseFeedback = \"up\" | \"down\" | null;\n\nexport interface StreamingResponseProps {\n /** Rendered response content. Pass plain text or the output of a Markdown renderer. */\n children: React.ReactNode;\n status?: StreamingResponseStatus;\n /** Plain-text value copied by the built-in copy action. */\n copyText?: string;\n /** Overrides the built-in clipboard action. */\n onCopy?: () => void | Promise<void>;\n onRetry?: () => void;\n /** Optional sources shown as a compact footer disclosure after streaming. */\n sources?: CitationItem[];\n sourcesOpen?: boolean;\n defaultSourcesOpen?: boolean;\n onSourcesOpenChange?: (open: boolean) => void;\n sourceIdPrefix?: string;\n feedback?: StreamingResponseFeedback;\n defaultFeedback?: StreamingResponseFeedback;\n onFeedbackChange?: (feedback: StreamingResponseFeedback) => void;\n /** Set false when a surrounding conversation log announces streamed text. */\n announce?: boolean;\n /** Hides the built-in completion actions without changing response status. */\n showActions?: boolean;\n /**\n * The footer's own controls in place of copy, retry and feedback — a\n * product's action row. Rendered once the response completes, before\n * the sources toggle; `null` leaves only the sources.\n */\n actions?: React.ReactNode;\n /**\n * Style the content as prose (paragraph rhythm, lists, code). Off when\n * the children bring their own Markdown styles.\n */\n prose?: boolean;\n copyLabel?: string;\n copiedLabel?: string;\n retryLabel?: string;\n helpfulLabel?: string;\n notHelpfulLabel?: string;\n /** The sources toggle's text for a given count. */\n sourcesLabel?: (count: number) => string;\n className?: string;\n contentClassName?: string;\n actionsClassName?: string;\n}\n\nfunction defaultSourcesLabel(count: number) {\n return `${count} ${count === 1 ? \"source\" : \"sources\"}`;\n}\n\nfunction ResponseAction({\n label,\n active,\n onClick,\n children,\n}: {\n label: string;\n /** Present on toggles only; drives `aria-pressed`. */\n active?: boolean;\n onClick: () => void;\n children: React.ReactNode;\n}) {\n const reduced = useReducedMotion() ?? false;\n\n return (\n <Button\n data-slot=\"streaming-response-action\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={label}\n title={label}\n aria-pressed={active}\n onClick={onClick}\n className={cn(\n \"text-muted-foreground hover:text-foreground\",\n active && \"bg-muted text-foreground\"\n )}\n render={\n <motion.button\n whileTap={reduced ? undefined : { scale: 0.9 }}\n transition={SPRING_PRESS}\n />\n }\n >\n {children}\n </Button>\n );\n}\n\nfunction StreamingResponse({\n children,\n status = \"streaming\",\n copyText,\n onCopy,\n onRetry,\n sources = [],\n sourcesOpen,\n defaultSourcesOpen = false,\n onSourcesOpenChange,\n sourceIdPrefix,\n feedback,\n defaultFeedback = null,\n onFeedbackChange,\n announce = true,\n showActions = true,\n actions,\n prose = true,\n copyLabel = \"Copy response\",\n copiedLabel = \"Copied\",\n retryLabel = \"Retry response\",\n helpfulLabel = \"Helpful\",\n notHelpfulLabel = \"Not helpful\",\n sourcesLabel = defaultSourcesLabel,\n className,\n contentClassName,\n actionsClassName,\n}: StreamingResponseProps) {\n const reduced = useReducedMotion() ?? false;\n const baseId = React.useId();\n const [copied, setCopied] = React.useState(false);\n const [internalFeedback, setInternalFeedback] =\n React.useState<StreamingResponseFeedback>(defaultFeedback);\n const [internalSourcesOpen, setInternalSourcesOpen] =\n React.useState(defaultSourcesOpen);\n const copyTimer = React.useRef<number | undefined>(undefined);\n const currentFeedback = feedback ?? internalFeedback;\n const currentSourcesOpen = sourcesOpen ?? internalSourcesOpen;\n const streaming = status === \"streaming\";\n const complete = status === \"complete\";\n const canCopy = Boolean(copyText || onCopy);\n const hasSources = sources.length > 0;\n const customActions = actions !== undefined;\n const shouldShowActions =\n showActions &&\n !streaming &&\n (customActions\n ? actions !== null || hasSources\n : canCopy || onRetry || complete || hasSources);\n const sourcesContentId = `${baseId}-sources`;\n const resolvedSourcePrefix =\n sourceIdPrefix ?? `response-source-${baseId.replace(/:/g, \"\")}`;\n\n React.useEffect(\n () => () => {\n if (copyTimer.current) window.clearTimeout(copyTimer.current);\n },\n []\n );\n\n const handleCopy = React.useCallback(async () => {\n if (onCopy) await onCopy();\n else if (copyText) await navigator.clipboard?.writeText(copyText);\n\n setCopied(true);\n if (copyTimer.current) window.clearTimeout(copyTimer.current);\n copyTimer.current = window.setTimeout(() => setCopied(false), 1600);\n }, [copyText, onCopy]);\n\n const setFeedback = (next: Exclude<StreamingResponseFeedback, null>) => {\n const value = currentFeedback === next ? null : next;\n if (feedback === undefined) setInternalFeedback(value);\n onFeedbackChange?.(value);\n };\n\n const setSourcesOpen = React.useCallback(\n (next: boolean) => {\n if (sourcesOpen === undefined) setInternalSourcesOpen(next);\n onSourcesOpenChange?.(next);\n },\n [onSourcesOpenChange, sourcesOpen]\n );\n\n return (\n <div\n data-slot=\"streaming-response\"\n data-state={status}\n aria-busy={streaming}\n className={cn(\"w-full\", className)}\n >\n <div\n data-slot=\"streaming-response-content\"\n aria-live={announce ? \"polite\" : \"off\"}\n className={cn(\n prose &&\n \"text-sm leading-6 text-foreground/90 [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-5 [&_p+p]:mt-3 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted/45 [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5\",\n contentClassName\n )}\n >\n {children}\n </div>\n\n <AnimatePresence initial={false}>\n {shouldShowActions ? (\n <motion.div\n data-slot=\"streaming-response-footer\"\n initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4 }}\n animate={{ opacity: 1, y: 0 }}\n exit={{ opacity: 0 }}\n transition={{ duration: reduced ? 0.12 : 0.22, ease: EASE_OUT }}\n className=\"mt-3\"\n >\n <div\n data-slot=\"streaming-response-actions\"\n className={cn(\"flex items-center gap-0.5\", actionsClassName)}\n >\n {customActions ? actions : null}\n {!customActions && canCopy ? (\n <ResponseAction\n label={copied ? copiedLabel : copyLabel}\n onClick={handleCopy}\n >\n {copied ? (\n <CheckIcon className=\"size-3.5\" />\n ) : (\n <CopyIcon className=\"size-3.5\" />\n )}\n </ResponseAction>\n ) : null}\n {!customActions && onRetry ? (\n <ResponseAction label={retryLabel} onClick={onRetry}>\n <RotateCcwIcon className=\"size-3.5\" />\n </ResponseAction>\n ) : null}\n {!customActions && complete ? (\n <>\n <ResponseAction\n label={helpfulLabel}\n active={currentFeedback === \"up\"}\n onClick={() => setFeedback(\"up\")}\n >\n <ThumbsUpIcon className=\"size-3.5\" />\n </ResponseAction>\n <ResponseAction\n label={notHelpfulLabel}\n active={currentFeedback === \"down\"}\n onClick={() => setFeedback(\"down\")}\n >\n <ThumbsDownIcon className=\"size-3.5\" />\n </ResponseAction>\n </>\n ) : null}\n {hasSources ? (\n <button\n data-slot=\"streaming-response-sources-trigger\"\n type=\"button\"\n aria-expanded={currentSourcesOpen}\n aria-controls={sourcesContentId}\n onClick={() => setSourcesOpen(!currentSourcesOpen)}\n className=\"group ml-1 inline-flex min-h-7 items-center gap-2 rounded-md px-1.5 text-xs text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <CitationStack citations={sources} />\n <span className=\"tabular-nums\">\n {sourcesLabel(sources.length)}\n </span>\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: currentSourcesOpen ? 180 : 0 }}\n transition={reduced ? { duration: 0 } : SPRING_SWAP}\n className=\"text-muted-foreground/50 group-hover:text-muted-foreground\"\n >\n <ChevronDownIcon className=\"size-3\" />\n </motion.span>\n </button>\n ) : null}\n </div>\n\n {hasSources ? (\n <Disclosure id={sourcesContentId} open={currentSourcesOpen}>\n <CitationList\n citations={sources}\n idPrefix={resolvedSourcePrefix}\n className=\"mt-2 rounded-lg bg-muted p-2\"\n />\n </Disclosure>\n ) : null}\n </motion.div>\n ) : null}\n </AnimatePresence>\n </div>\n );\n}\n\nexport { StreamingResponse };\n",
19
+ "type": "registry:ui",
20
+ "target": "components/ui/ai-streaming-response.tsx"
21
+ }
22
+ ],
23
+ "type": "registry:ui"
24
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "ai-suggestion",
4
+ "title": "AI Suggestion",
5
+ "description": "Conversation starters and follow-ups in a scrollable row.",
6
+ "registryDependencies": [
7
+ "@intelligo/button",
8
+ "scroll-area"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "base/ui/ai-suggestion/ai-suggestion.tsx",
13
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\nimport { cn } from \"@/lib/utils\";\n\nfunction Suggestions({\n className,\n children,\n ...props\n}: React.ComponentProps<typeof ScrollArea>) {\n return (\n <ScrollArea\n data-slot=\"suggestions\"\n className=\"w-full whitespace-nowrap\"\n {...props}\n >\n <div\n className={cn(\"flex w-max flex-nowrap items-center gap-2\", className)}\n >\n {children}\n </div>\n <ScrollBar className=\"hidden\" orientation=\"horizontal\" />\n </ScrollArea>\n );\n}\n\nfunction Suggestion({\n suggestion,\n onClick,\n className,\n variant = \"outline\",\n size = \"sm\",\n children,\n ...props\n}: Omit<React.ComponentProps<typeof Button>, \"onClick\"> & {\n suggestion: string;\n onClick?: (suggestion: string) => void;\n}) {\n return (\n <Button\n data-slot=\"suggestion\"\n className={cn(\"rounded-full px-4\", className)}\n onClick={() => onClick?.(suggestion)}\n size={size}\n type=\"button\"\n variant={variant}\n {...props}\n >\n {children || suggestion}\n </Button>\n );\n}\n\nexport { Suggestions, Suggestion };\n",
14
+ "type": "registry:ui",
15
+ "target": "components/ui/ai-suggestion.tsx"
16
+ }
17
+ ],
18
+ "type": "registry:ui"
19
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "ai-todo-list",
4
+ "title": "AI Todo List",
5
+ "description": "The agent's to-do list: a collapsible plan whose status marks morph between pending, in progress, completed and cancelled, with a rolling completion count.",
6
+ "dependencies": [
7
+ "motion",
8
+ "lucide-react"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-motion"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "base/ui/ai-todo-list/ai-todo-list.tsx",
16
+ "content": "\"use client\";\n\n/*\n * The agent's to-do list: a collapsible plan whose status marks morph\n * between pending, in progress, completed and cancelled, with a rolling\n * completion count in the header.\n */\n\nimport * as React from \"react\";\nimport { ChevronDownIcon, ListTodoIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n Disclosure,\n EASE_OUT,\n SPRING_LAYOUT,\n SPRING_SWAP,\n SwapText,\n} from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TodoItemStatus =\n | \"pending\"\n | \"in-progress\"\n | \"completed\"\n | \"cancelled\";\n\nexport interface TodoItem {\n id: string;\n title: React.ReactNode;\n status?: TodoItemStatus;\n /** 0–100. Omitted while in progress = an indeterminate spinning arc. */\n progress?: number;\n detail?: React.ReactNode;\n}\n\nexport interface TodoListProps extends Omit<\n React.ComponentProps<\"section\">,\n \"title\"\n> {\n items: TodoItem[];\n title?: React.ReactNode;\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Fold the list when every item completes; reopen if one reverts. */\n collapseOnComplete?: boolean;\n /** Viewport height in px before the list scrolls. */\n maxHeight?: number;\n /** Accessible name of the whole list. */\n label?: string;\n emptyLabel?: string;\n /** Spoken before each item's title. */\n statusLabels?: Partial<Record<TodoItemStatus, string>>;\n /** The sr-only completion count in the header. */\n completedLabel?: (done: number, total: number) => string;\n}\n\nconst DEFAULT_STATUS_LABELS: Record<TodoItemStatus, string> = {\n pending: \"Pending\",\n \"in-progress\": \"In progress\",\n completed: \"Completed\",\n cancelled: \"Cancelled\",\n};\n\nfunction defaultCompletedLabel(done: number, total: number) {\n return `${done} of ${total} tasks completed`;\n}\n\nfunction TodoHeaderIcon({ complete }: { complete: boolean }) {\n const reduce = useReducedMotion() ?? false;\n\n return (\n <span\n data-slot=\"todo-list-header-icon\"\n aria-hidden=\"true\"\n className=\"relative grid size-6 shrink-0 place-items-center\"\n >\n <AnimatePresence initial={false} mode=\"popLayout\">\n {complete ? (\n <motion.svg\n key=\"complete\"\n viewBox=\"0 0 24 24\"\n initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.72 }}\n animate={{ opacity: 1, scale: 1 }}\n exit={{ opacity: 0 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className=\"absolute size-5.5 overflow-visible text-success\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" fill=\"currentColor\" />\n <motion.path\n d=\"M7.5 12.25 10.5 15.25 16.75 8.75\"\n fill=\"none\"\n stroke=\"var(--background)\"\n strokeWidth=\"2.25\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n animate={{ pathLength: 1 }}\n transition={\n reduce ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }\n }\n />\n </motion.svg>\n ) : (\n <motion.span\n key=\"todo\"\n initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.8 }}\n animate={{ opacity: 1, scale: 1 }}\n exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.72 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className=\"absolute grid place-items-center text-muted-foreground\"\n >\n <ListTodoIcon className=\"size-4\" />\n </motion.span>\n )}\n </AnimatePresence>\n </span>\n );\n}\n\n/**\n * One SVG whose parts fade and draw between states, so a status change\n * morphs instead of swapping icons: a dashed ring while pending, a\n * progress arc (or a spinning one) while in progress, a drawn check\n * when completed, a drawn cross when cancelled.\n */\nfunction TodoStatusIcon({\n status,\n progress,\n}: {\n status: TodoItemStatus;\n progress?: number;\n}) {\n const reduce = useReducedMotion() ?? false;\n const normalizedProgress =\n progress === undefined ? 0.68 : Math.min(100, Math.max(0, progress)) / 100;\n const indeterminate =\n status === \"in-progress\" && progress === undefined && !reduce;\n\n return (\n <motion.svg\n data-slot=\"todo-list-status-icon\"\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n initial={false}\n className={cn(\n \"mx-0.5 size-5 shrink-0 overflow-visible text-muted-foreground\",\n status === \"in-progress\" && \"text-foreground\",\n status === \"cancelled\" && \"text-destructive\"\n )}\n >\n <motion.circle\n cx=\"12\"\n cy=\"12\"\n r=\"9\"\n fill=\"currentColor\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeDasharray={status === \"pending\" ? \"2 3\" : undefined}\n strokeLinecap=\"round\"\n initial={false}\n animate={{ fillOpacity: status === \"completed\" ? 0.06 : 0 }}\n transition={\n reduce ? { duration: 0 } : { duration: 0.18, ease: EASE_OUT }\n }\n className={cn(status === \"in-progress\" && \"opacity-20\")}\n />\n <motion.circle\n cx=\"12\"\n cy=\"12\"\n r=\"9\"\n pathLength=\"1\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n initial={false}\n animate={{\n pathLength: status === \"in-progress\" ? normalizedProgress : 0,\n opacity: status === \"in-progress\" ? 1 : 0,\n rotate: indeterminate ? 360 : -90,\n }}\n transition={\n indeterminate\n ? { rotate: { duration: 1.1, repeat: Infinity, ease: \"linear\" } }\n : reduce\n ? { duration: 0 }\n : SPRING_LAYOUT\n }\n style={{ transformOrigin: \"12px 12px\" }}\n />\n <motion.path\n d=\"M7.5 12.25 10.5 15.25 16.75 8.75\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n initial={false}\n animate={{\n pathLength: status === \"completed\" ? 1 : 0,\n opacity: status === \"completed\" ? 1 : 0,\n }}\n transition={\n reduce ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }\n }\n />\n <motion.path\n d=\"M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n initial={false}\n animate={{\n pathLength: status === \"cancelled\" ? 1 : 0,\n opacity: status === \"cancelled\" ? 1 : 0,\n }}\n transition={\n reduce ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }\n }\n />\n </motion.svg>\n );\n}\n\nfunction TodoList({\n items,\n title = \"To-dos\",\n open,\n defaultOpen = true,\n onOpenChange,\n collapseOnComplete = true,\n maxHeight = 248,\n label = \"Agent task list\",\n emptyLabel = \"No tasks yet\",\n statusLabels,\n completedLabel = defaultCompletedLabel,\n className,\n ...props\n}: TodoListProps) {\n const reduce = useReducedMotion() ?? false;\n const baseId = React.useId();\n const triggerId = `${baseId}-trigger`;\n const contentId = `${baseId}-content`;\n const viewportRef = React.useRef<HTMLDivElement>(null);\n const previousComplete = React.useRef(false);\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const currentOpen = open ?? internalOpen;\n const completed = items.filter((item) => item.status === \"completed\").length;\n const allComplete = items.length > 0 && completed === items.length;\n const itemCount = items.length;\n const labels = { ...DEFAULT_STATUS_LABELS, ...statusLabels };\n\n const setOpen = React.useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, open]\n );\n\n // Fold once everything is done; unfold again if a task comes back.\n React.useEffect(() => {\n if (previousComplete.current && !allComplete) {\n setOpen(true);\n }\n if (!previousComplete.current && allComplete && collapseOnComplete) {\n setOpen(false);\n }\n previousComplete.current = allComplete;\n }, [allComplete, collapseOnComplete, setOpen]);\n\n // A new item scrolls into view when the list is taller than the viewport.\n React.useLayoutEffect(() => {\n const viewport = viewportRef.current;\n if (!viewport || itemCount === 0) return;\n\n const frame = requestAnimationFrame(() => {\n if (viewport.scrollHeight <= viewport.clientHeight) return;\n if (typeof viewport.scrollTo === \"function\") {\n viewport.scrollTo({\n top: viewport.scrollHeight,\n behavior: reduce ? \"auto\" : \"smooth\",\n });\n } else {\n viewport.scrollTop = viewport.scrollHeight;\n }\n });\n return () => cancelAnimationFrame(frame);\n }, [itemCount, reduce]);\n\n return (\n <section\n data-slot=\"todo-list\"\n data-state={currentOpen ? \"open\" : \"closed\"}\n aria-label={label}\n className={cn(\n \"w-full overflow-hidden rounded-xl border border-border/70\",\n className\n )}\n {...props}\n >\n <button\n data-slot=\"todo-list-trigger\"\n id={triggerId}\n type=\"button\"\n aria-expanded={currentOpen}\n aria-controls={contentId}\n onClick={() => setOpen(!currentOpen)}\n className=\"group flex h-11 w-full items-center gap-2.5 rounded-xl px-3.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <TodoHeaderIcon complete={allComplete} />\n <h3\n data-slot=\"todo-list-title\"\n className=\"min-w-0 flex-1 truncate text-sm font-medium text-foreground/90\"\n >\n {title}\n </h3>\n <span\n data-slot=\"todo-list-count\"\n className={cn(\n \"shrink-0 text-xs font-medium tabular-nums text-muted-foreground\",\n allComplete && \"text-success\"\n )}\n >\n <span className=\"sr-only\">\n {completedLabel(completed, items.length)}\n </span>\n <span aria-hidden=\"true\" className=\"inline-flex\">\n <SwapText value={String(completed)}>{completed}</SwapText>\n <span>/</span>\n <span>{items.length}</span>\n </span>\n </span>\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: currentOpen ? 180 : 0 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className=\"text-muted-foreground/50 transition-colors group-hover:text-muted-foreground\"\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n\n <Disclosure\n id={contentId}\n role=\"region\"\n aria-labelledby={triggerId}\n open={currentOpen}\n >\n <div\n data-slot=\"todo-list-viewport\"\n ref={viewportRef}\n className=\"overflow-y-auto px-2 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n style={{ maxHeight }}\n >\n {items.length ? (\n <ol\n data-slot=\"todo-list-items\"\n aria-live=\"polite\"\n className=\"space-y-0\"\n >\n <AnimatePresence initial={false} mode=\"popLayout\">\n {items.map((item) => {\n const status = item.status ?? \"pending\";\n return (\n <motion.li\n data-slot=\"todo-list-item\"\n data-status={status}\n layout=\"position\"\n key={item.id}\n initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}\n animate={{ opacity: 1, y: 0 }}\n exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}\n transition={\n reduce\n ? { duration: 0 }\n : {\n opacity: { duration: 0.18, ease: EASE_OUT },\n y: SPRING_LAYOUT,\n layout: SPRING_LAYOUT,\n }\n }\n className=\"flex min-h-9 items-center gap-2.5 rounded-md px-1.5 py-1\"\n >\n <TodoStatusIcon\n status={status}\n progress={item.progress}\n />\n <span className=\"sr-only\">{labels[status]}: </span>\n <span\n data-slot=\"todo-list-item-title\"\n className={cn(\n \"min-w-0 flex-1 truncate text-sm leading-5\",\n status === \"pending\" && \"text-muted-foreground/65\",\n status === \"in-progress\" && \"text-foreground\",\n status === \"completed\" && \"text-muted-foreground/60\",\n status === \"cancelled\" && \"text-muted-foreground/55\"\n )}\n >\n <span className=\"relative inline-block max-w-full\">\n {item.title}\n {/* The strike-through draws left to right on completion. */}\n <motion.span\n aria-hidden=\"true\"\n initial={false}\n animate={{\n scaleX: status === \"completed\" ? 1 : 0,\n opacity: status === \"completed\" ? 1 : 0,\n }}\n transition={\n reduce\n ? { duration: 0 }\n : {\n duration: 0.28,\n ease: EASE_OUT,\n delay: 0.06,\n }\n }\n className=\"absolute inset-x-0 top-1/2 h-px origin-left bg-current\"\n />\n </span>\n </span>\n {item.detail ? (\n <span\n data-slot=\"todo-list-item-detail\"\n className=\"shrink-0 text-sm text-muted-foreground/55\"\n >\n {item.detail}\n </span>\n ) : null}\n </motion.li>\n );\n })}\n </AnimatePresence>\n </ol>\n ) : (\n <p\n data-slot=\"todo-list-empty\"\n className=\"px-1.5 py-2 text-sm text-muted-foreground\"\n >\n {emptyLabel}\n </p>\n )}\n </div>\n </Disclosure>\n </section>\n );\n}\n\nexport { TodoList };\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/ai-todo-list.tsx"
19
+ }
20
+ ],
21
+ "type": "registry:ui"
22
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "ai-tool-approval",
4
+ "title": "AI Tool Approval",
5
+ "description": "A permission card for a tool call: name, parameters behind a disclosure, allow once / always allow / deny, and the outcome as a status.",
6
+ "dependencies": [
7
+ "lucide-react",
8
+ "motion"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-code-block",
12
+ "@intelligo/ai-motion",
13
+ "@intelligo/button",
14
+ "@intelligo/input",
15
+ "@intelligo/spinner"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "base/ui/ai-tool-approval/ai-tool-approval.tsx",
20
+ "content": "\"use client\";\n\n/*\n * A permission card: the tool an agent wants to run, its parameters behind\n * a disclosure, and allow once / always allow / deny, with the outcome\n * shown as a status.\n */\n\nimport * as React from \"react\";\nimport {\n CheckIcon,\n ChevronDownIcon,\n CircleAlertIcon,\n ShieldCheckIcon,\n XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { CodeBlock } from \"@/components/ui/ai-code-block\";\nimport {\n Disclosure,\n EASE_OUT,\n SPRING_PRESS,\n SPRING_SWAP,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ToolApprovalStatus =\n | \"pending\"\n | \"approving\"\n | \"approved\"\n | \"denied\"\n | \"running\"\n | \"complete\"\n | \"error\";\n\nexport interface ToolApprovalParameter {\n id: string;\n label: React.ReactNode;\n value: React.ReactNode;\n}\n\nconst DEFAULT_STATUS_LABELS: Record<ToolApprovalStatus, string> = {\n pending: \"Approval required\",\n approving: \"Approving\",\n approved: \"Approved\",\n denied: \"Denied\",\n running: \"Running\",\n complete: \"Completed\",\n error: \"Failed\",\n};\n\nconst BADGE_CLASS: Record<ToolApprovalStatus, string> = {\n pending: \"border-warning/30 bg-warning/10 text-warning\",\n approving: \"border-info/30 bg-info/10 text-info\",\n running: \"border-info/30 bg-info/10 text-info\",\n approved: \"border-success/30 bg-success/10 text-success\",\n complete: \"border-success/30 bg-success/10 text-success\",\n denied: \"border-destructive/30 bg-destructive/10 text-destructive\",\n error: \"border-destructive/30 bg-destructive/10 text-destructive\",\n};\n\n/* ----------------------------------------------------------------------------\n * ToolApprovalCode: a parameter value that is code (a shell command, a\n * JSON payload), highlighted through the shared code block.\n * ------------------------------------------------------------------------- */\n\ntype CodeBlockProps = React.ComponentProps<typeof CodeBlock>;\n\nexport type ToolApprovalCodeProps = Omit<CodeBlockProps, \"language\"> & {\n language?: CodeBlockProps[\"language\"];\n};\n\nfunction ToolApprovalCode({\n language = \"bash\",\n className,\n ...props\n}: ToolApprovalCodeProps) {\n return (\n <CodeBlock\n language={language}\n className={cn(\n // Parameter values sit in a narrow grid column with nowhere to scroll\n // on touch, so they wrap instead of clipping.\n \"border-border/50 bg-muted/30 text-xs [&_code]:text-xs! [&_pre]:p-2.5! [&_pre]:text-xs! [&_pre]:leading-5 [&_pre]:break-words [&_pre]:whitespace-pre-wrap\",\n className\n )}\n {...props}\n />\n );\n}\n\n/* ----------------------------------------------------------------------------\n * ToolApproval: the card.\n * ------------------------------------------------------------------------- */\n\nexport interface ToolApprovalProps\n extends Omit<React.ComponentProps<\"div\">, \"title\"> {\n /** The tool's name, shown in monospace under the title. */\n tool: React.ReactNode;\n title?: React.ReactNode;\n description?: React.ReactNode;\n parameters?: ToolApprovalParameter[];\n status?: ToolApprovalStatus;\n /** Whether the parameter details are shown (controlled). */\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** `remember` is true when the user chose \"Always allow\". */\n onAllow?: (remember: boolean) => void;\n /** `reason` is set only when `denyReason` is on and the user typed one. */\n onDeny?: (reason?: string) => void;\n /** Offer \"Always allow\" beside \"Allow once\". */\n alwaysAllow?: boolean;\n /** Ask for an optional reason before denying. */\n denyReason?: boolean;\n allowOnceLabel?: string;\n alwaysAllowLabel?: string;\n denyLabel?: string;\n cancelLabel?: string;\n detailsLabel?: string;\n denyReasonPlaceholder?: string;\n statusLabels?: Partial<Record<ToolApprovalStatus, string>>;\n}\n\nfunction StatusIcon({\n status,\n busy,\n}: {\n status: ToolApprovalStatus;\n busy: boolean;\n}) {\n if (busy) return <Spinner className=\"size-4\" />;\n if (status === \"error\") return <CircleAlertIcon className=\"size-4\" />;\n if (status === \"denied\") return <XIcon className=\"size-4\" />;\n if (status === \"approved\" || status === \"complete\") {\n return <CheckIcon className=\"size-4\" />;\n }\n return <ShieldCheckIcon className=\"size-4\" />;\n}\n\nfunction ToolApproval({\n tool,\n title = \"Allow this tool to run?\",\n description,\n parameters = [],\n status = \"pending\",\n open,\n defaultOpen = false,\n onOpenChange,\n onAllow,\n onDeny,\n alwaysAllow = false,\n denyReason = false,\n allowOnceLabel = \"Allow once\",\n alwaysAllowLabel = \"Always allow\",\n denyLabel = \"Deny\",\n cancelLabel = \"Cancel\",\n detailsLabel = \"View details\",\n denyReasonPlaceholder = \"Reason (optional)\",\n statusLabels,\n className,\n ...props\n}: ToolApprovalProps) {\n const reduced = useReducedMotion() ?? false;\n const baseId = React.useId();\n const detailsId = `${baseId}-details`;\n const previousStatus = React.useRef(status);\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const [denying, setDenying] = React.useState(false);\n const [reason, setReason] = React.useState(\"\");\n const currentOpen = open ?? internalOpen;\n const setOpen = React.useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, open]\n );\n const busy = status === \"approving\" || status === \"running\";\n const pending = status === \"pending\";\n const error = status === \"error\";\n const statusLabel = statusLabels?.[status] ?? DEFAULT_STATUS_LABELS[status];\n\n // Once a decision lands the details fold away; the card reads as a receipt.\n React.useEffect(() => {\n if (previousStatus.current === \"pending\" && status !== \"pending\") {\n setOpen(false);\n setDenying(false);\n }\n previousStatus.current = status;\n }, [setOpen, status]);\n\n const submitDeny = () => {\n const trimmed = reason.trim();\n onDeny?.(trimmed ? trimmed : undefined);\n };\n\n return (\n <div\n data-slot=\"tool-approval\"\n data-state={status}\n aria-busy={busy || undefined}\n className={cn(\n \"w-full overflow-hidden rounded-xl border bg-card text-sm\",\n className\n )}\n {...props}\n >\n <div data-slot=\"tool-approval-header\" className=\"flex items-start gap-2.5 p-3\">\n <span\n data-slot=\"tool-approval-icon\"\n aria-hidden=\"true\"\n className={cn(\n \"mt-0.5 grid size-4 shrink-0 place-items-center text-muted-foreground\",\n error && \"text-destructive\"\n )}\n >\n <StatusIcon status={status} busy={busy} />\n </span>\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex min-w-0 items-start justify-between gap-3\">\n <div className=\"min-w-0\">\n <div\n data-slot=\"tool-approval-title\"\n className=\"leading-5 font-medium text-foreground\"\n >\n {title}\n </div>\n {description ? (\n <p\n data-slot=\"tool-approval-description\"\n className=\"mt-0.5 leading-5 text-muted-foreground\"\n >\n {description}\n </p>\n ) : (\n <div\n data-slot=\"tool-approval-tool\"\n className=\"mt-0.5 truncate font-mono text-xs text-muted-foreground\"\n >\n {tool}\n </div>\n )}\n </div>\n {pending ? null : (\n <span\n data-slot=\"tool-approval-status\"\n className={cn(\n \"shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors\",\n BADGE_CLASS[status]\n )}\n >\n {statusLabel}\n </span>\n )}\n </div>\n\n {parameters.length ? (\n <button\n type=\"button\"\n data-slot=\"tool-approval-details-trigger\"\n aria-expanded={currentOpen}\n aria-controls={detailsId}\n onClick={() => setOpen(!currentOpen)}\n className=\"mt-2 inline-flex items-center gap-1 rounded-md text-xs font-medium text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n {detailsLabel}\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: currentOpen ? 180 : 0 }}\n transition={reduced ? { duration: 0 } : SPRING_SWAP}\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n ) : null}\n </div>\n </div>\n\n <Disclosure id={detailsId} open={currentOpen}>\n <dl\n data-slot=\"tool-approval-details\"\n className=\"mx-3 mb-3 grid gap-2 rounded-lg bg-muted/60 p-3\"\n >\n {parameters.map((parameter) => (\n <div\n key={parameter.id}\n data-slot=\"tool-approval-parameter\"\n className=\"grid items-center gap-3 text-xs\"\n style={{ gridTemplateColumns: \"minmax(0, 7rem) minmax(0, 1fr)\" }}\n >\n <dt className=\"text-muted-foreground\">{parameter.label}</dt>\n <dd className=\"min-w-0 font-mono break-words text-foreground/85\">\n {parameter.value}\n </dd>\n </div>\n ))}\n </dl>\n </Disclosure>\n\n <AnimatePresence initial={false}>\n {pending ? (\n <motion.div\n data-slot=\"tool-approval-actions\"\n initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4 }}\n animate={{ opacity: 1, y: 0 }}\n exit={{ opacity: 0 }}\n transition={{ duration: reduced ? 0.12 : 0.22, ease: EASE_OUT }}\n className=\"flex flex-wrap items-center justify-end gap-2 px-3 pb-3\"\n >\n {denying ? (\n <form\n data-slot=\"tool-approval-deny-reason\"\n className=\"flex w-full flex-wrap items-center gap-2\"\n onSubmit={(event) => {\n event.preventDefault();\n submitDeny();\n }}\n >\n <Input\n autoFocus\n value={reason}\n onChange={(event) => setReason(event.target.value)}\n placeholder={denyReasonPlaceholder}\n aria-label={denyReasonPlaceholder}\n className=\"min-w-0 flex-1 text-xs\"\n />\n <Button\n type=\"submit\"\n variant=\"destructive\"\n size=\"sm\"\n >\n {denyLabel}\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"text-muted-foreground hover:text-foreground\"\n onClick={() => setDenying(false)}\n >\n {cancelLabel}\n </Button>\n </form>\n ) : (\n <>\n <motion.span\n className=\"inline-flex\"\n whileTap={reduced ? undefined : { scale: 0.97 }}\n transition={SPRING_PRESS}\n >\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={() => onAllow?.(false)}\n >\n {allowOnceLabel}\n </Button>\n </motion.span>\n {alwaysAllow ? (\n <motion.span\n className=\"inline-flex\"\n whileTap={reduced ? undefined : { scale: 0.97 }}\n transition={SPRING_PRESS}\n >\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => onAllow?.(true)}\n >\n {alwaysAllowLabel}\n </Button>\n </motion.span>\n ) : null}\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"text-muted-foreground hover:text-foreground\"\n onClick={() => {\n if (denyReason) setDenying(true);\n else onDeny?.();\n }}\n >\n {denyLabel}\n </Button>\n </>\n )}\n </motion.div>\n ) : null}\n </AnimatePresence>\n </div>\n );\n}\n\nexport { ToolApproval, ToolApprovalCode };\n",
21
+ "type": "registry:ui",
22
+ "target": "components/ui/ai-tool-approval.tsx"
23
+ }
24
+ ],
25
+ "type": "registry:ui"
26
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "ai-tool-result",
4
+ "title": "AI Tool Result",
5
+ "description": "The result of a tool the agent ran: a summary line that opens onto highlighted output, status rolling from running to done, copy and run-again actions.",
6
+ "dependencies": [
7
+ "motion",
8
+ "lucide-react"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/ai-code-block",
12
+ "@intelligo/ai-motion",
13
+ "@intelligo/button",
14
+ "@intelligo/spinner"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "base/ui/ai-tool-result/ai-tool-result.tsx",
19
+ "content": "\"use client\";\n\n/*\n * The result of a tool the agent ran — a one-line summary that opens onto\n * the output, with the status rolling from running to done, a copy and a\n * run-again action, and the viewport following the output while it\n * streams.\n */\n\nimport * as React from \"react\";\nimport {\n BanIcon,\n BracesIcon,\n CheckIcon,\n ChevronDownIcon,\n CircleCheckIcon,\n CircleXIcon,\n CopyIcon,\n RotateCcwIcon,\n SquareTerminalIcon,\n WrenchIcon,\n} from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport {\n type CodeLanguage,\n HighlightedCode,\n} from \"@/components/ui/ai-code-block\";\nimport { Disclosure, SPRING_SWAP, SwapText } from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ToolResultStatus = \"running\" | \"success\" | \"error\" | \"cancelled\";\nexport type ToolResultKind = \"terminal\" | \"request\" | \"custom\";\n\nconst STATUS_LABELS: Record<ToolResultStatus, string> = {\n running: \"Running\",\n success: \"Completed\",\n error: \"Failed\",\n cancelled: \"Cancelled\",\n};\n\nconst STATUS_CLASS: Record<ToolResultStatus, string> = {\n running: \"text-info\",\n success: \"text-success\",\n error: \"text-destructive\",\n cancelled: \"text-muted-foreground\",\n};\n\nfunction getSwapKey(value: React.ReactNode, fallback: string) {\n return typeof value === \"string\" || typeof value === \"number\"\n ? String(value)\n : fallback;\n}\n\nfunction KindIcon({ kind }: { kind: ToolResultKind }) {\n if (kind === \"terminal\") return <SquareTerminalIcon className=\"size-4\" />;\n if (kind === \"request\") return <BracesIcon className=\"size-4\" />;\n return <WrenchIcon className=\"size-4\" />;\n}\n\nfunction StatusIcon({\n status,\n label,\n}: {\n status: ToolResultStatus;\n label: string;\n}) {\n if (status === \"running\") return <Spinner className=\"size-3\" aria-label={label} />;\n if (status === \"success\") return <CircleCheckIcon className=\"size-3\" />;\n if (status === \"error\") return <CircleXIcon className=\"size-3\" />;\n return <BanIcon className=\"size-3\" />;\n}\n\nfunction ToolResultAction({\n label,\n onClick,\n children,\n}: {\n label: string;\n onClick: () => void;\n children: React.ReactNode;\n}) {\n return (\n <Button\n data-slot=\"tool-result-action\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={label}\n title={label}\n onClick={onClick}\n className=\"text-muted-foreground\"\n >\n {children}\n </Button>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * ToolResultOutput: the tool's text, highlighted, wrapping in the pane.\n * ------------------------------------------------------------------------- */\n\nexport interface ToolResultOutputProps\n extends Omit<React.ComponentProps<typeof HighlightedCode>, \"code\" | \"children\"> {\n children: string;\n language?: CodeLanguage;\n}\n\nfunction ToolResultOutput({\n children,\n language = \"bash\",\n className,\n ...props\n}: ToolResultOutputProps) {\n return (\n <HighlightedCode\n data-slot=\"tool-result-output\"\n code={children}\n language={language}\n className={cn(\"break-words whitespace-pre-wrap text-foreground/80\", className)}\n {...props}\n />\n );\n}\n\n/* ----------------------------------------------------------------------------\n * ToolResult\n * ------------------------------------------------------------------------- */\n\nexport interface ToolResultProps\n extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> {\n /** The tool's name, in monospace. */\n tool: React.ReactNode;\n /** What the call did, in words. */\n title: React.ReactNode;\n children: React.ReactNode;\n status?: ToolResultStatus;\n kind?: ToolResultKind;\n /** A small detail next to the title — a duration, a count. */\n meta?: React.ReactNode;\n /** Replaces the kind icon. */\n icon?: React.ReactNode;\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Close the output once the run ends; a new run opens it again. */\n collapseOnComplete?: boolean;\n /** Pixel height the output scrolls within. */\n maxHeight?: number;\n /** Text the copy action writes; `onCopy` takes over when given. */\n copyText?: string;\n onCopy?: () => void | Promise<void>;\n onRetry?: () => void;\n contentClassName?: string;\n statusLabels?: Partial<Record<ToolResultStatus, string>>;\n copyLabel?: string;\n copiedLabel?: string;\n retryLabel?: string;\n}\n\nfunction ToolResult({\n tool,\n title,\n children,\n status = \"running\",\n kind = \"custom\",\n meta,\n icon,\n open,\n defaultOpen = true,\n onOpenChange,\n collapseOnComplete = true,\n maxHeight = 220,\n copyText,\n onCopy,\n onRetry,\n className,\n contentClassName,\n statusLabels,\n copyLabel = \"Copy result\",\n copiedLabel = \"Copied\",\n retryLabel = \"Run again\",\n ...props\n}: ToolResultProps) {\n const reduce = useReducedMotion() ?? false;\n const baseId = React.useId();\n const triggerId = `${baseId}-trigger`;\n const contentId = `${baseId}-content`;\n const viewportRef = React.useRef<HTMLDivElement>(null);\n const previousStatus = React.useRef(status);\n const copyTimer = React.useRef<number | undefined>(undefined);\n const [copied, setCopied] = React.useState(false);\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const currentOpen = open ?? internalOpen;\n const running = status === \"running\";\n const canCopy = Boolean(copyText || onCopy);\n const labels = { ...STATUS_LABELS, ...statusLabels };\n const statusLabel = labels[status];\n const titleKey = getSwapKey(title, status);\n const metaKey = getSwapKey(meta, `${status}-meta`);\n const toolKey = getSwapKey(tool, `${status}-tool`);\n\n const setOpen = React.useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, open]\n );\n\n React.useEffect(() => {\n if (previousStatus.current !== \"running\" && status === \"running\") {\n setOpen(true);\n }\n if (\n previousStatus.current === \"running\" &&\n status !== \"running\" &&\n collapseOnComplete\n ) {\n setOpen(false);\n }\n previousStatus.current = status;\n }, [collapseOnComplete, setOpen, status]);\n\n React.useEffect(\n () => () => {\n if (copyTimer.current) window.clearTimeout(copyTimer.current);\n },\n []\n );\n\n // While the tool runs the viewport follows the newest output.\n React.useLayoutEffect(() => {\n const viewport = viewportRef.current;\n if (!viewport || !currentOpen || !running) return;\n\n const frame = requestAnimationFrame(() => {\n if (typeof viewport.scrollTo === \"function\") {\n viewport.scrollTo({\n top: viewport.scrollHeight,\n behavior: reduce ? \"auto\" : \"smooth\",\n });\n } else {\n viewport.scrollTop = viewport.scrollHeight;\n }\n });\n return () => cancelAnimationFrame(frame);\n });\n\n const handleCopy = React.useCallback(async () => {\n if (onCopy) await onCopy();\n else if (copyText) await navigator.clipboard?.writeText(copyText);\n\n setCopied(true);\n if (copyTimer.current) window.clearTimeout(copyTimer.current);\n copyTimer.current = window.setTimeout(() => setCopied(false), 1600);\n }, [copyText, onCopy]);\n\n return (\n <div\n data-slot=\"tool-result\"\n data-state={status}\n data-kind={kind}\n aria-busy={running || undefined}\n className={cn(\"w-full text-sm\", className)}\n {...props}\n >\n <button\n id={triggerId}\n type=\"button\"\n data-slot=\"tool-result-trigger\"\n aria-expanded={currentOpen}\n aria-controls={contentId}\n onClick={() => setOpen(!currentOpen)}\n className=\"group/tool-result flex min-h-9 w-full items-center gap-2 rounded-md py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n >\n <span\n aria-hidden=\"true\"\n className=\"grid size-4 shrink-0 place-items-center text-muted-foreground\"\n >\n {icon ?? <KindIcon kind={kind} />}\n </span>\n <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n <span className=\"min-w-0 truncate font-medium text-foreground/90\">\n <SwapText value={titleKey}>{title}</SwapText>\n </span>\n {meta ? (\n <span className=\"shrink-0 text-xs text-muted-foreground/60\">\n <SwapText value={metaKey}>{meta}</SwapText>\n </span>\n ) : null}\n <span className=\"min-w-0 truncate font-mono text-xs text-muted-foreground/55\">\n <SwapText value={toolKey}>{tool}</SwapText>\n </span>\n </span>\n <span\n data-slot=\"tool-result-status\"\n className={cn(\n \"inline-flex shrink-0 items-center gap-1 text-xs font-medium\",\n STATUS_CLASS[status]\n )}\n >\n <StatusIcon status={status} label={statusLabel} />\n <SwapText value={status}>{statusLabel}</SwapText>\n </span>\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: currentOpen ? 180 : 0 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className=\"shrink-0 text-muted-foreground/50 transition-colors group-hover/tool-result:text-muted-foreground\"\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n\n <Disclosure\n id={contentId}\n role=\"region\"\n aria-labelledby={triggerId}\n open={currentOpen}\n >\n <div className=\"pt-1.5 pl-6\">\n <div\n data-slot=\"tool-result-panel\"\n className=\"overflow-hidden rounded-xl bg-muted/80\"\n >\n <div\n ref={viewportRef}\n data-slot=\"tool-result-viewport\"\n role=\"log\"\n aria-live=\"polite\"\n className=\"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n style={{ maxHeight }}\n >\n <div className={cn(\"p-3\", contentClassName)}>{children}</div>\n </div>\n\n {canCopy || onRetry ? (\n <div\n data-slot=\"tool-result-actions\"\n className=\"flex items-center gap-0.5 px-2 pb-1.5\"\n >\n {canCopy ? (\n <ToolResultAction\n label={copied ? copiedLabel : copyLabel}\n onClick={handleCopy}\n >\n {copied ? (\n <CheckIcon className=\"size-3.5\" />\n ) : (\n <CopyIcon className=\"size-3.5\" />\n )}\n </ToolResultAction>\n ) : null}\n {onRetry ? (\n <ToolResultAction label={retryLabel} onClick={onRetry}>\n <RotateCcwIcon className=\"size-3.5\" />\n </ToolResultAction>\n ) : null}\n <span className=\"ml-auto text-xs text-muted-foreground/55\">\n <SwapText value={status}>{statusLabel}</SwapText>\n </span>\n </div>\n ) : null}\n </div>\n </div>\n </Disclosure>\n </div>\n );\n}\n\nexport { ToolResult, ToolResultOutput };\n",
20
+ "type": "registry:ui",
21
+ "target": "components/ui/ai-tool-result.tsx"
22
+ }
23
+ ],
24
+ "type": "registry:ui"
25
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "alert-dialog",
4
+ "title": "Alert Dialog",
5
+ "description": "A modal that confirms a destructive or irreversible action, with the dialog's backdrop and spring.",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "motion"
9
+ ],
10
+ "registryDependencies": [
11
+ "@intelligo/button",
12
+ "@intelligo/ai-motion"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "base/ui/alert-dialog/alert-dialog.tsx",
17
+ "content": "\"use client\";\n\n/*\n * The alert dialog: the dialog's backdrop and spring, for a decision the\n * reader must make. Backdrop and panel animate with motion; the root is\n * kept controlled so the exit plays.\n */\n\nimport * as React from \"react\";\nimport { AlertDialog as AlertDialogPrimitive } from \"@base-ui/react/alert-dialog\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n backdropMotion,\n popupMotion,\n useOpenState,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nconst AlertDialogOpenContext = React.createContext<boolean | null>(null);\n\nconst OVERLAY =\n \"fixed inset-0 isolate z-overlay bg-background/60 supports-backdrop-filter:backdrop-blur-sm\";\n\nfunction AlertDialog({\n open: openProp,\n defaultOpen,\n onOpenChange,\n ...props\n}: AlertDialogPrimitive.Root.Props) {\n const [open, setOpen] = useOpenState(openProp, defaultOpen, onOpenChange);\n return (\n <AlertDialogOpenContext.Provider value={open}>\n <AlertDialogPrimitive.Root\n data-slot=\"alert-dialog\"\n open={open}\n onOpenChange={setOpen}\n {...props}\n />\n </AlertDialogOpenContext.Provider>\n );\n}\n\nfunction AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {\n return (\n <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n );\n}\n\nfunction AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {\n return (\n <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n );\n}\n\nfunction AlertDialogOverlay({\n className,\n ...props\n}: AlertDialogPrimitive.Backdrop.Props) {\n return (\n <AlertDialogPrimitive.Backdrop\n data-slot=\"alert-dialog-overlay\"\n className={cn(\n OVERLAY,\n \"transition-[opacity,backdrop-filter] duration-normal ease-standard data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-fast\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AlertDialogContent({\n className,\n size = \"default\",\n ...props\n}: AlertDialogPrimitive.Popup.Props & {\n size?: \"default\" | \"sm\";\n}) {\n const open = React.useContext(AlertDialogOpenContext) ?? true;\n const reduced = useReducedMotion() ?? false;\n\n return (\n <AnimatePresence>\n {open && (\n <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" keepMounted>\n <AlertDialogPrimitive.Backdrop\n data-slot=\"alert-dialog-overlay\"\n render={<motion.div {...backdropMotion(reduced)} />}\n className={OVERLAY}\n />\n <AlertDialogPrimitive.Popup\n data-slot=\"alert-dialog-content\"\n data-size={size}\n render={<motion.div {...popupMotion(reduced, 0.94)} />}\n className={cn(\n \"group/alert-dialog-content fixed top-1/2 left-1/2 z-modal grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border bg-popover p-5 text-popover-foreground shadow-2xl outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm\",\n className\n )}\n {...props}\n />\n </AlertDialogPrimitive.Portal>\n )}\n </AnimatePresence>\n );\n}\n\nfunction AlertDialogHeader({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-dialog-header\"\n className={cn(\n \"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AlertDialogFooter({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-dialog-footer\"\n className={cn(\n \"-mx-5 -mb-5 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/60 px-5 py-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AlertDialogMedia({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"alert-dialog-media\"\n className={cn(\n \"mb-2 inline-flex size-10 items-center justify-center rounded-lg bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AlertDialogTitle({\n className,\n ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n return (\n <AlertDialogPrimitive.Title\n data-slot=\"alert-dialog-title\"\n className={cn(\n \"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AlertDialogDescription({\n className,\n ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n return (\n <AlertDialogPrimitive.Description\n data-slot=\"alert-dialog-description\"\n className={cn(\n \"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AlertDialogAction({\n className,\n ...props\n}: React.ComponentProps<typeof Button>) {\n return (\n <Button\n data-slot=\"alert-dialog-action\"\n className={cn(className)}\n {...props}\n />\n );\n}\n\nfunction AlertDialogCancel({\n className,\n variant = \"outline\",\n size = \"default\",\n ...props\n}: AlertDialogPrimitive.Close.Props &\n Pick<React.ComponentProps<typeof Button>, \"variant\" | \"size\">) {\n return (\n <AlertDialogPrimitive.Close\n data-slot=\"alert-dialog-cancel\"\n className={cn(className)}\n render={<Button variant={variant} size={size} />}\n {...props}\n />\n );\n}\n\nexport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogMedia,\n AlertDialogOverlay,\n AlertDialogPortal,\n AlertDialogTitle,\n AlertDialogTrigger,\n};\n",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/alert-dialog.tsx"
20
+ }
21
+ ],
22
+ "type": "registry:ui"
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "animated-list",
4
+ "title": "Animated List",
5
+ "description": "A list container that staggers its rows in on mount, rows that rise in, fall out and glide on reorder (ul, ol, div or tbody/tr), and a block that reveals once as it scrolls into view.",
6
+ "dependencies": [
7
+ "motion"
8
+ ],
9
+ "registryDependencies": [
10
+ "@intelligo/ai-motion"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "base/ui/animated-list/animated-list.tsx",
15
+ "content": "\"use client\";\n\n/*\n * Lists that arrive: a container that staggers its rows in on mount, rows\n * that rise in, fall out under AnimatePresence and glide when reordered,\n * and a block that reveals once as it scrolls into view.\n */\n\nimport * as React from \"react\";\nimport {\n motion,\n useInView,\n useReducedMotion,\n type HTMLMotionProps,\n type Variants,\n} from \"motion/react\";\n\nimport {\n EASE_OUT,\n listItem,\n listStagger,\n SPRING_LAYOUT,\n} from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* ----------------------------------------------------------------------------\n * AnimatedList: the container. Its children stagger in on mount; rows added\n * later rise in on their own. Render it as the element the markup needs —\n * a `tbody` for table rows keeps the table valid.\n * ------------------------------------------------------------------------- */\n\nexport type AnimatedListElement = \"ul\" | \"ol\" | \"div\" | \"tbody\";\n\nexport type AnimatedListProps<T extends AnimatedListElement = \"ul\"> = Omit<\n HTMLMotionProps<T>,\n \"variants\" | \"initial\" | \"animate\"\n> & {\n /** The element to render; `tbody` for table rows. */\n as?: T;\n /** Seconds between one row and the next. */\n stagger?: number;\n /** Seconds before the first row starts. */\n delay?: number;\n /** Play the stagger on mount (default). False shows the rows at rest. */\n animateOnMount?: boolean;\n};\n\nconst REDUCED_ITEM: Variants = {\n hidden: { opacity: 0 },\n shown: { opacity: 1, transition: { duration: 0 } },\n exit: { opacity: 0, transition: { duration: 0 } },\n};\n\nfunction AnimatedList<T extends AnimatedListElement = \"ul\">({\n as,\n stagger,\n delay,\n animateOnMount = true,\n className,\n ...props\n}: AnimatedListProps<T>) {\n const reduced = useReducedMotion() ?? false;\n const Component = motion[as ?? \"ul\"] as React.ElementType;\n\n const variants = React.useMemo<Variants>(() => {\n if (reduced) return { hidden: {}, shown: {} };\n if (stagger === undefined && delay === undefined) return listStagger;\n return {\n hidden: {},\n shown: {\n transition: {\n staggerChildren: stagger ?? 0.04,\n delayChildren: delay ?? 0.02,\n },\n },\n };\n }, [reduced, stagger, delay]);\n\n return (\n <Component\n data-slot=\"animated-list\"\n variants={variants}\n initial={animateOnMount ? \"hidden\" : false}\n animate=\"shown\"\n className={className}\n {...props}\n />\n );\n}\n\n/* ----------------------------------------------------------------------------\n * AnimatedListItem: a row. It takes its entrance from the list's stagger,\n * leaves with its exit when wrapped in AnimatePresence, and glides to its\n * new place on a reorder. Use `tr` inside a `tbody` list. Keep\n * AnimatePresence in its default mode for table rows — `popLayout` lifts\n * the leaving row out of the table.\n * ------------------------------------------------------------------------- */\n\nexport type AnimatedListItemElement = \"li\" | \"div\" | \"tr\";\n\nexport type AnimatedListItemProps<T extends AnimatedListItemElement = \"li\"> =\n Omit<HTMLMotionProps<T>, \"variants\"> & {\n /** The element to render; `tr` for table rows. */\n as?: T;\n };\n\nfunction AnimatedListItem<T extends AnimatedListItemElement = \"li\">({\n as,\n layout,\n transition,\n className,\n ...props\n}: AnimatedListItemProps<T>) {\n const reduced = useReducedMotion() ?? false;\n const Component = motion[as ?? \"li\"] as React.ElementType;\n\n return (\n <Component\n data-slot=\"animated-list-item\"\n variants={reduced ? REDUCED_ITEM : listItem}\n exit=\"exit\"\n layout={reduced ? false : (layout ?? \"position\")}\n transition={\n transition ?? (reduced ? { duration: 0 } : { layout: SPRING_LAYOUT })\n }\n className={className}\n {...props}\n />\n );\n}\n\n/* ----------------------------------------------------------------------------\n * ScrollReveal: a block that rises and clears the first time enough of it\n * scrolls into view.\n * ------------------------------------------------------------------------- */\n\nexport interface ScrollRevealProps\n extends Omit<HTMLMotionProps<\"div\">, \"initial\" | \"animate\"> {\n /** Distance in px the block rises from. */\n y?: number;\n /** Blur in px it clears from; kept at 10 or under. */\n blur?: number;\n /** Seconds the reveal takes. */\n duration?: number;\n /** Seconds before the reveal starts once in view. */\n delay?: number;\n /** Reveal only the first time (default), or every time it enters. */\n once?: boolean;\n /** How much of the block must be visible to reveal it. */\n amount?: \"some\" | \"all\" | number;\n /** The scroll container, for blocks inside a scroll area. Defaults to the viewport. */\n root?: React.RefObject<Element | null>;\n}\n\nfunction ScrollReveal({\n y = 16,\n blur = 8,\n duration = 0.6,\n delay = 0,\n once = true,\n amount = 0.3,\n root,\n transition,\n className,\n children,\n ...props\n}: ScrollRevealProps) {\n const reduced = useReducedMotion() ?? false;\n const ref = React.useRef<HTMLDivElement>(null);\n const inView = useInView(ref, { root, once, amount });\n const blurPx = Math.min(Math.max(blur, 0), 10);\n\n const hidden = reduced\n ? { opacity: 0 }\n : { opacity: 0, y, filter: `blur(${blurPx}px)` };\n const shown = reduced\n ? { opacity: 1 }\n : { opacity: 1, y: 0, filter: \"blur(0px)\" };\n\n return (\n <motion.div\n data-slot=\"scroll-reveal\"\n {...props}\n ref={ref}\n initial={hidden}\n animate={inView ? shown : hidden}\n transition={\n transition ??\n (reduced ? { duration: 0 } : { duration, delay, ease: EASE_OUT })\n }\n className={cn(className)}\n >\n {children}\n </motion.div>\n );\n}\n\nexport { AnimatedList, AnimatedListItem, ScrollReveal };\n",
16
+ "type": "registry:ui",
17
+ "target": "components/ui/animated-list.tsx"
18
+ }
19
+ ],
20
+ "type": "registry:ui"
21
+ }