diff --git a/.gitignore b/.gitignore
index 7d7617b7..aed07640 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,7 +7,6 @@ dist
# build outputs
.next
out
-.turbo
build
bin
dist-electron
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 9428e426..f969e523 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -1,14 +1,49 @@
{
- "extends": "../../tsconfig.base.json",
"compilerOptions": {
- "plugins": [{ "name": "next" }],
- "paths": {
- "@/*": ["./*"]
- },
+ "target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
- "noEmit": true
+ "lib": [
+ "ESNext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "jsx": "react-jsx",
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./*"
+ ]
+ },
+ "noEmit": true,
+ "allowJs": true,
+ "incremental": true
},
- "include": ["next-env.d.ts", "src", "app", "**/*.ts", "**/*.tsx"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "src",
+ "app",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
diff --git a/package.json b/package.json
index e312dbc4..e68c0a0f 100644
--- a/package.json
+++ b/package.json
@@ -4,12 +4,12 @@
"private": true,
"type": "module",
"scripts": {
- "dev:web": "turbo run dev --filter=@multica/web",
- "build": "turbo run build",
- "typecheck": "turbo run typecheck",
- "test": "turbo run test",
- "lint": "turbo run lint",
- "clean": "turbo run clean && rm -rf node_modules"
+ "dev:web": "pnpm --filter @multica/web dev",
+ "build": "pnpm --filter @multica/web build",
+ "typecheck": "pnpm --filter @multica/web typecheck",
+ "test": "pnpm --filter @multica/web test",
+ "lint": "pnpm --filter @multica/web lint",
+ "clean": "pnpm --filter @multica/web clean && rm -rf node_modules"
},
"packageManager": "pnpm@10.28.2",
"pnpm": {
@@ -26,7 +26,6 @@
"@types/node": "catalog:",
"@types/pg": "^8.20.0",
"pg": "^8.20.0",
- "turbo": "^2.5.0",
"typescript": "catalog:"
}
}
diff --git a/packages/ui/components.json b/packages/ui/components.json
deleted file mode 100644
index 819ca6e2..00000000
--- a/packages/ui/components.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "$schema": "https://ui.shadcn.com/schema.json",
- "style": "base-nova",
- "rsc": true,
- "tsx": true,
- "tailwind": {
- "config": "",
- "css": "src/styles/globals.css",
- "baseColor": "zinc",
- "cssVariables": true
- },
- "iconLibrary": "lucide",
- "aliases": {
- "components": "@multica/ui/components",
- "utils": "@multica/ui/lib/utils",
- "hooks": "@multica/ui/hooks",
- "lib": "@multica/ui/lib",
- "ui": "@multica/ui/components/ui"
- },
- "rtl": false,
- "menuColor": "inverted-translucent",
- "menuAccent": "subtle"
-}
diff --git a/packages/ui/package.json b/packages/ui/package.json
deleted file mode 100644
index d33e2e40..00000000
--- a/packages/ui/package.json
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "name": "@multica/ui",
- "version": "0.2.0",
- "private": true,
- "type": "module",
- "exports": {
- "./globals.css": "./src/styles/globals.css",
- "./postcss.config": "./postcss.config.mjs",
- "./lib/*": "./src/lib/*.ts",
- "./components/*": "./src/components/*.tsx",
- "./components/ui/*": "./src/components/ui/*.tsx",
- "./components/common/*": "./src/components/common/*.tsx",
- "./components/markdown": "./src/components/markdown/index.ts",
- "./hooks/*": "./src/hooks/*.ts"
- },
- "dependencies": {
- "@base-ui/react": "^1.3.0",
- "class-variance-authority": "catalog:",
- "clsx": "catalog:",
- "cmdk": "^1.1.1",
- "date-fns": "^4.1.0",
- "lucide-react": "catalog:",
- "next-themes": "^0.4.6",
- "react": "catalog:",
- "react-day-picker": "^9.14.0",
- "react-dom": "catalog:",
- "react-markdown": "^10.1.0",
- "shiki": "^3.21.0",
- "sonner": "^2.0.7",
- "tailwind-merge": "catalog:",
- "tailwindcss": "catalog:"
- },
- "devDependencies": {
- "@types/react": "catalog:",
- "@types/react-dom": "catalog:",
- "shadcn": "^4.1.0",
- "tw-animate-css": "^1.4.0",
- "typescript": "catalog:"
- }
-}
diff --git a/packages/ui/postcss.config.mjs b/packages/ui/postcss.config.mjs
deleted file mode 100644
index 4ae682d8..00000000
--- a/packages/ui/postcss.config.mjs
+++ /dev/null
@@ -1,6 +0,0 @@
-/** @type {import('postcss-load-config').Config} */
-const config = {
- plugins: { "@tailwindcss/postcss": {} },
-};
-
-export default config;
diff --git a/packages/ui/src/components/common/actor-avatar.tsx b/packages/ui/src/components/common/actor-avatar.tsx
deleted file mode 100644
index a5ced65f..00000000
--- a/packages/ui/src/components/common/actor-avatar.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Bot } from "lucide-react";
-import { cn } from "@multica/ui/lib/utils";
-
-interface ActorAvatarProps {
- actorType: string;
- actorId: string;
- size?: number;
- getName?: (type: string, id: string) => string;
- getInitials?: (type: string, id: string) => string;
- className?: string;
-}
-
-function ActorAvatar({
- actorType,
- actorId,
- size = 20,
- getName,
- getInitials,
- className,
-}: ActorAvatarProps) {
- const name = getName?.(actorType, actorId);
- const initials = getInitials?.(actorType, actorId);
- const isAgent = actorType === "agent";
-
- return (
-
- {isAgent ? (
-
- ) : (
- initials
- )}
-
- );
-}
-
-export { ActorAvatar, type ActorAvatarProps };
diff --git a/packages/ui/src/components/loading-indicator.tsx b/packages/ui/src/components/loading-indicator.tsx
deleted file mode 100644
index 3db5027e..00000000
--- a/packages/ui/src/components/loading-indicator.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-"use client";
-
-import { Spinner } from "@multica/ui/components/spinner";
-import { cn } from "@multica/ui/lib/utils";
-
-export type LoadingVariant = "generating" | "streaming";
-
-interface LoadingIndicatorProps {
- variant: LoadingVariant;
- className?: string;
-}
-
-const VARIANT_TEXT: Record = {
- generating: "Generating...",
- streaming: "Streaming...",
-};
-
-/**
- * Unified loading indicator for chat.
- * Use "generating" when waiting for AI response (no content yet).
- * Use "streaming" when content is actively being received.
- */
-export function LoadingIndicator({ variant, className }: LoadingIndicatorProps) {
- return (
-
-
- {VARIANT_TEXT[variant]}
-
- );
-}
diff --git a/packages/ui/src/components/markdown/CodeBlock.tsx b/packages/ui/src/components/markdown/CodeBlock.tsx
deleted file mode 100644
index a13b41ab..00000000
--- a/packages/ui/src/components/markdown/CodeBlock.tsx
+++ /dev/null
@@ -1,250 +0,0 @@
-import * as React from 'react'
-import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
-import { cn } from '@multica/ui/lib/utils'
-
-export interface CodeBlockProps {
- code: string
- language?: string
- className?: string
- /**
- * Render mode affects code block styling:
- * - 'terminal': Minimal, keeps control chars visible
- * - 'minimal': Clean code, basic styling
- * - 'full': Rich styling with background, copy button, etc.
- */
- mode?: 'terminal' | 'minimal' | 'full'
-}
-
-// Map common aliases to Shiki language names
-const LANGUAGE_ALIASES: Record = {
- js: 'javascript',
- ts: 'typescript',
- py: 'python',
- sh: 'bash',
- zsh: 'bash',
- yml: 'yaml',
- rb: 'ruby',
- rs: 'rust',
- kt: 'kotlin',
- 'objective-c': 'objc',
- objc: 'objc'
-}
-
-// Simple LRU cache for highlighted code
-const highlightCache = new Map()
-const CACHE_MAX_SIZE = 200
-
-function getCacheKey(code: string, lang: string): string {
- return `${lang}:${code}`
-}
-
-function isValidLanguage(lang: string): lang is BundledLanguage {
- const normalized = LANGUAGE_ALIASES[lang] || lang
- return normalized in bundledLanguages
-}
-
-/**
- * CodeBlock - Syntax highlighted code block using Shiki
- *
- * Uses Shiki dual themes with CSS variables for light/dark switching.
- * No JS-based dark mode detection needed — theme switching is handled
- * entirely via CSS (see globals.css for .shiki/.dark .shiki rules).
- *
- * @see https://shiki.style/guide/dual-themes
- */
-export function CodeBlock({
- code,
- language = 'text',
- className,
- mode = 'full'
-}: CodeBlockProps): React.JSX.Element {
- const [highlighted, setHighlighted] = React.useState(null)
- const [isLoading, setIsLoading] = React.useState(true)
- const [copied, setCopied] = React.useState(false)
-
- // Resolve language alias - keep as string to allow 'text' fallback
- const langLower = language.toLowerCase()
- const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower
-
- React.useEffect(() => {
- let cancelled = false
-
- async function highlight(): Promise {
- const cacheKey = getCacheKey(code, resolvedLang)
-
- const cached = highlightCache.get(cacheKey)
- if (cached) {
- if (!cancelled) {
- setHighlighted(cached)
- setIsLoading(false)
- }
- return
- }
-
- try {
- // Use valid language or fallback to plaintext
- const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text'
-
- // Dual themes: Shiki outputs CSS variables for both themes in one pass.
- // CSS handles switching via .dark selector (see globals.css).
- const html = await codeToHtml(code, {
- lang,
- themes: {
- light: 'github-light',
- dark: 'github-dark',
- },
- defaultColor: false,
- })
-
- // Cache the result
- if (highlightCache.size >= CACHE_MAX_SIZE) {
- const firstKey = highlightCache.keys().next().value
- if (firstKey) highlightCache.delete(firstKey)
- }
- highlightCache.set(cacheKey, html)
-
- if (!cancelled) {
- setHighlighted(html)
- setIsLoading(false)
- }
- } catch (error) {
- // Fallback to plain text on error
- console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error)
- if (!cancelled) {
- setHighlighted(null)
- setIsLoading(false)
- }
- }
- }
-
- highlight()
-
- return () => {
- cancelled = true
- }
- }, [code, resolvedLang])
-
- const handleCopy = React.useCallback(async () => {
- try {
- await navigator.clipboard.writeText(code)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- } catch (err) {
- console.error('Failed to copy code:', err)
- }
- }, [code])
-
- // Terminal mode: raw monospace with minimal styling
- if (mode === 'terminal') {
- return (
-
- {code}
-
- )
- }
-
- // Minimal mode: just syntax highlighting, no chrome
- if (mode === 'minimal') {
- if (isLoading || !highlighted) {
- return (
-
- {code}
-
- )
- }
-
- return (
-
- )
- }
-
- // Full mode: rich styling with header and copy button
- return (
-
- {/* Language label + copy button */}
-
-
- {resolvedLang !== 'text' ? resolvedLang : 'plain text'}
-
-
-
-
- {/* Code content */}
-
- {isLoading || !highlighted ? (
-
- {code}
-
- ) : (
-
- )}
-
-
- )
-}
-
-/**
- * InlineCode - Styled inline code span
- * Features: subtle background (3%), subtle border (5%), 75% opacity text
- */
-export function InlineCode({
- children,
- className
-}: {
- children: React.ReactNode
- className?: string
-}): React.JSX.Element {
- return (
-
- {children}
-
- )
-}
diff --git a/packages/ui/src/components/markdown/Markdown.tsx b/packages/ui/src/components/markdown/Markdown.tsx
deleted file mode 100644
index 9ea7b162..00000000
--- a/packages/ui/src/components/markdown/Markdown.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-import * as React from 'react'
-import ReactMarkdown, { type Components } from 'react-markdown'
-import rehypeRaw from 'rehype-raw'
-import remarkGfm from 'remark-gfm'
-import { cn } from '@multica/ui/lib/utils'
-import { CodeBlock, InlineCode } from './CodeBlock'
-import { preprocessLinks } from './linkify'
-
-/**
- * Render modes for markdown content:
- *
- * - 'terminal': Raw output with minimal formatting, control chars visible
- * Best for: Debug output, raw logs, when you want to see exactly what's there
- *
- * - 'minimal': Clean rendering with syntax highlighting but no extra chrome
- * Best for: Chat messages, inline content, when you want readability without clutter
- *
- * - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography
- * Best for: Documentation, long-form content, when presentation matters
- */
-export type RenderMode = 'terminal' | 'minimal' | 'full'
-
-export interface MarkdownProps {
- children: string
- /**
- * Render mode controlling formatting level
- * @default 'minimal'
- */
- mode?: RenderMode
- className?: string
- /**
- * Message ID for memoization (optional)
- * When provided, memoizes parsed blocks to avoid re-parsing during streaming
- */
- id?: string
- /**
- * Callback when a URL is clicked
- */
- onUrlClick?: (url: string) => void
- /**
- * Callback when a file path is clicked
- */
- onFileClick?: (path: string) => void
-}
-
-// File path detection regex - matches paths starting with /, ~/, or ./
-const FILE_PATH_REGEX =
- /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
-
-/**
- * Create custom components based on render mode
- */
-function createComponents(
- mode: RenderMode,
- onUrlClick?: (url: string) => void,
- onFileClick?: (path: string) => void
-): Partial {
- const baseComponents: Partial = {
- // Links: Make clickable with callbacks
- a: ({ href, children }) => {
- const handleClick = (e: React.MouseEvent): void => {
- e.preventDefault()
- if (href) {
- // Check if it's a file path
- if (FILE_PATH_REGEX.test(href) && onFileClick) {
- onFileClick(href)
- } else if (onUrlClick) {
- onUrlClick(href)
- } else {
- // Default: open in new window
- window.open(href, '_blank', 'noopener,noreferrer')
- }
- }
- }
-
- return (
-
- {children}
-
- )
- }
- }
-
- // Terminal mode: minimal formatting
- if (mode === 'terminal') {
- return {
- ...baseComponents,
- // No special code handling - just monospace
- code: ({ children }) => {children},
- pre: ({ children }) => {children},
- // Minimal paragraph spacing
- p: ({ children }) => {children}
,
- // Simple lists
- ul: ({ children }) => ,
- ol: ({ children }) => {children}
,
- li: ({ children }) => {children},
- // Plain tables
- table: ({ children }) => ,
- th: ({ children }) => {children} | ,
- td: ({ children }) => {children} |
- }
- }
-
- // Minimal mode: clean with syntax highlighting
- if (mode === 'minimal') {
- return {
- ...baseComponents,
- // Inline code
- code: ({ className, children, ...props }) => {
- const match = /language-(\w+)/.exec(className || '')
- const isBlock =
- 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
-
- // Block code - use CodeBlock with full mode
- if (match || isBlock) {
- const code = String(children).replace(/\n$/, '')
- return
- }
-
- // Inline code
- return {children}
- },
- pre: ({ children }) => <>{children}>,
- // Comfortable paragraph spacing
- p: ({ children }) => {children}
,
- // Styled lists
- ul: ({ children }) => (
-
- ),
- ol: ({ children }) => {children}
,
- li: ({ children }) => {children},
- // Clean tables
- table: ({ children }) => (
-
- ),
- thead: ({ children }) => {children},
- th: ({ children }) => (
- {children} |
- ),
- td: ({ children }) => {children} | ,
- // Headings - H1/H2 same size, differentiated by weight
- h1: ({ children }) => {children}
,
- h2: ({ children }) => (
- {children}
- ),
- h3: ({ children }) => (
- {children}
- ),
- // Blockquotes
- blockquote: ({ children }) => (
-
- {children}
-
- ),
- // Horizontal rules
- hr: () =>
,
- // Strong/emphasis
- strong: ({ children }) => {children},
- em: ({ children }) => {children}
- }
- }
-
- // Full mode: rich styling
- return {
- ...baseComponents,
- // Full code blocks with copy button
- code: ({ className, children, ...props }) => {
- const match = /language-(\w+)/.exec(className || '')
- const isBlock =
- 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
-
- if (match || isBlock) {
- const code = String(children).replace(/\n$/, '')
- return
- }
-
- return {children}
- },
- pre: ({ children }) => <>{children}>,
- // Rich paragraph spacing
- p: ({ children }) => {children}
,
- // Styled lists
- ul: ({ children }) => (
-
- ),
- ol: ({ children }) => {children}
,
- li: ({ children }) => {children},
- // Beautiful tables
- table: ({ children }) => (
-
- ),
- thead: ({ children }) => {children},
- tbody: ({ children }) => {children},
- th: ({ children }) => {children} | ,
- td: ({ children }) => {children} | ,
- tr: ({ children }) => {children}
,
- // Rich headings
- h1: ({ children }) => {children}
,
- h2: ({ children }) => (
- {children}
- ),
- h3: ({ children }) => {children}
,
- h4: ({ children }) => {children}
,
- // Styled blockquotes
- blockquote: ({ children }) => (
-
- {children}
-
- ),
- // Task lists (GFM)
- input: ({ type, checked }) => {
- if (type === 'checkbox') {
- return (
-
- )
- }
- return
- },
- // Horizontal rules
- hr: () =>
,
- // Strong/emphasis
- strong: ({ children }) => {children},
- em: ({ children }) => {children},
- del: ({ children }) => {children}
- }
-}
-
-/**
- * Markdown - Customizable markdown renderer with multiple render modes
- *
- * Features:
- * - Three render modes: terminal, minimal, full
- * - Syntax highlighting via Shiki
- * - GFM support (tables, task lists, strikethrough)
- * - Clickable links and file paths
- * - Memoization for streaming performance
- */
-export function Markdown({
- children,
- mode = 'minimal',
- className,
- onUrlClick,
- onFileClick
-}: MarkdownProps): React.JSX.Element {
- const components = React.useMemo(
- () => createComponents(mode, onUrlClick, onFileClick),
- [mode, onUrlClick, onFileClick]
- )
-
- // Preprocess to convert raw URLs and file paths to markdown links
- const processedContent = React.useMemo(() => preprocessLinks(children), [children])
-
- return (
-
-
- {processedContent}
-
-
- )
-}
-
-/**
- * MemoizedMarkdown - Optimized for streaming scenarios
- *
- * Splits content into blocks and memoizes each block separately,
- * so only new/changed blocks re-render during streaming.
- */
-export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
- // If id is provided, use it for memoization
- if (prevProps.id && nextProps.id) {
- return (
- prevProps.id === nextProps.id &&
- prevProps.children === nextProps.children &&
- prevProps.mode === nextProps.mode
- )
- }
- // Otherwise compare content and mode
- return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
-})
-MemoizedMarkdown.displayName = 'MemoizedMarkdown'
diff --git a/packages/ui/src/components/markdown/StreamingMarkdown.tsx b/packages/ui/src/components/markdown/StreamingMarkdown.tsx
deleted file mode 100644
index aad2f330..00000000
--- a/packages/ui/src/components/markdown/StreamingMarkdown.tsx
+++ /dev/null
@@ -1,225 +0,0 @@
-import * as React from 'react'
-import { Markdown, type RenderMode } from './Markdown'
-
-export interface StreamingMarkdownProps {
- content: string
- isStreaming: boolean
- mode?: RenderMode
- className?: string
- onUrlClick?: (url: string) => void
- onFileClick?: (path: string) => void
-}
-
-interface Block {
- content: string
- isCodeBlock: boolean
-}
-
-/**
- * djb2 hash (XOR variant) by Daniel J. Bernstein.
- * Used to generate stable React keys for completed content blocks.
- *
- * - 5381: empirically chosen initial value that produces fewer collisions
- * - (hash << 5) + hash: equivalent to hash * 33
- * - ^ charCode: XOR variant, favored by Bernstein over additive version
- * - >>> 0: convert to unsigned 32-bit integer
- *
- * Not cryptographic — just fast with good distribution for short strings.
- * @see http://www.cse.yorku.ca/~oz/hash.html
- */
-function simpleHash(str: string): string {
- let hash = 5381
- for (let i = 0; i < str.length; i++) {
- hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
- }
- return (hash >>> 0).toString(36)
-}
-
-/**
- * Split content into blocks (paragraphs and code blocks)
- *
- * Block boundaries:
- * - Double newlines (paragraph separators)
- * - Code fences (```)
- *
- * This is intentionally simple - just string scanning, no regex per line.
- */
-function splitIntoBlocks(content: string): Block[] {
- const blocks: Block[] = []
- const lines = content.split('\n')
- let currentBlock = ''
- let inCodeBlock = false
- let inMathBlock = false
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i] ?? ''
-
- // Check for code fence (``` at start of line, optionally followed by language)
- if (line.startsWith('```')) {
- if (!inCodeBlock) {
- // Starting a code block - flush current paragraph first
- if (currentBlock.trim()) {
- blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
- currentBlock = ''
- }
- inCodeBlock = true
- currentBlock = line + '\n'
- } else {
- // Ending a code block
- currentBlock += line
- blocks.push({ content: currentBlock, isCodeBlock: true })
- currentBlock = ''
- inCodeBlock = false
- }
- } else if (inCodeBlock) {
- // Inside code block - append line
- currentBlock += line + '\n'
- // Check for display math fence ($$)
- } else if (line.trim() === '$$') {
- if (!inMathBlock) {
- // Starting a math block - flush current paragraph first
- if (currentBlock.trim()) {
- blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
- currentBlock = ''
- }
- inMathBlock = true
- currentBlock = line + '\n'
- } else {
- // Ending a math block
- currentBlock += line
- blocks.push({ content: currentBlock, isCodeBlock: false })
- currentBlock = ''
- inMathBlock = false
- }
- } else if (inMathBlock) {
- // Inside math block - append line (don't split on blank lines)
- currentBlock += line + '\n'
- } else if (line === '') {
- // Empty line outside code block = paragraph boundary
- if (currentBlock.trim()) {
- blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
- currentBlock = ''
- }
- } else {
- // Regular text line
- if (currentBlock) {
- currentBlock += '\n' + line
- } else {
- currentBlock = line
- }
- }
- }
-
- // Flush remaining content
- if (currentBlock) {
- blocks.push({
- content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
- isCodeBlock: inCodeBlock
- })
- }
-
- return blocks
-}
-
-/**
- * Memoized block component
- *
- * Only re-renders if content or mode changes.
- * The key is assigned by the parent based on content hash,
- * so identical content won't even attempt to render.
- */
-const MemoizedBlock = React.memo(
- function Block({
- content,
- mode,
- className,
- onUrlClick,
- onFileClick
- }: {
- content: string
- mode: RenderMode
- className?: string
- onUrlClick?: (url: string) => void
- onFileClick?: (path: string) => void
- }) {
- return (
-
- {content}
-
- )
- },
- (prev, next) => {
- // Only re-render if content actually changed
- return prev.content === next.content && prev.mode === next.mode && prev.className === next.className
- }
-)
-MemoizedBlock.displayName = 'MemoizedBlock'
-
-/**
- * StreamingMarkdown - Optimized markdown renderer for streaming content
- *
- * Splits content into blocks (paragraphs, code blocks) and memoizes each block
- * independently. Only the last (active) block re-renders during streaming.
- *
- * Key insight: Completed blocks get a content-hash as their React key.
- * Same content = same key = React skips re-render entirely.
- *
- * @example
- * Content: "Hello\n\n```js\ncode\n```\n\nMore..."
- *
- * Block 1: "Hello" -> key="block-abc123" -> memoized
- * Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized
- * Block 3: "More..." -> key="active-2" -> re-renders
- */
-export function StreamingMarkdown({
- content,
- isStreaming,
- mode = 'minimal',
- className,
- onUrlClick,
- onFileClick
-}: StreamingMarkdownProps): React.JSX.Element {
- // Split into blocks - memoized to avoid recomputation
- // Must be called unconditionally to satisfy Rules of Hooks
- const blocks = React.useMemo(
- () => (isStreaming ? splitIntoBlocks(content) : []),
- [content, isStreaming]
- )
-
- // Not streaming - use simple Markdown (no block splitting needed)
- if (!isStreaming) {
- return (
-
- {content}
-
- )
- }
-
- // Empty content - return null, let parent handle loading indicator
- if (blocks.length === 0) {
- return <>>
- }
-
- return (
- <>
- {blocks.map((block, i) => {
- const isLastBlock = i === blocks.length - 1
-
- // Complete blocks use content hash as key -> stable identity -> memoized
- // Last block uses "active" prefix -> always re-renders on content change
- const key = isLastBlock ? `active-${i}` : `block-${i}-${simpleHash(block.content)}`
-
- return (
-
- )
- })}
- >
- )
-}
diff --git a/packages/ui/src/components/markdown/index.ts b/packages/ui/src/components/markdown/index.ts
deleted file mode 100644
index a2a89c50..00000000
--- a/packages/ui/src/components/markdown/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown'
-export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
-export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
-export { preprocessLinks, detectLinks, hasLinks } from './linkify'
diff --git a/packages/ui/src/components/markdown/linkify.ts b/packages/ui/src/components/markdown/linkify.ts
deleted file mode 100644
index f4ee1809..00000000
--- a/packages/ui/src/components/markdown/linkify.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-import LinkifyIt from 'linkify-it'
-
-/**
- * Linkify - URL and file path detection for markdown preprocessing
- *
- * Uses linkify-it (12M downloads/week) for battle-tested URL detection,
- * plus custom regex for local file paths.
- */
-
-// Initialize linkify-it with default settings (fuzzy URLs, emails enabled)
-const linkify = new LinkifyIt()
-
-// File path regex - detects /path, ~/path, ./path with common extensions
-// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension
-const FILE_PATH_REGEX =
- /(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
-
-interface DetectedLink {
- type: 'url' | 'email' | 'file'
- text: string
- url: string
- start: number
- end: number
-}
-
-interface CodeRange {
- start: number
- end: number
-}
-
-/**
- * Find all code block and inline code ranges in text
- * These ranges should be excluded from link detection
- */
-function findCodeRanges(text: string): CodeRange[] {
- const ranges: CodeRange[] = []
-
- // Find fenced code blocks (```...```)
- const fencedRegex = /```[\s\S]*?```/g
- let match
- while ((match = fencedRegex.exec(text)) !== null) {
- ranges.push({ start: match.index, end: match.index + match[0].length })
- }
-
- // Find display math blocks ($$...$$)
- const displayMathRegex = /\$\$[\s\S]*?\$\$/g
- while ((match = displayMathRegex.exec(text)) !== null) {
- const pos = match.index
- const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
- if (!insideOther) {
- ranges.push({ start: pos, end: pos + match[0].length })
- }
- }
-
- // Find inline math ($...$)
- const inlineMathRegex = /(? pos >= r.start && pos < r.end)
- if (!insideOther) {
- ranges.push({ start: pos, end: pos + match[0].length })
- }
- }
-
- // Find inline code (`...`)
- // But skip escaped backticks and code inside fenced blocks
- const inlineRegex = /(? pos >= r.start && pos < r.end)
- if (!insideOther) {
- ranges.push({ start: pos, end: pos + match[0].length })
- }
- }
-
- return ranges
-}
-
-/**
- * Check if a position is inside any code range
- */
-function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
- return ranges.some((r) => pos >= r.start && pos < r.end)
-}
-
-/**
- * Check if a link at given position is already a markdown link
- * Looks for patterns like [text](url) or [text][ref]
- */
-function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean {
- // Check if preceded by ]( which indicates we're inside a markdown link href
- // Pattern: [text](URL) - we're checking if URL is our link
- const before = text.slice(Math.max(0, linkStart - 2), linkStart)
- if (before.endsWith('](')) return true
-
- // Check if preceded by ][ for reference links
- if (before.endsWith('][')) return true
-
- // Check if the link text is wrapped in []
- // Pattern: [URL](href) - URL is being used as link text
- const charBefore = text[linkStart - 1]
- const charAfter = text[linkEnd]
- if (charBefore === '[' && charAfter === ']') return true
-
- return false
-}
-
-/**
- * Check if ranges overlap
- */
-function rangesOverlap(
- a: { start: number; end: number },
- b: { start: number; end: number }
-): boolean {
- return a.start < b.end && b.start < a.end
-}
-
-/**
- * Detect all links (URLs, emails, file paths) in text
- */
-export function detectLinks(text: string): DetectedLink[] {
- const links: DetectedLink[] = []
-
- // 1. Detect URLs and emails with linkify-it
- const urlMatches = linkify.match(text) || []
- for (const match of urlMatches) {
- links.push({
- type: match.schema === 'mailto:' ? 'email' : 'url',
- text: match.text,
- url: match.url,
- start: match.index,
- end: match.lastIndex
- })
- }
-
- // 2. Detect file paths with custom regex
- // Reset regex state
- FILE_PATH_REGEX.lastIndex = 0
- let fileMatch
- while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) {
- const path = fileMatch[1]
- if (!path) continue // Skip if no capture group
-
- // Calculate actual start position (after any leading whitespace/punctuation)
- const fullMatch = fileMatch[0]
- const pathOffset = fullMatch.indexOf(path)
- const start = fileMatch.index + pathOffset
-
- // Check for overlaps with URL matches (URLs take precedence)
- const pathRange = { start, end: start + path.length }
- const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link))
- if (overlapsUrl) continue
-
- links.push({
- type: 'file',
- text: path,
- url: path, // File paths are passed as-is to onFileClick handler
- start,
- end: start + path.length
- })
- }
-
- // Sort by position
- return links.sort((a, b) => a.start - b.start)
-}
-
-/**
- * Preprocess text to convert raw URLs and file paths into markdown links
- * Skips code blocks and already-linked content
- */
-export function preprocessLinks(text: string): string {
- // Quick check - if no potential links, return early
- if (!linkify.pretest(text) && !/[~/.]\//.test(text)) {
- return text
- }
-
- const codeRanges = findCodeRanges(text)
- const links = detectLinks(text)
-
- if (links.length === 0) return text
-
- // Build result, converting raw links to markdown links
- let result = ''
- let lastIndex = 0
-
- for (const link of links) {
- // Skip if inside code block
- if (isInsideCode(link.start, codeRanges)) continue
-
- // Skip if already a markdown link
- if (isAlreadyLinked(text, link.start, link.end)) continue
-
- // Add text before this link
- result += text.slice(lastIndex, link.start)
-
- // Convert to markdown link
- result += `[${link.text}](${link.url})`
-
- lastIndex = link.end
- }
-
- // Add remaining text
- result += text.slice(lastIndex)
-
- return result
-}
-
-/**
- * Test if text contains any detectable links
- * Useful for optimization - skip preprocessing if no links present
- */
-export function hasLinks(text: string): boolean {
- return linkify.pretest(text) || /[~/.]\/[\w]/.test(text)
-}
diff --git a/packages/ui/src/components/multica-icon.tsx b/packages/ui/src/components/multica-icon.tsx
deleted file mode 100644
index 52b0fe82..00000000
--- a/packages/ui/src/components/multica-icon.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useState, useEffect } from "react";
-import { cn } from "@multica/ui/lib/utils";
-
-interface MulticaIconProps extends React.ComponentProps<"span"> {
- /**
- * If true, play a one-time entrance spin animation.
- */
- animate?: boolean;
- /**
- * If true, disable hover spin animation.
- */
- noSpin?: boolean;
- /**
- * If true, show a border around the icon.
- */
- bordered?: boolean;
- /**
- * Size of the bordered icon: "sm" (default), "md", "lg"
- */
- size?: "sm" | "md" | "lg";
-}
-
-const borderedSizes = {
- sm: { wrapper: "p-1.5", icon: "size-3.5" },
- md: { wrapper: "p-2", icon: "size-4" },
- lg: { wrapper: "p-2.5", icon: "size-5" },
-};
-
-/**
- * Pure CSS 8-pointed asterisk icon matching the Multica logo.
- * Uses currentColor so it adapts to light/dark themes automatically.
- * Clip-path polygon traced from the original SVG path coordinates.
- */
-export function MulticaIcon({
- className,
- animate = false,
- noSpin = false,
- bordered = false,
- size = "sm",
- ...props
-}: MulticaIconProps) {
- const [entranceDone, setEntranceDone] = useState(!animate);
-
- useEffect(() => {
- if (!animate) return;
- const timer = setTimeout(() => setEntranceDone(true), 600);
- return () => clearTimeout(timer);
- }, [animate]);
-
- const clipPath = `polygon(
- 45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
- 81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
- 100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
- 55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
- 18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
- 0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
- )`;
-
- if (bordered) {
- const sizeConfig = borderedSizes[size];
- return (
-
-
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx
deleted file mode 100644
index 9328784e..00000000
--- a/packages/ui/src/components/spinner.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Spinner — 3x3 grid pulse for **active processing / execution** states.
- *
- * Use when the system is actively doing work or waiting for human action
- * (streaming content, generating responses, awaiting approval).
- * For passive content-loading states, use `` instead.
- *
- * Inherits color from `currentColor` (use Tailwind `text-*`).
- * Scales with font-size (use Tailwind `text-*` for size).
- */
-import { cn } from "@multica/ui/lib/utils"
-
-export interface SpinnerProps {
- /** Additional className for styling (color via text-*, size via Tailwind text-*) */
- className?: string
-}
-
-const DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]
-
-const cubeStyle: React.CSSProperties = {
- backgroundColor: "currentColor",
- animation: "spinner-grid 1.3s infinite ease-in-out",
- transform: "scale3d(0.5, 0.5, 1)",
-}
-
-export function Spinner({ className }: SpinnerProps) {
- return (
-
- {DELAYS.map((delay, i) => (
-
- ))}
-
-
-
- )
-}
diff --git a/packages/ui/src/components/theme-provider.tsx b/packages/ui/src/components/theme-provider.tsx
deleted file mode 100644
index 6a1ffe4d..00000000
--- a/packages/ui/src/components/theme-provider.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ThemeProvider as NextThemesProvider } from "next-themes"
-
-export function ThemeProvider({
- children,
- ...props
-}: React.ComponentProps) {
- return {children}
-}
diff --git a/packages/ui/src/components/theme-toggle.tsx b/packages/ui/src/components/theme-toggle.tsx
deleted file mode 100644
index 865269ac..00000000
--- a/packages/ui/src/components/theme-toggle.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client"
-
-import { useTheme } from "next-themes"
-import { Sun, Moon, Monitor } from "lucide-react"
-import {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
-} from "@multica/ui/components/ui/dropdown-menu"
-import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
-
-export function ThemeToggle() {
- const { setTheme } = useTheme()
-
- return (
-
-
-
-
- Theme
-
- }
- />
-
- setTheme("light")}>
- Light
-
- setTheme("dark")}>
- Dark
-
- setTheme("system")}>
- System
-
-
-
- )
-}
diff --git a/packages/ui/src/components/ui/alert-dialog.tsx b/packages/ui/src/components/ui/alert-dialog.tsx
deleted file mode 100644
index 6a43197b..00000000
--- a/packages/ui/src/components/ui/alert-dialog.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
-
-import { cn } from "@multica/ui/lib/utils"
-import { Button } from "@multica/ui/components/ui/button"
-
-function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
- return
-}
-
-function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
- return (
-
- )
-}
-
-function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
- return (
-
- )
-}
-
-function AlertDialogOverlay({
- className,
- ...props
-}: AlertDialogPrimitive.Backdrop.Props) {
- return (
-
- )
-}
-
-function AlertDialogContent({
- className,
- size = "default",
- ...props
-}: AlertDialogPrimitive.Popup.Props & {
- size?: "default" | "sm"
-}) {
- return (
-
-
-
-
- )
-}
-
-function AlertDialogHeader({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function AlertDialogFooter({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function AlertDialogMedia({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function AlertDialogTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function AlertDialogDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function AlertDialogAction({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function AlertDialogCancel({
- className,
- variant = "outline",
- size = "default",
- ...props
-}: AlertDialogPrimitive.Close.Props &
- Pick, "variant" | "size">) {
- return (
- }
- {...props}
- />
- )
-}
-
-export {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogMedia,
- AlertDialogOverlay,
- AlertDialogPortal,
- AlertDialogTitle,
- AlertDialogTrigger,
-}
diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx
deleted file mode 100644
index 99d988a1..00000000
--- a/packages/ui/src/components/ui/badge.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { mergeProps } from "@base-ui/react/merge-props"
-import { useRender } from "@base-ui/react/use-render"
-import { cva, type VariantProps } from "class-variance-authority"
-
-import { cn } from "@multica/ui/lib/utils"
-
-const badgeVariants = cva(
- "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
- secondary:
- "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
- destructive:
- "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
- outline:
- "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
- ghost:
- "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
- link: "text-primary underline-offset-4 hover:underline",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-)
-
-function Badge({
- className,
- variant = "default",
- render,
- ...props
-}: useRender.ComponentProps<"span"> & VariantProps) {
- return useRender({
- defaultTagName: "span",
- props: mergeProps<"span">(
- {
- className: cn(badgeVariants({ variant }), className),
- },
- props
- ),
- render,
- state: {
- slot: "badge",
- variant,
- },
- })
-}
-
-export { Badge, badgeVariants }
diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx
deleted file mode 100644
index 8948d6c2..00000000
--- a/packages/ui/src/components/ui/button.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-"use client"
-
-import { Button as ButtonPrimitive } from "@base-ui/react/button"
-import { cva, type VariantProps } from "class-variance-authority"
-
-import { cn } from "@multica/ui/lib/utils"
-
-const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
- outline:
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
- ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
- destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default:
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
- icon: "size-8",
- "icon-xs":
- "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
- "icon-sm":
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
-
-function Button({
- className,
- variant = "default",
- size = "default",
- ...props
-}: ButtonPrimitive.Props & VariantProps) {
- return (
-
- )
-}
-
-export { Button, buttonVariants }
diff --git a/packages/ui/src/components/ui/calendar.tsx b/packages/ui/src/components/ui/calendar.tsx
deleted file mode 100644
index 654c78fc..00000000
--- a/packages/ui/src/components/ui/calendar.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- DayPicker,
- getDefaultClassNames,
- type DayButton,
- type Locale,
-} from "react-day-picker"
-
-import { cn } from "@multica/ui/lib/utils"
-import { Button, buttonVariants } from "@multica/ui/components/ui/button"
-import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
-
-function Calendar({
- className,
- classNames,
- showOutsideDays = true,
- captionLayout = "label",
- buttonVariant = "ghost",
- locale,
- formatters,
- components,
- ...props
-}: React.ComponentProps & {
- buttonVariant?: React.ComponentProps["variant"]
-}) {
- const defaultClassNames = getDefaultClassNames()
-
- return (
- svg]:rotate-180`,
- String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
- className
- )}
- captionLayout={captionLayout}
- locale={locale}
- formatters={{
- formatMonthDropdown: (date) =>
- date.toLocaleString(locale?.code, { month: "short" }),
- ...formatters,
- }}
- classNames={{
- root: cn("w-fit", defaultClassNames.root),
- months: cn(
- "relative flex flex-col gap-4 md:flex-row",
- defaultClassNames.months
- ),
- month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
- nav: cn(
- "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
- defaultClassNames.nav
- ),
- button_previous: cn(
- buttonVariants({ variant: buttonVariant }),
- "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
- defaultClassNames.button_previous
- ),
- button_next: cn(
- buttonVariants({ variant: buttonVariant }),
- "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
- defaultClassNames.button_next
- ),
- month_caption: cn(
- "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
- defaultClassNames.month_caption
- ),
- dropdowns: cn(
- "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
- defaultClassNames.dropdowns
- ),
- dropdown_root: cn(
- "relative rounded-(--cell-radius)",
- defaultClassNames.dropdown_root
- ),
- dropdown: cn(
- "absolute inset-0 bg-popover opacity-0",
- defaultClassNames.dropdown
- ),
- caption_label: cn(
- "font-medium select-none",
- captionLayout === "label"
- ? "text-sm"
- : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
- defaultClassNames.caption_label
- ),
- table: "w-full border-collapse",
- weekdays: cn("flex", defaultClassNames.weekdays),
- weekday: cn(
- "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
- defaultClassNames.weekday
- ),
- week: cn("mt-2 flex w-full", defaultClassNames.week),
- week_number_header: cn(
- "w-(--cell-size) select-none",
- defaultClassNames.week_number_header
- ),
- week_number: cn(
- "text-[0.8rem] text-muted-foreground select-none",
- defaultClassNames.week_number
- ),
- day: cn(
- "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
- props.showWeekNumber
- ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
- : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
- defaultClassNames.day
- ),
- range_start: cn(
- "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
- defaultClassNames.range_start
- ),
- range_middle: cn("rounded-none", defaultClassNames.range_middle),
- range_end: cn(
- "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
- defaultClassNames.range_end
- ),
- today: cn(
- "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
- defaultClassNames.today
- ),
- outside: cn(
- "text-muted-foreground aria-selected:text-muted-foreground",
- defaultClassNames.outside
- ),
- disabled: cn(
- "text-muted-foreground opacity-50",
- defaultClassNames.disabled
- ),
- hidden: cn("invisible", defaultClassNames.hidden),
- ...classNames,
- }}
- components={{
- Root: ({ className, rootRef, ...props }) => {
- return (
-
- )
- },
- Chevron: ({ className, orientation, ...props }) => {
- if (orientation === "left") {
- return (
-
- )
- }
-
- if (orientation === "right") {
- return (
-
- )
- }
-
- return (
-
- )
- },
- DayButton: ({ ...props }) => (
-
- ),
- WeekNumber: ({ children, ...props }) => {
- return (
-
-
- {children}
-
- |
- )
- },
- ...components,
- }}
- {...props}
- />
- )
-}
-
-function CalendarDayButton({
- className,
- day,
- modifiers,
- locale,
- ...props
-}: React.ComponentProps & { locale?: Partial }) {
- const defaultClassNames = getDefaultClassNames()
-
- const ref = React.useRef(null)
- React.useEffect(() => {
- if (modifiers.focused) ref.current?.focus()
- }, [modifiers.focused])
-
- return (
-