@intelligo-dev/cli 1.0.0-beta.13 → 1.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/dist/bin.js +37 -0
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts +4 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +9 -0
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +4 -1
- package/dist/commands/create-flow.d.ts.map +1 -1
- package/dist/commands/create-flow.js +63 -31
- package/dist/commands/create-flow.js.map +1 -1
- package/dist/commands/create.d.ts +8 -4
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +15 -7
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +21 -19
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-check.d.ts +20 -1
- package/dist/commands/migrate-check.d.ts.map +1 -1
- package/dist/commands/migrate-check.js +35 -6
- package/dist/commands/migrate-check.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -8
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/sync-messages.d.ts +37 -0
- package/dist/commands/sync-messages.d.ts.map +1 -0
- package/dist/commands/sync-messages.js +88 -0
- package/dist/commands/sync-messages.js.map +1 -0
- package/dist/commands/sync-scaffold.d.ts +36 -0
- package/dist/commands/sync-scaffold.d.ts.map +1 -0
- package/dist/commands/sync-scaffold.js +53 -0
- package/dist/commands/sync-scaffold.js.map +1 -0
- package/dist/commands/sync.d.ts +103 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +361 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/upgrade-check.d.ts.map +1 -1
- package/dist/commands/upgrade-check.js +4 -1
- package/dist/commands/upgrade-check.js.map +1 -1
- package/dist/manifest.d.ts +27 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +30 -3
- package/dist/manifest.js.map +1 -1
- package/dist/module-exports.d.ts +63 -0
- package/dist/module-exports.d.ts.map +1 -0
- package/dist/module-exports.js +231 -0
- package/dist/module-exports.js.map +1 -0
- package/dist/registry-bundle.d.ts +43 -0
- package/dist/registry-bundle.d.ts.map +1 -0
- package/dist/registry-bundle.js +74 -0
- package/dist/registry-bundle.js.map +1 -0
- package/dist/registry-items.d.ts +41 -13
- package/dist/registry-items.d.ts.map +1 -1
- package/dist/registry-items.js +117 -33
- package/dist/registry-items.js.map +1 -1
- package/package.json +1 -1
- package/src/bin.ts +52 -0
- package/src/commands/add.ts +11 -0
- package/src/commands/create-flow.ts +82 -26
- package/src/commands/create.ts +17 -7
- package/src/commands/doctor.ts +27 -27
- package/src/commands/migrate-check.ts +51 -10
- package/src/commands/migrate.ts +2 -8
- package/src/commands/sync-messages.ts +114 -0
- package/src/commands/sync-scaffold.ts +83 -0
- package/src/commands/sync.ts +534 -0
- package/src/commands/upgrade-check.ts +4 -1
- package/src/manifest.ts +57 -3
- package/src/module-exports.ts +266 -0
- package/src/registry-bundle.ts +103 -0
- package/src/registry-items.ts +141 -36
- package/templates/app-scaffold/intelligo.ts.tpl +6 -47
- package/templates/manifest.json +19 -0
- package/templates/registry/ai-agent-activity.json +23 -0
- package/templates/registry/ai-agent-progress.json +21 -0
- package/templates/registry/ai-approval-card.json +27 -0
- package/templates/registry/ai-artifact.json +22 -0
- package/templates/registry/ai-branch.json +21 -0
- package/templates/registry/ai-citations.json +24 -0
- package/templates/registry/ai-code-block.json +23 -0
- package/templates/registry/ai-composer-menu.json +19 -0
- package/templates/registry/ai-file-diff.json +25 -0
- package/templates/registry/ai-image-generation.json +23 -0
- package/templates/registry/ai-markdown.json +23 -0
- package/templates/registry/ai-message-bubble.json +23 -0
- package/templates/registry/ai-message-scroller.json +22 -0
- package/templates/registry/ai-message.json +21 -0
- package/templates/registry/ai-motion.json +19 -0
- package/templates/registry/ai-prompt-input.json +28 -0
- package/templates/registry/ai-reasoning-text.json +22 -0
- package/templates/registry/ai-reasoning.json +22 -0
- package/templates/registry/ai-shimmer-text.json +19 -0
- package/templates/registry/ai-sidebar.json +25 -0
- package/templates/registry/ai-speech-input.json +21 -0
- package/templates/registry/ai-streaming-response.json +24 -0
- package/templates/registry/ai-suggestion.json +19 -0
- package/templates/registry/ai-todo-list.json +22 -0
- package/templates/registry/ai-tool-approval.json +26 -0
- package/templates/registry/ai-tool-result.json +25 -0
- package/templates/registry/alert-dialog.json +23 -0
- package/templates/registry/animated-list.json +21 -0
- package/templates/registry/app-shell.json +93 -0
- package/templates/registry/artifacts.json +82 -0
- package/templates/registry/attachment.json +22 -0
- package/templates/registry/auth-email-verification.json +39 -0
- package/templates/registry/auth-login.json +72 -0
- package/templates/registry/auth-password-reset.json +53 -0
- package/templates/registry/auth-signup.json +41 -0
- package/templates/registry/billing-settings.json +60 -0
- package/templates/registry/button.json +22 -0
- package/templates/registry/chat-eve.json +19 -0
- package/templates/registry/chat-panel.json +30 -0
- package/templates/registry/chat-share.json +42 -0
- package/templates/registry/chat-widget.json +34 -0
- package/templates/registry/chat.json +326 -0
- package/templates/registry/checkbox.json +22 -0
- package/templates/registry/checkout.json +40 -0
- package/templates/registry/collapsible.json +19 -0
- package/templates/registry/command.json +23 -0
- package/templates/registry/copy-button.json +21 -0
- package/templates/registry/dashboard.json +69 -0
- package/templates/registry/dialog.json +24 -0
- package/templates/registry/document-viewer.json +22 -0
- package/templates/registry/dropdown-menu.json +23 -0
- package/templates/registry/expandable-tabs.json +22 -0
- package/templates/registry/feature-gating.json +73 -0
- package/templates/registry/file-upload.json +24 -0
- package/templates/registry/hold-action-button.json +22 -0
- package/templates/registry/input-group.json +23 -0
- package/templates/registry/input.json +19 -0
- package/templates/registry/intelligo.json +187 -0
- package/templates/registry/invitation-accept.json +64 -0
- package/templates/registry/language-switcher.json +29 -0
- package/templates/registry/morphing-modal.json +24 -0
- package/templates/registry/notification-stack.json +24 -0
- package/templates/registry/notifications.json +87 -0
- package/templates/registry/onboarding.json +83 -0
- package/templates/registry/otp-input.json +22 -0
- package/templates/registry/page-header.json +15 -0
- package/templates/registry/payment-poll.json +52 -0
- package/templates/registry/popover-morph.json +22 -0
- package/templates/registry/popover.json +22 -0
- package/templates/registry/pricing.json +111 -0
- package/templates/registry/privacy-settings.json +65 -0
- package/templates/registry/profile-settings.json +68 -0
- package/templates/registry/progress.json +19 -0
- package/templates/registry/radio-group.json +22 -0
- package/templates/registry/registry.json +2945 -0
- package/templates/registry/route-error.json +65 -0
- package/templates/registry/select-morph.json +23 -0
- package/templates/registry/select.json +23 -0
- package/templates/registry/settings-shell.json +47 -0
- package/templates/registry/sheet.json +24 -0
- package/templates/registry/sidebar.json +28 -0
- package/templates/registry/smoke.json +34 -0
- package/templates/registry/spinner.json +16 -0
- package/templates/registry/stat-card.json +18 -0
- package/templates/registry/status-badge.json +18 -0
- package/templates/registry/switch.json +20 -0
- package/templates/registry/tabs.json +23 -0
- package/templates/registry/team-settings.json +97 -0
- package/templates/registry/textarea.json +16 -0
- package/templates/registry/tooltip.json +22 -0
- package/templates/registry/trial-banner.json +43 -0
- package/templates/registry/usage.json +84 -0
- package/templates/registry/workspace-settings.json +71 -0
- package/templates/registry-items.json +1 -1
- package/templates/registry-requires.json +26 -2
- package/templates/vitest/server-only.ts.tpl +7 -0
- package/templates/vitest/vitest.config.ts.tpl +37 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-agent-activity",
|
|
4
|
+
"title": "AI Agent Activity",
|
|
5
|
+
"description": "One adaptive stream of what the agent is doing: reasoning text, plan steps, web searches with results, tool calls and structured traces, gliding while it works and folding into a summary when done.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion",
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion",
|
|
12
|
+
"@intelligo/ai-shimmer-text"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "base/ui/ai-agent-activity/ai-agent-activity.tsx",
|
|
17
|
+
"content": "\"use client\";\n\n/*\n * One adaptive stream of what the agent is doing: reasoning text, plan\n * steps, web searches with their results, tool calls and structured\n * traces. While the run is working the newest rows glide up under a fade;\n * once complete the stream folds into a one-line summary that opens on\n * demand.\n */\n\nimport * as React from \"react\";\nimport {\n CheckIcon,\n ChevronDownIcon,\n CircleAlertIcon,\n CircleIcon,\n FileTextIcon,\n Globe2Icon,\n ImageIcon,\n MessageSquareIcon,\n PencilLineIcon,\n SearchIcon,\n SparklesIcon,\n SquareTerminalIcon,\n WrenchIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n EASE_OUT,\n Disclosure,\n SPRING_LAYOUT,\n SPRING_SWAP,\n} from \"@/components/ui/ai-motion\";\nimport { ShimmerText } from \"@/components/ui/ai-shimmer-text\";\nimport { cn } from \"@/lib/utils\";\n\n/* ----------------------------------------------------------------------------\n * Items\n * ------------------------------------------------------------------------- */\n\nexport type AgentActivityStatus = \"working\" | \"complete\";\nexport type AgentStepStatus = \"pending\" | \"active\" | \"complete\";\n\nexport interface AgentActivityStep {\n id: string;\n type: \"step\";\n label: React.ReactNode;\n status?: AgentStepStatus;\n meta?: React.ReactNode;\n}\n\nexport interface AgentActivityText {\n id: string;\n type: \"text\";\n content: React.ReactNode;\n}\n\nexport interface AgentSearchResult {\n id: string;\n title: React.ReactNode;\n domain?: React.ReactNode;\n url?: string;\n icon?: React.ReactNode;\n}\n\n/** A row's own state inside the stream; rows are complete by default. */\nexport type AgentRowStatus = \"running\" | \"complete\" | \"error\";\n\nexport interface AgentActivitySearch {\n id: string;\n type: \"search\";\n query: React.ReactNode;\n results?: AgentSearchResult[];\n moreCount?: number;\n status?: AgentRowStatus;\n /** Shown under the row when the reader opens it — the raw call, say. */\n details?: React.ReactNode;\n}\n\nexport interface AgentActivityTool {\n id: string;\n type: \"tool\";\n action: \"read\" | \"edit\" | \"run\" | (string & {});\n target?: React.ReactNode;\n additions?: number;\n deletions?: number;\n status?: AgentRowStatus;\n /** Shown under the row when the reader opens it — the raw call, say. */\n details?: React.ReactNode;\n}\n\nexport type AgentTraceKind =\n | \"thinking\"\n | \"message\"\n | \"write\"\n | \"run\"\n | \"read\"\n | (string & {});\n\nexport interface AgentActivityTrace {\n id: string;\n type: \"trace\";\n kind: AgentTraceKind;\n label: React.ReactNode;\n detail?: React.ReactNode;\n icon?: React.ReactNode;\n}\n\nexport type AgentActivityItem =\n | AgentActivityStep\n | AgentActivityText\n | AgentActivitySearch\n | AgentActivityTool\n | AgentActivityTrace;\n\nexport type AgentActivityContentType = AgentActivityItem[\"type\"] | \"mixed\";\n\n/* ----------------------------------------------------------------------------\n * Labels — every visible string, with an English default.\n * ------------------------------------------------------------------------- */\n\nexport interface AgentActivitySummaryLabels {\n /** Step- or text-only runs: `duration` is already formatted. */\n thought: (duration: string) => React.ReactNode;\n searched: string;\n tools: (count: number) => string;\n trace: (toolCalls: number, messages: number) => string;\n steps: (count: number) => string;\n}\n\nconst DEFAULT_ACTIVE_LABELS: Record<AgentActivityContentType, string> = {\n step: \"Thinking…\",\n text: \"Thinking…\",\n search: \"Searching the web…\",\n tool: \"Running tools…\",\n trace: \"Working through the run…\",\n mixed: \"Working through it…\",\n};\n\nconst DEFAULT_SUMMARY_LABELS: AgentActivitySummaryLabels = {\n thought: (duration) => (\n <>\n Thought for <span className=\"tabular-nums\">{duration}</span>\n </>\n ),\n searched: \"Searched the web\",\n tools: (count) => `Ran ${count} ${count === 1 ? \"tool\" : \"tools\"}`,\n trace: (toolCalls, messages) =>\n `${toolCalls} ${toolCalls === 1 ? \"tool call\" : \"tool calls\"}, ${messages} ${messages === 1 ? \"message\" : \"messages\"}`,\n steps: (count) => `Completed ${count} ${count === 1 ? \"step\" : \"steps\"}`,\n};\n\nfunction defaultFormatDuration(duration: number) {\n const seconds = Math.max(0, Math.round(duration));\n if (seconds < 60) return `${seconds}s`;\n\n const minutes = Math.floor(seconds / 60);\n const remainder = seconds % 60;\n return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;\n}\n\nfunction defaultMoreLabel(count: number) {\n return `+${count} more`;\n}\n\nfunction defaultActionLabel(action: string) {\n return action.charAt(0).toUpperCase() + action.slice(1);\n}\n\n/* ----------------------------------------------------------------------------\n * Rows\n * ------------------------------------------------------------------------- */\n\nfunction StepRow({ item }: { item: AgentActivityStep }) {\n const reduce = useReducedMotion() ?? false;\n const state = item.status ?? \"complete\";\n\n return (\n <div\n data-slot=\"agent-activity-step\"\n data-status={state}\n className=\"flex min-h-7 items-start gap-2.5 rounded-md px-1.5 py-1\"\n >\n <span\n aria-hidden=\"true\"\n className=\"mt-0.5 grid size-4 shrink-0 place-items-center text-muted-foreground/70\"\n >\n {state === \"complete\" ? (\n <CheckIcon className=\"size-4\" strokeWidth={1.8} />\n ) : state === \"active\" ? (\n <span className=\"relative grid size-3 place-items-center\">\n <motion.span\n className=\"absolute inset-0 rounded-full bg-foreground/10\"\n animate={\n reduce ? { opacity: 0.6 } : { opacity: [0.35, 0.8, 0.35] }\n }\n transition={\n reduce\n ? { duration: 0 }\n : { duration: 1.5, repeat: Number.POSITIVE_INFINITY }\n }\n />\n <span className=\"size-1.5 rounded-full bg-foreground/60\" />\n </span>\n ) : (\n <CircleIcon className=\"size-3\" strokeWidth={1.5} />\n )}\n </span>\n <span\n className={cn(\n \"min-w-0 flex-1 leading-5\",\n state === \"pending\"\n ? \"text-muted-foreground/55\"\n : \"text-foreground/90\"\n )}\n >\n {item.label}\n </span>\n {item.meta ? (\n <span className=\"shrink-0 leading-5 text-muted-foreground/55\">\n {item.meta}\n </span>\n ) : null}\n </div>\n );\n}\n\nfunction TextRow({ item }: { item: AgentActivityText }) {\n return (\n <div\n data-slot=\"agent-activity-text\"\n className=\"rounded-md px-1.5 py-1 leading-5 text-muted-foreground\"\n >\n {item.content}\n </div>\n );\n}\n\nfunction SearchResultRow({ result }: { result: AgentSearchResult }) {\n const content = (\n <>\n <span\n aria-hidden=\"true\"\n className=\"grid size-5 shrink-0 place-items-center text-muted-foreground\"\n >\n {result.icon ?? <Globe2Icon className=\"size-3\" strokeWidth={2} />}\n </span>\n <span className=\"min-w-0 truncate font-medium text-foreground/90\">\n {result.title}\n </span>\n {result.domain ? (\n <span className=\"min-w-0 truncate text-muted-foreground/55\">\n {result.domain}\n </span>\n ) : null}\n </>\n );\n const className = cn(\n \"flex min-h-7 items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none transition-colors\",\n result.url && \"hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring\"\n );\n\n return result.url ? (\n <a\n data-slot=\"agent-activity-search-result\"\n href={result.url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className={className}\n >\n {content}\n </a>\n ) : (\n <div data-slot=\"agent-activity-search-result\" className={className}>\n {content}\n </div>\n );\n}\n\n/** A row's glyph, pulsing while its call runs; an alert once it failed. */\nfunction RowStatusIcon({\n status,\n children,\n}: {\n status: AgentRowStatus;\n children: React.ReactNode;\n}) {\n const reduce = useReducedMotion() ?? false;\n if (status === \"error\") {\n return <CircleAlertIcon className=\"size-4 text-destructive\" strokeWidth={1.8} />;\n }\n if (status === \"running\") {\n return (\n <motion.span\n className=\"grid size-4 place-items-center\"\n animate={reduce ? { opacity: 0.7 } : { opacity: [0.4, 1, 0.4] }}\n transition={\n reduce\n ? { duration: 0 }\n : { duration: 1.4, repeat: Number.POSITIVE_INFINITY }\n }\n >\n {children}\n </motion.span>\n );\n }\n return <>{children}</>;\n}\n\n/** A row that opens onto its details, when it has any. */\nfunction RowWithDetails({\n details,\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\" | \"className\"> & {\n details?: React.ReactNode;\n className: string;\n children: React.ReactNode;\n}) {\n const reduce = useReducedMotion() ?? false;\n const [open, setOpen] = React.useState(false);\n const detailsId = React.useId();\n\n if (!details) {\n return (\n <div className={className} {...props}>\n {children}\n </div>\n );\n }\n\n return (\n <div {...props}>\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={detailsId}\n onClick={() => setOpen(!open)}\n className={cn(\n className,\n \"group/row w-full text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring\"\n )}\n >\n {children}\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: open ? 180 : 0 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className={cn(\n \"inline-flex shrink-0 text-muted-foreground/60 transition-opacity group-hover/row:opacity-100 group-focus-visible/row:opacity-100\",\n open ? \"opacity-100\" : \"opacity-0\"\n )}\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n <Disclosure id={detailsId} open={open}>\n <div data-slot=\"agent-activity-details\" className=\"pt-1 pr-1.5 pb-2 pl-8\">\n {details}\n </div>\n </Disclosure>\n </div>\n );\n}\n\nfunction SearchRow({\n item,\n moreLabel,\n}: {\n item: AgentActivitySearch;\n moreLabel: (count: number) => string;\n}) {\n const reduce = useReducedMotion() ?? false;\n const enter = reduce ? { opacity: 1 } : { opacity: 0, y: 6 };\n const visible = { opacity: 1, y: 0 };\n const exit = reduce ? { opacity: 0 } : { opacity: 0, y: -3 };\n const transition = reduce\n ? { duration: 0 }\n : {\n opacity: { duration: 0.18, ease: EASE_OUT },\n y: SPRING_LAYOUT,\n layout: SPRING_LAYOUT,\n };\n\n return (\n <div\n data-slot=\"agent-activity-search\"\n data-status={item.status ?? \"complete\"}\n className=\"space-y-0.5\"\n >\n <RowWithDetails\n details={item.details}\n className=\"flex min-h-7 items-center gap-2.5 rounded-md px-1.5 py-1 text-muted-foreground\"\n >\n <span aria-hidden=\"true\" className=\"grid size-4 shrink-0 place-items-center\">\n <RowStatusIcon status={item.status ?? \"complete\"}>\n <SearchIcon className=\"size-4\" strokeWidth={1.7} />\n </RowStatusIcon>\n </span>\n <span className=\"min-w-0 flex-1 truncate\">{item.query}</span>\n </RowWithDetails>\n {item.results?.length ? (\n <div className=\"space-y-0.5 pl-4\">\n <AnimatePresence initial mode=\"popLayout\">\n {item.results.map((result) => (\n <motion.div\n layout=\"position\"\n key={result.id}\n initial={enter}\n animate={visible}\n exit={exit}\n transition={transition}\n >\n <SearchResultRow result={result} />\n </motion.div>\n ))}\n </AnimatePresence>\n </div>\n ) : null}\n <AnimatePresence initial>\n {item.moreCount ? (\n <motion.div\n key=\"more-results\"\n initial={enter}\n animate={visible}\n exit={exit}\n transition={transition}\n className=\"px-1.5 py-1 pl-8 text-muted-foreground/55\"\n >\n {moreLabel(item.moreCount)}\n </motion.div>\n ) : null}\n </AnimatePresence>\n </div>\n );\n}\n\nfunction ActionIcon({ action }: { action: string }) {\n if (action === \"read\") return <FileTextIcon className=\"size-4\" />;\n if (action === \"edit\" || action === \"write\") {\n return <PencilLineIcon className=\"size-4\" />;\n }\n if (action === \"run\") return <SquareTerminalIcon className=\"size-4\" />;\n return <WrenchIcon className=\"size-4\" />;\n}\n\nfunction ToolRow({\n item,\n actionLabels,\n}: {\n item: AgentActivityTool;\n actionLabels?: Record<string, string>;\n}) {\n const action = actionLabels?.[item.action] ?? defaultActionLabel(item.action);\n const status = item.status ?? \"complete\";\n\n return (\n <RowWithDetails\n data-slot=\"agent-activity-tool\"\n data-action={item.action}\n data-status={status}\n details={item.details}\n className=\"flex min-h-8 min-w-0 items-center gap-2.5 rounded-md px-1.5 py-0.5 leading-5\"\n >\n <span\n aria-hidden=\"true\"\n className=\"grid size-4 shrink-0 place-items-center text-muted-foreground/70\"\n >\n <RowStatusIcon status={status}>\n <ActionIcon action={item.action} />\n </RowStatusIcon>\n </span>\n <span className=\"shrink-0 font-medium text-foreground/90\">{action}</span>\n {item.target ? (\n <span\n className={cn(\n \"min-w-0 flex-1 truncate rounded-lg bg-muted/80 px-2.5 py-1 font-mono text-xs text-muted-foreground\",\n status === \"error\" && \"bg-transparent px-0 font-sans text-destructive\"\n )}\n >\n {item.target}\n </span>\n ) : (\n <span className=\"flex-1\" />\n )}\n {typeof item.additions === \"number\" ||\n typeof item.deletions === \"number\" ? (\n <span className=\"flex shrink-0 items-center gap-2 font-mono tabular-nums\">\n {typeof item.additions === \"number\" ? (\n <span className=\"text-success\">+{item.additions}</span>\n ) : null}\n {typeof item.deletions === \"number\" ? (\n <span className=\"text-destructive\">−{item.deletions}</span>\n ) : null}\n </span>\n ) : null}\n </RowWithDetails>\n );\n}\n\nfunction TraceIcon({ kind }: { kind: AgentTraceKind }) {\n if (kind === \"thinking\") return <SparklesIcon className=\"size-4\" />;\n if (kind === \"message\") return <MessageSquareIcon className=\"size-4\" />;\n if (kind === \"write\") return <PencilLineIcon className=\"size-4\" />;\n if (kind === \"run\") return <SquareTerminalIcon className=\"size-4\" />;\n if (kind === \"read\") return <ImageIcon className=\"size-4\" />;\n return <WrenchIcon className=\"size-4\" />;\n}\n\nfunction TraceRow({ item }: { item: AgentActivityTrace }) {\n return (\n <div\n data-slot=\"agent-activity-trace\"\n data-kind={item.kind}\n className=\"grid min-h-8 items-center gap-2.5 rounded-md px-1.5 py-0.5\"\n style={{ gridTemplateColumns: \"1rem auto minmax(0, 1fr)\" }}\n >\n <span\n aria-hidden=\"true\"\n className=\"grid size-4 place-items-center text-muted-foreground/70\"\n >\n {item.icon ?? <TraceIcon kind={item.kind} />}\n </span>\n <span className=\"font-medium text-foreground/90\">{item.label}</span>\n {item.detail ? (\n <span className=\"min-w-0 truncate rounded-lg bg-muted/80 px-2.5 py-1 font-mono text-xs text-muted-foreground/70\">\n {item.detail}\n </span>\n ) : (\n <span />\n )}\n </div>\n );\n}\n\nexport interface AgentActivityRowProps {\n item: AgentActivityItem;\n /** \"+3 more\" under a search's results. */\n moreLabel?: (count: number) => string;\n /** The word shown for a tool action (`read` → \"Read\"); capitalised by default. */\n actionLabels?: Record<string, string>;\n}\n\nfunction AgentActivityRow({\n item,\n moreLabel = defaultMoreLabel,\n actionLabels,\n}: AgentActivityRowProps) {\n if (item.type === \"text\") return <TextRow item={item} />;\n if (item.type === \"search\")\n return <SearchRow item={item} moreLabel={moreLabel} />;\n if (item.type === \"tool\")\n return <ToolRow item={item} actionLabels={actionLabels} />;\n if (item.type === \"trace\") return <TraceRow item={item} />;\n return <StepRow item={item} />;\n}\n\n/* ----------------------------------------------------------------------------\n * The stream\n * ------------------------------------------------------------------------- */\n\nexport interface AgentActivityProps extends Omit<\n React.ComponentProps<\"div\">,\n \"children\"\n> {\n /** Chronological activity entries. Append or update items as events stream. */\n items: AgentActivityItem[];\n /** Expected activity kind before the first streamed item arrives. */\n contentType?: AgentActivityContentType;\n /** Current run phase. Active runs always stay expanded. */\n status?: AgentActivityStatus;\n /** Elapsed run time, in seconds. Used by the step-only summary. */\n duration?: number;\n /** Controlled expanded state used after the run completes. */\n open?: boolean;\n /** Initial expanded state used after the run completes. */\n defaultOpen?: boolean;\n /** Called when the completed activity disclosure changes state. */\n onOpenChange?: (open: boolean) => void;\n /** Collapse the disclosure when status changes from working to complete. */\n collapseOnComplete?: boolean;\n /** The status line while the run is active; overrides `activeLabels`. */\n activeLabel?: string;\n /** The status line per content kind while the run is active. */\n activeLabels?: Partial<Record<AgentActivityContentType, string>>;\n /** The completed summary; overrides `summaryLabels`. */\n summary?: React.ReactNode;\n /** The completed summary per content kind. */\n summaryLabels?: Partial<AgentActivitySummaryLabels>;\n /** \"45s\", \"2m 5s\" — for the step-only summary. */\n formatDuration?: (seconds: number) => string;\n /** \"+3 more\" under a search's results. */\n moreLabel?: (count: number) => string;\n /** The word shown for a tool action (`read` → \"Read\"); capitalised by default. */\n actionLabels?: Record<string, string>;\n /** Optional renderer for the contents of the active status row. */\n renderWorkingStatus?: (context: {\n label: string;\n duration: number;\n }) => React.ReactNode;\n /** Optional renderer for the contents before the built-in disclosure chevron. */\n renderCompletedStatus?: (context: {\n summary: React.ReactNode;\n duration: number;\n }) => React.ReactNode;\n /** Maximum visible activity height before the stream begins gliding. */\n maxHeight?: number;\n contentClassName?: string;\n}\n\nfunction useControllableOpen({\n open,\n defaultOpen,\n onOpenChange,\n}: {\n open?: boolean;\n defaultOpen: boolean;\n onOpenChange?: (open: boolean) => void;\n}) {\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const controlled = open !== undefined;\n const currentOpen = open ?? internalOpen;\n\n const setOpen = React.useCallback(\n (next: boolean) => {\n if (!controlled) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [controlled, onOpenChange]\n );\n\n return [currentOpen, setOpen] as const;\n}\n\nfunction getContentType(items: AgentActivityItem[]): AgentActivityContentType {\n const first = items[0]?.type;\n return first && items.every((item) => item.type === first) ? first : \"mixed\";\n}\n\nfunction getSummary(\n type: AgentActivityContentType,\n items: AgentActivityItem[],\n duration: string,\n labels: AgentActivitySummaryLabels\n): React.ReactNode {\n if (type === \"step\" || type === \"text\") return labels.thought(duration);\n if (type === \"search\") return labels.searched;\n if (type === \"tool\") return labels.tools(items.length);\n if (type === \"trace\") {\n const messages = items.filter(\n (item) =>\n item.type === \"trace\" &&\n (item.kind === \"thinking\" || item.kind === \"message\")\n ).length;\n return labels.trace(items.length - messages, messages);\n }\n return labels.steps(items.length);\n}\n\nfunction AgentActivity({\n items,\n contentType: initialContentType,\n status = \"working\",\n duration = 0,\n open,\n defaultOpen = false,\n onOpenChange,\n collapseOnComplete = true,\n activeLabel,\n activeLabels,\n summary,\n summaryLabels,\n formatDuration = defaultFormatDuration,\n moreLabel = defaultMoreLabel,\n actionLabels,\n renderWorkingStatus,\n renderCompletedStatus,\n maxHeight = 208,\n className,\n contentClassName,\n ...props\n}: AgentActivityProps) {\n const reduce = useReducedMotion() ?? false;\n const baseId = React.useId();\n const triggerId = `${baseId}-trigger`;\n const contentId = `${baseId}-content`;\n const contentRef = React.useRef<HTMLDivElement>(null);\n const viewportRef = React.useRef<HTMLDivElement>(null);\n const previousStatus = React.useRef(status);\n const [contentHeight, setContentHeight] = React.useState(0);\n const [currentOpen, setOpen] = useControllableOpen({\n open,\n defaultOpen,\n onOpenChange,\n });\n const working = status === \"working\";\n const expanded = working || currentOpen;\n const contentType = items.length\n ? getContentType(items)\n : (initialContentType ?? \"mixed\");\n // While working the viewport is a fixed window the content glides up\n // through; once complete it shrinks to fit (capped) and scrolls.\n const cappedHeight = Math.min(contentHeight, Math.max(0, maxHeight));\n const viewportHeight = working ? Math.max(0, maxHeight) : cappedHeight;\n const capped = contentHeight > maxHeight;\n const streamOffset = working\n ? Math.min(0, viewportHeight - contentHeight)\n : 0;\n\n React.useLayoutEffect(() => {\n const node = contentRef.current;\n if (!node) return;\n\n const measure = () => setContentHeight(node.offsetHeight);\n measure();\n\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n React.useEffect(() => {\n if (previousStatus.current === \"working\" && status === \"complete\") {\n setOpen(!collapseOnComplete);\n }\n previousStatus.current = status;\n }, [collapseOnComplete, setOpen, status]);\n\n const toggle = () => {\n const next = !currentOpen;\n setOpen(next);\n if (next) {\n requestAnimationFrame(() => viewportRef.current?.scrollTo({ top: 0 }));\n }\n };\n\n const liveLabel =\n activeLabel ??\n activeLabels?.[contentType] ??\n DEFAULT_ACTIVE_LABELS[contentType];\n const completedSummary =\n summary ??\n getSummary(contentType, items, formatDuration(duration), {\n ...DEFAULT_SUMMARY_LABELS,\n ...summaryLabels,\n });\n // Alpha-only masks: the colour words never paint.\n const maskImage = capped\n ? working\n ? \"linear-gradient(to bottom, transparent, black 12px)\"\n : \"linear-gradient(to bottom, transparent, black 12px, black calc(100% - 12px), transparent)\"\n : undefined;\n\n return (\n <div\n data-slot=\"agent-activity\"\n data-state={working ? \"working\" : expanded ? \"open\" : \"closed\"}\n data-content={contentType}\n aria-busy={working}\n className={cn(\"w-full text-sm\", className)}\n {...props}\n >\n {working ? (\n <div\n data-slot=\"agent-activity-status\"\n id={triggerId}\n role=\"status\"\n className=\"flex h-7 min-w-0 items-center text-muted-foreground\"\n >\n {renderWorkingStatus ? (\n renderWorkingStatus({ label: liveLabel, duration })\n ) : (\n // This row is the live region; the shimmer must not nest a second one.\n <ShimmerText role={undefined} className=\"font-medium\">\n {liveLabel}\n </ShimmerText>\n )}\n </div>\n ) : (\n <button\n data-slot=\"agent-activity-trigger\"\n id={triggerId}\n type=\"button\"\n aria-expanded={expanded}\n aria-controls={contentId}\n onClick={toggle}\n className=\"group flex h-7 min-w-0 items-center gap-1.5 rounded-md text-left font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n >\n <span className=\"truncate\">\n {renderCompletedStatus\n ? renderCompletedStatus({ summary: completedSummary, duration })\n : completedSummary}\n </span>\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: expanded ? 180 : 0 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className=\"inline-flex shrink-0 text-muted-foreground/70 group-hover:text-foreground\"\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n )}\n\n <Disclosure\n id={contentId}\n role=\"region\"\n aria-labelledby={triggerId}\n open={expanded}\n openHeight={viewportHeight}\n >\n <div\n data-slot=\"agent-activity-viewport\"\n ref={viewportRef}\n className={cn(\n \"pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n capped && expanded && !working\n ? \"overflow-y-auto\"\n : \"overflow-y-hidden\"\n )}\n style={{\n height: viewportHeight,\n maskImage,\n WebkitMaskImage: maskImage,\n }}\n >\n <motion.div\n data-slot=\"agent-activity-list\"\n ref={contentRef}\n role=\"list\"\n initial={false}\n animate={{ y: streamOffset }}\n transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n className={cn(\"space-y-0.5 py-2\", contentClassName)}\n >\n <AnimatePresence mode=\"popLayout\">\n {items.map((item) => (\n <motion.div\n data-slot=\"agent-activity-item\"\n data-type={item.type}\n layout=\"position\"\n key={item.id}\n role=\"listitem\"\n initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}\n animate={{ opacity: 1, y: 0 }}\n exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}\n transition={\n reduce\n ? { duration: 0 }\n : {\n opacity: { duration: 0.18, ease: EASE_OUT },\n y: SPRING_LAYOUT,\n layout: SPRING_LAYOUT,\n }\n }\n >\n <AgentActivityRow\n item={item}\n moreLabel={moreLabel}\n actionLabels={actionLabels}\n />\n </motion.div>\n ))}\n </AnimatePresence>\n </motion.div>\n </div>\n </Disclosure>\n </div>\n );\n}\n\nexport { AgentActivity, AgentActivityRow };\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": "components/ui/ai-agent-activity.tsx"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"type": "registry:ui"
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-agent-progress",
|
|
4
|
+
"title": "AI Agent Progress",
|
|
5
|
+
"description": "A long-running step: a pulsing grid beside the verb and an elapsed-time counter.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"@intelligo/ai-motion"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "base/ui/ai-agent-progress/ai-agent-progress.tsx",
|
|
15
|
+
"content": "\"use client\";\n\n/*\n * A long-running step: a 3×3 grid pulses in sequence beside the verb and\n * an elapsed-time counter.\n */\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport { EASE_IN_OUT } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst GRID_CELLS = [\n { id: \"top-left\", delay: 0 },\n { id: \"top-center\", delay: 0.14 },\n { id: \"top-right\", delay: 0.28 },\n { id: \"middle-left\", delay: 0.42 },\n { id: \"middle-center\", delay: 0.56 },\n { id: \"middle-right\", delay: 0.7 },\n { id: \"bottom-left\", delay: 0.84 },\n { id: \"bottom-center\", delay: 0.98 },\n { id: \"bottom-right\", delay: 1.12 },\n];\n\nexport interface AgentProgressProps\n extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n /** Verb describing the agent's current activity. */\n label?: string;\n /** Assistive-tech name; defaults to \"{label}, in progress\". */\n statusLabel?: string;\n /** Controlled elapsed time in seconds. */\n elapsedSeconds?: number;\n /** Starting time for the internal timer, in seconds. */\n initialSeconds?: number;\n /** Whether the internal timer should advance. Ignored when elapsedSeconds is provided. */\n running?: boolean;\n /** Renders the counter; \"1m 4.2s\" by default. */\n formatElapsed?: (totalSeconds: number) => string;\n}\n\nfunction defaultFormatElapsed(totalSeconds: number) {\n const safeSeconds = Math.max(0, totalSeconds);\n const minutes = Math.floor(safeSeconds / 60);\n const seconds = (safeSeconds % 60).toFixed(1);\n return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;\n}\n\nfunction AgentProgress({\n label = \"Churning\",\n statusLabel,\n elapsedSeconds,\n initialSeconds = 0,\n running = true,\n formatElapsed = defaultFormatElapsed,\n className,\n ...props\n}: AgentProgressProps) {\n const reduced = useReducedMotion() ?? false;\n const [internalSeconds, setInternalSeconds] = React.useState(initialSeconds);\n\n React.useEffect(() => {\n if (elapsedSeconds !== undefined || !running) return;\n\n const startedAt = performance.now() - initialSeconds * 1000;\n const timer = window.setInterval(() => {\n setInternalSeconds((performance.now() - startedAt) / 1000);\n }, 100);\n\n return () => window.clearInterval(timer);\n }, [elapsedSeconds, initialSeconds, running]);\n\n const elapsed = elapsedSeconds ?? internalSeconds;\n\n return (\n <span\n data-slot=\"agent-progress\"\n role=\"status\"\n aria-label={statusLabel ?? `${label}, in progress`}\n className={cn(\n \"inline-flex items-center gap-3 font-mono text-sm text-muted-foreground\",\n className\n )}\n {...props}\n >\n <span\n data-slot=\"agent-progress-grid\"\n aria-hidden=\"true\"\n className=\"grid size-5 shrink-0 grid-cols-3 gap-0.5\"\n >\n {GRID_CELLS.map(({ id, delay }) => (\n <motion.span\n key={id}\n className=\"rounded-xs bg-current\"\n animate={\n reduced\n ? { opacity: [0.35, 0.8, 0.35] }\n : {\n opacity: [0.28, 1, 0.28],\n scale: [0.72, 1, 0.72],\n }\n }\n transition={{\n duration: 1.55,\n ease: EASE_IN_OUT,\n repeat: Infinity,\n delay,\n }}\n />\n ))}\n </span>\n <span data-slot=\"agent-progress-label\" className=\"font-sans font-medium\">\n {label}\n </span>\n <span\n data-slot=\"agent-progress-elapsed\"\n aria-hidden=\"true\"\n className=\"tabular-nums text-muted-foreground/70\"\n >\n {formatElapsed(elapsed)}\n </span>\n </span>\n );\n}\n\nexport { AgentProgress };\n",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"target": "components/ui/ai-agent-progress.tsx"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"type": "registry:ui"
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-approval-card",
|
|
4
|
+
"title": "AI Approval Card",
|
|
5
|
+
"description": "A decision surface: approve, reject or request changes, or step through single-choice, multiple-choice and freeform questions and submit the answers.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"motion"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion",
|
|
12
|
+
"@intelligo/button",
|
|
13
|
+
"@intelligo/checkbox",
|
|
14
|
+
"@intelligo/input",
|
|
15
|
+
"@intelligo/radio-group",
|
|
16
|
+
"@intelligo/spinner"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "base/ui/ai-approval-card/ai-approval-card.tsx",
|
|
21
|
+
"content": "\"use client\";\n\n/*\n * A decision surface the agent hands to the user: approve / reject /\n * request changes, or a short run of questions (single choice, multiple\n * choice, freeform) answered one step at a time and submitted together.\n */\n\nimport * as React from \"react\";\nimport {\n ArrowLeftIcon,\n ArrowRightIcon,\n CheckIcon,\n CircleHelpIcon,\n MessageSquareTextIcon,\n XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n Disclosure,\n EASE_OUT,\n SPRING_SWAP,\n SwapText,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ApprovalCardStatus =\n | \"pending\"\n | \"submitting\"\n | \"approved\"\n | \"rejected\"\n | \"changes-requested\"\n | \"answered\";\n\nexport interface ApprovalCardOption {\n value: string;\n label: string;\n disabled?: boolean;\n}\n\nexport interface ApprovalCardQuestion {\n id: string;\n title: React.ReactNode;\n description?: React.ReactNode;\n options?: ApprovalCardOption[];\n /** Several options may be chosen; otherwise a single choice. */\n multiple?: boolean;\n /** Move on shortly after a single choice (default true). */\n autoAdvance?: boolean;\n /** Offer a freeform answer beside (or instead of) the options. */\n allowCustom?: boolean;\n customPlaceholder?: string;\n}\n\nexport interface ApprovalCardAnswer {\n selected: string[];\n custom?: string;\n}\n\nexport type ApprovalCardAnswers = Record<string, ApprovalCardAnswer>;\n\nexport interface ApprovalCardProps\n extends Omit<React.ComponentProps<\"div\">, \"title\" | \"onSubmit\"> {\n title?: React.ReactNode;\n description?: React.ReactNode;\n questions?: ApprovalCardQuestion[];\n status?: ApprovalCardStatus;\n answers?: ApprovalCardAnswers;\n defaultAnswers?: ApprovalCardAnswers;\n onAnswersChange?: (answers: ApprovalCardAnswers) => void;\n step?: number;\n defaultStep?: number;\n onStepChange?: (step: number) => void;\n onSubmit?: (answers: ApprovalCardAnswers) => void;\n onApprove?: () => void;\n onReject?: () => void;\n onRequestChanges?: () => void;\n onDismiss?: () => void;\n /** What to show once the decision has landed; defaults to the status label. */\n result?: React.ReactNode;\n approveLabel?: React.ReactNode;\n rejectLabel?: React.ReactNode;\n requestChangesLabel?: React.ReactNode;\n submitLabel?: React.ReactNode;\n dismissLabel?: string;\n previousLabel?: string;\n nextLabel?: string;\n customPlaceholder?: string;\n /** Screen-reader progress, e.g. \"Question 2 of 5\". */\n progressLabel?: (current: number, total: number) => string;\n statusLabels?: Partial<Record<ApprovalCardStatus, string>>;\n}\n\nconst EMPTY_ANSWER: ApprovalCardAnswer = { selected: [], custom: \"\" };\n\nconst DEFAULT_STATUS_LABELS: Record<ApprovalCardStatus, string> = {\n pending: \"Input required\",\n submitting: \"Submitting\",\n approved: \"Approved\",\n rejected: \"Rejected\",\n \"changes-requested\": \"Changes requested\",\n answered: \"Response submitted\",\n};\n\nconst ICON_CLASS: Partial<Record<ApprovalCardStatus, string>> = {\n approved: \"text-success\",\n answered: \"text-success\",\n rejected: \"text-destructive\",\n \"changes-requested\": \"text-warning\",\n};\n\nconst BADGE_CLASS: Record<ApprovalCardStatus, string> = {\n pending: \"border-warning/30 bg-warning/10 text-warning\",\n \"changes-requested\": \"border-warning/30 bg-warning/10 text-warning\",\n submitting: \"border-info/30 bg-info/10 text-info\",\n approved: \"border-success/30 bg-success/10 text-success\",\n answered: \"border-success/30 bg-success/10 text-success\",\n rejected: \"border-destructive/30 bg-destructive/10 text-destructive\",\n};\n\nfunction isAnswered(answer: ApprovalCardAnswer) {\n return answer.selected.length > 0 || Boolean(answer.custom?.trim());\n}\n\n/* ----------------------------------------------------------------------------\n * One question's controls: checkboxes, radios and/or a freeform input.\n * ------------------------------------------------------------------------- */\n\nconst OPTION_ROW =\n \"flex min-h-9 items-center gap-3 rounded-lg px-1.5 py-1 text-sm text-foreground select-none\";\n\nfunction OptionRow({\n disabled,\n children,\n}: {\n disabled: boolean;\n children: React.ReactNode;\n}) {\n return (\n <label\n data-slot=\"approval-card-option\"\n className={cn(\n OPTION_ROW,\n disabled ? \"cursor-not-allowed opacity-60\" : \"cursor-pointer\"\n )}\n >\n {children}\n </label>\n );\n}\n\nfunction QuestionOptions({\n question,\n answer,\n disabled,\n customPlaceholder,\n onChange,\n onSingleSelect,\n}: {\n question: ApprovalCardQuestion;\n answer: ApprovalCardAnswer;\n disabled: boolean;\n customPlaceholder: string;\n onChange: (answer: ApprovalCardAnswer) => void;\n onSingleSelect?: () => void;\n}) {\n const custom = answer.custom ?? \"\";\n\n return (\n <div data-slot=\"approval-card-question\" className=\"mt-3\">\n {question.options?.length ? (\n question.multiple ? (\n <div className=\"grid gap-0.5\">\n {question.options.map((option) => {\n const rowDisabled = disabled || Boolean(option.disabled);\n return (\n <OptionRow key={option.value} disabled={rowDisabled}>\n <Checkbox\n checked={answer.selected.includes(option.value)}\n disabled={rowDisabled}\n onCheckedChange={(checked) =>\n onChange({\n ...answer,\n selected: checked\n ? [...answer.selected, option.value]\n : answer.selected.filter(\n (value) => value !== option.value\n ),\n })\n }\n />\n <span>{option.label}</span>\n </OptionRow>\n );\n })}\n </div>\n ) : (\n <RadioGroup\n value={answer.selected[0] ?? \"\"}\n onValueChange={(value) => {\n onChange({ selected: [String(value)], custom: \"\" });\n onSingleSelect?.();\n }}\n className=\"gap-0.5\"\n >\n {question.options.map((option) => {\n const rowDisabled = disabled || Boolean(option.disabled);\n return (\n <OptionRow key={option.value} disabled={rowDisabled}>\n <RadioGroupItem value={option.value} disabled={rowDisabled} />\n <span>{option.label}</span>\n </OptionRow>\n );\n })}\n </RadioGroup>\n )\n ) : null}\n\n {question.allowCustom ? (\n <Input\n data-slot=\"approval-card-custom\"\n value={custom}\n disabled={disabled}\n placeholder={question.customPlaceholder ?? customPlaceholder}\n aria-label={question.customPlaceholder ?? customPlaceholder}\n onChange={(event) =>\n onChange({\n selected: question.multiple ? answer.selected : [],\n custom: event.target.value,\n })\n }\n className={cn(\n \"h-10 rounded-lg border-0 bg-background/70 px-3 text-sm focus-visible:bg-background\",\n question.options?.length && \"mt-1.5\"\n )}\n />\n ) : null}\n </div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Progress dots: one per question, the current one full size.\n * ------------------------------------------------------------------------- */\n\nfunction ProgressDots({\n current,\n ids,\n label,\n}: {\n current: number;\n ids: string[];\n label: string;\n}) {\n const reduced = useReducedMotion() ?? false;\n\n return (\n <span data-slot=\"approval-card-progress\" className=\"flex gap-1.5\">\n <span className=\"sr-only\">{label}</span>\n {ids.map((id, index) => (\n <motion.span\n key={id}\n aria-hidden=\"true\"\n initial={false}\n animate={{\n scale: reduced ? 1 : index === current ? 1 : 0.75,\n opacity: index <= current ? 1 : 0.35,\n }}\n transition={reduced ? { duration: 0 } : SPRING_SWAP}\n className=\"size-1.5 rounded-full bg-foreground\"\n />\n ))}\n </span>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * ApprovalCard: the card.\n * ------------------------------------------------------------------------- */\n\nfunction ApprovalCard({\n title = \"Approval required\",\n description,\n children,\n questions = [],\n status = \"pending\",\n answers,\n defaultAnswers = {},\n onAnswersChange,\n step,\n defaultStep = 0,\n onStepChange,\n onSubmit,\n onApprove,\n onReject,\n onRequestChanges,\n onDismiss,\n result,\n approveLabel = \"Approve\",\n rejectLabel = \"Reject\",\n requestChangesLabel = \"Request changes\",\n submitLabel = \"Submit response\",\n dismissLabel = \"Dismiss\",\n previousLabel = \"Previous question\",\n nextLabel = \"Next question\",\n customPlaceholder = \"Add another response…\",\n progressLabel = (current, total) => `Question ${current} of ${total}`,\n statusLabels,\n className,\n ...props\n}: ApprovalCardProps) {\n const reduced = useReducedMotion() ?? false;\n const [internalAnswers, setInternalAnswers] =\n React.useState<ApprovalCardAnswers>(defaultAnswers);\n const [internalStep, setInternalStep] = React.useState(defaultStep);\n const autoAdvanceTimer = React.useRef<number | undefined>(undefined);\n const currentAnswers = answers ?? internalAnswers;\n const currentStep = Math.min(\n Math.max(0, step ?? internalStep),\n Math.max(0, questions.length - 1)\n );\n const question = questions[currentStep];\n const questionMode = questions.length > 0;\n const lastStep = currentStep === questions.length - 1;\n const pending = status === \"pending\";\n const busy = status === \"submitting\";\n const interactive = pending || busy;\n const currentAnswer = question\n ? (currentAnswers[question.id] ?? EMPTY_ANSWER)\n : EMPTY_ANSWER;\n const displayTitle = question?.title ?? title;\n const titleKey = question?.id ?? String(status);\n const statusLabel = statusLabels?.[status] ?? DEFAULT_STATUS_LABELS[status];\n\n const clearAutoAdvance = React.useCallback(() => {\n if (autoAdvanceTimer.current === undefined) return;\n window.clearTimeout(autoAdvanceTimer.current);\n autoAdvanceTimer.current = undefined;\n }, []);\n\n React.useEffect(() => clearAutoAdvance, [clearAutoAdvance]);\n\n const setAnswers = React.useCallback(\n (next: ApprovalCardAnswers) => {\n if (answers === undefined) setInternalAnswers(next);\n onAnswersChange?.(next);\n },\n [answers, onAnswersChange]\n );\n\n const setStep = (next: number) => {\n clearAutoAdvance();\n if (step === undefined) setInternalStep(next);\n onStepChange?.(next);\n };\n\n const updateCurrentAnswer = (next: ApprovalCardAnswer) => {\n if (!question) return;\n setAnswers({ ...currentAnswers, [question.id]: next });\n };\n\n const continueQuestion = () => {\n if (currentStep < questions.length - 1) {\n setStep(currentStep + 1);\n return;\n }\n onSubmit?.(currentAnswers);\n };\n\n // A single choice reads as \"done\"; the next question follows after a beat\n // so the selection is seen before the panel slides.\n const queueAutoAdvance = () => {\n if (\n !question ||\n question.multiple ||\n question.autoAdvance === false ||\n currentStep >= questions.length - 1 ||\n busy\n ) {\n return;\n }\n\n clearAutoAdvance();\n autoAdvanceTimer.current = window.setTimeout(() => {\n setStep(currentStep + 1);\n }, 240);\n };\n\n return (\n <div\n data-slot=\"approval-card\"\n data-state={status}\n aria-busy={busy || undefined}\n className={cn(\n \"w-full overflow-hidden rounded-xl bg-muted p-4 text-sm\",\n className\n )}\n {...props}\n >\n <div className=\"flex items-start gap-3\">\n <span\n data-slot=\"approval-card-icon\"\n aria-hidden=\"true\"\n className={cn(\n \"grid size-5 shrink-0 place-items-center text-muted-foreground\",\n ICON_CLASS[status]\n )}\n >\n {busy ? (\n <Spinner className=\"size-4\" />\n ) : interactive ? (\n questionMode ? (\n <CircleHelpIcon className=\"size-4\" />\n ) : (\n <MessageSquareTextIcon className=\"size-4\" />\n )\n ) : status === \"rejected\" ? (\n <XIcon className=\"size-4\" />\n ) : (\n <CheckIcon className=\"size-4\" />\n )}\n </span>\n\n <div className=\"min-w-0 flex-1\">\n <div data-slot=\"approval-card-header\" className=\"flex min-w-0 items-start gap-3\">\n <h3\n data-slot=\"approval-card-title\"\n className=\"min-w-0 flex-1 text-base leading-5 font-medium text-foreground\"\n >\n <SwapText value={titleKey}>{displayTitle}</SwapText>\n </h3>\n {questionMode && interactive ? (\n <span\n data-slot=\"approval-card-counter\"\n className=\"shrink-0 text-xs text-muted-foreground/65 tabular-nums\"\n >\n {currentStep + 1}/{questions.length}\n </span>\n ) : (\n <span\n data-slot=\"approval-card-status\"\n className={cn(\n \"shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors\",\n BADGE_CLASS[status]\n )}\n >\n {statusLabel}\n </span>\n )}\n {onDismiss ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={dismissLabel}\n onClick={onDismiss}\n className=\"-mt-0.5 shrink-0 text-muted-foreground hover:text-foreground\"\n >\n <XIcon className=\"size-4\" />\n </Button>\n ) : null}\n </div>\n\n <Disclosure open={interactive}>\n {questionMode && question ? (\n <AnimatePresence initial={false} mode=\"wait\">\n <motion.div\n key={question.id}\n data-slot=\"approval-card-step\"\n initial={reduced ? { opacity: 1 } : { opacity: 0, x: 8 }}\n animate={{ opacity: 1, x: 0 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, x: -6 }}\n transition={{ duration: reduced ? 0 : 0.2, ease: EASE_OUT }}\n >\n {question.description ? (\n <p className=\"mt-1 leading-5 text-muted-foreground\">\n {question.description}\n </p>\n ) : null}\n <QuestionOptions\n question={question}\n answer={currentAnswer}\n disabled={busy}\n customPlaceholder={customPlaceholder}\n onChange={updateCurrentAnswer}\n onSingleSelect={queueAutoAdvance}\n />\n </motion.div>\n </AnimatePresence>\n ) : (\n <div data-slot=\"approval-card-content\">\n {description ? (\n <p className=\"mt-1 leading-5 text-muted-foreground\">\n {description}\n </p>\n ) : null}\n {children ? <div className=\"mt-3\">{children}</div> : null}\n </div>\n )}\n\n {questionMode ? (\n <div\n data-slot=\"approval-card-actions\"\n className=\"mt-4 flex items-center gap-3\"\n >\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n aria-label={previousLabel}\n disabled={busy || currentStep === 0}\n onClick={() => setStep(currentStep - 1)}\n >\n <ArrowLeftIcon className=\"size-4\" />\n </Button>\n <ProgressDots\n current={currentStep}\n ids={questions.map((item) => item.id)}\n label={progressLabel(currentStep + 1, questions.length)}\n />\n <Button\n type=\"button\"\n size={lastStep ? \"sm\" : \"icon\"}\n aria-label={lastStep ? undefined : nextLabel}\n disabled={busy || !isAnswered(currentAnswer)}\n aria-busy={busy || undefined}\n onClick={continueQuestion}\n className=\"ml-auto\"\n >\n {busy ? (\n <Spinner className=\"size-4\" />\n ) : lastStep ? (\n <>\n {submitLabel}\n <ArrowRightIcon className=\"size-3.5\" />\n </>\n ) : (\n <ArrowRightIcon className=\"size-4\" />\n )}\n </Button>\n </div>\n ) : (\n <div\n data-slot=\"approval-card-actions\"\n className=\"mt-4 flex flex-wrap items-center gap-2\"\n >\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={busy}\n aria-busy={busy || undefined}\n onClick={onApprove}\n >\n {approveLabel}\n </Button>\n {onRequestChanges ? (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n disabled={busy}\n onClick={onRequestChanges}\n >\n {requestChangesLabel}\n </Button>\n ) : null}\n {onReject ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={busy}\n onClick={onReject}\n className=\"text-muted-foreground hover:text-destructive\"\n >\n {rejectLabel}\n </Button>\n ) : null}\n </div>\n )}\n </Disclosure>\n\n {!interactive ? (\n <p\n data-slot=\"approval-card-result\"\n className=\"mt-1 text-sm text-muted-foreground\"\n >\n {result ?? statusLabel}\n </p>\n ) : null}\n </div>\n </div>\n </div>\n );\n}\n\nexport { ApprovalCard };\n",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"target": "components/ui/ai-approval-card.tsx"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"type": "registry:ui"
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-artifact",
|
|
4
|
+
"title": "AI Artifact",
|
|
5
|
+
"description": "A generated document's frame: header, actions and content.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"@intelligo/button",
|
|
11
|
+
"@intelligo/tooltip"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/ai-artifact/ai-artifact.tsx",
|
|
16
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { type LucideIcon, XIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nfunction Artifact({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"artifact\"\n className={cn(\n \"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction ArtifactHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"artifact-header\"\n className={cn(\n \"flex items-center justify-between gap-2 border-b bg-muted/50 px-4 py-3\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction ArtifactTitle({ className, ...props }: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"artifact-title\"\n className={cn(\"text-sm font-medium text-foreground\", className)}\n {...props}\n />\n );\n}\n\nfunction ArtifactDescription({\n className,\n ...props\n}: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"artifact-description\"\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n );\n}\n\nfunction ArtifactActions({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"artifact-actions\"\n className={cn(\"flex items-center gap-1\", className)}\n {...props}\n />\n );\n}\n\nfunction ArtifactClose({\n label,\n children,\n variant = \"ghost\",\n size = \"icon-sm\",\n ...props\n}: React.ComponentProps<typeof Button> & {\n /** Accessible name, e.g. \"Close\". */\n label: string;\n}) {\n return (\n <Button\n data-slot=\"artifact-close\"\n aria-label={label}\n size={size}\n type=\"button\"\n variant={variant}\n {...props}\n >\n {children ?? <XIcon />}\n </Button>\n );\n}\n\nfunction ArtifactAction({\n label,\n tooltip,\n icon: Icon,\n children,\n variant = \"ghost\",\n size = \"icon-sm\",\n ...props\n}: React.ComponentProps<typeof Button> & {\n /** Accessible name. */\n label: string;\n /** Shown on hover and focus when given. */\n tooltip?: string;\n icon?: LucideIcon;\n}) {\n const button = (\n <Button\n data-slot=\"artifact-action\"\n aria-label={label}\n size={size}\n type=\"button\"\n variant={variant}\n {...props}\n >\n {Icon ? <Icon /> : children}\n </Button>\n );\n\n if (!tooltip) return button;\n\n return (\n <Tooltip>\n <TooltipTrigger render={button} />\n <TooltipContent>{tooltip}</TooltipContent>\n </Tooltip>\n );\n}\n\nfunction ArtifactContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"artifact-content\"\n className={cn(\"flex-1 overflow-auto p-4\", className)}\n {...props}\n />\n );\n}\n\nexport {\n Artifact,\n ArtifactHeader,\n ArtifactTitle,\n ArtifactDescription,\n ArtifactActions,\n ArtifactClose,\n ArtifactAction,\n ArtifactContent,\n};\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/ai-artifact.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-branch",
|
|
4
|
+
"title": "AI Branch",
|
|
5
|
+
"description": "A pager over the versions of a reply or an edited message — previous, next, `2 / 3`.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"@intelligo/button"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "base/ui/ai-branch/ai-branch.tsx",
|
|
15
|
+
"content": "\"use client\";\n\n/*\n * For a thread that owns which version is on screen: this is a pager over\n * versions the thread keeps, not a component that clones its children per\n * branch.\n */\n\nimport * as React from \"react\";\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype BranchContextValue = {\n index: number;\n count: number;\n onIndexChange: (index: number) => void;\n};\n\nconst BranchContext = React.createContext<BranchContextValue | null>(null);\n\nfunction useBranch() {\n const context = React.useContext(BranchContext);\n if (!context) {\n throw new Error(\"Branch components must be used within Branch\");\n }\n return context;\n}\n\n/**\n * Versions of one reply — or one edited message — with the thread in\n * charge of `index`. Renders nothing when there is only one.\n */\nfunction Branch({\n index,\n count,\n onIndexChange,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n /** Zero-based; the version on screen. */\n index: number;\n count: number;\n onIndexChange: (index: number) => void;\n}) {\n const value = React.useMemo(\n () => ({ index, count, onIndexChange }),\n [index, count, onIndexChange]\n );\n if (count < 2) return null;\n return (\n <BranchContext.Provider value={value}>\n <div\n data-slot=\"branch\"\n className={cn(\"flex items-center gap-0.5\", className)}\n {...props}\n >\n {children}\n </div>\n </BranchContext.Provider>\n );\n}\n\nfunction BranchPrevious({\n label,\n className,\n ...props\n}: Omit<React.ComponentProps<typeof Button>, \"onClick\"> & {\n /** Accessible name, e.g. \"Previous version\". */\n label: string;\n}) {\n const { index, onIndexChange } = useBranch();\n return (\n <Button\n data-slot=\"branch-previous\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={label}\n disabled={index <= 0}\n onClick={() => onIndexChange(index - 1)}\n className={cn(\"text-muted-foreground\", className)}\n {...props}\n >\n <ChevronLeftIcon />\n </Button>\n );\n}\n\nfunction BranchNext({\n label,\n className,\n ...props\n}: Omit<React.ComponentProps<typeof Button>, \"onClick\"> & {\n /** Accessible name, e.g. \"Next version\". */\n label: string;\n}) {\n const { index, count, onIndexChange } = useBranch();\n return (\n <Button\n data-slot=\"branch-next\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={label}\n disabled={index >= count - 1}\n onClick={() => onIndexChange(index + 1)}\n className={cn(\"text-muted-foreground\", className)}\n {...props}\n >\n <ChevronRightIcon />\n </Button>\n );\n}\n\n/** \"2 / 3\". Digits only, so it needs no copy. */\nfunction BranchPage({ className, ...props }: React.ComponentProps<\"span\">) {\n const { index, count } = useBranch();\n return (\n <span\n data-slot=\"branch-page\"\n className={cn(\n \"min-w-8 text-center text-xs tabular-nums text-muted-foreground\",\n className\n )}\n aria-live=\"polite\"\n {...props}\n >\n {index + 1} / {count}\n </span>\n );\n}\n\nexport { Branch, BranchPrevious, BranchNext, BranchPage, useBranch };\n",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"target": "components/ui/ai-branch.tsx"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"type": "registry:ui"
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-citations",
|
|
4
|
+
"title": "AI Citations",
|
|
5
|
+
"description": "The sources an answer leans on: inline source pills with a hover preview, favicon stacks, a sources sheet and a collapsible reference list.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"motion"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion",
|
|
12
|
+
"hover-card",
|
|
13
|
+
"@intelligo/sheet"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "base/ui/ai-citations/ai-citations.tsx",
|
|
18
|
+
"content": "\"use client\";\n\n/*\n * The sources an answer leans on: an inline pill naming the site a claim\n * came from, with a preview on hover; favicon stacks; a sources button\n * that opens the full list in a sheet; and the older numbered marker and\n * collapsible list.\n */\n\nimport * as React from \"react\";\nimport {\n BookOpenTextIcon,\n ChevronDownIcon,\n ExternalLinkIcon,\n Globe2Icon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n Disclosure,\n EASE_OUT,\n SPRING_LAYOUT,\n SPRING_SWAP,\n} from \"@/components/ui/ai-motion\";\nimport {\n HoverCard,\n HoverCardContent,\n HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport {\n Sheet,\n SheetContent,\n SheetHeader,\n SheetTitle,\n SheetTrigger,\n} from \"@/components/ui/sheet\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CitationItem {\n id: string;\n title: React.ReactNode;\n /** The site, as shown: `wikipedia.org`. */\n domain?: string;\n url?: string;\n /** A line or two from the page. */\n snippet?: React.ReactNode;\n}\n\n/** The 10px index badges; the type scale has no step this small. */\nconst TINY_TEXT: React.CSSProperties = { fontSize: \"0.625rem\" };\n\n/* ----------------------------------------------------------------------------\n * Favicons. Resolve a site's conventional `/favicon.ico` and drop it once\n * it is known to be unusable, so a glyph is drawn instead of a broken\n * image. Plenty of sites answer with a 403 or 404, and `onError` is not\n * enough to catch it: an image the browser starts loading from\n * server-rendered HTML usually fails before React attaches a handler, and\n * that event is never replayed. `decode()` settles on the image's final\n * state instead of relying on an event firing at the right moment.\n * ------------------------------------------------------------------------- */\n\nfunction getFaviconUrl(value: string) {\n try {\n return new URL(\"/favicon.ico\", value).toString();\n } catch {\n return null;\n }\n}\n\nfunction useFavicon(url?: string) {\n const resolved = url ? getFaviconUrl(url) : null;\n const [failedSrc, setFailedSrc] = React.useState<string | null>(null);\n const src = resolved && resolved !== failedSrc ? resolved : null;\n\n const ref = React.useCallback(\n (img: HTMLImageElement | null) => {\n if (!img || !src) return;\n\n let released = false;\n img.decode().catch(() => {\n if (!released) setFailedSrc(src);\n });\n\n // The node is going away or the source changed; a late rejection then\n // describes an image we are no longer showing.\n return () => {\n released = true;\n };\n },\n [src]\n );\n\n return { src, ref };\n}\n\nfunction citationTargetId(prefix: string, citationId: string) {\n return `${prefix}-${citationId.replace(/[^a-zA-Z0-9_-]/g, \"-\")}`;\n}\n\n/** What a citation reads as when it has to be short: its site, else its title. */\nfunction shortName(citation: CitationItem): React.ReactNode {\n return citation.domain ?? citation.title;\n}\n\n/* ----------------------------------------------------------------------------\n * Citation: the numbered inline marker that jumps to its reference.\n * ------------------------------------------------------------------------- */\n\nexport interface CitationProps\n extends Omit<React.ComponentProps<\"a\">, \"href\" | \"children\"> {\n citationId: string;\n index: number;\n /** Must match the related Citations idPrefix. */\n idPrefix: string;\n /** Assistive-tech name; defaults to \"View citation {index}\". */\n label?: string;\n}\n\nfunction Citation({\n citationId,\n index,\n idPrefix,\n label,\n className,\n style,\n ...props\n}: CitationProps) {\n return (\n <a\n data-slot=\"citation\"\n href={`#${citationTargetId(idPrefix, citationId)}`}\n aria-label={label ?? `View citation ${index}`}\n className={cn(\n \"mx-0.5 inline-flex min-w-4 -translate-y-0.5 items-center justify-center rounded-md bg-muted/60 px-1 py-0.5 font-semibold leading-none text-muted-foreground no-underline outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n className\n )}\n style={{ ...TINY_TEXT, ...style }}\n {...props}\n >\n {index}\n </a>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationFavicon and CitationStack.\n * ------------------------------------------------------------------------- */\n\nfunction CitationFavicon({\n url,\n domain,\n className,\n}: {\n url?: string;\n /** Used when there is no url to read the site from. */\n domain?: string;\n className?: string;\n}) {\n const favicon = useFavicon(url ?? (domain ? `https://${domain}` : undefined));\n\n return (\n <span\n data-slot=\"citation-favicon\"\n aria-hidden=\"true\"\n className={cn(\n \"grid size-5 shrink-0 place-items-center text-muted-foreground\",\n className\n )}\n >\n {favicon.src ? (\n // Dynamic cross-site favicons: a plain img keeps the item portable.\n <img\n ref={favicon.ref}\n src={favicon.src}\n alt=\"\"\n width={16}\n height={16}\n referrerPolicy=\"no-referrer\"\n className=\"size-4 rounded-sm object-contain\"\n />\n ) : (\n <Globe2Icon className=\"size-3.5\" />\n )}\n </span>\n );\n}\n\nexport interface CitationStackProps {\n citations: CitationItem[];\n limit?: number;\n className?: string;\n}\n\nfunction CitationStack({ citations, limit = 3, className }: CitationStackProps) {\n return (\n <span\n data-slot=\"citation-stack\"\n aria-hidden=\"true\"\n className={cn(\"flex -space-x-1.5\", className)}\n >\n {citations.slice(0, limit).map((citation) => (\n <CitationFavicon\n key={citation.id}\n url={citation.url}\n domain={citation.domain}\n className=\"size-6 rounded-full bg-background ring-2 ring-background\"\n />\n ))}\n </span>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationCard: one source, read at a glance — site, title, snippet.\n * ------------------------------------------------------------------------- */\n\nfunction CitationCard({\n citation,\n index,\n className,\n}: {\n citation: CitationItem;\n index?: number;\n className?: string;\n}) {\n const content = (\n <>\n <span className=\"flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground\">\n <CitationFavicon\n url={citation.url}\n domain={citation.domain}\n className=\"size-4 [&_img]:size-3.5 [&_svg]:size-3\"\n />\n <span className=\"min-w-0 truncate\">{shortName(citation)}</span>\n {index !== undefined ? (\n <span\n className=\"ml-auto grid size-4 shrink-0 place-items-center rounded bg-foreground/5 font-semibold tabular-nums\"\n style={TINY_TEXT}\n >\n {index}\n </span>\n ) : null}\n </span>\n {citation.domain && citation.title !== citation.domain ? (\n <span className=\"line-clamp-2 text-sm leading-5 font-medium text-foreground\">\n {citation.title}\n </span>\n ) : null}\n {citation.snippet ? (\n <span className=\"line-clamp-2 text-xs leading-4 text-muted-foreground\">\n {citation.snippet}\n </span>\n ) : null}\n </>\n );\n const classes = cn(\n \"grid gap-1 rounded-md p-2 text-left outline-none transition-colors\",\n citation.url && \"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring\",\n className\n );\n\n return citation.url ? (\n <a\n data-slot=\"citation-card\"\n href={citation.url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className={classes}\n >\n {content}\n </a>\n ) : (\n <div data-slot=\"citation-card\" className={classes}>\n {content}\n </div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationPill: the inline source — `wikipedia.org +2` — with the sources\n * it stands for on hover. Clicking it opens the first source.\n * ------------------------------------------------------------------------- */\n\nexport interface CitationPillProps {\n /** The sources this claim cites, in order; the first names the pill. */\n citations: CitationItem[];\n /** Assistive-tech name, e.g. \"Source: wikipedia.org\". */\n label?: string;\n className?: string;\n}\n\nfunction CitationPill({ citations, label, className }: CitationPillProps) {\n const [first, ...rest] = citations;\n if (!first) return null;\n\n const pillClass = cn(\n \"mx-0.5 inline-flex max-w-44 -translate-y-px items-center gap-1 rounded-full bg-muted px-1.5 align-middle text-xs leading-5 font-medium text-muted-foreground no-underline! outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n className\n );\n const pill = (\n <>\n <CitationFavicon\n url={first.url}\n domain={first.domain}\n className=\"size-3.5 [&_img]:size-3 [&_svg]:size-3\"\n />\n <span className=\"min-w-0 truncate\">{shortName(first)}</span>\n {rest.length > 0 ? (\n <span className=\"shrink-0 tabular-nums opacity-70\">+{rest.length}</span>\n ) : null}\n </>\n );\n\n return (\n <HoverCard>\n <HoverCardTrigger\n data-slot=\"citation-pill\"\n delay={150}\n aria-label={label}\n className={pillClass}\n render={\n first.url ? (\n <a href={first.url} target=\"_blank\" rel=\"noreferrer noopener\" />\n ) : (\n <span tabIndex={0} />\n )\n }\n >\n {pill}\n </HoverCardTrigger>\n <HoverCardContent side=\"top\" align=\"start\" className=\"grid w-80 gap-0.5 p-1\">\n {citations.map((citation) => (\n <CitationCard key={citation.id} citation={citation} />\n ))}\n </HoverCardContent>\n </HoverCard>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationSources: the favicons and a count under an answer; the full\n * list opens in a sheet beside it.\n * ------------------------------------------------------------------------- */\n\nexport interface CitationSourcesProps {\n citations: CitationItem[];\n /** The button's text, e.g. \"5 sources\". */\n label: React.ReactNode;\n /** The sheet's heading, e.g. \"Sources\". */\n title: React.ReactNode;\n className?: string;\n}\n\nfunction CitationSources({\n citations,\n label,\n title,\n className,\n}: CitationSourcesProps) {\n if (citations.length === 0) return null;\n\n return (\n <Sheet>\n <SheetTrigger\n data-slot=\"citation-sources\"\n render={<button type=\"button\" />}\n className={cn(\n \"inline-flex h-8 w-fit items-center gap-2 rounded-full border bg-background py-1 pr-3 pl-1 text-sm text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n className\n )}\n >\n <CitationStack citations={citations} className=\"[&>span]:size-5.5\" />\n <span className=\"font-medium\">{label}</span>\n </SheetTrigger>\n <SheetContent side=\"right\" className=\"w-full gap-0 p-0 sm:max-w-md\">\n <SheetHeader className=\"border-b pr-12\">\n <SheetTitle>{title}</SheetTitle>\n </SheetHeader>\n <div className=\"grid min-h-0 flex-1 content-start gap-0.5 overflow-y-auto p-2\">\n {citations.map((citation) => (\n <CitationCard\n key={citation.id}\n citation={citation}\n index={Number.isNaN(Number(citation.id)) ? undefined : Number(citation.id)}\n className=\"p-2.5\"\n />\n ))}\n </div>\n </SheetContent>\n </Sheet>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationList: the numbered references.\n * ------------------------------------------------------------------------- */\n\nfunction CitationRow({\n citation,\n index,\n idPrefix,\n}: {\n citation: CitationItem;\n index: number;\n idPrefix: string;\n}) {\n const content = (\n <>\n <CitationFavicon url={citation.url} domain={citation.domain} />\n <span className=\"flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5\">\n <span className=\"truncate text-sm font-medium text-foreground/80 transition-colors group-hover/citation:text-foreground\">\n {citation.title}\n </span>\n {citation.domain ? (\n <span className=\"min-w-0 truncate text-xs text-muted-foreground/60\">\n {citation.domain}\n </span>\n ) : null}\n </span>\n <span className=\"flex shrink-0 items-center gap-1.5\">\n <span\n className=\"grid size-5 place-items-center rounded-md bg-foreground/5 font-semibold tabular-nums text-muted-foreground\"\n style={TINY_TEXT}\n >\n {index}\n </span>\n {citation.url ? (\n <ExternalLinkIcon className=\"size-3.5 text-muted-foreground/40 transition-colors group-hover/citation:text-muted-foreground\" />\n ) : null}\n </span>\n </>\n );\n const className =\n \"group/citation flex items-center gap-2 rounded-md px-1.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n const id = citationTargetId(idPrefix, citation.id);\n\n return citation.url ? (\n <a\n data-slot=\"citation-row\"\n id={id}\n href={citation.url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className={className}\n >\n {content}\n </a>\n ) : (\n <div data-slot=\"citation-row\" id={id} className={className}>\n {content}\n </div>\n );\n}\n\nexport interface CitationListProps {\n citations: CitationItem[];\n idPrefix?: string;\n className?: string;\n}\n\nfunction CitationList({ citations, idPrefix, className }: CitationListProps) {\n const reduced = useReducedMotion() ?? false;\n const baseId = React.useId();\n const resolvedPrefix =\n idPrefix ?? `citation-list-${baseId.replace(/:/g, \"\")}`;\n\n return (\n <div data-slot=\"citation-list\" className={cn(\"grid gap-0.5\", className)}>\n <AnimatePresence mode=\"popLayout\">\n {citations.map((citation, index) => (\n <motion.div\n layout=\"position\"\n key={citation.id}\n initial={reduced ? { opacity: 1 } : { opacity: 0, y: 6 }}\n animate={{ opacity: 1, y: 0 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, y: -3 }}\n transition={\n reduced\n ? { duration: 0 }\n : {\n opacity: { duration: 0.18, ease: EASE_OUT },\n y: SPRING_LAYOUT,\n layout: SPRING_LAYOUT,\n }\n }\n >\n <CitationRow\n citation={citation}\n index={index + 1}\n idPrefix={resolvedPrefix}\n />\n </motion.div>\n ))}\n </AnimatePresence>\n </div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Citations: the collapsible reference collection.\n * ------------------------------------------------------------------------- */\n\nexport interface CitationsProps {\n citations: CitationItem[];\n title?: React.ReactNode;\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n idPrefix?: string;\n className?: string;\n}\n\nfunction Citations({\n citations,\n title = \"Sources\",\n open,\n defaultOpen = false,\n onOpenChange,\n idPrefix,\n className,\n}: CitationsProps) {\n const reduced = useReducedMotion() ?? false;\n const baseId = React.useId();\n const contentId = `${baseId}-content`;\n const resolvedPrefix = idPrefix ?? `citation-${baseId.replace(/:/g, \"\")}`;\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const currentOpen = open ?? internalOpen;\n const setOpen = React.useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, open]\n );\n\n return (\n <div\n data-slot=\"citations\"\n data-state={currentOpen ? \"open\" : \"closed\"}\n className={cn(\"w-full text-sm\", className)}\n >\n <button\n data-slot=\"citations-trigger\"\n type=\"button\"\n aria-expanded={currentOpen}\n aria-controls={contentId}\n onClick={() => setOpen(!currentOpen)}\n className=\"group -ml-1 flex min-h-8 items-center gap-2 rounded-lg px-1 text-left text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <BookOpenTextIcon className=\"size-4\" />\n <span className=\"font-medium\">{title}</span>\n <span\n className=\"rounded-full bg-muted px-1.5 py-0.5 font-semibold tabular-nums\"\n style={TINY_TEXT}\n >\n {citations.length}\n </span>\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: currentOpen ? 180 : 0 }}\n transition={reduced ? { duration: 0 } : SPRING_SWAP}\n className=\"text-muted-foreground/60\"\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n\n <Disclosure id={contentId} open={currentOpen}>\n <CitationList\n citations={citations}\n idPrefix={resolvedPrefix}\n className=\"mt-1\"\n />\n </Disclosure>\n </div>\n );\n}\n\nexport {\n Citation,\n CitationCard,\n CitationFavicon,\n CitationList,\n CitationPill,\n Citations,\n CitationSources,\n CitationStack,\n};\n",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"target": "components/ui/ai-citations.tsx"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
@@ -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
|
+
}
|