From d1f73bf7fcd9936bb3b8e9bb0a29d5a7a3d7a427 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:57:11 +0800 Subject: [PATCH] refactor: remove unused monorepo tooling Delete packages/ui and packages/utils (zero imports from apps/web), remove Turborepo, inline tsconfig.base.json into web's tsconfig, and simplify root scripts to use pnpm --filter directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 - apps/web/tsconfig.json | 51 +- package.json | 13 +- packages/ui/components.json | 23 - packages/ui/package.json | 40 - packages/ui/postcss.config.mjs | 6 - .../ui/src/components/common/actor-avatar.tsx | 44 -- .../ui/src/components/loading-indicator.tsx | 30 - .../ui/src/components/markdown/CodeBlock.tsx | 250 ------ .../ui/src/components/markdown/Markdown.tsx | 301 -------- .../components/markdown/StreamingMarkdown.tsx | 225 ------ packages/ui/src/components/markdown/index.ts | 4 - .../ui/src/components/markdown/linkify.ts | 215 ------ packages/ui/src/components/multica-icon.tsx | 105 --- packages/ui/src/components/spinner.tsx | 47 -- packages/ui/src/components/theme-provider.tsx | 11 - packages/ui/src/components/theme-toggle.tsx | 40 - .../ui/src/components/ui/alert-dialog.tsx | 187 ----- packages/ui/src/components/ui/badge.tsx | 52 -- packages/ui/src/components/ui/button.tsx | 60 -- packages/ui/src/components/ui/calendar.tsx | 221 ------ packages/ui/src/components/ui/card.tsx | 103 --- packages/ui/src/components/ui/checkbox.tsx | 29 - packages/ui/src/components/ui/collapsible.tsx | 21 - packages/ui/src/components/ui/combobox.tsx | 300 -------- packages/ui/src/components/ui/dialog.tsx | 160 ---- .../ui/src/components/ui/dropdown-menu.tsx | 274 ------- packages/ui/src/components/ui/field.tsx | 238 ------ packages/ui/src/components/ui/hover-card.tsx | 51 -- packages/ui/src/components/ui/input-group.tsx | 158 ---- packages/ui/src/components/ui/input.tsx | 20 - packages/ui/src/components/ui/label.tsx | 20 - packages/ui/src/components/ui/link.tsx | 30 - packages/ui/src/components/ui/loading.tsx | 54 -- packages/ui/src/components/ui/popover.tsx | 90 --- packages/ui/src/components/ui/select.tsx | 204 ----- packages/ui/src/components/ui/separator.tsx | 25 - packages/ui/src/components/ui/sheet.tsx | 138 ---- packages/ui/src/components/ui/sidebar.tsx | 723 ------------------ packages/ui/src/components/ui/skeleton.tsx | 13 - packages/ui/src/components/ui/sonner.tsx | 49 -- packages/ui/src/components/ui/switch.tsx | 32 - packages/ui/src/components/ui/tabs.tsx | 82 -- packages/ui/src/components/ui/textarea.tsx | 18 - packages/ui/src/components/ui/tooltip.tsx | 66 -- packages/ui/src/hooks/use-auto-scroll.ts | 73 -- packages/ui/src/hooks/use-mobile.ts | 19 - packages/ui/src/hooks/use-scroll-fade.ts | 68 -- packages/ui/src/lib/utils.ts | 6 - packages/ui/src/styles/custom.css | 131 ---- packages/ui/src/styles/fonts.ts | 11 - packages/ui/src/styles/globals.css | 131 ---- packages/ui/tsconfig.json | 11 - packages/utils/package.json | 16 - packages/utils/src/date.ts | 27 - packages/utils/src/index.ts | 1 - packages/utils/tsconfig.json | 7 - pnpm-workspace.yaml | 1 - tsconfig.base.json | 21 - turbo.json | 42 - 60 files changed, 49 insertions(+), 5340 deletions(-) delete mode 100644 packages/ui/components.json delete mode 100644 packages/ui/package.json delete mode 100644 packages/ui/postcss.config.mjs delete mode 100644 packages/ui/src/components/common/actor-avatar.tsx delete mode 100644 packages/ui/src/components/loading-indicator.tsx delete mode 100644 packages/ui/src/components/markdown/CodeBlock.tsx delete mode 100644 packages/ui/src/components/markdown/Markdown.tsx delete mode 100644 packages/ui/src/components/markdown/StreamingMarkdown.tsx delete mode 100644 packages/ui/src/components/markdown/index.ts delete mode 100644 packages/ui/src/components/markdown/linkify.ts delete mode 100644 packages/ui/src/components/multica-icon.tsx delete mode 100644 packages/ui/src/components/spinner.tsx delete mode 100644 packages/ui/src/components/theme-provider.tsx delete mode 100644 packages/ui/src/components/theme-toggle.tsx delete mode 100644 packages/ui/src/components/ui/alert-dialog.tsx delete mode 100644 packages/ui/src/components/ui/badge.tsx delete mode 100644 packages/ui/src/components/ui/button.tsx delete mode 100644 packages/ui/src/components/ui/calendar.tsx delete mode 100644 packages/ui/src/components/ui/card.tsx delete mode 100644 packages/ui/src/components/ui/checkbox.tsx delete mode 100644 packages/ui/src/components/ui/collapsible.tsx delete mode 100644 packages/ui/src/components/ui/combobox.tsx delete mode 100644 packages/ui/src/components/ui/dialog.tsx delete mode 100644 packages/ui/src/components/ui/dropdown-menu.tsx delete mode 100644 packages/ui/src/components/ui/field.tsx delete mode 100644 packages/ui/src/components/ui/hover-card.tsx delete mode 100644 packages/ui/src/components/ui/input-group.tsx delete mode 100644 packages/ui/src/components/ui/input.tsx delete mode 100644 packages/ui/src/components/ui/label.tsx delete mode 100644 packages/ui/src/components/ui/link.tsx delete mode 100644 packages/ui/src/components/ui/loading.tsx delete mode 100644 packages/ui/src/components/ui/popover.tsx delete mode 100644 packages/ui/src/components/ui/select.tsx delete mode 100644 packages/ui/src/components/ui/separator.tsx delete mode 100644 packages/ui/src/components/ui/sheet.tsx delete mode 100644 packages/ui/src/components/ui/sidebar.tsx delete mode 100644 packages/ui/src/components/ui/skeleton.tsx delete mode 100644 packages/ui/src/components/ui/sonner.tsx delete mode 100644 packages/ui/src/components/ui/switch.tsx delete mode 100644 packages/ui/src/components/ui/tabs.tsx delete mode 100644 packages/ui/src/components/ui/textarea.tsx delete mode 100644 packages/ui/src/components/ui/tooltip.tsx delete mode 100644 packages/ui/src/hooks/use-auto-scroll.ts delete mode 100644 packages/ui/src/hooks/use-mobile.ts delete mode 100644 packages/ui/src/hooks/use-scroll-fade.ts delete mode 100644 packages/ui/src/lib/utils.ts delete mode 100644 packages/ui/src/styles/custom.css delete mode 100644 packages/ui/src/styles/fonts.ts delete mode 100644 packages/ui/src/styles/globals.css delete mode 100644 packages/ui/tsconfig.json delete mode 100644 packages/utils/package.json delete mode 100644 packages/utils/src/date.ts delete mode 100644 packages/utils/src/index.ts delete mode 100644 packages/utils/tsconfig.json delete mode 100644 tsconfig.base.json delete mode 100644 turbo.json 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 }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - // Plain tables - table: ({ children }) => {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 }) => ( -
      - {children} -
    - ), - ol: ({ children }) =>
      {children}
    , - li: ({ children }) =>
  • {children}
  • , - // Clean tables - table: ({ children }) => ( -
    - {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 }) => ( -
      - {children} -
    - ), - ol: ({ children }) =>
      {children}
    , - li: ({ children }) =>
  • {children}
  • , - // Beautiful tables - table: ({ children }) => ( -
    - {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 ( -