@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.
- package/README.md +41 -2
- package/dist/bin.js +37 -0
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts +4 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +9 -0
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +4 -1
- package/dist/commands/create-flow.d.ts.map +1 -1
- package/dist/commands/create-flow.js +63 -31
- package/dist/commands/create-flow.js.map +1 -1
- package/dist/commands/create.d.ts +8 -4
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +15 -7
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +21 -19
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-check.d.ts +20 -1
- package/dist/commands/migrate-check.d.ts.map +1 -1
- package/dist/commands/migrate-check.js +35 -6
- package/dist/commands/migrate-check.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -8
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/sync-messages.d.ts +37 -0
- package/dist/commands/sync-messages.d.ts.map +1 -0
- package/dist/commands/sync-messages.js +88 -0
- package/dist/commands/sync-messages.js.map +1 -0
- package/dist/commands/sync-scaffold.d.ts +36 -0
- package/dist/commands/sync-scaffold.d.ts.map +1 -0
- package/dist/commands/sync-scaffold.js +53 -0
- package/dist/commands/sync-scaffold.js.map +1 -0
- package/dist/commands/sync.d.ts +103 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +361 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/upgrade-check.d.ts.map +1 -1
- package/dist/commands/upgrade-check.js +4 -1
- package/dist/commands/upgrade-check.js.map +1 -1
- package/dist/manifest.d.ts +27 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +30 -3
- package/dist/manifest.js.map +1 -1
- package/dist/module-exports.d.ts +63 -0
- package/dist/module-exports.d.ts.map +1 -0
- package/dist/module-exports.js +231 -0
- package/dist/module-exports.js.map +1 -0
- package/dist/registry-bundle.d.ts +43 -0
- package/dist/registry-bundle.d.ts.map +1 -0
- package/dist/registry-bundle.js +74 -0
- package/dist/registry-bundle.js.map +1 -0
- package/dist/registry-items.d.ts +41 -13
- package/dist/registry-items.d.ts.map +1 -1
- package/dist/registry-items.js +117 -33
- package/dist/registry-items.js.map +1 -1
- package/package.json +1 -1
- package/src/bin.ts +52 -0
- package/src/commands/add.ts +11 -0
- package/src/commands/create-flow.ts +82 -26
- package/src/commands/create.ts +17 -7
- package/src/commands/doctor.ts +27 -27
- package/src/commands/migrate-check.ts +51 -10
- package/src/commands/migrate.ts +2 -8
- package/src/commands/sync-messages.ts +114 -0
- package/src/commands/sync-scaffold.ts +83 -0
- package/src/commands/sync.ts +534 -0
- package/src/commands/upgrade-check.ts +4 -1
- package/src/manifest.ts +57 -3
- package/src/module-exports.ts +266 -0
- package/src/registry-bundle.ts +103 -0
- package/src/registry-items.ts +141 -36
- package/templates/app-scaffold/intelligo.ts.tpl +6 -47
- package/templates/manifest.json +19 -0
- package/templates/registry/ai-agent-activity.json +23 -0
- package/templates/registry/ai-agent-progress.json +21 -0
- package/templates/registry/ai-approval-card.json +27 -0
- package/templates/registry/ai-artifact.json +22 -0
- package/templates/registry/ai-branch.json +21 -0
- package/templates/registry/ai-citations.json +24 -0
- package/templates/registry/ai-code-block.json +23 -0
- package/templates/registry/ai-composer-menu.json +19 -0
- package/templates/registry/ai-file-diff.json +25 -0
- package/templates/registry/ai-image-generation.json +23 -0
- package/templates/registry/ai-markdown.json +23 -0
- package/templates/registry/ai-message-bubble.json +23 -0
- package/templates/registry/ai-message-scroller.json +22 -0
- package/templates/registry/ai-message.json +21 -0
- package/templates/registry/ai-motion.json +19 -0
- package/templates/registry/ai-prompt-input.json +28 -0
- package/templates/registry/ai-reasoning-text.json +22 -0
- package/templates/registry/ai-reasoning.json +22 -0
- package/templates/registry/ai-shimmer-text.json +19 -0
- package/templates/registry/ai-sidebar.json +25 -0
- package/templates/registry/ai-speech-input.json +21 -0
- package/templates/registry/ai-streaming-response.json +24 -0
- package/templates/registry/ai-suggestion.json +19 -0
- package/templates/registry/ai-todo-list.json +22 -0
- package/templates/registry/ai-tool-approval.json +26 -0
- package/templates/registry/ai-tool-result.json +25 -0
- package/templates/registry/alert-dialog.json +23 -0
- package/templates/registry/animated-list.json +21 -0
- package/templates/registry/app-shell.json +93 -0
- package/templates/registry/artifacts.json +82 -0
- package/templates/registry/attachment.json +22 -0
- package/templates/registry/auth-email-verification.json +39 -0
- package/templates/registry/auth-login.json +72 -0
- package/templates/registry/auth-password-reset.json +53 -0
- package/templates/registry/auth-signup.json +41 -0
- package/templates/registry/billing-settings.json +60 -0
- package/templates/registry/button.json +22 -0
- package/templates/registry/chat-eve.json +19 -0
- package/templates/registry/chat-panel.json +30 -0
- package/templates/registry/chat-share.json +42 -0
- package/templates/registry/chat-widget.json +34 -0
- package/templates/registry/chat.json +326 -0
- package/templates/registry/checkbox.json +22 -0
- package/templates/registry/checkout.json +40 -0
- package/templates/registry/collapsible.json +19 -0
- package/templates/registry/command.json +23 -0
- package/templates/registry/copy-button.json +21 -0
- package/templates/registry/dashboard.json +69 -0
- package/templates/registry/dialog.json +24 -0
- package/templates/registry/document-viewer.json +22 -0
- package/templates/registry/dropdown-menu.json +23 -0
- package/templates/registry/expandable-tabs.json +22 -0
- package/templates/registry/feature-gating.json +73 -0
- package/templates/registry/file-upload.json +24 -0
- package/templates/registry/hold-action-button.json +22 -0
- package/templates/registry/input-group.json +23 -0
- package/templates/registry/input.json +19 -0
- package/templates/registry/intelligo.json +187 -0
- package/templates/registry/invitation-accept.json +64 -0
- package/templates/registry/language-switcher.json +29 -0
- package/templates/registry/morphing-modal.json +24 -0
- package/templates/registry/notification-stack.json +24 -0
- package/templates/registry/notifications.json +87 -0
- package/templates/registry/onboarding.json +83 -0
- package/templates/registry/otp-input.json +22 -0
- package/templates/registry/page-header.json +15 -0
- package/templates/registry/payment-poll.json +52 -0
- package/templates/registry/popover-morph.json +22 -0
- package/templates/registry/popover.json +22 -0
- package/templates/registry/pricing.json +111 -0
- package/templates/registry/privacy-settings.json +65 -0
- package/templates/registry/profile-settings.json +68 -0
- package/templates/registry/progress.json +19 -0
- package/templates/registry/radio-group.json +22 -0
- package/templates/registry/registry.json +2945 -0
- package/templates/registry/route-error.json +65 -0
- package/templates/registry/select-morph.json +23 -0
- package/templates/registry/select.json +23 -0
- package/templates/registry/settings-shell.json +47 -0
- package/templates/registry/sheet.json +24 -0
- package/templates/registry/sidebar.json +28 -0
- package/templates/registry/smoke.json +34 -0
- package/templates/registry/spinner.json +16 -0
- package/templates/registry/stat-card.json +18 -0
- package/templates/registry/status-badge.json +18 -0
- package/templates/registry/switch.json +20 -0
- package/templates/registry/tabs.json +23 -0
- package/templates/registry/team-settings.json +97 -0
- package/templates/registry/textarea.json +16 -0
- package/templates/registry/tooltip.json +22 -0
- package/templates/registry/trial-banner.json +43 -0
- package/templates/registry/usage.json +84 -0
- package/templates/registry/workspace-settings.json +71 -0
- package/templates/registry-items.json +1 -1
- package/templates/registry-requires.json +26 -2
- package/templates/vitest/server-only.ts.tpl +7 -0
- package/templates/vitest/vitest.config.ts.tpl +37 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-message-bubble",
|
|
4
|
+
"title": "AI Message Bubble",
|
|
5
|
+
"description": "The speech bubble inside a message row: six surface variants that pop into place, grouped bubbles and a collapsible long body.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion",
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion",
|
|
12
|
+
"@intelligo/ai-message"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "base/ui/ai-message-bubble/ai-message-bubble.tsx",
|
|
17
|
+
"content": "\"use client\";\n\n/*\n * The speech bubble inside a message row: a surface that pops into place,\n * a content layer that fades in after it, grouped bubbles and a\n * collapsible long body.\n * Alignment follows the surrounding `Message` unless overridden.\n */\n\nimport * as React from \"react\";\nimport { ChevronDownIcon } from \"lucide-react\";\nimport { motion, useReducedMotion, type HTMLMotionProps } from \"motion/react\";\n\nimport { MessageSideContext } from \"@/components/ui/ai-message\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MessageBubbleVariant =\n | \"solid\"\n | \"soft\"\n | \"tint\"\n | \"outline\"\n | \"ghost\"\n | \"danger\";\nexport type MessageBubbleAlign = \"start\" | \"end\";\n\ninterface MessageBubbleContextValue {\n align?: MessageBubbleAlign;\n animateIn: boolean;\n variant: MessageBubbleVariant;\n}\n\nconst MessageBubbleContext = React.createContext<MessageBubbleContextValue>({\n animateIn: true,\n variant: \"soft\",\n});\nconst MessageBubbleLayoutContext = React.createContext<() => void>(() => {});\n\nexport interface MessageBubbleProps\n extends Omit<HTMLMotionProps<\"div\">, \"children\"> {\n variant?: MessageBubbleVariant;\n /** Defaults to the surrounding Message alignment when omitted. */\n align?: MessageBubbleAlign;\n /** Plays the bubble entrance once when this component mounts. */\n animateIn?: boolean;\n children?: React.ReactNode;\n}\n\nexport interface MessageBubbleContentProps extends React.ComponentProps<\"div\"> {\n /** Replaces the content element while preserving bubble styling. */\n render?: React.ReactElement;\n}\n\nexport interface MessageBubbleGroupProps extends React.ComponentProps<\"div\"> {\n spacing?: \"compact\" | \"default\";\n}\n\nexport interface MessageBubbleCollapsibleProps\n extends React.ComponentProps<\"div\"> {\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n collapsedLines?: 2 | 3 | 4 | 5 | 6;\n moreLabel?: React.ReactNode;\n lessLabel?: React.ReactNode;\n contentClassName?: string;\n triggerClassName?: string;\n children?: React.ReactNode;\n}\n\nfunction mergeRefs<T>(...refs: Array<React.Ref<T> | undefined>) {\n return (node: T | null) => {\n for (const ref of refs) {\n if (typeof ref === \"function\") ref(node);\n else if (ref) ref.current = node;\n }\n };\n}\n\nconst BUBBLE_CONTENT_REVEAL = {\n duration: 0.12,\n ease: EASE_OUT,\n delay: 0.04,\n} as const;\n\n// Sent bubbles should pop into place quickly with one restrained overshoot.\nconst BUBBLE_POP = {\n type: \"spring\",\n stiffness: 520,\n damping: 27,\n mass: 0.52,\n} as const;\n\nfunction MessageBubble({\n variant = \"soft\",\n align,\n animateIn = false,\n className,\n children,\n initial,\n animate,\n exit,\n transition,\n layout,\n ...props\n}: MessageBubbleProps) {\n const reduced = useReducedMotion() ?? false;\n const messageSide = React.useContext(MessageSideContext);\n const resolvedAlign = align ?? messageSide ?? \"start\";\n\n return (\n <MessageBubbleContext.Provider\n value={{ align: resolvedAlign, animateIn, variant }}\n >\n <motion.div\n data-slot=\"message-bubble\"\n data-align={resolvedAlign}\n data-variant={variant}\n layout={layout}\n initial={initial ?? false}\n animate={animate}\n exit={\n exit ??\n (reduced ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.99 })\n }\n transition={\n transition ?? (reduced ? { duration: 0.12 } : SPRING_LAYOUT)\n }\n className={cn(\n \"group/bubble flex w-full flex-col\",\n resolvedAlign === \"end\" ? \"items-end\" : \"items-start\",\n className\n )}\n {...props}\n >\n {children}\n </motion.div>\n </MessageBubbleContext.Provider>\n );\n}\n\nfunction bubbleContentClass(\n variant: MessageBubbleVariant,\n interactive: boolean\n) {\n return cn(\n \"relative z-0 min-w-9 rounded-xl px-3.5 py-2.5 text-sm leading-6 text-foreground\",\n \"[&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-background/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-background/60 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5\",\n variant === \"solid\" && \"text-background\",\n variant === \"ghost\" && \"w-full rounded-none px-0 py-0\",\n variant === \"danger\" && \"text-destructive\",\n interactive &&\n \"cursor-pointer text-left outline-none transition-[background-color,color,transform] duration-150 hover:brightness-[0.98] focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99]\"\n );\n}\n\n/** The bubble is capped at most of the column; ghost fills it. */\nfunction bubbleContentStyle(\n variant: MessageBubbleVariant\n): React.CSSProperties {\n return variant === \"ghost\" ? { maxWidth: \"none\" } : { maxWidth: \"82%\" };\n}\n\nfunction bubbleSurfaceClass(\n variant: MessageBubbleVariant,\n align: MessageBubbleAlign\n) {\n return cn(\n \"pointer-events-none absolute inset-0 -z-10\",\n align === \"end\" ? \"origin-bottom-right\" : \"origin-bottom-left\",\n variant === \"solid\" && \"bg-foreground\",\n variant === \"soft\" && \"bg-muted\",\n variant === \"tint\" && \"bg-primary/10\",\n variant === \"outline\" && \"border border-border/70 bg-background\",\n variant === \"danger\" && \"bg-destructive/10\"\n );\n}\n\nfunction MessageBubbleContent({\n render,\n className,\n children,\n ref,\n style,\n ...props\n}: MessageBubbleContentProps) {\n const reduced = useReducedMotion() ?? false;\n const {\n align = \"start\",\n animateIn,\n variant,\n } = React.useContext(MessageBubbleContext);\n const [layoutVersion, setLayoutVersion] = React.useState(0);\n const notifyLayout = React.useCallback(\n () => setLayoutVersion((version) => version + 1),\n []\n );\n const interactive = render?.type === \"button\" || render?.type === \"a\";\n const classes = cn(bubbleContentClass(variant, interactive), className);\n const composedChildren = (\n <>\n {variant !== \"ghost\" ? (\n <motion.span\n aria-hidden=\"true\"\n layout={reduced ? false : \"size\"}\n layoutDependency={layoutVersion}\n initial={animateIn && !reduced ? { opacity: 0, scale: 0.92 } : false}\n animate={{ opacity: 1, scale: 1 }}\n transition={\n reduced\n ? { duration: 0 }\n : {\n opacity: { duration: 0.12, ease: EASE_OUT },\n scale: BUBBLE_POP,\n layout: SPRING_LAYOUT,\n }\n }\n className={bubbleSurfaceClass(variant, align)}\n style={{ borderRadius: \"inherit\" }}\n />\n ) : null}\n <MessageBubbleLayoutContext.Provider value={notifyLayout}>\n <motion.div\n initial={animateIn ? { opacity: 0 } : false}\n animate={{ opacity: 1 }}\n transition={\n reduced ? { duration: 0.12, ease: EASE_OUT } : BUBBLE_CONTENT_REVEAL\n }\n className=\"relative\"\n >\n {children}\n </motion.div>\n </MessageBubbleLayoutContext.Provider>\n </>\n );\n\n if (render) {\n // The element the caller hands in becomes the bubble — a <button>, an\n // <a> — and keeps the bubble's classes, ref and slot.\n const child = render as React.ReactElement<\n Record<string, unknown> & {\n className?: string;\n style?: React.CSSProperties;\n ref?: React.Ref<HTMLElement>;\n }\n >;\n\n return React.cloneElement(child, {\n ...props,\n ref: mergeRefs(child.props.ref, ref as React.Ref<HTMLElement> | undefined),\n className: cn(classes, child.props.className),\n style: { ...bubbleContentStyle(variant), ...style, ...child.props.style },\n children: composedChildren,\n \"data-slot\": \"message-bubble-content\",\n });\n }\n\n return (\n <div\n ref={ref}\n data-slot=\"message-bubble-content\"\n className={classes}\n style={{ ...bubbleContentStyle(variant), ...style }}\n {...props}\n >\n {composedChildren}\n </div>\n );\n}\n\nfunction MessageBubbleGroup({\n spacing = \"compact\",\n className,\n ...props\n}: MessageBubbleGroupProps) {\n return (\n <div\n data-slot=\"message-bubble-group\"\n className={cn(\n \"flex w-full flex-col\",\n spacing === \"compact\" ? \"gap-1.5\" : \"gap-3\",\n className\n )}\n {...props}\n />\n );\n}\n\nconst LINE_CLAMP_CLASS = {\n 2: \"line-clamp-2\",\n 3: \"line-clamp-3\",\n 4: \"line-clamp-4\",\n 5: \"line-clamp-5\",\n 6: \"line-clamp-6\",\n} as const;\n\nfunction MessageBubbleCollapsible({\n open,\n defaultOpen = false,\n onOpenChange,\n collapsedLines = 4,\n moreLabel = \"Show more\",\n lessLabel = \"Show less\",\n contentClassName,\n triggerClassName,\n className,\n children,\n ...props\n}: MessageBubbleCollapsibleProps) {\n const reduced = useReducedMotion() ?? false;\n const contentId = React.useId();\n const notifyLayout = React.useContext(MessageBubbleLayoutContext);\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const currentOpen = open ?? internalOpen;\n\n const setOpen = React.useCallback(\n (next: boolean) => {\n notifyLayout();\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [notifyLayout, onOpenChange, open]\n );\n\n return (\n <div\n data-slot=\"message-bubble-collapsible\"\n data-state={currentOpen ? \"open\" : \"closed\"}\n className={cn(\"w-full\", className)}\n {...props}\n >\n <div\n id={contentId}\n data-slot=\"message-bubble-collapsible-content\"\n className={cn(\n \"transition-[mask-image] duration-200\",\n !currentOpen && LINE_CLAMP_CLASS[collapsedLines],\n !currentOpen &&\n \"[mask-image:linear-gradient(to_bottom,var(--foreground)_68%,transparent_100%)]\",\n contentClassName\n )}\n >\n {children}\n </div>\n <button\n type=\"button\"\n data-slot=\"message-bubble-collapsible-trigger\"\n aria-expanded={currentOpen}\n aria-controls={contentId}\n onClick={() => setOpen(!currentOpen)}\n className={cn(\n \"mt-2 inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n triggerClassName\n )}\n >\n <span>{currentOpen ? lessLabel : moreLabel}</span>\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 </div>\n );\n}\n\nexport {\n MessageBubble,\n MessageBubbleContent,\n MessageBubbleGroup,\n MessageBubbleCollapsible,\n};\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": "components/ui/ai-message-bubble.tsx"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"type": "registry:ui"
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-message-scroller",
|
|
4
|
+
"title": "AI Message Scroller",
|
|
5
|
+
"description": "The scrolling transcript: follows streamed output while the reader stays at the live edge, lets go when they scroll up, with an optional preview rail for jumping between messages.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion",
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/ai-message-scroller/ai-message-scroller.tsx",
|
|
16
|
+
"content": "\"use client\";\n\n/*\n * The scrolling transcript: a viewport that keeps streamed output pinned\n * to the live edge while the reader stays near it, lets go the moment they\n * scroll up, and can grow a compact preview rail for jumping between\n * message rows.\n */\n\nimport * as React from \"react\";\nimport { ArrowDownIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* ----------------------------------------------------------------------------\n * Gesture hooks. A click carries no pointerType, and a finger cannot hover, so the rail needs to know which input is behind\n * each activation and when a pinned preview should let go.\n * ------------------------------------------------------------------------- */\n\ninterface BoundaryEvent {\n pointerId: number;\n pointerType: string;\n buttons: number;\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch,\n * and not currently pressed. A pen resting on the glass is making contact,\n * not hovering: `buttons` is the tell.\n */\nconst isHoveringPointer = (event: { pointerType: string; buttons: number }) =>\n event.pointerType !== \"touch\" && event.buttons === 0;\n\n/**\n * Pairs a surface's enter with its leave, per pointer. The state a hover\n * holds is released by the pointer that took it, whatever the buttons say\n * at the boundary, and a pointer that arrived in contact never took it.\n */\nfunction useHoverGesture() {\n const contact = React.useRef(new Set<number>());\n\n return React.useMemo(\n () => ({\n /** True when this enter starts a hover: the pointer arrived resting. */\n enter: (event: BoundaryEvent) => {\n if (isHoveringPointer(event)) {\n contact.current.delete(event.pointerId);\n return true;\n }\n contact.current.add(event.pointerId);\n return false;\n },\n /** True when this leave ends a hover that entered as one. */\n leave: (event: BoundaryEvent) => {\n const arrivedInContact = contact.current.delete(event.pointerId);\n return !arrivedInContact && event.pointerType !== \"touch\";\n },\n }),\n []\n );\n}\n\ninterface TapRecord<S> {\n pointerType: string;\n state: S;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot\n * report it. The record is spent by one click and dropped by everything\n * else (`pointercancel`, a keydown), because a record that outlives its\n * gesture would be read by the next keyboard-synthesised click.\n */\nfunction useTapGesture<S>() {\n const record = React.useRef<TapRecord<S> | null>(null);\n\n return React.useMemo(\n () => ({\n start: (event: { pointerType: string }, state: S) => {\n record.current = { pointerType: event.pointerType, state };\n },\n take: () => {\n const spent = record.current;\n record.current = null;\n return spent;\n },\n drop: () => {\n record.current = null;\n },\n }),\n []\n );\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. The\n * pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation. The gesture passes\n * through — the card is a preview, so the tap also lands where it aimed.\n */\nfunction useDismiss(\n open: boolean,\n onDismiss: () => void,\n ref: React.RefObject<HTMLElement | null>\n) {\n React.useEffect(() => {\n if (!open) return;\n const onKey = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") onDismiss();\n };\n const onPointer = (event: PointerEvent) => {\n const target = event.target as Element | null;\n if (!target || ref.current?.contains(target)) return;\n onDismiss();\n };\n window.addEventListener(\"keydown\", onKey);\n window.addEventListener(\"pointerdown\", onPointer, true);\n return () => {\n window.removeEventListener(\"keydown\", onKey);\n window.removeEventListener(\"pointerdown\", onPointer, true);\n };\n }, [open, onDismiss, ref]);\n}\n\n/* ----------------------------------------------------------------------------\n * PreviewRail: a column of ticks,\n * one per section, that grow towards the pointer and show a preview card\n * for the one under it.\n * ------------------------------------------------------------------------- */\n\nexport interface PreviewRailItem {\n id: string;\n label: string;\n ariaLabel?: string;\n description?: React.ReactNode;\n href?: string;\n target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n rel?: string;\n}\n\nexport interface PreviewRailProps {\n items: PreviewRailItem[];\n /** Accessible name of the navigation landmark. */\n label?: string;\n orientation?: \"vertical\" | \"horizontal\";\n activeId?: string;\n defaultActiveId?: string;\n onActiveChange?: (id: string) => void;\n onItemSelect?: (item: PreviewRailItem) => void;\n renderPreview?: (item: PreviewRailItem) => React.ReactNode;\n showPreview?: boolean;\n previewSide?: \"before\" | \"after\";\n highlightActive?: boolean;\n itemSize?: number;\n children?: React.ReactNode;\n className?: string;\n railClassName?: string;\n previewContainerClassName?: string;\n previewClassName?: string;\n}\n\nfunction DefaultPreview({ item }: { item: PreviewRailItem }) {\n return (\n <div\n data-slot=\"preview-rail-card\"\n className=\"rounded-xl border border-border bg-card p-4 shadow-sm\"\n >\n <p\n data-slot=\"preview-rail-title\"\n className=\"font-medium text-card-foreground\"\n >\n {item.label}\n </p>\n {item.description ? (\n <div\n data-slot=\"preview-rail-description\"\n className=\"mt-1 text-sm leading-6 text-muted-foreground\"\n >\n {item.description}\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction PreviewRail({\n items,\n label = \"Section navigation\",\n orientation = \"vertical\",\n activeId,\n defaultActiveId,\n onActiveChange,\n onItemSelect,\n renderPreview,\n showPreview = true,\n previewSide = \"after\",\n highlightActive = false,\n itemSize = 24,\n children,\n className,\n railClassName,\n previewContainerClassName,\n previewClassName,\n}: PreviewRailProps) {\n const uid = React.useId();\n const reduced = useReducedMotion() ?? false;\n const rootRef = React.useRef<HTMLDivElement>(null);\n const [internalActiveId, setInternalActiveId] = React.useState(\n defaultActiveId ?? items[0]?.id ?? \"\"\n );\n const [hoveredId, setHoveredId] = React.useState<string | null>(null);\n // A finger cannot hover, so a tap lights the tick instead. Kept apart from\n // the hovered one: they end in different ways, and a stray mouse move must\n // not clear a tick the keyboard or a tap chose.\n const [pinnedId, setPinnedId] = React.useState<string | null>(null);\n const [focusedId, setFocusedId] = React.useState<string | null>(null);\n // A click carries no pointerType, so the pointerdown before it is what says\n // whether the activation was a tap. Keyboard activation has none at all.\n const tap = useTapGesture<boolean>();\n const hover = useHoverGesture();\n\n const clearPinned = React.useCallback(() => setPinnedId(null), []);\n\n // The next tap outside the rail stands in for the pointer leaving it.\n useDismiss(pinnedId !== null, clearPinned, rootRef);\n\n const requestedActiveId = activeId ?? internalActiveId;\n const selectedId = items.some((item) => item.id === requestedActiveId)\n ? requestedActiveId\n : (items[0]?.id ?? \"\");\n const displayedId = hoveredId ?? pinnedId ?? focusedId ?? \"\";\n const highlightedId = displayedId || (highlightActive ? selectedId : \"\");\n const displayedIndex = items.findIndex((item) => item.id === highlightedId);\n const rowTemplate = items.length\n ? `repeat(${items.length}, ${itemSize}px)`\n : undefined;\n const isHorizontal = orientation === \"horizontal\";\n\n const selectItem = (id: string) => {\n if (activeId === undefined) setInternalActiveId(id);\n onActiveChange?.(id);\n };\n\n return (\n <motion.div\n layoutRoot\n ref={rootRef}\n data-slot=\"preview-rail\"\n onBlur={(event) => {\n // Both tick sources leave with the focus: a tap does not always land\n // focus, but when it does, tabbing away must not strand the card.\n if (!event.currentTarget.contains(event.relatedTarget)) {\n setFocusedId(null);\n setPinnedId(null);\n }\n }}\n className={cn(\n \"isolate relative flex w-full overflow-visible\",\n isHorizontal\n ? \"min-h-64 flex-col items-center justify-center\"\n : \"min-h-80\",\n className\n )}\n >\n <nav\n data-slot=\"preview-rail-nav\"\n aria-label={label}\n onPointerLeave={(event) => {\n // A touch pointer leaves on lift, which would clear the tick the tap\n // just chose — that one is cleared by the outside tap instead.\n if (hover.leave(event)) setHoveredId(null);\n }}\n style={\n isHorizontal\n ? { gridTemplateColumns: rowTemplate }\n : { gridTemplateRows: rowTemplate }\n }\n className={cn(\n \"relative z-10 grid shrink-0\",\n isHorizontal\n ? \"h-12 w-fit max-w-full self-center justify-center\"\n : \"w-12 content-center\",\n railClassName\n )}\n >\n {items.map((item, index) => {\n const selected = item.id === selectedId;\n const highlighted = item.id === highlightedId;\n const distance =\n displayedIndex < 0\n ? Number.POSITIVE_INFINITY\n : Math.abs(index - displayedIndex);\n const scale = highlighted\n ? 1\n : distance === 1\n ? 0.68\n : distance === 2\n ? 0.44\n : 0.25;\n\n const itemContent = (\n <motion.span\n data-slot=\"preview-rail-tick\"\n aria-hidden=\"true\"\n animate={isHorizontal ? { scaleY: scale } : { scaleX: scale }}\n transition={reduced ? { duration: 0 } : SPRING_LAYOUT}\n className={cn(\n \"block bg-current\",\n isHorizontal\n ? \"h-12 w-0.5 origin-bottom\"\n : \"h-0.5 w-12 origin-left\",\n highlighted ? \"text-foreground\" : undefined\n )}\n />\n );\n\n const sharedClassName = cn(\n \"relative flex text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n isHorizontal\n ? \"h-12 w-6 items-end justify-center\"\n : \"h-6 w-12 items-center\"\n );\n const sharedStyle = isHorizontal\n ? { width: itemSize }\n : { height: itemSize };\n const handlePointerEnter = (\n event: React.PointerEvent<HTMLElement>\n ) => {\n if (hover.enter(event)) setHoveredId(item.id);\n };\n const handlePointerDown = (\n event: React.PointerEvent<HTMLElement>\n ) => {\n tap.start(event, pinnedId === item.id);\n setFocusedId(null);\n };\n // A gesture the platform takes away sends no click, and a key press\n // starts an activation that never had a pointer behind it: either\n // one leaves a record the next click would read as a tap of its own.\n const dropGesture = () => tap.drop();\n const handleFocus = (currentTarget: HTMLElement) => {\n if (currentTarget.matches(\":focus-visible\")) {\n setFocusedId(item.id);\n }\n };\n const handleSelect = (event: React.MouseEvent<HTMLElement>) => {\n const gesture = tap.take();\n const tapped = gesture !== null && gesture.pointerType !== \"mouse\";\n\n if (tapped) {\n // A link would otherwise show its preview and leave the page in\n // the same tap, so the card is never read: the first tap lights\n // the tick, the second follows the link.\n if (item.href && !gesture.state) {\n event.preventDefault();\n setPinnedId(item.id);\n return;\n }\n setPinnedId(item.id);\n }\n\n selectItem(item.id);\n onItemSelect?.(item);\n };\n\n return item.href ? (\n <a\n key={item.id}\n data-slot=\"preview-rail-item\"\n href={item.href}\n target={item.target}\n rel={\n item.rel ??\n (item.target === \"_blank\" ? \"noreferrer noopener\" : undefined)\n }\n aria-label={item.ariaLabel ?? item.label}\n aria-current={selected ? \"page\" : undefined}\n onPointerEnter={handlePointerEnter}\n onPointerDown={handlePointerDown}\n onPointerCancel={dropGesture}\n onKeyDown={dropGesture}\n onFocus={(event) => handleFocus(event.currentTarget)}\n onClick={handleSelect}\n style={sharedStyle}\n className={sharedClassName}\n >\n {itemContent}\n </a>\n ) : (\n <button\n key={item.id}\n data-slot=\"preview-rail-item\"\n type=\"button\"\n aria-label={item.ariaLabel ?? item.label}\n aria-current={selected ? \"location\" : undefined}\n onPointerEnter={handlePointerEnter}\n onPointerDown={handlePointerDown}\n onPointerCancel={dropGesture}\n onKeyDown={dropGesture}\n onFocus={(event) => handleFocus(event.currentTarget)}\n onClick={handleSelect}\n style={sharedStyle}\n className={sharedClassName}\n >\n {itemContent}\n </button>\n );\n })}\n </nav>\n\n {showPreview ? (\n <div\n data-slot=\"preview-rail-previews\"\n aria-hidden=\"true\"\n style={\n isHorizontal\n ? { gridTemplateColumns: rowTemplate }\n : { gridTemplateRows: rowTemplate }\n }\n className={cn(\n \"pointer-events-none absolute z-50 grid\",\n isHorizontal\n ? \"top-1/2 left-1/2 h-5 w-fit max-w-full -translate-x-1/2 -translate-y-1/2 justify-center\"\n : previewSide === \"before\"\n ? \"inset-y-0 right-16 left-4 content-center\"\n : \"inset-y-0 right-4 left-16 content-center\",\n previewContainerClassName\n )}\n >\n {items.map((item) => (\n <div\n key={item.id}\n style={\n isHorizontal ? { width: itemSize } : { height: itemSize }\n }\n className={cn(\n \"relative flex items-center\",\n isHorizontal ? \"justify-center\" : undefined\n )}\n >\n {item.id === displayedId ? (\n <div\n className={cn(\n isHorizontal\n ? \"absolute bottom-12 left-1/2 w-72 -translate-x-1/2\"\n : cn(\n \"w-full max-w-sm\",\n previewSide === \"before\" && \"ml-auto\"\n ),\n previewClassName\n )}\n >\n <motion.div\n layoutId={`preview-rail-card-${uid}`}\n transition={reduced ? { duration: 0 } : SPRING_LAYOUT}\n >\n <AnimatePresence mode=\"wait\" initial={false}>\n <motion.div\n key={item.id}\n initial={\n reduced\n ? { opacity: 0 }\n : { opacity: 0, y: 4, filter: \"blur(6px)\" }\n }\n animate={\n reduced\n ? { opacity: 1 }\n : { opacity: 1, y: 0, filter: \"blur(0px)\" }\n }\n exit={\n reduced\n ? { opacity: 0 }\n : {\n opacity: 0,\n y: -2,\n filter: \"blur(4px)\",\n transition: { duration: 0.12, ease: EASE_OUT },\n }\n }\n transition={{\n duration: reduced ? 0 : 0.18,\n ease: EASE_OUT,\n }}\n >\n {renderPreview ? (\n renderPreview(item)\n ) : (\n <DefaultPreview item={item} />\n )}\n </motion.div>\n </AnimatePresence>\n </motion.div>\n </div>\n ) : null}\n </div>\n ))}\n </div>\n ) : null}\n\n {children ? (\n <div className=\"min-h-0 min-w-0 flex-1\">{children}</div>\n ) : null}\n </motion.div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * MessageScroller\n * ------------------------------------------------------------------------- */\n\nconst PREVIEW_TITLE_LENGTH = 56;\nconst PREVIEW_DESCRIPTION_LENGTH = 88;\n\nfunction truncateMessageText(text: string, limit: number) {\n if (text.length <= limit) return text;\n const excerpt = text.slice(0, limit);\n const boundary = excerpt.lastIndexOf(\" \");\n return `${excerpt.slice(0, boundary > limit * 0.65 ? boundary : limit).trim()}…`;\n}\n\nfunction getMessageText(message: HTMLElement) {\n const surface =\n message.querySelector<HTMLElement>('[data-slot=\"message-bubble-content\"]') ??\n message.querySelector<HTMLElement>('[data-slot=\"message-content\"]') ??\n message;\n return (surface.textContent ?? \"\").replace(/\\s+/g, \" \").trim();\n}\n\nfunction getMessagePreview(\n message: HTMLElement,\n emptyLabel: string,\n assistantResponse?: HTMLElement\n) {\n const text = getMessageText(message);\n if (!text) {\n return { label: emptyLabel, description: undefined };\n }\n\n if (text.length <= PREVIEW_TITLE_LENGTH) {\n const responseText = assistantResponse\n ? getMessageText(assistantResponse)\n : \"\";\n return {\n label: text,\n description: responseText\n ? truncateMessageText(responseText, PREVIEW_DESCRIPTION_LENGTH)\n : undefined,\n };\n }\n\n const titleExcerpt = text.slice(0, PREVIEW_TITLE_LENGTH);\n const titleBoundary = titleExcerpt.lastIndexOf(\" \");\n const titleEnd =\n titleBoundary > PREVIEW_TITLE_LENGTH * 0.65\n ? titleBoundary\n : PREVIEW_TITLE_LENGTH;\n const label = `${text.slice(0, titleEnd).trim()}…`;\n const responseText = assistantResponse\n ? getMessageText(assistantResponse)\n : text.slice(titleEnd).trim();\n return {\n label,\n description: responseText\n ? truncateMessageText(responseText, PREVIEW_DESCRIPTION_LENGTH)\n : undefined,\n };\n}\n\nexport interface MessageScrollerProps extends React.ComponentProps<\"div\"> {\n /** Keep streamed output pinned while the reader remains near the end. */\n followOutput?: boolean;\n /** Distance from the end that still counts as following the output. */\n followThreshold?: number;\n /** Smoothly follow growing content. */\n smooth?: boolean;\n /** Reports when the reader leaves or returns to the live edge. */\n onFollowChange?: (following: boolean) => void;\n /** Accessible label for the scrollable transcript. */\n label?: string;\n /** Marks the transcript as waiting for more streamed content. */\n busy?: boolean;\n /** Adds a compact rail for navigating between rendered Message rows. */\n navigation?: \"rail\";\n /** Accessible label for the optional message navigation rail. */\n navigationLabel?: string;\n /** Accessible label of one rail tick; `sender` is the row's `data-from`. */\n navigationItemLabel?: (\n sender: string,\n index: number,\n total: number\n ) => string;\n /** Preview title for a row that has no readable text. */\n emptyPreviewLabel?: string;\n /**\n * Accessible name of the control that brings a reader who scrolled\n * up back to the live edge. Unset hides the control.\n */\n scrollToEndLabel?: string;\n /**\n * A value that changes when the reader does something that should\n * bring them back to the end — the id of their latest message. A\n * change re-engages following and scrolls down, even if they had\n * scrolled away.\n */\n anchor?: string | number;\n viewportClassName?: string;\n contentClassName?: string;\n railClassName?: string;\n viewportRef?: React.Ref<HTMLElement>;\n viewportProps?: Omit<\n React.ComponentProps<\"section\">,\n \"children\" | \"className\" | \"ref\"\n >;\n contentProps?: Omit<\n React.ComponentProps<\"div\">,\n \"children\" | \"className\" | \"ref\"\n >;\n}\n\nconst defaultNavigationItemLabel = (\n sender: string,\n index: number,\n total: number\n) => `Go to ${sender} message ${index + 1} of ${total}`;\n\nfunction MessageScroller({\n followOutput = true,\n followThreshold = 56,\n smooth = true,\n onFollowChange,\n label = \"Conversation\",\n busy,\n navigation,\n navigationLabel = \"Message navigation\",\n navigationItemLabel = defaultNavigationItemLabel,\n emptyPreviewLabel = \"Message\",\n scrollToEndLabel = \"Scroll to the latest message\",\n anchor,\n viewportClassName,\n contentClassName,\n railClassName,\n viewportRef: externalViewportRef,\n viewportProps,\n contentProps,\n className,\n children,\n ...props\n}: MessageScrollerProps) {\n const reduced = useReducedMotion() ?? false;\n const viewportRef = React.useRef<HTMLElement | null>(null);\n const contentRef = React.useRef<HTMLDivElement>(null);\n const followingRef = React.useRef(followOutput);\n const programmaticScrollRef = React.useRef(false);\n const scrollTimerRef = React.useRef<number | undefined>(undefined);\n const frameRef = React.useRef<number | undefined>(undefined);\n const railFrameRef = React.useRef<number | undefined>(undefined);\n const railIdRef = React.useRef(new WeakMap<HTMLElement, string>());\n const railIdCounterRef = React.useRef(0);\n const railTargetsRef = React.useRef(new Map<string, HTMLElement>());\n const [railItems, setRailItems] = React.useState<PreviewRailItem[]>([]);\n const [activeRailId, setActiveRailId] = React.useState(\"\");\n const [railOverflowing, setRailOverflowing] = React.useState(false);\n const {\n onScroll: onViewportScroll,\n onWheel: onViewportWheel,\n onTouchStart: onViewportTouchStart,\n onPointerDown: onViewportPointerDown,\n onKeyDown: onViewportKeyDown,\n ...restViewportProps\n } = viewportProps ?? {};\n\n const setViewportRef = React.useCallback(\n (node: HTMLElement | null) => {\n viewportRef.current = node;\n if (typeof externalViewportRef === \"function\") {\n externalViewportRef(node);\n } else if (externalViewportRef) {\n externalViewportRef.current = node;\n }\n },\n [externalViewportRef]\n );\n\n // Mirrors `followingRef` for rendering: the scroll-to-latest control\n // shows only while the reader is away from the live edge.\n const [atEnd, setAtEnd] = React.useState(followOutput);\n\n const setFollowing = React.useCallback(\n (next: boolean) => {\n if (followingRef.current === next) return;\n followingRef.current = next;\n setAtEnd(next);\n onFollowChange?.(next);\n },\n [onFollowChange]\n );\n\n const updateActiveRailItem = React.useCallback(() => {\n if (navigation !== \"rail\") return;\n const viewport = viewportRef.current;\n const targets = [...railTargetsRef.current.entries()];\n if (!viewport || targets.length === 0) return;\n\n const viewportRect = viewport.getBoundingClientRect();\n if (viewport.scrollTop <= followThreshold) {\n const firstId = targets[0]?.[0] ?? \"\";\n setActiveRailId((current) => (current === firstId ? current : firstId));\n return;\n }\n\n const distanceFromEnd =\n viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;\n if (distanceFromEnd <= followThreshold) {\n const lastId = targets.at(-1)?.[0] ?? \"\";\n setActiveRailId((current) => (current === lastId ? current : lastId));\n return;\n }\n\n const viewportCenter = viewportRect.top + viewportRect.height / 2;\n let nearestId = targets[0]?.[0] ?? \"\";\n let nearestDistance = Number.POSITIVE_INFINITY;\n\n for (const [id, element] of targets) {\n const rect = element.getBoundingClientRect();\n const messageCenter = rect.top + rect.height / 2;\n const distance = Math.abs(messageCenter - viewportCenter);\n if (distance < nearestDistance) {\n nearestDistance = distance;\n nearestId = id;\n }\n }\n\n setActiveRailId((current) =>\n current === nearestId ? current : nearestId\n );\n }, [followThreshold, navigation]);\n\n const syncRailItems = React.useCallback(() => {\n if (navigation !== \"rail\") return;\n const content = contentRef.current;\n const viewport = viewportRef.current;\n if (!content || !viewport) return;\n\n const messages = Array.from(\n content.querySelectorAll<HTMLElement>('[data-slot=\"message\"]')\n );\n const targets = new Map<string, HTMLElement>();\n const nextItems = messages.map((message, index) => {\n let id = railIdRef.current.get(message);\n if (!id) {\n railIdCounterRef.current += 1;\n id = `message-rail-${railIdCounterRef.current}`;\n railIdRef.current.set(message, id);\n }\n targets.set(id, message);\n const sender = message.dataset.from ?? \"conversation\";\n const assistantResponse =\n sender === \"user\"\n ? messages\n .slice(index + 1)\n .find((candidate) => candidate.dataset.from === \"assistant\")\n : undefined;\n const preview = getMessagePreview(\n message,\n emptyPreviewLabel,\n assistantResponse\n );\n\n return {\n id,\n label: preview.label,\n description: preview.description,\n ariaLabel: navigationItemLabel(sender, index, messages.length),\n };\n });\n\n railTargetsRef.current = targets;\n setRailItems((current) => {\n const unchanged =\n current.length === nextItems.length &&\n current.every(\n (item, index) =>\n item.id === nextItems[index]?.id &&\n item.label === nextItems[index]?.label &&\n item.description === nextItems[index]?.description &&\n item.ariaLabel === nextItems[index]?.ariaLabel\n );\n return unchanged ? current : nextItems;\n });\n setRailOverflowing(\n viewport.scrollHeight > viewport.clientHeight + 1 && messages.length > 1\n );\n }, [emptyPreviewLabel, navigation, navigationItemLabel]);\n\n const scheduleRailSync = React.useCallback(() => {\n if (navigation !== \"rail\") return;\n if (railFrameRef.current) cancelAnimationFrame(railFrameRef.current);\n railFrameRef.current = requestAnimationFrame(() => {\n syncRailItems();\n updateActiveRailItem();\n });\n }, [navigation, syncRailItems, updateActiveRailItem]);\n\n const scrollToEnd = React.useCallback((behavior: ScrollBehavior) => {\n const viewport = viewportRef.current;\n if (!viewport) return;\n\n programmaticScrollRef.current = true;\n if (typeof viewport.scrollTo === \"function\") {\n viewport.scrollTo({ top: viewport.scrollHeight, behavior });\n } else {\n viewport.scrollTop = viewport.scrollHeight;\n }\n if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);\n scrollTimerRef.current = window.setTimeout(\n () => {\n programmaticScrollRef.current = false;\n },\n behavior === \"smooth\" ? 320 : 0\n );\n }, []);\n\n // Following is decided by direction, not by distance alone: a smooth\n // scroll down a long transcript outlives any fixed \"programmatic\"\n // window, and content growing under the viewport widens the gap\n // without the reader doing anything. Only a move up that the reader\n // made lets go; reaching the end, by any means, takes hold again.\n const lastScrollTopRef = React.useRef(0);\n const handleScroll = React.useCallback(() => {\n const viewport = viewportRef.current;\n if (!viewport) return;\n\n const top = viewport.scrollTop;\n const movedUp = top < lastScrollTopRef.current - 1;\n lastScrollTopRef.current = top;\n const distance = viewport.scrollHeight - top - viewport.clientHeight;\n if (distance <= followThreshold) setFollowing(true);\n else if (movedUp && !programmaticScrollRef.current) setFollowing(false);\n updateActiveRailItem();\n }, [followThreshold, setFollowing, updateActiveRailItem]);\n\n const leaveLiveEdge = React.useCallback(() => {\n programmaticScrollRef.current = false;\n }, []);\n\n React.useLayoutEffect(() => {\n followingRef.current = followOutput;\n if (!followOutput) return;\n\n frameRef.current = requestAnimationFrame(() => scrollToEnd(\"auto\"));\n return () => {\n if (frameRef.current) cancelAnimationFrame(frameRef.current);\n };\n }, [followOutput, scrollToEnd]);\n\n React.useEffect(() => {\n const content = contentRef.current;\n if (!content || typeof ResizeObserver === \"undefined\") return;\n\n const observer = new ResizeObserver(() => {\n scheduleRailSync();\n if (!followOutput || !followingRef.current) return;\n scrollToEnd(reduced || !smooth ? \"auto\" : \"smooth\");\n });\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [followOutput, reduced, scheduleRailSync, scrollToEnd, smooth]);\n\n React.useEffect(() => {\n if (navigation !== \"rail\") {\n railTargetsRef.current.clear();\n setRailItems([]);\n setRailOverflowing(false);\n return;\n }\n\n const content = contentRef.current;\n const viewport = viewportRef.current;\n if (!content || !viewport) return;\n\n scheduleRailSync();\n const mutationObserver =\n typeof MutationObserver === \"undefined\"\n ? null\n : new MutationObserver(scheduleRailSync);\n mutationObserver?.observe(content, {\n childList: true,\n characterData: true,\n subtree: true,\n });\n\n const resizeObserver =\n typeof ResizeObserver === \"undefined\"\n ? null\n : new ResizeObserver(scheduleRailSync);\n resizeObserver?.observe(content);\n resizeObserver?.observe(viewport);\n\n return () => {\n mutationObserver?.disconnect();\n resizeObserver?.disconnect();\n };\n }, [navigation, scheduleRailSync]);\n\n React.useEffect(\n () => () => {\n if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);\n if (frameRef.current) cancelAnimationFrame(frameRef.current);\n if (railFrameRef.current) cancelAnimationFrame(railFrameRef.current);\n },\n []\n );\n\n const scrollToRailItem = React.useCallback(\n (item: PreviewRailItem) => {\n const viewport = viewportRef.current;\n const target = railTargetsRef.current.get(item.id);\n if (!viewport || !target) return;\n\n const lastItem = railItems.at(-1)?.id === item.id;\n setActiveRailId(item.id);\n if (lastItem) {\n setFollowing(true);\n scrollToEnd(reduced || !smooth ? \"auto\" : \"smooth\");\n return;\n }\n\n setFollowing(false);\n programmaticScrollRef.current = true;\n const viewportRect = viewport.getBoundingClientRect();\n const targetRect = target.getBoundingClientRect();\n const top =\n viewport.scrollTop +\n targetRect.top -\n viewportRect.top -\n (viewport.clientHeight - targetRect.height) / 2;\n const behavior: ScrollBehavior = reduced || !smooth ? \"auto\" : \"smooth\";\n\n if (typeof viewport.scrollTo === \"function\") {\n viewport.scrollTo({ top, behavior });\n } else {\n viewport.scrollTop = top;\n }\n if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);\n scrollTimerRef.current = window.setTimeout(\n () => {\n programmaticScrollRef.current = false;\n },\n behavior === \"smooth\" ? 320 : 0\n );\n },\n [railItems, reduced, scrollToEnd, setFollowing, smooth]\n );\n\n const anchorRef = React.useRef(anchor);\n React.useEffect(() => {\n if (anchorRef.current === anchor) return;\n anchorRef.current = anchor;\n setFollowing(true);\n scrollToEnd(reduced || !smooth ? \"auto\" : \"smooth\");\n }, [anchor, reduced, scrollToEnd, setFollowing, smooth]);\n\n const returnToEnd = React.useCallback(() => {\n setFollowing(true);\n scrollToEnd(reduced || !smooth ? \"auto\" : \"smooth\");\n }, [reduced, scrollToEnd, setFollowing, smooth]);\n\n const viewport = (\n <section\n ref={setViewportRef}\n data-slot=\"message-scroller-viewport\"\n aria-label={label}\n {...restViewportProps}\n onScroll={(event) => {\n handleScroll();\n onViewportScroll?.(event);\n }}\n onWheel={(event) => {\n leaveLiveEdge();\n onViewportWheel?.(event);\n }}\n onTouchStart={(event) => {\n leaveLiveEdge();\n onViewportTouchStart?.(event);\n }}\n onPointerDown={(event) => {\n // A drag on the scrollbar is the reader's scroll too.\n leaveLiveEdge();\n onViewportPointerDown?.(event);\n }}\n onKeyDown={(event) => {\n if ([\"ArrowUp\", \"PageUp\", \"Home\"].includes(event.key)) {\n leaveLiveEdge();\n }\n onViewportKeyDown?.(event);\n }}\n className={cn(\n \"h-full overflow-y-auto overscroll-contain outline-none [overflow-anchor:none] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n navigation === \"rail\"\n ? \"[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n : \"[scrollbar-gutter:stable]\",\n viewportClassName,\n navigation === \"rail\" && railOverflowing && \"pr-10\"\n )}\n >\n <div\n ref={contentRef}\n data-slot=\"message-scroller-content\"\n role=\"log\"\n aria-live=\"polite\"\n aria-relevant=\"additions text\"\n aria-busy={busy}\n className={contentClassName}\n {...contentProps}\n >\n {children}\n </div>\n </section>\n );\n\n return (\n <div\n data-slot=\"message-scroller\"\n className={cn(\"relative min-h-0\", className)}\n {...props}\n >\n {navigation === \"rail\" ? (\n <PreviewRail\n items={railOverflowing ? railItems : []}\n label={navigationLabel}\n activeId={activeRailId}\n onItemSelect={scrollToRailItem}\n previewSide=\"before\"\n highlightActive\n itemSize={14}\n className=\"h-full min-h-0 overflow-hidden\"\n previewContainerClassName=\"right-8 left-3\"\n previewClassName=\"mr-1 w-64 max-w-full [&_[data-slot=preview-rail-card]]:h-20 [&_[data-slot=preview-rail-card]]:overflow-hidden [&_[data-slot=preview-rail-card]]:p-3 [&_[data-slot=preview-rail-title]]:line-clamp-1 [&_[data-slot=preview-rail-title]]:text-xs [&_[data-slot=preview-rail-title]]:leading-4 [&_[data-slot=preview-rail-description]]:line-clamp-2 [&_[data-slot=preview-rail-description]]:text-xs [&_[data-slot=preview-rail-description]]:leading-4\"\n railClassName={cn(\n \"absolute inset-y-3 right-1 w-7 content-center py-1 [&_[data-slot=preview-rail-item]]:w-7 [&_[data-slot=preview-rail-item]]:justify-end [&_[data-slot=preview-rail-tick]]:h-px [&_[data-slot=preview-rail-tick]]:w-4 [&_[data-slot=preview-rail-tick]]:origin-right\",\n railOverflowing\n ? \"pointer-events-auto opacity-100\"\n : \"pointer-events-none opacity-0\",\n railClassName\n )}\n >\n {viewport}\n </PreviewRail>\n ) : (\n viewport\n )}\n <AnimatePresence>\n {scrollToEndLabel && !atEnd ? (\n <motion.button\n key=\"scroll-to-end\"\n type=\"button\"\n data-slot=\"message-scroller-button\"\n aria-label={scrollToEndLabel}\n title={scrollToEndLabel}\n onClick={returnToEnd}\n initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.9 }}\n animate={{ opacity: 1, y: 0, scale: 1 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.9 }}\n transition={reduced ? { duration: 0.12 } : SPRING_LAYOUT}\n className=\"absolute bottom-3 left-1/2 z-20 grid size-9 -translate-x-1/2 place-items-center rounded-full border bg-background text-muted-foreground shadow-md outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <ArrowDownIcon className=\"size-4\" />\n </motion.button>\n ) : null}\n </AnimatePresence>\n </div>\n );\n}\n\nexport { MessageScroller, PreviewRail };\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/ai-message-scroller.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-message",
|
|
4
|
+
"title": "AI Message",
|
|
5
|
+
"description": "One turn of a conversation: the row with its side, optional avatar, content column, header, footer, a centred marker and the typing indicator.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"@intelligo/ai-motion"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "base/ui/ai-message/ai-message.tsx",
|
|
15
|
+
"content": "\"use client\";\n\n/*\n * One turn of a conversation: the row, its optional avatar, the content\n * column, header, footer, a centred marker and the typing indicator. The\n * bubble (`ai-message-bubble`) reads which side the row sits on through\n * `MessageSideContext`.\n */\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion, type HTMLMotionProps } from \"motion/react\";\n\nimport { EASE_OUT } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MessageFrom = \"user\" | \"assistant\";\nexport type MessageSide = \"start\" | \"end\";\n\n/** Which edge the surrounding message sits on; read by the bubble. */\nconst MessageSideContext = React.createContext<MessageSide | undefined>(\n undefined\n);\n\ninterface MessageContextValue {\n from: MessageFrom;\n}\n\nconst MessageContext = React.createContext<MessageContextValue>({\n from: \"assistant\",\n});\n\nexport interface MessageProps\n extends Omit<HTMLMotionProps<\"article\">, \"children\"> {\n from: MessageFrom;\n /** Plays a trailing-edge pop-up once when this message row mounts. */\n animateIn?: boolean;\n children: React.ReactNode;\n}\n\nexport interface MessageGroupProps extends React.ComponentProps<\"div\"> {\n spacing?: \"compact\" | \"default\";\n}\n\nexport interface MessageAvatarProps extends React.ComponentProps<\"div\"> {\n /** Keep an empty avatar slot so grouped messages remain aligned. */\n placeholder?: boolean;\n}\n\nexport type MessageContentProps = React.ComponentProps<\"div\">;\nexport type MessageHeaderProps = React.ComponentProps<\"div\">;\nexport type MessageFooterProps = React.ComponentProps<\"div\">;\nexport type MessageMarkerProps = React.ComponentProps<\"div\">;\n\nexport interface MessageTypingProps extends React.ComponentProps<\"span\"> {\n /** What assistive tech reads while the dots bounce. */\n label?: string;\n}\n\n// A sent row should rise from the live edge without changing measured layout.\nconst MESSAGE_POP_UP = {\n type: \"spring\",\n stiffness: 480,\n damping: 32,\n mass: 0.62,\n} as const;\n\nfunction Message({\n from,\n animateIn = false,\n children,\n className,\n initial,\n animate,\n transition,\n exit,\n style,\n ...props\n}: MessageProps) {\n const reduced = useReducedMotion() ?? false;\n\n return (\n <MessageSideContext.Provider value={from === \"user\" ? \"end\" : \"start\"}>\n <MessageContext.Provider value={{ from }}>\n <motion.article\n data-slot=\"message\"\n data-from={from}\n aria-label={props[\"aria-label\"] ?? `${from} message`}\n initial={\n initial ??\n (animateIn && !reduced\n ? { opacity: 0, transform: \"translateY(8px) scale(0.95)\" }\n : false)\n }\n animate={\n animate ??\n (animateIn && !reduced\n ? { opacity: 1, transform: \"translateY(0px) scale(1)\" }\n : { opacity: 1 })\n }\n exit={\n exit ??\n (reduced\n ? { opacity: 0 }\n : { opacity: 0, transform: \"translateY(-3px) scale(0.99)\" })\n }\n transition={\n transition ?? (reduced ? { duration: 0.12 } : MESSAGE_POP_UP)\n }\n style={{\n transformOrigin: from === \"user\" ? \"100% 100%\" : \"0% 100%\",\n ...style,\n }}\n className={cn(\n \"group/message flex w-full items-start gap-2\",\n from === \"user\" ? \"flex-row-reverse\" : \"flex-row\",\n className\n )}\n {...props}\n >\n {children}\n </motion.article>\n </MessageContext.Provider>\n </MessageSideContext.Provider>\n );\n}\n\nfunction MessageGroup({\n spacing = \"compact\",\n className,\n ...props\n}: MessageGroupProps) {\n return (\n <div\n data-slot=\"message-group\"\n className={cn(\n \"flex w-full flex-col\",\n spacing === \"compact\" ? \"gap-1.5\" : \"gap-4\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction MessageAvatar({\n placeholder = false,\n children,\n className,\n ...props\n}: MessageAvatarProps) {\n return (\n <div\n data-slot=\"message-avatar\"\n aria-hidden={placeholder || undefined}\n className={cn(\n \"grid size-7 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-xs font-medium text-muted-foreground [&_img]:size-full [&_img]:object-cover [&_svg]:size-3.5\",\n placeholder && \"invisible\",\n className\n )}\n {...props}\n >\n {children}\n </div>\n );\n}\n\nfunction MessageContent({ className, ...props }: MessageContentProps) {\n const { from } = React.useContext(MessageContext);\n\n return (\n <div\n data-slot=\"message-content\"\n className={cn(\n \"flex min-w-0 flex-1 flex-col gap-1.5\",\n from === \"user\" ? \"items-end\" : \"items-start\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction MessageHeader({ className, ...props }: MessageHeaderProps) {\n const { from } = React.useContext(MessageContext);\n\n return (\n <div\n data-slot=\"message-header\"\n className={cn(\n \"flex items-center gap-1.5 px-1 text-xs leading-none text-muted-foreground\",\n from === \"user\" ? \"justify-end\" : \"justify-start\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction MessageFooter({ className, ...props }: MessageFooterProps) {\n const { from } = React.useContext(MessageContext);\n\n return (\n <div\n data-slot=\"message-footer\"\n className={cn(\n \"flex min-h-5 items-center gap-1 px-1 text-xs text-muted-foreground\",\n from === \"user\" ? \"justify-end\" : \"justify-start\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction MessageMarker({ className, style, ...props }: MessageMarkerProps) {\n return (\n <div\n data-slot=\"message-marker\"\n className={cn(\n \"mx-auto flex w-fit items-center gap-1.5 rounded-full bg-muted/70 px-2.5 py-1 text-center text-xs text-muted-foreground\",\n className\n )}\n style={{ maxWidth: \"88%\", ...style }}\n {...props}\n />\n );\n}\n\nfunction MessageTyping({\n label = \"Responding\",\n className,\n ...props\n}: MessageTypingProps) {\n const reduced = useReducedMotion() ?? false;\n\n return (\n <span\n data-slot=\"message-typing\"\n className={cn(\"inline-flex h-5 items-center gap-1\", className)}\n {...props}\n >\n <span className=\"sr-only\">{label}</span>\n {[0, 1, 2].map((index) => (\n <motion.span\n key={index}\n aria-hidden=\"true\"\n className=\"size-1 rounded-full bg-current\"\n animate={\n reduced\n ? { opacity: 0.45 }\n : { opacity: [0.28, 0.85, 0.28], y: [0, -2, 0] }\n }\n transition={{\n duration: 1.05,\n ease: EASE_OUT,\n repeat: Number.POSITIVE_INFINITY,\n delay: index * 0.14,\n }}\n />\n ))}\n </span>\n );\n}\n\nexport {\n Message,\n MessageGroup,\n MessageAvatar,\n MessageContent,\n MessageHeader,\n MessageFooter,\n MessageMarker,\n MessageTyping,\n MessageSideContext,\n};\n",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"target": "components/ui/ai-message.tsx"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"type": "registry:ui"
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-motion",
|
|
4
|
+
"title": "AI Motion",
|
|
5
|
+
"description": "The motion vocabulary every tier shares: easing curves, spring presets, a transform-only disclosure, a text swap, the popup and backdrop presets the primitives open with, the button press and list staggers, all gated on reduced motion.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "base/ui/ai-motion/ai-motion.tsx",
|
|
13
|
+
"content": "\"use client\";\n\n/*\n * The motion vocabulary the components share: easing curves, spring\n * presets, a transform-only disclosure, a text swap, the popup and backdrop\n * presets the primitives open with, the button's press, and list staggers.\n * Every animation here is gated on `prefers-reduced-motion`.\n */\n\nimport * as React from \"react\";\nimport {\n AnimatePresence,\n motion,\n useReducedMotion,\n type HTMLMotionProps,\n type Variants,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ----------------------------------------------------------------------------\n * Easing and springs. Strong custom curves — the CSS defaults feel weak.\n * ------------------------------------------------------------------------- */\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n type: \"spring\",\n stiffness: 500,\n damping: 30,\n mass: 0.6,\n} as const;\n/** Content swaps — label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n type: \"spring\",\n stiffness: 460,\n damping: 30,\n mass: 0.55,\n} as const;\n/** Overlay panel entrances — popovers and sheets summoned by a pointer. */\nexport const SPRING_PANEL = {\n type: \"spring\",\n stiffness: 420,\n damping: 40,\n mass: 0.5,\n} as const;\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n type: \"spring\",\n stiffness: 360,\n damping: 32,\n mass: 0.6,\n} as const;\n/** Popups rising out of their trigger — menus, popovers, dialogs, tooltips. */\nexport const SPRING_POPUP = {\n type: \"spring\",\n stiffness: 520,\n damping: 38,\n mass: 0.6,\n} as const;\n/** Cursor-follow physics for decorative tracking. */\nexport const SPRING_MOUSE = { stiffness: 200, damping: 15, mass: 0.3 } as const;\n/** Dragged handles and fills — critically damped, never rebounds. */\nexport const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 } as const;\n\n/* ----------------------------------------------------------------------------\n * Disclosure: a transform-only reveal for collapsible agent content.\n * ------------------------------------------------------------------------- */\n\nexport interface DisclosureProps\n extends Omit<HTMLMotionProps<\"div\">, \"animate\" | \"initial\"> {\n open: boolean;\n openHeight?: React.CSSProperties[\"height\"];\n}\n\nfunction Disclosure({\n open,\n openHeight = \"auto\",\n className,\n style,\n transition,\n ...props\n}: DisclosureProps) {\n const reduced = useReducedMotion() ?? false;\n\n return (\n <motion.div\n data-slot=\"disclosure\"\n data-state={open ? \"open\" : \"closed\"}\n {...props}\n aria-hidden={!open}\n inert={!open}\n initial={false}\n animate={\n reduced\n ? { opacity: open ? 1 : 0 }\n : {\n opacity: open ? 1 : 0,\n clipPath: open ? \"inset(0 0 0% 0)\" : \"inset(0 0 100% 0)\",\n y: open ? 0 : -4,\n }\n }\n transition={\n transition ?? {\n duration: reduced ? 0 : open ? 0.22 : 0.14,\n ease: EASE_OUT,\n }\n }\n className={cn(\"overflow-hidden\", className)}\n style={{\n ...style,\n height: open ? openHeight : 0,\n pointerEvents: open ? undefined : \"none\",\n transformOrigin: \"top\",\n }}\n />\n );\n}\n\n/* ----------------------------------------------------------------------------\n * SwapText: a value that rolls or blurs into its replacement — a counter,\n * a status word — without the layout jumping.\n * ------------------------------------------------------------------------- */\n\nexport type SwapAnimation = \"blur\" | \"roll\";\n\nconst ROLL_EXIT = { duration: 0.14, ease: EASE_OUT } as const;\nconst BLUR = { duration: 0.2, ease: \"easeInOut\" } as const;\n\nconst SWAP_VARIANTS: Record<SwapAnimation, Variants> = {\n blur: {\n initial: { opacity: 0, filter: \"blur(8px)\", scale: 0.96 },\n animate: { opacity: 1, filter: \"blur(0px)\", scale: 1, transition: BLUR },\n exit: { opacity: 0, filter: \"blur(8px)\", scale: 0.96, transition: BLUR },\n },\n roll: {\n initial: { opacity: 0, y: \"0.55em\", filter: \"blur(3px)\" },\n animate: {\n opacity: 1,\n y: 0,\n filter: \"blur(0px)\",\n transition: SPRING_SWAP,\n },\n exit: {\n opacity: 0,\n y: \"-0.55em\",\n filter: \"blur(3px)\",\n transition: ROLL_EXIT,\n },\n },\n};\n\nexport interface SwapTextProps {\n /** The identity of the current content; a change animates the swap. */\n value: string;\n children: React.ReactNode;\n animation?: SwapAnimation;\n className?: string;\n}\n\nfunction SwapText({\n value,\n children,\n animation = \"roll\",\n className,\n}: SwapTextProps) {\n const reduced = useReducedMotion() ?? false;\n\n return (\n <span\n data-slot=\"swap-text\"\n className={cn(\n \"relative inline-block max-w-full align-bottom whitespace-nowrap\",\n className\n )}\n style={{ clipPath: \"inset(0 -999px)\" }}\n >\n {/* The invisible copy holds the width so the swap never reflows. */}\n <span aria-hidden className=\"invisible inline-block whitespace-nowrap\">\n {children}\n </span>\n <AnimatePresence initial={false}>\n <motion.span\n key={`${animation}-${value}`}\n variants={SWAP_VARIANTS[animation]}\n initial={reduced ? false : \"initial\"}\n animate={\n reduced\n ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 }\n : \"animate\"\n }\n exit={reduced ? undefined : \"exit\"}\n className=\"absolute top-0 left-0 inline-block max-w-full truncate\"\n >\n {children}\n </motion.span>\n </AnimatePresence>\n </span>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Popups. A Base UI popup animates with motion only when its root is\n * controlled, so AnimatePresence can see the open state and hold the\n * portal mounted through the exit. `useOpenState` makes any root\n * controlled without changing its API: a passed `open` still wins,\n * `defaultOpen` still seeds it, and a cancelled change is respected.\n * ------------------------------------------------------------------------- */\n\ntype OpenChangeHandler<Details> =\n | ((open: boolean, details: Details) => void)\n | undefined;\n\nexport function useOpenState<Details>(\n open: boolean | undefined,\n defaultOpen: boolean | undefined,\n onOpenChange: OpenChangeHandler<Details>\n) {\n const [uncontrolled, setUncontrolled] = React.useState(defaultOpen ?? false);\n const controlled = open !== undefined;\n\n const setOpen = React.useCallback(\n (next: boolean, details: Details) => {\n onOpenChange?.(next, details);\n if ((details as { isCanceled?: boolean } | undefined)?.isCanceled) return;\n if (!controlled) setUncontrolled(next);\n },\n [controlled, onOpenChange]\n );\n\n return [controlled ? open : uncontrolled, setOpen] as const;\n}\n\n/**\n * The enter and exit of a popup that grows out of its trigger: scale from\n * the trigger's side (the popup's `origin-(--transform-origin)`), a blur\n * that clears, a quick fade out. Opacity always animates — Base UI reads\n * it through `getAnimations()` to know when the exit is done.\n */\nexport function popupMotion(reduced: boolean, from = 0.92) {\n if (reduced) {\n return {\n initial: { opacity: 0 },\n animate: { opacity: 1, transition: { duration: 0 } },\n exit: { opacity: 0, transition: { duration: 0 } },\n } as const;\n }\n return {\n initial: { opacity: 0, scale: from, filter: \"blur(4px)\" },\n animate: {\n opacity: 1,\n scale: 1,\n filter: \"blur(0px)\",\n transition: {\n ...SPRING_POPUP,\n opacity: { duration: 0.16, ease: EASE_OUT },\n filter: { duration: 0.2, ease: EASE_OUT },\n },\n },\n exit: {\n opacity: 0,\n scale: (from + 1) / 2,\n filter: \"blur(2px)\",\n transition: { duration: 0.12, ease: EASE_IN_OUT },\n },\n } as const;\n}\n\n/** A backdrop that fades under a dialog or sheet. */\nexport function backdropMotion(reduced: boolean) {\n const duration = reduced ? 0 : 0.2;\n return {\n initial: { opacity: 0 },\n animate: { opacity: 1, transition: { duration, ease: EASE_OUT } },\n exit: { opacity: 0, transition: { duration: duration * 0.7 } },\n } as const;\n}\n\n/* ----------------------------------------------------------------------------\n * Press: the button's element — a spring down on press, a spring back on\n * release, and an optional ripple from the press point. The button item\n * renders it through Base UI's `render`, so the button module itself stays\n * importable from a server component.\n * ------------------------------------------------------------------------- */\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nexport interface PressProps extends HTMLMotionProps<\"button\"> {\n /** How far the surface sinks on press; 1 turns the press off. */\n pressScale?: number;\n /** Spread a ripple from the press point. */\n ripple?: boolean;\n}\n\nfunction Press({\n pressScale = 0.97,\n ripple = false,\n className,\n children,\n onPointerDown,\n ...props\n}: PressProps) {\n const reduced = useReducedMotion() ?? false;\n const [ripples, setRipples] = React.useState<Ripple[]>([]);\n const nextId = React.useRef(0);\n const opensPopup =\n props[\"aria-haspopup\"] !== undefined && props[\"aria-haspopup\"] !== false;\n const sinks = !reduced && !opensPopup && pressScale !== 1;\n\n return (\n <motion.button\n {...props}\n className={cn(ripple && \"relative overflow-hidden\", className)}\n whileTap={sinks ? { scale: pressScale } : undefined}\n transition={SPRING_PRESS}\n onPointerDown={(event) => {\n onPointerDown?.(event);\n if (!ripple || reduced) return;\n const rect = event.currentTarget.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height) * 2;\n const id = nextId.current++;\n setRipples((current) => [\n ...current,\n {\n id,\n x: event.clientX - rect.left - size / 2,\n y: event.clientY - rect.top - size / 2,\n size,\n },\n ]);\n }}\n >\n {children as React.ReactNode}\n {ripple && (\n <span\n aria-hidden\n className=\"pointer-events-none absolute inset-0 overflow-hidden\"\n >\n <AnimatePresence>\n {ripples.map((r) => (\n <motion.span\n key={r.id}\n className=\"absolute rounded-full bg-current\"\n style={{ left: r.x, top: r.y, width: r.size, height: r.size }}\n initial={{ scale: 0, opacity: 0.18 }}\n animate={{ scale: 1, opacity: 0 }}\n transition={{ duration: 0.6, ease: EASE_OUT }}\n onAnimationComplete={() =>\n setRipples((current) => current.filter((c) => c.id !== r.id))\n }\n />\n ))}\n </AnimatePresence>\n </span>\n )}\n </motion.button>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Lists. A container staggers its children in; each child rises and\n * clears. Pair `listStagger` on the parent with `listItem` on the rows.\n * ------------------------------------------------------------------------- */\n\nexport const listStagger: Variants = {\n hidden: {},\n shown: { transition: { staggerChildren: 0.04, delayChildren: 0.02 } },\n};\n\nexport const listItem: Variants = {\n hidden: { opacity: 0, y: 6, filter: \"blur(2px)\" },\n shown: {\n opacity: 1,\n y: 0,\n filter: \"blur(0px)\",\n transition: { duration: 0.32, ease: EASE_OUT },\n },\n exit: { opacity: 0, y: -4, transition: { duration: 0.14, ease: EASE_IN_OUT } },\n};\n\nexport { Disclosure, Press, SwapText };\n",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"target": "components/ui/ai-motion.tsx"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-prompt-input",
|
|
4
|
+
"title": "AI Prompt Input",
|
|
5
|
+
"description": "The chat composer: autosizing textarea that sends on Enter, attachments by picker, drop and paste, an action menu, selects, and a submit that stops a streaming reply.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"ai@^7.0.103",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"motion"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"@intelligo/attachment",
|
|
13
|
+
"@intelligo/button",
|
|
14
|
+
"@intelligo/dropdown-menu",
|
|
15
|
+
"@intelligo/select",
|
|
16
|
+
"@intelligo/spinner",
|
|
17
|
+
"@intelligo/ai-motion"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "base/ui/ai-prompt-input/ai-prompt-input.tsx",
|
|
22
|
+
"content": "\"use client\";\n\n/*\n * The chat composer: one rounded field, the textarea growing with its\n * content, a bottom row of quiet controls and a round send button that\n * morphs into stop while a reply streams. Errors report a code and every\n * label is passed in, so nothing is English by default.\n */\n\nimport * as React from \"react\";\nimport type { ChatStatus, FileUIPart } from \"ai\";\nimport {\n ArrowUpIcon,\n ImageIcon,\n PaperclipIcon,\n PlusIcon,\n SquareIcon,\n XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n Attachment,\n AttachmentAction,\n AttachmentActions,\n AttachmentContent,\n AttachmentGroup,\n AttachmentMedia,\n AttachmentTitle,\n} from \"@/components/ui/attachment\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Button } from \"@/components/ui/button\";\nimport { SPRING_SWAP } from \"@/components/ui/ai-motion\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\ntype AttachmentFile = FileUIPart & { id: string };\n\ntype AttachmentsContextValue = {\n files: AttachmentFile[];\n add: (files: File[] | FileList) => void;\n remove: (id: string) => void;\n clear: () => void;\n openFileDialog: () => void;\n fileInputRef: React.RefObject<HTMLInputElement | null>;\n};\n\nconst AttachmentsContext = React.createContext<AttachmentsContextValue | null>(\n null\n);\n\nfunction usePromptInputAttachments() {\n const context = React.useContext(AttachmentsContext);\n if (!context) {\n throw new Error(\n \"usePromptInputAttachments must be used within a PromptInput\"\n );\n }\n return context;\n}\n\ntype PromptInputMessage = {\n text: string;\n files: FileUIPart[];\n};\n\ntype PromptInputError = { code: \"max_files\" | \"max_file_size\" | \"accept\" };\n\nfunction matchesAccept(file: File, accept?: string) {\n if (!accept || accept.trim() === \"\") return true;\n return accept\n .split(\",\")\n .map((pattern) => pattern.trim())\n .filter(Boolean)\n .some((pattern) =>\n pattern.endsWith(\"/*\")\n ? file.type.startsWith(pattern.slice(0, -1))\n : file.type === pattern\n );\n}\n\nasync function blobUrlToDataUrl(url: string): Promise<string | null> {\n try {\n const blob = await (await fetch(url)).blob();\n return await new Promise((resolve) => {\n const reader = new FileReader();\n reader.onloadend = () => resolve(reader.result as string);\n reader.onerror = () => resolve(null);\n reader.readAsDataURL(blob);\n });\n } catch {\n return null;\n }\n}\n\nfunction PromptInput({\n className,\n accept,\n multiple,\n globalDrop = false,\n maxFiles,\n maxFileSize,\n onError,\n onSubmit,\n children,\n ...props\n}: Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"onError\"> & {\n /** e.g. \"image/*\"; any type when omitted. */\n accept?: string;\n multiple?: boolean;\n /** Accept drops anywhere on the page, not only on the composer. */\n globalDrop?: boolean;\n maxFiles?: number;\n /** In bytes. */\n maxFileSize?: number;\n onError?: (error: PromptInputError) => void;\n onSubmit: (\n message: PromptInputMessage,\n event: React.FormEvent<HTMLFormElement>\n ) => void | Promise<void>;\n}) {\n const inputRef = React.useRef<HTMLInputElement | null>(null);\n const formRef = React.useRef<HTMLFormElement | null>(null);\n const [files, setFiles] = React.useState<AttachmentFile[]>([]);\n const filesRef = React.useRef(files);\n filesRef.current = files;\n\n const add = React.useCallback(\n (fileList: File[] | FileList) => {\n const incoming = Array.from(fileList);\n const accepted = incoming.filter((file) => matchesAccept(file, accept));\n if (incoming.length && accepted.length === 0) {\n onError?.({ code: \"accept\" });\n return;\n }\n const sized = accepted.filter((file) =>\n maxFileSize ? file.size <= maxFileSize : true\n );\n if (accepted.length > 0 && sized.length === 0) {\n onError?.({ code: \"max_file_size\" });\n return;\n }\n setFiles((previous) => {\n const capacity =\n typeof maxFiles === \"number\"\n ? Math.max(0, maxFiles - previous.length)\n : undefined;\n const capped =\n typeof capacity === \"number\" ? sized.slice(0, capacity) : sized;\n if (typeof capacity === \"number\" && sized.length > capacity) {\n onError?.({ code: \"max_files\" });\n }\n return previous.concat(\n capped.map((file) => ({\n id: crypto.randomUUID(),\n type: \"file\" as const,\n url: URL.createObjectURL(file),\n mediaType: file.type,\n filename: file.name,\n }))\n );\n });\n },\n [accept, maxFiles, maxFileSize, onError]\n );\n\n const remove = React.useCallback((id: string) => {\n setFiles((previous) => {\n const found = previous.find((file) => file.id === id);\n if (found?.url) URL.revokeObjectURL(found.url);\n return previous.filter((file) => file.id !== id);\n });\n }, []);\n\n const clear = React.useCallback(() => {\n setFiles((previous) => {\n for (const file of previous) {\n if (file.url) URL.revokeObjectURL(file.url);\n }\n return [];\n });\n }, []);\n\n const openFileDialog = React.useCallback(() => inputRef.current?.click(), []);\n\n // Drops land on the composer, or anywhere on the page with globalDrop.\n React.useEffect(() => {\n const target: HTMLElement | Document | null = globalDrop\n ? document\n : formRef.current;\n if (!target) return;\n const onDragOver = (event: Event) => {\n if ((event as DragEvent).dataTransfer?.types?.includes(\"Files\")) {\n event.preventDefault();\n }\n };\n const onDrop = (event: Event) => {\n const transfer = (event as DragEvent).dataTransfer;\n if (transfer?.types?.includes(\"Files\")) event.preventDefault();\n if (transfer?.files && transfer.files.length > 0) add(transfer.files);\n };\n target.addEventListener(\"dragover\", onDragOver);\n target.addEventListener(\"drop\", onDrop);\n return () => {\n target.removeEventListener(\"dragover\", onDragOver);\n target.removeEventListener(\"drop\", onDrop);\n };\n }, [add, globalDrop]);\n\n // Revoke object URLs on unmount.\n React.useEffect(\n () => () => {\n for (const file of filesRef.current) {\n if (file.url) URL.revokeObjectURL(file.url);\n }\n },\n []\n );\n\n const context = React.useMemo<AttachmentsContextValue>(\n () => ({\n files,\n add,\n remove,\n clear,\n openFileDialog,\n fileInputRef: inputRef,\n }),\n [files, add, remove, clear, openFileDialog]\n );\n\n function handleSubmit(event: React.FormEvent<HTMLFormElement>) {\n event.preventDefault();\n const form = event.currentTarget;\n const text = (new FormData(form).get(\"message\") as string) || \"\";\n // Reset before the async conversion so typing during it is not lost.\n form.reset();\n\n Promise.all(\n files.map(async ({ id: _id, ...file }) =>\n file.url?.startsWith(\"blob:\")\n ? { ...file, url: (await blobUrlToDataUrl(file.url)) ?? file.url }\n : file\n )\n )\n .then(async (converted) => {\n await onSubmit({ text, files: converted }, event);\n clear();\n })\n .catch(() => {\n // Keep the attachments so the user can retry.\n });\n }\n\n return (\n <AttachmentsContext.Provider value={context}>\n <input\n accept={accept}\n className=\"hidden\"\n multiple={multiple}\n onChange={(event) => {\n if (event.currentTarget.files) add(event.currentTarget.files);\n // Allow picking a file that was just removed.\n event.currentTarget.value = \"\";\n }}\n ref={inputRef}\n tabIndex={-1}\n type=\"file\"\n />\n <form\n data-slot=\"prompt-input\"\n className={cn(\"w-full\", className)}\n onSubmit={handleSubmit}\n ref={formRef}\n {...props}\n >\n <div\n data-slot=\"prompt-input-surface\"\n className=\"flex w-full flex-col rounded-xl border border-border/80 bg-background p-2 transition-colors focus-within:border-foreground/25 has-[textarea:disabled]:opacity-60\"\n >\n {children}\n </div>\n </form>\n </AttachmentsContext.Provider>\n );\n}\n\nfunction PromptInputBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"prompt-input-body\"\n className={cn(\"contents\", className)}\n {...props}\n />\n );\n}\n\nfunction PromptInputTextarea({\n className,\n onKeyDown,\n onPaste,\n ...props\n}: React.ComponentProps<\"textarea\">) {\n const attachments = usePromptInputAttachments();\n const [isComposing, setIsComposing] = React.useState(false);\n\n // A caller's handlers run first; what they `preventDefault` on, the\n // composer leaves alone (a menu taking Escape, an ArrowUp recall).\n function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"Enter\") {\n if (isComposing || event.nativeEvent.isComposing || event.shiftKey) {\n return;\n }\n event.preventDefault();\n const form = event.currentTarget.form;\n const submit = form?.querySelector<HTMLButtonElement>(\n 'button[type=\"submit\"]'\n );\n if (submit?.disabled) return;\n form?.requestSubmit();\n }\n\n // Backspace in an empty composer removes the last attachment.\n if (\n event.key === \"Backspace\" &&\n event.currentTarget.value === \"\" &&\n attachments.files.length > 0\n ) {\n event.preventDefault();\n const last = attachments.files.at(-1);\n if (last) attachments.remove(last.id);\n }\n }\n\n function handlePaste(event: React.ClipboardEvent<HTMLTextAreaElement>) {\n onPaste?.(event);\n if (event.defaultPrevented) return;\n const pasted: File[] = [];\n for (const item of event.clipboardData?.items ?? []) {\n if (item.kind === \"file\") {\n const file = item.getAsFile();\n if (file) pasted.push(file);\n }\n }\n if (pasted.length > 0) {\n event.preventDefault();\n attachments.add(pasted);\n }\n }\n\n return (\n <textarea\n data-slot=\"prompt-input-textarea\"\n rows={2}\n className={cn(\n \"field-sizing-content block max-h-64 min-h-12 w-full resize-none bg-transparent px-2 pt-1.5 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/60 disabled:cursor-not-allowed\",\n className\n )}\n name=\"message\"\n onCompositionEnd={() => setIsComposing(false)}\n onCompositionStart={() => setIsComposing(true)}\n onKeyDown={handleKeyDown}\n onPaste={handlePaste}\n {...props}\n />\n );\n}\n\nfunction PromptInputHeader({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"prompt-input-header\"\n className={cn(\"order-first flex flex-wrap items-center gap-1 px-1 pb-1\", className)}\n {...props}\n />\n );\n}\n\nfunction PromptInputFooter({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"prompt-input-footer\"\n className={cn(\"mt-1 flex min-h-8 items-center justify-between gap-1\", className)}\n {...props}\n />\n );\n}\n\nfunction PromptInputTools({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"prompt-input-tools\"\n className={cn(\"flex items-center gap-1\", className)}\n {...props}\n />\n );\n}\n\nfunction PromptInputButton({\n variant = \"ghost\",\n size,\n className,\n ...props\n}: React.ComponentProps<typeof Button>) {\n const iconOnly = React.Children.count(props.children) <= 1;\n return (\n <Button\n data-slot=\"prompt-input-button\"\n size={size ?? (iconOnly ? \"icon-sm\" : \"sm\")}\n type=\"button\"\n variant={variant}\n className={cn(\n \"text-muted-foreground hover:text-foreground\",\n iconOnly && \"size-8\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction PromptInputAttachments({\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<typeof AttachmentGroup>, \"children\"> & {\n children: (file: AttachmentFile) => React.ReactNode;\n}) {\n const attachments = usePromptInputAttachments();\n if (!attachments.files.length) return null;\n return (\n <AttachmentGroup\n data-slot=\"prompt-input-attachments\"\n className={cn(\"w-full px-1 pt-1\", className)}\n {...props}\n >\n {attachments.files.map((file) => (\n <React.Fragment key={file.id}>{children(file)}</React.Fragment>\n ))}\n </AttachmentGroup>\n );\n}\n\nfunction PromptInputAttachment({\n data,\n removeLabel,\n ...props\n}: React.ComponentProps<typeof Attachment> & {\n data: AttachmentFile;\n /** Accessible name of the remove button, e.g. \"Remove attachment\". */\n removeLabel: string;\n}) {\n const attachments = usePromptInputAttachments();\n const isImage = Boolean(data.mediaType?.startsWith(\"image/\") && data.url);\n\n return (\n <Attachment data-slot=\"prompt-input-attachment\" size=\"sm\" {...props}>\n <AttachmentMedia variant={isImage ? \"image\" : \"icon\"}>\n {isImage ? <img src={data.url} alt=\"\" /> : <PaperclipIcon />}\n </AttachmentMedia>\n {data.filename && (\n <AttachmentContent>\n <AttachmentTitle>{data.filename}</AttachmentTitle>\n </AttachmentContent>\n )}\n <AttachmentActions>\n <AttachmentAction\n aria-label={removeLabel}\n onClick={() => attachments.remove(data.id)}\n >\n <XIcon />\n </AttachmentAction>\n </AttachmentActions>\n </Attachment>\n );\n}\n\nfunction PromptInputActionMenu(\n props: React.ComponentProps<typeof DropdownMenu>\n) {\n return <DropdownMenu {...props} />;\n}\n\nfunction PromptInputActionMenuTrigger({\n children,\n ...props\n}: React.ComponentProps<typeof PromptInputButton>) {\n // The plus turns into a cross while the menu is open — a CSS turn, so\n // it needs no motion gate.\n return (\n <DropdownMenuTrigger\n render={\n <PromptInputButton\n {...props}\n className={cn(\n \"[&>svg]:transition-transform [&>svg]:duration-200 aria-expanded:[&>svg]:rotate-45 motion-reduce:[&>svg]:transition-none\",\n props.className\n )}\n />\n }\n >\n {children ?? <PlusIcon />}\n </DropdownMenuTrigger>\n );\n}\n\nfunction PromptInputActionMenuContent({\n align = \"start\",\n className,\n ...props\n}: React.ComponentProps<typeof DropdownMenuContent>) {\n return (\n <DropdownMenuContent\n align={align}\n className={cn(\"w-56 rounded-lg p-1.5\", className)}\n {...props}\n />\n );\n}\n\nfunction PromptInputActionMenuItem(\n props: React.ComponentProps<typeof DropdownMenuItem>\n) {\n return <DropdownMenuItem {...props} />;\n}\n\nfunction PromptInputActionAddAttachments({\n label,\n ...props\n}: React.ComponentProps<typeof DropdownMenuItem> & {\n /** e.g. \"Add photos or files\". */\n label: string;\n}) {\n const attachments = usePromptInputAttachments();\n return (\n <DropdownMenuItem {...props} onClick={() => attachments.openFileDialog()}>\n <ImageIcon /> {label}\n </DropdownMenuItem>\n );\n}\n\nfunction PromptInputSubmit({\n status,\n label,\n variant = \"default\",\n size = \"icon-sm\",\n className,\n children,\n ...props\n}: React.ComponentProps<typeof Button> & {\n status?: ChatStatus;\n /** Accessible name — \"Send\", or \"Stop\" while streaming. */\n label: string;\n}) {\n const reduced = useReducedMotion() ?? false;\n const pending = status === \"submitted\" || status === \"streaming\";\n let icon = <ArrowUpIcon />;\n if (status === \"submitted\") icon = <Spinner />;\n else if (status === \"streaming\") icon = <SquareIcon className=\"size-3 fill-current\" />;\n else if (status === \"error\") icon = <XIcon />;\n const key = status === \"submitted\" || status === \"streaming\" ? \"stop\" : status === \"error\" ? \"error\" : \"send\";\n\n return (\n <Button\n data-slot=\"prompt-input-submit\"\n aria-label={label}\n aria-busy={status === \"submitted\" || undefined}\n size={size}\n // While a reply is pending the button stops it; it never resubmits.\n type={pending ? \"button\" : \"submit\"}\n variant={variant}\n className={cn(\"ml-auto size-8\", className)}\n {...props}\n >\n {children ?? (\n // Send and stop trade places with a small pop; reduced motion swaps them plainly.\n <AnimatePresence initial={false} mode=\"popLayout\">\n <motion.span\n key={key}\n initial={reduced ? { opacity: 1 } : { opacity: 0, y: 3, scale: 0.8 }}\n animate={{ opacity: 1, y: 0, scale: 1 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.8 }}\n transition={reduced ? { duration: 0 } : SPRING_SWAP}\n className=\"grid place-items-center [&>svg]:size-4\"\n >\n {icon}\n </motion.span>\n </AnimatePresence>\n )}\n </Button>\n );\n}\n\nfunction PromptInputSelect(props: React.ComponentProps<typeof Select>) {\n return <Select {...props} />;\n}\n\nfunction PromptInputSelectTrigger({\n className,\n ...props\n}: React.ComponentProps<typeof SelectTrigger>) {\n return (\n <SelectTrigger\n className={cn(\n \"h-8 max-w-52 rounded-lg border-none bg-transparent px-2 text-xs font-medium text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground\",\n className\n )}\n {...props}\n />\n );\n}\n\nconst PromptInputSelectContent = SelectContent;\nconst PromptInputSelectItem = SelectItem;\nconst PromptInputSelectValue = SelectValue;\n\nexport {\n PromptInput,\n PromptInputBody,\n PromptInputTextarea,\n PromptInputHeader,\n PromptInputFooter,\n PromptInputTools,\n PromptInputButton,\n PromptInputAttachments,\n PromptInputAttachment,\n PromptInputActionMenu,\n PromptInputActionMenuTrigger,\n PromptInputActionMenuContent,\n PromptInputActionMenuItem,\n PromptInputActionAddAttachments,\n PromptInputSubmit,\n PromptInputSelect,\n PromptInputSelectTrigger,\n PromptInputSelectContent,\n PromptInputSelectItem,\n PromptInputSelectValue,\n usePromptInputAttachments,\n type PromptInputMessage,\n type PromptInputError,\n};\n",
|
|
23
|
+
"type": "registry:ui",
|
|
24
|
+
"target": "components/ui/ai-prompt-input.tsx"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"type": "registry:ui"
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-reasoning-text",
|
|
4
|
+
"title": "AI Reasoning Text",
|
|
5
|
+
"description": "What the agent is doing while it reasons: phrases cycle with a cascade, swap or scramble behind an ASCII loader.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"@intelligo/ai-motion",
|
|
11
|
+
"@intelligo/ai-shimmer-text"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/ai-reasoning-text/ai-reasoning-text.tsx",
|
|
16
|
+
"content": "\"use client\";\n\n/*\n * The line that says what the agent is doing while it reasons: phrases\n * cycle with a cascade, a swap or a scramble, each shimmering, behind a\n * terminal-style ASCII loader.\n */\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { EASE_OUT, SPRING_SWAP } from \"@/components/ui/ai-motion\";\nimport { shimmerStyle } from \"@/components/ui/ai-shimmer-text\";\nimport { cn } from \"@/lib/utils\";\n\n/* ----------------------------------------------------------------------------\n * AsciiLoader: the frame set a CLI agent cycles through.\n * ------------------------------------------------------------------------- */\n\nconst ASCII_LINE = [\"|\", \"/\", \"-\", \"\\\\\"];\n\nexport interface AsciiLoaderProps\n extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n /** Glyphs shown in turn. */\n frames?: string[];\n /** Font size in px. */\n size?: number;\n /** Seconds per full cycle. */\n speed?: number;\n}\n\nfunction AsciiLoader({\n frames = ASCII_LINE,\n size = 14,\n speed = 0.8,\n className,\n style,\n ...props\n}: AsciiLoaderProps) {\n const reduced = useReducedMotion() ?? false;\n const [frame, setFrame] = React.useState(0);\n const count = frames.length;\n\n React.useEffect(() => {\n // Reduced motion slows the cycle rather than stopping it — it's a glyph\n // swap, not on-screen movement.\n const step = ((reduced ? speed * 2.5 : speed) / Math.max(1, count)) * 1000;\n const id = window.setInterval(\n () => setFrame((current) => (current + 1) % Math.max(1, count)),\n step\n );\n return () => window.clearInterval(id);\n }, [count, speed, reduced]);\n\n return (\n <span\n data-slot=\"ascii-loader\"\n className={cn(\"font-mono leading-none tabular-nums\", className)}\n style={{ fontSize: size, lineHeight: 1, ...style }}\n {...props}\n >\n {frames[frame % Math.max(1, count)]}\n </span>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * TextScramble: characters resolve into `text` left to right.\n * ------------------------------------------------------------------------- */\n\nconst DEFAULT_GLYPHS = \"ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/\";\n\nexport interface TextScrambleProps {\n /** Final text revealed by the scramble animation. */\n text: string;\n /** Maximum animation duration in milliseconds. */\n duration?: number;\n /** Characters sampled while unresolved positions are scrambling. */\n glyphs?: string;\n className?: string;\n style?: React.CSSProperties;\n}\n\nfunction TextScramble({\n text,\n duration,\n glyphs = DEFAULT_GLYPHS,\n className,\n style,\n}: TextScrambleProps) {\n const reduced = useReducedMotion() ?? false;\n const [display, setDisplay] = React.useState(text);\n const mounted = React.useRef(false);\n const still = reduced || !glyphs;\n\n React.useEffect(() => {\n // The first text lands as it is; only a change scrambles.\n if (!mounted.current) {\n mounted.current = true;\n return;\n }\n if (still) return;\n\n const characters = text.split(\"\");\n const startedAt = performance.now();\n const animationDuration =\n duration ?? Math.min(760, Math.max(420, characters.length * 32));\n let frame = 0;\n let lastUpdate = 0;\n\n const animate = (now: number) => {\n if (now - lastUpdate >= 40) {\n lastUpdate = now;\n const progress = Math.min((now - startedAt) / animationDuration, 1);\n const settled = Math.floor(progress * characters.length);\n setDisplay(\n characters\n .map((character, index) => {\n if (index < settled || character === \" \") return character;\n return glyphs[Math.floor(Math.random() * glyphs.length)];\n })\n .join(\"\")\n );\n }\n\n if (now - startedAt < animationDuration) {\n frame = requestAnimationFrame(animate);\n } else {\n setDisplay(text);\n }\n };\n\n frame = requestAnimationFrame(animate);\n return () => cancelAnimationFrame(frame);\n }, [duration, glyphs, still, text]);\n\n return (\n <span\n data-slot=\"text-scramble\"\n className={cn(\"inline-block whitespace-pre\", className)}\n style={style}\n >\n <span className=\"sr-only\">{text}</span>\n <span aria-hidden=\"true\">{still ? text : display}</span>\n </span>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * ReasoningText.\n * ------------------------------------------------------------------------- */\n\nconst DEFAULT_PHRASES = [\n \"Thinking\",\n \"Reading the context\",\n \"Connecting the details\",\n \"Forming a response\",\n];\n\nconst CASCADE_STAGGER = 0.025;\n\nexport type ReasoningTextVariant = \"cascade\" | \"swap\" | \"scramble\";\n\nexport interface ReasoningTextProps {\n /** Phrases cycled through while the agent works. */\n phrases?: string[];\n /** Appended to every phrase; \"…\" by default. */\n suffix?: string;\n /** Animation used when the active phrase changes. */\n variant?: ReasoningTextVariant;\n /** Milliseconds each phrase remains visible. */\n interval?: number;\n /** Seconds taken for one shimmer pass. */\n shimmerDuration?: number;\n /** Optional leading visual. Defaults to a terminal-style ASCII loader. */\n indicator?: React.ReactNode;\n className?: string;\n}\n\ntype PhraseProps = {\n phrase: string;\n suffix: string;\n reduced: boolean;\n shimmerDuration: number;\n};\n\nfunction CascadePhrase({ phrase, suffix, reduced, shimmerDuration }: PhraseProps) {\n const text = `${phrase}${suffix}`;\n\n if (reduced) {\n return (\n <span\n className=\"shimmer col-start-1 row-start-1 inline-block justify-self-start whitespace-pre\"\n style={shimmerStyle(shimmerDuration)}\n >\n {text}\n </span>\n );\n }\n\n return (\n <AnimatePresence initial={false}>\n <motion.span\n key={phrase}\n className=\"col-start-1 row-start-1 inline-block justify-self-start whitespace-pre\"\n initial=\"initial\"\n animate=\"animate\"\n exit=\"exit\"\n >\n {text.split(\"\").map((character, characterIndex) => (\n <motion.span\n // Position is the stable cascade slot identity.\n key={characterIndex}\n custom={characterIndex * CASCADE_STAGGER}\n variants={{\n initial: { opacity: 0, y: \"100%\" },\n animate: (delay: number) => ({\n opacity: 1,\n y: \"0%\",\n transition: { ...SPRING_SWAP, delay },\n }),\n exit: (delay: number) => ({\n opacity: 0,\n y: \"-100%\",\n transition: {\n duration: 0.14,\n ease: EASE_OUT,\n delay: delay * 0.45,\n },\n }),\n }}\n className=\"shimmer inline-block whitespace-pre will-change-[opacity,transform]\"\n style={shimmerStyle(shimmerDuration)}\n >\n {character}\n </motion.span>\n ))}\n </motion.span>\n </AnimatePresence>\n );\n}\n\nfunction SwapPhrase({ phrase, suffix, reduced, shimmerDuration }: PhraseProps) {\n return (\n <AnimatePresence initial={false}>\n <motion.span\n key={phrase}\n className=\"shimmer col-start-1 row-start-1 inline-block justify-self-start whitespace-nowrap will-change-[opacity,transform]\"\n style={shimmerStyle(shimmerDuration)}\n initial={reduced ? { opacity: 0 } : { opacity: 0, y: 3 }}\n animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, y: -3 }}\n transition={{\n duration: reduced ? 0.12 : 0.2,\n ease: EASE_OUT,\n }}\n >\n {phrase}\n {suffix}\n </motion.span>\n </AnimatePresence>\n );\n}\n\nfunction ScramblePhrase({ phrase, suffix, shimmerDuration }: PhraseProps) {\n return (\n <TextScramble\n text={`${phrase}${suffix}`}\n className=\"shimmer col-start-1 row-start-1 justify-self-start tabular-nums\"\n style={shimmerStyle(shimmerDuration)}\n />\n );\n}\n\nfunction ReasoningText({\n phrases = DEFAULT_PHRASES,\n suffix = \"…\",\n variant = \"cascade\",\n interval = 1800,\n shimmerDuration = 2.2,\n indicator,\n className,\n}: ReasoningTextProps) {\n const reduced = useReducedMotion() ?? false;\n const [index, setIndex] = React.useState(0);\n const statusId = React.useId();\n const safePhrases = phrases.length > 0 ? phrases : DEFAULT_PHRASES;\n const phrase = safePhrases[index % safePhrases.length] ?? \"\";\n const longestPhrase = safePhrases.reduce((longest, current) =>\n current.length > longest.length ? current : longest\n );\n const phraseProps = { phrase, suffix, reduced, shimmerDuration };\n\n React.useEffect(() => {\n if (safePhrases.length < 2) return;\n\n const timer = window.setInterval(() => {\n setIndex((current) => (current + 1) % safePhrases.length);\n }, Math.max(600, interval));\n\n return () => window.clearInterval(timer);\n }, [interval, safePhrases.length]);\n\n return (\n <span\n data-slot=\"reasoning-text\"\n data-variant={variant}\n role=\"status\"\n aria-live=\"polite\"\n aria-labelledby={statusId}\n className={cn(\n \"inline-flex items-center gap-2 text-sm font-medium text-muted-foreground\",\n className\n )}\n >\n <span\n data-slot=\"reasoning-text-indicator\"\n aria-hidden=\"true\"\n className=\"inline-flex size-3 shrink-0 items-center justify-center\"\n >\n {indicator ?? <AsciiLoader />}\n </span>\n\n <span aria-hidden=\"true\" className=\"grid overflow-hidden text-left\">\n {/* The invisible copy holds the width so a phrase change never reflows. */}\n <span className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n {longestPhrase}\n {suffix}\n </span>\n {variant === \"cascade\" ? (\n <CascadePhrase {...phraseProps} />\n ) : variant === \"scramble\" ? (\n <ScramblePhrase {...phraseProps} />\n ) : (\n <SwapPhrase {...phraseProps} />\n )}\n </span>\n\n <span id={statusId} className=\"sr-only\">\n {phrase}\n </span>\n </span>\n );\n}\n\nexport { AsciiLoader, ReasoningText, TextScramble };\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/ai-reasoning-text.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-reasoning",
|
|
4
|
+
"title": "AI Reasoning",
|
|
5
|
+
"description": "Streamed reasoning, collapsible, with the time it took.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"streamdown"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/collapsible"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/ai-reasoning/ai-reasoning.tsx",
|
|
16
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { BrainIcon, ChevronDownIcon } from \"lucide-react\";\nimport { Streamdown } from \"streamdown\";\n\nimport {\n Collapsible,\n CollapsibleContent,\n CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { cn } from \"@/lib/utils\";\n\nconst AUTO_CLOSE_DELAY = 1000;\n\ntype ReasoningContextValue = {\n isStreaming: boolean;\n isOpen: boolean;\n setIsOpen: (open: boolean) => void;\n duration: number | undefined;\n};\n\nconst ReasoningContext = React.createContext<ReasoningContextValue | null>(\n null\n);\n\nfunction useReasoning() {\n const context = React.useContext(ReasoningContext);\n if (!context) {\n throw new Error(\"Reasoning components must be used within Reasoning\");\n }\n return context;\n}\n\nfunction Reasoning({\n className,\n isStreaming = false,\n open,\n defaultOpen = true,\n onOpenChange,\n duration: durationProp,\n children,\n ...props\n}: Omit<\n React.ComponentProps<typeof Collapsible>,\n \"open\" | \"defaultOpen\" | \"onOpenChange\"\n> & {\n isStreaming?: boolean;\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Seconds spent reasoning, when the caller already knows it. */\n duration?: number;\n}) {\n const [openState, setOpenState] = React.useState(defaultOpen);\n const isOpen = open ?? openState;\n const setIsOpen = React.useCallback(\n (next: boolean) => {\n if (open === undefined) setOpenState(next);\n onOpenChange?.(next);\n },\n [open, onOpenChange]\n );\n\n const [measured, setMeasured] = React.useState<number | undefined>();\n const duration = durationProp ?? measured;\n const startedAt = React.useRef<number | null>(null);\n const [hasAutoClosed, setHasAutoClosed] = React.useState(false);\n\n React.useEffect(() => {\n if (isStreaming) {\n if (startedAt.current === null) startedAt.current = Date.now();\n } else if (startedAt.current !== null) {\n setMeasured(Math.ceil((Date.now() - startedAt.current) / 1000));\n startedAt.current = null;\n }\n }, [isStreaming]);\n\n // Open while streaming; close once, shortly after it ends.\n React.useEffect(() => {\n if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {\n const timer = setTimeout(() => {\n setIsOpen(false);\n setHasAutoClosed(true);\n }, AUTO_CLOSE_DELAY);\n return () => clearTimeout(timer);\n }\n }, [defaultOpen, isStreaming, isOpen, hasAutoClosed, setIsOpen]);\n\n return (\n <ReasoningContext.Provider\n value={{ isStreaming, isOpen, setIsOpen, duration }}\n >\n <Collapsible\n data-slot=\"reasoning\"\n className={className}\n open={isOpen}\n onOpenChange={(next) => setIsOpen(next)}\n {...props}\n >\n {children}\n </Collapsible>\n </ReasoningContext.Provider>\n );\n}\n\nfunction ReasoningTrigger({\n className,\n children,\n getThinkingMessage,\n ...props\n}: React.ComponentProps<typeof CollapsibleTrigger> & {\n /** The translated status line: \"Thinking…\" while streaming, \"Thought for 3 seconds\" after. */\n getThinkingMessage?: (\n isStreaming: boolean,\n duration?: number\n ) => React.ReactNode;\n}) {\n const { isStreaming, isOpen, duration } = useReasoning();\n\n return (\n <CollapsibleTrigger\n data-slot=\"reasoning-trigger\"\n className={cn(\n \"flex w-full items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground\",\n className\n )}\n {...props}\n >\n {children ?? (\n <>\n <BrainIcon className=\"size-4\" />\n <span className={cn(isStreaming && \"shimmer\")}>\n {getThinkingMessage?.(isStreaming, duration)}\n </span>\n <ChevronDownIcon\n className={cn(\n \"size-4 transition-transform\",\n isOpen && \"rotate-180\"\n )}\n />\n </>\n )}\n </CollapsibleTrigger>\n );\n}\n\nfunction ReasoningContent({\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<typeof CollapsibleContent>, \"children\"> & {\n children: string;\n}) {\n return (\n <CollapsibleContent\n data-slot=\"reasoning-content\"\n className={cn(\"mt-3 text-sm text-muted-foreground\", className)}\n {...props}\n >\n <Streamdown>{children}</Streamdown>\n </CollapsibleContent>\n );\n}\n\nexport { Reasoning, ReasoningTrigger, ReasoningContent, useReasoning };\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/ai-reasoning.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-shimmer-text",
|
|
4
|
+
"title": "AI Shimmer Text",
|
|
5
|
+
"description": "A status line that reads as alive while the agent works; a token gradient sweeps the text and the words swap with motion, honouring reduced motion.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "base/ui/ai-shimmer-text/ai-shimmer-text.tsx",
|
|
13
|
+
"content": "\"use client\";\n\n/*\n * A status line that reads as alive while the agent works — the words swap\n * with a small motion, and a gradient sweeps over the text. The sweep\n * rides the theme's `shimmer` utility (keyframe, text clipping and the\n * reduced-motion reset all live there); this file only chooses the\n * gradient and the pace. Respects `prefers-reduced-motion`: the swap\n * becomes a plain replace and the utility drops the sweep.\n */\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/** The sweep: muted text with a foreground highlight passing through. */\nconst SHIMMER_GRADIENT =\n \"linear-gradient(110deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)\";\n\n/**\n * The custom properties the `shimmer` utility reads. Pair with the\n * `shimmer` class on any element that should carry the sweep.\n */\nfunction shimmerStyle(\n /** Seconds taken for one pass; the utility's own default when omitted. */\n duration?: number,\n gradient: string = SHIMMER_GRADIENT\n): React.CSSProperties {\n const style: Record<string, string> = { \"--shimmer-image\": gradient };\n if (duration !== undefined) style[\"--shimmer-duration\"] = `${duration}s`;\n return style as React.CSSProperties;\n}\n\nfunction ShimmerText({\n children,\n className,\n duration,\n gradient,\n style,\n ...props\n}: Omit<React.ComponentProps<\"span\">, \"children\"> & {\n /** The current status text; a change animates the swap. */\n children: string;\n /** Seconds taken for one shimmer pass. */\n duration?: number;\n /** A CSS image overriding the default token sweep. */\n gradient?: string;\n}) {\n const reduced = useReducedMotion();\n\n return (\n <span\n data-slot=\"shimmer-text\"\n role=\"status\"\n className={cn(\n \"relative inline-grid overflow-hidden text-sm text-muted-foreground\",\n className\n )}\n style={style}\n {...props}\n >\n <AnimatePresence mode=\"popLayout\" initial={false}>\n <motion.span\n key={children}\n className=\"shimmer col-start-1 row-start-1\"\n style={shimmerStyle(duration, gradient)}\n initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6 }}\n animate={{ opacity: 1, y: 0 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}\n transition={{ duration: reduced ? 0 : 0.18, ease: \"easeOut\" }}\n >\n {children}\n </motion.span>\n </AnimatePresence>\n </span>\n );\n}\n\nexport { ShimmerText, shimmerStyle, SHIMMER_GRADIENT };\n",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"target": "components/ui/ai-shimmer-text.tsx"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
}
|