From 705b77b634f7408a1aefaf4c739034a01e2ed03a Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sat, 1 Aug 2026 14:34:40 +0700 Subject: [PATCH 01/20] feat(ui): add Phase 0 AI/agentic chat primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce presentational AI components under @constructive-io/ui/ai and the @constructive/ai registry block: composer, messages, loaders, markdown, code blocks, and stick-to-bottom chat scroll. Motion uses Constructive tokens with Beautiful UI–inspired pixel loaders and shimmer; runtime stays host-owned (no AI SDK coupling). --- packages/ui/docs/AI_COMPONENTS.md | 52 + packages/ui/package.json | 10 + packages/ui/registry.json | 1140 ++++++++++++++--- packages/ui/scripts/build-registry.ts | 12 + .../ui/src/components/ai/agent-loader.tsx | 319 +++++ .../ui/src/components/ai/chat-container.tsx | 115 ++ packages/ui/src/components/ai/code-block.tsx | 112 ++ packages/ui/src/components/ai/index.ts | 54 + packages/ui/src/components/ai/markdown.tsx | 145 +++ packages/ui/src/components/ai/message.tsx | 137 ++ .../ui/src/components/ai/prompt-input.tsx | 248 ++++ .../ui/src/components/ai/response-stream.tsx | 86 ++ .../ui/src/components/ai/scroll-button.tsx | 56 + .../ui/src/components/ai/system-message.tsx | 37 + .../ui/src/components/ai/text-shimmer.tsx | 41 + .../src/components/ai/use-throttled-text.ts | 51 + packages/ui/src/index.ts | 3 + packages/ui/src/stories/ai/Ai.stories.tsx | 159 +++ packages/ui/src/styles/globals.css | 92 ++ packages/ui/src/theme.ts | 43 + packages/ui/test/ai-markdown.test.tsx | 31 + packages/ui/test/ai-throttled-text.test.tsx | 134 ++ 22 files changed, 2873 insertions(+), 204 deletions(-) create mode 100644 packages/ui/docs/AI_COMPONENTS.md create mode 100644 packages/ui/src/components/ai/agent-loader.tsx create mode 100644 packages/ui/src/components/ai/chat-container.tsx create mode 100644 packages/ui/src/components/ai/code-block.tsx create mode 100644 packages/ui/src/components/ai/index.ts create mode 100644 packages/ui/src/components/ai/markdown.tsx create mode 100644 packages/ui/src/components/ai/message.tsx create mode 100644 packages/ui/src/components/ai/prompt-input.tsx create mode 100644 packages/ui/src/components/ai/response-stream.tsx create mode 100644 packages/ui/src/components/ai/scroll-button.tsx create mode 100644 packages/ui/src/components/ai/system-message.tsx create mode 100644 packages/ui/src/components/ai/text-shimmer.tsx create mode 100644 packages/ui/src/components/ai/use-throttled-text.ts create mode 100644 packages/ui/src/stories/ai/Ai.stories.tsx create mode 100644 packages/ui/test/ai-markdown.test.tsx create mode 100644 packages/ui/test/ai-throttled-text.test.tsx diff --git a/packages/ui/docs/AI_COMPONENTS.md b/packages/ui/docs/AI_COMPONENTS.md new file mode 100644 index 0000000..e397b8e --- /dev/null +++ b/packages/ui/docs/AI_COMPONENTS.md @@ -0,0 +1,52 @@ +# AI components + +Presentational primitives for AI and agentic chat UIs. Runtime-agnostic: hosts +wire streaming state (AI SDK, pi, custom) and pass props — no IPC, GraphQL, or +model client lives in this package. + +## Install + +```bash +pnpm add @constructive-io/ui +# or registry source install +pnpm dlx shadcn@latest add @constructive/ai +``` + +```ts +import { + AgentLoader, + ChatContainer, + Message, + PromptInput, + PromptInputTextarea, + PromptInputActions, +} from '@constructive-io/ui/ai'; +``` + +## Phase 0 surface + +| Export | Role | +| --- | --- | +| `TextShimmer` | Status text with sweeping gradient | +| `AgentLoader` / `Loader` | Pixel grid (drive/dots/orbit) + simple spinners | +| `PromptInput` family | Compound composer shell | +| `Message` family | User/assistant message layout | +| `Markdown` | Throttled zero-dep markdown subset | +| `CodeBlock` | Code surface with copy | +| `ResponseStream` | Client typewriter for fixtures | +| `ChatContainer` + `ScrollButton` | Stick-to-bottom transcript | +| `SystemMessage` | Banner notice via Alert | +| `useThrottledText` | Shared stream throttle (66ms) | + +## Design rules + +- Constructive OKLCH tokens only (no hard-coded brand hex) +- Continuous loaders use CSS keyframes (`ai-shimmer-text`, `ai-pixel-on`, …) +- Respect `prefers-reduced-motion` (package globals already clamp animations) +- Composer default shape is `rounded-xl`; pass `shape="pill"` for rounder shells +- Markdown skips full GFM tables; hosts can replace with marked+DOMPurify when needed + +## Planned next + +Reasoning / thinking traces, tool chips, approval cards, plan tracker, context +ring — see the session plan for AI agentic components. diff --git a/packages/ui/package.json b/packages/ui/package.json index 687a7e0..ce657cb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -639,6 +639,16 @@ "types": "./dist/components/toast/index.d.cts", "default": "./dist/components/toast/index.cjs" } + }, + "./ai": { + "import": { + "types": "./dist/components/ai/index.d.ts", + "default": "./dist/components/ai/index.js" + }, + "require": { + "types": "./dist/components/ai/index.d.cts", + "default": "./dist/components/ai/index.cjs" + } } }, "main": "./dist/index.cjs", diff --git a/packages/ui/registry.json b/packages/ui/registry.json index 7b13cdb..a75baef 100644 --- a/packages/ui/registry.json +++ b/packages/ui/registry.json @@ -240,6 +240,15 @@ }, ".animate-shimmer": { "animation": "shimmer 2s ease-in-out infinite" + }, + ".animate-ai-shimmer-text": { + "animation": "ai-shimmer-text 1.4s linear infinite" + }, + ".animate-ai-pixel-on": { + "animation": "ai-pixel-on 650ms ease-in-out infinite" + }, + ".animate-ai-fade-up": { + "animation": "ai-fade-up 300ms cubic-bezier(0.23, 1, 0.32, 1) both" } }, "@keyframes pulse-glow": { @@ -339,6 +348,81 @@ "transform": "translateX(100%)" } }, + "@keyframes ai-shimmer-text": { + "0%": { + "background-position": "100% 0" + }, + "100%": { + "background-position": "-100% 0" + } + }, + "@keyframes ai-pixel-on": { + "0%, 100%": { + "opacity": "0.15" + }, + "40%, 60%": { + "opacity": "0.95" + } + }, + "@keyframes ai-fade-up": { + "from": { + "opacity": "0", + "transform": "translateY(6px)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes ai-bounce-dots": { + "0%, 80%, 100%": { + "transform": "scale(0.6)", + "opacity": "0.4" + }, + "40%": { + "transform": "scale(1)", + "opacity": "1" + } + }, + "@keyframes ai-typing": { + "0%, 60%, 100%": { + "transform": "translateY(0)", + "opacity": "0.4" + }, + "30%": { + "transform": "translateY(-3px)", + "opacity": "1" + } + }, + "@keyframes ai-wave": { + "0%, 100%": { + "transform": "scaleY(0.5)" + }, + "50%": { + "transform": "scaleY(1)" + } + }, + "@keyframes ai-pulse-dot": { + "0%, 100%": { + "transform": "scale(0.85)", + "opacity": "0.5" + }, + "50%": { + "transform": "scale(1.1)", + "opacity": "1" + } + }, + "@keyframes ai-loading-dots": { + "0%, 20%": { + "opacity": "0" + }, + "40%": { + "opacity": "1" + }, + "100%": { + "opacity": "0" + } + }, "[data-slot=\"portal-root\"] > *": { "pointer-events": "auto" } @@ -350,9 +434,14 @@ "type": "registry:lib", "title": "cn utility", "description": "Tailwind CSS class name merge utility using clsx and tailwind-merge.", - "categories": ["utility"], + "categories": [ + "utility" + ], "docs": "Import: `import { cn } from '@/lib/utils'`", - "dependencies": ["clsx", "tailwind-merge"], + "dependencies": [ + "clsx", + "tailwind-merge" + ], "files": [ { "path": "registry/constructive/lib/utils.ts", @@ -365,7 +454,9 @@ "type": "registry:lib", "title": "Slot utility", "description": "Polymorphic component utilities for asChild pattern.", - "categories": ["utility"], + "categories": [ + "utility" + ], "docs": "Import: `import { Slot } from '@/lib/slot'`", "files": [ { @@ -379,7 +470,9 @@ "type": "registry:hook", "title": "useControllableState", "description": "Hook for components with controlled/uncontrolled state.", - "categories": ["hook"], + "categories": [ + "hook" + ], "docs": "Import: `import { useControllableState } from '@/hooks/use-controllable-state'`", "files": [ { @@ -393,7 +486,9 @@ "type": "registry:hook", "title": "useDebounce", "description": "Hook for debouncing values.", - "categories": ["hook"], + "categories": [ + "hook" + ], "docs": "Import: `import { useDebounce } from '@/hooks/use-debounce'`", "files": [ { @@ -407,7 +502,9 @@ "type": "registry:hook", "title": "useIsMobile", "description": "Hook for detecting mobile viewport.", - "categories": ["hook"], + "categories": [ + "hook" + ], "docs": "Import: `import { useIsMobile } from '@/hooks/use-mobile'`", "files": [ { @@ -421,9 +518,13 @@ "type": "registry:lib", "title": "Motion Config", "description": "Animation presets and spring configurations for motion library.", - "categories": ["animation"], + "categories": [ + "animation" + ], "docs": "Import: `import { motionConfig } from '@/lib/motion/motion-config'`", - "dependencies": ["motion"], + "dependencies": [ + "motion" + ], "files": [ { "path": "registry/constructive/lib/motion/motion-config.ts", @@ -436,10 +537,17 @@ "type": "registry:ui", "title": "Button", "description": "A button component with multiple variants and sizes.", - "categories": ["primitive"], + "categories": [ + "primitive" + ], "docs": "Import: `import { Button } from '@/components/ui/button'`", - "dependencies": ["class-variance-authority"], - "registryDependencies": ["cn", "slot"], + "dependencies": [ + "class-variance-authority" + ], + "registryDependencies": [ + "cn", + "slot" + ], "files": [ { "path": "registry/constructive/ui/button.tsx", @@ -452,10 +560,16 @@ "type": "registry:ui", "title": "Badge", "description": "A badge component for status indicators and labels.", - "categories": ["primitive"], + "categories": [ + "primitive" + ], "docs": "Import: `import { Badge } from '@/components/ui/badge'`", - "dependencies": ["class-variance-authority"], - "registryDependencies": ["cn"], + "dependencies": [ + "class-variance-authority" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/badge.tsx", @@ -468,9 +582,13 @@ "type": "registry:ui", "title": "Label", "description": "A label component for form fields.", - "categories": ["primitive"], + "categories": [ + "primitive" + ], "docs": "Import: `import { Label } from '@/components/ui/label'`", - "registryDependencies": ["cn"], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/label.tsx", @@ -483,9 +601,13 @@ "type": "registry:ui", "title": "Skeleton", "description": "A skeleton loading placeholder component.", - "categories": ["primitive"], + "categories": [ + "primitive" + ], "docs": "Import: `import { Skeleton } from '@/components/ui/skeleton'`", - "registryDependencies": ["cn"], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/skeleton.tsx", @@ -498,10 +620,16 @@ "type": "registry:ui", "title": "Card", "description": "A card component with header, content, and footer sections.", - "categories": ["primitive"], + "categories": [ + "primitive" + ], "docs": "Import: `import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/card'`", - "dependencies": ["class-variance-authority"], - "registryDependencies": ["cn"], + "dependencies": [ + "class-variance-authority" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/card.tsx", @@ -514,10 +642,16 @@ "type": "registry:ui", "title": "Separator", "description": "A visual separator for dividing content.", - "categories": ["primitive"], + "categories": [ + "primitive" + ], "docs": "Import: `import { Separator } from '@/components/ui/separator'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/separator.tsx", @@ -530,10 +664,16 @@ "type": "registry:ui", "title": "Alert", "description": "An alert component for displaying messages.", - "categories": ["primitive"], + "categories": [ + "primitive" + ], "docs": "Import: `import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'`", - "dependencies": ["class-variance-authority"], - "registryDependencies": ["cn"], + "dependencies": [ + "class-variance-authority" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/alert.tsx", @@ -546,10 +686,16 @@ "type": "registry:ui", "title": "Input", "description": "A text input component built on Base UI.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { Input } from '@/components/ui/input'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/input.tsx", @@ -562,9 +708,13 @@ "type": "registry:ui", "title": "Textarea", "description": "A multiline text input component.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { Textarea } from '@/components/ui/textarea'`", - "registryDependencies": ["cn"], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/textarea.tsx", @@ -577,10 +727,16 @@ "type": "registry:ui", "title": "Checkbox", "description": "A checkbox component built on Base UI.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { Checkbox } from '@/components/ui/checkbox'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/checkbox.tsx", @@ -593,10 +749,16 @@ "type": "registry:ui", "title": "Checkbox Group", "description": "A group wrapper for multiple checkboxes.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { CheckboxGroup } from '@/components/ui/checkbox-group'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["checkbox"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "checkbox" + ], "files": [ { "path": "registry/constructive/ui/checkbox-group.tsx", @@ -609,10 +771,16 @@ "type": "registry:ui", "title": "Radio Group", "description": "A radio group component built on Base UI.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { RadioGroup } from '@/components/ui/radio-group'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/radio-group.tsx", @@ -625,10 +793,16 @@ "type": "registry:ui", "title": "Switch", "description": "A toggle switch component built on Base UI.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { Switch } from '@/components/ui/switch'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/switch.tsx", @@ -641,10 +815,16 @@ "type": "registry:ui", "title": "Progress", "description": "A progress bar component built on Base UI.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { Progress } from '@/components/ui/progress'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/progress.tsx", @@ -657,10 +837,16 @@ "type": "registry:ui", "title": "Avatar", "description": "An avatar component with image and fallback support.", - "categories": ["data-display"], + "categories": [ + "data-display" + ], "docs": "Import: `import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/avatar.tsx", @@ -673,7 +859,9 @@ "type": "registry:ui", "title": "Portal", "description": "Portal management for overlay components with z-index layering.", - "categories": ["layout"], + "categories": [ + "layout" + ], "docs": "Import: `import { Portal } from '@/components/ui/portal'`", "registryDependencies": [], "files": [ @@ -688,10 +876,16 @@ "type": "registry:ui", "title": "Scroll Area", "description": "A custom scrollable area component built on Base UI.", - "categories": ["layout"], + "categories": [ + "layout" + ], "docs": "Import: `import { ScrollArea } from '@/components/ui/scroll-area'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/scroll-area.tsx", @@ -704,10 +898,18 @@ "type": "registry:ui", "title": "Select", "description": "A select/dropdown component built on Base UI.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { Select } from '@/components/ui/select'`", - "dependencies": ["@base-ui/react", "lucide-react"], - "registryDependencies": ["cn", "portal"], + "dependencies": [ + "@base-ui/react", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "portal" + ], "files": [ { "path": "registry/constructive/ui/select.tsx", @@ -720,10 +922,17 @@ "type": "registry:ui", "title": "Tooltip", "description": "A tooltip component built on Base UI.", - "categories": ["overlay"], + "categories": [ + "overlay" + ], "docs": "Import: `import { Tooltip } from '@/components/ui/tooltip'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn", "portal"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn", + "portal" + ], "files": [ { "path": "registry/constructive/ui/tooltip.tsx", @@ -736,10 +945,17 @@ "type": "registry:ui", "title": "Popover", "description": "A popover component built on Base UI.", - "categories": ["overlay"], + "categories": [ + "overlay" + ], "docs": "Import: `import { Popover } from '@/components/ui/popover'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn", "portal"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn", + "portal" + ], "files": [ { "path": "registry/constructive/ui/popover.tsx", @@ -752,10 +968,20 @@ "type": "registry:ui", "title": "Dialog", "description": "A modal dialog component built on Base UI.", - "categories": ["overlay"], + "categories": [ + "overlay" + ], "docs": "Import: `import { Dialog } from '@/components/ui/dialog'`", - "dependencies": ["@base-ui/react", "lucide-react"], - "registryDependencies": ["cn", "portal", "button", "scroll-area"], + "dependencies": [ + "@base-ui/react", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "portal", + "button", + "scroll-area" + ], "files": [ { "path": "registry/constructive/ui/dialog.tsx", @@ -768,10 +994,18 @@ "type": "registry:ui", "title": "Alert Dialog", "description": "An alert dialog for confirmations built on Base UI.", - "categories": ["overlay"], + "categories": [ + "overlay" + ], "docs": "Import: `import { AlertDialog } from '@/components/ui/alert-dialog'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn", "portal", "button"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn", + "portal", + "button" + ], "files": [ { "path": "registry/constructive/ui/alert-dialog.tsx", @@ -784,10 +1018,18 @@ "type": "registry:ui", "title": "Dropdown Menu", "description": "A dropdown menu component built on Base UI.", - "categories": ["overlay"], + "categories": [ + "overlay" + ], "docs": "Import: `import { DropdownMenu } from '@/components/ui/dropdown-menu'`", - "dependencies": ["@base-ui/react", "lucide-react"], - "registryDependencies": ["cn", "portal"], + "dependencies": [ + "@base-ui/react", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "portal" + ], "files": [ { "path": "registry/constructive/ui/dropdown-menu.tsx", @@ -800,10 +1042,21 @@ "type": "registry:ui", "title": "Sheet", "description": "A slide-out panel component with animations.", - "categories": ["overlay"], + "categories": [ + "overlay" + ], "docs": "Import: `import { Sheet } from '@/components/ui/sheet'`", - "dependencies": ["@base-ui/react", "class-variance-authority", "lucide-react", "motion"], - "registryDependencies": ["cn", "portal", "motion-config"], + "dependencies": [ + "@base-ui/react", + "class-variance-authority", + "lucide-react", + "motion" + ], + "registryDependencies": [ + "cn", + "portal", + "motion-config" + ], "files": [ { "path": "registry/constructive/ui/sheet.tsx", @@ -816,10 +1069,17 @@ "type": "registry:ui", "title": "Drawer", "description": "A bottom drawer component using Vaul.", - "categories": ["overlay"], + "categories": [ + "overlay" + ], "docs": "Import: `import { Drawer } from '@/components/ui/drawer'`", - "dependencies": ["vaul"], - "registryDependencies": ["cn", "portal"], + "dependencies": [ + "vaul" + ], + "registryDependencies": [ + "cn", + "portal" + ], "files": [ { "path": "registry/constructive/ui/drawer.tsx", @@ -832,10 +1092,16 @@ "type": "registry:ui", "title": "Tabs", "description": "A tabs component built on Base UI.", - "categories": ["layout"], + "categories": [ + "layout" + ], "docs": "Import: `import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/tabs.tsx", @@ -848,10 +1114,16 @@ "type": "registry:ui", "title": "Collapsible", "description": "A collapsible/accordion component built on Base UI.", - "categories": ["layout"], + "categories": [ + "layout" + ], "docs": "Import: `import { Collapsible } from '@/components/ui/collapsible'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/collapsible.tsx", @@ -864,10 +1136,17 @@ "type": "registry:ui", "title": "Resizable", "description": "Resizable panel layouts using react-resizable-panels.", - "categories": ["layout"], + "categories": [ + "layout" + ], "docs": "Import: `import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'`", - "dependencies": ["lucide-react", "react-resizable-panels@^3.0.0"], - "registryDependencies": ["cn"], + "dependencies": [ + "lucide-react", + "react-resizable-panels@^3.0.0" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/resizable.tsx", @@ -880,9 +1159,13 @@ "type": "registry:ui", "title": "Table", "description": "A table component with header, body, and cell primitives.", - "categories": ["data-display"], + "categories": [ + "data-display" + ], "docs": "Import: `import { Table, TableHeader, TableBody, TableRow, TableCell } from '@/components/ui/table'`", - "registryDependencies": ["cn"], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/table.tsx", @@ -895,10 +1178,17 @@ "type": "registry:ui", "title": "Pagination", "description": "A pagination component with navigation controls.", - "categories": ["navigation"], + "categories": [ + "navigation" + ], "docs": "Import: `import { Pagination } from '@/components/ui/pagination'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["cn", "button"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn", + "button" + ], "files": [ { "path": "registry/constructive/ui/pagination.tsx", @@ -911,10 +1201,16 @@ "type": "registry:ui", "title": "Breadcrumb", "description": "A breadcrumb navigation component.", - "categories": ["navigation"], + "categories": [ + "navigation" + ], "docs": "Import: `import { Breadcrumb } from '@/components/ui/breadcrumb'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["cn"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/breadcrumb.tsx", @@ -927,10 +1223,16 @@ "type": "registry:ui", "title": "Form", "description": "Form utilities with react-hook-form integration.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'`", - "dependencies": ["react-hook-form"], - "registryDependencies": ["cn"], + "dependencies": [ + "react-hook-form" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/form.tsx", @@ -943,9 +1245,15 @@ "type": "registry:ui", "title": "Form Control", "description": "A form control wrapper with label and error display.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { FormControl } from '@/components/ui/form-control'`", - "registryDependencies": ["cn", "slot", "label"], + "registryDependencies": [ + "cn", + "slot", + "label" + ], "files": [ { "path": "registry/constructive/ui/form-control.tsx", @@ -958,10 +1266,17 @@ "type": "registry:ui", "title": "Input Group", "description": "Input with addons and icons.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { InputGroup } from '@/components/ui/input-group'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["cn", "input"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn", + "input" + ], "files": [ { "path": "registry/constructive/ui/input-group.tsx", @@ -974,10 +1289,20 @@ "type": "registry:ui", "title": "Autocomplete", "description": "An autocomplete component built on Base UI.", - "categories": ["input"], + "categories": [ + "input" + ], "docs": "Import: `import { Autocomplete } from '@/components/ui/autocomplete'`", - "dependencies": ["@base-ui/react", "lucide-react"], - "registryDependencies": ["cn", "portal", "input", "scroll-area"], + "dependencies": [ + "@base-ui/react", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "portal", + "input", + "scroll-area" + ], "files": [ { "path": "registry/constructive/ui/autocomplete.tsx", @@ -990,10 +1315,18 @@ "type": "registry:ui", "title": "Command", "description": "A command palette component.", - "categories": ["overlay"], + "categories": [ + "overlay" + ], "docs": "Import: `import { Command } from '@/components/ui/command'`", - "dependencies": ["cmdk", "lucide-react"], - "registryDependencies": ["cn", "portal"], + "dependencies": [ + "cmdk", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "portal" + ], "files": [ { "path": "registry/constructive/ui/command.tsx", @@ -1006,10 +1339,20 @@ "type": "registry:ui", "title": "Combobox", "description": "A combobox component with search built on Base UI.", - "categories": ["input"], + "categories": [ + "input" + ], "docs": "Import: `import { Combobox } from '@/components/ui/combobox'`", - "dependencies": ["@base-ui/react", "lucide-react"], - "registryDependencies": ["cn", "portal", "input", "scroll-area"], + "dependencies": [ + "@base-ui/react", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "portal", + "input", + "scroll-area" + ], "files": [ { "path": "registry/constructive/ui/combobox.tsx", @@ -1022,10 +1365,20 @@ "type": "registry:ui", "title": "Multi Select", "description": "A multi-select component with tags.", - "categories": ["input"], + "categories": [ + "input" + ], "docs": "Import: `import { MultiSelect } from '@/components/ui/multi-select'`", - "dependencies": ["class-variance-authority", "lucide-react"], - "registryDependencies": ["cn", "badge", "popover", "separator"], + "dependencies": [ + "class-variance-authority", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "badge", + "popover", + "separator" + ], "files": [ { "path": "registry/constructive/ui/multi-select.tsx", @@ -1038,10 +1391,20 @@ "type": "registry:ui", "title": "Tags", "description": "A tags input component with autocomplete.", - "categories": ["input"], + "categories": [ + "input" + ], "docs": "Import: `import { Tags } from '@/components/ui/tags'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["cn", "badge", "button", "command", "popover"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn", + "badge", + "button", + "command", + "popover" + ], "files": [ { "path": "registry/constructive/ui/tags.tsx", @@ -1054,10 +1417,20 @@ "type": "registry:ui", "title": "Record Picker", "description": "A record selection component with search and filtering.", - "categories": ["input"], + "categories": [ + "input" + ], "docs": "Import: `import { RecordPicker } from '@/components/ui/record-picker'`", - "dependencies": ["@remixicon/react", "match-sorter"], - "registryDependencies": ["use-debounce", "checkbox", "input-group", "scroll-area"], + "dependencies": [ + "@remixicon/react", + "match-sorter" + ], + "registryDependencies": [ + "use-debounce", + "checkbox", + "input-group", + "scroll-area" + ], "files": [ { "path": "registry/constructive/ui/record-picker.tsx", @@ -1070,10 +1443,16 @@ "type": "registry:ui", "title": "Stepper", "description": "A stepper component for multi-step workflows.", - "categories": ["navigation"], + "categories": [ + "navigation" + ], "docs": "Import: `import { Stepper } from '@/components/ui/stepper'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["cn"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/stepper.tsx", @@ -1086,10 +1465,25 @@ "type": "registry:ui", "title": "Sidebar", "description": "A responsive Base UI sidebar with off-canvas, icon-only, and fixed collapse modes.", - "categories": ["navigation"], + "categories": [ + "navigation" + ], "docs": "Import: `import { Sidebar, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'`", - "dependencies": ["@base-ui/react", "class-variance-authority", "lucide-react"], - "registryDependencies": ["cn", "use-mobile", "button", "input", "separator", "sheet", "skeleton", "tooltip"], + "dependencies": [ + "@base-ui/react", + "class-variance-authority", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "use-mobile", + "button", + "input", + "separator", + "sheet", + "skeleton", + "tooltip" + ], "files": [ { "path": "registry/constructive/ui/sidebar.tsx", @@ -1102,10 +1496,17 @@ "type": "registry:ui", "title": "Calendar", "description": "A calendar component using React Aria Components.", - "categories": ["input"], + "categories": [ + "input" + ], "docs": "Import: `import { Calendar } from '@/components/ui/calendar-rac'`", - "dependencies": ["@internationalized/date", "react-aria-components"], - "registryDependencies": ["cn"], + "dependencies": [ + "@internationalized/date", + "react-aria-components" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/calendar-rac.tsx", @@ -1118,10 +1519,20 @@ "type": "registry:ui", "title": "JSON Input", "description": "A JSON editor component using React Ace.", - "categories": ["input"], + "categories": [ + "input" + ], "docs": "Import: `import { JsonInput } from '@/components/ui/json-input'`", - "dependencies": ["lucide-react", "react-ace", "ace-builds"], - "registryDependencies": ["cn", "button", "tooltip"], + "dependencies": [ + "lucide-react", + "react-ace", + "ace-builds" + ], + "registryDependencies": [ + "cn", + "button", + "tooltip" + ], "files": [ { "path": "registry/constructive/ui/json-input.tsx", @@ -1134,9 +1545,13 @@ "type": "registry:ui", "title": "Toaster", "description": "Toast notifications using Sonner.", - "categories": ["block"], + "categories": [ + "block" + ], "docs": "Import: `import { Toaster } from '@/components/ui/sonner'`", - "dependencies": ["sonner"], + "dependencies": [ + "sonner" + ], "registryDependencies": [], "files": [ { @@ -1150,10 +1565,17 @@ "type": "registry:ui", "title": "Dock", "description": "A macOS-style dock component.", - "categories": ["navigation"], + "categories": [ + "navigation" + ], "docs": "Import: `import { Dock, DockIcon } from '@/components/ui/dock'`", - "dependencies": ["class-variance-authority", "motion"], - "registryDependencies": ["cn"], + "dependencies": [ + "class-variance-authority", + "motion" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/dock.tsx", @@ -1166,7 +1588,9 @@ "type": "registry:ui", "title": "Page Header", "description": "A simple page header component.", - "categories": ["navigation"], + "categories": [ + "navigation" + ], "docs": "Import: `import { PageHeader } from '@/components/ui/page-header'`", "registryDependencies": [], "files": [ @@ -1181,9 +1605,13 @@ "type": "registry:ui", "title": "Flickering Grid", "description": "An animated grid background effect.", - "categories": ["effect"], + "categories": [ + "effect" + ], "docs": "Import: `import { FlickeringGrid } from '@/components/ui/flickering-grid'`", - "registryDependencies": ["cn"], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/flickering-grid.tsx", @@ -1196,10 +1624,18 @@ "type": "registry:ui", "title": "Flow Zoom Panel", "description": "Zoom in/out and fit-view controls for React Flow canvases.", - "categories": ["layout"], + "categories": [ + "layout" + ], "docs": "Import: `import { FlowZoomPanel } from '@/components/ui/flow-zoom-panel'`", - "dependencies": ["@xyflow/react", "@remixicon/react"], - "registryDependencies": ["cn", "button"], + "dependencies": [ + "@xyflow/react", + "@remixicon/react" + ], + "registryDependencies": [ + "cn", + "button" + ], "files": [ { "path": "registry/constructive/ui/flow-zoom-panel.tsx", @@ -1212,10 +1648,16 @@ "type": "registry:ui", "title": "Motion Grid", "description": "An animated grid with frame-based transitions.", - "categories": ["effect"], + "categories": [ + "effect" + ], "docs": "Import: `import { MotionGrid } from '@/components/ui/motion-grid'`", - "dependencies": ["motion"], - "registryDependencies": ["cn"], + "dependencies": [ + "motion" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/motion-grid.tsx", @@ -1228,9 +1670,13 @@ "type": "registry:ui", "title": "Progressive Blur", "description": "A gradient blur overlay effect.", - "categories": ["effect"], + "categories": [ + "effect" + ], "docs": "Import: `import { ProgressiveBlur } from '@/components/ui/progressive-blur'`", - "registryDependencies": ["cn"], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/progressive-blur.tsx", @@ -1243,9 +1689,14 @@ "type": "registry:ui", "title": "Progressive Blur Scroll Container", "description": "A scroll container with blur effects at edges.", - "categories": ["effect"], + "categories": [ + "effect" + ], "docs": "Import: `import { ProgressiveBlurScrollContainer } from '@/components/ui/progressive-blur-scroll-container'`", - "registryDependencies": ["cn", "progressive-blur"], + "registryDependencies": [ + "cn", + "progressive-blur" + ], "files": [ { "path": "registry/constructive/ui/progressive-blur-scroll-container.tsx", @@ -1258,9 +1709,13 @@ "type": "registry:ui", "title": "Responsive Diagram", "description": "A responsive diagram container with auto-scaling.", - "categories": ["effect"], + "categories": [ + "effect" + ], "docs": "Import: `import { ResponsiveDiagram } from '@/components/ui/responsive-diagram'`", - "registryDependencies": ["cn"], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/responsive-diagram.tsx", @@ -1273,10 +1728,17 @@ "type": "registry:ui", "title": "Unlink Button", "description": "A button for unlinking/removing records.", - "categories": ["action"], + "categories": [ + "action" + ], "docs": "Import: `import { UnlinkButton } from '@/components/ui/unlink-button'`", - "dependencies": ["@remixicon/react"], - "registryDependencies": ["cn", "button"], + "dependencies": [ + "@remixicon/react" + ], + "registryDependencies": [ + "cn", + "button" + ], "files": [ { "path": "registry/constructive/ui/unlink-button.tsx", @@ -1289,10 +1751,25 @@ "type": "registry:block", "title": "Org Chart", "description": "An interactive organizational chart with automatic hierarchy layout, zoom controls, node actions, and drag-to-reparent behavior.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Import: `import { OrgChart } from '@/components/ui/org-chart'`", - "dependencies": ["@xyflow/react", "@remixicon/react", "lucide-react"], - "registryDependencies": ["cn", "button", "tooltip", "avatar", "badge", "dropdown-menu", "flow-zoom-panel"], + "dependencies": [ + "@xyflow/react", + "@remixicon/react", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "button", + "tooltip", + "avatar", + "badge", + "dropdown-menu", + "flow-zoom-panel" + ], "files": [ { "path": "registry/constructive/blocks/org-chart/index.ts", @@ -1337,10 +1814,22 @@ "type": "registry:block", "title": "Storage Foundation", "description": "Shared storage types, formatters, file icons, status badges, breadcrumbs, and object-toolbar primitives.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Install this transitively through `storage-browser`, or import its files directly from `@/components/ui/storage/*`.", - "dependencies": ["lucide-react"], - "registryDependencies": ["cn", "badge", "breadcrumb", "button", "dropdown-menu", "input-group"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn", + "badge", + "breadcrumb", + "button", + "dropdown-menu", + "input-group" + ], "files": [ { "path": "registry/constructive/blocks/storage/types.ts", @@ -1373,10 +1862,22 @@ "type": "registry:block", "title": "Storage Bucket Rail", "description": "A controlled, scrollable bucket list with visibility, object-count, selection, and create-bucket affordances.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Import: `import { BucketRail } from '@/components/ui/storage/bucket-rail'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["storage", "cn", "badge", "button", "scroll-area", "separator"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "storage", + "cn", + "badge", + "button", + "scroll-area", + "separator" + ], "files": [ { "path": "registry/constructive/blocks/storage/bucket-rail.tsx", @@ -1389,10 +1890,22 @@ "type": "registry:block", "title": "Storage Object Table", "description": "A controlled object table with sorting, selection, row actions, skeleton loading, and empty results.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Import: `import { ObjectTable } from '@/components/ui/storage/object-table'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["storage", "button", "checkbox", "dropdown-menu", "skeleton", "table"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "storage", + "button", + "checkbox", + "dropdown-menu", + "skeleton", + "table" + ], "files": [ { "path": "registry/constructive/blocks/storage/object-table.tsx", @@ -1405,10 +1918,20 @@ "type": "registry:block", "title": "Storage Upload Dropzone", "description": "A drag-and-drop upload target with a browse action and an in-flight upload progress list.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Import: `import { UploadDropzone } from '@/components/ui/storage/upload-dropzone'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["storage", "cn", "button", "progress"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "storage", + "cn", + "button", + "progress" + ], "files": [ { "path": "registry/constructive/blocks/storage/upload-dropzone.tsx", @@ -1421,10 +1944,24 @@ "type": "registry:block", "title": "Storage Object Detail Sheet", "description": "A controlled object-detail sheet with preview, metadata, rename, download, link, and confirmed delete actions.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Import: `import { ObjectDetailSheet } from '@/components/ui/storage/object-detail-sheet'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["storage", "cn", "alert-dialog", "button", "input", "separator", "skeleton", "sheet"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "storage", + "cn", + "alert-dialog", + "button", + "input", + "separator", + "skeleton", + "sheet" + ], "files": [ { "path": "registry/constructive/blocks/storage/object-detail-sheet.tsx", @@ -1437,10 +1974,26 @@ "type": "registry:block", "title": "Storage Bucket Config Sheet", "description": "A create-or-edit bucket form with visibility and optional controls gated by the host schema.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Import: `import { BucketConfigSheet } from '@/components/ui/storage/bucket-config-sheet'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["storage", "cn", "badge", "button", "input", "label", "radio-group", "switch", "textarea", "sheet"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "storage", + "cn", + "badge", + "button", + "input", + "label", + "radio-group", + "switch", + "textarea", + "sheet" + ], "files": [ { "path": "registry/constructive/blocks/storage/bucket-config-sheet.tsx", @@ -1453,10 +2006,18 @@ "type": "registry:block", "title": "Storage Empty State", "description": "Accessible empty and unavailable states for missing buckets, unprovisioned storage, empty buckets, and denied access.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Import: `import { StorageEmptyState } from '@/components/ui/storage/storage-empty-state'`", - "dependencies": ["lucide-react"], - "registryDependencies": ["cn", "button"], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn", + "button" + ], "files": [ { "path": "registry/constructive/blocks/storage/storage-empty-state.tsx", @@ -1469,9 +2030,22 @@ "type": "registry:block", "title": "Storage Browser", "description": "A complete controlled storage workspace with bucket navigation, object discovery, upload, configuration, detail, and empty states.", - "categories": ["block", "application"], + "categories": [ + "block", + "application" + ], "docs": "Import: `import { StorageBrowser } from '@/components/ui/storage'`", - "registryDependencies": ["storage", "storage-bucket-rail", "storage-object-table", "storage-upload-dropzone", "storage-object-detail-sheet", "storage-bucket-config-sheet", "storage-empty-state", "cn", "separator"], + "registryDependencies": [ + "storage", + "storage-bucket-rail", + "storage-object-table", + "storage-upload-dropzone", + "storage-object-detail-sheet", + "storage-bucket-config-sheet", + "storage-empty-state", + "cn", + "separator" + ], "files": [ { "path": "registry/constructive/blocks/storage/index.ts", @@ -1488,10 +2062,18 @@ "type": "registry:block", "title": "Stack Navigation", "description": "iOS-style navigation stack with gestures and animations.", - "categories": ["block"], + "categories": [ + "block" + ], "docs": "Import: `import { Stack, StackCard } from '@/components/ui/stack'`", - "dependencies": ["motion", "@use-gesture/react", "lucide-react"], - "registryDependencies": ["cn"], + "dependencies": [ + "motion", + "@use-gesture/react", + "lucide-react" + ], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/blocks/stack/index.ts", @@ -1552,10 +2134,17 @@ "type": "registry:block", "title": "Toast Notifications", "description": "Styled toast notification functions using Sonner.", - "categories": ["block"], + "categories": [ + "block" + ], "docs": "Import: `import { toastSuccess, toastError, toastWarning, toastInfo } from '@/components/ui/toast'`", - "dependencies": ["sonner", "lucide-react"], - "registryDependencies": ["button"], + "dependencies": [ + "sonner", + "lucide-react" + ], + "registryDependencies": [ + "button" + ], "files": [ { "path": "registry/constructive/blocks/toast/index.ts", @@ -1584,10 +2173,20 @@ "type": "registry:block", "title": "App Bar", "description": "A sticky application header with a sidebar trigger, responsive breadcrumbs, and framework-neutral search and action slots.", - "categories": ["block", "navigation"], + "categories": [ + "block", + "navigation" + ], "docs": "Import: `import { AppBar } from '@/components/ui/app-bar'`", - "dependencies": ["@base-ui/react"], - "registryDependencies": ["cn", "breadcrumb", "separator", "sidebar"], + "dependencies": [ + "@base-ui/react" + ], + "registryDependencies": [ + "cn", + "breadcrumb", + "separator", + "sidebar" + ], "files": [ { "path": "registry/constructive/blocks/app-bar.tsx", @@ -1600,10 +2199,23 @@ "type": "registry:block", "title": "App Shell", "description": "A provider-neutral application shell with typed navigation, brand, account, breadcrumb, and link-render contracts.", - "categories": ["block", "navigation"], + "categories": [ + "block", + "navigation" + ], "docs": "Import: `import { AppShell } from '@/components/ui/app-shell'`", - "dependencies": ["@base-ui/react", "lucide-react"], - "registryDependencies": ["cn", "app-bar", "avatar", "collapsible", "dropdown-menu", "sidebar"], + "dependencies": [ + "@base-ui/react", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "app-bar", + "avatar", + "collapsible", + "dropdown-menu", + "sidebar" + ], "files": [ { "path": "registry/constructive/blocks/app-shell.tsx", @@ -1616,9 +2228,15 @@ "type": "registry:ui", "title": "Field", "description": "Composable form layout with groups, fieldsets, legends, labels, descriptions, separators, and accessible errors.", - "categories": ["form"], + "categories": [ + "form" + ], "docs": "Import: `import { Field, FieldGroup, FieldLabel, FieldDescription, FieldError } from '@/components/ui/field'`", - "registryDependencies": ["cn", "label", "separator"], + "registryDependencies": [ + "cn", + "label", + "separator" + ], "files": [ { "path": "registry/constructive/ui/field.tsx", @@ -1631,9 +2249,13 @@ "type": "registry:ui", "title": "Empty", "description": "A composable empty-state layout with media, title, description, and action content.", - "categories": ["layout"], + "categories": [ + "layout" + ], "docs": "Import: `import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent } from '@/components/ui/empty'`", - "registryDependencies": ["cn"], + "registryDependencies": [ + "cn" + ], "files": [ { "path": "registry/constructive/ui/empty.tsx", @@ -1641,13 +2263,100 @@ } ] }, + { + "name": "ai", + "type": "registry:block", + "title": "AI Kit", + "description": "Presentational primitives for AI and agentic chat UIs: composer, messages, loaders, markdown, code blocks, and transcript scroll.", + "categories": [ + "block", + "ai" + ], + "docs": "Import: `import { PromptInput, Message, AgentLoader, ChatContainer } from '@constructive-io/ui/ai'` or `@/components/ui/ai`.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn", + "button", + "avatar", + "tooltip", + "alert" + ], + "files": [ + { + "path": "registry/constructive/blocks/ai/index.ts", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/text-shimmer.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/agent-loader.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/prompt-input.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/message.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/markdown.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/code-block.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/response-stream.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/chat-container.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/scroll-button.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/system-message.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/ai/use-throttled-text.ts", + "type": "registry:component" + } + ] + }, { "name": "form-kit", "type": "registry:ui", "title": "Form Kit", "description": "All form-related components: input, textarea, checkbox, radio, select, switch, progress, form controls.", - "categories": ["bundle"], - "registryDependencies": ["input", "textarea", "checkbox", "checkbox-group", "radio-group", "switch", "select", "progress", "form", "form-control", "input-group", "field", "label"], + "categories": [ + "bundle" + ], + "registryDependencies": [ + "input", + "textarea", + "checkbox", + "checkbox-group", + "radio-group", + "switch", + "select", + "progress", + "form", + "form-control", + "input-group", + "field", + "label" + ], "files": [] }, { @@ -1655,8 +2364,19 @@ "type": "registry:ui", "title": "Overlay Kit", "description": "All overlay components: dialog, sheet, drawer, popover, tooltip, dropdown menu, alert dialog.", - "categories": ["bundle"], - "registryDependencies": ["dialog", "sheet", "drawer", "popover", "tooltip", "dropdown-menu", "alert-dialog", "command"], + "categories": [ + "bundle" + ], + "registryDependencies": [ + "dialog", + "sheet", + "drawer", + "popover", + "tooltip", + "dropdown-menu", + "alert-dialog", + "command" + ], "files": [] }, { @@ -1664,8 +2384,20 @@ "type": "registry:ui", "title": "Layout Kit", "description": "Layout and navigation components: tabs, sidebar, collapsible, resizable panels, scroll area, breadcrumb, pagination.", - "categories": ["bundle"], - "registryDependencies": ["tabs", "sidebar", "collapsible", "resizable", "scroll-area", "breadcrumb", "pagination", "separator", "empty"], + "categories": [ + "bundle" + ], + "registryDependencies": [ + "tabs", + "sidebar", + "collapsible", + "resizable", + "scroll-area", + "breadcrumb", + "pagination", + "separator", + "empty" + ], "files": [] } ] diff --git a/packages/ui/scripts/build-registry.ts b/packages/ui/scripts/build-registry.ts index 5c8f376..53b68b1 100644 --- a/packages/ui/scripts/build-registry.ts +++ b/packages/ui/scripts/build-registry.ts @@ -98,6 +98,18 @@ const clientFiles = new Set([ 'storage-empty-state.tsx', 'upload-dropzone.tsx', 'use-mobile.ts', + // AI / agentic + 'text-shimmer.tsx', + 'agent-loader.tsx', + 'prompt-input.tsx', + 'message.tsx', + 'markdown.tsx', + 'code-block.tsx', + 'response-stream.tsx', + 'chat-container.tsx', + 'scroll-button.tsx', + 'system-message.tsx', + 'use-throttled-text.ts', ]); function sourcePathForRegistryPath(registryPath: string): string { diff --git a/packages/ui/src/components/ai/agent-loader.tsx b/packages/ui/src/components/ai/agent-loader.tsx new file mode 100644 index 0000000..526e7ee --- /dev/null +++ b/packages/ui/src/components/ai/agent-loader.tsx @@ -0,0 +1,319 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { TextShimmer } from './text-shimmer'; + +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; + +function subscribeToReducedMotion(onStoreChange: () => void) { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return () => undefined; + } + const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY); + mediaQuery.addEventListener('change', onStoreChange); + return () => mediaQuery.removeEventListener('change', onStoreChange); +} + +function getReducedMotionSnapshot() { + return typeof window === 'undefined' || typeof window.matchMedia !== 'function' + ? true + : window.matchMedia(REDUCED_MOTION_QUERY).matches; +} + +/** Chevron wavefront across a 3×3 grid (Drive / Dots). */ +const chevronDelays = Array.from({ length: 9 }, (_, i) => { + const r = Math.floor(i / 3); + const c = i % 3; + return (c + Math.abs(r - 1)) * 90; +}); + +const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3]; +const orbitDelays = Array.from({ length: 9 }, (_, i) => { + const k = ORBIT_ORDER.indexOf(i); + return k === -1 ? null : k * 110; +}); + +type PixelVariant = 'drive' | 'dots' | 'orbit'; + +const PIXEL_PATTERNS: Record< + PixelVariant, + { delays: (number | null)[]; durationMs: number; round: boolean } +> = { + drive: { delays: chevronDelays, durationMs: 650, round: false }, + dots: { delays: chevronDelays, durationMs: 650, round: true }, + orbit: { delays: orbitDelays, durationMs: 950, round: false }, +}; + +type SimpleVariant = + | 'circular' + | 'bounce-dots' + | 'typing' + | 'wave' + | 'pulse-dot' + | 'text-shimmer' + | 'loading-dots'; + +type AgentLoaderVariant = PixelVariant | SimpleVariant; + +type AgentLoaderProps = { + variant?: AgentLoaderVariant; + /** Status label (pixel + text variants). */ + label?: string; + /** Show a live elapsed timer next to the pixel loader. */ + showElapsed?: boolean; + /** When provided, use this elapsed ms instead of an internal clock. */ + elapsedMs?: number; + size?: 'sm' | 'md' | 'lg'; + className?: string; + text?: string; +}; + +function formatElapsed(totalSeconds: number): string { + if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds.toFixed(1)}s`; +} + +function useElapsedSeconds(enabled: boolean, controlledMs?: number) { + const [ticks, setTicks] = React.useState(0); + React.useEffect(() => { + if (!enabled || controlledMs !== undefined) return; + const id = window.setInterval(() => setTicks((value) => value + 1), 100); + return () => window.clearInterval(id); + }, [enabled, controlledMs]); + if (controlledMs !== undefined) return controlledMs / 1000; + return ticks / 10; +} + +function PixelGrid({ + variant, + reducedMotion, +}: { + variant: PixelVariant; + reducedMotion: boolean; +}) { + const { delays, durationMs, round } = PIXEL_PATTERNS[variant]; + return ( + + {delays.map((delay, index) => ( + + ))} + + ); +} + +function sizeClass(size: 'sm' | 'md' | 'lg') { + return { sm: 'size-4', md: 'size-5', lg: 'size-6' }[size]; +} + +function CircularLoader({ size, className }: { size: 'sm' | 'md' | 'lg'; className?: string }) { + return ( + + ); +} + +function DotsBounce({ size, className }: { size: 'sm' | 'md' | 'lg'; className?: string }) { + const dot = { sm: 'size-1.5', md: 'size-2', lg: 'size-2.5' }[size]; + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ); +} + +function TypingLoader({ size, className }: { size: 'sm' | 'md' | 'lg'; className?: string }) { + const dot = { sm: 'size-1', md: 'size-1.5', lg: 'size-2' }[size]; + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ); +} + +function WaveLoader({ size, className }: { size: 'sm' | 'md' | 'lg'; className?: string }) { + const heights = { + sm: ['6px', '9px', '12px', '9px', '6px'], + md: ['8px', '12px', '16px', '12px', '8px'], + lg: ['10px', '15px', '20px', '15px', '10px'], + }[size]; + return ( + + {heights.map((height, i) => ( + + ))} + + ); +} + +function PulseDot({ size, className }: { size: 'sm' | 'md' | 'lg'; className?: string }) { + const dot = { sm: 'size-1', md: 'size-2', lg: 'size-3' }[size]; + return ( + + ); +} + +/** + * Agent loading indicator. Pixel variants (drive / dots / orbit) match the + * Beautiful UI craft; simple variants cover common chat spinners. + */ +function AgentLoader({ + variant = 'drive', + label = 'Working', + showElapsed = true, + elapsedMs, + size = 'md', + className, + text, +}: AgentLoaderProps) { + const reducedMotion = React.useSyncExternalStore( + subscribeToReducedMotion, + getReducedMotionSnapshot, + () => true, + ); + const isPixel = variant === 'drive' || variant === 'dots' || variant === 'orbit'; + const elapsed = useElapsedSeconds(isPixel && showElapsed, elapsedMs); + const statusText = text ?? label; + + if (isPixel) { + return ( + + + {statusText} + {showElapsed ? ( + + {formatElapsed(elapsed)} + + ) : null} + + {statusText} + {showElapsed ? `, ${formatElapsed(elapsed)}` : ''} + + + ); + } + + if (variant === 'text-shimmer') { + return ( + + + {statusText} + + + ); + } + + if (variant === 'loading-dots') { + return ( + + + {statusText} + + + {[0, 1, 2].map((i) => ( + + . + + ))} + + + ); + } + + const indicator = + variant === 'circular' ? ( + + ) : variant === 'bounce-dots' ? ( + + ) : variant === 'typing' ? ( + + ) : variant === 'wave' ? ( + + ) : ( + + ); + + return ( + + {indicator} + {statusText ? {statusText} : Loading} + + ); +} + +/** @deprecated Prefer AgentLoader — alias kept for prompt-kit familiarity. */ +const Loader = AgentLoader; + +export { AgentLoader, Loader }; +export type { AgentLoaderProps, AgentLoaderVariant, PixelVariant, SimpleVariant }; diff --git a/packages/ui/src/components/ai/chat-container.tsx b/packages/ui/src/components/ai/chat-container.tsx new file mode 100644 index 0000000..3c28709 --- /dev/null +++ b/packages/ui/src/components/ai/chat-container.tsx @@ -0,0 +1,115 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +type ChatContainerContextValue = { + scrollRef: React.RefObject; + isAtBottom: boolean; + scrollToBottom: (behavior?: ScrollBehavior) => void; +}; + +const ChatContainerContext = React.createContext(null); + +function useChatContainer() { + const context = React.useContext(ChatContainerContext); + if (!context) { + throw new Error('useChatContainer must be used within ChatContainer.'); + } + return context; +} + +type ChatContainerProps = React.ComponentProps<'div'> & { + /** Pixels from the bottom that still count as "pinned". */ + bottomThreshold?: number; + /** Auto-follow new content while pinned to the bottom. */ + autoScroll?: boolean; +}; + +/** + * Scroll region for chat transcripts with stick-to-bottom tracking. + * Pair with ScrollButton for jump-to-latest. + */ +function ChatContainer({ + className, + children, + bottomThreshold = 48, + autoScroll = true, + ...props +}: ChatContainerProps) { + const scrollRef = React.useRef(null); + const [isAtBottom, setIsAtBottom] = React.useState(true); + const isAtBottomRef = React.useRef(true); + + const scrollToBottom = React.useCallback((behavior: ScrollBehavior = 'smooth') => { + const node = scrollRef.current; + if (!node) return; + node.scrollTo({ top: node.scrollHeight, behavior }); + }, []); + + const updatePinned = React.useCallback(() => { + const node = scrollRef.current; + if (!node) return; + const distance = node.scrollHeight - node.scrollTop - node.clientHeight; + const pinned = distance <= bottomThreshold; + isAtBottomRef.current = pinned; + setIsAtBottom(pinned); + }, [bottomThreshold]); + + React.useEffect(() => { + const node = scrollRef.current; + if (!node) return; + updatePinned(); + node.addEventListener('scroll', updatePinned, { passive: true }); + const observer = new ResizeObserver(() => { + if (autoScroll && isAtBottomRef.current) { + node.scrollTop = node.scrollHeight; + } + updatePinned(); + }); + observer.observe(node); + if (node.firstElementChild) observer.observe(node.firstElementChild); + return () => { + node.removeEventListener('scroll', updatePinned); + observer.disconnect(); + }; + }, [autoScroll, updatePinned]); + + // Follow content growth while pinned. + React.useLayoutEffect(() => { + if (!autoScroll || !isAtBottomRef.current) return; + const node = scrollRef.current; + if (!node) return; + node.scrollTop = node.scrollHeight; + }); + + return ( + +
+ {children} +
+
+ ); +} + +type ChatContainerContentProps = React.ComponentProps<'div'>; + +function ChatContainerContent({ className, ...props }: ChatContainerContentProps) { + return ( +
+ ); +} + +export { ChatContainer, ChatContainerContent, useChatContainer }; +export type { ChatContainerProps, ChatContainerContentProps }; diff --git a/packages/ui/src/components/ai/code-block.tsx b/packages/ui/src/components/ai/code-block.tsx new file mode 100644 index 0000000..2d979ce --- /dev/null +++ b/packages/ui/src/components/ai/code-block.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { Check, Copy } from 'lucide-react'; +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { Button } from '../button'; + +type CodeBlockProps = React.ComponentProps<'div'> & { + code: string; + language?: string; + /** Optional filename shown in the header. */ + filename?: string; + /** Show the copy control (default true). */ + showCopy?: boolean; + /** Reveal code line-by-line for demos / progressive stream. */ + streamingLines?: boolean; + /** Lines revealed per tick when streamingLines is true. */ + lineIntervalMs?: number; +}; + +/** + * Agent code surface with optional language label and copy action. + * Syntax highlighting is left to the host (shiki) for settled content — + * while streaming, plain mono keeps CPU flat. + */ +function CodeBlock({ + code, + language, + filename, + showCopy = true, + streamingLines = false, + lineIntervalMs = 240, + className, + ...props +}: CodeBlockProps) { + const [copied, setCopied] = React.useState(false); + const lines = React.useMemo(() => code.replace(/\n$/, '').split('\n'), [code]); + const [visibleCount, setVisibleCount] = React.useState(streamingLines ? 0 : lines.length); + + React.useEffect(() => { + if (!streamingLines) { + setVisibleCount(lines.length); + return; + } + setVisibleCount(0); + if (lines.length === 0) return; + const id = window.setInterval(() => { + setVisibleCount((count) => { + if (count >= lines.length) { + window.clearInterval(id); + return count; + } + return count + 1; + }); + }, lineIntervalMs); + return () => window.clearInterval(id); + }, [streamingLines, lines, lineIntervalMs, code]); + + const visible = streamingLines ? lines.slice(0, visibleCount).join('\n') : code; + + const handleCopy = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard can fail in non-secure contexts; stay quiet. + } + }, [code]); + + const headerLabel = filename ?? language; + + return ( +
+ {(headerLabel || showCopy) && ( +
+ + {headerLabel} + {filename && language ? ( + · {language} + ) : null} + + {showCopy ? ( + + ) : null} +
+ )} +
+				{visible}
+			
+
+ ); +} + +export { CodeBlock }; +export type { CodeBlockProps }; diff --git a/packages/ui/src/components/ai/index.ts b/packages/ui/src/components/ai/index.ts new file mode 100644 index 0000000..4b54d04 --- /dev/null +++ b/packages/ui/src/components/ai/index.ts @@ -0,0 +1,54 @@ +// AI / agentic UI primitives — presentational, runtime-agnostic. +// Wire streaming state and tool results from the host (AI SDK, pi, custom). + +export { TextShimmer, type TextShimmerProps } from './text-shimmer'; +export { + AgentLoader, + Loader, + type AgentLoaderProps, + type AgentLoaderVariant, + type PixelVariant, + type SimpleVariant, +} from './agent-loader'; +export { + PromptInput, + PromptInputTextarea, + PromptInputActions, + PromptInputAction, + PromptInputBody, + usePromptInput, + type PromptInputProps, + type PromptInputTextareaProps, + type PromptInputActionsProps, + type PromptInputActionProps, + type PromptInputBodyProps, +} from './prompt-input'; +export { + Message, + MessageAvatar, + MessageContent, + MessageActions, + MessageAction, + type MessageProps, + type MessageAvatarProps, + type MessageContentProps, + type MessageActionsProps, + type MessageActionProps, +} from './message'; +export { Markdown, renderBasicMarkdown, type MarkdownProps } from './markdown'; +export { CodeBlock, type CodeBlockProps } from './code-block'; +export { + ResponseStream, + type ResponseStreamProps, + type ResponseStreamMode, +} from './response-stream'; +export { + ChatContainer, + ChatContainerContent, + useChatContainer, + type ChatContainerProps, + type ChatContainerContentProps, +} from './chat-container'; +export { ScrollButton, type ScrollButtonProps } from './scroll-button'; +export { SystemMessage, type SystemMessageProps } from './system-message'; +export { useThrottledText, STREAM_THROTTLE_MS } from './use-throttled-text'; diff --git a/packages/ui/src/components/ai/markdown.tsx b/packages/ui/src/components/ai/markdown.tsx new file mode 100644 index 0000000..7b1c62b --- /dev/null +++ b/packages/ui/src/components/ai/markdown.tsx @@ -0,0 +1,145 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { useThrottledText } from './use-throttled-text'; + +type MarkdownProps = React.ComponentProps<'div'> & { + children: string; + /** Throttle re-renders while tokens arrive. */ + streaming?: boolean; +}; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Lightweight markdown → HTML without hard deps. + * Handles paragraphs, bold, italic, inline code, fenced code, links, lists. + * Hosts that need full GFM can pass pre-rendered HTML via a future `html` prop + * or wrap with their own marked+DOMPurify pipeline. + */ +function renderBasicMarkdown(source: string): string { + const fences: string[] = []; + let text = source.replace(/```([\w-]*)\n?([\s\S]*?)```/g, (_match, lang: string, code: string) => { + const index = fences.length; + const language = lang ? ` data-language="${escapeHtml(lang)}"` : ''; + fences.push( + `
${escapeHtml(code.replace(/\n$/, ''))}
`, + ); + return `\u0000FENCE${index}\u0000`; + }); + + text = escapeHtml(text); + + text = text.replace(/`([^`\n]+)`/g, '$1'); + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + text = text.replace(/(?$1'); + text = text.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1', + ); + + const lines = text.split('\n'); + const html: string[] = []; + let listOpen: 'ul' | 'ol' | null = null; + + const closeList = () => { + if (listOpen) { + html.push(``); + listOpen = null; + } + }; + + for (const line of lines) { + const unordered = line.match(/^[-*]\s+(.+)$/); + const ordered = line.match(/^\d+\.\s+(.+)$/); + const heading = line.match(/^(#{1,3})\s+(.+)$/); + + if (unordered) { + if (listOpen !== 'ul') { + closeList(); + html.push('
    '); + listOpen = 'ul'; + } + html.push(`
  • ${unordered[1]}
  • `); + continue; + } + if (ordered) { + if (listOpen !== 'ol') { + closeList(); + html.push('
      '); + listOpen = 'ol'; + } + html.push(`
    1. ${ordered[1]}
    2. `); + continue; + } + + closeList(); + + if (heading) { + const level = heading[1].length; + html.push(`${heading[2]}`); + continue; + } + + if (line.trim() === '') { + html.push(''); + continue; + } + + if (line.includes('\u0000FENCE')) { + html.push(line.replace(/\u0000FENCE(\d+)\u0000/g, (_m, i) => fences[Number(i)] ?? '')); + continue; + } + + html.push(`

      ${line}

      `); + } + closeList(); + + return html + .join('\n') + .replace(/\u0000FENCE(\d+)\u0000/g, (_m, i) => fences[Number(i)] ?? ''); +} + +/** + * Markdown renderer for assistant messages. Throttles while streaming. + * Uses a zero-dependency subset suitable for most agent replies; swap for a + * host-level marked pipeline when you need tables/footnotes. + */ +function Markdown({ className, children, streaming = false, ...props }: MarkdownProps) { + const text = useThrottledText(children, streaming); + const html = React.useMemo(() => renderBasicMarkdown(text), [text]); + + return ( +
      + ); +} + +export { Markdown, renderBasicMarkdown }; +export type { MarkdownProps }; diff --git a/packages/ui/src/components/ai/message.tsx b/packages/ui/src/components/ai/message.tsx new file mode 100644 index 0000000..712466d --- /dev/null +++ b/packages/ui/src/components/ai/message.tsx @@ -0,0 +1,137 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '../avatar'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../tooltip'; +import { Markdown } from './markdown'; + +type MessageProps = React.ComponentProps<'div'> & { + /** Visual role for default density / alignment helpers. */ + from?: 'user' | 'assistant' | 'system'; +}; + +function Message({ className, from, ...props }: MessageProps) { + return ( +
      + ); +} + +type MessageAvatarProps = { + src?: string; + alt?: string; + fallback?: string; + className?: string; + delay?: number; +}; + +function MessageAvatar({ src, alt = '', fallback, className, delay }: MessageAvatarProps) { + return ( + + {src ? : null} + {fallback ? {fallback} : null} + + ); +} + +type MessageContentProps = React.ComponentProps<'div'> & { + markdown?: boolean; + /** While true, markdown re-renders are throttled (see Markdown). */ + streaming?: boolean; +}; + +function MessageContent({ + className, + markdown = false, + streaming = false, + children, + ...props +}: MessageContentProps) { + const contentClassName = cn( + 'min-w-0 max-w-[min(100%,42rem)] text-sm leading-relaxed text-foreground', + className, + ); + + if (markdown && typeof children === 'string') { + return ( + + {children} + + ); + } + + return ( +
      + {children} +
      + ); +} + +type MessageActionsProps = React.ComponentProps<'div'>; + +function MessageActions({ className, ...props }: MessageActionsProps) { + return ( +
      + ); +} + +type MessageActionProps = { + tooltip: React.ReactNode; + side?: 'top' | 'bottom' | 'left' | 'right'; + children: React.ReactNode; + className?: string; +} & Omit, 'children'>; + +function MessageAction({ + tooltip, + children, + className, + side = 'top', + ...props +}: MessageActionProps) { + return ( + + + {children} + + {tooltip} + + + + ); +} + +export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction }; +export type { + MessageProps, + MessageAvatarProps, + MessageContentProps, + MessageActionsProps, + MessageActionProps, +}; diff --git a/packages/ui/src/components/ai/prompt-input.tsx b/packages/ui/src/components/ai/prompt-input.tsx new file mode 100644 index 0000000..1df8b12 --- /dev/null +++ b/packages/ui/src/components/ai/prompt-input.tsx @@ -0,0 +1,248 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../tooltip'; + +type PromptInputContextValue = { + isLoading: boolean; + value: string; + setValue: (value: string) => void; + maxHeight: number | string; + onSubmit?: () => void; + disabled?: boolean; + textareaRef: React.RefObject; +}; + +const PromptInputContext = React.createContext(null); + +function usePromptInput() { + const context = React.useContext(PromptInputContext); + if (!context) { + throw new Error('PromptInput compound parts must be used within .'); + } + return context; +} + +type PromptInputProps = React.ComponentProps<'div'> & { + isLoading?: boolean; + value?: string; + onValueChange?: (value: string) => void; + maxHeight?: number | string; + onSubmit?: () => void; + disabled?: boolean; + /** Visual shell. `default` uses Constructive radius; `pill` is more rounded. */ + shape?: 'default' | 'pill'; +}; + +/** + * Compound chat composer shell. Host owns submit/streaming; this owns layout, + * autosize context, and action slots. + */ +function PromptInput({ + className, + isLoading = false, + maxHeight = 240, + value, + onValueChange, + onSubmit, + children, + disabled = false, + shape = 'default', + onClick, + ...props +}: PromptInputProps) { + const [internalValue, setInternalValue] = React.useState(value ?? ''); + const textareaRef = React.useRef(null); + + React.useEffect(() => { + if (value !== undefined) setInternalValue(value); + }, [value]); + + const handleChange = React.useCallback( + (next: string) => { + setInternalValue(next); + onValueChange?.(next); + }, + [onValueChange], + ); + + const handleClick: React.MouseEventHandler = (event) => { + if (!disabled) textareaRef.current?.focus(); + onClick?.(event); + }; + + return ( + + +
      + {children} +
      +
      +
      + ); +} + +type PromptInputTextareaProps = Omit, 'value' | 'onChange'> & { + disableAutosize?: boolean; +}; + +function PromptInputTextarea({ + className, + onKeyDown, + disableAutosize = false, + ...props +}: PromptInputTextareaProps) { + const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } = usePromptInput(); + + const adjustHeight = React.useCallback( + (element: HTMLTextAreaElement | null) => { + if (!element || disableAutosize) return; + element.style.height = 'auto'; + if (typeof maxHeight === 'number') { + element.style.height = `${Math.min(element.scrollHeight, maxHeight)}px`; + } else { + element.style.height = `min(${element.scrollHeight}px, ${maxHeight})`; + } + }, + [disableAutosize, maxHeight], + ); + + const setRefs = React.useCallback( + (element: HTMLTextAreaElement | null) => { + textareaRef.current = element; + adjustHeight(element); + }, + [adjustHeight, textareaRef], + ); + + React.useLayoutEffect(() => { + adjustHeight(textareaRef.current); + }, [adjustHeight, textareaRef, value]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + onSubmit?.(); + } + onKeyDown?.(event); + }; + + return ( +