@intelligo-dev/cli 1.0.0-beta.1 → 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/NOTICE +6 -0
- package/README.md +102 -0
- package/dist/bin.js +122 -37
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts +26 -1
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +70 -4
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +37 -0
- package/dist/commands/create-flow.d.ts.map +1 -0
- package/dist/commands/create-flow.js +212 -0
- package/dist/commands/create-flow.js.map +1 -0
- package/dist/commands/create.d.ts +27 -6
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +53 -13
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts +14 -3
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +295 -37
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-check.d.ts +73 -1
- package/dist/commands/migrate-check.d.ts.map +1 -1
- package/dist/commands/migrate-check.js +130 -5
- package/dist/commands/migrate-check.js.map +1 -1
- package/dist/commands/migrate.d.ts +97 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +141 -0
- package/dist/commands/migrate.js.map +1 -0
- 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 +5 -5
- package/dist/commands/upgrade-check.d.ts.map +1 -1
- package/dist/commands/upgrade-check.js +22 -6
- package/dist/commands/upgrade-check.js.map +1 -1
- package/dist/env-files.d.ts +12 -0
- package/dist/env-files.d.ts.map +1 -0
- package/dist/env-files.js +29 -0
- package/dist/env-files.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +36 -15
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +32 -16
- package/dist/manifest.js.map +1 -1
- package/dist/migrations.d.ts +3 -6
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +3 -6
- package/dist/migrations.js.map +1 -1
- package/dist/model-catalogue.d.ts +14 -0
- package/dist/model-catalogue.d.ts.map +1 -0
- package/dist/model-catalogue.js +34 -0
- package/dist/model-catalogue.js.map +1 -0
- 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 +84 -0
- package/dist/registry-items.d.ts.map +1 -0
- package/dist/registry-items.js +187 -0
- package/dist/registry-items.js.map +1 -0
- package/package.json +36 -10
- package/src/bin.ts +316 -0
- package/src/commands/add.ts +255 -0
- package/src/commands/create-flow.ts +284 -0
- package/src/commands/create.ts +149 -0
- package/src/commands/doctor.ts +487 -0
- package/src/commands/migrate-check.ts +298 -0
- package/src/commands/migrate.ts +228 -0
- 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 +160 -0
- package/src/env-files.ts +31 -0
- package/src/index.ts +39 -0
- package/src/manifest.ts +147 -0
- package/src/migrations-dir.ts +25 -0
- package/src/migrations.ts +133 -0
- package/src/model-catalogue.ts +35 -0
- package/src/module-exports.ts +266 -0
- package/src/registry-bundle.ts +103 -0
- package/src/registry-items.ts +237 -0
- package/templates/admin-page/admin-page.tsx.tpl +2 -3
- package/templates/app-scaffold/assistant-route.ts.tpl +2 -2
- package/templates/app-scaffold/auth-route.ts.tpl +10 -0
- package/templates/app-scaffold/components.json.tpl +6 -2
- package/templates/app-scaffold/db-schema.ts.tpl +29 -0
- package/templates/app-scaffold/drizzle-journal.json.tpl +5 -0
- package/templates/app-scaffold/drizzle.config.ts.tpl +45 -0
- package/templates/app-scaffold/env.example.tpl +65 -1
- package/templates/app-scaffold/globals.css.tpl +172 -281
- package/templates/app-scaffold/i18n-request.ts.tpl +25 -0
- package/templates/app-scaffold/instrumentation.ts.tpl +21 -0
- package/templates/app-scaffold/intelligo.ts.tpl +95 -41
- package/templates/app-scaffold/layout.tsx.tpl +18 -1
- package/templates/app-scaffold/lib-utils.ts.tpl +1 -6
- package/templates/app-scaffold/next.config.mjs.tpl +2 -0
- package/templates/app-scaffold/package.json.tpl +31 -17
- package/templates/app-scaffold/page.tsx.tpl +16 -0
- package/templates/app-scaffold/plans.ts.tpl +32 -13
- package/templates/app-scaffold/{middleware.ts.tpl → proxy.ts.tpl} +6 -0
- package/templates/app-scaffold/sonner.tsx.tpl +42 -20
- package/templates/app-scaffold/stripe-webhook-route.ts.tpl +24 -0
- package/templates/app-scaffold/theme-provider.tsx.tpl +6 -4
- package/templates/app-scaffold/tsconfig.json.tpl +20 -4
- package/templates/app-scaffold/use-mobile.ts.tpl +19 -0
- package/templates/app-scaffold/workspace-bootstrap.ts.tpl +30 -0
- package/templates/maintenance/maintenance-route.ts.tpl +119 -0
- package/templates/manifest.json +75 -17
- 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 +116 -0
- package/templates/registry-requires.json +180 -0
- package/templates/usage-page/usage-page.tsx.tpl +23 -8
- package/templates/vitest/server-only.ts.tpl +7 -0
- package/templates/vitest/vitest.config.ts.tpl +37 -0
- package/templates/billing-page/billing-page.tsx.tpl +0 -72
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-code-block",
|
|
4
|
+
"title": "AI Code Block",
|
|
5
|
+
"description": "Highlighted code that streams without layout shift: header with filename, language, status and copy action, line numbers, focused lines, light and dark.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"shiki",
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/copy-button",
|
|
12
|
+
"@intelligo/spinner"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "base/ui/ai-code-block/ai-code-block.tsx",
|
|
17
|
+
"content": "\"use client\";\n\n/*\n * Highlighted code that streams without the layout jumping: a header with\n * the file name, language, status and copy action; line numbers; focused\n * lines; rows that keep their colour while the next chunk is being\n * tokenised. Built on Intelligo's tokens and Base UI. Highlighting is\n * shiki, light and dark themes at once; the plain text shows until the\n * tokens are ready.\n *\n * Colour: shiki gives every token a light and a dark colour, so the\n * container declares `color-scheme` per theme (the one structural\n * `dark:` in this file) and each token paints with `light-dark()`.\n */\n\nimport * as React from \"react\";\nimport { CheckIcon, FileCode2Icon } from \"lucide-react\";\nimport {\n type BundledLanguage,\n codeToHtml,\n codeToTokensWithThemes,\n type ShikiTransformer,\n type SpecialLanguage,\n} from \"shiki\";\n\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CodeLanguage = BundledLanguage | SpecialLanguage;\nexport type CodeBlockStatus = \"streaming\" | \"complete\";\n\nconst LIGHT_THEME = \"one-light\";\nconst DARK_THEME = \"one-dark-pro\";\n\n/* ----------------------------------------------------------------------------\n * highlightCode: the HTML form, kept for callers that want a whole block\n * rendered by shiki (light and dark) rather than token rows.\n * ------------------------------------------------------------------------- */\n\nconst lineNumbers: ShikiTransformer = {\n name: \"line-numbers\",\n line(node, line) {\n node.children.unshift({\n type: \"element\",\n tagName: \"span\",\n properties: {\n className: [\n \"inline-block\",\n \"min-w-10\",\n \"mr-4\",\n \"text-right\",\n \"select-none\",\n \"text-muted-foreground\",\n ],\n },\n children: [{ type: \"text\", value: String(line) }],\n });\n },\n};\n\nasync function highlightCode(\n code: string,\n language: CodeLanguage,\n showLineNumbers = false\n): Promise<[light: string, dark: string]> {\n const transformers = showLineNumbers ? [lineNumbers] : [];\n return Promise.all([\n codeToHtml(code, { lang: language, theme: LIGHT_THEME, transformers }),\n codeToHtml(code, { lang: language, theme: DARK_THEME, transformers }),\n ]);\n}\n\n/* ----------------------------------------------------------------------------\n * Tokens: one shiki pass gives both theme colours per token. The hook\n * keeps the last result while a longer code (a stream) is tokenised, so\n * lines already on screen never flash back to plain text.\n * ------------------------------------------------------------------------- */\n\nexport interface CodeToken {\n content: string;\n offset: number;\n light?: string;\n dark?: string;\n}\n\nexport type CodeTokenLines = CodeToken[][];\n\nconst TOKEN_CACHE_LIMIT = 64;\nconst tokenCache = new Map<string, CodeTokenLines>();\n\nfunction tokenCacheKey(code: string, language: CodeLanguage) {\n return `${language}\u0000${code}`;\n}\n\nfunction rememberTokens(key: string, lines: CodeTokenLines) {\n if (tokenCache.size >= TOKEN_CACHE_LIMIT) {\n const oldest = tokenCache.keys().next().value;\n if (oldest !== undefined) tokenCache.delete(oldest);\n }\n tokenCache.set(key, lines);\n}\n\nasync function tokenizeCode(\n code: string,\n language: CodeLanguage\n): Promise<CodeTokenLines> {\n const lines = await codeToTokensWithThemes(code, {\n lang: language,\n themes: { light: LIGHT_THEME, dark: DARK_THEME },\n });\n return lines.map((line) =>\n line.map((token) => ({\n content: token.content,\n offset: token.offset,\n light: token.variants.light?.color,\n dark: token.variants.dark?.color,\n }))\n );\n}\n\ntype TokenResult = {\n key: string;\n code: string;\n language: CodeLanguage;\n lines: CodeTokenLines;\n};\n\n/**\n * Shiki tokens for `code`, or `null` until they exist. While a stream\n * grows the previous tokens stay valid for the lines they cover.\n */\nfunction useCodeTokens(\n code: string,\n language: CodeLanguage\n): CodeTokenLines | null {\n const key = tokenCacheKey(code, language);\n const cached = tokenCache.get(key);\n const [result, setResult] = React.useState<TokenResult | null>(\n cached ? { key, code, language, lines: cached } : null\n );\n\n React.useEffect(() => {\n const current = tokenCache.get(key);\n if (current) {\n setResult({ key, code, language, lines: current });\n return;\n }\n\n let cancelled = false;\n tokenizeCode(code, language)\n .then((lines) => {\n if (cancelled) return;\n rememberTokens(key, lines);\n setResult({ key, code, language, lines });\n })\n .catch(() => {\n // An unknown language or a failed load: the plain text stays.\n });\n return () => {\n cancelled = true;\n };\n }, [code, key, language]);\n\n if (result?.key === key) return result.lines;\n if (result?.language === language && code.startsWith(result.code)) {\n return result.lines;\n }\n return null;\n}\n\nfunction splitLines(code: string) {\n let offset = 0;\n return code.split(\"\\n\").map((content) => {\n const line = { content, offset };\n offset += content.length + 1;\n return line;\n });\n}\n\n/* ----------------------------------------------------------------------------\n * CodeLine and HighlightedCode: the token rows, without chrome.\n * ------------------------------------------------------------------------- */\n\nexport interface CodeLineProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n code: string;\n tokens?: CodeToken[];\n}\n\nfunction CodeLine({ code, tokens, className, ...props }: CodeLineProps) {\n return (\n <span data-slot=\"code-line\" className={className} {...props}>\n {tokens\n ? tokens.map((token) => (\n <span\n key={`${token.offset}-${token.content}`}\n style={{\n color: `light-dark(${token.light ?? \"currentColor\"}, ${\n token.dark ?? token.light ?? \"currentColor\"\n })`,\n }}\n >\n {token.content}\n </span>\n ))\n : code}\n </span>\n );\n}\n\n/** The `color-scheme` switch every token colour reads through. */\nconst SCHEME = \"[color-scheme:light] dark:[color-scheme:dark]\";\n\nexport interface HighlightedCodeProps\n extends Omit<React.ComponentProps<\"pre\">, \"children\"> {\n code: string;\n language?: CodeLanguage;\n}\n\n/** Highlighted code as a bare `<pre>`, for output panes and inline results. */\nfunction HighlightedCode({\n code,\n language = \"bash\",\n className,\n ...props\n}: HighlightedCodeProps) {\n const tokens = useCodeTokens(code, language);\n const lines = splitLines(code);\n\n return (\n <pre\n data-slot=\"highlighted-code\"\n className={cn(\n \"m-0 overflow-x-auto font-mono text-xs leading-5 whitespace-pre text-foreground/85\",\n SCHEME,\n className\n )}\n {...props}\n >\n <code>\n {lines.map((line, index) => (\n <React.Fragment key={line.offset}>\n <CodeLine code={line.content} tokens={tokens?.[index]} />\n {index < lines.length - 1 ? \"\\n\" : null}\n </React.Fragment>\n ))}\n </code>\n </pre>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CodeBlock: the framed block with header, line numbers and actions.\n * ------------------------------------------------------------------------- */\n\ntype CodeBlockContextValue = { code: string };\n\nconst CodeBlockContext = React.createContext<CodeBlockContextValue>({\n code: \"\",\n});\n\nexport interface CodeBlockProps\n extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n code: string;\n language?: CodeLanguage;\n /** Shown in the header; the header appears when there is one. */\n filename?: React.ReactNode;\n status?: CodeBlockStatus;\n /** Force the header on (or off) regardless of filename and status. */\n showHeader?: boolean;\n showLineNumbers?: boolean;\n /** 1-based line numbers to emphasise. */\n highlightLines?: number[];\n /** Pixel height the viewport scrolls within; unset grows with the code. */\n maxHeight?: number;\n wrap?: boolean;\n /** Render the built-in copy action (in the header, or top-right). */\n copyable?: boolean;\n onCopy?: () => void;\n copyLabel?: string;\n copiedLabel?: string;\n statusLabels?: Partial<Record<CodeBlockStatus, string>>;\n /** Extra actions — `<CodeBlockCopyButton />` or the caller's own. */\n children?: React.ReactNode;\n}\n\nconst STATUS_LABELS: Record<CodeBlockStatus, string> = {\n streaming: \"Writing\",\n complete: \"Ready\",\n};\n\nfunction CodeBlock({\n code,\n language = \"typescript\",\n filename,\n status = \"complete\",\n showHeader,\n showLineNumbers = false,\n highlightLines,\n maxHeight,\n wrap = false,\n copyable = false,\n onCopy,\n copyLabel = \"Copy code\",\n copiedLabel = \"Copied\",\n statusLabels,\n className,\n children,\n ...props\n}: CodeBlockProps) {\n const viewportRef = React.useRef<HTMLDivElement>(null);\n const streaming = status === \"streaming\";\n const header = showHeader ?? (Boolean(filename) || streaming);\n const tokens = useCodeTokens(code, language);\n const highlighted = React.useMemo(\n () => new Set(highlightLines ?? []),\n [highlightLines]\n );\n const lines = splitLines(code);\n const labels = { ...STATUS_LABELS, ...statusLabels };\n\n // A stream keeps the newest line in view; the caller's scroll position\n // is left alone once the code is complete.\n React.useLayoutEffect(() => {\n const viewport = viewportRef.current;\n if (!viewport || !streaming) return;\n\n const frame = requestAnimationFrame(() => {\n if (viewport.scrollHeight <= viewport.clientHeight) return;\n const reduce = window.matchMedia?.(\n \"(prefers-reduced-motion: reduce)\"\n ).matches;\n if (typeof viewport.scrollTo === \"function\") {\n viewport.scrollTo({\n top: viewport.scrollHeight,\n behavior: reduce ? \"auto\" : \"smooth\",\n });\n } else {\n viewport.scrollTop = viewport.scrollHeight;\n }\n });\n return () => cancelAnimationFrame(frame);\n });\n\n const actions =\n copyable || children ? (\n <div\n data-slot=\"code-block-actions\"\n className={cn(\n \"flex shrink-0 items-center gap-1\",\n !header && \"absolute top-2 right-2\"\n )}\n >\n {copyable ? (\n <CopyButton\n data-slot=\"code-block-copy\"\n value={code}\n label={copyLabel}\n copiedLabel={copiedLabel}\n onCopy={onCopy}\n />\n ) : null}\n {children}\n </div>\n ) : null;\n\n return (\n <CodeBlockContext.Provider value={{ code }}>\n <div\n data-slot=\"code-block\"\n data-state={status}\n aria-busy={streaming || undefined}\n className={cn(\n \"group/code-block relative w-full overflow-hidden rounded-lg border bg-background text-sm text-foreground\",\n className\n )}\n {...props}\n >\n {header ? (\n <div\n data-slot=\"code-block-header\"\n className=\"flex h-10 items-center gap-2.5 border-b px-3\"\n >\n <FileCode2Icon\n aria-hidden=\"true\"\n className=\"size-3.5 shrink-0 text-muted-foreground/70\"\n />\n {filename ? (\n <span className=\"min-w-0 truncate font-mono text-xs text-foreground/80\">\n {filename}\n </span>\n ) : null}\n <span className=\"text-xs font-medium tracking-wide text-muted-foreground/55 uppercase\">\n {language}\n </span>\n <span\n data-slot=\"code-block-status\"\n className={cn(\n \"ml-auto inline-flex shrink-0 items-center gap-1 text-xs font-medium\",\n streaming ? \"text-info\" : \"text-success\"\n )}\n >\n {streaming ? (\n <Spinner className=\"size-3\" aria-label={labels.streaming} />\n ) : (\n <CheckIcon className=\"size-3\" aria-hidden=\"true\" />\n )}\n {streaming ? labels.streaming : labels.complete}\n </span>\n {actions}\n </div>\n ) : (\n actions\n )}\n\n <div\n ref={viewportRef}\n data-slot=\"code-block-viewport\"\n role={streaming ? \"log\" : undefined}\n aria-live={streaming ? \"polite\" : undefined}\n className={cn(\n \"overflow-auto py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n SCHEME\n )}\n style={{ maxHeight }}\n >\n <pre\n className={cn(\n \"m-0 font-mono text-xs leading-5 text-foreground/85\",\n !wrap && \"min-w-max\"\n )}\n >\n <code>\n {lines.map((line, index) => {\n const lineNumber = index + 1;\n return (\n <span\n key={line.offset}\n data-slot=\"code-block-line\"\n data-highlighted={\n highlighted.has(lineNumber) || undefined\n }\n className={cn(\n \"grid min-h-5\",\n highlighted.has(lineNumber) && \"bg-info/10\"\n )}\n style={{\n gridTemplateColumns: showLineNumbers\n ? \"2.75rem minmax(0, 1fr)\"\n : \"minmax(0, 1fr)\",\n }}\n >\n {showLineNumbers ? (\n <span className=\"pr-3 text-right text-muted-foreground/40 tabular-nums select-none\">\n {lineNumber}\n </span>\n ) : null}\n <CodeLine\n code={line.content}\n tokens={tokens?.[index]}\n className={cn(\n \"pr-4\",\n showLineNumbers ? \"pl-1\" : \"pl-4\",\n wrap ? \"break-words whitespace-pre-wrap\" : \"whitespace-pre\"\n )}\n />\n </span>\n );\n })}\n </code>\n </pre>\n </div>\n </div>\n </CodeBlockContext.Provider>\n );\n}\n\nfunction CodeBlockCopyButton(\n props: Omit<React.ComponentProps<typeof CopyButton>, \"value\">\n) {\n const { code } = React.useContext(CodeBlockContext);\n return <CopyButton data-slot=\"code-block-copy\" value={code} {...props} />;\n}\n\nexport {\n CodeBlock,\n CodeBlockCopyButton,\n CodeLine,\n HighlightedCode,\n highlightCode,\n useCodeTokens,\n};\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": "components/ui/ai-code-block.tsx"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"type": "registry:ui"
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-composer-menu",
|
|
4
|
+
"title": "AI Composer Menu",
|
|
5
|
+
"description": "The picker a composer opens for slash commands and @ mentions, anchored above the textarea.",
|
|
6
|
+
"dependencies": [],
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"@intelligo/command"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "base/ui/ai-composer-menu/ai-composer-menu.tsx",
|
|
13
|
+
"content": "\"use client\";\n\n/*\n * The picker a composer opens for `/` commands and `@` mentions: a Command\n * list that sits above the textarea, filtered by what was typed after the\n * trigger. Presentation only — the trigger detection lives with the\n * composer. Positioned in flow (absolute, above its `relative` parent)\n * rather than in a popup, so focus never leaves the textarea while the\n * reader keeps typing.\n */\n\nimport * as React from \"react\";\n\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ComposerMenuOption = {\n value: string;\n label: string;\n description?: string;\n icon?: React.ReactNode;\n /** Grouped under this heading when given. */\n group?: string;\n};\n\nfunction ComposerMenu({\n open,\n options,\n query,\n onSelect,\n emptyLabel,\n className,\n}: {\n open: boolean;\n options: ComposerMenuOption[];\n /** What was typed after the trigger, for the filter. */\n query: string;\n onSelect: (option: ComposerMenuOption) => void;\n emptyLabel: string;\n className?: string;\n}) {\n const filtered = React.useMemo(() => {\n const needle = query.trim().toLowerCase();\n if (!needle) return options;\n return options.filter(\n (option) =>\n option.label.toLowerCase().includes(needle) ||\n option.value.toLowerCase().includes(needle) ||\n option.description?.toLowerCase().includes(needle)\n );\n }, [options, query]);\n\n const groups = React.useMemo(() => {\n const map = new Map<string, ComposerMenuOption[]>();\n for (const option of filtered) {\n const key = option.group ?? \"\";\n map.set(key, [...(map.get(key) ?? []), option]);\n }\n return [...map.entries()];\n }, [filtered]);\n\n if (!open) return null;\n\n return (\n <div\n data-slot=\"composer-menu\"\n role=\"presentation\"\n className={cn(\n \"absolute bottom-full left-0 z-50 mb-2 w-80 max-w-full overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md\",\n className\n )}\n >\n <Command shouldFilter={false} loop>\n <CommandList className=\"max-h-64\">\n <CommandEmpty>{emptyLabel}</CommandEmpty>\n {groups.map(([group, items]) => (\n <CommandGroup key={group} heading={group || undefined}>\n {items.map((option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n onSelect={() => onSelect(option)}\n onMouseDown={(event) => event.preventDefault()}\n >\n {option.icon}\n <span className=\"flex min-w-0 flex-col\">\n <span className=\"truncate\">{option.label}</span>\n {option.description ? (\n <span className=\"truncate text-xs text-muted-foreground\">\n {option.description}\n </span>\n ) : null}\n </span>\n </CommandItem>\n ))}\n </CommandGroup>\n ))}\n </CommandList>\n </Command>\n </div>\n );\n}\n\nexport { ComposerMenu };\n",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"target": "components/ui/ai-composer-menu.tsx"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-file-diff",
|
|
4
|
+
"title": "AI File Diff",
|
|
5
|
+
"description": "A file the agent is changing: the path with live added and removed counts, opening onto a unified diff whose highlighted rows arrive while the change streams.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion",
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-code-block",
|
|
12
|
+
"@intelligo/ai-motion",
|
|
13
|
+
"@intelligo/button",
|
|
14
|
+
"@intelligo/spinner"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "base/ui/ai-file-diff/ai-file-diff.tsx",
|
|
19
|
+
"content": "\"use client\";\n\n/*\n * A file the agent is changing — the path with live added/removed counts,\n * opening onto a unified diff whose rows arrive one by one while the\n * change streams. Rows are highlighted through the shared shiki tokens.\n */\n\nimport * as React from \"react\";\nimport {\n CheckIcon,\n ChevronDownIcon,\n CopyIcon,\n FileCode2Icon,\n} from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport {\n CodeLine,\n type CodeLanguage,\n useCodeTokens,\n} from \"@/components/ui/ai-code-block\";\nimport { Disclosure, SPRING_SWAP } from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport type FileDiffStatus = \"streaming\" | \"complete\";\nexport type FileDiffLineType = \"added\" | \"removed\" | \"context\";\n\nexport interface FileDiffLine {\n /** Stable across renders, so a streamed row never remounts. */\n id: string;\n type?: FileDiffLineType;\n oldLine?: number;\n newLine?: number;\n content: string;\n}\n\nexport interface FileDiffProps\n extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n /** The file's path. */\n file: React.ReactNode;\n lines: FileDiffLine[];\n status?: FileDiffStatus;\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Close the diff once the change is applied; a new stream opens it. */\n collapseOnComplete?: boolean;\n /** Pixel height the diff scrolls within. */\n maxHeight?: number;\n language?: CodeLanguage;\n /** Text the copy action writes; `onCopy` takes over when given. */\n copyText?: string;\n onCopy?: () => void | Promise<void>;\n statusLabels?: Partial<Record<FileDiffStatus, string>>;\n copyLabel?: string;\n copiedLabel?: string;\n /** The `sr-only` heading of the rows. */\n changesLabel?: string;\n}\n\nconst STATUS_LABELS: Record<FileDiffStatus, string> = {\n streaming: \"Applying changes\",\n complete: \"Changes applied\",\n};\n\n/** The `color-scheme` switch the token colours read through. */\nconst SCHEME = \"[color-scheme:light] dark:[color-scheme:dark]\";\n\nfunction ChangeCount({\n value,\n type,\n}: {\n value: number;\n type: \"added\" | \"removed\";\n}) {\n if (!value) return null;\n return (\n <span\n data-slot=\"file-diff-count\"\n data-type={type}\n className={cn(\n \"font-mono text-xs tabular-nums\",\n type === \"added\" ? \"text-success\" : \"text-destructive\"\n )}\n >\n {type === \"added\" ? \"+\" : \"−\"}\n {value}\n </span>\n );\n}\n\nfunction FileDiff({\n file,\n lines,\n status = \"streaming\",\n open,\n defaultOpen = true,\n onOpenChange,\n collapseOnComplete = true,\n maxHeight = 220,\n language = \"typescript\",\n copyText,\n onCopy,\n className,\n statusLabels,\n copyLabel = \"Copy diff\",\n copiedLabel = \"Copied\",\n changesLabel = \"File changes\",\n ...props\n}: FileDiffProps) {\n const reduce = useReducedMotion() ?? false;\n const baseId = React.useId();\n const triggerId = `${baseId}-trigger`;\n const contentId = `${baseId}-content`;\n const viewportRef = React.useRef<HTMLDivElement>(null);\n const previousStatus = React.useRef(status);\n const copyTimer = React.useRef<number | undefined>(undefined);\n const [copied, setCopied] = React.useState(false);\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const currentOpen = open ?? internalOpen;\n const streaming = status === \"streaming\";\n const additions = lines.filter((line) => line.type === \"added\").length;\n const deletions = lines.filter((line) => line.type === \"removed\").length;\n const canCopy = Boolean(copyText || onCopy);\n const labels = { ...STATUS_LABELS, ...statusLabels };\n const code = lines.map((line) => line.content).join(\"\\n\");\n const tokens = useCodeTokens(code, language);\n\n const setOpen = React.useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, open]\n );\n\n React.useEffect(() => {\n if (previousStatus.current !== \"streaming\" && status === \"streaming\") {\n setOpen(true);\n }\n if (\n previousStatus.current === \"streaming\" &&\n status === \"complete\" &&\n collapseOnComplete\n ) {\n setOpen(false);\n }\n previousStatus.current = status;\n }, [collapseOnComplete, setOpen, status]);\n\n React.useEffect(\n () => () => {\n if (copyTimer.current) window.clearTimeout(copyTimer.current);\n },\n []\n );\n\n // While the change streams the viewport follows the newest row.\n React.useLayoutEffect(() => {\n const viewport = viewportRef.current;\n if (!viewport || !currentOpen || !streaming) return;\n\n const frame = requestAnimationFrame(() => {\n if (viewport.scrollHeight <= viewport.clientHeight) return;\n if (typeof viewport.scrollTo === \"function\") {\n viewport.scrollTo({\n top: viewport.scrollHeight,\n behavior: reduce ? \"auto\" : \"smooth\",\n });\n } else {\n viewport.scrollTop = viewport.scrollHeight;\n }\n });\n return () => cancelAnimationFrame(frame);\n });\n\n const handleCopy = React.useCallback(async () => {\n if (onCopy) await onCopy();\n else if (copyText) await navigator.clipboard?.writeText(copyText);\n\n setCopied(true);\n if (copyTimer.current) window.clearTimeout(copyTimer.current);\n copyTimer.current = window.setTimeout(() => setCopied(false), 1600);\n }, [copyText, onCopy]);\n\n return (\n <div\n data-slot=\"file-diff\"\n data-state={status}\n aria-busy={streaming || undefined}\n className={cn(\"w-full text-sm\", className)}\n {...props}\n >\n <button\n id={triggerId}\n type=\"button\"\n data-slot=\"file-diff-trigger\"\n aria-expanded={currentOpen}\n aria-controls={contentId}\n onClick={() => setOpen(!currentOpen)}\n className=\"group/file-diff flex min-h-9 w-full items-center gap-2 rounded-md py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n >\n <FileCode2Icon\n aria-hidden=\"true\"\n className=\"size-4 shrink-0 text-muted-foreground\"\n />\n <span className=\"min-w-0 flex-1 truncate font-mono text-xs text-foreground/80\">\n {file}\n </span>\n <span className=\"flex shrink-0 items-center gap-2\">\n <ChangeCount value={additions} type=\"added\" />\n <ChangeCount value={deletions} type=\"removed\" />\n </span>\n <span\n data-slot=\"file-diff-status\"\n className=\"grid size-4 shrink-0 place-items-center text-muted-foreground/60\"\n >\n {streaming ? (\n <Spinner className=\"size-3.5\" aria-label={labels.streaming} />\n ) : (\n <CheckIcon aria-label={labels.complete} className=\"size-3.5\" />\n )}\n </span>\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: currentOpen ? 180 : 0 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className=\"shrink-0 text-muted-foreground/45 transition-colors group-hover/file-diff:text-muted-foreground\"\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n\n <Disclosure\n id={contentId}\n role=\"region\"\n aria-labelledby={triggerId}\n open={currentOpen}\n >\n <div className=\"pt-1.5 pl-6\">\n <div\n data-slot=\"file-diff-panel\"\n className=\"overflow-hidden rounded-xl bg-muted/80\"\n >\n <div\n ref={viewportRef}\n data-slot=\"file-diff-viewport\"\n aria-live=\"polite\"\n className={cn(\n \"overflow-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n SCHEME\n )}\n style={{ maxHeight }}\n >\n <div className=\"font-mono text-xs leading-5\">\n <span className=\"sr-only\">{changesLabel}</span>\n {lines.map((line, index) => {\n const type = line.type ?? \"context\";\n return (\n <div\n key={line.id}\n data-slot=\"file-diff-line\"\n data-type={type}\n className={cn(\n \"grid\",\n type === \"added\" && \"bg-success/10\",\n type === \"removed\" && \"bg-destructive/10\"\n )}\n style={{\n gridTemplateColumns:\n \"2.25rem 2.25rem 1rem minmax(0, 1fr)\",\n }}\n >\n <span className=\"pr-2 text-right text-muted-foreground/40 tabular-nums select-none\">\n {line.oldLine}\n </span>\n <span className=\"pr-2 text-right text-muted-foreground/40 tabular-nums select-none\">\n {line.newLine}\n </span>\n <span\n className={cn(\n \"text-center text-muted-foreground/45 select-none\",\n type === \"added\" && \"text-success\",\n type === \"removed\" && \"text-destructive\"\n )}\n >\n {type === \"added\" ? \"+\" : type === \"removed\" ? \"−\" : \"\"}\n </span>\n <CodeLine\n code={line.content}\n tokens={tokens?.[index]}\n className=\"min-w-0 px-1.5 whitespace-pre\"\n />\n </div>\n );\n })}\n </div>\n </div>\n\n {canCopy ? (\n <div\n data-slot=\"file-diff-actions\"\n className=\"flex justify-end px-2 pt-1 pb-1.5\"\n >\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={copied ? copiedLabel : copyLabel}\n title={copied ? copiedLabel : copyLabel}\n onClick={handleCopy}\n className=\"text-muted-foreground\"\n >\n {copied ? (\n <CheckIcon className=\"size-3.5\" />\n ) : (\n <CopyIcon className=\"size-3.5\" />\n )}\n </Button>\n </div>\n ) : null}\n </div>\n </div>\n </Disclosure>\n </div>\n );\n}\n\nexport { FileDiff };\n",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"target": "components/ui/ai-file-diff.tsx"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-image-generation",
|
|
4
|
+
"title": "AI Image Generation",
|
|
5
|
+
"description": "An image the agent is making: reserved frame, dither field while queued and generating, media sharpening in as it refines and completes.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"motion"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion",
|
|
12
|
+
"@intelligo/button"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "base/ui/ai-image-generation/ai-image-generation.tsx",
|
|
17
|
+
"content": "\"use client\";\n\n/*\n * An image the agent is making: the frame is reserved up front, a dither\n * field breathes over it while it is queued and generating, and the media\n * sharpens into place as it refines and completes — no layout shift at any\n * step.\n */\n\nimport * as React from \"react\";\nimport { CheckIcon, CircleAlertIcon, RotateCcwIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n EASE_IN_OUT,\n EASE_OUT,\n SPRING_PRESS,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ImageGenerationStatus =\n | \"queued\"\n | \"generating\"\n | \"refining\"\n | \"complete\"\n | \"error\";\n\nexport interface ImageGenerationProps {\n /** The completed media. Pass an img, Next Image, canvas, video, or custom preview. */\n children?: React.ReactNode;\n status?: ImageGenerationStatus;\n /** Accessible description. Defaults to a description derived from prompt. */\n label?: string;\n prompt?: string;\n resolution?: string;\n /** CSS aspect ratio reserved before generated media is available. */\n aspectRatio?: React.CSSProperties[\"aspectRatio\"];\n size?: \"compact\" | \"fluid\";\n /** Lets the active dither cluster follow fine-pointer movement. */\n interactive?: boolean;\n /** Overrides the status line for the current status only. */\n statusText?: string;\n /** The status line per status. */\n statusLabels?: Partial<Record<ImageGenerationStatus, string>>;\n showStatus?: boolean;\n onRetry?: () => void;\n retryLabel?: string;\n className?: string;\n mediaClassName?: string;\n statusClassName?: string;\n}\n\nconst STATUS_TEXT: Record<ImageGenerationStatus, string> = {\n queued: \"Waiting to generate\",\n generating: \"Generating image\",\n refining: \"Refining details\",\n complete: \"Image ready\",\n error: \"Generation failed\",\n};\n\nconst MEDIA_STATE: Record<\n ImageGenerationStatus,\n { filter: string; opacity: number; scale: number }\n> = {\n queued: { filter: \"blur(4px) saturate(0.75)\", opacity: 0, scale: 1.02 },\n generating: { filter: \"blur(3px) saturate(0.85)\", opacity: 0, scale: 1.015 },\n refining: { filter: \"blur(1.5px) saturate(0.95)\", opacity: 0.62, scale: 1.005 },\n complete: { filter: \"blur(0px) saturate(1)\", opacity: 1, scale: 1 },\n error: { filter: \"blur(2px) saturate(0.5)\", opacity: 0.28, scale: 1 },\n};\n\nconst OVERLAY_OPACITY: Record<ImageGenerationStatus, number> = {\n queued: 1,\n generating: 1,\n refining: 0.48,\n complete: 0,\n error: 0,\n};\n\nconst DOT_GAP = 10;\nconst TWO_PI = Math.PI * 2;\n\n/** The 10px resolution chip; the type scale has no step this small. */\nconst TINY_TEXT: React.CSSProperties = { fontSize: \"0.625rem\" };\n\n/*\n * True only on devices with a real hover (mouse / trackpad). Touch devices\n * fire phantom `:hover` on tap that sticks until tap-elsewhere — hover-only\n * effects are gated behind this.\n */\nconst HOVER_QUERY = \"(hover: hover) and (pointer: fine)\";\n\nfunction subscribeHover(onChange: () => void) {\n const query = window.matchMedia(HOVER_QUERY);\n query.addEventListener(\"change\", onChange);\n return () => query.removeEventListener(\"change\", onChange);\n}\n\nfunction useHoverCapable() {\n return React.useSyncExternalStore(\n subscribeHover,\n () => window.matchMedia(HOVER_QUERY).matches,\n () => false\n );\n}\n\nfunction DitherMark({\n status,\n reduced,\n}: {\n status: ImageGenerationStatus;\n reduced: boolean;\n}) {\n if (status === \"complete\") {\n return <CheckIcon aria-hidden=\"true\" className=\"size-3.5\" />;\n }\n\n if (status === \"error\") {\n return <CircleAlertIcon aria-hidden=\"true\" className=\"size-3.5\" />;\n }\n\n return (\n <motion.span\n data-slot=\"image-generation-mark\"\n aria-hidden=\"true\"\n animate={reduced ? undefined : { rotate: 360 }}\n transition={{\n duration: 2.4,\n ease: EASE_IN_OUT,\n repeat: Number.POSITIVE_INFINITY,\n }}\n className=\"grid size-3.5 grid-cols-2 place-items-center gap-0.5\"\n >\n <span className=\"size-1 rounded-xs bg-current\" />\n <span className=\"size-1 rounded-xs bg-current opacity-55\" />\n <span className=\"size-1 rounded-xs bg-current opacity-55\" />\n <span className=\"size-1 rounded-xs bg-current\" />\n </motion.span>\n );\n}\n\nfunction DitherField({\n interactive,\n reduced,\n status,\n}: {\n interactive: boolean;\n reduced: boolean;\n status: ImageGenerationStatus;\n}) {\n const canHover = useHoverCapable();\n const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n React.useEffect(() => {\n const canvas = canvasRef.current;\n const context = canvas?.getContext(\"2d\");\n if (!canvas || !context) return;\n\n let frame = 0;\n let width = 0;\n let height = 0;\n let dotColor = \"currentColor\";\n const pointer = {\n x: 0,\n y: 0,\n targetX: 0,\n targetY: 0,\n inside: false,\n };\n const pointerEnabled = interactive && canHover && !reduced;\n\n const resize = () => {\n const rect = canvas.getBoundingClientRect();\n width = rect.width || canvas.clientWidth || 208;\n height = rect.height || canvas.clientHeight || 208;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n\n canvas.width = Math.round(width * dpr);\n canvas.height = Math.round(height * dpr);\n context.setTransform(dpr, 0, 0, dpr, 0, 0);\n dotColor = window.getComputedStyle(canvas).color;\n pointer.x = width / 2;\n pointer.y = height / 2;\n pointer.targetX = pointer.x;\n pointer.targetY = pointer.y;\n };\n\n const draw = (time: number) => {\n context.clearRect(0, 0, width, height);\n\n if (!pointer.inside) {\n pointer.targetX =\n width / 2 + (reduced ? 0 : Math.sin(time / 1700) * width * 0.12);\n pointer.targetY =\n height / 2 + (reduced ? 0 : Math.cos(time / 2100) * height * 0.1);\n }\n\n const follow = reduced ? 1 : pointer.inside ? 0.16 : 0.045;\n pointer.x += (pointer.targetX - pointer.x) * follow;\n pointer.y += (pointer.targetY - pointer.y) * follow;\n\n const radius = Math.min(width, height) * 0.38;\n const columns = Math.ceil(width / DOT_GAP) + 1;\n const rows = Math.ceil(height / DOT_GAP) + 1;\n const offsetX = (width - (columns - 1) * DOT_GAP) / 2;\n const offsetY = (height - (rows - 1) * DOT_GAP) / 2;\n\n context.fillStyle = dotColor;\n\n for (let row = 0; row < rows; row += 1) {\n for (let column = 0; column < columns; column += 1) {\n const anchorX = offsetX + column * DOT_GAP;\n const anchorY = offsetY + row * DOT_GAP;\n const deltaX = anchorX - pointer.x;\n const deltaY = anchorY - pointer.y;\n const distance = Math.hypot(deltaX, deltaY);\n const proximity = Math.max(0, 1 - distance / radius);\n const influence = proximity * proximity * (3 - 2 * proximity);\n const displacement = influence * influence * 9;\n const directionX = distance > 0 ? deltaX / distance : 0;\n const directionY = distance > 0 ? deltaY / distance : 0;\n const x = anchorX + directionX * displacement;\n const y = anchorY + directionY * displacement;\n const dotRadius = 0.65 + influence * 0.85;\n\n context.globalAlpha = 0.17 + influence * 0.72;\n context.beginPath();\n context.arc(x, y, dotRadius, 0, TWO_PI);\n context.fill();\n }\n }\n\n context.globalAlpha = 1;\n // Reduced motion draws the field once and leaves it still.\n if (!reduced) frame = window.requestAnimationFrame(draw);\n };\n\n const handlePointerMove = (event: PointerEvent) => {\n if (!pointerEnabled) return;\n const rect = canvas.getBoundingClientRect();\n pointer.inside = true;\n pointer.targetX = event.clientX - rect.left;\n pointer.targetY = event.clientY - rect.top;\n };\n\n const handlePointerLeave = () => {\n pointer.inside = false;\n };\n\n const resizeObserver =\n typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(resize);\n\n resize();\n resizeObserver?.observe(canvas);\n canvas.addEventListener(\"pointermove\", handlePointerMove, { passive: true });\n canvas.addEventListener(\"pointerleave\", handlePointerLeave);\n draw(0);\n\n return () => {\n if (frame) window.cancelAnimationFrame(frame);\n resizeObserver?.disconnect();\n canvas.removeEventListener(\"pointermove\", handlePointerMove);\n canvas.removeEventListener(\"pointerleave\", handlePointerLeave);\n };\n }, [canHover, interactive, reduced]);\n\n return (\n <motion.div\n data-slot=\"image-generation-dither\"\n aria-hidden=\"true\"\n initial={false}\n animate={{ opacity: OVERLAY_OPACITY[status] }}\n transition={{ duration: reduced ? 0 : 0.4, ease: EASE_OUT }}\n className=\"absolute inset-0 overflow-hidden bg-muted\"\n >\n <canvas\n ref={canvasRef}\n className=\"absolute inset-0 size-full text-foreground\"\n />\n </motion.div>\n );\n}\n\nfunction ImageGeneration({\n children,\n status = \"generating\",\n label,\n prompt,\n resolution = \"1024 × 1024\",\n aspectRatio = \"1 / 1\",\n size = \"compact\",\n interactive = true,\n statusText,\n statusLabels,\n showStatus = true,\n onRetry,\n retryLabel = \"Try again\",\n className,\n mediaClassName,\n statusClassName,\n}: ImageGenerationProps) {\n const reduced = useReducedMotion() ?? false;\n const active =\n status === \"queued\" || status === \"generating\" || status === \"refining\";\n const mediaState = MEDIA_STATE[status];\n const resolvedStatusText =\n statusText ?? statusLabels?.[status] ?? STATUS_TEXT[status];\n const resolvedLabel =\n label ?? (prompt ? `${resolvedStatusText}: ${prompt}` : resolvedStatusText);\n\n return (\n <div\n data-slot=\"image-generation\"\n data-state={status}\n aria-busy={active}\n className={cn(\"w-full\", className)}\n >\n <div className={cn(\"w-full\", size === \"compact\" && \"mx-auto max-w-52\")}>\n <div\n data-slot=\"image-generation-frame\"\n role=\"img\"\n aria-label={resolvedLabel}\n style={{ aspectRatio }}\n className=\"relative isolate w-full overflow-hidden rounded-xl bg-muted\"\n >\n <motion.div\n data-slot=\"image-generation-media\"\n aria-hidden={children ? undefined : true}\n initial={false}\n animate={\n reduced\n ? { opacity: mediaState.opacity }\n : {\n filter: mediaState.filter,\n opacity: mediaState.opacity,\n scale: mediaState.scale,\n }\n }\n transition={\n reduced ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT }\n }\n className={cn(\n \"absolute inset-0 [&>*]:size-full [&>*]:object-cover [&_img]:size-full [&_img]:object-cover\",\n mediaClassName\n )}\n >\n {children}\n </motion.div>\n\n <AnimatePresence initial={false}>\n {active ? (\n <motion.div\n key=\"dither-field\"\n initial={{ opacity: 0 }}\n animate={{ opacity: 1 }}\n exit={{ opacity: 0 }}\n transition={{ duration: reduced ? 0 : 0.25, ease: EASE_OUT }}\n className=\"absolute inset-0\"\n >\n <DitherField\n interactive={interactive}\n reduced={reduced}\n status={status}\n />\n </motion.div>\n ) : null}\n </AnimatePresence>\n\n {resolution ? (\n <span\n data-slot=\"image-generation-resolution\"\n className=\"absolute top-2 right-2 z-10 rounded-full bg-background/75 px-2 py-0.5 font-mono tabular-nums text-muted-foreground\"\n style={TINY_TEXT}\n >\n {resolution}\n </span>\n ) : null}\n </div>\n\n {showStatus || prompt ? (\n <div className=\"mt-3 text-left\">\n {showStatus ? (\n <div\n data-slot=\"image-generation-status\"\n aria-live=\"polite\"\n className={cn(\n \"flex min-h-5 items-center gap-2 text-sm font-medium text-foreground\",\n status === \"error\" && \"text-destructive\",\n statusClassName\n )}\n >\n <DitherMark status={status} reduced={reduced} />\n <AnimatePresence mode=\"popLayout\" initial={false}>\n <motion.span\n key={resolvedStatusText}\n initial={reduced ? false : { opacity: 0, y: 4 }}\n animate={{ opacity: 1, y: 0 }}\n exit={reduced ? undefined : { opacity: 0, y: -4 }}\n transition={{\n duration: reduced ? 0 : 0.15,\n ease: EASE_OUT,\n }}\n >\n {resolvedStatusText}\n </motion.span>\n </AnimatePresence>\n </div>\n ) : null}\n {prompt ? (\n <p\n data-slot=\"image-generation-prompt\"\n className=\"mt-0.5 truncate text-xs text-muted-foreground\"\n >\n “{prompt}”\n </p>\n ) : null}\n </div>\n ) : null}\n\n {status === \"error\" && onRetry ? (\n <Button\n data-slot=\"image-generation-retry\"\n type=\"button\"\n variant=\"ghost\"\n onClick={onRetry}\n className=\"mt-3 min-h-10 px-3 text-foreground\"\n render={\n <motion.button\n whileTap={reduced ? undefined : { scale: 0.96 }}\n transition={SPRING_PRESS}\n />\n }\n >\n <RotateCcwIcon aria-hidden=\"true\" className=\"size-4\" />\n {retryLabel}\n </Button>\n ) : null}\n </div>\n </div>\n );\n}\n\nexport { ImageGeneration };\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": "components/ui/ai-image-generation.tsx"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"type": "registry:ui"
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-markdown",
|
|
4
|
+
"title": "AI Markdown",
|
|
5
|
+
"description": "Markdown as a model writes it — GFM, highlighted code, CJK emphasis — with maths and diagrams fetched only when the text contains them.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"streamdown",
|
|
8
|
+
"@streamdown/code",
|
|
9
|
+
"@streamdown/math",
|
|
10
|
+
"@streamdown/mermaid",
|
|
11
|
+
"@streamdown/cjk",
|
|
12
|
+
"katex"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "base/ui/ai-markdown/ai-markdown.tsx",
|
|
17
|
+
"content": "\"use client\";\n\n/**\n * Markdown as a model writes it: GFM, highlighted code, CJK-friendly\n * emphasis and, when the text calls for them, maths and diagrams.\n *\n * KaTeX and Mermaid weigh over a megabyte between them, so neither ships\n * with the page. Each is fetched the first time a `$$` block or a\n * `mermaid` fence appears in any rendered text, and every `Markdown` on\n * the page picks it up once it lands; until then the source shows as\n * plain text or a code block.\n */\n\nimport * as React from \"react\";\nimport { Streamdown } from \"streamdown\";\nimport { cjk } from \"@streamdown/cjk\";\nimport { code } from \"@streamdown/code\";\nimport \"katex/dist/katex.min.css\";\n\ntype StreamdownProps = React.ComponentProps<typeof Streamdown>;\ntype Plugins = NonNullable<StreamdownProps[\"plugins\"]>;\ntype OptionalPlugin = \"math\" | \"mermaid\";\n\nconst NEEDS: Record<OptionalPlugin, RegExp> = {\n math: /\\$\\$|```math/,\n mermaid: /```mermaid/,\n};\n\nconst LOADERS: Record<OptionalPlugin, () => Promise<unknown>> = {\n math: () => import(\"@streamdown/math\").then((m) => m.math),\n mermaid: () => import(\"@streamdown/mermaid\").then((m) => m.mermaid),\n};\n\nconst loaded: Partial<Record<OptionalPlugin, unknown>> = {};\nconst requested = new Set<OptionalPlugin>();\nconst listeners = new Set<() => void>();\nlet version = 0;\n\nfunction request(name: OptionalPlugin) {\n if (requested.has(name)) return;\n requested.add(name);\n LOADERS[name]()\n .then((plugin) => {\n loaded[name] = plugin;\n version += 1;\n for (const notify of listeners) notify();\n })\n // A failed fetch leaves the source readable as text; the next\n // render that needs the plugin asks again.\n .catch(() => requested.delete(name));\n}\n\nfunction subscribe(notify: () => void) {\n listeners.add(notify);\n return () => {\n listeners.delete(notify);\n };\n}\n\nconst getVersion = () => version;\nconst getServerVersion = () => 0;\n\n// One object per combination, so Streamdown sees the same `plugins`\n// across renders and only reparses when a plugin actually arrives.\nconst combinations = new Map<string, Plugins>();\n\nfunction pluginsFor(math: boolean, mermaid: boolean): Plugins {\n const key = `${math}:${mermaid}`;\n let plugins = combinations.get(key);\n if (!plugins) {\n // The plugin packages type `Pluggable` against their own `unified`\n // copy; the shapes are the ones Streamdown expects.\n plugins = {\n code,\n cjk,\n ...(math ? { math: loaded.math } : {}),\n ...(mermaid ? { mermaid: loaded.mermaid } : {}),\n } as unknown as Plugins;\n combinations.set(key, plugins);\n }\n return plugins;\n}\n\nexport type MarkdownProps = Omit<StreamdownProps, \"plugins\">;\n\nexport function Markdown({ children, ...props }: MarkdownProps) {\n const text = typeof children === \"string\" ? children : \"\";\n const needsMath = NEEDS.math.test(text);\n const needsMermaid = NEEDS.mermaid.test(text);\n\n React.useEffect(() => {\n if (needsMath) request(\"math\");\n if (needsMermaid) request(\"mermaid\");\n }, [needsMath, needsMermaid]);\n\n React.useSyncExternalStore(subscribe, getVersion, getServerVersion);\n\n return (\n <Streamdown\n plugins={pluginsFor(\n needsMath && \"math\" in loaded,\n needsMermaid && \"mermaid\" in loaded\n )}\n {...props}\n >\n {children}\n </Streamdown>\n );\n}\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": "components/ui/ai-markdown.tsx"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"type": "registry:ui"
|
|
23
|
+
}
|
|
@@ -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
|
+
}
|