UNPKG

@vibeship/devtools

Version:

Comprehensive markdown-based project management system with AI capabilities for Next.js applications

1 lines 94.7 kB
{"version":3,"sources":["../src/zero/VibeshipDevTools.tsx","../src/zero/hooks.ts","../src/zero/scanner.ts","../src/zero/components/TaskPanel.tsx","../src/zero/components/VirtualList.tsx","../src/zero/components/UpgradePrompt.tsx","../src/zero/components/UpgradeWizard.tsx","../src/zero/components/ErrorBoundary.tsx"],"sourcesContent":["/**\n * Zero-config Vibeship DevTools component\n * Works immediately with no setup required\n */\n\n\"use client\";\n\nimport React, { useState, useEffect } from 'react';\nimport { useClientSideScan, useProgressiveLevel } from './hooks';\nimport { TaskPanel } from './components/TaskPanel';\nimport { UpgradePrompt, UpgradeBanner } from './components/UpgradePrompt';\nimport { QuickSetupWizard } from './components/UpgradeWizard';\nimport { ErrorBoundary } from './components/ErrorBoundary';\n\nexport interface VibeshipDevToolsProps {\n /**\n * Position of the floating button\n */\n position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';\n \n /**\n * Whether to show upgrade prompts\n */\n showUpgradePrompts?: boolean;\n \n /**\n * Whether to start in open state\n */\n defaultOpen?: boolean;\n \n /**\n * Custom class name for the container\n */\n className?: string;\n \n /**\n * Callback when upgrade is initiated\n */\n onUpgrade?: (nextLevel: number) => void;\n \n /**\n * Scanner options (advanced)\n */\n scanOptions?: {\n includePublicFiles?: boolean;\n includeSourceMaps?: boolean;\n maxTasks?: number;\n };\n}\n\nexport function VibeshipDevTools({\n position = 'bottom-left',\n showUpgradePrompts = true,\n defaultOpen = false,\n className = '',\n onUpgrade,\n scanOptions = {},\n}: VibeshipDevToolsProps) {\n const [isOpen, setIsOpen] = useState(defaultOpen);\n const [showUpgrade, setShowUpgrade] = useState(false);\n const [dismissedUpgrade, setDismissedUpgrade] = useState(false);\n const [showSetupWizard, setShowSetupWizard] = useState(false);\n \n // Add keyboard shortcut support\n useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n // Cmd/Ctrl + Shift + K\n if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'K') {\n event.preventDefault();\n setIsOpen(!isOpen);\n }\n };\n\n window.addEventListener('keydown', handleKeyDown);\n return () => window.removeEventListener('keydown', handleKeyDown);\n }, [isOpen]);\n \n // Use the client-side scanner with conservative defaults\n const { tasks, isScanning, lastScanTime, rescan } = useClientSideScan({\n enabled: true,\n watchChanges: true,\n includePublicFiles: scanOptions.includePublicFiles ?? false, // Disabled by default to avoid 404s\n includeSourceMaps: scanOptions.includeSourceMaps ?? false, // Disabled by default to avoid 404s\n maxTasks: scanOptions.maxTasks ?? 100,\n });\n \n // Use progressive level detection\n const { currentLevel, levels, canUpgrade } = useProgressiveLevel();\n \n // Show upgrade prompt after delay if applicable\n useEffect(() => {\n if (showUpgradePrompts && canUpgrade && !dismissedUpgrade && tasks.length > 0) {\n const timer = setTimeout(() => {\n setShowUpgrade(true);\n }, 30000); // Show after 30 seconds\n \n return () => clearTimeout(timer);\n }\n }, [showUpgradePrompts, canUpgrade, dismissedUpgrade, tasks.length]);\n \n // Position classes\n const positionClasses = {\n 'bottom-right': 'bottom-4 right-4',\n 'bottom-left': 'bottom-4 left-4',\n 'top-right': 'top-4 right-4',\n 'top-left': 'top-4 left-4',\n };\n \n const panelPositionClasses = {\n 'bottom-right': 'bottom-16 right-4',\n 'bottom-left': 'bottom-16 left-4',\n 'top-right': 'top-16 right-4',\n 'top-left': 'top-16 left-4',\n };\n \n return (\n <ErrorBoundary\n fallback={(error, retry) => (\n <div className={`fixed ${positionClasses[position]} z-50`}>\n <button\n onClick={retry}\n className=\"relative p-3 bg-red-500 text-white rounded-full shadow-lg hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2\"\n aria-label=\"DevTools Error - Click to retry\"\n title={`DevTools Error: ${error.message}`}\n >\n <svg className=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n </svg>\n </button>\n </div>\n )}\n >\n {/* Floating action button */}\n <div className={`fixed ${positionClasses[position]} z-50 ${className}`}>\n <button\n onClick={() => setIsOpen(!isOpen)}\n className=\"relative p-3 bg-purple-500 text-white rounded-full shadow-lg hover:bg-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2\"\n aria-label=\"Toggle DevTools\"\n >\n <svg className=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4\" />\n </svg>\n \n {/* Task count badge */}\n {tasks.length > 0 && (\n <span className=\"absolute -top-1 -right-1 px-2 py-0.5 text-xs bg-red-500 text-white rounded-full\">\n {tasks.length}\n </span>\n )}\n </button>\n </div>\n \n {/* Task panel */}\n {isOpen && (\n <div className={`fixed ${panelPositionClasses[position]} z-40 w-96 h-[600px] max-h-[80vh] bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden`}>\n {/* Zero-config banner */}\n {currentLevel === 0 && !dismissedUpgrade && (\n <UpgradeBanner\n message=\"Running in read-only mode. Add an API route to enable editing.\"\n actionText=\"Setup\"\n onAction={() => {\n setShowSetupWizard(true);\n }}\n onDismiss={() => setDismissedUpgrade(true)}\n />\n )}\n \n <TaskPanel\n tasks={tasks}\n isScanning={isScanning}\n lastScanTime={lastScanTime}\n onRescan={rescan}\n className=\"h-full\"\n />\n </div>\n )}\n \n {/* Upgrade prompt */}\n {showUpgrade && currentLevel < levels.length - 1 && (\n <UpgradePrompt\n currentLevel={currentLevel}\n nextLevel={{\n ...levels[currentLevel + 1],\n setupTime: currentLevel === 0 ? '2 minutes' : '5 minutes',\n setupSteps: currentLevel === 0 ? [\n 'Create /app/api/vibeship/route.ts',\n 'Export handlers from @vibeship/devtools/api',\n 'Restart your dev server',\n ] : [\n 'Create vibeship.config.ts',\n 'Define custom scan paths',\n 'Enable additional features',\n ],\n }}\n onUpgrade={() => {\n onUpgrade?.(currentLevel + 1);\n setShowUpgrade(false);\n window.open('https://vibeship.dev/docs/upgrade', '_blank');\n }}\n onDismiss={() => {\n setShowUpgrade(false);\n setDismissedUpgrade(true);\n }}\n onLearnMore={() => {\n window.open('https://vibeship.dev/docs/features', '_blank');\n }}\n />\n )}\n \n {/* Setup Wizard */}\n {showSetupWizard && (\n <QuickSetupWizard\n onComplete={() => {\n setShowSetupWizard(false);\n onUpgrade?.(1);\n window.location.reload();\n }}\n onCancel={() => setShowSetupWizard(false)}\n />\n )}\n </ErrorBoundary>\n );\n}\n\n/**\n * Static DevTools panel for embedding in layouts\n */\nexport interface VibeshipDevToolsPanelProps {\n /**\n * Whether to show upgrade prompts\n */\n showUpgradePrompts?: boolean;\n \n /**\n * Custom class name for the container\n */\n className?: string;\n \n /**\n * Callback when upgrade is initiated\n */\n onUpgrade?: (nextLevel: number) => void;\n \n /**\n * Scanner options (advanced)\n */\n scanOptions?: {\n includePublicFiles?: boolean;\n includeSourceMaps?: boolean;\n maxTasks?: number;\n };\n}\n\nexport function VibeshipDevToolsPanel({\n showUpgradePrompts = true,\n className = '',\n onUpgrade,\n scanOptions = {},\n}: VibeshipDevToolsPanelProps) {\n const [dismissedUpgrade, setDismissedUpgrade] = useState(false);\n const [showSetupWizard, setShowSetupWizard] = useState(false);\n \n // Use the client-side scanner with conservative defaults\n const { tasks, isScanning, lastScanTime, rescan } = useClientSideScan({\n enabled: true,\n watchChanges: true,\n includePublicFiles: scanOptions.includePublicFiles ?? false, // Disabled by default to avoid 404s\n includeSourceMaps: scanOptions.includeSourceMaps ?? false, // Disabled by default to avoid 404s\n maxTasks: scanOptions.maxTasks ?? 100,\n });\n \n // Use progressive level detection\n const { currentLevel, levels } = useProgressiveLevel();\n \n return (\n <div className={`w-full h-full ${className}`}>\n {/* Zero-config banner */}\n {showUpgradePrompts && currentLevel === 0 && !dismissedUpgrade && (\n <UpgradeBanner\n message=\"Running in read-only mode. Add an API route to enable editing.\"\n actionText=\"Setup\"\n onAction={() => {\n onUpgrade?.(1);\n window.open('https://vibeship.dev/docs/setup', '_blank');\n }}\n onDismiss={() => setDismissedUpgrade(true)}\n />\n )}\n \n <TaskPanel\n tasks={tasks}\n isScanning={isScanning}\n lastScanTime={lastScanTime}\n onRescan={rescan}\n className=\"h-full\"\n />\n \n {/* Setup Wizard */}\n {showSetupWizard && (\n <QuickSetupWizard\n onComplete={() => {\n setShowSetupWizard(false);\n onUpgrade?.(1);\n window.location.reload();\n }}\n onCancel={() => setShowSetupWizard(false)}\n />\n )}\n </div>\n );\n}","/**\n * React hooks for zero-config mode\n */\n\nimport { useEffect, useState, useCallback, useRef, useMemo } from 'react';\nimport { \n scanForTasks, \n scanForTasksAsync,\n watchForChanges, \n type ClientTask, \n type ScanOptions \n} from './scanner';\n\nexport interface UseClientSideScanOptions extends ScanOptions {\n enabled?: boolean;\n watchChanges?: boolean;\n scanInterval?: number;\n includeAsync?: boolean;\n}\n\n/**\n * Hook to scan for tasks on the client side\n */\nexport function useClientSideScan(options: UseClientSideScanOptions = {}) {\n const {\n enabled = true,\n watchChanges: watchChangesOption = true,\n scanInterval,\n ...scanOptions\n } = options;\n\n const [tasks, setTasks] = useState<ClientTask[]>([]);\n const [isScanning, setIsScanning] = useState(false);\n const [lastScanTime, setLastScanTime] = useState<Date | null>(null);\n const cleanupRef = useRef<(() => void) | null>(null);\n\n // Memoize scanOptions to prevent infinite re-renders\n const stableScanOptions = useMemo(() => scanOptions, [\n scanOptions.includeComments,\n scanOptions.includeHtml,\n scanOptions.includeProps,\n scanOptions.includeSourceMaps,\n scanOptions.includePublicFiles,\n scanOptions.includeAsync,\n scanOptions.maxTasks,\n JSON.stringify(scanOptions.publicFiles) // Serialize array for comparison\n ]);\n\n // Manual rescan function\n const rescan = useCallback(async () => {\n if (!enabled) return;\n\n setIsScanning(true);\n try {\n const newTasks = stableScanOptions.includeAsync || stableScanOptions.includeSourceMaps || stableScanOptions.includePublicFiles\n ? await scanForTasksAsync(stableScanOptions)\n : scanForTasks(stableScanOptions);\n setTasks(newTasks);\n setLastScanTime(new Date());\n } finally {\n setIsScanning(false);\n }\n }, [enabled, stableScanOptions]);\n\n // Set up scanning\n useEffect(() => {\n if (!enabled) {\n setTasks([]);\n return;\n }\n\n // Initial scan function\n const performInitialScan = async () => {\n setIsScanning(true);\n try {\n const newTasks = stableScanOptions.includeAsync || stableScanOptions.includeSourceMaps || stableScanOptions.includePublicFiles\n ? await scanForTasksAsync(stableScanOptions)\n : scanForTasks(stableScanOptions);\n setTasks(newTasks);\n setLastScanTime(new Date());\n } finally {\n setIsScanning(false);\n }\n };\n\n // Initial scan\n performInitialScan();\n\n // Set up change watching\n if (watchChangesOption) {\n cleanupRef.current = watchForChanges((newTasks) => {\n setTasks(newTasks);\n setLastScanTime(new Date());\n }, stableScanOptions);\n }\n\n // Set up interval scanning if specified\n let intervalId: NodeJS.Timeout;\n if (scanInterval && scanInterval > 0) {\n intervalId = setInterval(() => {\n performInitialScan();\n }, scanInterval);\n }\n\n // Cleanup\n return () => {\n if (cleanupRef.current) {\n cleanupRef.current();\n cleanupRef.current = null;\n }\n if (intervalId) {\n clearInterval(intervalId);\n }\n };\n }, [enabled, watchChangesOption, scanInterval, stableScanOptions]);\n\n return {\n tasks,\n isScanning,\n lastScanTime,\n rescan,\n taskCount: tasks.length,\n };\n}\n\n/**\n * Hook to track feature availability\n */\nexport function useFeatureDetection() {\n const [features, setFeatures] = useState({\n hasApiRoute: false,\n hasAiEnabled: false,\n hasFileAccess: false,\n isDevMode: false,\n });\n\n useEffect(() => {\n const detectFeatures = async () => {\n const detected = {\n hasApiRoute: false, // Disable API route check in zero-config mode to avoid 404s\n hasAiEnabled: checkAiEnabled(),\n hasFileAccess: false, // Always false in client-side mode\n isDevMode: checkDevMode(),\n };\n setFeatures(detected);\n };\n\n // Only check once on mount\n detectFeatures();\n }, []); // Empty dependency array - only run once\n\n return features;\n}\n\n// Cache API route check to avoid repeated 404s\nlet apiRouteCache: { result: boolean; timestamp: number } | null = null;\nconst API_ROUTE_CACHE_DURATION = 30000; // 30 seconds\n\n/**\n * Check if API route is available (cached)\n */\nasync function checkApiRoute(): Promise<boolean> {\n const now = Date.now();\n \n // Return cached result if still valid\n if (apiRouteCache && (now - apiRouteCache.timestamp) < API_ROUTE_CACHE_DURATION) {\n return apiRouteCache.result;\n }\n \n try {\n const response = await fetch('/api/vibeship/health', {\n method: 'GET',\n headers: { 'Content-Type': 'application/json' },\n });\n const result = response.ok;\n \n // Cache the result\n apiRouteCache = { result, timestamp: now };\n return result;\n } catch (error) {\n // Silently cache the failure - don't log 404s as they're expected in zero-config mode\n apiRouteCache = { result: false, timestamp: now };\n return false;\n }\n}\n\n/**\n * Check if AI features are enabled\n */\nfunction checkAiEnabled(): boolean {\n // Check for common AI API key patterns in window object\n if (typeof window !== 'undefined') {\n const win = window as any;\n return !!(\n win.OPENAI_API_KEY ||\n win.ANTHROPIC_API_KEY ||\n win.process?.env?.OPENAI_API_KEY ||\n win.process?.env?.ANTHROPIC_API_KEY\n );\n }\n return false;\n}\n\n/**\n * Check if in development mode\n */\nfunction checkDevMode(): boolean {\n if (typeof window !== 'undefined') {\n const win = window as any;\n return (\n win.process?.env?.NODE_ENV === 'development' ||\n win.location?.hostname === 'localhost' ||\n win.location?.hostname === '127.0.0.1'\n );\n }\n return false;\n}\n\n/**\n * Hook for progressive enhancement levels\n */\nexport function useProgressiveLevel() {\n const features = useFeatureDetection();\n const [currentLevel, setCurrentLevel] = useState(0);\n\n useEffect(() => {\n // Determine current level based on features\n if (features.hasApiRoute && features.hasFileAccess) {\n setCurrentLevel(3); // Advanced\n } else if (features.hasApiRoute) {\n setCurrentLevel(2); // Basic Config\n } else {\n setCurrentLevel(0); // Zero Config\n }\n }, [features]);\n\n const levels = [\n {\n level: 0,\n name: 'Zero Config',\n description: 'Client-side scanning only',\n features: ['View tasks', 'Read-only mode'],\n },\n {\n level: 1,\n name: 'Basic Config',\n description: 'API route enabled',\n features: ['Task scanning', 'Basic editing', 'File access'],\n },\n {\n level: 2,\n name: 'Custom Config',\n description: 'Configuration file added',\n features: ['Custom paths', 'AI features', 'Advanced patterns'],\n },\n {\n level: 3,\n name: 'Advanced',\n description: 'Full control mode',\n features: ['Custom implementations', 'Enterprise features'],\n },\n ];\n\n return {\n currentLevel,\n levels,\n features,\n canUpgrade: currentLevel < 3,\n };\n}","/**\n * Client-side task scanner for zero-config mode\n * Scans visible content in the browser without requiring API setup\n */\n\nexport interface ClientTask {\n id: string;\n type: string;\n content: string;\n file: string;\n line: number;\n priority: 'low' | 'medium' | 'high';\n source: 'dom' | 'props' | 'code' | 'sourcemap' | 'public';\n}\n\nexport interface ScanOptions {\n includeComments?: boolean;\n includeHtml?: boolean;\n includeProps?: boolean;\n includeSourceMaps?: boolean;\n includePublicFiles?: boolean;\n includeAsync?: boolean;\n publicFiles?: string[];\n maxTasks?: number;\n}\n\n// Task patterns for different contexts\nconst TASK_PATTERNS = {\n // JavaScript/TypeScript comments\n jsComments: [\n /\\/\\/\\s*(TODO|FIXME|HACK|NOTE|BUG|OPTIMIZE|REFACTOR):\\s*(.+)/gi,\n /\\/\\*\\s*(TODO|FIXME|HACK|NOTE|BUG|OPTIMIZE|REFACTOR):\\s*(.+?)\\s*\\*\\//gi,\n ],\n // HTML comments\n htmlComments: [\n /<!--\\s*(TODO|FIXME|HACK|NOTE|BUG|OPTIMIZE|REFACTOR):\\s*(.+?)\\s*-->/gi,\n ],\n // Markdown\n markdown: [\n /[-*]\\s*(TODO|FIXME|HACK|NOTE|BUG|OPTIMIZE|REFACTOR):\\s*(.+)/gi,\n />\\s*(TODO|FIXME|HACK|NOTE|BUG|OPTIMIZE|REFACTOR):\\s*(.+)/gi,\n ],\n};\n\n/**\n * Scan DOM content for tasks\n */\nexport function scanDomContent(options: ScanOptions = {}): ClientTask[] {\n const tasks: ClientTask[] = [];\n const { includeComments = true, includeHtml = true, maxTasks = 100 } = options;\n\n if (!includeComments && !includeHtml) {\n return tasks;\n }\n\n // Get all text content from pre/code elements (likely contains code)\n if (includeComments) {\n const codeElements = document.querySelectorAll('pre, code');\n codeElements.forEach((element) => {\n const content = element.textContent || '';\n const elementTasks = scanTextContent(content, 'dom', 'DOM Element');\n tasks.push(...elementTasks);\n });\n }\n\n // Scan HTML comments in the page\n if (includeHtml) {\n const htmlContent = document.documentElement.innerHTML;\n TASK_PATTERNS.htmlComments.forEach((pattern) => {\n let match;\n while ((match = pattern.exec(htmlContent)) && tasks.length < maxTasks) {\n tasks.push({\n id: `client-html-${tasks.length}`,\n type: match[1].toLowerCase(),\n content: match[2].trim(),\n file: 'HTML Content',\n line: 0,\n priority: inferPriority(match[1], match[2]),\n source: 'dom',\n });\n }\n });\n }\n\n return tasks.slice(0, maxTasks);\n}\n\n/**\n * Scan Next.js props for tasks\n */\nexport function scanNextJsProps(options: ScanOptions = {}): ClientTask[] {\n const tasks: ClientTask[] = [];\n const { maxTasks = 100 } = options;\n\n // Check for Next.js __NEXT_DATA__\n if (typeof window !== 'undefined' && (window as any).__NEXT_DATA__) {\n const nextData = (window as any).__NEXT_DATA__;\n const propsString = JSON.stringify(nextData.props);\n \n // Scan stringified props for task patterns\n const propTasks = scanTextContent(propsString, 'props', 'Next.js Props');\n tasks.push(...propTasks.slice(0, maxTasks));\n }\n\n return tasks;\n}\n\n/**\n * Scan any text content for tasks\n */\nexport function scanTextContent(\n content: string,\n source: ClientTask['source'],\n fileName: string = 'Unknown'\n): ClientTask[] {\n const tasks: ClientTask[] = [];\n const allPatterns = [\n ...TASK_PATTERNS.jsComments,\n ...TASK_PATTERNS.markdown,\n ];\n\n allPatterns.forEach((pattern) => {\n let match;\n const regex = new RegExp(pattern);\n while ((match = regex.exec(content))) {\n tasks.push({\n id: `client-${source}-${tasks.length}`,\n type: match[1].toLowerCase(),\n content: match[2].trim(),\n file: fileName,\n line: 0, // Can't determine line number in client-side scanning\n priority: inferPriority(match[1], match[2]),\n source,\n });\n }\n });\n\n return tasks;\n}\n\n/**\n * Infer task priority from type and content\n */\nfunction inferPriority(type: string, content: string): ClientTask['priority'] {\n const upperType = type.toUpperCase();\n const lowerContent = content.toLowerCase();\n\n // High priority indicators\n if (\n upperType === 'FIXME' ||\n upperType === 'BUG' ||\n lowerContent.includes('urgent') ||\n lowerContent.includes('critical') ||\n lowerContent.includes('asap') ||\n lowerContent.includes('important')\n ) {\n return 'high';\n }\n\n // Low priority indicators\n if (\n upperType === 'NOTE' ||\n upperType === 'OPTIMIZE' ||\n lowerContent.includes('maybe') ||\n lowerContent.includes('consider') ||\n lowerContent.includes('later') ||\n lowerContent.includes('someday')\n ) {\n return 'low';\n }\n\n // Default to medium\n return 'medium';\n}\n\n/**\n * Main scanner that combines all scanning methods\n */\nexport function scanForTasks(options: ScanOptions = {}): ClientTask[] {\n const allTasks: ClientTask[] = [];\n const { maxTasks = 100 } = options;\n\n // Scan DOM content\n const domTasks = scanDomContent(options);\n allTasks.push(...domTasks);\n\n // Scan Next.js props if available\n const propTasks = scanNextJsProps(options);\n allTasks.push(...propTasks);\n\n // Remove duplicates based on type and content\n const uniqueTasks = allTasks.reduce((acc, task) => {\n const key = `${task.type}-${task.content}`;\n if (!acc.has(key)) {\n acc.set(key, task);\n }\n return acc;\n }, new Map<string, ClientTask>());\n\n return Array.from(uniqueTasks.values()).slice(0, maxTasks);\n}\n\n/**\n * Scan source maps for tasks\n */\nexport async function scanSourceMaps(options: ScanOptions = {}): Promise<ClientTask[]> {\n const tasks: ClientTask[] = [];\n const { maxTasks = 100 } = options;\n \n // Only scan in development\n if (process.env.NODE_ENV !== 'development') {\n return tasks;\n }\n\n try {\n const scripts = Array.from(document.scripts);\n \n for (const script of scripts) {\n if (script.src && script.src.endsWith('.js')) {\n try {\n const mapResponse = await fetch(script.src + '.map');\n if (mapResponse.ok) {\n const mapData = await mapResponse.json();\n \n // Extract sources and their content\n if (mapData.sourcesContent) {\n mapData.sources?.forEach((source: string, index: number) => {\n const content = mapData.sourcesContent[index];\n if (content) {\n const sourceTasks = scanTextContent(\n content,\n 'sourcemap',\n source.split('/').pop() || 'Unknown'\n );\n tasks.push(...sourceTasks);\n }\n });\n }\n }\n } catch {\n // Ignore errors for individual source maps\n }\n }\n \n if (tasks.length >= maxTasks) break;\n }\n } catch (error) {\n console.debug('Error scanning source maps:', error);\n }\n\n return tasks.slice(0, maxTasks);\n}\n\n/**\n * Scan public files for tasks\n */\nexport async function scanPublicFiles(options: ScanOptions = {}): Promise<ClientTask[]> {\n const tasks: ClientTask[] = [];\n const { maxTasks = 100, publicFiles = [] } = options;\n \n // Default public files to check\n const defaultFiles = [\n '/README.md',\n '/CHANGELOG.md',\n '/TODO.md',\n '/docs/index.md',\n '/.tickets/index.md',\n ];\n \n const filesToScan = [...new Set([...defaultFiles, ...publicFiles])];\n \n for (const file of filesToScan) {\n try {\n const response = await fetch(file);\n if (response.ok) {\n const content = await response.text();\n const fileTasks = scanTextContent(\n content,\n 'public',\n file.split('/').pop() || 'Unknown'\n );\n tasks.push(...fileTasks);\n }\n } catch {\n // File doesn't exist or not accessible, skip\n }\n \n if (tasks.length >= maxTasks) break;\n }\n \n return tasks.slice(0, maxTasks);\n}\n\n/**\n * Enhanced async scanner that includes all sources\n */\nexport async function scanForTasksAsync(options: ScanOptions = {}): Promise<ClientTask[]> {\n const allTasks: ClientTask[] = [];\n const { \n maxTasks = 100,\n includeSourceMaps = false,\n includePublicFiles = false, // Changed to false to avoid 404s in fresh projects\n } = options;\n\n // Get synchronous tasks first\n const syncTasks = scanForTasks(options);\n allTasks.push(...syncTasks);\n\n // Add async sources\n if (includeSourceMaps) {\n const sourceMapTasks = await scanSourceMaps(options);\n allTasks.push(...sourceMapTasks);\n }\n\n if (includePublicFiles) {\n const publicTasks = await scanPublicFiles(options);\n allTasks.push(...publicTasks);\n }\n\n // Remove duplicates\n const uniqueTasks = allTasks.reduce((acc, task) => {\n const key = `${task.type}-${task.content}`;\n if (!acc.has(key)) {\n acc.set(key, task);\n }\n return acc;\n }, new Map<string, ClientTask>());\n\n return Array.from(uniqueTasks.values()).slice(0, maxTasks);\n}\n\n/**\n * Watch for DOM changes and rescan\n */\nexport function watchForChanges(\n callback: (tasks: ClientTask[]) => void,\n options: ScanOptions = {}\n): () => void {\n let timeoutId: NodeJS.Timeout;\n\n const debouncedScan = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(async () => {\n // Use async scanner if async sources are enabled\n const tasks = options.includeSourceMaps || options.includePublicFiles\n ? await scanForTasksAsync(options)\n : scanForTasks(options);\n callback(tasks);\n }, 500);\n };\n\n // Initial scan\n debouncedScan();\n\n // Watch for DOM changes\n const observer = new MutationObserver(debouncedScan);\n observer.observe(document.body, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n\n // Cleanup function\n return () => {\n observer.disconnect();\n clearTimeout(timeoutId);\n };\n}","/**\n * Read-only task panel for zero-config mode\n */\n\nimport React, { useState, useMemo } from 'react';\nimport type { ClientTask } from '../scanner';\nimport { VirtualList } from './VirtualList';\n\nexport interface TaskPanelProps {\n tasks: ClientTask[];\n isScanning?: boolean;\n lastScanTime?: Date | null;\n onRescan?: () => void;\n className?: string;\n}\n\nconst TASK_TYPE_COLORS = {\n todo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',\n fixme: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',\n bug: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',\n hack: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',\n note: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',\n optimize: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',\n refactor: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',\n};\n\nconst PRIORITY_ICONS = {\n high: '🔴',\n medium: '🟡',\n low: '🟢',\n};\n\nexport function TaskPanel({\n tasks,\n isScanning = false,\n lastScanTime,\n onRescan,\n className = '',\n}: TaskPanelProps) {\n const [filter, setFilter] = useState<string>('all');\n const [searchTerm, setSearchTerm] = useState('');\n\n // Filter and search tasks\n const filteredTasks = useMemo(() => {\n return tasks.filter((task) => {\n // Type filter\n if (filter !== 'all' && task.type !== filter) {\n return false;\n }\n\n // Search filter\n if (searchTerm && !task.content.toLowerCase().includes(searchTerm.toLowerCase())) {\n return false;\n }\n\n return true;\n });\n }, [tasks, filter, searchTerm]);\n\n // Count tasks by type\n const taskCounts = useMemo(() => {\n const counts: Record<string, number> = {};\n tasks.forEach((task) => {\n counts[task.type] = (counts[task.type] || 0) + 1;\n });\n return counts;\n }, [tasks]);\n\n const taskTypes = Object.keys(taskCounts);\n\n return (\n <div className={`flex flex-col h-full bg-white dark:bg-gray-900 ${className}`}>\n {/* Header */}\n <div className=\"flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-4\">\n <div className=\"flex items-center justify-between mb-3\">\n <h2 className=\"text-lg font-semibold text-gray-900 dark:text-white\">\n Tasks ({filteredTasks.length})\n </h2>\n <button\n onClick={onRescan}\n disabled={isScanning}\n className=\"px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n {isScanning ? 'Scanning...' : 'Rescan'}\n </button>\n </div>\n\n {/* Search */}\n <div className=\"mb-3\">\n <input\n type=\"text\"\n placeholder=\"Search tasks...\"\n value={searchTerm}\n onChange={(e) => setSearchTerm(e.target.value)}\n className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500\"\n />\n </div>\n\n {/* Filter tabs */}\n <div className=\"flex gap-2 overflow-x-auto\">\n <button\n onClick={() => setFilter('all')}\n className={`px-3 py-1 text-sm rounded whitespace-nowrap ${\n filter === 'all'\n ? 'bg-blue-500 text-white'\n : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'\n }`}\n >\n All ({tasks.length})\n </button>\n {taskTypes.map((type) => (\n <button\n key={type}\n onClick={() => setFilter(type)}\n className={`px-3 py-1 text-sm rounded whitespace-nowrap ${\n filter === type\n ? 'bg-blue-500 text-white'\n : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'\n }`}\n >\n {type.toUpperCase()} ({taskCounts[type]})\n </button>\n ))}\n </div>\n </div>\n\n {/* Task list */}\n <div className=\"flex-1 overflow-y-auto\">\n {filteredTasks.length === 0 ? (\n <div className=\"text-center py-8 text-gray-500 dark:text-gray-400\">\n {searchTerm || filter !== 'all'\n ? 'No tasks match your filters'\n : 'No tasks found. Tasks will appear here when detected in your code.'}\n </div>\n ) : (\n <VirtualList\n items={filteredTasks}\n height={600}\n itemHeight={120}\n overscan={3}\n renderItem={(task, index) => (\n <div className=\"px-4 py-2\">\n <TaskCard task={task} />\n </div>\n )}\n className=\"w-full\"\n emptyMessage=\"No tasks found. Tasks will appear here when detected in your code.\"\n />\n )}\n </div>\n\n {/* Footer */}\n {lastScanTime && (\n <div className=\"flex-shrink-0 border-t border-gray-200 dark:border-gray-700 px-4 py-2 text-xs text-gray-500 dark:text-gray-400\">\n Last scan: {lastScanTime.toLocaleTimeString()}\n </div>\n )}\n </div>\n );\n}\n\nfunction TaskCard({ task }: { task: ClientTask }) {\n const typeColor = TASK_TYPE_COLORS[task.type as keyof typeof TASK_TYPE_COLORS] || TASK_TYPE_COLORS.note;\n const priorityIcon = PRIORITY_ICONS[task.priority];\n\n return (\n <div className=\"p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700\">\n <div className=\"flex items-start gap-3\">\n <span className=\"text-lg\" title={`Priority: ${task.priority}`}>\n {priorityIcon}\n </span>\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 mb-1\">\n <span className={`px-2 py-0.5 text-xs font-medium rounded ${typeColor}`}>\n {task.type.toUpperCase()}\n </span>\n <span className=\"text-xs text-gray-500 dark:text-gray-400\">\n {task.file}\n </span>\n </div>\n <p className=\"text-sm text-gray-900 dark:text-white break-words\">\n {task.content}\n </p>\n <div className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n Source: {task.source}\n </div>\n </div>\n </div>\n </div>\n );\n}","/**\n * Virtual list component for performance optimization\n * Efficiently renders large task lists by only rendering visible items\n */\n\nimport React, { useRef, useState, useEffect, useCallback } from 'react';\n\nexport interface VirtualListProps<T> {\n items: T[];\n height: number;\n itemHeight: number | ((index: number) => number);\n overscan?: number;\n renderItem: (item: T, index: number) => React.ReactNode;\n className?: string;\n onScroll?: (scrollTop: number) => void;\n emptyMessage?: string;\n}\n\nexport function VirtualList<T>({\n items,\n height,\n itemHeight,\n overscan = 5,\n renderItem,\n className = '',\n onScroll,\n emptyMessage = 'No items to display',\n}: VirtualListProps<T>) {\n const containerRef = useRef<HTMLDivElement>(null);\n const [scrollTop, setScrollTop] = useState(0);\n const [isScrolling, setIsScrolling] = useState(false);\n const scrollTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);\n\n // Calculate item heights\n const getItemHeight = useCallback(\n (index: number) => {\n return typeof itemHeight === 'function' ? itemHeight(index) : itemHeight;\n },\n [itemHeight]\n );\n\n // Calculate total height\n const totalHeight = items.reduce((acc, _, index) => {\n return acc + getItemHeight(index);\n }, 0);\n\n // Calculate visible range\n const getVisibleRange = useCallback(() => {\n let accumulatedHeight = 0;\n let startIndex = 0;\n let endIndex = items.length - 1;\n\n // Find start index\n for (let i = 0; i < items.length; i++) {\n const itemHeight = getItemHeight(i);\n if (accumulatedHeight + itemHeight > scrollTop) {\n startIndex = Math.max(0, i - overscan);\n break;\n }\n accumulatedHeight += itemHeight;\n }\n\n // Find end index\n accumulatedHeight = 0;\n for (let i = startIndex; i < items.length; i++) {\n if (accumulatedHeight > scrollTop + height) {\n endIndex = Math.min(items.length - 1, i + overscan);\n break;\n }\n accumulatedHeight += getItemHeight(i);\n }\n\n return { startIndex, endIndex };\n }, [items.length, scrollTop, height, overscan, getItemHeight]);\n\n // Calculate offset for visible items\n const getItemOffset = useCallback(\n (index: number) => {\n let offset = 0;\n for (let i = 0; i < index; i++) {\n offset += getItemHeight(i);\n }\n return offset;\n },\n [getItemHeight]\n );\n\n const handleScroll = useCallback(\n (e: React.UIEvent<HTMLDivElement>) => {\n const newScrollTop = e.currentTarget.scrollTop;\n setScrollTop(newScrollTop);\n setIsScrolling(true);\n \n // Clear existing timeout\n if (scrollTimeoutRef.current) {\n clearTimeout(scrollTimeoutRef.current);\n }\n \n // Set scrolling to false after scroll ends\n scrollTimeoutRef.current = setTimeout(() => {\n setIsScrolling(false);\n }, 150);\n \n onScroll?.(newScrollTop);\n },\n [onScroll]\n );\n\n // Cleanup timeout on unmount\n useEffect(() => {\n return () => {\n if (scrollTimeoutRef.current) {\n clearTimeout(scrollTimeoutRef.current);\n }\n };\n }, []);\n\n if (items.length === 0) {\n return (\n <div\n className={`flex items-center justify-center text-gray-500 dark:text-gray-400 ${className}`}\n style={{ height }}\n >\n {emptyMessage}\n </div>\n );\n }\n\n const { startIndex, endIndex } = getVisibleRange();\n const visibleItems = items.slice(startIndex, endIndex + 1);\n\n return (\n <div\n ref={containerRef}\n className={`overflow-auto ${className}`}\n style={{ height }}\n onScroll={handleScroll}\n >\n {/* Total height container */}\n <div style={{ height: totalHeight, position: 'relative' }}>\n {/* Visible items */}\n {visibleItems.map((item, index) => {\n const actualIndex = startIndex + index;\n const offset = getItemOffset(actualIndex);\n const itemHeight = getItemHeight(actualIndex);\n\n return (\n <div\n key={actualIndex}\n style={{\n position: 'absolute',\n top: offset,\n left: 0,\n right: 0,\n height: itemHeight,\n // Reduce opacity while scrolling for better performance\n opacity: isScrolling ? 0.8 : 1,\n transition: 'opacity 0.2s',\n }}\n >\n {renderItem(item, actualIndex)}\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n\n/**\n * Hook to use with VirtualList for dynamic item heights\n */\nexport function useVirtualListDynamicHeights<T>(\n items: T[],\n estimatedItemHeight: number = 60\n) {\n const heightsRef = useRef<Map<number, number>>(new Map());\n const [, forceUpdate] = useState({});\n\n const measureItem = useCallback((index: number, element: HTMLElement | null) => {\n if (!element) return;\n \n const height = element.getBoundingClientRect().height;\n const currentHeight = heightsRef.current.get(index);\n \n if (currentHeight !== height) {\n heightsRef.current.set(index, height);\n forceUpdate({});\n }\n }, []);\n\n const getItemHeight = useCallback(\n (index: number) => {\n return heightsRef.current.get(index) || estimatedItemHeight;\n },\n [estimatedItemHeight]\n );\n\n const resetHeights = useCallback(() => {\n heightsRef.current.clear();\n forceUpdate({});\n }, []);\n\n return {\n getItemHeight,\n measureItem,\n resetHeights,\n };\n}","/**\n * Upgrade prompt component for progressive enhancement\n */\n\nimport React, { useState } from 'react';\n\nexport interface UpgradeLevel {\n level: number;\n name: string;\n description: string;\n features: string[];\n setupTime?: string;\n setupSteps?: string[];\n}\n\nexport interface UpgradePromptProps {\n currentLevel: number;\n nextLevel: UpgradeLevel;\n onUpgrade?: () => void;\n onDismiss?: () => void;\n onLearnMore?: () => void;\n}\n\nexport function UpgradePrompt({\n currentLevel,\n nextLevel,\n onUpgrade,\n onDismiss,\n onLearnMore,\n}: UpgradePromptProps) {\n const [isExpanded, setIsExpanded] = useState(false);\n\n return (\n <div className=\"fixed bottom-4 right-4 max-w-md bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden\">\n <div className=\"p-4\">\n <div className=\"flex items-start justify-between mb-3\">\n <div className=\"flex-1\">\n <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">\n Unlock {nextLevel.name}\n </h3>\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mt-1\">\n {nextLevel.description}\n </p>\n </div>\n {onDismiss && (\n <button\n onClick={onDismiss}\n className=\"ml-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300\"\n aria-label=\"Dismiss\"\n >\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n )}\n </div>\n\n {/* Features list */}\n <div className=\"mb-4\">\n <h4 className=\"text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\n New features:\n </h4>\n <ul className=\"space-y-1\">\n {nextLevel.features.map((feature, index) => (\n <li key={index} className=\"flex items-center text-sm text-gray-600 dark:text-gray-400\">\n <svg className=\"w-4 h-4 mr-2 text-green-500\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\n </svg>\n {feature}\n </li>\n ))}\n </ul>\n </div>\n\n {/* Setup time */}\n {nextLevel.setupTime && (\n <div className=\"mb-4 flex items-center text-sm text-gray-600 dark:text-gray-400\">\n <svg className=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n </svg>\n Setup time: {nextLevel.setupTime}\n </div>\n )}\n\n {/* Expandable setup steps */}\n {nextLevel.setupSteps && nextLevel.setupSteps.length > 0 && (\n <div className=\"mb-4\">\n <button\n onClick={() => setIsExpanded(!isExpanded)}\n className=\"flex items-center text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300\"\n >\n <svg\n className={`w-4 h-4 mr-1 transform transition-transform ${isExpanded ? 'rotate-90' : ''}`}\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n </svg>\n {isExpanded ? 'Hide' : 'Show'} setup steps\n </button>\n \n {isExpanded && (\n <ol className=\"mt-2 space-y-1 list-decimal list-inside\">\n {nextLevel.setupSteps.map((step, index) => (\n <li key={index} className=\"text-sm text-gray-600 dark:text-gray-400\">\n {step}\n </li>\n ))}\n </ol>\n )}\n </div>\n )}\n\n {/* Action buttons */}\n <div className=\"flex gap-3\">\n {onUpgrade && (\n <button\n onClick={onUpgrade}\n className=\"flex-1 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500\"\n >\n Upgrade Now\n </button>\n )}\n {onLearnMore && (\n <button\n onClick={onLearnMore}\n className=\"flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500\"\n >\n Learn More\n </button>\n )}\n {!onUpgrade && !onLearnMore && onDismiss && (\n <button\n onClick={onDismiss}\n className=\"flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500\"\n >\n Maybe Later\n </button>\n )}\n </div>\n </div>\n\n {/* Progress indicator */}\n <div className=\"h-1 bg-gray-200 dark:bg-gray-700\">\n <div\n className=\"h-full bg-blue-500 transition-all duration-300\"\n style={{ width: `${((currentLevel + 1) / 4) * 100}%` }}\n />\n </div>\n </div>\n );\n}\n\n/**\n * Mini upgrade banner for less intrusive prompting\n */\nexport interface UpgradeBannerProps {\n message: string;\n actionText?: string;\n onAction?: () => void;\n onDismiss?: () => void;\n}\n\nexport function UpgradeBanner({\n message,\n actionText = 'Upgrade',\n onAction,\n onDismiss,\n}: UpgradeBannerProps) {\n return (\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800 px-4 py-2\">\n <div className=\"flex items-center justify-between\">\n <p className=\"text-sm text-blue-800 dark:text-blue-200\">\n {message}\n </p>\n <div className=\"flex items-center gap-2\">\n {onAction && (\n <button\n onClick={onAction}\n className=\"px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600\"\n >\n {actionText}\n </button>\n )}\n {onDismiss && (\n <button\n onClick={onDismiss}\n className=\"text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300\"\n aria-label=\"Dismiss\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n )}\n </div>\n </div>\n </div>\n );\n}","/**\n * Upgrade wizard component for zero-config mode\n * Guides users through setting up full features\n */\n\nimport React, { useState, useEffect } from 'react';\n\nexport interface UpgradeStep {\n title: string;\n description?: string;\n command?: string;\n code?: string;\n time?: string;\n action?: () => void | Promise<void>;\n}\n\nexport interface UpgradeWizardProps {\n steps: UpgradeStep[];\n onComplete?: () => void;\n onCancel?: () => void;\n title?: string;\n className?: string;\n}\n\nexport function UpgradeWizard({\n steps,\n onComplete,\n onCancel,\n title = 'Enable Full Features',\n className = '',\n}: UpgradeWizardProps) {\n const [currentStep, setCurrentStep] = useState(0);\n const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());\n const [isRunning, setIsRunning] = useState(false);\n const [copiedCommand, setCopiedCommand] = useState<string | null>(null);\n\n const currentStepData = steps[currentStep];\n const isLastStep = currentStep === steps.length - 1;\n const allStepsCompleted = completedSteps.size === steps.length;\n\n const handleStepComplete = async () => {\n setCompletedSteps(prev => new Set([...prev, currentStep]));\n \n if (currentStepData.action) {\n setIsRunning(true);\n try {\n await currentStepData.action();\n } finally {\n setIsRunning(false);\n }\n }\n\n if (isLastStep) {\n onComplete?.();\n } else {\n setCurrentStep(prev => prev + 1);\n }\n };\n\n const handleCopyCommand = (command: string) => {\n navigator.clipboard.writeText(command);\n setCopiedCommand(command);\n setTimeout(() => setCopiedCommand(null), 2000);\n };\n\n return (\n <div className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 ${className}`}>\n <div className=\"w-full max-w-2xl bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden\">\n {/* Header */}\n <div className=\"px-6 py-4 border-b border-gray-200 dark:border-gray-700\">\n <div className=\"flex items-center justify-between\">\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white\">\n {title}\n </h2>\n {onCancel && (\n <button\n onClick={onCancel}\n className=\"text-gray-400 hover:text-gray-600 dark:hover:text-g