-
-
+
+
+
+
Prompt Templates
+
+ Choose the AI prompt that best fits your workflow and project requirements
+
-
-
- Prompt Library
-
-
- Choose a prompt from the library to use as the system prompt
+
+
+
+ {PromptLibrary.getInfoList().map((prompt, index) => (
+
+ {
+ setPromptId(id);
+ toast.success(`Switched to ${prompt.label} template`);
+ }}
+ className="h-full"
+ />
+
+ ))}
+
+
+
+ {/* Custom Prompt Management Section */}
+
+
+
+
+
Custom Prompts
+
+ Create and manage your own custom AI assistant prompts for specialized workflows
-
{
- setPromptId(e.target.value);
- toast.success('Prompt template updated');
- }}
- className={classNames(
- 'p-2 rounded-lg text-sm min-w-[200px]',
- 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
- 'text-bolt-elements-textPrimary',
- 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
- 'group-hover:border-purple-500/30',
- 'transition-all duration-200',
- )}
- >
- {PromptLibrary.getList().map((x) => (
-
- {x.label}
-
- ))}
-
+
+
);
diff --git a/app/components/@settings/tabs/github/GitHubTab.tsx b/app/components/@settings/tabs/github/GitHubTab.tsx
new file mode 100644
index 0000000000..b619fb5f02
--- /dev/null
+++ b/app/components/@settings/tabs/github/GitHubTab.tsx
@@ -0,0 +1,281 @@
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { useGitHubConnection, useGitHubStats } from '~/lib/hooks';
+import { LoadingState, ErrorState, ConnectionTestIndicator, RepositoryCard } from './components/shared';
+import { GitHubConnection } from './components/GitHubConnection';
+import { GitHubUserProfile } from './components/GitHubUserProfile';
+import { GitHubStats } from './components/GitHubStats';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import { classNames } from '~/utils/classNames';
+import { ChevronDown } from 'lucide-react';
+import { GitHubErrorBoundary } from './components/GitHubErrorBoundary';
+import { GitHubProgressiveLoader } from './components/GitHubProgressiveLoader';
+import { GitHubCacheManager } from './components/GitHubCacheManager';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+// GitHub logo SVG component
+const GithubLogo = () => (
+
+
+
+);
+
+export default function GitHubTab() {
+ const { connection, isConnected, isLoading, error, testConnection } = useGitHubConnection();
+ const {
+ stats,
+ isLoading: isStatsLoading,
+ error: statsError,
+ } = useGitHubStats(
+ connection,
+ {
+ autoFetch: true,
+ cacheTimeout: 30 * 60 * 1000, // 30 minutes
+ },
+ isConnected && connection ? !connection.token : false,
+ ); // Use server-side when no token but connected
+
+ const [connectionTest, setConnectionTest] = useState
(null);
+ const [isStatsExpanded, setIsStatsExpanded] = useState(false);
+ const [isReposExpanded, setIsReposExpanded] = useState(false);
+
+ const handleTestConnection = async () => {
+ if (!connection?.user) {
+ setConnectionTest({
+ status: 'error',
+ message: 'No connection established',
+ timestamp: Date.now(),
+ });
+ return;
+ }
+
+ setConnectionTest({
+ status: 'testing',
+ message: 'Testing connection...',
+ });
+
+ try {
+ const isValid = await testConnection();
+
+ if (isValid) {
+ setConnectionTest({
+ status: 'success',
+ message: `Connected successfully as ${connection.user.login}`,
+ timestamp: Date.now(),
+ });
+ } else {
+ setConnectionTest({
+ status: 'error',
+ message: 'Connection test failed',
+ timestamp: Date.now(),
+ });
+ }
+ } catch (error) {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ timestamp: Date.now(),
+ });
+ }
+ };
+
+ // Loading state for initial connection check
+ if (isLoading) {
+ return (
+
+
+
+
GitHub Integration
+
+
+
+ );
+ }
+
+ // Error state for connection issues
+ if (error && !connection) {
+ return (
+
+
+
+
GitHub Integration
+
+
window.location.reload()}
+ retryLabel="Reload Page"
+ />
+
+ );
+ }
+
+ // Not connected state
+ if (!isConnected || !connection) {
+ return (
+
+
+
+
GitHub Integration
+
+
+ Connect your GitHub account to enable advanced repository management features, statistics, and seamless
+ integration.
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ GitHub Integration
+
+
+
+ {connection?.rateLimit && (
+
+
+
+ API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
+
+
+ )}
+
+
+
+
+ Manage your GitHub integration with advanced repository features and comprehensive statistics
+
+
+ {/* Connection Test Results */}
+
+
+ {/* Connection Component */}
+
+
+ {/* User Profile */}
+ {connection.user &&
}
+
+ {/* Stats Section */}
+
+
+ {/* Repositories Section */}
+ {stats?.repos && stats.repos.length > 0 && (
+
+
+
+
+
+
+
+ All Repositories ({stats.repos.length})
+
+
+
+
+
+
+
+
+
+ {(isReposExpanded ? stats.repos : stats.repos.slice(0, 12)).map((repo) => (
+ window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
+ />
+ ))}
+
+
+ {stats.repos.length > 12 && !isReposExpanded && (
+
+ setIsReposExpanded(true)}
+ className="text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
+ >
+ Show {stats.repos.length - 12} more repositories
+
+
+ )}
+
+
+
+
+ )}
+
+ {/* Stats Error State */}
+ {statsError && !stats && (
+
window.location.reload()}
+ retryLabel="Retry"
+ />
+ )}
+
+ {/* Stats Loading State */}
+ {isStatsLoading && !stats && (
+
+
+
+ )}
+
+ {/* Cache Management Section - Only show when connected */}
+ {isConnected && connection && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/GitHubTabRefactored.tsx b/app/components/@settings/tabs/github/GitHubTabRefactored.tsx
new file mode 100644
index 0000000000..9a3bf1002a
--- /dev/null
+++ b/app/components/@settings/tabs/github/GitHubTabRefactored.tsx
@@ -0,0 +1,167 @@
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { useGitHubConnection, useGitHubStats } from '~/lib/hooks';
+import { GitHubConnection } from './components/GitHubConnection';
+import { GitHubUserProfile } from './components/GitHubUserProfile';
+import { GitHubStats } from './components/GitHubStats';
+import { GitHubRepositories } from './components/GitHubRepositories';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+// GitHub logo SVG component
+const GithubLogo = () => (
+
+
+
+);
+
+export default function GitHubTab() {
+ const { connection, isConnected, testConnection } = useGitHubConnection();
+ const { stats } = useGitHubStats(connection, { autoFetch: true });
+
+ const [connectionTest, setConnectionTest] = useState(null);
+ const [isStatsExpanded, setIsStatsExpanded] = useState(false);
+ const [isReposExpanded, setIsReposExpanded] = useState(false);
+
+ const handleTestConnection = async () => {
+ if (!connection?.user) {
+ setConnectionTest({
+ status: 'error',
+ message: 'No connection established',
+ timestamp: Date.now(),
+ });
+ return;
+ }
+
+ setConnectionTest({
+ status: 'testing',
+ message: 'Testing connection...',
+ });
+
+ try {
+ const isValid = await testConnection();
+
+ if (isValid) {
+ setConnectionTest({
+ status: 'success',
+ message: `Connected successfully as ${connection.user.login}`,
+ timestamp: Date.now(),
+ });
+ } else {
+ setConnectionTest({
+ status: 'error',
+ message: 'Connection test failed',
+ timestamp: Date.now(),
+ });
+ }
+ } catch (error) {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ timestamp: Date.now(),
+ });
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ GitHub Integration
+
+
+
+ {connection?.rateLimit && (
+
+
+
+ API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
+
+
+ )}
+
+
+
+
+ Connect and manage your GitHub integration with advanced repository management features
+
+
+ {/* Connection Test Results */}
+ {connectionTest && (
+
+
+ {connectionTest.status === 'success' && (
+
+ )}
+ {connectionTest.status === 'error' && (
+
+ )}
+ {connectionTest.status === 'testing' && (
+
+ )}
+
+ {connectionTest.message}
+
+
+ {connectionTest.timestamp && (
+ {new Date(connectionTest.timestamp).toLocaleString()}
+ )}
+
+ )}
+
+ {/* Connection Component */}
+
+
+ {/* User Profile & Stats */}
+ {isConnected && connection?.user && (
+ <>
+
+
+
+
+ {/* Repositories Section */}
+ {stats?.repos && stats.repos.length > 0 && (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx b/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx
new file mode 100644
index 0000000000..0496929e32
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx
@@ -0,0 +1,367 @@
+import React, { useState, useCallback, useEffect, useMemo } from 'react';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+import { Database, Trash2, RefreshCw, Clock, HardDrive, CheckCircle } from 'lucide-react';
+
+interface CacheEntry {
+ key: string;
+ size: number;
+ timestamp: number;
+ lastAccessed: number;
+ data: any;
+}
+
+interface CacheStats {
+ totalSize: number;
+ totalEntries: number;
+ oldestEntry: number;
+ newestEntry: number;
+ hitRate?: number;
+}
+
+interface GitHubCacheManagerProps {
+ className?: string;
+ showStats?: boolean;
+}
+
+// Cache management utilities
+class CacheManagerService {
+ private static readonly _cachePrefix = 'github_';
+ private static readonly _cacheKeys = [
+ 'github_connection',
+ 'github_stats_cache',
+ 'github_repositories_cache',
+ 'github_user_cache',
+ 'github_rate_limits',
+ ];
+
+ static getCacheEntries(): CacheEntry[] {
+ const entries: CacheEntry[] = [];
+
+ for (const key of this._cacheKeys) {
+ try {
+ const data = localStorage.getItem(key);
+
+ if (data) {
+ const parsed = JSON.parse(data);
+ entries.push({
+ key,
+ size: new Blob([data]).size,
+ timestamp: parsed.timestamp || Date.now(),
+ lastAccessed: parsed.lastAccessed || Date.now(),
+ data: parsed,
+ });
+ }
+ } catch (error) {
+ console.warn(`Failed to parse cache entry: ${key}`, error);
+ }
+ }
+
+ return entries.sort((a, b) => b.lastAccessed - a.lastAccessed);
+ }
+
+ static getCacheStats(): CacheStats {
+ const entries = this.getCacheEntries();
+
+ if (entries.length === 0) {
+ return {
+ totalSize: 0,
+ totalEntries: 0,
+ oldestEntry: 0,
+ newestEntry: 0,
+ };
+ }
+
+ const totalSize = entries.reduce((sum, entry) => sum + entry.size, 0);
+ const timestamps = entries.map((e) => e.timestamp);
+
+ return {
+ totalSize,
+ totalEntries: entries.length,
+ oldestEntry: Math.min(...timestamps),
+ newestEntry: Math.max(...timestamps),
+ };
+ }
+
+ static clearCache(keys?: string[]): void {
+ const keysToRemove = keys || this._cacheKeys;
+
+ for (const key of keysToRemove) {
+ localStorage.removeItem(key);
+ }
+ }
+
+ static clearExpiredCache(maxAge: number = 24 * 60 * 60 * 1000): number {
+ const entries = this.getCacheEntries();
+ const now = Date.now();
+ let removedCount = 0;
+
+ for (const entry of entries) {
+ if (now - entry.timestamp > maxAge) {
+ localStorage.removeItem(entry.key);
+ removedCount++;
+ }
+ }
+
+ return removedCount;
+ }
+
+ static compactCache(): void {
+ const entries = this.getCacheEntries();
+
+ for (const entry of entries) {
+ try {
+ // Re-serialize with minimal data
+ const compacted = {
+ ...entry.data,
+ lastAccessed: Date.now(),
+ };
+ localStorage.setItem(entry.key, JSON.stringify(compacted));
+ } catch (error) {
+ console.warn(`Failed to compact cache entry: ${entry.key}`, error);
+ }
+ }
+ }
+
+ static formatSize(bytes: number): string {
+ if (bytes === 0) {
+ return '0 B';
+ }
+
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+ }
+}
+
+export function GitHubCacheManager({ className = '', showStats = true }: GitHubCacheManagerProps) {
+ const [cacheEntries, setCacheEntries] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [lastClearTime, setLastClearTime] = useState(null);
+
+ const refreshCacheData = useCallback(() => {
+ setCacheEntries(CacheManagerService.getCacheEntries());
+ }, []);
+
+ useEffect(() => {
+ refreshCacheData();
+ }, [refreshCacheData]);
+
+ const cacheStats = useMemo(() => CacheManagerService.getCacheStats(), [cacheEntries]);
+
+ const handleClearAll = useCallback(async () => {
+ setIsLoading(true);
+
+ try {
+ CacheManagerService.clearCache();
+ setLastClearTime(Date.now());
+ refreshCacheData();
+
+ // Trigger a page refresh to update all components
+ setTimeout(() => {
+ window.location.reload();
+ }, 1000);
+ } catch (error) {
+ console.error('Failed to clear cache:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [refreshCacheData]);
+
+ const handleClearExpired = useCallback(() => {
+ setIsLoading(true);
+
+ try {
+ const removedCount = CacheManagerService.clearExpiredCache();
+ refreshCacheData();
+
+ if (removedCount > 0) {
+ // Show success message or trigger update
+ console.log(`Removed ${removedCount} expired cache entries`);
+ }
+ } catch (error) {
+ console.error('Failed to clear expired cache:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [refreshCacheData]);
+
+ const handleCompactCache = useCallback(() => {
+ setIsLoading(true);
+
+ try {
+ CacheManagerService.compactCache();
+ refreshCacheData();
+ } catch (error) {
+ console.error('Failed to compact cache:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [refreshCacheData]);
+
+ const handleClearSpecific = useCallback(
+ (key: string) => {
+ setIsLoading(true);
+
+ try {
+ CacheManagerService.clearCache([key]);
+ refreshCacheData();
+ } catch (error) {
+ console.error(`Failed to clear cache key: ${key}`, error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [refreshCacheData],
+ );
+
+ if (!showStats && cacheEntries.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
GitHub Cache Management
+
+
+
+
+
+
+
+
+
+ {showStats && (
+
+
+
+
+ Total Size
+
+
+ {CacheManagerService.formatSize(cacheStats.totalSize)}
+
+
+
+
+
+
+ Entries
+
+
{cacheStats.totalEntries}
+
+
+
+
+
+ Oldest
+
+
+ {cacheStats.oldestEntry ? new Date(cacheStats.oldestEntry).toLocaleDateString() : 'N/A'}
+
+
+
+
+
+
+ Status
+
+
+ {cacheStats.totalEntries > 0 ? 'Active' : 'Empty'}
+
+
+
+ )}
+
+ {cacheEntries.length > 0 && (
+
+
+ Cache Entries ({cacheEntries.length})
+
+
+
+ {cacheEntries.map((entry) => (
+
+
+
+ {entry.key.replace('github_', '')}
+
+
+ {CacheManagerService.formatSize(entry.size)} • {new Date(entry.lastAccessed).toLocaleString()}
+
+
+
+
handleClearSpecific(entry.key)}
+ disabled={isLoading}
+ className="ml-2"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ Clear Expired
+
+
+
+
+ Compact
+
+
+ {cacheEntries.length > 0 && (
+
+
+ Clear All
+
+ )}
+
+
+ {lastClearTime && (
+
+
+ Cache cleared successfully at {new Date(lastClearTime).toLocaleTimeString()}
+
+ )}
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubConnection.tsx b/app/components/@settings/tabs/github/components/GitHubConnection.tsx
new file mode 100644
index 0000000000..f82406f3a9
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubConnection.tsx
@@ -0,0 +1,227 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+import { useGitHubConnection } from '~/lib/hooks';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+interface GitHubConnectionProps {
+ connectionTest: ConnectionTestResult | null;
+ onTestConnection: () => void;
+}
+
+export function GitHubConnection({ connectionTest, onTestConnection }: GitHubConnectionProps) {
+ const { isConnected, isLoading, isConnecting, connect, disconnect, error } = useGitHubConnection();
+
+ const [token, setToken] = React.useState('');
+ const [tokenType, setTokenType] = React.useState<'classic' | 'fine-grained'>('classic');
+
+ const handleConnect = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!token.trim()) {
+ return;
+ }
+
+ try {
+ await connect(token, tokenType);
+ setToken(''); // Clear token on successful connection
+ } catch {
+ // Error handling is done in the hook
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading connection...
+
+
+ );
+ }
+
+ return (
+
+
+ {!isConnected && (
+
+
+
+ Tip: You can also set the{' '}
+
+ VITE_GITHUB_ACCESS_TOKEN
+ {' '}
+ environment variable to connect automatically.
+
+
+ For fine-grained tokens, also set{' '}
+
+ VITE_GITHUB_TOKEN_TYPE=fine-grained
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx b/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx
new file mode 100644
index 0000000000..531f682ee3
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx
@@ -0,0 +1,105 @@
+import React, { Component } from 'react';
+import type { ReactNode, ErrorInfo } from 'react';
+import { Button } from '~/components/ui/Button';
+import { AlertTriangle } from 'lucide-react';
+
+interface Props {
+ children: ReactNode;
+ fallback?: ReactNode;
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+export class GitHubErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error('GitHub Error Boundary caught an error:', error, errorInfo);
+
+ if (this.props.onError) {
+ this.props.onError(error, errorInfo);
+ }
+ }
+
+ handleRetry = () => {
+ this.setState({ hasError: false, error: null });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) {
+ return this.props.fallback;
+ }
+
+ return (
+
+
+
+
+
GitHub Integration Error
+
+ Something went wrong while loading GitHub data. This could be due to network issues, API limits, or a
+ temporary problem.
+
+
+ {this.state.error && (
+
+ Show error details
+
+ {this.state.error.message}
+
+
+ )}
+
+
+
+
+ Try Again
+
+ window.location.reload()}>
+ Reload Page
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+// Higher-order component for wrapping components with error boundary
+export function withGitHubErrorBoundary(component: React.ComponentType
) {
+ return function WrappedComponent(props: P) {
+ return {React.createElement(component, props)} ;
+ };
+}
+
+// Hook for handling async errors in GitHub operations
+export function useGitHubErrorHandler() {
+ const handleError = React.useCallback((error: unknown, context?: string) => {
+ console.error(`GitHub Error ${context ? `(${context})` : ''}:`, error);
+
+ /*
+ * You could integrate with error tracking services here
+ * For example: Sentry, LogRocket, etc.
+ */
+
+ return error instanceof Error ? error.message : 'An unknown error occurred';
+ }, []);
+
+ return { handleError };
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx b/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx
new file mode 100644
index 0000000000..7f28ee16e0
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx
@@ -0,0 +1,266 @@
+import React, { useState, useCallback, useMemo } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+import { Loader2, ChevronDown, RefreshCw, AlertCircle, CheckCircle } from 'lucide-react';
+
+interface ProgressiveLoaderProps {
+ isLoading: boolean;
+ isRefreshing?: boolean;
+ error?: string | null;
+ onRetry?: () => void;
+ onRefresh?: () => void;
+ children: React.ReactNode;
+ className?: string;
+ loadingMessage?: string;
+ refreshingMessage?: string;
+ showProgress?: boolean;
+ progressSteps?: Array<{
+ key: string;
+ label: string;
+ completed: boolean;
+ loading?: boolean;
+ error?: boolean;
+ }>;
+}
+
+export function GitHubProgressiveLoader({
+ isLoading,
+ isRefreshing = false,
+ error,
+ onRetry,
+ onRefresh,
+ children,
+ className = '',
+ loadingMessage = 'Loading...',
+ refreshingMessage = 'Refreshing...',
+ showProgress = false,
+ progressSteps = [],
+}: ProgressiveLoaderProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Calculate progress percentage
+ const progress = useMemo(() => {
+ if (!showProgress || progressSteps.length === 0) {
+ return 0;
+ }
+
+ const completed = progressSteps.filter((step) => step.completed).length;
+
+ return Math.round((completed / progressSteps.length) * 100);
+ }, [showProgress, progressSteps]);
+
+ const handleToggleExpanded = useCallback(() => {
+ setIsExpanded((prev) => !prev);
+ }, []);
+
+ // Loading state with progressive steps
+ if (isLoading) {
+ return (
+
+
+
+ {showProgress && progress > 0 && (
+
+ {progress}%
+
+ )}
+
+
+
+
{loadingMessage}
+
+ {showProgress && progressSteps.length > 0 && (
+
+ {/* Progress bar */}
+
+
+
+
+ {/* Steps toggle */}
+
+ Show details
+
+
+
+ {/* Progress steps */}
+
+ {isExpanded && (
+
+ {progressSteps.map((step) => (
+
+ {step.error ? (
+
+ ) : step.completed ? (
+
+ ) : step.loading ? (
+
+ ) : (
+
+ )}
+
+ {step.label}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
+
+
+
Failed to Load
+
{error}
+
+
+
+ {onRetry && (
+
+
+ Try Again
+
+ )}
+ {onRefresh && (
+
+
+ Refresh
+
+ )}
+
+
+ );
+ }
+
+ // Success state - render children with optional refresh indicator
+ return (
+
+ {isRefreshing && (
+
+
+
+ {refreshingMessage}
+
+
+ )}
+
+ {children}
+
+ );
+}
+
+// Hook for managing progressive loading steps
+export function useProgressiveLoader() {
+ const [steps, setSteps] = useState<
+ Array<{
+ key: string;
+ label: string;
+ completed: boolean;
+ loading?: boolean;
+ error?: boolean;
+ }>
+ >([]);
+
+ const addStep = useCallback((key: string, label: string) => {
+ setSteps((prev) => [
+ ...prev.filter((step) => step.key !== key),
+ { key, label, completed: false, loading: false, error: false },
+ ]);
+ }, []);
+
+ const updateStep = useCallback(
+ (
+ key: string,
+ updates: {
+ completed?: boolean;
+ loading?: boolean;
+ error?: boolean;
+ label?: string;
+ },
+ ) => {
+ setSteps((prev) => prev.map((step) => (step.key === key ? { ...step, ...updates } : step)));
+ },
+ [],
+ );
+
+ const removeStep = useCallback((key: string) => {
+ setSteps((prev) => prev.filter((step) => step.key !== key));
+ }, []);
+
+ const clearSteps = useCallback(() => {
+ setSteps([]);
+ }, []);
+
+ const startStep = useCallback(
+ (key: string) => {
+ updateStep(key, { loading: true, error: false });
+ },
+ [updateStep],
+ );
+
+ const completeStep = useCallback(
+ (key: string) => {
+ updateStep(key, { completed: true, loading: false, error: false });
+ },
+ [updateStep],
+ );
+
+ const errorStep = useCallback(
+ (key: string) => {
+ updateStep(key, { error: true, loading: false });
+ },
+ [updateStep],
+ );
+
+ return {
+ steps,
+ addStep,
+ updateStep,
+ removeStep,
+ clearSteps,
+ startStep,
+ completeStep,
+ errorStep,
+ };
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubRepositories.tsx b/app/components/@settings/tabs/github/components/GitHubRepositories.tsx
new file mode 100644
index 0000000000..b2fc4b51a9
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubRepositories.tsx
@@ -0,0 +1,258 @@
+import React from 'react';
+import type { GitHubRepoInfo } from '~/types/GitHub';
+
+interface GitHubRepositoriesProps {
+ repositories: GitHubRepoInfo[];
+ isExpanded: boolean;
+ onToggleExpanded: (expanded: boolean) => void;
+}
+
+export function GitHubRepositories({ repositories, isExpanded, onToggleExpanded }: GitHubRepositoriesProps) {
+ if (!repositories || repositories.length === 0) {
+ return null;
+ }
+
+ const displayedRepos = isExpanded ? repositories : repositories.slice(0, 12);
+
+ return (
+
+
+
All Repositories ({repositories.length})
+ {repositories.length > 12 && (
+ onToggleExpanded(!isExpanded)}
+ className="text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
+ >
+ {isExpanded ? 'Show Less' : `Show All ${repositories.length}`}
+
+ )}
+
+
+ {displayedRepos.map((repo) => (
+
+ ))}
+
+
+ );
+}
+
+interface RepositoryCardProps {
+ repo: GitHubRepoInfo;
+}
+
+function RepositoryCard({ repo }: RepositoryCardProps) {
+ const daysSinceUpdate = Math.floor((Date.now() - new Date(repo.updated_at).getTime()) / (1000 * 60 * 60 * 24));
+
+ const isHealthy = daysSinceUpdate < 30 && !repo.archived && repo.stargazers_count > 0;
+ const isActive = daysSinceUpdate < 7;
+ const healthColor = repo.archived
+ ? 'bg-gray-500'
+ : isActive
+ ? 'bg-green-500'
+ : isHealthy
+ ? 'bg-blue-500'
+ : 'bg-yellow-500';
+ const healthTitle = repo.archived ? 'Archived' : isActive ? 'Very Active' : isHealthy ? 'Healthy' : 'Needs Attention';
+
+ const formatTimeAgo = () => {
+ if (daysSinceUpdate === 0) {
+ return 'Today';
+ }
+
+ if (daysSinceUpdate === 1) {
+ return '1 day ago';
+ }
+
+ if (daysSinceUpdate < 7) {
+ return `${daysSinceUpdate} days ago`;
+ }
+
+ if (daysSinceUpdate < 30) {
+ return `${Math.floor(daysSinceUpdate / 7)} weeks ago`;
+ }
+
+ return new Date(repo.updated_at).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ const calculateHealthScore = () => {
+ const hasStars = repo.stargazers_count > 0;
+ const hasRecentActivity = daysSinceUpdate < 30;
+ const hasContributors = (repo.contributors_count || 0) > 1;
+ const hasDescription = !!repo.description;
+ const hasTopics = (repo.topics || []).length > 0;
+ const hasLicense = !!repo.license;
+
+ const healthScore = [hasStars, hasRecentActivity, hasContributors, hasDescription, hasTopics, hasLicense].filter(
+ Boolean,
+ ).length;
+
+ const maxScore = 6;
+ const percentage = Math.round((healthScore / maxScore) * 100);
+
+ const getScoreColor = (score: number) => {
+ if (score >= 5) {
+ return 'text-green-500';
+ }
+
+ if (score >= 3) {
+ return 'text-yellow-500';
+ }
+
+ return 'text-red-500';
+ };
+
+ return {
+ percentage,
+ color: getScoreColor(healthScore),
+ score: healthScore,
+ maxScore,
+ };
+ };
+
+ const health = calculateHealthScore();
+
+ return (
+
+ {/* Repository Health Indicator */}
+
+
+
+
+
+
+
+ {repo.name}
+
+ {repo.fork && (
+
+ )}
+ {repo.archived && (
+
+ )}
+
+
+
+
+ {repo.stargazers_count.toLocaleString()}
+
+
+
+ {repo.forks_count.toLocaleString()}
+
+ {repo.issues_count !== undefined && (
+
+
+ {repo.issues_count}
+
+ )}
+ {repo.pull_requests_count !== undefined && (
+
+
+ {repo.pull_requests_count}
+
+ )}
+
+
+
+
+ {repo.description && (
+
{repo.description}
+ )}
+
+ {/* Repository metrics bar */}
+
+ {repo.license && (
+
+ {repo.license.spdx_id || repo.license.name}
+
+ )}
+ {repo.topics &&
+ repo.topics.slice(0, 2).map((topic) => (
+
+ {topic}
+
+ ))}
+ {repo.archived && (
+
+ Archived
+
+ )}
+ {repo.fork && (
+
+ Fork
+
+ )}
+
+
+
+
+
+
+
+ {repo.default_branch}
+
+ {repo.branches_count && (
+
+
+ {repo.branches_count}
+
+ )}
+ {repo.contributors_count && (
+
+
+ {repo.contributors_count}
+
+ )}
+ {repo.size && (
+
+
+ {(repo.size / 1024).toFixed(1)}MB
+
+ )}
+
+
+ {formatTimeAgo()}
+
+ {repo.topics && repo.topics.length > 0 && (
+
+
+ {repo.topics.length}
+
+ )}
+
+
+ {/* Repository Health Score */}
+
+
+
+
{health.percentage}%
+
+
+
+
+ View
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubStats.tsx b/app/components/@settings/tabs/github/components/GitHubStats.tsx
new file mode 100644
index 0000000000..4b7d8fbf72
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubStats.tsx
@@ -0,0 +1,291 @@
+import React from 'react';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import { classNames } from '~/utils/classNames';
+import { useGitHubStats } from '~/lib/hooks';
+import type { GitHubConnection, GitHubStats as GitHubStatsType } from '~/types/GitHub';
+import { GitHubErrorBoundary } from './GitHubErrorBoundary';
+
+interface GitHubStatsProps {
+ connection: GitHubConnection;
+ isExpanded: boolean;
+ onToggleExpanded: (expanded: boolean) => void;
+}
+
+export function GitHubStats({ connection, isExpanded, onToggleExpanded }: GitHubStatsProps) {
+ const { stats, isLoading, isRefreshing, refreshStats, isStale } = useGitHubStats(
+ connection,
+ {
+ autoFetch: true,
+ cacheTimeout: 30 * 60 * 1000, // 30 minutes
+ },
+ !connection?.token,
+ ); // Use server-side if no token
+
+ return (
+
+
+
+ );
+}
+
+function GitHubStatsContent({
+ stats,
+ isLoading,
+ isRefreshing,
+ refreshStats,
+ isStale,
+ isExpanded,
+ onToggleExpanded,
+}: {
+ stats: GitHubStatsType | null;
+ isLoading: boolean;
+ isRefreshing: boolean;
+ refreshStats: () => Promise;
+ isStale: boolean;
+ isExpanded: boolean;
+ onToggleExpanded: (expanded: boolean) => void;
+}) {
+ if (!stats) {
+ return (
+
+
+
+ {isLoading ? (
+ <>
+
+
Loading GitHub stats...
+ >
+ ) : (
+
No stats available
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ GitHub Stats
+ {isStale && (Stale) }
+
+
+
+
{
+ e.stopPropagation();
+ refreshStats();
+ }}
+ disabled={isRefreshing}
+ variant="outline"
+ size="sm"
+ className="text-xs"
+ >
+ {isRefreshing ? (
+ <>
+
+ Refreshing...
+ >
+ ) : (
+ <>
+
+ Refresh
+ >
+ )}
+
+
+
+
+
+
+
+
+ {/* Languages Section */}
+
+
Top Languages
+ {stats.mostUsedLanguages && stats.mostUsedLanguages.length > 0 ? (
+
+
+ {stats.mostUsedLanguages.slice(0, 15).map(({ language, bytes, repos }) => (
+
+ {language} ({repos})
+
+ ))}
+
+
+ Based on actual codebase size across repositories
+
+
+ ) : (
+
+ {Object.entries(stats.languages)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 5)
+ .map(([language]) => (
+
+ {language}
+
+ ))}
+
+ )}
+
+
+ {/* GitHub Overview Summary */}
+
+
GitHub Overview
+
+
+
+ {(stats.publicRepos || 0) + (stats.privateRepos || 0)}
+
+
Total Repositories
+
+
+
{stats.totalBranches || 0}
+
Total Branches
+
+
+
+ {stats.organizations?.length || 0}
+
+
Organizations
+
+
+
+ {Object.keys(stats.languages).length}
+
+
Languages Used
+
+
+
+
+ {/* Activity Summary */}
+
+
Activity Summary
+
+ {[
+ {
+ label: 'Total Branches',
+ value: stats.totalBranches || 0,
+ icon: 'i-ph:git-branch',
+ iconColor: 'text-bolt-elements-icon-info',
+ },
+ {
+ label: 'Contributors',
+ value: stats.totalContributors || 0,
+ icon: 'i-ph:users',
+ iconColor: 'text-bolt-elements-icon-success',
+ },
+ {
+ label: 'Issues',
+ value: stats.totalIssues || 0,
+ icon: 'i-ph:circle',
+ iconColor: 'text-bolt-elements-icon-warning',
+ },
+ {
+ label: 'Pull Requests',
+ value: stats.totalPullRequests || 0,
+ icon: 'i-ph:git-pull-request',
+ iconColor: 'text-bolt-elements-icon-accent',
+ },
+ ].map((stat, index) => (
+
+
{stat.label}
+
+
+ {stat.value.toLocaleString()}
+
+
+ ))}
+
+
+
+ {/* Organizations Section */}
+ {stats.organizations && stats.organizations.length > 0 && (
+
+ )}
+
+ {/* Last Updated */}
+
+
+ Last updated: {stats.lastUpdated ? new Date(stats.lastUpdated).toLocaleString() : 'Never'}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx b/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx
new file mode 100644
index 0000000000..fd56860080
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import type { GitHubUserResponse } from '~/types/GitHub';
+
+interface GitHubUserProfileProps {
+ user: GitHubUserResponse;
+ className?: string;
+}
+
+export function GitHubUserProfile({ user, className = '' }: GitHubUserProfileProps) {
+ return (
+
+
+
+
+ {user.name || user.login}
+
+
@{user.login}
+ {user.bio && (
+
+ {user.bio}
+
+ )}
+
+
+
+ {user.followers} followers
+
+
+
+ {user.public_repos} public repos
+
+
+
+ {user.public_gists} gists
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/NewRepositoryForm.tsx b/app/components/@settings/tabs/github/components/NewRepositoryForm.tsx
new file mode 100644
index 0000000000..a827beb9de
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/NewRepositoryForm.tsx
@@ -0,0 +1,201 @@
+import React from 'react';
+import { Input } from '~/components/ui';
+import { classNames } from '~/utils/classNames';
+import { Plus, Lock, Globe } from 'lucide-react';
+
+interface NewRepositoryFormProps {
+ repoName: string;
+ onRepoNameChange: (name: string) => void;
+ isPrivate: boolean;
+ onPrivateChange: (isPrivate: boolean) => void;
+ description?: string;
+ onDescriptionChange?: (description: string) => void;
+ isSubmitting?: boolean;
+ onSubmit: (e: React.FormEvent) => void;
+ error?: string;
+ className?: string;
+}
+
+export function NewRepositoryForm({
+ repoName,
+ onRepoNameChange,
+ isPrivate,
+ onPrivateChange,
+ description,
+ onDescriptionChange,
+ isSubmitting = false,
+ onSubmit,
+ error,
+ className = '',
+}: NewRepositoryFormProps) {
+ /*
+ * Validation function for future use
+ * const isValidRepoName = (name: string) => {
+ * if (!name) {
+ * return false;
+ * }
+ * if (name.length < 1 || name.length > 100) {
+ * return false;
+ * }
+ * // GitHub repository name validation
+ * return /^[a-zA-Z0-9._-]+$/.test(name);
+ * };
+ */
+
+ const validateRepoName = (name: string) => {
+ if (!name) {
+ return 'Repository name is required';
+ }
+
+ if (name.length < 1) {
+ return 'Repository name cannot be empty';
+ }
+
+ if (name.length > 100) {
+ return 'Repository name cannot exceed 100 characters';
+ }
+
+ if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
+ return 'Repository name can only contain alphanumeric characters, periods, hyphens, and underscores';
+ }
+
+ if (name.startsWith('.') || name.endsWith('.')) {
+ return 'Repository name cannot start or end with a period';
+ }
+
+ if (name.startsWith('-') || name.endsWith('-')) {
+ return 'Repository name cannot start or end with a hyphen';
+ }
+
+ return null;
+ };
+
+ const nameError = validateRepoName(repoName);
+ const canSubmit = !nameError && repoName.trim() !== '' && !isSubmitting;
+
+ return (
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/PushSuccessDialog.tsx b/app/components/@settings/tabs/github/components/PushSuccessDialog.tsx
new file mode 100644
index 0000000000..e471e9e524
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/PushSuccessDialog.tsx
@@ -0,0 +1,158 @@
+import React from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { motion } from 'framer-motion';
+import { Button } from '~/components/ui/Button';
+import { formatSize } from '~/utils/formatSize';
+import { CheckCircle, ExternalLink, Copy, Files } from 'lucide-react';
+import { toast } from 'react-toastify';
+
+interface PushedFile {
+ path: string;
+ size: number;
+}
+
+interface PushSuccessDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ repositoryUrl: string;
+ repositoryName?: string;
+ pushedFiles: PushedFile[];
+}
+
+export function PushSuccessDialog({
+ isOpen,
+ onClose,
+ repositoryUrl,
+ repositoryName,
+ pushedFiles,
+}: PushSuccessDialogProps) {
+ const totalFiles = pushedFiles.length;
+ const totalSize = pushedFiles.reduce((sum, file) => sum + file.size, 0);
+
+ const handleCopyUrl = async () => {
+ try {
+ await navigator.clipboard.writeText(repositoryUrl);
+ toast.success('Repository URL copied to clipboard');
+ } catch (error) {
+ console.error('Failed to copy URL:', error);
+ toast.error('Failed to copy URL');
+ }
+ };
+
+ const handleOpenRepository = () => {
+ window.open(repositoryUrl, '_blank', 'noopener,noreferrer');
+ };
+
+ return (
+ !open && onClose()}>
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Successfully Pushed to GitHub!
+
+
+ Your project has been pushed to{' '}
+ {repositoryName && {repositoryName} }
+
+
+
+
+
+ {/* Repository URL */}
+
+
+
+ {repositoryUrl}
+
+
+
+
+
+
+ {/* Statistics */}
+
+
+
+
+ Push Summary
+
+
+
+
{totalFiles}
+
Files Pushed
+
+
+
{formatSize(totalSize)}
+
Total Size
+
+
+
+
+
+ {/* File List */}
+ {pushedFiles.length > 0 && (
+
+
+ Pushed Files ({pushedFiles.length})
+
+
+ {pushedFiles.slice(0, 10).map((file, index) => (
+
+
+ {file.path}
+
+
+ {formatSize(file.size)}
+
+
+ ))}
+ {pushedFiles.length > 10 && (
+
+ ... and {pushedFiles.length - 10} more files
+
+ )}
+
+
+ )}
+
+ {/* Actions */}
+
+
+
+ Close
+
+
+
+ View Repository
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/PushToGitHubDialogRefactored.tsx b/app/components/@settings/tabs/github/components/PushToGitHubDialogRefactored.tsx
new file mode 100644
index 0000000000..f932c96701
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/PushToGitHubDialogRefactored.tsx
@@ -0,0 +1,263 @@
+import React, { useState, useEffect } from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { motion } from 'framer-motion';
+import { X, Github, Plus, Folder } from 'lucide-react';
+import { useGitHubConnection, useRepositoryPush } from '~/lib/hooks';
+import { RepositorySelector } from './RepositorySelector';
+import { NewRepositoryForm } from './NewRepositoryForm';
+import { PushSuccessDialog } from './PushSuccessDialog';
+import { classNames } from '~/utils/classNames';
+import type { GitHubRepoInfo } from '~/types/GitHub';
+
+interface PushToGitHubDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onPush?: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise;
+}
+
+type TabType = 'existing' | 'new';
+
+export function PushToGitHubDialogRefactored({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
+ const { connection, isConnected } = useGitHubConnection();
+ const {
+ recentRepos,
+ filteredRepos,
+ isFetchingRepos,
+ isLoading,
+ error,
+ showSuccessDialog,
+ createdRepoUrl,
+ pushedFiles,
+ pushToRepository,
+ pushToExistingRepository,
+ filterRepos,
+ closeSuccessDialog,
+ resetState,
+ } = useRepositoryPush(connection);
+
+ const [activeTab, setActiveTab] = useState('existing');
+ const [repoName, setRepoName] = useState('');
+ const [description, setDescription] = useState('');
+ const [isPrivate, setIsPrivate] = useState(false);
+
+ // Reset form when dialog closes
+ useEffect(() => {
+ if (!isOpen) {
+ setRepoName('');
+ setDescription('');
+ setIsPrivate(false);
+ resetState();
+ }
+ }, [isOpen, resetState]);
+
+ // Switch to new repo tab if no repositories available
+ useEffect(() => {
+ if (!isFetchingRepos && recentRepos.length === 0 && activeTab === 'existing') {
+ setActiveTab('new');
+ }
+ }, [isFetchingRepos, recentRepos.length, activeTab]);
+
+ const handleCreateRepository = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!repoName.trim()) {
+ return;
+ }
+
+ try {
+ await pushToRepository({
+ repoName: repoName.trim(),
+ isPrivate,
+ description: description.trim() || undefined,
+ });
+
+ // Call the optional onPush callback for backward compatibility
+ if (onPush) {
+ await onPush(repoName, connection?.user?.login, connection?.token, isPrivate);
+ }
+ } catch {
+ // Error handling is done in the hook
+ }
+ };
+
+ const handleExistingRepoSelect = async (repo: GitHubRepoInfo) => {
+ try {
+ await pushToExistingRepository(repo);
+
+ // Call the optional onPush callback for backward compatibility
+ if (onPush) {
+ await onPush(repo.name, connection?.user?.login, connection?.token, repo.private);
+ }
+ } catch {
+ // Error handling is done in the hook
+ }
+ };
+
+ const handleDialogClose = () => {
+ if (!isLoading) {
+ onClose();
+ }
+ };
+
+ if (!isConnected || !connection) {
+ return (
+
+
+
+
+
+
+
+
GitHub Connection Required
+
+ Please connect your GitHub account to push repositories.
+
+
+ Close
+
+
+
+
+
+
+ );
+ }
+
+ const repositoryName = createdRepoUrl ? createdRepoUrl.split('/').pop() : undefined;
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+ Push to GitHub
+
+
+ Push your project to a GitHub repository
+
+
+
+
+
+
+
+
+
+
+ {/* Tab Navigation */}
+
+
setActiveTab('existing')}
+ className={classNames(
+ 'flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors',
+ activeTab === 'existing'
+ ? 'text-bolt-elements-item-contentAccent border-b-2 border-bolt-elements-item-contentAccent bg-bolt-elements-item-contentAccent/5'
+ : 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary',
+ )}
+ disabled={isLoading}
+ >
+
+ Existing Repository
+ {recentRepos.length > 0 && (
+
+ {recentRepos.length}
+
+ )}
+
+
setActiveTab('new')}
+ className={classNames(
+ 'flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors',
+ activeTab === 'new'
+ ? 'text-bolt-elements-item-contentAccent border-b-2 border-bolt-elements-item-contentAccent bg-bolt-elements-item-contentAccent/5'
+ : 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary',
+ )}
+ disabled={isLoading}
+ >
+
+ New Repository
+
+
+
+ {/* Content */}
+
+
+ {activeTab === 'existing' && (
+
+ )}
+
+ {activeTab === 'new' && (
+
+ )}
+
+
+
+ {/* Footer info */}
+
+
+
+ Connected as {connection.user?.login}
+ {connection.rateLimit && (
+ <>
+ •
+
+ API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ {/* Success Dialog */}
+
+ >
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/RepositorySelectionDialogRefactored.tsx b/app/components/@settings/tabs/github/components/RepositorySelectionDialogRefactored.tsx
new file mode 100644
index 0000000000..9fb5d326e6
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/RepositorySelectionDialogRefactored.tsx
@@ -0,0 +1,363 @@
+import React from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { motion } from 'framer-motion';
+import { X, Github, Folder, Search, Link, ArrowLeft } from 'lucide-react';
+import { Button } from '~/components/ui/Button';
+import { Input } from '~/components/ui';
+import { useGitHubConnection, useRepositorySearch } from '~/lib/hooks';
+import { RepositorySelector } from './RepositorySelector';
+import { classNames } from '~/utils/classNames';
+import type { GitHubRepoInfo } from '~/types/GitHub';
+
+interface RepositorySelectionDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSelect: (url: string) => void;
+}
+
+export function RepositorySelectionDialogRefactored({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
+ const { connection, isConnected } = useGitHubConnection();
+ const {
+ myRepositories,
+ searchResults,
+ selectedRepository,
+ searchQuery,
+ activeTab,
+ branches,
+ selectedBranch,
+ customUrl,
+ isLoadingRepos,
+ isSearching,
+ isLoadingBranches,
+ error,
+ filteredRepositories,
+ canSelectRepository,
+ setSearchQuery,
+ setActiveTab,
+ setCustomUrl,
+ setSelectedBranch,
+ selectRepository,
+ searchRepositories,
+ clearSelection,
+ } = useRepositorySearch(connection);
+
+ const handleRepositorySelect = async (repo: GitHubRepoInfo) => {
+ await selectRepository(repo);
+ };
+
+ const handleSearch = async () => {
+ if (searchQuery.trim()) {
+ await searchRepositories();
+ }
+ };
+
+ const handleConfirmSelection = () => {
+ if (activeTab === 'url') {
+ // Convert GitHub web URL to Git clone URL if needed
+ let url = customUrl;
+
+ if (url.includes('github.com') && url.includes('/tree/')) {
+ // Extract owner/repo from URL like: https://github.com/owner/repo/tree/branch
+ const match = url.match(/github\.com\/([^\/]+\/[^\/]+)/);
+
+ if (match) {
+ url = `https://github.com/${match[1]}.git`;
+ }
+ } else if (url.includes('github.com') && !url.endsWith('.git')) {
+ // Add .git suffix if missing
+ url = url.replace(/\/$/, '') + '.git';
+ }
+
+ onSelect(url);
+
+ return;
+ }
+
+ if (selectedRepository && selectedBranch) {
+ console.log('Selected repository data:', selectedRepository);
+ console.log('Selected branch:', selectedBranch);
+
+ // Use clone_url which is the proper Git URL, or construct from html_url as fallback
+ let url = selectedRepository.clone_url;
+
+ if (!url) {
+ // Fallback: construct clone URL from html_url
+ url = selectedRepository.html_url.replace(/\/$/, '') + '.git';
+ console.log('No clone_url found, constructed URL:', url);
+ } else {
+ console.log('Using clone_url:', url);
+ }
+
+ console.log('Final URL to select:', url);
+ onSelect(url);
+ } else {
+ console.log('Missing repository or branch:', { selectedRepository, selectedBranch });
+ }
+ };
+
+ const handleDialogClose = () => {
+ clearSelection();
+ onClose();
+ };
+
+ if (!isConnected || !connection) {
+ return (
+
+
+
+
+
+
+
+
GitHub Connection Required
+
+ Please connect your GitHub account to browse repositories.
+
+
+ Close
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+ Import from GitHub
+
+
+ Select a repository to import into your workspace
+
+
+
+
+
+
+
+
+
+
+ {/* Tab Navigation */}
+
+ setActiveTab('my-repos')}
+ className={classNames(
+ 'flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors',
+ activeTab === 'my-repos'
+ ? 'text-bolt-elements-item-contentAccent border-b-2 border-bolt-elements-item-contentAccent bg-bolt-elements-item-contentAccent/5'
+ : 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary',
+ )}
+ >
+
+ My Repositories
+ {myRepositories.length > 0 && (
+
+ {myRepositories.length}
+
+ )}
+
+ setActiveTab('search')}
+ className={classNames(
+ 'flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors',
+ activeTab === 'search'
+ ? 'text-bolt-elements-item-contentAccent border-b-2 border-bolt-elements-item-contentAccent bg-bolt-elements-item-contentAccent/5'
+ : 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary',
+ )}
+ >
+
+ Search GitHub
+
+ setActiveTab('url')}
+ className={classNames(
+ 'flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors',
+ activeTab === 'url'
+ ? 'text-bolt-elements-item-contentAccent border-b-2 border-bolt-elements-item-contentAccent bg-bolt-elements-item-contentAccent/5'
+ : 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary',
+ )}
+ >
+
+ Custom URL
+
+
+
+ {/* Content */}
+
+
+ {/* Repository List */}
+
+
+
+
+ {activeTab !== 'url' && (
+
undefined} // Search is handled above
+ />
+ )}
+
+ {activeTab === 'url' && (
+
+
+
+ Import from Custom URL
+
+
+ Enter a GitHub repository URL to import it directly
+
+
+ )}
+
+
+
+ {/* Repository Details */}
+ {(selectedRepository || (activeTab === 'url' && customUrl)) && (
+
+ {selectedRepository && (
+ <>
+
+
+
+
+
Repository Details
+
+
+
+
+
+ {selectedRepository.name}
+
+
{selectedRepository.full_name}
+ {selectedRepository.description && (
+
+ {selectedRepository.description}
+
+ )}
+
+
+
+
+ Branch
+
+ {isLoadingBranches ? (
+
Loading branches...
+ ) : (
+
setSelectedBranch(e.target.value)}
+ className="w-full px-3 py-2 text-sm border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent"
+ >
+ {branches.map((branch) => (
+
+ {branch.name}
+ {branch.name === selectedRepository.default_branch && ' (default)'}
+
+ ))}
+
+ )}
+
+
+ >
+ )}
+
+ {activeTab === 'url' && customUrl && (
+
+ )}
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+
Connected as {connection.user?.login}
+
+
+ Cancel
+
+
+ Import Repository
+
+
+
+
+
+ {error && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/RepositorySelector.tsx b/app/components/@settings/tabs/github/components/RepositorySelector.tsx
new file mode 100644
index 0000000000..cfb19fa0a1
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/RepositorySelector.tsx
@@ -0,0 +1,162 @@
+import React, { useState } from 'react';
+import { SearchInput, Badge, EmptyState, StatusIndicator } from '~/components/ui';
+import { Search, Folder, Star, GitFork, Clock, Lock } from 'lucide-react';
+import type { GitHubRepoInfo } from '~/types/GitHub';
+import { formatSize } from '~/utils/formatSize';
+
+interface RepositorySelectorProps {
+ repositories: GitHubRepoInfo[];
+ filteredRepositories: GitHubRepoInfo[];
+ isLoading: boolean;
+ onRepositorySelect: (repo: GitHubRepoInfo) => void;
+ onSearch: (query: string) => void;
+ className?: string;
+}
+
+export function RepositorySelector({
+ repositories,
+ filteredRepositories,
+ isLoading,
+ onRepositorySelect,
+ onSearch,
+ className = '',
+}: RepositorySelectorProps) {
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const handleSearch = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ setSearchQuery(value);
+ onSearch(value);
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (repositories.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {filteredRepositories.length === 0 ? (
+
+ ) : (
+
+ {filteredRepositories.map((repo) => (
+ onRepositorySelect(repo)} />
+ ))}
+
+ )}
+
+
+ Showing {filteredRepositories.length} of {repositories.length} repositories
+
+
+ );
+}
+
+interface RepositoryItemProps {
+ repository: GitHubRepoInfo;
+ onSelect: () => void;
+}
+
+function RepositoryItem({ repository, onSelect }: RepositoryItemProps) {
+ const formatTimeAgo = (dateString: string) => {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffTime = Math.abs(now.getTime() - date.getTime());
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+
+ if (diffDays <= 1) {
+ return 'Today';
+ }
+
+ if (diffDays <= 7) {
+ return `${diffDays} days ago`;
+ }
+
+ if (diffDays <= 30) {
+ return `${Math.floor(diffDays / 7)} weeks ago`;
+ }
+
+ return date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ return (
+
+
+
+
{repository.name}
+ {repository.private && }
+ {repository.fork && }
+ {repository.archived && (
+
+ Archived
+
+ )}
+
+
+
+
+
+ {repository.stargazers_count}
+
+
+
+ {repository.forks_count}
+
+
+
+
+ {repository.description && (
+ {repository.description}
+ )}
+
+
+
+ {repository.language && (
+
+
+ {repository.language}
+
+ )}
+ {repository.size &&
{formatSize(repository.size * 1024)} }
+
+
+
+
+ {formatTimeAgo(repository.updated_at)}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/shared/GitHubDialog.tsx b/app/components/@settings/tabs/github/components/shared/GitHubDialog.tsx
new file mode 100644
index 0000000000..f7660b52ed
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/shared/GitHubDialog.tsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { motion } from 'framer-motion';
+import { X, Github } from 'lucide-react';
+import { classNames } from '~/utils/classNames';
+
+interface GitHubDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ description?: string;
+ children: React.ReactNode;
+ size?: 'sm' | 'md' | 'lg' | 'xl';
+ footer?: React.ReactNode;
+ preventClose?: boolean;
+ className?: string;
+}
+
+const sizeClasses = {
+ sm: 'max-w-md',
+ md: 'max-w-2xl',
+ lg: 'max-w-4xl',
+ xl: 'max-w-6xl',
+};
+
+export function GitHubDialog({
+ isOpen,
+ onClose,
+ title,
+ description,
+ children,
+ size = 'md',
+ footer,
+ preventClose = false,
+ className = '',
+}: GitHubDialogProps) {
+ const handleClose = () => {
+ if (!preventClose) {
+ onClose();
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+ {title}
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {!preventClose && (
+
+
+
+
+
+ )}
+
+
+ {/* Content */}
+ {children}
+
+ {/* Footer */}
+ {footer && (
+
+ {footer}
+
+ )}
+
+
+
+
+
+ );
+}
+
+interface GitHubDialogContentProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function GitHubDialogContent({ children, className = '' }: GitHubDialogContentProps) {
+ return {children}
;
+}
+
+interface GitHubDialogFooterProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function GitHubDialogFooter({ children, className = '' }: GitHubDialogFooterProps) {
+ return {children}
;
+}
diff --git a/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx b/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx
new file mode 100644
index 0000000000..c36fa09c0f
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx
@@ -0,0 +1,264 @@
+import React from 'react';
+import { Loader2, AlertCircle, CheckCircle, Info, Github } from 'lucide-react';
+import { classNames } from '~/utils/classNames';
+
+interface LoadingStateProps {
+ message?: string;
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export function LoadingState({ message = 'Loading...', size = 'md', className = '' }: LoadingStateProps) {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ const textSizeClasses = {
+ sm: 'text-sm',
+ md: 'text-base',
+ lg: 'text-lg',
+ };
+
+ return (
+
+ );
+}
+
+interface ErrorStateProps {
+ title?: string;
+ message: string;
+ onRetry?: () => void;
+ retryLabel?: string;
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export function ErrorState({
+ title = 'Error',
+ message,
+ onRetry,
+ retryLabel = 'Try Again',
+ size = 'md',
+ className = '',
+}: ErrorStateProps) {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ const textSizeClasses = {
+ sm: 'text-sm',
+ md: 'text-base',
+ lg: 'text-lg',
+ };
+
+ return (
+
+
+
{title}
+
{message}
+ {onRetry && (
+
+ {retryLabel}
+
+ )}
+
+ );
+}
+
+interface SuccessStateProps {
+ title?: string;
+ message: string;
+ onAction?: () => void;
+ actionLabel?: string;
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export function SuccessState({
+ title = 'Success',
+ message,
+ onAction,
+ actionLabel = 'Continue',
+ size = 'md',
+ className = '',
+}: SuccessStateProps) {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ const textSizeClasses = {
+ sm: 'text-sm',
+ md: 'text-base',
+ lg: 'text-lg',
+ };
+
+ return (
+
+
+
{title}
+
{message}
+ {onAction && (
+
+ {actionLabel}
+
+ )}
+
+ );
+}
+
+interface GitHubConnectionRequiredProps {
+ onConnect?: () => void;
+ className?: string;
+}
+
+export function GitHubConnectionRequired({ onConnect, className = '' }: GitHubConnectionRequiredProps) {
+ return (
+
+
+
GitHub Connection Required
+
+ Please connect your GitHub account to access this feature. You'll be able to browse repositories, push code, and
+ manage your GitHub integration.
+
+ {onConnect && (
+
+
+ Connect GitHub
+
+ )}
+
+ );
+}
+
+interface InformationStateProps {
+ title: string;
+ message: string;
+ icon?: React.ComponentType<{ className?: string }>;
+ onAction?: () => void;
+ actionLabel?: string;
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export function InformationState({
+ title,
+ message,
+ icon = Info,
+ onAction,
+ actionLabel = 'Got it',
+ size = 'md',
+ className = '',
+}: InformationStateProps) {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ const textSizeClasses = {
+ sm: 'text-sm',
+ md: 'text-base',
+ lg: 'text-lg',
+ };
+
+ return (
+
+ {React.createElement(icon, { className: classNames('text-blue-500 mb-2', sizeClasses[size]) })}
+
{title}
+
{message}
+ {onAction && (
+
+ {actionLabel}
+
+ )}
+
+ );
+}
+
+interface ConnectionTestIndicatorProps {
+ status: 'success' | 'error' | 'testing' | null;
+ message?: string;
+ timestamp?: number;
+ className?: string;
+}
+
+export function ConnectionTestIndicator({ status, message, timestamp, className = '' }: ConnectionTestIndicatorProps) {
+ if (!status) {
+ return null;
+ }
+
+ const getStatusColor = () => {
+ switch (status) {
+ case 'success':
+ return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-700';
+ case 'error':
+ return 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-700';
+ case 'testing':
+ return 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-700';
+ default:
+ return 'bg-gray-50 border-gray-200 dark:bg-gray-900/20 dark:border-gray-700';
+ }
+ };
+
+ const getStatusIcon = () => {
+ switch (status) {
+ case 'success':
+ return ;
+ case 'error':
+ return ;
+ case 'testing':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusTextColor = () => {
+ switch (status) {
+ case 'success':
+ return 'text-green-800 dark:text-green-200';
+ case 'error':
+ return 'text-red-800 dark:text-red-200';
+ case 'testing':
+ return 'text-blue-800 dark:text-blue-200';
+ default:
+ return 'text-gray-800 dark:text-gray-200';
+ }
+ };
+
+ return (
+
+
+ {getStatusIcon()}
+ {message || status}
+
+ {timestamp &&
{new Date(timestamp).toLocaleString()}
}
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx b/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx
new file mode 100644
index 0000000000..f0ff7fa130
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx
@@ -0,0 +1,361 @@
+import React from 'react';
+import { classNames } from '~/utils/classNames';
+import { formatSize } from '~/utils/formatSize';
+import type { GitHubRepoInfo } from '~/types/GitHub';
+import {
+ Star,
+ GitFork,
+ Clock,
+ Lock,
+ Archive,
+ GitBranch,
+ Users,
+ Database,
+ Tag,
+ Heart,
+ ExternalLink,
+ Circle,
+ GitPullRequest,
+} from 'lucide-react';
+
+interface RepositoryCardProps {
+ repository: GitHubRepoInfo;
+ variant?: 'default' | 'compact' | 'detailed';
+ onSelect?: () => void;
+ showHealthScore?: boolean;
+ showExtendedMetrics?: boolean;
+ className?: string;
+}
+
+export function RepositoryCard({
+ repository,
+ variant = 'default',
+ onSelect,
+ showHealthScore = false,
+ showExtendedMetrics = false,
+ className = '',
+}: RepositoryCardProps) {
+ const daysSinceUpdate = Math.floor((Date.now() - new Date(repository.updated_at).getTime()) / (1000 * 60 * 60 * 24));
+
+ const formatTimeAgo = () => {
+ if (daysSinceUpdate === 0) {
+ return 'Today';
+ }
+
+ if (daysSinceUpdate === 1) {
+ return '1 day ago';
+ }
+
+ if (daysSinceUpdate < 7) {
+ return `${daysSinceUpdate} days ago`;
+ }
+
+ if (daysSinceUpdate < 30) {
+ return `${Math.floor(daysSinceUpdate / 7)} weeks ago`;
+ }
+
+ return new Date(repository.updated_at).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ const calculateHealthScore = () => {
+ const hasStars = repository.stargazers_count > 0;
+ const hasRecentActivity = daysSinceUpdate < 30;
+ const hasContributors = (repository.contributors_count || 0) > 1;
+ const hasDescription = !!repository.description;
+ const hasTopics = (repository.topics || []).length > 0;
+ const hasLicense = !!repository.license;
+
+ const healthScore = [hasStars, hasRecentActivity, hasContributors, hasDescription, hasTopics, hasLicense].filter(
+ Boolean,
+ ).length;
+
+ const maxScore = 6;
+ const percentage = Math.round((healthScore / maxScore) * 100);
+
+ const getScoreColor = (score: number) => {
+ if (score >= 5) {
+ return 'text-green-500';
+ }
+
+ if (score >= 3) {
+ return 'text-yellow-500';
+ }
+
+ return 'text-red-500';
+ };
+
+ return {
+ percentage,
+ color: getScoreColor(healthScore),
+ score: healthScore,
+ maxScore,
+ };
+ };
+
+ const getHealthIndicatorColor = () => {
+ const isActive = daysSinceUpdate < 7;
+ const isHealthy = daysSinceUpdate < 30 && !repository.archived && repository.stargazers_count > 0;
+
+ if (repository.archived) {
+ return 'bg-gray-500';
+ }
+
+ if (isActive) {
+ return 'bg-green-500';
+ }
+
+ if (isHealthy) {
+ return 'bg-blue-500';
+ }
+
+ return 'bg-yellow-500';
+ };
+
+ const getHealthTitle = () => {
+ if (repository.archived) {
+ return 'Archived';
+ }
+
+ if (daysSinceUpdate < 7) {
+ return 'Very Active';
+ }
+
+ if (daysSinceUpdate < 30 && repository.stargazers_count > 0) {
+ return 'Healthy';
+ }
+
+ return 'Needs Attention';
+ };
+
+ const health = showHealthScore ? calculateHealthScore() : null;
+
+ if (variant === 'compact') {
+ return (
+
+
+
+
{repository.name}
+ {repository.private &&
}
+ {repository.fork &&
}
+ {repository.archived &&
}
+
+
+
+
+
+ {repository.stargazers_count}
+
+
+
+ {repository.forks_count}
+
+
+
+
+ {repository.description && (
+ {repository.description}
+ )}
+
+
+
+ {repository.language && (
+
+
+ {repository.language}
+
+ )}
+ {repository.size &&
{formatSize(repository.size * 1024)} }
+
+
+
+
+ {formatTimeAgo()}
+
+
+
+ );
+ }
+
+ const Component = onSelect ? 'button' : 'div';
+ const interactiveProps = onSelect
+ ? {
+ onClick: onSelect,
+ className: classNames(
+ 'group cursor-pointer hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200',
+ className,
+ ),
+ }
+ : { className };
+
+ return (
+
+ {/* Repository Health Indicator */}
+ {variant === 'detailed' && (
+
+ )}
+
+
+
+
+
+
+ {repository.name}
+
+ {repository.fork && (
+
+
+
+ )}
+ {repository.archived && (
+
+
+
+ )}
+
+
+
+
+ {repository.stargazers_count.toLocaleString()}
+
+
+
+ {repository.forks_count.toLocaleString()}
+
+ {showExtendedMetrics && repository.issues_count !== undefined && (
+
+
+ {repository.issues_count}
+
+ )}
+ {showExtendedMetrics && repository.pull_requests_count !== undefined && (
+
+
+ {repository.pull_requests_count}
+
+ )}
+
+
+
+
+ {repository.description && (
+
{repository.description}
+ )}
+
+ {/* Repository metrics bar */}
+
+ {repository.license && (
+
+ {repository.license.spdx_id || repository.license.name}
+
+ )}
+ {repository.topics &&
+ repository.topics.slice(0, 2).map((topic) => (
+
+ {topic}
+
+ ))}
+ {repository.archived && (
+
+ Archived
+
+ )}
+ {repository.fork && (
+
+ Fork
+
+ )}
+
+
+
+
+
+
+
+ {repository.default_branch}
+
+ {showExtendedMetrics && repository.branches_count && (
+
+
+ {repository.branches_count}
+
+ )}
+ {showExtendedMetrics && repository.contributors_count && (
+
+
+ {repository.contributors_count}
+
+ )}
+ {repository.size && (
+
+
+ {(repository.size / 1024).toFixed(1)}MB
+
+ )}
+
+
+ {formatTimeAgo()}
+
+ {repository.topics && repository.topics.length > 0 && (
+
+
+ {repository.topics.length}
+
+ )}
+
+
+
+ {/* Repository Health Score */}
+ {health && (
+
+
+ {health.percentage}%
+
+ )}
+
+ {onSelect && (
+
+
+ View
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/shared/index.ts b/app/components/@settings/tabs/github/components/shared/index.ts
new file mode 100644
index 0000000000..df3ffa33f7
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/shared/index.ts
@@ -0,0 +1,10 @@
+export { RepositoryCard } from './RepositoryCard';
+export { GitHubDialog, GitHubDialogContent, GitHubDialogFooter } from './GitHubDialog';
+export {
+ LoadingState,
+ ErrorState,
+ SuccessState,
+ GitHubConnectionRequired,
+ InformationState,
+ ConnectionTestIndicator,
+} from './GitHubStateIndicators';
diff --git a/app/components/@settings/tabs/gitlab/GitLabTab.tsx b/app/components/@settings/tabs/gitlab/GitLabTab.tsx
new file mode 100644
index 0000000000..ff992c13b7
--- /dev/null
+++ b/app/components/@settings/tabs/gitlab/GitLabTab.tsx
@@ -0,0 +1,207 @@
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { useGitLabConnection } from '~/lib/stores/gitlabConnection';
+import GitLabConnection from '~/components/@settings/tabs/connections/gitlab/GitLabConnection';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import { classNames } from '~/utils/classNames';
+import { ChevronDown } from 'lucide-react';
+
+// GitLab logo SVG component
+const GitLabLogo = () => (
+
+
+
+);
+
+export default function GitLabTab() {
+ const { connection, isConnected, stats } = useGitLabConnection();
+ const [isReposExpanded, setIsReposExpanded] = useState(false);
+
+ // Loading state for initial connection check
+ if (!connection && !isConnected) {
+ return (
+
+
+
+
GitLab Integration
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ GitLab Integration
+
+
+
+ {connection.get()?.rateLimit && (
+
+
+
+ API: {connection.get()?.rateLimit?.remaining}/{connection.get()?.rateLimit?.limit}
+
+
+ )}
+
+
+
+
+ Manage your GitLab integration with advanced repository features and comprehensive statistics
+
+
+ {/* Connection Test Results */}
+ {/* connectionTest && (
+
+
+
+ {connectionTest.status === 'success' ? (
+
+ ) : connectionTest.status === 'error' ? (
+
+ ) : (
+
+ )}
+
+
+ {connectionTest.message}
+
+
+
+ ) */}
+
+ {/* GitLab Connection Component */}
+
+
+ {/* Repositories Section */}
+ {isConnected && stats.get() && stats.get()?.projects && stats.get()!.projects!.length > 0 && (
+
+
+
+
+
+
+
+ All Repositories ({stats.get()?.projects?.length})
+
+
+
+
+
+
+
+
+
+ {(isReposExpanded ? stats.get()?.projects : stats.get()?.projects?.slice(0, 12))?.map((repo: any) => (
+
+
+
+
{repo.name}
+
{repo.path_with_namespace}
+ {repo.description && (
+
+ {repo.description}
+
+ )}
+
+
+
+
+
+
+ {repo.star_count}
+
+
+
+ {repo.forks_count}
+
+
+
window.open(repo.http_url_to_repo, '_blank', 'noopener,noreferrer')}
+ className="text-xs"
+ >
+ View
+
+
+
+ ))}
+
+
+ {stats.get()?.projects && stats.get()!.projects!.length > 12 && !isReposExpanded && (
+
+ setIsReposExpanded(true)}
+ className="text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
+ >
+ Show {(stats.get()?.projects?.length || 0) - 12} more repositories
+
+
+ )}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/@settings/tabs/mcp/EnhancedMcpServerListItem.tsx b/app/components/@settings/tabs/mcp/EnhancedMcpServerListItem.tsx
new file mode 100644
index 0000000000..3cfe2adffb
--- /dev/null
+++ b/app/components/@settings/tabs/mcp/EnhancedMcpServerListItem.tsx
@@ -0,0 +1,865 @@
+import { useState } from 'react';
+import { Switch } from '~/components/ui/Switch';
+import { Button } from '~/components/ui/Button';
+
+// Helper function to sanitize config for UI display
+function sanitizeConfigForDisplay(config: any): any {
+ const sanitized = { ...config };
+
+ // Sanitize environment variables
+ if (sanitized.env) {
+ sanitized.env = {};
+
+ for (const [key, value] of Object.entries(config.env)) {
+ if (isSensitiveEnvVar(key)) {
+ sanitized.env[key] = '[REDACTED]';
+ } else {
+ sanitized.env[key] = value;
+ }
+ }
+ }
+
+ // Sanitize headers
+ if (sanitized.headers) {
+ sanitized.headers = {};
+
+ for (const [key, value] of Object.entries(config.headers)) {
+ if (isSensitiveHeader(key)) {
+ sanitized.headers[key] = '[REDACTED]';
+ } else {
+ sanitized.headers[key] = value;
+ }
+ }
+ }
+
+ return sanitized;
+}
+
+function isSensitiveEnvVar(key: string): boolean {
+ const sensitivePatterns = [
+ /api[_-]?key/i,
+ /secret/i,
+ /token/i,
+ /password/i,
+ /auth/i,
+ /credential/i,
+ /bearer/i,
+ /authorization/i,
+ /private[_-]?key/i,
+ /access[_-]?token/i,
+ /refresh[_-]?token/i,
+ ];
+ return sensitivePatterns.some((pattern) => pattern.test(key));
+}
+
+function isSensitiveHeader(key: string): boolean {
+ const sensitivePatterns = [/authorization/i, /api[_-]?key/i, /bearer/i, /token/i, /secret/i];
+ return sensitivePatterns.some((pattern) => pattern.test(key));
+}
+import { Badge } from '~/components/ui/Badge';
+import { Input } from '~/components/ui/Input';
+import { Label } from '~/components/ui/Label';
+import { Textarea } from '~/components/ui/Textarea';
+import { ConfirmationDialog, Dialog, DialogTitle } from '~/components/ui/Dialog';
+import * as RadixDialog from '@radix-ui/react-dialog';
+import McpStatusBadge from './McpStatusBadge';
+import McpServerListItem from './McpServerListItem';
+import { classNames } from '~/utils/classNames';
+import { ChevronDown, ChevronRight, Trash2, Settings, Play, Pause, AlertTriangle } from 'lucide-react';
+import type { MCPServer } from '~/lib/services/mcpService';
+
+// Helper function to detect server type based on name and config
+const detectServerType = (serverName: string, config: any): string | null => {
+ // Try to match by server name first
+ const lowerName = serverName.toLowerCase();
+
+ if (lowerName.includes('github')) {
+ return 'github';
+ }
+
+ if (lowerName.includes('slack')) {
+ return 'slack';
+ }
+
+ if (lowerName.includes('gdrive') || lowerName.includes('google-drive')) {
+ return 'gdrive';
+ }
+
+ if (lowerName.includes('postgres')) {
+ return 'postgres';
+ }
+
+ if (lowerName.includes('brave')) {
+ return 'brave-search';
+ }
+
+ if (lowerName.includes('kubernetes') || lowerName.includes('k8s')) {
+ return 'kubernetes';
+ }
+
+ if (lowerName.includes('elasticsearch') || lowerName.includes('elastic')) {
+ return 'elasticsearch';
+ }
+
+ if (lowerName.includes('weather')) {
+ return 'weather';
+ }
+
+ if (lowerName.includes('sentry')) {
+ return 'sentry';
+ }
+
+ if (lowerName.includes('twitter') || lowerName.includes('x-')) {
+ return 'twitter';
+ }
+
+ if (lowerName.includes('reddit')) {
+ return 'reddit';
+ }
+
+ if (lowerName.includes('youtube') || lowerName.includes('yt-')) {
+ return 'youtube';
+ }
+
+ if (lowerName.includes('openai') || lowerName.includes('gpt')) {
+ return 'openai';
+ }
+
+ if (lowerName.includes('anthropic') || lowerName.includes('claude')) {
+ return 'anthropic';
+ }
+
+ if (lowerName.includes('perplexity')) {
+ return 'perplexity';
+ }
+
+ if (lowerName.includes('replicate')) {
+ return 'replicate';
+ }
+
+ if (lowerName.includes('mem0') || lowerName.includes('coding')) {
+ return 'mem0-coding';
+ }
+
+ if (lowerName.includes('devstandards') || lowerName.includes('standards')) {
+ return 'devstandards';
+ }
+
+ if (lowerName.includes('code-runner') || lowerName.includes('coderunner')) {
+ return 'code-runner';
+ }
+
+ if (lowerName.includes('vscode')) {
+ return 'vscode-mcp';
+ }
+
+ if (lowerName.includes('xcode')) {
+ return 'xcode-mcp';
+ }
+
+ if (lowerName.includes('context7') || lowerName.includes('documentation')) {
+ return 'context7-docs';
+ }
+
+ if (lowerName.includes('shrimp') || lowerName.includes('task-manager')) {
+ return 'shrimp-task-manager';
+ }
+
+ if (lowerName.includes('sonarqube') || lowerName.includes('sonar')) {
+ return 'sonarqube';
+ }
+
+ if (lowerName.includes('semgrep') || lowerName.includes('security')) {
+ return 'semgrep-security';
+ }
+
+ if (lowerName.includes('jupyter') || lowerName.includes('notebook')) {
+ return 'jupyter-mcp';
+ }
+
+ if (lowerName.includes('git-ingest') || lowerName.includes('git-analyzer')) {
+ return 'mcp-git-ingest';
+ }
+
+ // Try to match by command args for STDIO servers
+ if (config.type === 'stdio' && config.args) {
+ const argsStr = config.args.join(' ').toLowerCase();
+
+ if (argsStr.includes('server-github')) {
+ return 'github';
+ }
+
+ if (argsStr.includes('server-slack')) {
+ return 'slack';
+ }
+
+ if (argsStr.includes('server-gdrive')) {
+ return 'gdrive';
+ }
+
+ if (argsStr.includes('server-postgres')) {
+ return 'postgres';
+ }
+
+ if (argsStr.includes('server-sqlite')) {
+ return 'sqlite';
+ }
+
+ if (argsStr.includes('server-git')) {
+ return 'git';
+ }
+
+ if (argsStr.includes('hacker-news-mcp')) {
+ return 'hacker-news';
+ }
+
+ if (argsStr.includes('qdrant')) {
+ return 'qdrant';
+ }
+
+ if (argsStr.includes('@mem0ai/mem0-mcp')) {
+ return 'mem0-coding';
+ }
+
+ if (argsStr.includes('@ivangrynenko/devstandards_mcp')) {
+ return 'devstandards';
+ }
+
+ if (argsStr.includes('@axliupore/mcp-code-runner')) {
+ return 'code-runner';
+ }
+
+ if (argsStr.includes('@juehang/vscode-mcp-server')) {
+ return 'vscode-mcp';
+ }
+
+ if (argsStr.includes('@r-huijts/xcode-mcp-server')) {
+ return 'xcode-mcp';
+ }
+
+ if (argsStr.includes('@context7/server')) {
+ return 'context7-docs';
+ }
+
+ if (argsStr.includes('@cjo4m06/mcp-shrimp-task-manager')) {
+ return 'shrimp-task-manager';
+ }
+
+ if (argsStr.includes('@sonarsource/sonarqube-mcp')) {
+ return 'sonarqube';
+ }
+
+ if (argsStr.includes('@semgrep/mcp-server')) {
+ return 'semgrep-security';
+ }
+
+ if (argsStr.includes('@datalayer/jupyter-mcp-server')) {
+ return 'jupyter-mcp';
+ }
+
+ if (argsStr.includes('@adhikasp/mcp-git-ingest')) {
+ return 'mcp-git-ingest';
+ }
+
+ if (argsStr.includes('@modelcontextprotocol/server-brave-search')) {
+ return 'brave-search';
+ }
+ }
+
+ // Try to match by URL for HTTP servers
+ if (config.url) {
+ // Add URL-based detections here if needed
+ }
+
+ return null;
+};
+
+// API key configurations (should match McpServerWizard)
+const SERVER_API_CONFIGS: Record<
+ string,
+ Record
+> = {
+ github: {
+ GITHUB_PERSONAL_ACCESS_TOKEN: {
+ label: 'GitHub Personal Access Token',
+ placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxx',
+ description: 'Create at github.com/settings/tokens with repo and read:user scopes',
+ required: true,
+ },
+ },
+ slack: {
+ SLACK_BOT_TOKEN: {
+ label: 'Slack Bot Token',
+ placeholder: 'xoxb-xxxxxxxxxxxx-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from your Slack app settings (OAuth & Permissions)',
+ required: true,
+ },
+ },
+ gdrive: {
+ GOOGLE_CLIENT_ID: {
+ label: 'Google Client ID',
+ placeholder: 'xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com',
+ description: 'From Google Cloud Console OAuth 2.0 credentials',
+ required: true,
+ },
+ GOOGLE_CLIENT_SECRET: {
+ label: 'Google Client Secret',
+ placeholder: 'GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'From Google Cloud Console OAuth 2.0 credentials',
+ required: true,
+ },
+ },
+ postgres: {
+ DATABASE_URL: {
+ label: 'Database URL',
+ placeholder: 'postgresql://username:password@hostname:port/database',
+ description: 'PostgreSQL connection string',
+ required: true,
+ },
+ },
+ 'brave-search': {
+ BRAVE_API_KEY: {
+ label: 'Brave Search API Key',
+ placeholder: 'BSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get your API key from https://api.search.brave.com/app/keys',
+ required: true,
+ },
+ },
+ kubernetes: {
+ KUBECONFIG: {
+ label: 'Kubeconfig Path',
+ placeholder: '~/.kube/config',
+ description: 'Path to your Kubernetes configuration file',
+ required: true,
+ },
+ },
+ elasticsearch: {
+ ELASTICSEARCH_URL: {
+ label: 'Elasticsearch URL',
+ placeholder: 'http://localhost:9200',
+ description: 'Elasticsearch cluster URL',
+ required: true,
+ },
+ ELASTICSEARCH_API_KEY: {
+ label: 'Elasticsearch API Key',
+ placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Elasticsearch API key for authentication',
+ required: true,
+ },
+ },
+ weather: {
+ OPENWEATHER_API_KEY: {
+ label: 'OpenWeatherMap API Key',
+ placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get your API key from openweathermap.org',
+ required: true,
+ },
+ },
+ sentry: {
+ SENTRY_AUTH_TOKEN: {
+ label: 'Sentry Auth Token',
+ placeholder: 'sntrys_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Create at sentry.io/settings/account/api/auth-tokens',
+ required: true,
+ },
+ },
+ twitter: {
+ TWITTER_BEARER_TOKEN: {
+ label: 'Twitter Bearer Token',
+ placeholder: 'AAAAAAAAAAAAAAAAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Twitter Developer Portal (API v2)',
+ required: true,
+ },
+ },
+ reddit: {
+ REDDIT_CLIENT_ID: {
+ label: 'Reddit Client ID',
+ placeholder: 'your_client_id_here',
+ description: 'From Reddit App Settings',
+ required: true,
+ },
+ REDDIT_CLIENT_SECRET: {
+ label: 'Reddit Client Secret',
+ placeholder: 'your_client_secret_here',
+ description: 'From Reddit App Settings',
+ required: true,
+ },
+ },
+ youtube: {
+ YOUTUBE_API_KEY: {
+ label: 'YouTube API Key',
+ placeholder: 'AIzaSyAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Google Cloud Console (YouTube Data API v3)',
+ required: true,
+ },
+ },
+ openai: {
+ OPENAI_API_KEY: {
+ label: 'OpenAI API Key',
+ placeholder: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from OpenAI Platform (platform.openai.com/api-keys)',
+ required: true,
+ },
+ },
+ anthropic: {
+ ANTHROPIC_API_KEY: {
+ label: 'Anthropic API Key',
+ placeholder: 'sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Anthropic Console (console.anthropic.com/)',
+ required: true,
+ },
+ },
+ perplexity: {
+ PERPLEXITY_API_KEY: {
+ label: 'Perplexity API Key',
+ placeholder: 'pplx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Perplexity API dashboard',
+ required: true,
+ },
+ },
+ replicate: {
+ REPLICATE_API_TOKEN: {
+ label: 'Replicate API Token',
+ placeholder: 'r8_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Replicate account settings',
+ required: true,
+ },
+ },
+ sonarqube: {
+ SONARQUBE_URL: {
+ label: 'SonarQube Server URL',
+ placeholder: 'http://localhost:9000',
+ description: 'URL of your SonarQube server',
+ required: true,
+ },
+ SONARQUBE_TOKEN: {
+ label: 'SonarQube Authentication Token',
+ placeholder: 'squ_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Generate from SonarQube: Administration > Security > Users',
+ required: true,
+ },
+ },
+};
+
+interface EnhancedMcpServerListItemProps {
+ serverName: string;
+ mcpServer: MCPServer;
+ isEnabled: boolean;
+ isExpanded: boolean;
+ isCheckingServers: boolean;
+ onToggleExpanded: (serverName: string) => void;
+ onToggleEnabled: (serverName: string, enabled: boolean) => void;
+ onRemoveServer: (serverName: string) => void;
+ onEditServer?: (serverName: string, config: any) => void;
+}
+
+export default function EnhancedMcpServerListItem({
+ serverName,
+ mcpServer,
+ isEnabled,
+ isExpanded,
+ isCheckingServers,
+ onToggleExpanded,
+ onToggleEnabled,
+ onRemoveServer,
+ onEditServer,
+}: EnhancedMcpServerListItemProps) {
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showEditDialog, setShowEditDialog] = useState(false);
+ const [isToggling, setIsToggling] = useState(false);
+ const [editedConfig, setEditedConfig] = useState(mcpServer.config);
+ const [apiKeys, setApiKeys] = useState>({});
+
+ const isAvailable = mcpServer.status === 'available';
+ const serverTools = isAvailable ? Object.entries(mcpServer.tools) : [];
+ const canConnect =
+ mcpServer.config.type === 'streamable-http' || mcpServer.config.type === 'sse'
+ ? !!mcpServer.config.url
+ : !!mcpServer.config.command;
+
+ const handleToggleEnabled = async (enabled: boolean) => {
+ if (isToggling) {
+ return;
+ }
+
+ setIsToggling(true);
+
+ try {
+ await onToggleEnabled(serverName, enabled);
+ } finally {
+ setIsToggling(false);
+ }
+ };
+
+ const handleRemoveServer = () => {
+ onRemoveServer(serverName);
+ setShowDeleteConfirm(false);
+ };
+
+ const handleEditServer = () => {
+ setEditedConfig(mcpServer.config);
+
+ // Extract API keys from existing config
+ const existingKeys: Record = {};
+
+ if (mcpServer.config.type === 'stdio') {
+ const stdioConfig = mcpServer.config as { type: 'stdio'; env?: Record };
+ const serverType = detectServerType(serverName, mcpServer.config);
+
+ if (serverType && SERVER_API_CONFIGS[serverType] && stdioConfig.env) {
+ Object.keys(SERVER_API_CONFIGS[serverType]).forEach((key) => {
+ if (stdioConfig.env && stdioConfig.env[key]) {
+ existingKeys[key] = stdioConfig.env[key];
+ }
+ });
+ }
+ }
+
+ setApiKeys(existingKeys);
+
+ setShowEditDialog(true);
+ };
+
+ const handleSaveConfig = () => {
+ if (onEditServer) {
+ // Merge API keys into config if it's a STDIO server
+ let finalConfig = { ...editedConfig };
+ const serverType = detectServerType(serverName, editedConfig);
+
+ if (
+ editedConfig.type === 'stdio' &&
+ serverType &&
+ SERVER_API_CONFIGS[serverType] &&
+ Object.keys(apiKeys).length > 0
+ ) {
+ const stdioConfig = editedConfig as {
+ type: 'stdio';
+ command: string;
+ env?: Record;
+ cwd?: string;
+ args?: string[];
+ };
+ finalConfig = {
+ ...stdioConfig,
+ env: {
+ ...stdioConfig.env,
+ ...Object.fromEntries(Object.entries(apiKeys).filter(([_, value]) => value.trim() !== '')),
+ },
+ };
+ }
+
+ onEditServer(serverName, finalConfig);
+ }
+
+ setShowEditDialog(false);
+ };
+
+ const handleApiKeyChange = (key: string, value: string) => {
+ setApiKeys((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ const handleConfigChange = (field: string, value: any) => {
+ setEditedConfig((prev) => ({
+ ...prev,
+ [field]: value,
+ }));
+ };
+
+ const getServerTypeInfo = () => {
+ const { type } = mcpServer.config;
+ const typeColors = {
+ stdio: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20',
+ sse: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border border-purple-500/20',
+ 'streamable-http': 'bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20',
+ };
+
+ return {
+ color:
+ typeColors[type] ||
+ 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor',
+ label: type.toUpperCase(),
+ };
+ };
+
+ const typeInfo = getServerTypeInfo();
+
+ return (
+
+
+ {/* Header Row */}
+
+
+ {/* Expand/Collapse Button */}
+
onToggleExpanded(serverName)}
+ className="p-1 rounded !bg-bolt-elements-background-depth-2 hover:!bg-bolt-elements-background-depth-3 transition-colors"
+ aria-expanded={isExpanded}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Server Name & Info */}
+
+
+
{serverName}
+
+ {typeInfo.label}
+
+ {!canConnect && (
+
+
+ Config Error
+
+ )}
+
+
+
+ {mcpServer.config.type === 'stdio' ? (
+
+ {mcpServer.config.command} {mcpServer.config.args?.join(' ')}
+
+ ) : (
+ {mcpServer.config.url}
+ )}
+
+
+
+
+ {/* Controls */}
+
+ {/* Status Badge */}
+ {isCheckingServers ? (
+
+ ) : (
+
+ )}
+
+ {/* Tool Count */}
+ {isAvailable && (
+
+ {serverTools.length} tool{serverTools.length !== 1 ? 's' : ''}
+
+ )}
+
+ {/* Enable/Disable Toggle */}
+
+
+ {/* Actions */}
+
+
+
+
+ setShowDeleteConfirm(true)}
+ className="h-8 w-8 !bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary hover:!bg-bolt-elements-background-depth-3 hover:text-red-600"
+ aria-label={`Remove ${serverName}`}
+ >
+
+
+
+
+
+
+ {/* Error Message */}
+ {!isAvailable && mcpServer.error && (
+
+
+ Error: {mcpServer.error}
+
+
+ )}
+
+ {/* Expanded Content */}
+ {isExpanded && (
+
+ {isAvailable ? (
+
+
+ Available Tools ({serverTools.length})
+
+ {serverTools.length === 0 ? (
+
No tools available from this server
+ ) : (
+
+ {serverTools.map(([toolName, toolSchema]) => (
+
+ ))}
+
+ )}
+
+ ) : (
+
+
+ Server is not available. Check the configuration and ensure the server is running.
+
+
+ )}
+
+ {/* Server Configuration Preview */}
+
+
+ View Configuration
+
+
+
+ {JSON.stringify(sanitizeConfigForDisplay(mcpServer.config), null, 2)}
+
+
+
+
+ )}
+
+
+ {/* Delete Confirmation Dialog */}
+
setShowDeleteConfirm(false)}
+ onConfirm={handleRemoveServer}
+ title={`Remove ${serverName}?`}
+ description="This will permanently remove the MCP server configuration. This action cannot be undone."
+ confirmLabel="Remove Server"
+ variant="destructive"
+ />
+
+ {/* Edit Server Dialog */}
+
+
+
+
+
+ Edit Server: {serverName}
+
+
+
+ {editedConfig.type === 'stdio' ? (
+ <>
+
+ Command
+ handleConfigChange('command', e.target.value)}
+ placeholder="e.g., npx, node, python"
+ />
+
+
+ Arguments (one per line)
+
+ >
+ ) : (
+
+ Server URL
+ handleConfigChange('url', e.target.value)}
+ placeholder="https://api.example.com/mcp"
+ />
+
+ )}
+
+ {/* API Key Configuration */}
+ {editedConfig.type === 'stdio' &&
+ (() => {
+ const serverType = detectServerType(serverName, editedConfig);
+ const apiConfig = serverType ? SERVER_API_CONFIGS[serverType] : null;
+
+ if (apiConfig) {
+ return (
+
+
+ 🔑 API Configuration
+
+
+ {Object.entries(apiConfig).map(([key, config]) => (
+
+
+ {config.label}
+ {config.required && * }
+
+
handleApiKeyChange(key, e.target.value)}
+ className="font-mono text-xs"
+ />
+
{config.description}
+
+ ))}
+
+
+ );
+ }
+
+ return null;
+ })()}
+
+
+
Configuration Preview
+
+ {JSON.stringify(editedConfig, null, 2)}
+
+
+
+
+
+ setShowEditDialog(false)}>
+ Cancel
+
+
+ Save Changes
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/mcp/McpServerList.tsx b/app/components/@settings/tabs/mcp/McpServerList.tsx
index 6e15fa9ed0..313014bcb0 100644
--- a/app/components/@settings/tabs/mcp/McpServerList.tsx
+++ b/app/components/@settings/tabs/mcp/McpServerList.tsx
@@ -1,6 +1,7 @@
import type { MCPServer } from '~/lib/services/mcpService';
import McpStatusBadge from '~/components/@settings/tabs/mcp/McpStatusBadge';
import McpServerListItem from '~/components/@settings/tabs/mcp/McpServerListItem';
+import { ChevronDown, ChevronRight } from 'lucide-react';
type McpServerListProps = {
serverEntries: [string, MCPServer][];
@@ -41,9 +42,11 @@ export default function McpServerList({
className="flex items-center gap-1.5 text-bolt-elements-textPrimary"
aria-expanded={isExpanded}
>
-
+ {isExpanded ? (
+
+ ) : (
+
+ )}
{serverName}
diff --git a/app/components/@settings/tabs/mcp/McpServerWizard.tsx b/app/components/@settings/tabs/mcp/McpServerWizard.tsx
new file mode 100644
index 0000000000..e227bf3437
--- /dev/null
+++ b/app/components/@settings/tabs/mcp/McpServerWizard.tsx
@@ -0,0 +1,1984 @@
+import { useState, useMemo, useEffect } from 'react';
+import * as RadixDialog from '@radix-ui/react-dialog';
+import { Dialog, DialogTitle } from '~/components/ui/Dialog';
+
+// Helper function to sanitize config for UI display
+function sanitizeConfigForDisplay(config: any): any {
+ const sanitized = { ...config };
+
+ // Sanitize environment variables
+ if (sanitized.env) {
+ sanitized.env = {};
+
+ for (const [key, value] of Object.entries(config.env)) {
+ if (isSensitiveEnvVar(key)) {
+ sanitized.env[key] = '[REDACTED]';
+ } else {
+ sanitized.env[key] = value;
+ }
+ }
+ }
+
+ // Sanitize headers
+ if (sanitized.headers) {
+ sanitized.headers = {};
+
+ for (const [key, value] of Object.entries(config.headers)) {
+ if (isSensitiveHeader(key)) {
+ sanitized.headers[key] = '[REDACTED]';
+ } else {
+ sanitized.headers[key] = value;
+ }
+ }
+ }
+
+ return sanitized;
+}
+
+function isSensitiveEnvVar(key: string): boolean {
+ const sensitivePatterns = [
+ /api[_-]?key/i,
+ /secret/i,
+ /token/i,
+ /password/i,
+ /auth/i,
+ /credential/i,
+ /bearer/i,
+ /authorization/i,
+ /private[_-]?key/i,
+ /access[_-]?token/i,
+ /refresh[_-]?token/i,
+ ];
+ return sensitivePatterns.some((pattern) => pattern.test(key));
+}
+
+function isSensitiveHeader(key: string): boolean {
+ const sensitivePatterns = [/authorization/i, /api[_-]?key/i, /bearer/i, /token/i, /secret/i];
+ return sensitivePatterns.some((pattern) => pattern.test(key));
+}
+import { Button } from '~/components/ui/Button';
+import { Input } from '~/components/ui/Input';
+import { Textarea } from '~/components/ui/Textarea';
+import { Label } from '~/components/ui/Label';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/Card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/Tabs';
+import { Badge } from '~/components/ui/Badge';
+import {
+ Plus,
+ Server,
+ Globe,
+ Terminal,
+ Zap,
+ FileText,
+ Search,
+ Database,
+ Brain,
+ MessageSquare,
+ Cloud,
+ Github,
+ CheckCircle,
+ ArrowRight,
+ ArrowLeft,
+ Loader2,
+ AlertCircle,
+ X,
+} from 'lucide-react';
+import type { MCPServerConfig } from '~/lib/services/mcpService';
+
+// Progress indicator component
+const ProgressIndicator = ({ currentStep }: { currentStep: number }) => {
+ const steps = [
+ { label: 'Choose Template', description: 'Select or create server' },
+ { label: 'Configure', description: 'Set up authentication' },
+ { label: 'Review', description: 'Finalize and add' },
+ ];
+
+ return (
+
+
+ {steps.map((step, index) => {
+ const stepNumber = index + 1;
+ const isActive = stepNumber === currentStep;
+ const isCompleted = stepNumber < currentStep;
+
+ return (
+
+
+
+ {isCompleted ? : stepNumber}
+
+
+
+ {step.label}
+
+
{step.description}
+
+
+ {index < steps.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+// Search component
+const SearchBar = ({
+ value,
+ onChange,
+ placeholder,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder: string;
+}) => {
+ return (
+
+
+ onChange(e.target.value)}
+ className="pl-10 pr-10"
+ aria-label="Search MCP servers"
+ role="searchbox"
+ />
+ {value && (
+ onChange('')}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+ );
+};
+
+const SERVER_TEMPLATES = {
+ stdio: [
+ {
+ id: 'everything',
+ name: 'Everything Server',
+ description: 'Comprehensive MCP server with multiple tools - great for testing',
+ category: 'General',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-everything'],
+ },
+ tags: ['verified', 'popular', 'multi-tool', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'filesystem',
+ name: 'Filesystem Server',
+ description: 'Access and manipulate local files and directories',
+ category: 'Files',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-filesystem'],
+ },
+ tags: ['verified', 'files', 'local', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'memory',
+ name: 'Memory Server',
+ description: 'Persistent memory and knowledge storage',
+ category: 'AI',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-memory'],
+ },
+ tags: ['verified', 'memory', 'ai', 'storage', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'postgres',
+ name: 'PostgreSQL Server',
+ description: 'Connect and query PostgreSQL databases',
+ category: 'Database',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-postgres'],
+ },
+ tags: ['verified', 'database', 'sql', 'postgres', 'requires-setup'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires PostgreSQL database credentials',
+ },
+ {
+ id: 'github',
+ name: 'GitHub Server',
+ description: 'GitHub repository and issue management',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-github'],
+ },
+ tags: ['verified', 'github', 'git', 'issues', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires GitHub Personal Access Token',
+ },
+ {
+ id: 'puppeteer',
+ name: 'Puppeteer Server',
+ description: 'Web scraping and browser automation',
+ category: 'Web',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-puppeteer'],
+ },
+ tags: ['verified', 'scraping', 'automation', 'browser', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'slack',
+ name: 'Slack Server',
+ description: 'Slack workspace and messaging integration',
+ category: 'Communication',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-slack'],
+ },
+ tags: ['verified', 'slack', 'messaging', 'communication', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Slack Bot Token',
+ },
+ {
+ id: 'gdrive',
+ name: 'Google Drive Server',
+ description: 'Google Drive file management and operations',
+ category: 'Cloud',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-gdrive'],
+ },
+ tags: ['verified', 'google', 'drive', 'cloud', 'files', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Google OAuth credentials',
+ },
+ ],
+ http: [
+ {
+ id: 'deepwiki',
+ name: 'DeepWiki Server',
+ description: 'Access Wikipedia and knowledge base functionality',
+ category: 'Knowledge',
+ icon:
,
+ config: {
+ type: 'streamable-http' as const,
+ url: 'https://mcp.deepwiki.com/mcp',
+ },
+ tags: ['verified', 'wikipedia', 'search', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'exa-search',
+ name: 'Exa Web Search',
+ description: 'Advanced web search with Exa (formerly Metaphor)',
+ category: 'Web',
+ icon:
,
+ config: {
+ type: 'streamable-http' as const,
+ url: 'https://mcp.exa.ai/search',
+ },
+ tags: ['verified', 'search', 'web', 'ai', 'semantic', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Exa API key',
+ },
+ {
+ id: 'brave-search',
+ name: 'Brave Search',
+ description: 'Privacy-focused web search with Brave Search API',
+ category: 'Web',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-brave-search'],
+ },
+ tags: ['verified', 'search', 'web', 'privacy', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Brave Search API key',
+ },
+ {
+ id: 'sqlite',
+ name: 'SQLite Server',
+ description: 'Local SQLite database operations and queries',
+ category: 'Database',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-sqlite'],
+ },
+ tags: ['verified', 'database', 'sqlite', 'local', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'git',
+ name: 'Git Server',
+ description: 'Git repository operations and version control',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-git'],
+ },
+ tags: ['verified', 'git', 'version-control', 'development', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'hacker-news',
+ name: 'Hacker News',
+ description: 'Access Hacker News articles and discussions',
+ category: 'Knowledge',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@punkpeye/hacker-news-mcp'],
+ },
+ tags: ['verified', 'news', 'tech', 'discussions', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'qdrant',
+ name: 'Qdrant Vector Search',
+ description: 'Vector similarity search with Qdrant database',
+ category: 'AI',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@qdrant/mcp-server'],
+ },
+ tags: ['verified', 'vector-search', 'ai', 'embeddings', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'kubernetes',
+ name: 'Kubernetes Server',
+ description: 'Kubernetes cluster management and operations',
+ category: 'DevOps',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@kubernetes/mcp-server'],
+ },
+ tags: ['verified', 'kubernetes', 'containers', 'devops', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Kubernetes cluster access',
+ },
+ {
+ id: 'elasticsearch',
+ name: 'Elasticsearch Server',
+ description: 'Search and analytics with Elasticsearch',
+ category: 'Database',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@elastic/mcp-server'],
+ },
+ tags: ['verified', 'search', 'analytics', 'elasticsearch', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Elasticsearch cluster access',
+ },
+ {
+ id: 'weather',
+ name: 'Weather Server',
+ description: 'Weather data and forecasts from various providers',
+ category: 'Utilities',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@weather/mcp-server'],
+ },
+ tags: ['verified', 'weather', 'forecast', 'utilities', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires weather API key (OpenWeatherMap, etc.)',
+ },
+ {
+ id: 'sentry',
+ name: 'Sentry Error Tracking',
+ description: 'Access Sentry error tracking and monitoring data',
+ category: 'Monitoring',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@sentry/mcp-server'],
+ },
+ tags: ['verified', 'monitoring', 'errors', 'logging', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Sentry API token',
+ },
+ {
+ id: 'twitter',
+ name: 'Twitter/X API',
+ description: 'Access Twitter/X posts, search, and user data',
+ category: 'Social',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@twitter/mcp-server'],
+ },
+ tags: ['verified', 'social', 'twitter', 'api', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Twitter API v2 Bearer Token',
+ },
+ {
+ id: 'reddit',
+ name: 'Reddit API',
+ description: 'Access Reddit posts, comments, and communities',
+ category: 'Social',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@reddit/mcp-server'],
+ },
+ tags: ['verified', 'social', 'reddit', 'api', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Reddit API credentials',
+ },
+ {
+ id: 'youtube',
+ name: 'YouTube API',
+ description: 'Search and access YouTube videos and channels',
+ category: 'Media',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@youtube/mcp-server'],
+ },
+ tags: ['verified', 'media', 'youtube', 'video', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires YouTube Data API v3 key',
+ },
+ {
+ id: 'openai',
+ name: 'OpenAI API',
+ description: 'Access OpenAI models, completions, and embeddings',
+ category: 'AI',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@openai/mcp-server'],
+ },
+ tags: ['verified', 'ai', 'openai', 'gpt', 'embeddings', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires OpenAI API key',
+ },
+ {
+ id: 'anthropic',
+ name: 'Anthropic Claude',
+ description: 'Access Anthropic Claude models and completions',
+ category: 'AI',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@anthropic/mcp-server'],
+ },
+ tags: ['verified', 'ai', 'anthropic', 'claude', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Anthropic API key',
+ },
+ {
+ id: 'perplexity',
+ name: 'Perplexity AI',
+ description: 'Advanced AI-powered search and research',
+ category: 'AI',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@perplexity/mcp-server'],
+ },
+ tags: ['verified', 'ai', 'search', 'research', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Perplexity API key',
+ },
+ {
+ id: 'replicate',
+ name: 'Replicate AI',
+ description: 'Access to thousands of AI models for various tasks',
+ category: 'AI',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@replicate/mcp-server'],
+ },
+ tags: ['verified', 'ai', 'models', 'inference', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires Replicate API token',
+ },
+ {
+ id: 'mem0-coding',
+ name: 'Mem0 Coding Assistant',
+ description: 'Manage coding preferences, patterns, and best practices',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@mem0ai/mem0-mcp'],
+ },
+ tags: ['verified', 'coding', 'preferences', 'patterns', 'best-practices', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'devstandards',
+ name: 'DevStandards MCP',
+ description: 'Access development best practices, security guidelines, and coding standards',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@ivangrynenko/devstandards_mcp'],
+ },
+ tags: ['verified', 'standards', 'security', 'best-practices', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'code-runner',
+ name: 'Code Runner MCP',
+ description: 'Execute code locally via Docker in multiple programming languages',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@axliupore/mcp-code-runner'],
+ },
+ tags: ['verified', 'code-execution', 'docker', 'multi-language', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'vscode-mcp',
+ name: 'VS Code MCP Server',
+ description: 'Interact with VS Code workspace, files, and editor capabilities',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@juehang/vscode-mcp-server'],
+ },
+ tags: ['verified', 'vscode', 'workspace', 'files', 'editor', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'xcode-mcp',
+ name: 'Xcode MCP Server',
+ description: 'Xcode project management, file operations, and build automation',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@r-huijts/xcode-mcp-server'],
+ },
+ tags: ['verified', 'xcode', 'ios', 'macos', 'build', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'context7-docs',
+ name: 'Context7 Documentation',
+ description: 'Up-to-date code documentation for AI code editors',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@context7/server'],
+ },
+ tags: ['verified', 'documentation', 'code-docs', 'ai-editor', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'shrimp-task-manager',
+ name: 'Shrimp Task Manager',
+ description: 'Programming-focused task management with memory and dependencies',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@cjo4m06/mcp-shrimp-task-manager'],
+ },
+ tags: ['verified', 'task-management', 'coding', 'memory', 'dependencies', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'sonarqube',
+ name: 'SonarQube Code Quality',
+ description: 'Code quality metrics, issues, and quality gate status',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@sonarsource/sonarqube-mcp'],
+ },
+ tags: ['verified', 'code-quality', 'metrics', 'issues', 'requires-auth'],
+ status: 'verified',
+ requiresAuth: true,
+ authNote: 'Requires SonarQube server access',
+ },
+ {
+ id: 'semgrep-security',
+ name: 'Semgrep Security Scanner',
+ description: 'Scan code for security vulnerabilities and bugs',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@semgrep/mcp-server'],
+ },
+ tags: ['verified', 'security', 'vulnerabilities', 'scanning', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'jupyter-mcp',
+ name: 'Jupyter Notebook MCP',
+ description: 'Real-time interaction with Jupyter notebooks for code editing and execution',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@datalayer/jupyter-mcp-server'],
+ },
+ tags: ['verified', 'jupyter', 'notebook', 'data-science', 'python', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ {
+ id: 'mcp-git-ingest',
+ name: 'Git Repository Analyzer',
+ description: 'Use LLM to read and analyze GitHub repositories',
+ category: 'Development',
+ icon:
,
+ config: {
+ type: 'stdio' as const,
+ command: 'npx',
+ args: ['-y', '@adhikasp/mcp-git-ingest'],
+ },
+ tags: ['verified', 'git', 'github', 'analysis', 'llm', 'no-auth'],
+ status: 'verified',
+ requiresAuth: false,
+ },
+ ],
+};
+
+const CATEGORIES = [
+ 'All',
+ 'General',
+ 'Files',
+ 'Development',
+ 'Database',
+ 'Web',
+ 'Knowledge',
+ 'Cloud',
+ 'AI',
+ 'Communication',
+ 'DevOps',
+ 'Utilities',
+ 'Monitoring',
+ 'Social',
+ 'Media',
+];
+
+const getCategoryIcon = (category: string): string => {
+ switch (category) {
+ case 'All':
+ return '📋 All';
+ case 'AI':
+ return '🤖 AI';
+ case 'Development':
+ return '💻 Dev';
+ case 'Database':
+ return '🗄️ DB';
+ case 'Web':
+ return '🌐 Web';
+ case 'Social':
+ return '👥 Social';
+ case 'Media':
+ return '📺 Media';
+ case 'DevOps':
+ return '⚙️ DevOps';
+ case 'Utilities':
+ return '🛠️ Utils';
+ case 'Monitoring':
+ return '📊 Monitor';
+ case 'Files':
+ return '📁 Files';
+ case 'Knowledge':
+ return '📚 Knowledge';
+ case 'Cloud':
+ return '☁️ Cloud';
+ case 'Communication':
+ return '💬 Comm';
+ default:
+ return category;
+ }
+};
+
+interface ApiKeyConfig {
+ [key: string]: {
+ label: string;
+ placeholder: string;
+ description: string;
+ required: boolean;
+ };
+}
+
+const SERVER_API_CONFIGS: Record
= {
+ github: {
+ GITHUB_PERSONAL_ACCESS_TOKEN: {
+ label: 'GitHub Personal Access Token',
+ placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxx',
+ description: 'Create at github.com/settings/tokens with repo and read:user scopes',
+ required: true,
+ },
+ },
+ slack: {
+ SLACK_BOT_TOKEN: {
+ label: 'Slack Bot Token',
+ placeholder: 'xoxb-xxxxxxxxxxxx-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from your Slack app settings (OAuth & Permissions)',
+ required: true,
+ },
+ },
+ gdrive: {
+ GOOGLE_CLIENT_ID: {
+ label: 'Google Client ID',
+ placeholder: 'xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com',
+ description: 'From Google Cloud Console OAuth 2.0 credentials',
+ required: true,
+ },
+ GOOGLE_CLIENT_SECRET: {
+ label: 'Google Client Secret',
+ placeholder: 'GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'From Google Cloud Console OAuth 2.0 credentials',
+ required: true,
+ },
+ },
+ postgres: {
+ DATABASE_URL: {
+ label: 'Database URL',
+ placeholder: 'postgresql://username:password@hostname:port/database',
+ description: 'PostgreSQL connection string',
+ required: true,
+ },
+ },
+ 'exa-search': {
+ EXA_API_KEY: {
+ label: 'Exa API Key',
+ placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get your API key from exa.ai dashboard',
+ required: true,
+ },
+ },
+ 'brave-search': {
+ BRAVE_API_KEY: {
+ label: 'Brave Search API Key',
+ placeholder: 'BSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get your API key from https://api.search.brave.com/app/keys',
+ required: true,
+ },
+ },
+ kubernetes: {
+ KUBECONFIG: {
+ label: 'Kubeconfig Path',
+ placeholder: '~/.kube/config',
+ description: 'Path to your Kubernetes configuration file',
+ required: true,
+ },
+ },
+ elasticsearch: {
+ ELASTICSEARCH_URL: {
+ label: 'Elasticsearch URL',
+ placeholder: 'http://localhost:9200',
+ description: 'Elasticsearch cluster URL',
+ required: true,
+ },
+ ELASTICSEARCH_API_KEY: {
+ label: 'Elasticsearch API Key',
+ placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Elasticsearch API key for authentication',
+ required: true,
+ },
+ },
+ weather: {
+ OPENWEATHER_API_KEY: {
+ label: 'OpenWeatherMap API Key',
+ placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get your API key from openweathermap.org',
+ required: true,
+ },
+ },
+ sentry: {
+ SENTRY_AUTH_TOKEN: {
+ label: 'Sentry Auth Token',
+ placeholder: 'sntrys_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Create at sentry.io/settings/account/api/auth-tokens',
+ required: true,
+ },
+ },
+ twitter: {
+ TWITTER_BEARER_TOKEN: {
+ label: 'Twitter Bearer Token',
+ placeholder: 'AAAAAAAAAAAAAAAAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Twitter Developer Portal (API v2)',
+ required: true,
+ },
+ },
+ reddit: {
+ REDDIT_CLIENT_ID: {
+ label: 'Reddit Client ID',
+ placeholder: 'your_client_id_here',
+ description: 'From Reddit App Settings',
+ required: true,
+ },
+ REDDIT_CLIENT_SECRET: {
+ label: 'Reddit Client Secret',
+ placeholder: 'your_client_secret_here',
+ description: 'From Reddit App Settings',
+ required: true,
+ },
+ },
+ youtube: {
+ YOUTUBE_API_KEY: {
+ label: 'YouTube API Key',
+ placeholder: 'AIzaSyAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Google Cloud Console (YouTube Data API v3)',
+ required: true,
+ },
+ },
+ openai: {
+ OPENAI_API_KEY: {
+ label: 'OpenAI API Key',
+ placeholder: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from OpenAI Platform (platform.openai.com/api-keys)',
+ required: true,
+ },
+ },
+ anthropic: {
+ ANTHROPIC_API_KEY: {
+ label: 'Anthropic API Key',
+ placeholder: 'sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Anthropic Console (console.anthropic.com/)',
+ required: true,
+ },
+ },
+ perplexity: {
+ PERPLEXITY_API_KEY: {
+ label: 'Perplexity API Key',
+ placeholder: 'pplx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Perplexity API dashboard',
+ required: true,
+ },
+ },
+ replicate: {
+ REPLICATE_API_TOKEN: {
+ label: 'Replicate API Token',
+ placeholder: 'r8_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Get from Replicate account settings',
+ required: true,
+ },
+ },
+ sonarqube: {
+ SONARQUBE_URL: {
+ label: 'SonarQube Server URL',
+ placeholder: 'http://localhost:9000',
+ description: 'URL of your SonarQube server',
+ required: true,
+ },
+ SONARQUBE_TOKEN: {
+ label: 'SonarQube Authentication Token',
+ placeholder: 'squ_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+ description: 'Generate from SonarQube: Administration > Security > Users',
+ required: true,
+ },
+ },
+};
+
+interface McpServerWizardProps {
+ onAddServer: (serverName: string, config: MCPServerConfig) => void;
+}
+
+export default function McpServerWizard({ onAddServer }: McpServerWizardProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [step, setStep] = useState<'template' | 'custom' | 'configure'>('template');
+ const [selectedTemplate, setSelectedTemplate] = useState(null);
+ const [customConfig, setCustomConfig] = useState({
+ type: 'stdio',
+ });
+ const [serverName, setServerName] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState('All');
+ const [apiKeys, setApiKeys] = useState>({});
+ const [customEnvVars, setCustomEnvVars] = useState>({});
+ const [newEnvVarKey, setNewEnvVarKey] = useState('');
+ const [newEnvVarValue, setNewEnvVarValue] = useState('');
+ const [previewedTemplate, setPreviewedTemplate] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const filteredTemplates = useMemo(() => {
+ const allTemplates = [...SERVER_TEMPLATES.stdio, ...SERVER_TEMPLATES.http];
+
+ // Filter by category first
+ const categoryFiltered =
+ selectedCategory === 'All'
+ ? allTemplates
+ : allTemplates.filter((template) => template.category === selectedCategory);
+
+ // Then filter by search query
+ if (!searchQuery.trim()) {
+ return categoryFiltered;
+ }
+
+ const query = searchQuery.toLowerCase();
+
+ return categoryFiltered.filter(
+ (template) =>
+ template.name.toLowerCase().includes(query) ||
+ template.description.toLowerCase().includes(query) ||
+ template.tags.some((tag: string) => tag.toLowerCase().includes(query)) ||
+ template.category.toLowerCase().includes(query),
+ );
+ }, [selectedCategory, searchQuery]);
+
+ useEffect(() => {
+ if (step !== 'template') {
+ return;
+ }
+
+ if (filteredTemplates.length > 0) {
+ if (!previewedTemplate || !filteredTemplates.find((t) => t.id === previewedTemplate.id)) {
+ setPreviewedTemplate(filteredTemplates[0]);
+ }
+ } else {
+ setPreviewedTemplate(null);
+ }
+ }, [filteredTemplates, previewedTemplate, step]);
+
+ const handleTemplateSelect = (template: any) => {
+ setSelectedTemplate(template);
+ setServerName(template.id);
+ setStep('configure');
+ };
+
+ const handleCustomConfigure = () => {
+ setSelectedTemplate(null);
+ setServerName('');
+ setCustomConfig({ type: 'stdio' });
+ setStep('custom');
+ };
+
+ const getCurrentStepNumber = () => {
+ switch (step) {
+ case 'template':
+ return 1;
+ case 'custom':
+ return 1;
+ case 'configure':
+ return selectedTemplate ? 2 : 3;
+ default:
+ return 1;
+ }
+ };
+
+ const handleSubmit = async () => {
+ setIsSubmitting(true);
+
+ try {
+ const baseConfig = selectedTemplate ? selectedTemplate.config : (customConfig as MCPServerConfig);
+
+ // Add API keys and custom environment variables
+ let finalConfig = { ...baseConfig };
+
+ // For custom servers, always use the custom environment variables
+ if (!selectedTemplate && Object.keys(customEnvVars).length > 0) {
+ if (baseConfig.type === 'stdio') {
+ finalConfig = {
+ ...baseConfig,
+ env: {
+ ...baseConfig.env,
+ ...customEnvVars,
+ },
+ };
+ } else if (baseConfig.type === 'streamable-http') {
+ // For HTTP servers, add custom env vars as headers if they look like headers
+ const authHeaders: Record = {};
+
+ Object.entries(customEnvVars).forEach(([key, value]) => {
+ if (key.toLowerCase().includes('authorization') || key.toLowerCase().includes('api')) {
+ if (key.toLowerCase().includes('bearer')) {
+ authHeaders.Authorization = `Bearer ${value}`;
+ } else {
+ authHeaders[key] = value;
+ }
+ }
+ });
+
+ if (Object.keys(authHeaders).length > 0) {
+ finalConfig = {
+ ...baseConfig,
+ headers: {
+ ...baseConfig.headers,
+ ...authHeaders,
+ },
+ };
+ }
+ }
+ }
+
+ // For template servers with authentication
+ if (selectedTemplate && selectedTemplate.requiresAuth && Object.keys(apiKeys).length > 0) {
+ if (baseConfig.type === 'stdio') {
+ finalConfig = {
+ ...baseConfig,
+ env: {
+ ...baseConfig.env,
+ ...apiKeys,
+ },
+ };
+ } else if (baseConfig.type === 'streamable-http') {
+ // For HTTP servers, add API keys as headers
+ const authHeaders: Record = {};
+
+ if (apiKeys.EXA_API_KEY) {
+ authHeaders.Authorization = `Bearer ${apiKeys.EXA_API_KEY}`;
+ }
+
+ finalConfig = {
+ ...baseConfig,
+ headers: {
+ ...baseConfig.headers,
+ ...authHeaders,
+ },
+ };
+ }
+ }
+
+ onAddServer(serverName, finalConfig);
+ setIsOpen(false);
+ resetWizard();
+ } catch (error) {
+ console.error('Error adding MCP server:', error);
+
+ // You could add error state here
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const resetWizard = () => {
+ setStep('template');
+ setSelectedTemplate(null);
+ setCustomConfig({ type: 'stdio' });
+ setServerName('');
+ setApiKeys({});
+ setCustomEnvVars({});
+ setNewEnvVarKey('');
+ setNewEnvVarValue('');
+ setSearchQuery('');
+ setIsSubmitting(false);
+ };
+
+ const isConfigValid = () => {
+ if (!serverName.trim()) {
+ return false;
+ }
+
+ // Check if required API keys are provided
+ if (selectedTemplate && selectedTemplate.requiresAuth) {
+ const requiredKeys = SERVER_API_CONFIGS[selectedTemplate.id];
+
+ if (requiredKeys) {
+ const missingKeys = Object.entries(requiredKeys)
+ .filter(([key, config]) => config.required && !apiKeys[key])
+ .map(([key]) => key);
+
+ if (missingKeys.length > 0) {
+ return false;
+ }
+ }
+ }
+
+ if (selectedTemplate) {
+ return true;
+ }
+
+ if (customConfig.type === 'stdio') {
+ return !!customConfig.command;
+ } else {
+ return !!customConfig.url;
+ }
+ };
+
+ const handleApiKeyChange = (key: string, value: string) => {
+ setApiKeys((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ const handleAddEnvVar = () => {
+ if (newEnvVarKey.trim() && newEnvVarValue.trim()) {
+ setCustomEnvVars((prev) => ({
+ ...prev,
+ [newEnvVarKey.trim()]: newEnvVarValue.trim(),
+ }));
+ setNewEnvVarKey('');
+ setNewEnvVarValue('');
+ }
+ };
+
+ const handleRemoveEnvVar = (key: string) => {
+ setCustomEnvVars((prev) => {
+ const newVars = { ...prev };
+ delete newVars[key];
+
+ return newVars;
+ });
+ };
+
+ const handleEnvVarChange = (key: string, value: string) => {
+ setCustomEnvVars((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ return (
+
+
+
+
+ Add MCP Server
+
+
+
+
+
+
+ Add MCP Server
+
+
+
+
+ {step === 'template' && (
+
+
+
+
+
+
Categories
+
+ {CATEGORIES.map((category) => (
+ setSelectedCategory(category)}
+ >
+ {getCategoryIcon(category)}
+
+ ))}
+
+
+
+ {filteredTemplates.map((template) => (
+
setPreviewedTemplate(template)}
+ onDoubleClick={() => handleTemplateSelect(template)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setPreviewedTemplate(template);
+ }
+ }}
+ tabIndex={0}
+ role="listitem"
+ aria-label={`Select ${template.name} server template`}
+ aria-selected={previewedTemplate?.id === template.id}
+ className={`p-4 rounded-xl cursor-pointer transition-all duration-200 border focus:outline-none focus:ring-2 focus:ring-bolt-elements-item-backgroundAccent focus:ring-offset-2 focus:ring-offset-bolt-elements-background-depth-1 ${
+ previewedTemplate?.id === template.id
+ ? 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent border-bolt-elements-item-backgroundAccent shadow-md'
+ : 'hover:bg-bolt-elements-background-depth-1 border-bolt-elements-borderColor hover:border-bolt-elements-item-backgroundAccent/50'
+ }`}
+ >
+
+
+ {template.icon}
+
+
+
{template.name}
+
{template.category}
+
+ {template.tags.slice(0, 2).map((tag: string) => (
+
+ {tag}
+
+ ))}
+ {template.tags.length > 2 && (
+
+ +{template.tags.length - 2}
+
+ )}
+
+
+ {template.requiresAuth && (
+
+
+ 🔐 Auth
+
+
+ )}
+
+
+ ))}
+
+ {filteredTemplates.length === 0 && (
+
+
+
+ {searchQuery ? 'No servers match your search' : 'No servers in this category'}
+
+
+ )}
+
+
+
+ {previewedTemplate ? (
+ <>
+
+
+
+
+ {previewedTemplate.icon}
+
+
+
+
+ {previewedTemplate.name}
+
+
+ {previewedTemplate.status === 'verified' && (
+
+ ✓ Verified
+
+ )}
+ {previewedTemplate.requiresAuth && 🔐 Requires Auth }
+
+
+
+ {previewedTemplate.description}
+
+
+
+
+ {previewedTemplate.category}
+
+
+
+ {previewedTemplate.config.type}
+
+
+
+
+
+
+ {previewedTemplate.authNote && (
+
+
+
+
+ Authentication Required: {' '}
+ {previewedTemplate.authNote}
+
+
+
+ )}
+
+
+
+
+
+ Tags
+
+
+
+ {previewedTemplate.tags.map((tag: string) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+
+ Server Type
+
+
+
+
+ {previewedTemplate.config.type === 'stdio'
+ ? 'STDIO'
+ : previewedTemplate.config.type === 'streamable-http'
+ ? 'HTTP'
+ : 'SSE'}
+
+ {previewedTemplate.config.type === 'stdio' && (
+ Local process execution
+ )}
+
+
+
+
+
+
+
+ Configuration Preview
+
+
+
+
+
+ Type:
+
+ {previewedTemplate.config.type}
+
+
+ {previewedTemplate.config.command && (
+
+ Command:
+
+ {previewedTemplate.config.command}
+
+
+ )}
+ {previewedTemplate.config.url && (
+
+ URL:
+
+ {previewedTemplate.config.url}
+
+
+ )}
+ {previewedTemplate.config.args && previewedTemplate.config.args.length > 0 && (
+
+
Arguments:
+
+ {previewedTemplate.config.args.map((arg: string, index: number) => (
+
+ {arg}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+ Create Custom Server
+
+
handleTemplateSelect(previewedTemplate)}
+ >
+ Configure Template
+
+
+
+ >
+ ) : (
+
+
+
+
+
+ {searchQuery ? 'No servers match your search' : 'No templates found'}
+
+
+ {searchQuery
+ ? `Try adjusting your search terms or browse all categories.`
+ : 'Try selecting a different category or create a custom configuration.'}
+
+
+ {searchQuery && (
+ setSearchQuery('')}>
+
+ Clear Search
+
+ )}
+
+
+ Create Custom Server
+
+
+
+ )}
+
+
+ )}
+
+ {step === 'custom' && (
+
+
+
Custom MCP Server Configuration
+
Configure your own MCP server connection
+
+
+
setCustomConfig({ ...customConfig, type: value as any })}
+ >
+
+
+
+ STDIO
+
+
+
+ SSE
+
+
+
+ HTTP
+
+
+
+
+
+
Command
+
setCustomConfig({ ...customConfig, command: e.target.value })}
+ className={!customConfig.command ? 'border-red-500/50 focus:border-red-500' : ''}
+ aria-invalid={!customConfig.command}
+ aria-describedby={!customConfig.command ? 'command-error' : undefined}
+ />
+ {!customConfig.command && (
+
+
+ Command is required for STDIO servers
+
+ )}
+
+
+ Arguments (one per line)
+
+
+ {/* Environment Variables Section */}
+
+
Environment Variables
+
+ {/* Add new environment variable */}
+
+ setNewEnvVarKey(e.target.value)}
+ className="flex-1"
+ />
+ setNewEnvVarValue(e.target.value)}
+ className="flex-1"
+ />
+
+ Add
+
+
+
+ {/* Display existing environment variables */}
+ {Object.keys(customEnvVars).length > 0 && (
+
+ {Object.entries(customEnvVars).map(([key, value]) => (
+
+ {key}
+ handleEnvVarChange(key, e.target.value)}
+ className="flex-1 text-xs"
+ />
+ handleRemoveEnvVar(key)}
+ size="sm"
+ variant="outline"
+ className="px-2"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {Object.keys(customEnvVars).length === 0 && (
+
+ No environment variables added. Add API keys, tokens, or other configuration here.
+
+ )}
+
+ {Object.keys(customEnvVars).length > 0 && (
+
+
+ ⚠️
+
+ Security: Environment variables containing sensitive data may be visible in
+ system process lists. Consider using secure credential storage for production use.
+
+
+
+ )}
+
+
+
+
+
+ Server URL
+ setCustomConfig({ ...customConfig, url: e.target.value })}
+ />
+
+
+ {/* Headers Section for SSE */}
+
+
Headers (Optional)
+
+ setNewEnvVarKey(e.target.value)}
+ className="flex-1"
+ />
+ setNewEnvVarValue(e.target.value)}
+ className="flex-1"
+ />
+
+ Add
+
+
+
+ {Object.keys(customEnvVars).length > 0 && (
+ <>
+
+ {Object.entries(customEnvVars).map(([key, value]) => (
+
+ {key}
+ handleEnvVarChange(key, e.target.value)}
+ className="flex-1 text-xs"
+ />
+ handleRemoveEnvVar(key)}
+ size="sm"
+ variant="outline"
+ className="px-2"
+ >
+
+
+
+ ))}
+
+
+
+ ⚠️
+
+ Security: Headers containing sensitive data may be visible in logs.
+ Consider using secure credential storage for production use.
+
+
+
+ >
+ )}
+
+
+
+
+
+
Server URL
+
setCustomConfig({ ...customConfig, url: e.target.value })}
+ className={!customConfig.url ? 'border-red-500/50 focus:border-red-500' : ''}
+ aria-invalid={!customConfig.url}
+ aria-describedby={!customConfig.url ? 'url-error' : undefined}
+ />
+ {!customConfig.url && (
+
+
+ URL is required for HTTP servers
+
+ )}
+
+
+ {/* Headers Section for HTTP */}
+
+
Headers (Optional)
+
+ setNewEnvVarKey(e.target.value)}
+ className="flex-1"
+ />
+ setNewEnvVarValue(e.target.value)}
+ className="flex-1"
+ />
+
+ Add
+
+
+
+ {Object.keys(customEnvVars).length > 0 && (
+ <>
+
+ {Object.entries(customEnvVars).map(([key, value]) => (
+
+ {key}
+ handleEnvVarChange(key, e.target.value)}
+ className="flex-1 text-xs"
+ />
+ handleRemoveEnvVar(key)}
+ size="sm"
+ variant="outline"
+ className="px-2"
+ >
+
+
+
+ ))}
+
+
+
+ ⚠️
+
+ Security: Headers containing sensitive data may be visible in logs.
+ Consider using secure credential storage for production use.
+
+
+
+ >
+ )}
+
+
+
+
+ )}
+
+ {step === 'configure' && (
+
+
+
+ {selectedTemplate ? `Configure ${selectedTemplate.name}` : 'Configure Custom Server'}
+
+
Finalize your server configuration
+
+
+
+
+
Server Name
+
setServerName(e.target.value)}
+ className={!serverName.trim() ? 'border-red-500/50 focus:border-red-500' : ''}
+ aria-invalid={!serverName.trim()}
+ aria-describedby={!serverName.trim() ? 'server-name-error' : undefined}
+ />
+ {!serverName.trim() && (
+
+
+ Server name is required
+
+ )}
+
+
+ {selectedTemplate && selectedTemplate.requiresAuth && SERVER_API_CONFIGS[selectedTemplate.id] && (
+
+
+ 🔑 API Configuration Required
+
+ This server requires API keys or credentials to function properly
+
+
+
+ {Object.entries(SERVER_API_CONFIGS[selectedTemplate.id]).map(([key, config]) => (
+
+
+ {config.label}
+ {config.required && * }
+
+
handleApiKeyChange(key, e.target.value)}
+ className="font-mono text-xs"
+ />
+
{config.description}
+
+ ))}
+
+
+
+ ⚠️
+ Security Warning
+
+
+ Important: API keys are passed to MCP servers via environment variables.
+ While they are stored securely and not logged, they may be visible in system process lists.
+ Use caution with sensitive credentials.
+
+
+
+
+ )}
+
+ {selectedTemplate && (
+
+
+
+ {selectedTemplate.icon}
+
+ {selectedTemplate.name}
+ {selectedTemplate.description}
+
+
+
+
+
+
+ {JSON.stringify(sanitizeConfigForDisplay(selectedTemplate.config), null, 2)}
+
+
+
+
+ )}
+
+
+
+
setStep('template')} disabled={isSubmitting}>
+
+ Back
+
+
+ {isSubmitting ? (
+ <>
+
+ Adding Server...
+ >
+ ) : (
+ <>
+
+ Add Server
+ >
+ )}
+
+
+
+ )}
+
+ {step === 'custom' && (
+
+
setStep('template')} disabled={isSubmitting}>
+
+ Back
+
+
setStep('configure')}
+ disabled={!customConfig.command && !customConfig.url}
+ >
+ Continue
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/mcp/McpStatusBadge.tsx b/app/components/@settings/tabs/mcp/McpStatusBadge.tsx
index 3cbbb1f1f4..679883d1a0 100644
--- a/app/components/@settings/tabs/mcp/McpStatusBadge.tsx
+++ b/app/components/@settings/tabs/mcp/McpStatusBadge.tsx
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
+import { CheckCircle, AlertCircle } from 'lucide-react';
export default function McpStatusBadge({ status }: { status: 'checking' | 'available' | 'unavailable' }) {
const { styles, label, icon, ariaLabel } = useMemo(() => {
@@ -15,13 +16,13 @@ export default function McpStatusBadge({ status }: { status: 'checking' | 'avail
styles: `${base} bg-green-100 text-green-800 dark:bg-green-900/80 dark:text-green-200`,
label: 'Available',
ariaLabel: 'Server available',
- icon: ,
+ icon: ,
},
unavailable: {
styles: `${base} bg-red-100 text-red-800 dark:bg-red-900/80 dark:text-red-200`,
label: 'Unavailable',
ariaLabel: 'Server unavailable',
- icon: ,
+ icon: ,
},
};
diff --git a/app/components/@settings/tabs/mcp/McpTab.tsx b/app/components/@settings/tabs/mcp/McpTab.tsx
index 9fb765be84..d7897366e7 100644
--- a/app/components/@settings/tabs/mcp/McpTab.tsx
+++ b/app/components/@settings/tabs/mcp/McpTab.tsx
@@ -4,6 +4,15 @@ import type { MCPConfig } from '~/lib/services/mcpService';
import { toast } from 'react-toastify';
import { useMCPStore } from '~/lib/stores/mcp';
import McpServerList from '~/components/@settings/tabs/mcp/McpServerList';
+import McpServerWizard from '~/components/@settings/tabs/mcp/McpServerWizard';
+import EnhancedMcpServerListItem from '~/components/@settings/tabs/mcp/EnhancedMcpServerListItem';
+import ToolBrowser from '~/components/@settings/tabs/mcp/ToolBrowser';
+import { Button } from '~/components/ui/Button';
+import { Badge } from '~/components/ui/Badge';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/Tabs';
+import { Textarea } from '~/components/ui/Textarea';
+import { Input } from '~/components/ui/Input';
+import { RotateCcw, ExternalLink, Save, Settings, Play, Pause, CheckCircle, Server, Wrench } from 'lucide-react';
const EXAMPLE_MCP_CONFIG: MCPConfig = {
mcpServers: {
@@ -33,6 +42,9 @@ export default function McpTab() {
const initialize = useMCPStore((state) => state.initialize);
const updateSettings = useMCPStore((state) => state.updateSettings);
const checkServersAvailabilities = useMCPStore((state) => state.checkServersAvailabilities);
+ const toggleServer = useMCPStore((state) => state.toggleServer);
+ const addServer = useMCPStore((state) => state.addServer);
+ const removeServer = useMCPStore((state) => state.removeServer);
const [isSaving, setIsSaving] = useState(false);
const [mcpConfigText, setMCPConfigText] = useState('');
@@ -40,6 +52,10 @@ export default function McpTab() {
const [error, setError] = useState(null);
const [isCheckingServers, setIsCheckingServers] = useState(false);
const [expandedServer, setExpandedServer] = useState(null);
+ const [viewMode, setViewMode] = useState<'enhanced' | 'json'>('enhanced');
+ const [bulkActionMode, setBulkActionMode] = useState(false);
+ const [selectedServers, setSelectedServers] = useState>(new Set());
+ const [activeTab, setActiveTab] = useState('servers');
useEffect(() => {
if (!isInitialized) {
@@ -81,6 +97,8 @@ export default function McpTab() {
await updateSettings({
mcpConfig: parsedConfig,
maxLLMSteps,
+ enabledServers: settings.enabledServers,
+ version: settings.version,
});
toast.success('MCP configuration saved');
@@ -121,119 +139,368 @@ export default function McpTab() {
const serverEntries = useMemo(() => Object.entries(serverTools), [serverTools]);
+ const serverStats = useMemo(() => {
+ const total = serverEntries.length;
+ const available = serverEntries.filter(([, server]) => server.status === 'available').length;
+ const enabled = Object.keys(settings.mcpConfig.mcpServers).filter(
+ (name) => settings.enabledServers[name] !== false,
+ ).length;
+ const totalTools = serverEntries
+ .filter(([, server]) => server.status === 'available')
+ .reduce((acc, [, server]) => acc + (server.status === 'available' ? Object.keys(server.tools).length : 0), 0);
+
+ return { total, available, enabled, totalTools };
+ }, [serverEntries, settings]);
+
+ const handleAddServer = async (serverName: string, config: any) => {
+ try {
+ await addServer(serverName, config);
+ toast.success(`Added MCP server "${serverName}"`);
+ } catch (error) {
+ toast.error(`Failed to add server: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ };
+
+ const handleToggleServer = async (serverName: string, enabled: boolean) => {
+ try {
+ await toggleServer(serverName, enabled);
+ toast.success(`${enabled ? 'Enabled' : 'Disabled'} server "${serverName}"`);
+ } catch (error) {
+ toast.error(
+ `Failed to ${enabled ? 'enable' : 'disable'} server: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ );
+ }
+ };
+
+ const handleRemoveServer = async (serverName: string) => {
+ try {
+ await removeServer(serverName);
+ toast.success(`Removed server "${serverName}"`);
+ } catch (error) {
+ toast.error(`Failed to remove server: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ };
+
+ const handleBulkEnable = async () => {
+ const promises = Array.from(selectedServers).map((serverName) => handleToggleServer(serverName, true));
+ await Promise.allSettled(promises);
+ setSelectedServers(new Set());
+ setBulkActionMode(false);
+ };
+
+ const handleBulkDisable = async () => {
+ const promises = Array.from(selectedServers).map((serverName) => handleToggleServer(serverName, false));
+ await Promise.allSettled(promises);
+ setSelectedServers(new Set());
+ setBulkActionMode(false);
+ };
+
+ const handleEditServer = async (serverName: string, newConfig: any) => {
+ try {
+ const currentSettings = settings;
+ const updatedSettings = {
+ ...currentSettings,
+ mcpConfig: {
+ mcpServers: {
+ ...currentSettings.mcpConfig.mcpServers,
+ [serverName]: newConfig,
+ },
+ },
+ };
+
+ await updateSettings(updatedSettings);
+ toast.success(`Updated server "${serverName}"`);
+ } catch (error) {
+ toast.error(`Failed to update server: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ };
+
+ const selectAllServers = () => {
+ setSelectedServers(new Set(serverEntries.map(([name]) => name)));
+ };
+
+ const deselectAllServers = () => {
+ setSelectedServers(new Set());
+ };
+
return (
-
-
-
-
MCP Servers Configured {' '}
-
- {isCheckingServers ? (
-
- ) : (
-
- )}
- Check availability
-
+
+ {/* Header with Stats */}
+
+
+
MCP Integration
+
+ Manage Model Context Protocol servers and browse available tools
+
-
-
-
-
- Configuration
-
-
-
-
- Configuration JSON
-
-
+
+ {/* Security Warning */}
+
+
+
⚠️
-
- Maximum number of sequential LLM calls (steps)
-
- handleMaxLLMCallChange(e.target.value)}
- className="w-full px-3 py-2 text-bolt-elements-textPrimary text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
- />
-
-
- The MCP configuration format is identical to the one used in Claude Desktop.
-
- View example servers
-
-
+
+ Security Notice: API Key Visibility
+
+
+ MCP servers configured with API keys may expose them in system process lists. Sensitive environment
+ variables are now redacted from logs, but consider using secure credential storage for production
+ deployments.
+
-
-
-
-
- Load Example
-
-
-
-
-
- {isSaving ? 'Saving...' : 'Save Configuration'}
-
-
+
+ {/* Main Tabs */}
+
+
+
+
+ Servers
+
+
+
+ Tools
+
+
+
+ Config
+
+
+
+ {/* Server Management Tab */}
+
+ {/* Action Bar */}
+
+
+
+
+
+
+
setViewMode(viewMode === 'enhanced' ? 'json' : 'enhanced')}
+ className="flex items-center gap-2"
+ >
+
+ {viewMode === 'enhanced' ? 'JSON Mode' : 'Enhanced Mode'}
+
+
+ {serverEntries.length > 0 && (
+
setBulkActionMode(!bulkActionMode)}
+ className="flex items-center gap-2"
+ >
+
+ {bulkActionMode ? 'Exit Selection' : 'Bulk Actions'}
+
+ )}
+
+
+
+
+ {isCheckingServers ? (
+
+ ) : (
+
+ )}
+ Check Status
+
+
+
+
+ {/* Bulk Actions Bar */}
+ {bulkActionMode && (
+
+
+
+
+ {selectedServers.size} server{selectedServers.size !== 1 ? 's' : ''} selected
+
+
+
+ Select All
+
+
+ Clear
+
+
+
+
+ {selectedServers.size > 0 && (
+
+
+
+ Enable Selected
+
+
+
+ Disable Selected
+
+
+ )}
+
+
+ )}
+
+ {/* Server List */}
+ {viewMode === 'enhanced' ? (
+
+
+ {serverEntries.length === 0 ? (
+
+
+
No MCP Servers
+
+ Add your first MCP server to get started
+
+
+
+ ) : (
+ serverEntries.map(([serverName, mcpServer]) => (
+
+ ))
+ )}
+
+
+ ) : (
+
+ )}
+
+
+ {/* Tool Browser Tab */}
+
+
+
+
+ {/* Configuration Tab */}
+
+
+ Advanced Configuration
+
+
+
+
+ Configuration JSON
+
+
+
+
+
+ Maximum number of sequential LLM calls (steps)
+
+ handleMaxLLMCallChange(e.target.value)}
+ className="bg-bolt-elements-background-depth-2 border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary focus:border-bolt-elements-borderColorActive"
+ />
+
+
+
+
+
+
+ Load Example
+
+
+
+
+
+ {isSaving ? 'Saving...' : 'Save Configuration'}
+
+
+
+
+
+
);
}
diff --git a/app/components/@settings/tabs/mcp/ToolBrowser.tsx b/app/components/@settings/tabs/mcp/ToolBrowser.tsx
new file mode 100644
index 0000000000..92061572ca
--- /dev/null
+++ b/app/components/@settings/tabs/mcp/ToolBrowser.tsx
@@ -0,0 +1,230 @@
+import { useState } from 'react';
+import ToolCategoryFilter from './ToolCategoryFilter';
+import McpServerListItem from './McpServerListItem';
+import { Badge } from '~/components/ui/Badge';
+import { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/Card';
+import { Button } from '~/components/ui/Button';
+import { ChevronDown, ChevronUp, Server, Wrench as ToolIcon, Copy } from 'lucide-react';
+import type { Tool } from 'ai';
+
+interface ToolInfo {
+ serverName: string;
+ toolName: string;
+ tool: Tool;
+ category: string;
+}
+
+interface ToolBrowserProps {
+ serverTools: Record
;
+}
+
+export default function ToolBrowser({ serverTools }: ToolBrowserProps) {
+ const [selectedCategory, setSelectedCategory] = useState('all');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [expandedTool, setExpandedTool] = useState(null);
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
+
+ const toggleToolExpanded = (toolKey: string) => {
+ setExpandedTool(expandedTool === toolKey ? null : toolKey);
+ };
+
+ const copyToolName = async (toolName: string) => {
+ try {
+ await navigator.clipboard.writeText(toolName);
+ } catch {
+ // Fallback for browsers that don't support clipboard API
+ const textArea = document.createElement('textarea');
+ textArea.value = toolName;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ }
+ };
+
+ const renderToolCard = (toolInfo: ToolInfo) => {
+ const toolKey = `${toolInfo.serverName}-${toolInfo.toolName}`;
+ const isExpanded = expandedTool === toolKey;
+
+ return (
+
+
+
+
+
+
+
{toolInfo.toolName}
+
+
+
+ {toolInfo.serverName}
+
+
+ {toolInfo.category}
+
+
+
+
+
+ copyToolName(toolInfo.toolName)}
+ className="h-6 w-6 p-0 !bg-transparent border border-bolt-elements-borderColor hover:!bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary"
+ title="Copy tool name"
+ >
+
+
+ toggleToolExpanded(toolKey)}
+ className="h-6 w-6 p-0 !bg-transparent border border-bolt-elements-borderColor hover:!bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary"
+ >
+ {isExpanded ? : }
+
+
+
+
+
+ {toolInfo.tool.description && (
+
+ {toolInfo.tool.description}
+
+ )}
+
+ {isExpanded && (
+
+
+
+ )}
+
+ );
+ };
+
+ const renderToolList = (toolInfo: ToolInfo) => {
+ const toolKey = `${toolInfo.serverName}-${toolInfo.toolName}`;
+ const isExpanded = expandedTool === toolKey;
+
+ return (
+
+
+
+
+
+
+
+
{toolInfo.toolName}
+
+ {toolInfo.serverName}
+
+
+ {toolInfo.tool.description && (
+
+ {toolInfo.tool.description}
+
+ )}
+
+
+
+
+
+ {toolInfo.category}
+
+ copyToolName(toolInfo.toolName)}
+ className="h-6 w-6 p-0 !bg-transparent border border-bolt-elements-borderColor hover:!bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary"
+ title="Copy tool name"
+ >
+
+
+ toggleToolExpanded(toolKey)}
+ className="h-6 w-6 p-0 !bg-transparent border border-bolt-elements-borderColor hover:!bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary"
+ >
+ {isExpanded ? : }
+
+
+
+
+ {isExpanded && (
+
+
+
+ )}
+
+
+ );
+ };
+
+ return (
+
+
+
Tool Browser
+
+ setViewMode('list')}
+ className={
+ viewMode === 'list'
+ ? '!bg-bolt-elements-item-backgroundAccent !text-bolt-elements-item-contentAccent hover:!bg-bolt-elements-button-primary-backgroundHover'
+ : ''
+ }
+ >
+ List
+
+ setViewMode('grid')}
+ className={
+ viewMode === 'grid'
+ ? '!bg-bolt-elements-item-backgroundAccent !text-bolt-elements-item-contentAccent hover:!bg-bolt-elements-button-primary-backgroundHover'
+ : ''
+ }
+ >
+ Grid
+
+
+
+
+
+ {(filteredTools) => (
+
+ {filteredTools.length === 0 ? (
+
+
+
No tools found
+
+ {searchTerm
+ ? `No tools match "${searchTerm}"`
+ : selectedCategory !== 'all'
+ ? `No tools in the ${selectedCategory} category`
+ : 'No tools are currently available'}
+
+
+ ) : viewMode === 'grid' ? (
+
{filteredTools.map(renderToolCard)}
+ ) : (
+
{filteredTools.map(renderToolList)}
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/mcp/ToolCategoryFilter.tsx b/app/components/@settings/tabs/mcp/ToolCategoryFilter.tsx
new file mode 100644
index 0000000000..1fc9aaa26a
--- /dev/null
+++ b/app/components/@settings/tabs/mcp/ToolCategoryFilter.tsx
@@ -0,0 +1,264 @@
+import { useMemo } from 'react';
+import { Badge } from '~/components/ui/Badge';
+import { Input } from '~/components/ui/Input';
+import { classNames } from '~/utils/classNames';
+import {
+ Search,
+ FileText,
+ Globe,
+ Code2,
+ Database,
+ Cloud,
+ Shield,
+ Cpu,
+ MessageSquare,
+ Settings,
+ Filter,
+} from 'lucide-react';
+import type { Tool } from 'ai';
+
+// Tool categories based on common patterns
+const TOOL_CATEGORIES = {
+ files: {
+ name: 'Files',
+ icon: ,
+ keywords: ['file', 'directory', 'read', 'write', 'upload', 'download', 'filesystem'],
+ color: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20',
+ },
+ web: {
+ name: 'Web',
+ icon: ,
+ keywords: ['http', 'url', 'web', 'api', 'fetch', 'request', 'browser', 'scrape'],
+ color: 'bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20',
+ },
+ code: {
+ name: 'Code',
+ icon: ,
+ keywords: ['code', 'execute', 'run', 'compile', 'build', 'lint', 'format', 'git'],
+ color: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border border-purple-500/20',
+ },
+ data: {
+ name: 'Data',
+ icon: ,
+ keywords: ['database', 'query', 'sql', 'json', 'csv', 'parse', 'transform', 'analyze'],
+ color: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20',
+ },
+ cloud: {
+ name: 'Cloud',
+ icon: ,
+ keywords: ['aws', 'azure', 'gcp', 'docker', 'kubernetes', 'deploy', 'container'],
+ color: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border border-cyan-500/20',
+ },
+ security: {
+ name: 'Security',
+ icon: ,
+ keywords: ['auth', 'security', 'encrypt', 'decrypt', 'token', 'key', 'certificate'],
+ color: 'bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20',
+ },
+ system: {
+ name: 'System',
+ icon: ,
+ keywords: ['system', 'process', 'memory', 'cpu', 'disk', 'monitor', 'status'],
+ color:
+ 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor',
+ },
+ communication: {
+ name: 'Communication',
+ icon: ,
+ keywords: ['email', 'slack', 'discord', 'webhook', 'notify', 'message', 'send'],
+ color: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border border-pink-500/20',
+ },
+};
+
+interface ToolInfo {
+ serverName: string;
+ toolName: string;
+ tool: Tool;
+ category: string;
+}
+
+interface ToolCategoryFilterProps {
+ serverTools: Record;
+ selectedCategory: string;
+ searchTerm: string;
+ onCategoryChange: (category: string) => void;
+ onSearchChange: (search: string) => void;
+ children: (filteredTools: ToolInfo[]) => React.ReactNode;
+}
+
+export default function ToolCategoryFilter({
+ serverTools,
+ selectedCategory,
+ searchTerm,
+ onCategoryChange,
+ onSearchChange,
+ children,
+}: ToolCategoryFilterProps) {
+ const allTools = useMemo(() => {
+ const tools: ToolInfo[] = [];
+
+ Object.entries(serverTools).forEach(([serverName, serverInfo]) => {
+ if (serverInfo.status === 'available') {
+ Object.entries(serverInfo.tools).forEach(([toolName, tool]) => {
+ const category = categorizeTools(toolName, tool as Tool);
+ tools.push({
+ serverName,
+ toolName,
+ tool: tool as Tool,
+ category,
+ });
+ });
+ }
+ });
+
+ return tools;
+ }, [serverTools]);
+
+ const filteredTools = useMemo(() => {
+ let filtered = allTools;
+
+ // Filter by category
+ if (selectedCategory !== 'all') {
+ filtered = filtered.filter((tool) => tool.category === selectedCategory);
+ }
+
+ // Filter by search term
+ if (searchTerm) {
+ const searchLower = searchTerm.toLowerCase();
+ filtered = filtered.filter(
+ (tool) =>
+ tool.toolName.toLowerCase().includes(searchLower) ||
+ tool.tool.description?.toLowerCase().includes(searchLower) ||
+ tool.serverName.toLowerCase().includes(searchLower),
+ );
+ }
+
+ return filtered;
+ }, [allTools, selectedCategory, searchTerm]);
+
+ const categoryStats = useMemo(() => {
+ const stats: Record = { all: allTools.length };
+
+ (Object.keys(TOOL_CATEGORIES) as Array).forEach((category) => {
+ stats[category] = allTools.filter((tool) => tool.category === category).length;
+ });
+
+ stats.other = allTools.filter((tool) => tool.category === 'other').length;
+
+ return stats;
+ }, [allTools]);
+
+ return (
+
+ {/* Search Bar */}
+
+
+ onSearchChange(e.target.value)}
+ className="pl-10"
+ />
+
+
+ {/* Category Filters */}
+
+ onCategoryChange('all')}
+ >
+
+ All ({categoryStats.all})
+
+
+ {Object.entries(TOOL_CATEGORIES).map(([key, category]) => (
+ onCategoryChange(key)}
+ >
+ {category.icon}
+ {category.name} ({categoryStats[key] || 0})
+
+ ))}
+
+ {categoryStats.other > 0 && (
+ onCategoryChange('other')}
+ >
+
+ Other ({categoryStats.other})
+
+ )}
+
+
+ {/* Results Summary */}
+
+
+ {filteredTools.length} tool{filteredTools.length !== 1 ? 's' : ''}
+ {searchTerm && ` matching "${searchTerm}"`}
+ {selectedCategory !== 'all' &&
+ ` in ${TOOL_CATEGORIES[selectedCategory as keyof typeof TOOL_CATEGORIES]?.name || selectedCategory}`}
+
+ {(searchTerm || selectedCategory !== 'all') && (
+ {
+ onSearchChange('');
+ onCategoryChange('all');
+ }}
+ className="text-bolt-elements-textPrimary hover:underline"
+ >
+ Clear filters
+
+ )}
+
+
+ {/* Render filtered tools */}
+ {children(filteredTools)}
+
+ );
+}
+
+function categorizeTools(toolName: string, tool: Tool): string {
+ const searchText = `${toolName} ${tool.description || ''}`.toLowerCase();
+
+ // Check for exact matches first
+ const exactMatches = {
+ files: ['read_file', 'write_file', 'list_directory', 'create_directory', 'delete_file'],
+ web: ['fetch', 'get_url', 'web_search', 'http_request', 'scrape'],
+ code: ['execute_code', 'run_command', 'git_clone', 'build_project'],
+ data: ['query_database', 'parse_json', 'transform_data', 'analyze'],
+ };
+
+ for (const [category, exactTools] of Object.entries(exactMatches)) {
+ if (exactTools.some((exact) => toolName.toLowerCase().includes(exact))) {
+ return category;
+ }
+ }
+
+ // Fall back to keyword matching
+ for (const [category, config] of Object.entries(TOOL_CATEGORIES)) {
+ if (config.keywords.some((keyword) => searchText.includes(keyword))) {
+ return category;
+ }
+ }
+
+ return 'other';
+}
diff --git a/app/components/@settings/tabs/netlify/NetlifyTab.tsx b/app/components/@settings/tabs/netlify/NetlifyTab.tsx
new file mode 100644
index 0000000000..27222383f2
--- /dev/null
+++ b/app/components/@settings/tabs/netlify/NetlifyTab.tsx
@@ -0,0 +1,1146 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { toast } from 'react-toastify';
+import { classNames } from '~/utils/classNames';
+import { useStore } from '@nanostores/react';
+import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
+import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import { formatDistanceToNow } from 'date-fns';
+import { Badge } from '~/components/ui/Badge';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+interface SiteAction {
+ name: string;
+ icon: string;
+ action: (siteId: string) => Promise;
+ requiresConfirmation?: boolean;
+ variant?: 'default' | 'destructive' | 'outline';
+}
+
+// Netlify logo SVG component
+const NetlifyLogo = () => (
+
+
+
+);
+
+export default function NetlifyTab() {
+ const connection = useStore(netlifyConnection);
+ const [tokenInput, setTokenInput] = useState('');
+ const [fetchingStats, setFetchingStats] = useState(false);
+ const [sites, setSites] = useState([]);
+ const [deploys, setDeploys] = useState([]);
+ const [deploymentCount, setDeploymentCount] = useState(0);
+ const [lastUpdated, setLastUpdated] = useState('');
+ const [isStatsOpen, setIsStatsOpen] = useState(false);
+ const [activeSiteIndex, setActiveSiteIndex] = useState(0);
+ const [isSitesExpanded, setIsSitesExpanded] = useState(false);
+ const [isDeploysExpanded, setIsDeploysExpanded] = useState(false);
+ const [isActionLoading, setIsActionLoading] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [connectionTest, setConnectionTest] = useState(null);
+
+ // Connection testing function
+ const testConnection = async () => {
+ if (!connection.token) {
+ setConnectionTest({
+ status: 'error',
+ message: 'No token provided',
+ timestamp: Date.now(),
+ });
+ return;
+ }
+
+ setConnectionTest({
+ status: 'testing',
+ message: 'Testing connection...',
+ });
+
+ try {
+ const response = await fetch('https://api.netlify.com/api/v1/user', {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (response.ok) {
+ const data = (await response.json()) as any;
+ setConnectionTest({
+ status: 'success',
+ message: `Connected successfully as ${data.email}`,
+ timestamp: Date.now(),
+ });
+ } else {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${response.status} ${response.statusText}`,
+ timestamp: Date.now(),
+ });
+ }
+ } catch (error) {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ timestamp: Date.now(),
+ });
+ }
+ };
+
+ // Site actions
+ const siteActions: SiteAction[] = [
+ {
+ name: 'Clear Cache',
+ icon: 'i-ph:arrows-clockwise',
+ action: async (siteId: string) => {
+ try {
+ const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/cache`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to clear cache');
+ }
+
+ toast.success('Site cache cleared successfully');
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to clear site cache: ${error}`);
+ }
+ },
+ },
+ {
+ name: 'Delete Site',
+ icon: 'i-ph:trash',
+ action: async (siteId: string) => {
+ try {
+ const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete site');
+ }
+
+ toast.success('Site deleted successfully');
+ fetchNetlifyStats(connection.token);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to delete site: ${error}`);
+ }
+ },
+ requiresConfirmation: true,
+ variant: 'destructive',
+ },
+ ];
+
+ // Deploy management functions
+ const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => {
+ try {
+ setIsActionLoading(true);
+
+ const endpoint =
+ action === 'publish'
+ ? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore`
+ : `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`;
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to ${action} deploy`);
+ }
+
+ toast.success(`Deploy ${action}ed successfully`);
+ fetchNetlifyStats(connection.token);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to ${action} deploy: ${error}`);
+ } finally {
+ setIsActionLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ // Initialize connection with environment token if available
+ initializeNetlifyConnection();
+ }, []);
+
+ useEffect(() => {
+ // Check if we have a connection with a token but no stats
+ if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) {
+ fetchNetlifyStats(connection.token);
+ }
+
+ // Update local state from connection
+ if (connection.stats) {
+ setSites(connection.stats.sites || []);
+ setDeploys(connection.stats.deploys || []);
+ setDeploymentCount(connection.stats.deploys?.length || 0);
+ setLastUpdated(connection.stats.lastDeployTime || '');
+ }
+ }, [connection]);
+
+ const handleConnect = async () => {
+ if (!tokenInput) {
+ toast.error('Please enter a Netlify API token');
+ return;
+ }
+
+ setIsConnecting(true);
+
+ try {
+ const response = await fetch('https://api.netlify.com/api/v1/user', {
+ headers: {
+ Authorization: `Bearer ${tokenInput}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const userData = (await response.json()) as NetlifyUser;
+
+ // Update the connection store
+ updateNetlifyConnection({
+ user: userData,
+ token: tokenInput,
+ });
+
+ toast.success('Connected to Netlify successfully');
+
+ // Fetch stats after successful connection
+ fetchNetlifyStats(tokenInput);
+ } catch (error) {
+ console.error('Error connecting to Netlify:', error);
+ toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } finally {
+ setIsConnecting(false);
+ setTokenInput('');
+ }
+ };
+
+ const handleDisconnect = () => {
+ // Clear from localStorage
+ localStorage.removeItem('netlify_connection');
+
+ // Remove cookies
+ document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
+
+ // Update the store
+ updateNetlifyConnection({ user: null, token: '' });
+ setConnectionTest(null);
+ toast.success('Disconnected from Netlify');
+ };
+
+ const fetchNetlifyStats = async (token: string) => {
+ setFetchingStats(true);
+
+ try {
+ // Fetch sites
+ const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!sitesResponse.ok) {
+ throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`);
+ }
+
+ const sitesData = (await sitesResponse.json()) as NetlifySite[];
+ setSites(sitesData);
+
+ // Fetch deploys and builds for ALL sites
+ const allDeploysData: NetlifyDeploy[] = [];
+ const allBuildsData: NetlifyBuild[] = [];
+ let lastDeployTime = '';
+ let totalDeploymentCount = 0;
+
+ if (sitesData && sitesData.length > 0) {
+ // Process sites in batches to avoid overwhelming the API
+ const batchSize = 3;
+ const siteBatches = [];
+
+ for (let i = 0; i < sitesData.length; i += batchSize) {
+ siteBatches.push(sitesData.slice(i, i + batchSize));
+ }
+
+ for (const batch of siteBatches) {
+ const batchPromises = batch.map(async (site) => {
+ try {
+ // Fetch deploys for this site
+ const deploysResponse = await fetch(
+ `https://api.netlify.com/api/v1/sites/${site.id}/deploys?per_page=20`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ );
+
+ let siteDeploys: NetlifyDeploy[] = [];
+
+ if (deploysResponse.ok) {
+ siteDeploys = (await deploysResponse.json()) as NetlifyDeploy[];
+ }
+
+ // Fetch builds for this site
+ const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${site.id}/builds?per_page=10`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ let siteBuilds: NetlifyBuild[] = [];
+
+ if (buildsResponse.ok) {
+ siteBuilds = (await buildsResponse.json()) as NetlifyBuild[];
+ }
+
+ return { site, deploys: siteDeploys, builds: siteBuilds };
+ } catch (error) {
+ console.error(`Failed to fetch data for site ${site.name}:`, error);
+ return { site, deploys: [], builds: [] };
+ }
+ });
+
+ const batchResults = await Promise.all(batchPromises);
+
+ for (const result of batchResults) {
+ allDeploysData.push(...result.deploys);
+ allBuildsData.push(...result.builds);
+ totalDeploymentCount += result.deploys.length;
+ }
+
+ // Small delay between batches
+ if (batch !== siteBatches[siteBatches.length - 1]) {
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+ }
+
+ // Sort deploys by creation date (newest first)
+ allDeploysData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+
+ // Set the most recent deploy time
+ if (allDeploysData.length > 0) {
+ lastDeployTime = allDeploysData[0].created_at;
+ setLastUpdated(lastDeployTime);
+ }
+
+ setDeploys(allDeploysData);
+ setDeploymentCount(totalDeploymentCount);
+ }
+
+ // Update the stats in the store
+ updateNetlifyConnection({
+ stats: {
+ sites: sitesData,
+ deploys: allDeploysData,
+ builds: allBuildsData,
+ lastDeployTime,
+ totalSites: sitesData.length,
+ totalDeploys: totalDeploymentCount,
+ totalBuilds: allBuildsData.length,
+ },
+ });
+
+ toast.success('Netlify stats updated');
+ } catch (error) {
+ console.error('Error fetching Netlify stats:', error);
+ toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } finally {
+ setFetchingStats(false);
+ }
+ };
+
+ const renderStats = () => {
+ if (!connection.user || !connection.stats) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {/* Netlify Overview Dashboard */}
+
+
Netlify Overview
+
+
+
+ {connection.stats.totalSites}
+
+
Total Sites
+
+
+
+ {connection.stats.totalDeploys || deploymentCount}
+
+
Total Deployments
+
+
+
+ {connection.stats.totalBuilds || 0}
+
+
Total Builds
+
+
+
+ {sites.filter((site) => site.published_deploy?.state === 'ready').length}
+
+
Live Sites
+
+
+
+
+ {/* Advanced Analytics */}
+
+
Deployment Analytics
+
+
+
+
+ Success Rate
+
+
+ {(() => {
+ const successfulDeploys = deploys.filter((deploy) => deploy.state === 'ready').length;
+ const failedDeploys = deploys.filter((deploy) => deploy.state === 'error').length;
+ const successRate =
+ deploys.length > 0 ? Math.round((successfulDeploys / deploys.length) * 100) : 0;
+
+ return [
+ { label: 'Success Rate', value: `${successRate}%` },
+ { label: 'Successful', value: successfulDeploys },
+ { label: 'Failed', value: failedDeploys },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Recent Activity
+
+
+ {(() => {
+ const now = Date.now();
+ const last24Hours = deploys.filter(
+ (deploy) => now - new Date(deploy.created_at).getTime() < 24 * 60 * 60 * 1000,
+ ).length;
+ const last7Days = deploys.filter(
+ (deploy) => now - new Date(deploy.created_at).getTime() < 7 * 24 * 60 * 60 * 1000,
+ ).length;
+ const activeSites = sites.filter((site) => {
+ const lastDeploy = site.published_deploy?.published_at;
+ return lastDeploy && now - new Date(lastDeploy).getTime() < 7 * 24 * 60 * 60 * 1000;
+ }).length;
+
+ return [
+ { label: 'Last 24 hours', value: last24Hours },
+ { label: 'Last 7 days', value: last7Days },
+ { label: 'Active sites', value: activeSites },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+ {/* Site Health Metrics */}
+
+
Site Health Overview
+
+ {(() => {
+ const healthySites = sites.filter(
+ (site) => site.published_deploy?.state === 'ready' && site.ssl_url,
+ ).length;
+ const sslEnabled = sites.filter((site) => !!site.ssl_url).length;
+ const customDomain = sites.filter((site) => !!site.custom_domain).length;
+ const needsAttention = sites.filter(
+ (site) => site.published_deploy?.state === 'error' || !site.published_deploy,
+ ).length;
+ const buildingSites = sites.filter(
+ (site) =>
+ site.published_deploy?.state === 'building' || site.published_deploy?.state === 'processing',
+ ).length;
+
+ return [
+ {
+ label: 'Healthy',
+ value: healthySites,
+ icon: 'i-ph:heart',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900/20',
+ textColor: 'text-green-800 dark:text-green-400',
+ },
+ {
+ label: 'SSL Enabled',
+ value: sslEnabled,
+ icon: 'i-ph:lock',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900/20',
+ textColor: 'text-blue-800 dark:text-blue-400',
+ },
+ {
+ label: 'Custom Domain',
+ value: customDomain,
+ icon: 'i-ph:globe',
+ color: 'text-purple-500',
+ bgColor: 'bg-purple-100 dark:bg-purple-900/20',
+ textColor: 'text-purple-800 dark:text-purple-400',
+ },
+ {
+ label: 'Building',
+ value: buildingSites,
+ icon: 'i-ph:gear',
+ color: 'text-yellow-500',
+ bgColor: 'bg-yellow-100 dark:bg-yellow-900/20',
+ textColor: 'text-yellow-800 dark:text-yellow-400',
+ },
+ {
+ label: 'Needs Attention',
+ value: needsAttention,
+ icon: 'i-ph:warning',
+ color: 'text-red-500',
+ bgColor: 'bg-red-100 dark:bg-red-900/20',
+ textColor: 'text-red-800 dark:text-red-400',
+ },
+ ];
+ })().map((metric, index) => (
+
+ ))}
+
+
+
+
+
+
+ {connection.stats.totalSites} Sites
+
+
+
+ {deploymentCount} Deployments
+
+
+
+ {connection.stats.totalBuilds || 0} Builds
+
+ {lastUpdated && (
+
+
+ Updated {formatDistanceToNow(new Date(lastUpdated))} ago
+
+ )}
+
+ {sites.length > 0 && (
+
+
+
+
+
+
+ Your Sites ({sites.length})
+
+ {sites.length > 8 && (
+
setIsSitesExpanded(!isSitesExpanded)}
+ className="text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
+ >
+ {isSitesExpanded ? 'Show Less' : `Show All ${sites.length}`}
+
+ )}
+
+
fetchNetlifyStats(connection.token)}
+ disabled={fetchingStats}
+ className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10"
+ >
+
+ {fetchingStats ? 'Refreshing...' : 'Refresh'}
+
+
+
+ {(isSitesExpanded ? sites : sites.slice(0, 8)).map((site, index) => (
+
{
+ setActiveSiteIndex(index);
+ }}
+ >
+
+
+
+
+ {site.published_deploy?.state === 'ready' ? (
+
+ ) : (
+
+ )}
+
+ {site.published_deploy?.state || 'Unknown'}
+
+
+
+
+
+
+
+
+ {site.published_deploy?.framework && (
+
+
+
{site.published_deploy.framework}
+
+ )}
+ {site.custom_domain && (
+
+ )}
+ {site.branch && (
+
+ )}
+
+
+
+ {activeSiteIndex === index && (
+ <>
+
+
+ {siteActions.map((action) => (
+
{
+ e.stopPropagation();
+
+ if (action.requiresConfirmation) {
+ if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
+ return;
+ }
+ }
+
+ setIsActionLoading(true);
+ await action.action(site.id);
+ setIsActionLoading(false);
+ }}
+ disabled={isActionLoading}
+ className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
+ >
+
+ {action.name}
+
+ ))}
+
+
+ {site.published_deploy && (
+
+
+
+
+ Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago
+
+
+ {site.published_deploy.branch && (
+
+
+
+ Branch: {site.published_deploy.branch}
+
+
+ )}
+
+ )}
+ >
+ )}
+
+ ))}
+
+
+ {deploys.length > 0 && (
+
+
+
+
+
+ All Deployments ({deploys.length})
+
+ {deploys.length > 10 && (
+
setIsDeploysExpanded(!isDeploysExpanded)}
+ className="text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
+ >
+ {isDeploysExpanded ? 'Show Less' : `Show All ${deploys.length}`}
+
+ )}
+
+
+
+ {(isDeploysExpanded ? deploys : deploys.slice(0, 10)).map((deploy) => (
+
+
+
+
+ {deploy.state === 'ready' ? (
+
+ ) : deploy.state === 'error' ? (
+
+ ) : (
+
+ )}
+
+ {deploy.state}
+
+
+
+
+ {formatDistanceToNow(new Date(deploy.created_at))} ago
+
+
+ {deploy.branch && (
+
+
+
+ Branch: {deploy.branch}
+
+
+ )}
+ {deploy.deploy_url && (
+
+ )}
+
+
{
+ const siteForDeploy = sites.find((site) => site.id === deploy.site_id);
+
+ if (siteForDeploy) {
+ handleDeploy(siteForDeploy.id, deploy.id, 'publish');
+ }
+ }}
+ disabled={isActionLoading}
+ className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
+ >
+
+ Publish
+
+ {deploy.state === 'ready' ? (
+
{
+ const siteForDeploy = sites.find((site) => site.id === deploy.site_id);
+
+ if (siteForDeploy) {
+ handleDeploy(siteForDeploy.id, deploy.id, 'lock');
+ }
+ }}
+ disabled={isActionLoading}
+ className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
+ >
+
+ Lock
+
+ ) : (
+
{
+ const siteForDeploy = sites.find((site) => site.id === deploy.site_id);
+
+ if (siteForDeploy) {
+ handleDeploy(siteForDeploy.id, deploy.id, 'unlock');
+ }
+ }}
+ disabled={isActionLoading}
+ className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
+ >
+
+ Unlock
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Builds Section */}
+ {connection.stats.builds && connection.stats.builds.length > 0 && (
+
+
+
+
+ Recent Builds ({connection.stats.builds.length})
+
+
+
+ {connection.stats.builds.slice(0, 8).map((build: any) => (
+
+
+
+
+ {build.done ? (
+
+ ) : (
+
+ )}
+
+ {build.done ? 'Completed' : 'Building'}
+
+
+
+
+ {formatDistanceToNow(new Date(build.created_at))} ago
+
+
+ {build.commit_ref && (
+
+
+
+ {build.commit_ref.substring(0, 7)}
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ Netlify Integration
+
+
+
+ {connection.user && (
+
+ {connectionTest?.status === 'testing' ? (
+ <>
+
+ Testing...
+ >
+ ) : (
+ <>
+
+ Test Connection
+ >
+ )}
+
+ )}
+
+
+
+
+ Connect and manage your Netlify sites with advanced deployment controls and site management
+
+
+ {/* Connection Test Results */}
+ {connectionTest && (
+
+
+ {connectionTest.status === 'success' && (
+
+ )}
+ {connectionTest.status === 'error' && (
+
+ )}
+ {connectionTest.status === 'testing' && (
+
+ )}
+
+ {connectionTest.message}
+
+
+ {connectionTest.timestamp && (
+ {new Date(connectionTest.timestamp).toLocaleString()}
+ )}
+
+ )}
+
+ {/* Main Connection Component */}
+
+
+ {!connection.user ? (
+
+
+
+
+ Tip: You can also set the{' '}
+
+ VITE_NETLIFY_ACCESS_TOKEN
+ {' '}
+ environment variable to connect automatically.
+
+
+
+
+
+ API Token
+
+
setTokenInput(e.target.value)}
+ placeholder="Enter your Netlify API token"
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
+ 'border border-[#E5E5E5] dark:border-[#333333]',
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
+ 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
+ 'disabled:opacity-50',
+ )}
+ />
+
+
+
+
+
+ {isConnecting ? (
+ <>
+
+ Connecting...
+ >
+ ) : (
+ <>
+
+ Connect
+ >
+ )}
+
+
+
+ ) : (
+
+
+
+
+ Disconnect
+
+
+
+ Connected to Netlify
+
+
+ {renderStats()}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/notifications/NotificationsTab.tsx b/app/components/@settings/tabs/notifications/NotificationsTab.tsx
index cb5f3da1c7..cc3d799b2c 100644
--- a/app/components/@settings/tabs/notifications/NotificationsTab.tsx
+++ b/app/components/@settings/tabs/notifications/NotificationsTab.tsx
@@ -5,6 +5,21 @@ import { useStore } from '@nanostores/react';
import { formatDistanceToNow } from 'date-fns';
import { classNames } from '~/utils/classNames';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import {
+ ArrowUpCircle,
+ AlertCircle,
+ AlertTriangle,
+ Info,
+ Bell,
+ GitBranch,
+ Settings,
+ Bot,
+ Wifi,
+ Funnel,
+ ChevronDown,
+ Trash2,
+ BellOff,
+} from 'lucide-react';
interface NotificationDetails {
type?: string;
@@ -91,7 +106,7 @@ const NotificationsTab = () => {
const getNotificationStyle = (level: string, type?: string) => {
if (type === 'update') {
return {
- icon: 'i-ph:arrow-circle-up',
+ icon: ArrowUpCircle,
color: 'text-purple-500 dark:text-purple-400',
bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
};
@@ -100,25 +115,25 @@ const NotificationsTab = () => {
switch (level) {
case 'error':
return {
- icon: 'i-ph:warning-circle',
+ icon: AlertCircle,
color: 'text-red-500 dark:text-red-400',
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
};
case 'warning':
return {
- icon: 'i-ph:warning',
+ icon: AlertTriangle,
color: 'text-yellow-500 dark:text-yellow-400',
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
};
case 'info':
return {
- icon: 'i-ph:info',
+ icon: Info,
color: 'text-blue-500 dark:text-blue-400',
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
};
default:
return {
- icon: 'i-ph:bell',
+ icon: Bell,
color: 'text-gray-500 dark:text-gray-400',
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
};
@@ -129,8 +144,8 @@ const NotificationsTab = () => {
if (details.type === 'update') {
return (
-
{details.message}
-
+
{details.message}
+
Current Version: {details.currentVersion}
Latest Version: {details.latestVersion}
Branch: {details.branch}
@@ -141,32 +156,37 @@ const NotificationsTab = () => {
'mt-2 inline-flex items-center gap-2',
'rounded-lg px-3 py-1.5',
'text-sm font-medium',
- 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
- 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
- 'text-gray-900 dark:text-white',
- 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
+ 'bg-bolt-elements-background-depth-2',
+ 'border border-bolt-elements-borderColor',
+ 'text-bolt-elements-textPrimary',
+ 'hover:bg-bolt-elements-item-backgroundActive',
'transition-all duration-200',
)}
>
-
+
View Changes
);
}
- return details.message ?
{details.message}
: null;
+ return details.message ?
{details.message}
: null;
};
- const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [
- { id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' },
- { id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' },
- { id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' },
- { id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' },
- { id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' },
- { id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' },
- { id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' },
- { id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' },
+ const filterOptions: {
+ id: FilterType;
+ label: string;
+ icon: string | React.ComponentType<{ className?: string }>;
+ color: string;
+ }[] = [
+ { id: 'all', label: 'All Notifications', icon: Bell, color: '#9333ea' },
+ { id: 'system', label: 'System', icon: Settings, color: '#6b7280' },
+ { id: 'update', label: 'Updates', icon: ArrowUpCircle, color: '#9333ea' },
+ { id: 'error', label: 'Errors', icon: AlertCircle, color: '#ef4444' },
+ { id: 'warning', label: 'Warnings', icon: AlertTriangle, color: '#f59e0b' },
+ { id: 'info', label: 'Information', icon: Info, color: '#3b82f6' },
+ { id: 'provider', label: 'Providers', icon: Bot, color: '#10b981' },
+ { id: 'network', label: 'Network', icon: Wifi, color: '#6366f1' },
];
return (
@@ -178,25 +198,34 @@ const NotificationsTab = () => {
className={classNames(
'flex items-center gap-2',
'rounded-lg px-3 py-1.5',
- 'text-sm text-gray-900 dark:text-white',
- 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
- 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
- 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
+ 'text-sm text-bolt-elements-textPrimary',
+ 'bg-bolt-elements-background-depth-2',
+ 'border border-bolt-elements-borderColor',
+ 'hover:bg-bolt-elements-item-backgroundActive',
'transition-all duration-200',
)}
>
-
opt.id === filter)?.icon || 'i-ph:funnel')}
- style={{ color: filterOptions.find((opt) => opt.id === filter)?.color }}
- />
+ {(() => {
+ const filterOption = filterOptions.find((opt) => opt.id === filter);
+ const IconComponent = filterOption?.icon;
+ const iconColor = filterOption?.color;
+
+ return typeof IconComponent === 'string' ? (
+
+ ) : IconComponent ? (
+
+ ) : (
+
+ );
+ })()}
{filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
-
+
{
{filterOptions.map((option) => (
handleFilterChange(option.id)}
>
-
+ {typeof option.icon === 'string' ? (
+
+ ) : (
+
+ )}
- {option.label}
+
+ {option.label}
+
))}
@@ -225,14 +266,14 @@ const NotificationsTab = () => {
className={classNames(
'group flex items-center gap-2',
'rounded-lg px-3 py-1.5',
- 'text-sm text-gray-900 dark:text-white',
- 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
- 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
- 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
+ 'text-sm text-bolt-elements-textPrimary',
+ 'bg-bolt-elements-background-depth-2',
+ 'border border-bolt-elements-borderColor',
+ 'hover:bg-bolt-elements-item-backgroundActive',
'transition-all duration-200',
)}
>
-
+
Clear All
@@ -245,14 +286,14 @@ const NotificationsTab = () => {
className={classNames(
'flex flex-col items-center justify-center gap-4',
'rounded-lg p-8 text-center',
- 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
- 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'bg-bolt-elements-background-depth-2',
+ 'border border-bolt-elements-borderColor',
)}
>
-
+
-
No Notifications
-
You're all caught up!
+
No Notifications
+
You're all caught up!
) : (
@@ -266,25 +307,25 @@ const NotificationsTab = () => {
className={classNames(
'flex flex-col gap-2',
'rounded-lg p-4',
- 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
- 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'bg-bolt-elements-background-depth-2',
+ 'border border-bolt-elements-borderColor',
style.bg,
'transition-all duration-200',
)}
>
-
+ {React.createElement(style.icon, { className: classNames('text-lg', style.color) })}
-
{log.message}
+
{log.message}
{log.details && renderNotificationDetails(log.details as NotificationDetails)}
-
+
Category: {log.category}
{log.subCategory ? ` > ${log.subCategory}` : ''}
-
+
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
diff --git a/app/components/@settings/tabs/profile/ProfileTab.tsx b/app/components/@settings/tabs/profile/ProfileTab.tsx
index 6783a9072d..3c6420d27e 100644
--- a/app/components/@settings/tabs/profile/ProfileTab.tsx
+++ b/app/components/@settings/tabs/profile/ProfileTab.tsx
@@ -4,6 +4,7 @@ import { classNames } from '~/utils/classNames';
import { profileStore, updateProfile } from '~/lib/stores/profile';
import { toast } from 'react-toastify';
import { debounce } from '~/utils/debounce';
+import { Bot, Loader2, Camera, User, Type } from 'lucide-react';
export default function ProfileTab() {
const profile = useStore(profileStore);
@@ -89,7 +90,7 @@ export default function ProfileTab() {
)}
/>
) : (
-
+
)}
{isUploading ? (
-
+
) : (
-
+
)}
@@ -129,7 +130,7 @@ export default function ProfileTab() {
Username
Bio
Cloud Providers
@@ -205,9 +201,8 @@ const CloudProvidersTab = () => {
whileTap={{ scale: 0.9 }}
>
- {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || Bot, {
className: 'w-full h-full',
- 'aria-label': `${provider.name} logo`,
})}
@@ -267,7 +262,7 @@ const CloudProvidersTab = () => {
onClick={() => setEditingProvider(provider.name)}
>
-
+
{provider.settings.baseUrl || 'Click to set base URL'}
@@ -279,7 +274,7 @@ const CloudProvidersTab = () => {
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
-
+
Environment URL set in .env file
diff --git a/app/components/@settings/tabs/providers/local/EndpointStatusDashboard.tsx b/app/components/@settings/tabs/providers/local/EndpointStatusDashboard.tsx
new file mode 100644
index 0000000000..9a46211f8f
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/EndpointStatusDashboard.tsx
@@ -0,0 +1,238 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth';
+
+interface EndpointInfo {
+ provider: string;
+ configuredUrl: string;
+ status: 'healthy' | 'unhealthy' | 'checking' | 'unknown';
+ responseTime?: number;
+ availableModels?: number;
+ version?: string;
+ error?: string;
+ lastChecked?: Date;
+}
+
+interface EndpointStatusDashboardProps {
+ className?: string;
+}
+
+export default function EndpointStatusDashboard({ className }: EndpointStatusDashboardProps) {
+ const { healthStatuses } = useLocalModelHealth();
+
+ const endpointData: EndpointInfo[] = healthStatuses.map(status => ({
+ provider: status.provider,
+ configuredUrl: status.baseUrl,
+ status: status.status,
+ responseTime: status.responseTime,
+ availableModels: status.availableModels?.length || 0,
+ version: status.version,
+ error: status.error,
+ lastChecked: status.lastChecked,
+ }));
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'healthy': return 'bg-green-500';
+ case 'unhealthy': return 'bg-red-500';
+ case 'checking': return 'bg-blue-500';
+ default: return 'bg-gray-500';
+ }
+ };
+
+ const getStatusBg = (status: string) => {
+ switch (status) {
+ case 'healthy': return 'bg-green-500/10 border-green-500/20';
+ case 'unhealthy': return 'bg-red-500/10 border-red-500/20';
+ case 'checking': return 'bg-blue-500/10 border-blue-500/20';
+ default: return 'bg-gray-500/10 border-gray-500/20';
+ }
+ };
+
+ const formatTime = (ms?: number) => {
+ if (!ms) return 'N/A';
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+ };
+
+ const formatLastChecked = (date?: Date) => {
+ if (!date) return 'Never';
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const minutes = Math.floor(diff / (1000 * 60));
+
+ if (minutes < 1) return 'Just now';
+ if (minutes < 60) return `${minutes}m ago`;
+
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+
+ return date.toLocaleDateString();
+ };
+
+ if (endpointData.length === 0) {
+ return (
+
+
+
+ No Endpoints Configured
+
+
+ Configure and enable local providers to see their endpoint status here.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Endpoint Status Dashboard
+
+
+
+
+ {endpointData.map((endpoint, index) => (
+
+
+
+
+
+
+ {endpoint.provider}
+
+
+ {endpoint.configuredUrl}
+
+
+
+
+
+
+ {endpoint.status.charAt(0).toUpperCase() + endpoint.status.slice(1)}
+
+
+ {formatLastChecked(endpoint.lastChecked)}
+
+
+
+
+
+
+
Response Time
+
+ {formatTime(endpoint.responseTime)}
+
+
+
+
+
Models
+
+ {endpoint.availableModels || 0}
+
+
+
+
+
Version
+
+ {endpoint.version || 'Unknown'}
+
+
+
+
+
Status
+
+ {endpoint.status === 'checking' ? 'Checking...' : endpoint.status}
+
+
+
+
+ {endpoint.error && (
+
+
+
+ {endpoint.error.includes('CORS_ERROR') ? (
+
+
{endpoint.error.replace('CORS_ERROR: ', '')}
+
+
Try these solutions:
+
+ Check LM Studio's Local Server settings for CORS option
+ Try starting LM Studio server with lmstudio-server --cors
+ Use Bolt's desktop app (no CORS restrictions)
+ Install a CORS browser extension
+
+
Note: CORS options may vary by LM Studio version
+
+
+ ) : (
+
{endpoint.error}
+ )}
+
+
+ )}
+
+ ))}
+
+
+ {/* Summary Stats */}
+
+
+
+ {endpointData.filter(e => e.status === 'healthy').length}
+
+
Healthy
+
+
+
+
+ {endpointData.filter(e => e.status === 'unhealthy').length}
+
+
Unhealthy
+
+
+
+
+ {endpointData.filter(e => e.status === 'checking').length}
+
+
Checking
+
+
+
+
+ {endpointData.reduce((sum, e) => sum + (e.availableModels || 0), 0)}
+
+
Total Models
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/ErrorBoundary.tsx b/app/components/@settings/tabs/providers/local/ErrorBoundary.tsx
new file mode 100644
index 0000000000..38a90ca0bd
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/ErrorBoundary.tsx
@@ -0,0 +1,76 @@
+import React, { Component } from 'react';
+import type { ReactNode } from 'react';
+import { classNames } from '~/utils/classNames';
+
+interface Props {
+ children: ReactNode;
+ fallback?: ReactNode;
+ onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
+}
+
+interface State {
+ hasError: boolean;
+ error?: Error;
+}
+
+export default class ErrorBoundary extends Component
{
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('Local Providers Error Boundary caught an error:', error, errorInfo);
+ this.props.onError?.(error, errorInfo);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) {
+ return this.props.fallback;
+ }
+
+ return (
+
+
+
+ Something went wrong
+
+
+ There was an error loading the local providers section.
+
+
this.setState({ hasError: false, error: undefined })}
+ className={classNames(
+ 'px-4 py-2 rounded-lg text-sm font-medium',
+ 'bg-red-500/10 text-red-500',
+ 'hover:bg-red-500/20',
+ 'transition-colors duration-200'
+ )}
+ >
+ Try Again
+
+ {process.env.NODE_ENV === 'development' && this.state.error && (
+
+
+ Error Details
+
+
+ {this.state.error.stack}
+
+
+ )}
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/app/components/@settings/tabs/providers/local/LMStudioModelManager.tsx b/app/components/@settings/tabs/providers/local/LMStudioModelManager.tsx
new file mode 100644
index 0000000000..77f64f1c82
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/LMStudioModelManager.tsx
@@ -0,0 +1,465 @@
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { useToast } from '~/components/ui/use-toast';
+import { useModelPerformance } from '~/lib/hooks/useModelPerformance';
+import ErrorBoundary from './ErrorBoundary';
+import { ModelManagerSkeleton } from './LoadingSkeleton';
+
+interface LMStudioModel {
+ id: string;
+ object: string;
+ created?: number;
+ owned_by?: string;
+ // Additional fields we can extract
+ isLoaded?: boolean;
+ contextLength?: number;
+ architecture?: string;
+ parameters?: string;
+}
+
+interface ModelStatus {
+ isHealthy: boolean;
+ responseTime?: number;
+ error?: string;
+ lastChecked: Date;
+}
+
+interface LMStudioModelManagerProps {
+ baseUrl: string;
+ className?: string;
+}
+
+export default function LMStudioModelManager({ baseUrl, className }: LMStudioModelManagerProps) {
+ const [models, setModels] = useState([]);
+ const [modelStatuses, setModelStatuses] = useState>({});
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedModel, setSelectedModel] = useState(null);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const { toast } = useToast();
+ const { recordMetrics } = useModelPerformance();
+
+ // Normalize base URL - LM Studio server already includes /v1
+ const normalizedBaseUrl = baseUrl.includes('/v1') ? baseUrl : baseUrl.endsWith('/') ? `${baseUrl}v1` : `${baseUrl}/v1`;
+
+ // Fetch models from LM Studio
+ const fetchModels = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(`${normalizedBaseUrl}/models`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'bolt.diy/1.0',
+ },
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => 'Unknown error');
+ throw new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
+ }
+
+ const data = await response.json();
+
+ if (!data.data || !Array.isArray(data.data)) {
+ throw new Error('Invalid response format from LM Studio');
+ }
+
+ const modelsWithMetadata = data.data.map((model: any) => {
+ // Ensure we have the required fields
+ if (!model.id) {
+ console.warn('Model missing ID:', model);
+ return null;
+ }
+
+ return {
+ id: model.id,
+ object: model.object || 'model',
+ created: model.created || Date.now() / 1000,
+ owned_by: model.owned_by || 'lm-studio',
+ // Extract metadata from model ID
+ architecture: extractArchitecture(model.id),
+ parameters: extractParameters(model.id),
+ contextLength: estimateContextLength(model.id),
+ isLoaded: true, // If it's in the list, it's loaded
+ };
+ }).filter(Boolean); // Remove any null entries
+
+ setModels(modelsWithMetadata);
+ toast('Successfully loaded LM Studio models');
+ } catch (error) {
+ console.error('Error fetching LM Studio models:', error);
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
+ toast(`Failed to fetch LM Studio models: ${errorMessage}`);
+ setModels([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [normalizedBaseUrl, toast]);
+
+ // Test model health
+ const testModelHealth = useCallback(async (modelId: string): Promise => {
+ const startTime = Date.now();
+
+ try {
+ const response = await fetch(`${normalizedBaseUrl}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'bolt.diy/1.0',
+ },
+ body: JSON.stringify({
+ model: modelId,
+ messages: [{ role: 'user', content: 'Hello' }],
+ max_tokens: 5,
+ temperature: 0.1,
+ }),
+ signal: AbortSignal.timeout(15000),
+ });
+
+ const responseTime = Date.now() - startTime;
+
+ if (!response.ok) {
+ return {
+ isHealthy: false,
+ responseTime,
+ error: `HTTP ${response.status}: ${response.statusText}`,
+ lastChecked: new Date(),
+ };
+ }
+
+ const data = await response.json();
+
+ if (!data.choices || !Array.isArray(data.choices) || data.choices.length === 0) {
+ // Record failed request
+ recordMetrics({
+ provider: 'LMStudio',
+ model: modelId,
+ baseUrl: normalizedBaseUrl,
+ responseTime,
+ success: false,
+ error: 'Invalid response format',
+ });
+
+ return {
+ isHealthy: false,
+ responseTime,
+ error: 'Invalid response format',
+ lastChecked: new Date(),
+ };
+ }
+
+ // Record successful request
+ recordMetrics({
+ provider: 'LMStudio',
+ model: modelId,
+ baseUrl: normalizedBaseUrl,
+ responseTime,
+ success: true,
+ });
+
+ return {
+ isHealthy: true,
+ responseTime,
+ lastChecked: new Date(),
+ };
+ } catch (error) {
+ const responseTime = Date.now() - startTime;
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+
+ // Record failed request
+ recordMetrics({
+ provider: 'LMStudio',
+ model: modelId,
+ baseUrl: normalizedBaseUrl,
+ responseTime,
+ success: false,
+ error: errorMessage,
+ });
+
+ return {
+ isHealthy: false,
+ responseTime,
+ error: errorMessage,
+ lastChecked: new Date(),
+ };
+ }
+ }, [normalizedBaseUrl, recordMetrics]);
+
+ // Test all models health
+ const testAllModelsHealth = useCallback(async () => {
+ setIsRefreshing(true);
+ const statusPromises = models.map(async (model) => {
+ const status = await testModelHealth(model.id);
+ return { modelId: model.id, status };
+ });
+
+ try {
+ const results = await Promise.all(statusPromises);
+ const newStatuses: Record = {};
+
+ results.forEach(({ modelId, status }) => {
+ newStatuses[modelId] = status;
+ });
+
+ setModelStatuses(newStatuses);
+ } catch (error) {
+ console.error('Error testing model health:', error);
+ toast('Failed to test model health');
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [models, testModelHealth, toast]);
+
+ // Extract architecture from model name
+ const extractArchitecture = (modelId: string): string => {
+ const id = modelId.toLowerCase();
+ if (id.includes('llama')) return 'LLaMA';
+ if (id.includes('mistral')) return 'Mistral';
+ if (id.includes('phi')) return 'Phi';
+ if (id.includes('gemma')) return 'Gemma';
+ if (id.includes('qwen')) return 'Qwen';
+ if (id.includes('codellama')) return 'Code Llama';
+ return 'Unknown';
+ };
+
+ // Extract parameter count from model name
+ const extractParameters = (modelId: string): string => {
+ const match = modelId.match(/(\d+)b/i);
+ return match ? `${match[1]}B` : 'Unknown';
+ };
+
+ // Estimate context length based on model
+ const estimateContextLength = (modelId: string): number => {
+ const id = modelId.toLowerCase();
+ if (id.includes('32k')) return 32768;
+ if (id.includes('16k')) return 16384;
+ if (id.includes('8k')) return 8192;
+ if (id.includes('4k')) return 4096;
+ // Default estimates based on model type
+ if (id.includes('llama')) return 4096;
+ if (id.includes('mistral')) return 8192;
+ if (id.includes('phi')) return 2048;
+ return 4096;
+ };
+
+ // Format response time
+ const formatResponseTime = (ms: number): string => {
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
+ };
+
+ // Load models on mount
+ useEffect(() => {
+ fetchModels();
+ }, [fetchModels]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (models.length === 0) {
+ return (
+
+
+
+ No Models Loaded
+
+
+ Make sure LM Studio is running and has models loaded.
+
+
+ Retry
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ LM Studio Models
+
+
+ {models.length} model{models.length !== 1 ? 's' : ''} loaded • {baseUrl}
+
+
+
+
+
+
+ {isRefreshing ? 'Testing...' : 'Test Health'}
+
+
+
+
+ Refresh
+
+
+
+
+ {/* Models Grid */}
+
+ {models.map((model) => {
+ const status = modelStatuses[model.id];
+ const isSelected = selectedModel === model.id;
+
+ return (
+
setSelectedModel(isSelected ? null : model.id)}
+ whileHover={{ scale: 1.01 }}
+ transition={{ duration: 0.2 }}
+ >
+
+
+ {/* Status Indicator */}
+
+
+
+
+ {model.id}
+
+
+ {model.architecture && {model.architecture} }
+ {model.parameters && • {model.parameters} }
+ {model.contextLength && • {model.contextLength.toLocaleString()} ctx }
+
+
+
+
+
+ {status?.responseTime && (
+
+ {formatResponseTime(status.responseTime)}
+
+ )}
+
+
+
+
+
+ {/* Expanded Details */}
+
+ {isSelected && (
+
+
+
+
Status:
+
+ {status?.isHealthy ? 'Healthy' :
+ status?.isHealthy === false ? 'Unhealthy' : 'Unknown'}
+
+
+
+
+
Created:
+
+ {model.created ? new Date(model.created * 1000).toLocaleDateString() : 'Unknown'}
+
+
+
+
+
Owner:
+
+ {model.owned_by || 'Unknown'}
+
+
+
+
+
Last Checked:
+
+ {status?.lastChecked ? status.lastChecked.toLocaleTimeString() : 'Never'}
+
+
+
+
+ {status?.error && (
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/LoadingSkeleton.tsx b/app/components/@settings/tabs/providers/local/LoadingSkeleton.tsx
new file mode 100644
index 0000000000..cbfab3f183
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/LoadingSkeleton.tsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+
+interface LoadingSkeletonProps {
+ className?: string;
+ lines?: number;
+ height?: string;
+}
+
+export function LoadingSkeleton({ className, lines = 1, height = 'h-4' }: LoadingSkeletonProps) {
+ return (
+
+ {Array.from({ length: lines }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+interface ModelCardSkeletonProps {
+ className?: string;
+}
+
+export function ModelCardSkeleton({ className }: ModelCardSkeletonProps) {
+ return (
+
+
+
+ );
+}
+
+interface ProviderCardSkeletonProps {
+ className?: string;
+}
+
+export function ProviderCardSkeleton({ className }: ProviderCardSkeletonProps) {
+ return (
+
+
+
+ );
+}
+
+interface ModelManagerSkeletonProps {
+ className?: string;
+ cardCount?: number;
+}
+
+export function ModelManagerSkeleton({ className, cardCount = 3 }: ModelManagerSkeletonProps) {
+ return (
+
+ {/* Header */}
+
+
+ {/* Model Cards */}
+
+ {Array.from({ length: cardCount }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/LocalProvidersGuide.tsx b/app/components/@settings/tabs/providers/local/LocalProvidersGuide.tsx
new file mode 100644
index 0000000000..6080e0794f
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/LocalProvidersGuide.tsx
@@ -0,0 +1,322 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+
+interface EndpointInfo {
+ provider: string;
+ defaultUrl: string;
+ endpoints: {
+ models: string;
+ chat: string;
+ completions: string;
+ health: string;
+ version?: string;
+ };
+ features: string[];
+ notes: string;
+}
+
+const PROVIDER_ENDPOINTS: EndpointInfo[] = [
+ {
+ provider: 'Ollama',
+ defaultUrl: 'http://127.0.0.1:11434',
+ endpoints: {
+ models: '/api/tags',
+ chat: '/api/chat',
+ completions: '/api/generate',
+ health: '/api/tags',
+ version: '/api/version'
+ },
+ features: [
+ 'Local model inference',
+ 'Automatic model discovery',
+ 'Streaming support',
+ 'Multiple model formats',
+ 'GPU acceleration support',
+ 'REST API interface'
+ ],
+ notes: 'Install models using Ollama CLI or desktop app. Models appear automatically in Bolt.'
+ },
+ {
+ provider: 'LM Studio',
+ defaultUrl: 'http://localhost:1234/v1',
+ endpoints: {
+ models: '/models',
+ chat: '/chat/completions',
+ completions: '/completions',
+ health: '/models'
+ },
+ features: [
+ 'Local model hosting',
+ 'OpenAI-compatible API',
+ 'Real-time model switching',
+ 'Multi-modal support',
+ 'GPU memory management',
+ 'Local web interface'
+ ],
+ notes: 'Load models through LM Studio interface. The server runs at http://localhost:1234/v1. Configure server settings in LM Studio preferences.'
+ },
+ {
+ provider: 'OpenAI-like',
+ defaultUrl: 'http://localhost:8080',
+ endpoints: {
+ models: '/v1/models',
+ chat: '/v1/chat/completions',
+ completions: '/v1/completions',
+ health: '/v1/models'
+ },
+ features: [
+ 'OpenAI API compatibility',
+ 'Flexible endpoint configuration',
+ 'Custom model support',
+ 'Streaming responses',
+ 'Function calling',
+ 'Vision capabilities'
+ ],
+ notes: 'Compatible with services like vLLM, Text Generation WebUI, and other OpenAI-compatible servers.'
+ }
+];
+
+const LOCAL_MODEL_ECOSYSTEM = [
+ {
+ category: 'Popular Local Solutions',
+ providers: [
+ { name: 'Ollama', description: 'Easy-to-use CLI and API for running LLMs locally', pros: ['Simple setup', 'Large model library', 'Active community'] },
+ { name: 'LM Studio', description: 'User-friendly GUI for running LLMs with chat interface', pros: ['Graphical interface', 'Easy model management', 'Good performance'] },
+ { name: 'GPT4All', description: 'Cross-platform chat clients for local LLMs', pros: ['Multiple platforms', 'No coding required', 'Model marketplace'] },
+ { name: 'LocalAI', description: 'OpenAI-compatible API server for local models', pros: ['API compatibility', 'Multi-modal', 'Extensible'] }
+ ]
+ },
+ {
+ category: 'Advanced Solutions',
+ providers: [
+ { name: 'vLLM', description: 'High-performance inference server for large language models', pros: ['High throughput', 'Distributed inference', 'Production ready'] },
+ { name: 'Text Generation WebUI', description: 'Feature-rich web interface for text generation', pros: ['Rich features', 'Extensions', 'Model comparison'] },
+ { name: 'KoboldAI', description: 'AI writing assistant with local model support', pros: ['Writing focused', 'Multiple backends', 'Community models'] },
+ { name: 'SillyTavern', description: 'Advanced chat interface for character roleplay', pros: ['Roleplay focused', 'Extensions', 'Custom characters'] }
+ ]
+ }
+];
+
+interface LocalProvidersGuideProps {
+ className?: string;
+}
+
+export default function LocalProvidersGuide({ className }: LocalProvidersGuideProps) {
+ return (
+
+ {/* Header */}
+
+
+ Local AI Providers Guide
+
+
+ Comprehensive information about local AI model providers and their capabilities
+
+
+
+ {/* Provider Endpoints */}
+
+
+ Provider Endpoints & Features
+
+
+
+ {PROVIDER_ENDPOINTS.map((provider, index) => (
+
+
+
+
+
+ {provider.provider}
+
+
+ Default: {provider.defaultUrl}
+
+
+
+
+ {/* Endpoints */}
+
+
API Endpoints
+
+ {Object.entries(provider.endpoints).map(([key, endpoint]) => (
+
+ ))}
+
+
+
+ {/* Features */}
+
+
Features
+
+ {provider.features.map((feature, i) => (
+
+ {feature}
+
+ ))}
+
+
+
+ {/* Notes */}
+
+
+ ))}
+
+
+
+ {/* Local Model Ecosystem */}
+
+
+ Local AI Model Ecosystem
+
+
+
+ {LOCAL_MODEL_ECOSYSTEM.map((category, categoryIndex) => (
+
+
+ {category.category}
+
+
+
+ {category.providers.map((provider, providerIndex) => (
+
+
+
+
{provider.name}
+
{provider.description}
+
+
+
Pros:
+
+ {provider.pros.map((pro, i) => (
+
+ {pro}
+
+ ))}
+
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* CORS Configuration */}
+
+
+ CORS Configuration
+
+
+ When running local AI providers, you may encounter CORS (Cross-Origin Resource Sharing) errors
+ when accessing them from web applications like Bolt.
+
+
+
+
+
LM Studio
+
+
Note: CORS configuration in LM Studio may vary by version.
+
+
+
Method 1 - Server Settings:
+
+ Open LM Studio
+ Go to "Local Server" tab
+ Look for CORS or "Allow Cross-Origin" option
+ Enable it and restart server
+
+
+
+
Method 2 - Command Line:
+
Start LM Studio server with: lmstudio-server --cors
+
+
+
+
+
+
+
Alternative Solutions
+
+
+
Browser Extension:
+
Install a CORS proxy browser extension (like "CORS Unblock")
+
+
+
Development Proxy:
+
Use a local proxy server to forward requests
+
+
+
Electron App:
+
Bolt's desktop version doesn't have CORS restrictions
+
+
+
+
+
+
+ {/* Best Practices */}
+
+
+ Best Practices for Local AI
+
+
+
+
+
Hardware Considerations
+
+ • Minimum 16GB RAM for small models (7B parameters)
+ • 32GB+ RAM recommended for larger models
+ • NVIDIA GPU with 8GB+ VRAM for acceleration
+ • SSD storage for faster model loading
+
+
+
+
+
Performance Tips
+
+ • Use quantized models (Q4, Q8) for better performance
+ • Enable GPU acceleration when available
+ • Monitor RAM/VRAM usage during inference
+ • Use streaming for better user experience
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx b/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
index 70e8d2f517..66b4b90656 100644
--- a/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
+++ b/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
@@ -6,23 +6,30 @@ import type { IProviderConfig } from '~/types/model';
import { logStore } from '~/lib/stores/logs';
import { motion, AnimatePresence } from 'framer-motion';
import { classNames } from '~/utils/classNames';
-import { BsRobot } from 'react-icons/bs';
-import type { IconType } from 'react-icons';
+import { Bot, Cpu, Zap, Code, Database, Box, Loader2, RefreshCw, Trash2, Link, ExternalLink } from 'lucide-react';
import { BiChip } from 'react-icons/bi';
-import { TbBrandOpenai } from 'react-icons/tb';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
import { useToast } from '~/components/ui/use-toast';
import { Progress } from '~/components/ui/Progress';
-import OllamaModelInstaller from './OllamaModelInstaller';
+import ModelHealthIndicator, { ModelHealthSummary } from './ModelHealthIndicator';
+import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth';
+import ModelPerformanceDashboard from './ModelPerformanceDashboard';
+import LMStudioModelManager from './LMStudioModelManager';
+import OpenAILikeModelManager from './OpenAILikeModelManager';
+import ErrorBoundary from './ErrorBoundary';
+import { ProviderCardSkeleton } from './LoadingSkeleton';
+import LocalProvidersGuide from './LocalProvidersGuide';
+import EndpointStatusDashboard from './EndpointStatusDashboard';
+import Cookies from 'js-cookie';
// Add type for provider names to ensure type safety
type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
// Update the PROVIDER_ICONS type to use the ProviderName type
-const PROVIDER_ICONS: Record = {
- Ollama: BsRobot,
- LMStudio: BsRobot,
- OpenAILike: TbBrandOpenai,
+const PROVIDER_ICONS: Record> = {
+ Ollama: Bot,
+ LMStudio: Bot,
+ OpenAILike: Zap,
};
// Update PROVIDER_DESCRIPTIONS to use the same type
@@ -35,6 +42,23 @@ const PROVIDER_DESCRIPTIONS: Record = {
// Add a constant for the Ollama API base URL
const OLLAMA_API_URL = 'http://127.0.0.1:11434';
+// Helper function to check if the data is a valid Ollama pull progress response
+function isOllamaPullProgress(data: any): data is {
+ status: string;
+ digest?: string;
+ total?: number;
+ completed?: number;
+} {
+ return (
+ data &&
+ typeof data === 'object' &&
+ typeof data.status === 'string' &&
+ (data.digest === undefined || typeof data.digest === 'string') &&
+ (data.total === undefined || typeof data.total === 'number') &&
+ (data.completed === undefined || typeof data.completed === 'number')
+ );
+}
+
interface OllamaModel {
name: string;
digest: string;
@@ -55,21 +79,6 @@ interface OllamaModel {
};
}
-interface OllamaPullResponse {
- status: string;
- completed?: number;
- total?: number;
- digest?: string;
-}
-
-const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => {
- return (
- typeof data === 'object' &&
- data !== null &&
- 'status' in data &&
- typeof (data as OllamaPullResponse).status === 'string'
- );
-};
export default function LocalProvidersTab() {
const { providers, updateProviderSettings } = useSettings();
@@ -78,7 +87,27 @@ export default function LocalProvidersTab() {
const [ollamaModels, setOllamaModels] = useState([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [editingProvider, setEditingProvider] = useState(null);
+ const [showPerformanceDashboard, setShowPerformanceDashboard] = useState(false);
+ const [showModelManagement, setShowModelManagement] = useState(false);
+ const [showProvidersGuide, setShowProvidersGuide] = useState(false);
+ const [showEndpointStatus, setShowEndpointStatus] = useState(false);
+ const [isLoadingProviders, setIsLoadingProviders] = useState(true);
const { toast } = useToast();
+ const { startMonitoring, stopMonitoring } = useLocalModelHealth();
+
+ // Get API keys from cookies
+ const getApiKeyFromCookies = useCallback((providerName: string): string | undefined => {
+ try {
+ const apiKeysString = Cookies.get('apiKeys');
+ if (apiKeysString) {
+ const apiKeys = JSON.parse(apiKeysString);
+ return apiKeys[providerName];
+ }
+ } catch (error) {
+ console.error('Error parsing API keys from cookies:', error);
+ }
+ return undefined;
+ }, []);
// Effect to filter and sort providers
useEffect(() => {
@@ -132,6 +161,7 @@ export default function LocalProvidersTab() {
return a.name.localeCompare(b.name);
});
setFilteredProviders(sorted);
+ setIsLoadingProviders(false);
}, [providers, updateProviderSettings]);
// Add effect to update category toggle state based on provider states
@@ -140,20 +170,55 @@ export default function LocalProvidersTab() {
setCategoryEnabled(newCategoryState);
}, [filteredProviders]);
- // Fetch Ollama models when enabled
+ // Start/stop health monitoring based on provider enabled state
useEffect(() => {
- const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
+ const providersToMonitor = filteredProviders.filter(
+ (provider) => provider.settings.enabled && provider.settings.baseUrl
+ );
- if (ollamaProvider?.settings.enabled) {
- fetchOllamaModels();
- }
- }, [filteredProviders]);
+ const providersToStop = filteredProviders.filter(
+ (provider) => !provider.settings.enabled && provider.settings.baseUrl
+ );
+
+ // Start monitoring for enabled providers
+ providersToMonitor.forEach((provider) => {
+ const providerType =
+ provider.name === 'LMStudio' ? 'LMStudio' : provider.name === 'OpenAILike' ? 'OpenAILike' : 'Ollama';
+ startMonitoring(providerType, provider.settings.baseUrl!);
+ });
+
+ // Stop monitoring for disabled providers
+ providersToStop.forEach((provider) => {
+ const providerType =
+ provider.name === 'LMStudio' ? 'LMStudio' : provider.name === 'OpenAILike' ? 'OpenAILike' : 'Ollama';
+ stopMonitoring(providerType, provider.settings.baseUrl!);
+ });
- const fetchOllamaModels = async () => {
+ // Cleanup function
+ return () => {
+ filteredProviders.forEach((provider) => {
+ const providerType =
+ provider.name === 'LMStudio' ? 'LMStudio' : provider.name === 'OpenAILike' ? 'OpenAILike' : 'Ollama';
+ if (provider.settings.baseUrl) {
+ stopMonitoring(providerType, provider.settings.baseUrl);
+ }
+ });
+ };
+ }, [filteredProviders, startMonitoring, stopMonitoring]);
+
+ const fetchOllamaModels = useCallback(async () => {
try {
setIsLoadingModels(true);
- const response = await fetch('http://127.0.0.1:11434/api/tags');
+ const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
+ const baseUrl = ollamaProvider?.settings.baseUrl || OLLAMA_API_URL;
+
+ const response = await fetch(`${baseUrl}/api/tags`);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
const data = (await response.json()) as { models: OllamaModel[] };
setOllamaModels(
@@ -164,14 +229,27 @@ export default function LocalProvidersTab() {
);
} catch (error) {
console.error('Error fetching Ollama models:', error);
+ toast('Failed to fetch Ollama models. Make sure Ollama is running.');
} finally {
setIsLoadingModels(false);
}
- };
+ }, [filteredProviders, toast]);
+
+ // Fetch Ollama models when enabled
+ useEffect(() => {
+ const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
+
+ if (ollamaProvider?.settings.enabled) {
+ fetchOllamaModels();
+ }
+ }, [filteredProviders, fetchOllamaModels]);
const updateOllamaModel = async (modelName: string): Promise => {
try {
- const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
+ const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
+ const baseUrl = ollamaProvider?.settings.baseUrl || OLLAMA_API_URL;
+
+ const response = await fetch(`${baseUrl}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName }),
@@ -200,7 +278,7 @@ export default function LocalProvidersTab() {
for (const line of lines) {
const rawData = JSON.parse(line);
- if (!isOllamaPullResponse(rawData)) {
+ if (!isOllamaPullProgress(rawData)) {
console.error('Invalid response format:', rawData);
continue;
}
@@ -223,7 +301,7 @@ export default function LocalProvidersTab() {
}
}
- const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
+ const updatedResponse = await fetch(`${baseUrl}/api/tags`);
const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
const updatedModel = updatedData.models.find((m) => m.name === modelName);
@@ -280,7 +358,10 @@ export default function LocalProvidersTab() {
const handleDeleteOllamaModel = async (modelName: string) => {
try {
- const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
+ const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
+ const baseUrl = ollamaProvider?.settings.baseUrl || OLLAMA_API_URL;
+
+ const response = await fetch(`${baseUrl}/api/delete`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
@@ -297,7 +378,7 @@ export default function LocalProvidersTab() {
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
console.error(`Error deleting ${modelName}:`, errorMessage);
- toast(`Failed to delete ${modelName}`);
+ toast(`Failed to delete ${modelName}: ${errorMessage}`);
}
};
@@ -305,17 +386,17 @@ export default function LocalProvidersTab() {
const ModelDetails = ({ model }: { model: OllamaModel }) => (
-
+
{model.digest.substring(0, 7)}
{model.details && (
<>
-
+
{model.details.parameter_size}
-
+
{model.details.quantization_level}
>
@@ -350,11 +431,11 @@ export default function LocalProvidersTab() {
>
{model.status === 'updating' ? (
) : (
-
+
)}
-
+
);
return (
- {
+ console.error('LocalProvidersTab error:', error, errorInfo);
+ toast('An error occurred in the local providers section');
+ }}
>
-
+
{/* Header section */}
-
-
-
-
-
-
-
-
Local AI Models
+
+
+
+
+
+
+
+
+
Local AI Models
+ BETA
+
+
Configure and manage your local AI providers
-
Configure and manage your local AI providers
+
+
+
+ Enable All
+
-
-
Enable All
-
+ {/* Action buttons */}
+
+
setShowPerformanceDashboard(!showPerformanceDashboard)}
+ className={classNames(
+ 'px-3 py-2 rounded-lg text-sm font-medium',
+ 'bg-bolt-elements-background-depth-3',
+ 'hover:bg-bolt-elements-background-depth-4',
+ 'text-bolt-elements-textPrimary',
+ 'transition-colors duration-200',
+ 'flex items-center gap-2',
+ showPerformanceDashboard ? 'bg-purple-500/10 text-purple-500' : ''
+ )}
+ >
+
+ {showPerformanceDashboard ? 'Hide Performance' : 'Show Performance'}
+
+
+
setShowModelManagement(!showModelManagement)}
+ className={classNames(
+ 'px-3 py-2 rounded-lg text-sm font-medium',
+ 'bg-bolt-elements-background-depth-3',
+ 'hover:bg-bolt-elements-background-depth-4',
+ 'text-bolt-elements-textPrimary',
+ 'transition-colors duration-200',
+ 'flex items-center gap-2',
+ showModelManagement ? 'bg-purple-500/10 text-purple-500' : ''
+ )}
+ >
+
+ {showModelManagement ? 'Hide Models' : 'Manage Models'}
+
+
+
setShowProvidersGuide(!showProvidersGuide)}
+ className={classNames(
+ 'px-3 py-2 rounded-lg text-sm font-medium',
+ 'bg-bolt-elements-background-depth-3',
+ 'hover:bg-bolt-elements-background-depth-4',
+ 'text-bolt-elements-textPrimary',
+ 'transition-colors duration-200',
+ 'flex items-center gap-2',
+ showProvidersGuide ? 'bg-purple-500/10 text-purple-500' : ''
+ )}
+ >
+
+ {showProvidersGuide ? 'Hide Guide' : 'Providers Guide'}
+
+
+
setShowEndpointStatus(!showEndpointStatus)}
+ className={classNames(
+ 'px-3 py-2 rounded-lg text-sm font-medium',
+ 'bg-bolt-elements-background-depth-3',
+ 'hover:bg-bolt-elements-background-depth-4',
+ 'text-bolt-elements-textPrimary',
+ 'transition-colors duration-200',
+ 'flex items-center gap-2',
+ showEndpointStatus ? 'bg-purple-500/10 text-purple-500' : ''
+ )}
+ >
+
+ {showEndpointStatus ? 'Hide Status' : 'Endpoint Status'}
+
+ {/* Health Summary */}
+
+
+ {/* Performance Dashboard */}
+
+ {showPerformanceDashboard && (
+
+
+
+ )}
+
+
+ {/* Model Management Dashboard */}
+
+ {showModelManagement && (
+
+ {/* LM Studio Model Manager */}
+ {(() => {
+ const lmStudioProvider = filteredProviders.find((p) => p.name === 'LMStudio');
+ return lmStudioProvider?.settings.enabled && lmStudioProvider.settings.baseUrl ? (
+
+ ) : null;
+ })()}
+
+ {/* OpenAI-like Model Manager */}
+ {(() => {
+ const openAILikeProvider = filteredProviders.find((p) => p.name === 'OpenAILike');
+ return openAILikeProvider?.settings.enabled && openAILikeProvider.settings.baseUrl ? (
+
+ ) : null;
+ })()}
+
+ {/* Show message if no providers are enabled */}
+ {(() => {
+ const hasEnabledProviders = filteredProviders.some(
+ (p) => (p.name === 'LMStudio' || p.name === 'OpenAILike') &&
+ p.settings.enabled &&
+ p.settings.baseUrl
+ );
+
+ return !hasEnabledProviders ? (
+
+
+
+ No Model Managers Available
+
+
+ Enable and configure LM Studio or OpenAI-like providers to manage their models.
+
+
+ ) : null;
+ })()}
+
+ )}
+
+
+ {/* Providers Guide */}
+
+ {showProvidersGuide && (
+
+
+
+ )}
+
+
+ {/* Endpoint Status Dashboard */}
+
+ {showEndpointStatus && (
+
+
+
+ )}
+
+
{/* Ollama Section */}
{filteredProviders
.filter((provider) => provider.name === 'Ollama')
@@ -449,9 +707,8 @@ export default function LocalProvidersTab() {
)}
whileHover={{ scale: 1.1, rotate: 5 }}
>
- {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || Bot, {
className: 'w-7 h-7',
- 'aria-label': `${provider.name} icon`,
})}
@@ -462,6 +719,21 @@ export default function LocalProvidersTab() {
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
+ {provider.settings.enabled && provider.settings.baseUrl && (
+
+
+
+ )}
-
+
{provider.settings.baseUrl || OLLAMA_API_URL}
@@ -530,12 +802,12 @@ export default function LocalProvidersTab() {
{isLoadingModels ? (
) : (
@@ -557,7 +829,7 @@ export default function LocalProvidersTab() {
) : ollamaModels.length === 0 ? (
-
+
No models installed yet
Browse models at{' '}
@@ -568,7 +840,7 @@ export default function LocalProvidersTab() {
className="text-purple-500 hover:underline inline-flex items-center gap-0.5 text-base font-medium"
>
ollama.com/library
-
+
{' '}
and copy model names to install
@@ -620,8 +892,26 @@ export default function LocalProvidersTab() {
)}
- {/* Model Installation Section */}
-
+ {/* Model Installation Info */}
+
+
+
+ Use the Ollama app or CLI to install models. Installed models will appear here automatically.
+ Visit{' '}
+
+ ollama.com/library
+ {' '}
+ to browse available models.
+
+
)}
@@ -629,11 +919,20 @@ export default function LocalProvidersTab() {
{/* Other Providers Section */}
-
Other Local Providers
-
- {filteredProviders
- .filter((provider) => provider.name !== 'Ollama')
- .map((provider, index) => (
+
+
+
Other Local Providers
+
+
+ {isLoadingProviders ? (
+ // Show loading skeletons
+ Array.from({ length: 2 }).map((_, index) => (
+
+ ))
+ ) : (
+ filteredProviders
+ .filter((provider) => provider.name !== 'Ollama')
+ .map((provider, index) => (
- {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || Bot, {
className: 'w-7 h-7',
- 'aria-label': `${provider.name} icon`,
})}
@@ -733,7 +1031,7 @@ export default function LocalProvidersTab() {
)}
>
-
+
{provider.settings.baseUrl || 'Click to set base URL'}
@@ -743,11 +1041,13 @@ export default function LocalProvidersTab() {
)}
- ))}
+ ))
+ )}
-
-
+
+
+
);
}
diff --git a/app/components/@settings/tabs/providers/local/ModelConfigurationPanel.tsx b/app/components/@settings/tabs/providers/local/ModelConfigurationPanel.tsx
new file mode 100644
index 0000000000..78766c00f1
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/ModelConfigurationPanel.tsx
@@ -0,0 +1,458 @@
+import React, { useState, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import type { ModelCapabilities } from '~/lib/services/modelCapabilitiesDetector';
+
+interface ModelConfigurationPanelProps {
+ modelId: string;
+ provider: string;
+ capabilities?: ModelCapabilities;
+ onConfigurationChange?: (config: ModelConfiguration) => void;
+ className?: string;
+}
+
+export interface ModelConfiguration {
+ temperature: number;
+ topP: number;
+ maxTokens: number;
+ frequencyPenalty: number;
+ presencePenalty: number;
+ stopSequences: string[];
+ streamingEnabled: boolean;
+ functionCallingEnabled: boolean;
+}
+
+const DEFAULT_CONFIG: ModelConfiguration = {
+ temperature: 0.7,
+ topP: 0.9,
+ maxTokens: 2048,
+ frequencyPenalty: 0,
+ presencePenalty: 0,
+ stopSequences: [],
+ streamingEnabled: true,
+ functionCallingEnabled: false,
+};
+
+interface ConfigSliderProps {
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ step: number;
+ description: string;
+ onChange: (value: number) => void;
+ disabled?: boolean;
+}
+
+function ConfigSlider({ label, value, min, max, step, description, onChange, disabled }: ConfigSliderProps) {
+ return (
+
+
+ {label}
+
+ {value}
+
+
+
+
onChange(parseFloat(e.target.value))}
+ disabled={disabled}
+ className={classNames(
+ 'w-full h-2 rounded-lg appearance-none cursor-pointer',
+ 'bg-bolt-elements-background-depth-3',
+ 'slider-thumb:appearance-none slider-thumb:w-4 slider-thumb:h-4',
+ 'slider-thumb:rounded-full slider-thumb:bg-purple-500',
+ 'slider-thumb:cursor-pointer slider-thumb:border-0',
+ disabled ? 'opacity-50 cursor-not-allowed' : ''
+ )}
+ />
+
+
{description}
+
+ );
+}
+
+interface CapabilityBadgeProps {
+ label: string;
+ supported: boolean;
+ icon: string;
+}
+
+function CapabilityBadge({ label, supported, icon }: CapabilityBadgeProps) {
+ return (
+
+
+
{label}
+ {supported &&
}
+
+ );
+}
+
+export default function ModelConfigurationPanel({
+ modelId,
+ provider,
+ capabilities,
+ onConfigurationChange,
+ className
+}: ModelConfigurationPanelProps) {
+ const [config, setConfig] = useState
(DEFAULT_CONFIG);
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [stopSequenceInput, setStopSequenceInput] = useState('');
+
+ // Initialize configuration with recommended settings
+ useEffect(() => {
+ if (capabilities?.recommendedSettings) {
+ setConfig(prev => ({
+ ...prev,
+ temperature: capabilities.recommendedSettings?.temperature ?? prev.temperature,
+ topP: capabilities.recommendedSettings?.topP ?? prev.topP,
+ maxTokens: capabilities.recommendedSettings?.maxTokens ?? prev.maxTokens,
+ streamingEnabled: capabilities.supportsStreaming,
+ functionCallingEnabled: capabilities.supportsFunctionCalling,
+ }));
+ }
+ }, [capabilities]);
+
+ // Notify parent of configuration changes
+ useEffect(() => {
+ onConfigurationChange?.(config);
+ }, [config, onConfigurationChange]);
+
+ const updateConfig = (updates: Partial) => {
+ setConfig(prev => ({ ...prev, ...updates }));
+ };
+
+ const addStopSequence = () => {
+ if (stopSequenceInput.trim() && !config.stopSequences.includes(stopSequenceInput.trim())) {
+ updateConfig({
+ stopSequences: [...config.stopSequences, stopSequenceInput.trim()]
+ });
+ setStopSequenceInput('');
+ }
+ };
+
+ const removeStopSequence = (sequence: string) => {
+ updateConfig({
+ stopSequences: config.stopSequences.filter(s => s !== sequence)
+ });
+ };
+
+ const resetToDefaults = () => {
+ const defaultConfig = capabilities?.recommendedSettings
+ ? {
+ ...DEFAULT_CONFIG,
+ temperature: capabilities.recommendedSettings.temperature ?? DEFAULT_CONFIG.temperature,
+ topP: capabilities.recommendedSettings.topP ?? DEFAULT_CONFIG.topP,
+ maxTokens: capabilities.recommendedSettings.maxTokens ?? DEFAULT_CONFIG.maxTokens,
+ streamingEnabled: capabilities.supportsStreaming,
+ functionCallingEnabled: capabilities.supportsFunctionCalling,
+ }
+ : DEFAULT_CONFIG;
+
+ setConfig(defaultConfig);
+ };
+
+ return (
+
+ {/* Model Info Header */}
+
+
+
+ {modelId}
+
+
+ {provider} • Configure model parameters
+
+
+
+
+ Reset to Defaults
+
+
+
+ {/* Model Capabilities */}
+ {capabilities && (
+
+
Model Capabilities
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Model Metadata */}
+ {(capabilities.modelSize || capabilities.architecture || capabilities.quantization) && (
+
+ {capabilities.architecture && (
+
+
+ {capabilities.architecture}
+
+ )}
+ {capabilities.modelSize && (
+
+
+ {capabilities.modelSize}
+
+ )}
+ {capabilities.quantization && (
+
+
+ {capabilities.quantization}
+
+ )}
+ {capabilities.memoryRequirements && (
+
+
+ {capabilities.memoryRequirements}
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Basic Configuration */}
+
+
Basic Settings
+
+ updateConfig({ temperature: value })}
+ />
+
+ updateConfig({ topP: value })}
+ />
+
+ updateConfig({ maxTokens: value })}
+ />
+
+
+ {/* Advanced Configuration Toggle */}
+
setShowAdvanced(!showAdvanced)}
+ className={classNames(
+ 'flex items-center gap-2 text-sm font-medium',
+ 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary',
+ 'transition-colors duration-200'
+ )}
+ >
+
+ Advanced Settings
+
+
+ {/* Advanced Configuration */}
+
+ {showAdvanced && (
+
+ updateConfig({ frequencyPenalty: value })}
+ />
+
+ updateConfig({ presencePenalty: value })}
+ />
+
+ {/* Stop Sequences */}
+
+
+ Stop Sequences
+
+
+
+ setStopSequenceInput(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && addStopSequence()}
+ placeholder="Enter stop sequence..."
+ className={classNames(
+ 'flex-1 px-3 py-2 rounded-lg text-sm',
+ 'bg-bolt-elements-background-depth-3',
+ 'border border-bolt-elements-borderColor',
+ 'text-bolt-elements-textPrimary',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500'
+ )}
+ />
+
+ Add
+
+
+
+ {config.stopSequences.length > 0 && (
+
+ {config.stopSequences.map((sequence, index) => (
+
+ {sequence}
+ removeStopSequence(sequence)}
+ className="hover:text-red-500 transition-colors duration-200"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ Sequences where the model will stop generating text.
+
+
+
+ {/* Feature Toggles */}
+
+
+
+
+ Streaming
+
+
+ Enable real-time response streaming
+
+
+
updateConfig({ streamingEnabled: e.target.checked })}
+ disabled={!capabilities?.supportsStreaming}
+ className="w-4 h-4 text-purple-500 rounded focus:ring-purple-500"
+ />
+
+
+
+
+
+ Function Calling
+
+
+ Enable function/tool calling capabilities
+
+
+
updateConfig({ functionCallingEnabled: e.target.checked })}
+ disabled={!capabilities?.supportsFunctionCalling}
+ className="w-4 h-4 text-purple-500 rounded focus:ring-purple-500"
+ />
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/ModelHealthIndicator.tsx b/app/components/@settings/tabs/providers/local/ModelHealthIndicator.tsx
new file mode 100644
index 0000000000..065769bec5
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/ModelHealthIndicator.tsx
@@ -0,0 +1,228 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { useProviderHealth, useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth';
+import type { ModelHealthStatus } from '~/lib/services/localModelHealthMonitor';
+
+interface ModelHealthIndicatorProps {
+ provider: 'Ollama' | 'LMStudio' | 'OpenAILike';
+ baseUrl: string;
+ showDetails?: boolean;
+ className?: string;
+}
+
+interface HealthStatusBadgeProps {
+ status: ModelHealthStatus['status'];
+ responseTime?: number;
+ className?: string;
+}
+
+function HealthStatusBadge({ status, responseTime, className }: HealthStatusBadgeProps) {
+ const getStatusConfig = () => {
+ switch (status) {
+ case 'healthy':
+ return {
+ color: 'bg-green-500',
+ textColor: 'text-green-600',
+ bgColor: 'bg-green-500/10 border-green-500/20',
+ borderColor: 'border-green-500/20',
+ icon: 'i-ph:check-circle-fill',
+ label: 'Healthy',
+ };
+ case 'unhealthy':
+ return {
+ color: 'bg-red-500',
+ textColor: 'text-red-600',
+ bgColor: 'bg-red-500/10 border-red-500/20',
+ borderColor: 'border-red-500/20',
+ icon: 'i-ph:x-circle-fill',
+ label: 'Unhealthy',
+ };
+ case 'checking':
+ return {
+ color: 'bg-blue-500',
+ textColor: 'text-blue-600',
+ bgColor: 'bg-blue-500/10 border-blue-500/20',
+ borderColor: 'border-blue-500/20',
+ icon: 'i-ph:circle-notch',
+ label: 'Checking',
+ };
+ default:
+ return {
+ color: 'bg-gray-500',
+ textColor: 'text-gray-600',
+ bgColor: 'bg-gray-500/10 border-gray-500/20',
+ borderColor: 'border-gray-500/20',
+ icon: 'i-ph:question',
+ label: 'Unknown',
+ };
+ }
+ };
+
+ const config = getStatusConfig();
+
+ return (
+
+
+ {status === 'checking' && (
+
+ )}
+
+
{config.label}
+ {responseTime !== undefined && status === 'healthy' && (
+
({responseTime}ms)
+ )}
+
+ );
+}
+
+export default function ModelHealthIndicator({
+ provider,
+ baseUrl,
+ showDetails = false,
+ className
+}: ModelHealthIndicatorProps) {
+ const { status, isHealthy, performHealthCheck } = useProviderHealth(provider, baseUrl);
+
+ if (!status) {
+ return (
+
+
+
+ );
+ }
+
+ const handleRefresh = async () => {
+ await performHealthCheck();
+ };
+
+ return (
+
+
+
+ {showDetails && (
+
+
+
+
+
+ {status.availableModels && status.availableModels.length > 0 && (
+
+ {status.availableModels.length} model{status.availableModels.length !== 1 ? 's' : ''}
+
+ )}
+
+ {status.version && (
+
+ v{status.version}
+
+ )}
+
+ )}
+
+ {status.error && (
+
+ {status.error}
+
+ )}
+
+ );
+}
+
+interface ModelHealthSummaryProps {
+ className?: string;
+}
+
+export function ModelHealthSummary({ className }: ModelHealthSummaryProps) {
+ const { healthStatuses, getOverallHealth } = useLocalModelHealth();
+ const stats = getOverallHealth();
+ const totalProviders = healthStatuses.length;
+
+ if (totalProviders === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ Local Providers Health
+
+
+ {totalProviders} provider{totalProviders !== 1 ? 's' : ''} monitored
+
+
+
+
+
+ {stats.healthy > 0 && (
+
+ )}
+
+ {stats.unhealthy > 0 && (
+
+ )}
+
+ {stats.checking > 0 && (
+
+ )}
+
+ {stats.unknown > 0 && (
+
+ )}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/ModelManagementDashboard.tsx b/app/components/@settings/tabs/providers/local/ModelManagementDashboard.tsx
new file mode 100644
index 0000000000..cf49d3ed38
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/ModelManagementDashboard.tsx
@@ -0,0 +1,437 @@
+import React, { useState, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { modelCapabilitiesDetector, type ModelCapabilities } from '~/lib/services/modelCapabilitiesDetector';
+import ModelConfigurationPanel, { type ModelConfiguration } from './ModelConfigurationPanel';
+
+interface ModelManagementDashboardProps {
+ provider: string;
+ baseUrl: string;
+ apiKey?: string;
+ models: Array<{ id: string; name: string }>;
+ className?: string;
+}
+
+interface ModelWithCapabilities {
+ id: string;
+ name: string;
+ capabilities?: ModelCapabilities;
+ configuration?: ModelConfiguration;
+ isLoading: boolean;
+ error?: string;
+}
+
+interface ModelCardProps {
+ model: ModelWithCapabilities;
+ onConfigurationChange: (modelId: string, config: ModelConfiguration) => void;
+ onRefreshCapabilities: (modelId: string) => void;
+}
+
+function ModelCard({ model, onConfigurationChange, onRefreshCapabilities }: ModelCardProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [showConfig, setShowConfig] = useState(false);
+
+ const getStatusColor = () => {
+ if (model.isLoading) return 'text-blue-500';
+ if (model.error) return 'text-red-500';
+ if (model.capabilities?.isVerified) return 'text-green-500';
+ return 'text-gray-500';
+ };
+
+ const getStatusIcon = () => {
+ if (model.isLoading) return 'i-ph:circle-notch animate-spin';
+ if (model.error) return 'i-ph:warning-circle';
+ if (model.capabilities?.isVerified) return 'i-ph:check-circle';
+ return 'i-ph:question';
+ };
+
+ const formatResponseTime = (ms?: number) => {
+ if (!ms) return 'N/A';
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
+ };
+
+ return (
+
+ {/* Model Header */}
+
+
+
+
+
+
+
+ {model.name}
+
+ {model.capabilities && (
+
+ {model.capabilities.architecture && (
+ {model.capabilities.architecture}
+ )}
+ {model.capabilities.modelSize && (
+ • {model.capabilities.modelSize}
+ )}
+ {model.capabilities.quantization && (
+ • {model.capabilities.quantization}
+ )}
+
+ )}
+
+
+
+
+ {model.capabilities?.averageResponseTime && (
+
+ {formatResponseTime(model.capabilities.averageResponseTime)}
+
+ )}
+
+
onRefreshCapabilities(model.id)}
+ disabled={model.isLoading}
+ className={classNames(
+ 'p-1 rounded-md text-xs',
+ 'hover:bg-bolt-elements-background-depth-3',
+ 'text-bolt-elements-textSecondary',
+ 'transition-colors duration-200',
+ model.isLoading ? 'opacity-50 cursor-not-allowed' : ''
+ )}
+ title="Refresh capabilities"
+ >
+
+
+
+
setExpanded(!expanded)}
+ className={classNames(
+ 'p-1 rounded-md text-xs',
+ 'hover:bg-bolt-elements-background-depth-3',
+ 'text-bolt-elements-textSecondary',
+ 'transition-all duration-200'
+ )}
+ >
+
+
+
+
+
+ {/* Error Display */}
+ {model.error && (
+
+ )}
+
+
+ {/* Expanded Content */}
+
+ {expanded && (
+
+ {model.capabilities && (
+
+ {/* Capabilities Grid */}
+
+
+ Capabilities
+
+
+ {[
+ { key: 'supportsChat', label: 'Chat', icon: 'i-ph:chat-circle' },
+ { key: 'supportsStreaming', label: 'Streaming', icon: 'i-ph:lightning' },
+ { key: 'supportsFunctionCalling', label: 'Functions', icon: 'i-ph:function' },
+ { key: 'supportsVision', label: 'Vision', icon: 'i-ph:eye' },
+ { key: 'supportsEmbeddings', label: 'Embeddings', icon: 'i-ph:vector-three' },
+ { key: 'isVerified', label: 'Verified', icon: 'i-ph:shield-check' },
+ ].map(({ key, label, icon }) => (
+
+ ))}
+
+
+
+ {/* Model Metadata */}
+ {(model.capabilities.memoryRequirements || model.capabilities.contextWindow) && (
+
+
+ Specifications
+
+
+ {model.capabilities.memoryRequirements && (
+
+ Memory:
+
+ {model.capabilities.memoryRequirements}
+
+
+ )}
+ {model.capabilities.contextWindow && (
+
+ Context:
+
+ {model.capabilities.contextWindow.toLocaleString()} tokens
+
+
+ )}
+
+
+ )}
+
+ {/* Validation Warnings */}
+ {model.capabilities.validationWarnings && model.capabilities.validationWarnings.length > 0 && (
+
+
+ Warnings
+
+
+ {model.capabilities.validationWarnings.map((warning, index) => (
+
+ {warning}
+
+ ))}
+
+
+ )}
+
+ {/* Configuration Panel Toggle */}
+
+ setShowConfig(!showConfig)}
+ className={classNames(
+ 'px-3 py-2 rounded-lg text-sm font-medium',
+ 'bg-purple-500/10 text-purple-500',
+ 'hover:bg-purple-500/20',
+ 'transition-colors duration-200'
+ )}
+ >
+ {showConfig ? 'Hide Configuration' : 'Configure Model'}
+
+
+
+ {/* Configuration Panel */}
+
+ {showConfig && (
+
+ onConfigurationChange(model.id, config)}
+ />
+
+ )}
+
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export default function ModelManagementDashboard({
+ provider,
+ baseUrl,
+ apiKey,
+ models,
+ className
+}: ModelManagementDashboardProps) {
+ const [modelsWithCapabilities, setModelsWithCapabilities] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Initialize models
+ useEffect(() => {
+ setModelsWithCapabilities(
+ models.map(model => ({
+ id: model.id,
+ name: model.name,
+ isLoading: false,
+ }))
+ );
+ }, [models]);
+
+ // Load capabilities for all models
+ const loadAllCapabilities = async () => {
+ setIsLoading(true);
+
+ const updatedModels = await Promise.allSettled(
+ modelsWithCapabilities.map(async (model) => {
+ try {
+ setModelsWithCapabilities(prev =>
+ prev.map(m => m.id === model.id ? { ...m, isLoading: true, error: undefined } : m)
+ );
+
+ const capabilities = await modelCapabilitiesDetector.detectCapabilities(
+ provider,
+ model.id,
+ baseUrl,
+ apiKey
+ );
+
+ return {
+ ...model,
+ capabilities,
+ isLoading: false,
+ error: undefined,
+ };
+ } catch (error) {
+ return {
+ ...model,
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ })
+ );
+
+ const finalModels = updatedModels.map((result, index) =>
+ result.status === 'fulfilled' ? result.value : modelsWithCapabilities[index]
+ );
+
+ setModelsWithCapabilities(finalModels);
+ setIsLoading(false);
+ };
+
+ // Refresh capabilities for a specific model
+ const refreshModelCapabilities = async (modelId: string) => {
+ const model = modelsWithCapabilities.find(m => m.id === modelId);
+ if (!model) return;
+
+ setModelsWithCapabilities(prev =>
+ prev.map(m => m.id === modelId ? { ...m, isLoading: true, error: undefined } : m)
+ );
+
+ try {
+ const capabilities = await modelCapabilitiesDetector.detectCapabilities(
+ provider,
+ modelId,
+ baseUrl,
+ apiKey
+ );
+
+ setModelsWithCapabilities(prev =>
+ prev.map(m => m.id === modelId ? { ...m, capabilities, isLoading: false, error: undefined } : m)
+ );
+ } catch (error) {
+ setModelsWithCapabilities(prev =>
+ prev.map(m => m.id === modelId ? {
+ ...m,
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ } : m)
+ );
+ }
+ };
+
+ // Handle configuration changes
+ const handleConfigurationChange = (modelId: string, config: ModelConfiguration) => {
+ setModelsWithCapabilities(prev =>
+ prev.map(m => m.id === modelId ? { ...m, configuration: config } : m)
+ );
+ };
+
+ if (models.length === 0) {
+ return (
+
+
+
+ No Models Available
+
+
+ Make sure your {provider} server is running and has models loaded.
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Model Management
+
+
+ {provider} • {models.length} model{models.length !== 1 ? 's' : ''} available
+
+
+
+
+
+ {isLoading ? 'Analyzing...' : 'Analyze All Models'}
+
+
+
+ {/* Models Grid */}
+
+ {modelsWithCapabilities.map((model) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/ModelPerformanceDashboard.tsx b/app/components/@settings/tabs/providers/local/ModelPerformanceDashboard.tsx
new file mode 100644
index 0000000000..84eeb4ecbf
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/ModelPerformanceDashboard.tsx
@@ -0,0 +1,367 @@
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { useModelPerformance } from '~/lib/hooks/useModelPerformance';
+import type { AggregatedMetrics } from '~/lib/services/modelPerformanceMonitor';
+
+interface ModelPerformanceDashboardProps {
+ className?: string;
+}
+
+interface MetricCardProps {
+ title: string;
+ value: string | number;
+ subtitle?: string;
+ icon: string;
+ color: 'green' | 'blue' | 'purple' | 'orange' | 'red';
+ trend?: 'up' | 'down' | 'stable';
+}
+
+function MetricCard({ title, value, subtitle, icon, color, trend }: MetricCardProps) {
+ const colorClasses = {
+ green: 'bg-green-500/10 text-green-500 border-green-500/20',
+ blue: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
+ purple: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
+ orange: 'bg-orange-500/10 text-orange-500 border-orange-500/20',
+ red: 'bg-red-500/10 text-red-500 border-red-500/20',
+ };
+
+ const trendIcons = {
+ up: 'i-ph:trend-up',
+ down: 'i-ph:trend-down',
+ stable: 'i-ph:minus',
+ };
+
+ return (
+
+
+
+
{value}
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+ );
+}
+
+interface ModelPerformanceRowProps {
+ metrics: AggregatedMetrics;
+ rank: number;
+}
+
+function ModelPerformanceRow({ metrics, rank }: ModelPerformanceRowProps) {
+ const [expanded, setExpanded] = useState(false);
+
+ const successRate = metrics.totalRequests > 0 ?
+ (metrics.successfulRequests / metrics.totalRequests) * 100 : 0;
+
+ const formatTime = (ms: number) => {
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+ };
+
+ const formatTokensPerSecond = (tps: number) => {
+ if (tps === 0) return 'N/A';
+ return `${Math.round(tps)} tok/s`;
+ };
+
+ return (
+
+ setExpanded(!expanded)}
+ >
+
+
+
+ #{rank}
+
+
+
+ {metrics.model}
+
+
+ {metrics.provider}
+
+
+
+
+
+
+
+ {formatTime(metrics.averageResponseTime)}
+
+
Response
+
+
+
+
+ {formatTokensPerSecond(metrics.averageTokensPerSecond)}
+
+
Speed
+
+
+
+
+ {successRate.toFixed(1)}%
+
+
Success
+
+
+
+
+
+
+
+
+ {expanded && (
+
+
+
+
+ {metrics.totalRequests}
+
+
Total Requests
+
+
+
+
+ {metrics.successfulRequests}
+
+
Successful
+
+
+
+
+ {metrics.failedRequests}
+
+
Failed
+
+
+
+
+ {metrics.totalTokensProcessed.toLocaleString()}
+
+
Total Tokens
+
+
+
+ )}
+
+
+ );
+}
+
+export default function ModelPerformanceDashboard({ className }: ModelPerformanceDashboardProps) {
+ const { allMetrics, getPerformanceComparison, clearAllMetrics, exportMetrics } = useModelPerformance();
+ const [showComparison, setShowComparison] = useState(false);
+
+ const comparison = getPerformanceComparison();
+
+ // Calculate overall statistics
+ const totalRequests = allMetrics.reduce((sum, m) => sum + m.totalRequests, 0);
+ const totalSuccessful = allMetrics.reduce((sum, m) => sum + m.successfulRequests, 0);
+ const totalFailed = allMetrics.reduce((sum, m) => sum + m.failedRequests, 0);
+ const averageResponseTime = allMetrics.length > 0 ?
+ allMetrics.reduce((sum, m) => sum + m.averageResponseTime, 0) / allMetrics.length : 0;
+ const averageTokensPerSecond = allMetrics.length > 0 ?
+ allMetrics.reduce((sum, m) => sum + m.averageTokensPerSecond, 0) / allMetrics.length : 0;
+
+ const handleExport = () => {
+ const data = exportMetrics();
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `model-performance-${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ if (allMetrics.length === 0) {
+ return (
+
+
+
+ No Performance Data
+
+
+ Start using your local models to see performance metrics here.
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Performance Dashboard
+
+
+ Monitor and compare your local model performance
+
+
+
+
+ setShowComparison(!showComparison)}
+ className={classNames(
+ 'px-3 py-2 rounded-lg text-sm font-medium',
+ 'bg-bolt-elements-background-depth-3',
+ 'hover:bg-bolt-elements-background-depth-4',
+ 'text-bolt-elements-textPrimary',
+ 'transition-colors duration-200'
+ )}
+ >
+ {showComparison ? 'Hide Comparison' : 'Show Comparison'}
+
+
+
+ Export Data
+
+
+
+ Clear All
+
+
+
+
+ {/* Overview Metrics */}
+
+
+
+ 0 ? ((totalSuccessful / totalRequests) * 100).toFixed(1) : 0}%`}
+ subtitle={`${totalSuccessful} successful`}
+ icon="i-ph:check-circle"
+ color="green"
+ />
+
+
+
+ 0 ? `${Math.round(averageTokensPerSecond)}` : 'N/A'}
+ subtitle="tokens/sec"
+ icon="i-ph:lightning"
+ color="orange"
+ />
+
+
+
+
+ {/* Model Comparison */}
+
+ {showComparison && (
+
+
+ Model Performance Ranking
+
+
+
+ {comparison.map((item, index) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx b/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx
deleted file mode 100644
index 9568076f24..0000000000
--- a/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx
+++ /dev/null
@@ -1,603 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { motion } from 'framer-motion';
-import { classNames } from '~/utils/classNames';
-import { Progress } from '~/components/ui/Progress';
-import { useToast } from '~/components/ui/use-toast';
-import { useSettings } from '~/lib/hooks/useSettings';
-
-interface OllamaModelInstallerProps {
- onModelInstalled: () => void;
-}
-
-interface InstallProgress {
- status: string;
- progress: number;
- downloadedSize?: string;
- totalSize?: string;
- speed?: string;
-}
-
-interface ModelInfo {
- name: string;
- desc: string;
- size: string;
- tags: string[];
- installedVersion?: string;
- latestVersion?: string;
- needsUpdate?: boolean;
- status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error';
- details?: {
- family: string;
- parameter_size: string;
- quantization_level: string;
- };
-}
-
-const POPULAR_MODELS: ModelInfo[] = [
- {
- name: 'deepseek-coder:6.7b',
- desc: "DeepSeek's code generation model",
- size: '4.1GB',
- tags: ['coding', 'popular'],
- },
- {
- name: 'llama2:7b',
- desc: "Meta's Llama 2 (7B parameters)",
- size: '3.8GB',
- tags: ['general', 'popular'],
- },
- {
- name: 'mistral:7b',
- desc: "Mistral's 7B model",
- size: '4.1GB',
- tags: ['general', 'popular'],
- },
- {
- name: 'gemma:7b',
- desc: "Google's Gemma model",
- size: '4.0GB',
- tags: ['general', 'new'],
- },
- {
- name: 'codellama:7b',
- desc: "Meta's Code Llama model",
- size: '4.1GB',
- tags: ['coding', 'popular'],
- },
- {
- name: 'neural-chat:7b',
- desc: "Intel's Neural Chat model",
- size: '4.1GB',
- tags: ['chat', 'popular'],
- },
- {
- name: 'phi:latest',
- desc: "Microsoft's Phi-2 model",
- size: '2.7GB',
- tags: ['small', 'fast'],
- },
- {
- name: 'qwen:7b',
- desc: "Alibaba's Qwen model",
- size: '4.1GB',
- tags: ['general'],
- },
- {
- name: 'solar:10.7b',
- desc: "Upstage's Solar model",
- size: '6.1GB',
- tags: ['large', 'powerful'],
- },
- {
- name: 'openchat:7b',
- desc: 'Open-source chat model',
- size: '4.1GB',
- tags: ['chat', 'popular'],
- },
- {
- name: 'dolphin-phi:2.7b',
- desc: 'Lightweight chat model',
- size: '1.6GB',
- tags: ['small', 'fast'],
- },
- {
- name: 'stable-code:3b',
- desc: 'Lightweight coding model',
- size: '1.8GB',
- tags: ['coding', 'small'],
- },
-];
-
-function formatBytes(bytes: number): string {
- if (bytes === 0) {
- return '0 B';
- }
-
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
-
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
-}
-
-function formatSpeed(bytesPerSecond: number): string {
- return `${formatBytes(bytesPerSecond)}/s`;
-}
-
-// Add Ollama Icon SVG component
-function OllamaIcon({ className }: { className?: string }) {
- return (
-
-
-
- );
-}
-
-export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) {
- const [modelString, setModelString] = useState('');
- const [searchQuery, setSearchQuery] = useState('');
- const [isInstalling, setIsInstalling] = useState(false);
- const [isChecking, setIsChecking] = useState(false);
- const [installProgress, setInstallProgress] = useState(null);
- const [selectedTags, setSelectedTags] = useState([]);
- const [models, setModels] = useState(POPULAR_MODELS);
- const { toast } = useToast();
- const { providers } = useSettings();
-
- // Get base URL from provider settings
- const baseUrl = providers?.Ollama?.settings?.baseUrl || 'http://127.0.0.1:11434';
-
- // Function to check installed models and their versions
- const checkInstalledModels = async () => {
- try {
- const response = await fetch(`${baseUrl}/api/tags`, {
- method: 'GET',
- });
-
- if (!response.ok) {
- throw new Error('Failed to fetch installed models');
- }
-
- const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> };
- const installedModels = data.models || [];
-
- // Update models with installed versions
- setModels((prevModels) =>
- prevModels.map((model) => {
- const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase());
-
- if (installed) {
- return {
- ...model,
- installedVersion: installed.digest.substring(0, 8),
- needsUpdate: installed.digest !== installed.latest,
- latestVersion: installed.latest?.substring(0, 8),
- };
- }
-
- return model;
- }),
- );
- } catch (error) {
- console.error('Error checking installed models:', error);
- }
- };
-
- // Check installed models on mount and after installation
- useEffect(() => {
- checkInstalledModels();
- }, [baseUrl]);
-
- const handleCheckUpdates = async () => {
- setIsChecking(true);
-
- try {
- await checkInstalledModels();
- toast('Model versions checked');
- } catch (err) {
- console.error('Failed to check model versions:', err);
- toast('Failed to check model versions');
- } finally {
- setIsChecking(false);
- }
- };
-
- const filteredModels = models.filter((model) => {
- const matchesSearch =
- searchQuery === '' ||
- model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- model.desc.toLowerCase().includes(searchQuery.toLowerCase());
- const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag));
-
- return matchesSearch && matchesTags;
- });
-
- const handleInstallModel = async (modelToInstall: string) => {
- if (!modelToInstall) {
- return;
- }
-
- try {
- setIsInstalling(true);
- setInstallProgress({
- status: 'Starting download...',
- progress: 0,
- downloadedSize: '0 B',
- totalSize: 'Calculating...',
- speed: '0 B/s',
- });
- setModelString('');
- setSearchQuery('');
-
- const response = await fetch(`${baseUrl}/api/pull`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ name: modelToInstall }),
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const reader = response.body?.getReader();
-
- if (!reader) {
- throw new Error('Failed to get response reader');
- }
-
- let lastTime = Date.now();
- let lastBytes = 0;
-
- while (true) {
- const { done, value } = await reader.read();
-
- if (done) {
- break;
- }
-
- const text = new TextDecoder().decode(value);
- const lines = text.split('\n').filter(Boolean);
-
- for (const line of lines) {
- try {
- const data = JSON.parse(line);
-
- if ('status' in data) {
- const currentTime = Date.now();
- const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds
- const bytesDiff = (data.completed || 0) - lastBytes;
- const speed = bytesDiff / timeDiff;
-
- setInstallProgress({
- status: data.status,
- progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
- downloadedSize: formatBytes(data.completed || 0),
- totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
- speed: formatSpeed(speed),
- });
-
- lastTime = currentTime;
- lastBytes = data.completed || 0;
- }
- } catch (err) {
- console.error('Error parsing progress:', err);
- }
- }
- }
-
- toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.');
-
- // Ensure we call onModelInstalled after successful installation
- setTimeout(() => {
- onModelInstalled();
- }, 1000);
- } catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
- console.error(`Error installing ${modelToInstall}:`, errorMessage);
- toast(`Failed to install ${modelToInstall}. ${errorMessage}`);
- } finally {
- setIsInstalling(false);
- setInstallProgress(null);
- }
- };
-
- const handleUpdateModel = async (modelToUpdate: string) => {
- try {
- setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m)));
-
- const response = await fetch(`${baseUrl}/api/pull`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ name: modelToUpdate }),
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const reader = response.body?.getReader();
-
- if (!reader) {
- throw new Error('Failed to get response reader');
- }
-
- let lastTime = Date.now();
- let lastBytes = 0;
-
- while (true) {
- const { done, value } = await reader.read();
-
- if (done) {
- break;
- }
-
- const text = new TextDecoder().decode(value);
- const lines = text.split('\n').filter(Boolean);
-
- for (const line of lines) {
- try {
- const data = JSON.parse(line);
-
- if ('status' in data) {
- const currentTime = Date.now();
- const timeDiff = (currentTime - lastTime) / 1000;
- const bytesDiff = (data.completed || 0) - lastBytes;
- const speed = bytesDiff / timeDiff;
-
- setInstallProgress({
- status: data.status,
- progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
- downloadedSize: formatBytes(data.completed || 0),
- totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
- speed: formatSpeed(speed),
- });
-
- lastTime = currentTime;
- lastBytes = data.completed || 0;
- }
- } catch (err) {
- console.error('Error parsing progress:', err);
- }
- }
- }
-
- toast('Successfully updated ' + modelToUpdate);
-
- // Refresh model list after update
- await checkInstalledModels();
- } catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
- console.error(`Error updating ${modelToUpdate}:`, errorMessage);
- toast(`Failed to update ${modelToUpdate}. ${errorMessage}`);
- setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m)));
- } finally {
- setInstallProgress(null);
- }
- };
-
- const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags)));
-
- return (
-
-
-
-
-
-
Ollama Models
-
Install and manage your Ollama models
-
-
-
- {isChecking ? (
-
- ) : (
-
- )}
- Check Updates
-
-
-
-
-
-
-
{
- const value = e.target.value;
- setSearchQuery(value);
- setModelString(value);
- }}
- disabled={isInstalling}
- />
-
- Browse models at{' '}
-
- ollama.com/library
-
- {' '}
- and copy model names to install
-
-
-
-
handleInstallModel(modelString)}
- disabled={!modelString || isInstalling}
- className={classNames(
- 'rounded-lg px-4 py-2',
- 'bg-purple-500 text-white text-sm',
- 'hover:bg-purple-600',
- 'transition-all duration-200',
- 'flex items-center gap-2',
- { 'opacity-50 cursor-not-allowed': !modelString || isInstalling },
- )}
- whileHover={{ scale: 1.02 }}
- whileTap={{ scale: 0.98 }}
- >
- {isInstalling ? (
-
- ) : (
-
- )}
-
-
-
-
- {allTags.map((tag) => (
- {
- setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
- }}
- className={classNames(
- 'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
- selectedTags.includes(tag)
- ? 'bg-purple-500 text-white'
- : 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
- )}
- >
- {tag}
-
- ))}
-
-
-
- {filteredModels.map((model) => (
-
-
-
-
-
-
{model.name}
-
{model.desc}
-
-
-
{model.size}
- {model.installedVersion && (
-
- v{model.installedVersion}
- {model.needsUpdate && model.latestVersion && (
- v{model.latestVersion} available
- )}
-
- )}
-
-
-
-
- {model.tags.map((tag) => (
-
- {tag}
-
- ))}
-
-
- {model.installedVersion ? (
- model.needsUpdate ? (
-
handleUpdateModel(model.name)}
- className={classNames(
- 'px-2 py-0.5 rounded-lg text-xs',
- 'bg-purple-500 text-white',
- 'hover:bg-purple-600',
- 'transition-all duration-200',
- 'flex items-center gap-1',
- )}
- whileHover={{ scale: 1.02 }}
- whileTap={{ scale: 0.98 }}
- >
-
- Update
-
- ) : (
-
Up to date
- )
- ) : (
-
handleInstallModel(model.name)}
- className={classNames(
- 'px-2 py-0.5 rounded-lg text-xs',
- 'bg-purple-500 text-white',
- 'hover:bg-purple-600',
- 'transition-all duration-200',
- 'flex items-center gap-1',
- )}
- whileHover={{ scale: 1.02 }}
- whileTap={{ scale: 0.98 }}
- >
-
- Install
-
- )}
-
-
-
-
- ))}
-
-
- {installProgress && (
-
-
-
{installProgress.status}
-
-
- {installProgress.downloadedSize} / {installProgress.totalSize}
-
- {installProgress.speed}
- {Math.round(installProgress.progress)}%
-
-
-
-
- )}
-
- );
-}
diff --git a/app/components/@settings/tabs/providers/local/OpenAILikeModelManager.tsx b/app/components/@settings/tabs/providers/local/OpenAILikeModelManager.tsx
new file mode 100644
index 0000000000..fd50419a50
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/OpenAILikeModelManager.tsx
@@ -0,0 +1,568 @@
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { useToast } from '~/components/ui/use-toast';
+import { useModelPerformance } from '~/lib/hooks/useModelPerformance';
+import ErrorBoundary from './ErrorBoundary';
+import { ModelManagerSkeleton } from './LoadingSkeleton';
+
+interface OpenAILikeModel {
+ id: string;
+ object: string;
+ created?: number;
+ owned_by?: string;
+ permission?: any[];
+ root?: string;
+ parent?: string;
+ // Additional fields we can extract
+ isAvailable?: boolean;
+ contextLength?: number;
+ architecture?: string;
+ capabilities?: string[];
+}
+
+interface ModelStatus {
+ isHealthy: boolean;
+ responseTime?: number;
+ error?: string;
+ lastChecked: Date;
+ supportsChat?: boolean;
+ supportsCompletion?: boolean;
+ supportsStreaming?: boolean;
+}
+
+interface OpenAILikeModelManagerProps {
+ baseUrl: string;
+ apiKey?: string;
+ providerName?: string;
+ className?: string;
+}
+
+export default function OpenAILikeModelManager({
+ baseUrl,
+ apiKey,
+ providerName = 'OpenAI-like',
+ className
+}: OpenAILikeModelManagerProps) {
+ const [models, setModels] = useState([]);
+ const [modelStatuses, setModelStatuses] = useState>({});
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedModel, setSelectedModel] = useState(null);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const { toast } = useToast();
+ const { recordMetrics } = useModelPerformance();
+
+ // Normalize base URL - OpenAI-compatible APIs use /v1 prefix
+ const normalizedBaseUrl = baseUrl.endsWith('/v1') ? baseUrl : baseUrl.endsWith('/') ? `${baseUrl}v1` : `${baseUrl}/v1`;
+
+ // Fetch models from OpenAI-like API
+ const fetchModels = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'bolt.diy/1.0',
+ };
+
+ if (apiKey) {
+ headers.Authorization = `Bearer ${apiKey}`;
+ }
+
+ const response = await fetch(`${normalizedBaseUrl}/models`, {
+ method: 'GET',
+ headers,
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => 'Unknown error');
+ throw new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
+ }
+
+ const data = await response.json();
+
+ if (!data.data || !Array.isArray(data.data)) {
+ throw new Error(`Invalid response format from ${providerName}`);
+ }
+
+ const modelsWithMetadata = data.data.map((model: any) => {
+ // Ensure we have the required fields
+ if (!model.id) {
+ console.warn('Model missing ID:', model);
+ return null;
+ }
+
+ return {
+ id: model.id,
+ object: model.object || 'model',
+ created: model.created || Date.now() / 1000,
+ owned_by: model.owned_by || providerName.toLowerCase(),
+ permission: model.permission || [],
+ root: model.root,
+ parent: model.parent,
+ // Extract metadata from model ID
+ architecture: extractArchitecture(model.id),
+ contextLength: estimateContextLength(model.id),
+ capabilities: extractCapabilities(model.id),
+ isAvailable: true, // If it's in the list, it's available
+ };
+ }).filter(Boolean); // Remove any null entries
+
+ setModels(modelsWithMetadata);
+ toast(`Successfully loaded ${modelsWithMetadata.length} models from ${providerName}`);
+ } catch (error) {
+ console.error(`Error fetching ${providerName} models:`, error);
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
+ toast(`Failed to fetch ${providerName} models: ${errorMessage}`);
+ setModels([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [normalizedBaseUrl, apiKey, providerName, toast]);
+
+ // Test model capabilities
+ const testModelCapabilities = useCallback(async (modelId: string): Promise => {
+ const startTime = Date.now();
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'bolt.diy/1.0',
+ };
+
+ if (apiKey) {
+ headers.Authorization = `Bearer ${apiKey}`;
+ }
+
+ const status: ModelStatus = {
+ isHealthy: false,
+ lastChecked: new Date(),
+ supportsChat: false,
+ supportsCompletion: false,
+ supportsStreaming: false,
+ };
+
+ try {
+ // Test chat completions
+ const chatResponse = await fetch(`${normalizedBaseUrl}/chat/completions`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ model: modelId,
+ messages: [{ role: 'user', content: 'Hello' }],
+ max_tokens: 5,
+ temperature: 0.1,
+ }),
+ signal: AbortSignal.timeout(15000),
+ });
+
+ const responseTime = Date.now() - startTime;
+ status.responseTime = responseTime;
+
+ if (chatResponse.ok) {
+ const chatData = await chatResponse.json();
+ if (chatData.choices && Array.isArray(chatData.choices) && chatData.choices.length > 0) {
+ status.supportsChat = true;
+ status.isHealthy = true;
+
+ // Record successful chat request
+ recordMetrics({
+ provider: 'OpenAILike',
+ model: modelId,
+ baseUrl: normalizedBaseUrl,
+ responseTime,
+ success: true,
+ });
+ }
+ } else {
+ // Record failed chat request
+ recordMetrics({
+ provider: 'OpenAILike',
+ model: modelId,
+ baseUrl: normalizedBaseUrl,
+ responseTime,
+ success: false,
+ error: `HTTP ${chatResponse.status}: ${chatResponse.statusText}`,
+ });
+ }
+
+ // Test streaming if chat works
+ if (status.supportsChat) {
+ try {
+ const streamResponse = await fetch(`${normalizedBaseUrl}/chat/completions`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ model: modelId,
+ messages: [{ role: 'user', content: 'Hi' }],
+ max_tokens: 5,
+ stream: true,
+ }),
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (streamResponse.ok) {
+ const contentType = streamResponse.headers.get('content-type');
+ status.supportsStreaming = contentType?.includes('text/event-stream') ||
+ contentType?.includes('application/x-ndjson') || false;
+ }
+ } catch {
+ // Streaming test failed, but that's okay
+ }
+ }
+
+ // Test completions endpoint
+ try {
+ const completionResponse = await fetch(`${normalizedBaseUrl}/completions`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ model: modelId,
+ prompt: 'Hello',
+ max_tokens: 5,
+ temperature: 0.1,
+ }),
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (completionResponse.ok) {
+ const completionData = await completionResponse.json();
+ if (completionData.choices && Array.isArray(completionData.choices) && completionData.choices.length > 0) {
+ status.supportsCompletion = true;
+ if (!status.isHealthy) {
+ status.isHealthy = true;
+ }
+ }
+ }
+ } catch {
+ // Completion test failed, but that's okay if chat works
+ }
+
+ if (!status.isHealthy) {
+ status.error = 'Model does not respond to chat or completion requests';
+ }
+
+ return status;
+ } catch (error) {
+ const responseTime = Date.now() - startTime;
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+
+ // Record failed request
+ recordMetrics({
+ provider: 'OpenAILike',
+ model: modelId,
+ baseUrl: normalizedBaseUrl,
+ responseTime,
+ success: false,
+ error: errorMessage,
+ });
+
+ return {
+ isHealthy: false,
+ responseTime,
+ error: errorMessage,
+ lastChecked: new Date(),
+ supportsChat: false,
+ supportsCompletion: false,
+ supportsStreaming: false,
+ };
+ }
+ }, [normalizedBaseUrl, apiKey, recordMetrics]);
+
+ // Test all models capabilities
+ const testAllModelsCapabilities = useCallback(async () => {
+ setIsRefreshing(true);
+ const statusPromises = models.map(async (model) => {
+ const status = await testModelCapabilities(model.id);
+ return { modelId: model.id, status };
+ });
+
+ try {
+ const results = await Promise.all(statusPromises);
+ const newStatuses: Record = {};
+
+ results.forEach(({ modelId, status }) => {
+ newStatuses[modelId] = status;
+ });
+
+ setModelStatuses(newStatuses);
+ } catch (error) {
+ console.error('Error testing model capabilities:', error);
+ toast('Failed to test model capabilities');
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [models, testModelCapabilities, toast]);
+
+ // Extract architecture from model name
+ const extractArchitecture = (modelId: string): string => {
+ const id = modelId.toLowerCase();
+ if (id.includes('gpt-4')) return 'GPT-4';
+ if (id.includes('gpt-3.5')) return 'GPT-3.5';
+ if (id.includes('gpt-3')) return 'GPT-3';
+ if (id.includes('claude')) return 'Claude';
+ if (id.includes('llama')) return 'LLaMA';
+ if (id.includes('mistral')) return 'Mistral';
+ if (id.includes('gemini')) return 'Gemini';
+ if (id.includes('palm')) return 'PaLM';
+ return 'Unknown';
+ };
+
+ // Extract capabilities from model name
+ const extractCapabilities = (modelId: string): string[] => {
+ const capabilities: string[] = [];
+ const id = modelId.toLowerCase();
+
+ if (id.includes('vision') || id.includes('v')) capabilities.push('Vision');
+ if (id.includes('code') || id.includes('codex')) capabilities.push('Code');
+ if (id.includes('instruct') || id.includes('chat')) capabilities.push('Chat');
+ if (id.includes('embedding')) capabilities.push('Embeddings');
+ if (id.includes('function') || id.includes('tool')) capabilities.push('Functions');
+
+ return capabilities;
+ };
+
+ // Estimate context length based on model
+ const estimateContextLength = (modelId: string): number => {
+ const id = modelId.toLowerCase();
+ if (id.includes('128k')) return 128000;
+ if (id.includes('32k')) return 32768;
+ if (id.includes('16k')) return 16384;
+ if (id.includes('8k')) return 8192;
+ if (id.includes('4k')) return 4096;
+
+ // Default estimates based on model type
+ if (id.includes('gpt-4')) return 8192;
+ if (id.includes('gpt-3.5')) return 4096;
+ if (id.includes('claude')) return 100000;
+ if (id.includes('gemini')) return 32768;
+
+ return 4096;
+ };
+
+ // Format response time
+ const formatResponseTime = (ms: number): string => {
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
+ };
+
+ // Load models on mount
+ useEffect(() => {
+ fetchModels();
+ }, [fetchModels]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (models.length === 0) {
+ return (
+
+
+
+ No Models Available
+
+
+ Make sure {providerName} is accessible and properly configured.
+
+
+ Retry
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ {providerName} Models
+
+
+ {models.length} model{models.length !== 1 ? 's' : ''} available • {baseUrl}
+
+
+
+
+
+
+ {isRefreshing ? 'Testing...' : 'Test Capabilities'}
+
+
+
+
+ Refresh
+
+
+
+
+ {/* Models Grid */}
+
+ {models.map((model) => {
+ const status = modelStatuses[model.id];
+ const isSelected = selectedModel === model.id;
+
+ return (
+
setSelectedModel(isSelected ? null : model.id)}
+ whileHover={{ scale: 1.01 }}
+ transition={{ duration: 0.2 }}
+ >
+
+
+ {/* Status Indicator */}
+
+
+
+
+ {model.id}
+
+
+ {model.architecture && {model.architecture} }
+ {model.contextLength && • {model.contextLength.toLocaleString()} ctx }
+ {model.capabilities && model.capabilities.length > 0 && (
+ • {model.capabilities.join(', ')}
+ )}
+
+
+
+
+
+ {status?.responseTime && (
+
+ {formatResponseTime(status.responseTime)}
+
+ )}
+
+
+
+
+
+ {/* Expanded Details */}
+
+ {isSelected && (
+
+
+
+
Status:
+
+ {status?.isHealthy ? 'Available' :
+ status?.isHealthy === false ? 'Unavailable' : 'Unknown'}
+
+
+
+
+
Chat Support:
+
+ {status?.supportsChat ? 'Yes' : 'Unknown'}
+
+
+
+
+
Streaming:
+
+ {status?.supportsStreaming ? 'Yes' : 'Unknown'}
+
+
+
+
+
Owner:
+
+ {model.owned_by || 'Unknown'}
+
+
+
+
+ {status?.error && (
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx b/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx
deleted file mode 100644
index 401bd42fe9..0000000000
--- a/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { ServiceStatus } from './types';
-import { ProviderStatusCheckerFactory } from './provider-factory';
-
-export default function ServiceStatusTab() {
- const [serviceStatuses, setServiceStatuses] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const checkAllProviders = async () => {
- try {
- setLoading(true);
- setError(null);
-
- const providers = ProviderStatusCheckerFactory.getProviderNames();
- const statuses: ServiceStatus[] = [];
-
- for (const provider of providers) {
- try {
- const checker = ProviderStatusCheckerFactory.getChecker(provider);
- const result = await checker.checkStatus();
-
- statuses.push({
- provider,
- ...result,
- lastChecked: new Date().toISOString(),
- });
- } catch (err) {
- console.error(`Error checking ${provider} status:`, err);
- statuses.push({
- provider,
- status: 'degraded',
- message: 'Unable to check service status',
- incidents: ['Error checking service status'],
- lastChecked: new Date().toISOString(),
- });
- }
- }
-
- setServiceStatuses(statuses);
- } catch (err) {
- console.error('Error checking provider statuses:', err);
- setError('Failed to check service statuses');
- } finally {
- setLoading(false);
- }
- };
-
- checkAllProviders();
-
- // Set up periodic checks every 5 minutes
- const interval = setInterval(checkAllProviders, 5 * 60 * 1000);
-
- return () => clearInterval(interval);
- }, []);
-
- const getStatusColor = (status: ServiceStatus['status']) => {
- switch (status) {
- case 'operational':
- return 'text-green-500 dark:text-green-400';
- case 'degraded':
- return 'text-yellow-500 dark:text-yellow-400';
- case 'down':
- return 'text-red-500 dark:text-red-400';
- default:
- return 'text-gray-500 dark:text-gray-400';
- }
- };
-
- const getStatusIcon = (status: ServiceStatus['status']) => {
- switch (status) {
- case 'operational':
- return 'i-ph:check-circle';
- case 'degraded':
- return 'i-ph:warning';
- case 'down':
- return 'i-ph:x-circle';
- default:
- return 'i-ph:question';
- }
- };
-
- if (loading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- return (
-
-
- {serviceStatuses.map((service) => (
-
-
-
{service.message}
- {service.incidents && service.incidents.length > 0 && (
-
-
Recent Incidents:
-
- {service.incidents.map((incident, index) => (
- {incident}
- ))}
-
-
- )}
-
- Last checked: {new Date(service.lastChecked).toLocaleString()}
-
-
- ))}
-
-
- );
-}
diff --git a/app/components/@settings/tabs/providers/service-status/base-provider.ts b/app/components/@settings/tabs/providers/service-status/base-provider.ts
deleted file mode 100644
index dde4bd318b..0000000000
--- a/app/components/@settings/tabs/providers/service-status/base-provider.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types';
-
-export abstract class BaseProviderChecker {
- protected config: ProviderConfig;
-
- constructor(config: ProviderConfig) {
- this.config = config;
- }
-
- protected async checkApiEndpoint(
- url: string,
- headers?: Record,
- testModel?: string,
- ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> {
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 10000);
-
- const startTime = performance.now();
-
- // Add common headers
- const processedHeaders = {
- 'Content-Type': 'application/json',
- ...headers,
- };
-
- const response = await fetch(url, {
- method: 'GET',
- headers: processedHeaders,
- signal: controller.signal,
- });
-
- const endTime = performance.now();
- const responseTime = endTime - startTime;
-
- clearTimeout(timeoutId);
-
- const data = (await response.json()) as ApiResponse;
-
- if (!response.ok) {
- let errorMessage = `API returned status: ${response.status}`;
-
- if (data.error?.message) {
- errorMessage = data.error.message;
- } else if (data.message) {
- errorMessage = data.message;
- }
-
- return {
- ok: false,
- status: response.status,
- message: errorMessage,
- responseTime,
- };
- }
-
- // Different providers have different model list formats
- let models: string[] = [];
-
- if (Array.isArray(data)) {
- models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
- } else if (data.data && Array.isArray(data.data)) {
- models = data.data.map((model) => model.id || model.name || '');
- } else if (data.models && Array.isArray(data.models)) {
- models = data.models.map((model) => model.id || model.name || '');
- } else if (data.model) {
- models = [data.model];
- }
-
- if (!testModel || models.length > 0) {
- return {
- ok: true,
- status: response.status,
- responseTime,
- message: 'API key is valid',
- };
- }
-
- if (testModel && !models.includes(testModel)) {
- return {
- ok: true,
- status: 'model_not_found',
- message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
- responseTime,
- };
- }
-
- return {
- ok: true,
- status: response.status,
- message: 'API key is valid',
- responseTime,
- };
- } catch (error) {
- console.error(`Error checking API endpoint ${url}:`, error);
- return {
- ok: false,
- status: error instanceof Error ? error.message : 'Unknown error',
- message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
- responseTime: 0,
- };
- }
- }
-
- protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> {
- try {
- const response = await fetch(url, {
- mode: 'no-cors',
- headers: {
- Accept: 'text/html',
- },
- });
- return response.type === 'opaque' ? 'reachable' : 'unreachable';
- } catch (error) {
- console.error(`Error checking ${url}:`, error);
- return 'unreachable';
- }
- }
-
- abstract checkStatus(): Promise;
-}
diff --git a/app/components/@settings/tabs/providers/service-status/provider-factory.ts b/app/components/@settings/tabs/providers/service-status/provider-factory.ts
deleted file mode 100644
index d9f627d595..0000000000
--- a/app/components/@settings/tabs/providers/service-status/provider-factory.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import type { ProviderName, ProviderConfig, StatusCheckResult } from './types';
-import { BaseProviderChecker } from './base-provider';
-
-import { AmazonBedrockStatusChecker } from './providers/amazon-bedrock';
-import { CohereStatusChecker } from './providers/cohere';
-import { DeepseekStatusChecker } from './providers/deepseek';
-import { GoogleStatusChecker } from './providers/google';
-import { GroqStatusChecker } from './providers/groq';
-import { HuggingFaceStatusChecker } from './providers/huggingface';
-import { HyperbolicStatusChecker } from './providers/hyperbolic';
-import { MistralStatusChecker } from './providers/mistral';
-import { OpenRouterStatusChecker } from './providers/openrouter';
-import { PerplexityStatusChecker } from './providers/perplexity';
-import { TogetherStatusChecker } from './providers/together';
-import { XAIStatusChecker } from './providers/xai';
-import { MoonshotStatusChecker } from './providers/moonshot';
-
-export class ProviderStatusCheckerFactory {
- private static _providerConfigs: Record = {
- AmazonBedrock: {
- statusUrl: 'https://health.aws.amazon.com/health/status',
- apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
- headers: {},
- testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
- },
- Cohere: {
- statusUrl: 'https://status.cohere.com/',
- apiUrl: 'https://api.cohere.ai/v1/models',
- headers: {},
- testModel: 'command',
- },
- Deepseek: {
- statusUrl: 'https://status.deepseek.com/',
- apiUrl: 'https://api.deepseek.com/v1/models',
- headers: {},
- testModel: 'deepseek-chat',
- },
- Google: {
- statusUrl: 'https://status.cloud.google.com/',
- apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
- headers: {},
- testModel: 'gemini-pro',
- },
- Groq: {
- statusUrl: 'https://groqstatus.com/',
- apiUrl: 'https://api.groq.com/v1/models',
- headers: {},
- testModel: 'mixtral-8x7b-32768',
- },
- HuggingFace: {
- statusUrl: 'https://status.huggingface.co/',
- apiUrl: 'https://api-inference.huggingface.co/models',
- headers: {},
- testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
- },
- Hyperbolic: {
- statusUrl: 'https://status.hyperbolic.ai/',
- apiUrl: 'https://api.hyperbolic.ai/v1/models',
- headers: {},
- testModel: 'hyperbolic-1',
- },
- Mistral: {
- statusUrl: 'https://status.mistral.ai/',
- apiUrl: 'https://api.mistral.ai/v1/models',
- headers: {},
- testModel: 'mistral-tiny',
- },
- OpenRouter: {
- statusUrl: 'https://status.openrouter.ai/',
- apiUrl: 'https://openrouter.ai/api/v1/models',
- headers: {},
- testModel: 'anthropic/claude-3-sonnet',
- },
- Perplexity: {
- statusUrl: 'https://status.perplexity.com/',
- apiUrl: 'https://api.perplexity.ai/v1/models',
- headers: {},
- testModel: 'pplx-7b-chat',
- },
- Together: {
- statusUrl: 'https://status.together.ai/',
- apiUrl: 'https://api.together.xyz/v1/models',
- headers: {},
- testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
- },
- Moonshot: {
- statusUrl: 'https://status.moonshot.ai/',
- apiUrl: 'https://api.moonshot.ai/v1/models',
- headers: {},
- testModel: 'moonshot-v1-8k',
- },
- XAI: {
- statusUrl: 'https://status.x.ai/',
- apiUrl: 'https://api.x.ai/v1/models',
- headers: {},
- testModel: 'grok-1',
- },
- };
-
- static getChecker(provider: ProviderName): BaseProviderChecker {
- const config = this._providerConfigs[provider];
-
- if (!config) {
- throw new Error(`No configuration found for provider: ${provider}`);
- }
-
- switch (provider) {
- case 'AmazonBedrock':
- return new AmazonBedrockStatusChecker(config);
- case 'Cohere':
- return new CohereStatusChecker(config);
- case 'Deepseek':
- return new DeepseekStatusChecker(config);
- case 'Google':
- return new GoogleStatusChecker(config);
- case 'Groq':
- return new GroqStatusChecker(config);
- case 'HuggingFace':
- return new HuggingFaceStatusChecker(config);
- case 'Hyperbolic':
- return new HyperbolicStatusChecker(config);
- case 'Mistral':
- return new MistralStatusChecker(config);
- case 'OpenRouter':
- return new OpenRouterStatusChecker(config);
- case 'Perplexity':
- return new PerplexityStatusChecker(config);
- case 'Together':
- return new TogetherStatusChecker(config);
- case 'Moonshot':
- return new MoonshotStatusChecker(config);
- case 'XAI':
- return new XAIStatusChecker(config);
- default:
- return new (class extends BaseProviderChecker {
- async checkStatus(): Promise {
- const endpointStatus = await this.checkEndpoint(this.config.statusUrl);
- const apiStatus = await this.checkEndpoint(this.config.apiUrl);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- })(config);
- }
- }
-
- static getProviderNames(): ProviderName[] {
- return Object.keys(this._providerConfigs) as ProviderName[];
- }
-
- static getProviderConfig(provider: ProviderName): ProviderConfig {
- const config = this._providerConfigs[provider];
-
- if (!config) {
- throw new Error(`Unknown provider: ${provider}`);
- }
-
- return config;
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts b/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts
deleted file mode 100644
index dff9d9a1fb..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class AmazonBedrockStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check AWS health status page
- const statusPageResponse = await fetch('https://health.aws.amazon.com/health/status');
- const text = await statusPageResponse.text();
-
- // Check for Bedrock and general AWS status
- const hasBedrockIssues =
- text.includes('Amazon Bedrock') &&
- (text.includes('Service is experiencing elevated error rates') ||
- text.includes('Service disruption') ||
- text.includes('Degraded Service'));
-
- const hasGeneralIssues = text.includes('Service disruption') || text.includes('Multiple services affected');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
-
- for (const match of incidentMatches) {
- const [, date, title, impact] = match;
-
- if (title.includes('Bedrock') || title.includes('AWS')) {
- incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
- }
- }
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All services operational';
-
- if (hasBedrockIssues) {
- status = 'degraded';
- message = 'Amazon Bedrock service issues reported';
- } else if (hasGeneralIssues) {
- status = 'degraded';
- message = 'AWS experiencing general issues';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
- const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents: incidents.slice(0, 5),
- };
- } catch (error) {
- console.error('Error checking Amazon Bedrock status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
- const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts b/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts
deleted file mode 100644
index dccbf66b39..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class AnthropicStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.anthropic.com/');
- const text = await statusPageResponse.text();
-
- // Check for specific Anthropic status indicators
- const isOperational = text.includes('All Systems Operational');
- const hasDegradedPerformance = text.includes('Degraded Performance');
- const hasPartialOutage = text.includes('Partial Outage');
- const hasMajorOutage = text.includes('Major Outage');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
-
- if (incidentSection) {
- const incidentLines = incidentSection[1]
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && line.includes('202')); // Only get dated incidents
-
- incidents.push(...incidentLines.slice(0, 5));
- }
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (hasMajorOutage) {
- status = 'down';
- message = 'Major service outage';
- } else if (hasPartialOutage) {
- status = 'down';
- message = 'Partial service outage';
- } else if (hasDegradedPerformance) {
- status = 'degraded';
- message = 'Service experiencing degraded performance';
- } else if (!isOperational) {
- status = 'degraded';
- message = 'Service status unknown';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
- const apiEndpoint = 'https://api.anthropic.com/v1/messages';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents,
- };
- } catch (error) {
- console.error('Error checking Anthropic status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
- const apiEndpoint = 'https://api.anthropic.com/v1/messages';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/cohere.ts b/app/components/@settings/tabs/providers/service-status/providers/cohere.ts
deleted file mode 100644
index 7707f7377d..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/cohere.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class CohereStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.cohere.com/');
- const text = await statusPageResponse.text();
-
- // Check for specific Cohere status indicators
- const isOperational = text.includes('All Systems Operational');
- const hasIncidents = text.includes('Active Incidents');
- const hasDegradation = text.includes('Degraded Performance');
- const hasOutage = text.includes('Service Outage');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
-
- if (incidentSection) {
- const incidentLines = incidentSection[1]
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && line.includes('202')); // Only get dated incidents
-
- incidents.push(...incidentLines.slice(0, 5));
- }
-
- // Check specific services
- const services = {
- api: {
- operational: text.includes('API Service') && text.includes('Operational'),
- degraded: text.includes('API Service') && text.includes('Degraded Performance'),
- outage: text.includes('API Service') && text.includes('Service Outage'),
- },
- generation: {
- operational: text.includes('Generation Service') && text.includes('Operational'),
- degraded: text.includes('Generation Service') && text.includes('Degraded Performance'),
- outage: text.includes('Generation Service') && text.includes('Service Outage'),
- },
- };
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (services.api.outage || services.generation.outage || hasOutage) {
- status = 'down';
- message = 'Service outage detected';
- } else if (services.api.degraded || services.generation.degraded || hasDegradation || hasIncidents) {
- status = 'degraded';
- message = 'Service experiencing issues';
- } else if (!isOperational) {
- status = 'degraded';
- message = 'Service status unknown';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
- const apiEndpoint = 'https://api.cohere.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents,
- };
- } catch (error) {
- console.error('Error checking Cohere status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
- const apiEndpoint = 'https://api.cohere.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts b/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts
deleted file mode 100644
index 7aa88bac42..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class DeepseekStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- /*
- * Check status page - Note: Deepseek doesn't have a public status page yet
- * so we'll check their API endpoint directly
- */
- const apiEndpoint = 'https://api.deepseek.com/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- // Check their website as a secondary indicator
- const websiteStatus = await this.checkEndpoint('https://deepseek.com');
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
- status = apiStatus !== 'reachable' ? 'down' : 'degraded';
- message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
- }
-
- return {
- status,
- message,
- incidents: [], // No public incident tracking available yet
- };
- } catch (error) {
- console.error('Error checking Deepseek status:', error);
-
- return {
- status: 'degraded',
- message: 'Unable to determine service status',
- incidents: ['Note: Limited status information available'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/google.ts b/app/components/@settings/tabs/providers/service-status/providers/google.ts
deleted file mode 100644
index 80b5ecf81c..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/google.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class GoogleStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.cloud.google.com/');
- const text = await statusPageResponse.text();
-
- // Check for Vertex AI and general cloud status
- const hasVertexAIIssues =
- text.includes('Vertex AI') &&
- (text.includes('Incident') ||
- text.includes('Disruption') ||
- text.includes('Outage') ||
- text.includes('degraded'));
-
- const hasGeneralIssues = text.includes('Major Incidents') || text.includes('Service Disruption');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
-
- for (const match of incidentMatches) {
- const [, date, title, impact] = match;
-
- if (title.includes('Vertex AI') || title.includes('Cloud')) {
- incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
- }
- }
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All services operational';
-
- if (hasVertexAIIssues) {
- status = 'degraded';
- message = 'Vertex AI service issues reported';
- } else if (hasGeneralIssues) {
- status = 'degraded';
- message = 'Google Cloud experiencing issues';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
- const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents: incidents.slice(0, 5),
- };
- } catch (error) {
- console.error('Error checking Google status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
- const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/groq.ts b/app/components/@settings/tabs/providers/service-status/providers/groq.ts
deleted file mode 100644
index c465cedd81..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/groq.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class GroqStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://groqstatus.com/');
- const text = await statusPageResponse.text();
-
- const isOperational = text.includes('All Systems Operational');
- const hasIncidents = text.includes('Active Incidents');
- const hasDegradation = text.includes('Degraded Performance');
- const hasOutage = text.includes('Service Outage');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Status:(.*?)(?=\n|$)/g);
-
- for (const match of incidentMatches) {
- const [, date, title, status] = match;
- incidents.push(`${date}: ${title.trim()} - ${status.trim()}`);
- }
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (hasOutage) {
- status = 'down';
- message = 'Service outage detected';
- } else if (hasDegradation || hasIncidents) {
- status = 'degraded';
- message = 'Service experiencing issues';
- } else if (!isOperational) {
- status = 'degraded';
- message = 'Service status unknown';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
- const apiEndpoint = 'https://api.groq.com/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents: incidents.slice(0, 5),
- };
- } catch (error) {
- console.error('Error checking Groq status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
- const apiEndpoint = 'https://api.groq.com/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts b/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts
deleted file mode 100644
index 80dcfe848d..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class HuggingFaceStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.huggingface.co/');
- const text = await statusPageResponse.text();
-
- // Check for "All services are online" message
- const allServicesOnline = text.includes('All services are online');
-
- // Get last update time
- const lastUpdateMatch = text.match(/Last updated on (.*?)(EST|PST|GMT)/);
- const lastUpdate = lastUpdateMatch ? `${lastUpdateMatch[1]}${lastUpdateMatch[2]}` : '';
-
- // Check individual services and their uptime percentages
- const services = {
- 'Huggingface Hub': {
- operational: text.includes('Huggingface Hub') && text.includes('Operational'),
- uptime: text.match(/Huggingface Hub[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
- },
- 'Git Hosting and Serving': {
- operational: text.includes('Git Hosting and Serving') && text.includes('Operational'),
- uptime: text.match(/Git Hosting and Serving[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
- },
- 'Inference API': {
- operational: text.includes('Inference API') && text.includes('Operational'),
- uptime: text.match(/Inference API[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
- },
- 'HF Endpoints': {
- operational: text.includes('HF Endpoints') && text.includes('Operational'),
- uptime: text.match(/HF Endpoints[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
- },
- Spaces: {
- operational: text.includes('Spaces') && text.includes('Operational'),
- uptime: text.match(/Spaces[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
- },
- };
-
- // Create service status messages with uptime
- const serviceMessages = Object.entries(services).map(([name, info]) => {
- if (info.uptime) {
- return `${name}: ${info.uptime}% uptime`;
- }
-
- return `${name}: ${info.operational ? 'Operational' : 'Issues detected'}`;
- });
-
- // Determine overall status
- let status: StatusCheckResult['status'] = 'operational';
- let message = allServicesOnline
- ? `All services are online (Last updated on ${lastUpdate})`
- : 'Checking individual services';
-
- // Only mark as degraded if we explicitly detect issues
- const hasIssues = Object.values(services).some((service) => !service.operational);
-
- if (hasIssues) {
- status = 'degraded';
- message = `Service issues detected (Last updated on ${lastUpdate})`;
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
- const apiEndpoint = 'https://api-inference.huggingface.co/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents: serviceMessages,
- };
- } catch (error) {
- console.error('Error checking HuggingFace status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
- const apiEndpoint = 'https://api-inference.huggingface.co/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts b/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts
deleted file mode 100644
index 6dca268fb7..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class HyperbolicStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- /*
- * Check API endpoint directly since Hyperbolic is a newer provider
- * and may not have a public status page yet
- */
- const apiEndpoint = 'https://api.hyperbolic.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- // Check their website as a secondary indicator
- const websiteStatus = await this.checkEndpoint('https://hyperbolic.ai');
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
- status = apiStatus !== 'reachable' ? 'down' : 'degraded';
- message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
- }
-
- return {
- status,
- message,
- incidents: [], // No public incident tracking available yet
- };
- } catch (error) {
- console.error('Error checking Hyperbolic status:', error);
-
- return {
- status: 'degraded',
- message: 'Unable to determine service status',
- incidents: ['Note: Limited status information available'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/mistral.ts b/app/components/@settings/tabs/providers/service-status/providers/mistral.ts
deleted file mode 100644
index 5966682cff..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/mistral.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class MistralStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.mistral.ai/');
- const text = await statusPageResponse.text();
-
- const isOperational = text.includes('All Systems Operational');
- const hasIncidents = text.includes('Active Incidents');
- const hasDegradation = text.includes('Degraded Performance');
- const hasOutage = text.includes('Service Outage');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentSection = text.match(/Recent Events(.*?)(?=\n\n)/s);
-
- if (incidentSection) {
- const incidentLines = incidentSection[1]
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && !line.includes('No incidents'));
-
- incidents.push(...incidentLines.slice(0, 5));
- }
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (hasOutage) {
- status = 'down';
- message = 'Service outage detected';
- } else if (hasDegradation || hasIncidents) {
- status = 'degraded';
- message = 'Service experiencing issues';
- } else if (!isOperational) {
- status = 'degraded';
- message = 'Service status unknown';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
- const apiEndpoint = 'https://api.mistral.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents,
- };
- } catch (error) {
- console.error('Error checking Mistral status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
- const apiEndpoint = 'https://api.mistral.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/moonshot.ts b/app/components/@settings/tabs/providers/service-status/providers/moonshot.ts
deleted file mode 100644
index 718d755308..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/moonshot.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class MoonshotStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check Moonshot API endpoint
- const apiEndpoint = 'https://api.moonshot.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- // Check their main website
- const websiteStatus = await this.checkEndpoint('https://www.moonshot.ai');
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
- status = apiStatus !== 'reachable' ? 'down' : 'degraded';
- message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
- }
-
- return {
- status,
- message,
- incidents: [], // No public incident tracking available yet
- };
- } catch (error) {
- console.error('Error checking Moonshot status:', error);
-
- return {
- status: 'degraded',
- message: 'Unable to determine service status',
- incidents: ['Note: Limited status information available'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/openai.ts b/app/components/@settings/tabs/providers/service-status/providers/openai.ts
deleted file mode 100644
index 252c16ea1b..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/openai.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class OpenAIStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.openai.com/');
- const text = await statusPageResponse.text();
-
- // Check individual services
- const services = {
- api: {
- operational: text.includes('API ? Operational'),
- degraded: text.includes('API ? Degraded Performance'),
- outage: text.includes('API ? Major Outage') || text.includes('API ? Partial Outage'),
- },
- chat: {
- operational: text.includes('ChatGPT ? Operational'),
- degraded: text.includes('ChatGPT ? Degraded Performance'),
- outage: text.includes('ChatGPT ? Major Outage') || text.includes('ChatGPT ? Partial Outage'),
- },
- };
-
- // Extract recent incidents
- const incidents: string[] = [];
- const incidentMatches = text.match(/Past Incidents(.*?)(?=\w+ \d+, \d{4})/s);
-
- if (incidentMatches) {
- const recentIncidents = incidentMatches[1]
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && line.includes('202')); // Get only dated incidents
-
- incidents.push(...recentIncidents.slice(0, 5));
- }
-
- // Determine overall status
- let status: StatusCheckResult['status'] = 'operational';
- const messages: string[] = [];
-
- if (services.api.outage || services.chat.outage) {
- status = 'down';
-
- if (services.api.outage) {
- messages.push('API: Major Outage');
- }
-
- if (services.chat.outage) {
- messages.push('ChatGPT: Major Outage');
- }
- } else if (services.api.degraded || services.chat.degraded) {
- status = 'degraded';
-
- if (services.api.degraded) {
- messages.push('API: Degraded Performance');
- }
-
- if (services.chat.degraded) {
- messages.push('ChatGPT: Degraded Performance');
- }
- } else if (services.api.operational) {
- messages.push('API: Operational');
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
- const apiEndpoint = 'https://api.openai.com/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message: messages.join(', ') || 'Status unknown',
- incidents,
- };
- } catch (error) {
- console.error('Error checking OpenAI status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
- const apiEndpoint = 'https://api.openai.com/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts b/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts
deleted file mode 100644
index f05edb98a6..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class OpenRouterStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.openrouter.ai/');
- const text = await statusPageResponse.text();
-
- // Check for specific OpenRouter status indicators
- const isOperational = text.includes('All Systems Operational');
- const hasIncidents = text.includes('Active Incidents');
- const hasDegradation = text.includes('Degraded Performance');
- const hasOutage = text.includes('Service Outage');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
-
- if (incidentSection) {
- const incidentLines = incidentSection[1]
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && line.includes('202')); // Only get dated incidents
-
- incidents.push(...incidentLines.slice(0, 5));
- }
-
- // Check specific services
- const services = {
- api: {
- operational: text.includes('API Service') && text.includes('Operational'),
- degraded: text.includes('API Service') && text.includes('Degraded Performance'),
- outage: text.includes('API Service') && text.includes('Service Outage'),
- },
- routing: {
- operational: text.includes('Routing Service') && text.includes('Operational'),
- degraded: text.includes('Routing Service') && text.includes('Degraded Performance'),
- outage: text.includes('Routing Service') && text.includes('Service Outage'),
- },
- };
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (services.api.outage || services.routing.outage || hasOutage) {
- status = 'down';
- message = 'Service outage detected';
- } else if (services.api.degraded || services.routing.degraded || hasDegradation || hasIncidents) {
- status = 'degraded';
- message = 'Service experiencing issues';
- } else if (!isOperational) {
- status = 'degraded';
- message = 'Service status unknown';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
- const apiEndpoint = 'https://openrouter.ai/api/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents,
- };
- } catch (error) {
- console.error('Error checking OpenRouter status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
- const apiEndpoint = 'https://openrouter.ai/api/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts b/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts
deleted file mode 100644
index 31a8088e3c..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class PerplexityStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.perplexity.ai/');
- const text = await statusPageResponse.text();
-
- // Check for specific Perplexity status indicators
- const isOperational = text.includes('All Systems Operational');
- const hasIncidents = text.includes('Active Incidents');
- const hasDegradation = text.includes('Degraded Performance');
- const hasOutage = text.includes('Service Outage');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
-
- if (incidentSection) {
- const incidentLines = incidentSection[1]
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && line.includes('202')); // Only get dated incidents
-
- incidents.push(...incidentLines.slice(0, 5));
- }
-
- // Check specific services
- const services = {
- api: {
- operational: text.includes('API Service') && text.includes('Operational'),
- degraded: text.includes('API Service') && text.includes('Degraded Performance'),
- outage: text.includes('API Service') && text.includes('Service Outage'),
- },
- inference: {
- operational: text.includes('Inference Service') && text.includes('Operational'),
- degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
- outage: text.includes('Inference Service') && text.includes('Service Outage'),
- },
- };
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (services.api.outage || services.inference.outage || hasOutage) {
- status = 'down';
- message = 'Service outage detected';
- } else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
- status = 'degraded';
- message = 'Service experiencing issues';
- } else if (!isOperational) {
- status = 'degraded';
- message = 'Service status unknown';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
- const apiEndpoint = 'https://api.perplexity.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents,
- };
- } catch (error) {
- console.error('Error checking Perplexity status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
- const apiEndpoint = 'https://api.perplexity.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/together.ts b/app/components/@settings/tabs/providers/service-status/providers/together.ts
deleted file mode 100644
index 77abce9810..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/together.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class TogetherStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- // Check status page
- const statusPageResponse = await fetch('https://status.together.ai/');
- const text = await statusPageResponse.text();
-
- // Check for specific Together status indicators
- const isOperational = text.includes('All Systems Operational');
- const hasIncidents = text.includes('Active Incidents');
- const hasDegradation = text.includes('Degraded Performance');
- const hasOutage = text.includes('Service Outage');
-
- // Extract incidents
- const incidents: string[] = [];
- const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
-
- if (incidentSection) {
- const incidentLines = incidentSection[1]
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && line.includes('202')); // Only get dated incidents
-
- incidents.push(...incidentLines.slice(0, 5));
- }
-
- // Check specific services
- const services = {
- api: {
- operational: text.includes('API Service') && text.includes('Operational'),
- degraded: text.includes('API Service') && text.includes('Degraded Performance'),
- outage: text.includes('API Service') && text.includes('Service Outage'),
- },
- inference: {
- operational: text.includes('Inference Service') && text.includes('Operational'),
- degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
- outage: text.includes('Inference Service') && text.includes('Service Outage'),
- },
- };
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (services.api.outage || services.inference.outage || hasOutage) {
- status = 'down';
- message = 'Service outage detected';
- } else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
- status = 'degraded';
- message = 'Service experiencing issues';
- } else if (!isOperational) {
- status = 'degraded';
- message = 'Service status unknown';
- }
-
- // If status page check fails, fallback to endpoint check
- if (!statusPageResponse.ok) {
- const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
- const apiEndpoint = 'https://api.together.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- return {
- status,
- message,
- incidents,
- };
- } catch (error) {
- console.error('Error checking Together status:', error);
-
- // Fallback to basic endpoint check
- const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
- const apiEndpoint = 'https://api.together.ai/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/providers/xai.ts b/app/components/@settings/tabs/providers/service-status/providers/xai.ts
deleted file mode 100644
index 7b98c6a382..0000000000
--- a/app/components/@settings/tabs/providers/service-status/providers/xai.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
-import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
-
-export class XAIStatusChecker extends BaseProviderChecker {
- async checkStatus(): Promise {
- try {
- /*
- * Check API endpoint directly since XAI is a newer provider
- * and may not have a public status page yet
- */
- const apiEndpoint = 'https://api.xai.com/v1/models';
- const apiStatus = await this.checkEndpoint(apiEndpoint);
-
- // Check their website as a secondary indicator
- const websiteStatus = await this.checkEndpoint('https://x.ai');
-
- let status: StatusCheckResult['status'] = 'operational';
- let message = 'All systems operational';
-
- if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
- status = apiStatus !== 'reachable' ? 'down' : 'degraded';
- message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
- }
-
- return {
- status,
- message,
- incidents: [], // No public incident tracking available yet
- };
- } catch (error) {
- console.error('Error checking XAI status:', error);
-
- return {
- status: 'degraded',
- message: 'Unable to determine service status',
- incidents: ['Note: Limited status information available'],
- };
- }
- }
-}
diff --git a/app/components/@settings/tabs/providers/service-status/types.ts b/app/components/@settings/tabs/providers/service-status/types.ts
deleted file mode 100644
index d09a865c46..0000000000
--- a/app/components/@settings/tabs/providers/service-status/types.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { IconType } from 'react-icons';
-
-export type ProviderName =
- | 'AmazonBedrock'
- | 'Cohere'
- | 'Deepseek'
- | 'Google'
- | 'Groq'
- | 'HuggingFace'
- | 'Hyperbolic'
- | 'Mistral'
- | 'Moonshot'
- | 'OpenRouter'
- | 'Perplexity'
- | 'Together'
- | 'XAI';
-
-export type ServiceStatus = {
- provider: ProviderName;
- status: 'operational' | 'degraded' | 'down';
- lastChecked: string;
- statusUrl?: string;
- icon?: IconType;
- message?: string;
- responseTime?: number;
- incidents?: string[];
-};
-
-export interface ProviderConfig {
- statusUrl: string;
- apiUrl: string;
- headers: Record;
- testModel: string;
-}
-
-export type ApiResponse = {
- error?: {
- message: string;
- };
- message?: string;
- model?: string;
- models?: Array<{
- id?: string;
- name?: string;
- }>;
- data?: Array<{
- id?: string;
- name?: string;
- }>;
-};
-
-export type StatusCheckResult = {
- status: 'operational' | 'degraded' | 'down';
- message: string;
- incidents: string[];
-};
diff --git a/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx b/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx
deleted file mode 100644
index b61ed04359..0000000000
--- a/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx
+++ /dev/null
@@ -1,886 +0,0 @@
-import React, { useEffect, useState, useCallback } from 'react';
-import { motion } from 'framer-motion';
-import { classNames } from '~/utils/classNames';
-import { TbActivityHeartbeat } from 'react-icons/tb';
-import { BsCheckCircleFill, BsXCircleFill, BsExclamationCircleFill } from 'react-icons/bs';
-import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
-import { BsRobot, BsCloud } from 'react-icons/bs';
-import { TbBrain } from 'react-icons/tb';
-import { BiChip, BiCodeBlock } from 'react-icons/bi';
-import { FaCloud, FaBrain } from 'react-icons/fa';
-import type { IconType } from 'react-icons';
-import { useSettings } from '~/lib/hooks/useSettings';
-import { useToast } from '~/components/ui/use-toast';
-
-// Types
-type ProviderName =
- | 'AmazonBedrock'
- | 'Anthropic'
- | 'Cohere'
- | 'Deepseek'
- | 'Google'
- | 'Groq'
- | 'HuggingFace'
- | 'Mistral'
- | 'OpenAI'
- | 'OpenRouter'
- | 'Perplexity'
- | 'Together'
- | 'XAI';
-
-type ServiceStatus = {
- provider: ProviderName;
- status: 'operational' | 'degraded' | 'down';
- lastChecked: string;
- statusUrl?: string;
- icon?: IconType;
- message?: string;
- responseTime?: number;
- incidents?: string[];
-};
-
-type ProviderConfig = {
- statusUrl: string;
- apiUrl: string;
- headers: Record;
- testModel: string;
-};
-
-// Types for API responses
-type ApiResponse = {
- error?: {
- message: string;
- };
- message?: string;
- model?: string;
- models?: Array<{
- id?: string;
- name?: string;
- }>;
- data?: Array<{
- id?: string;
- name?: string;
- }>;
-};
-
-// Constants
-const PROVIDER_STATUS_URLS: Record = {
- OpenAI: {
- statusUrl: 'https://status.openai.com/',
- apiUrl: 'https://api.openai.com/v1/models',
- headers: {
- Authorization: 'Bearer $OPENAI_API_KEY',
- },
- testModel: 'gpt-3.5-turbo',
- },
- Anthropic: {
- statusUrl: 'https://status.anthropic.com/',
- apiUrl: 'https://api.anthropic.com/v1/messages',
- headers: {
- 'x-api-key': '$ANTHROPIC_API_KEY',
- 'anthropic-version': '2024-02-29',
- },
- testModel: 'claude-3-sonnet-20240229',
- },
- Cohere: {
- statusUrl: 'https://status.cohere.com/',
- apiUrl: 'https://api.cohere.ai/v1/models',
- headers: {
- Authorization: 'Bearer $COHERE_API_KEY',
- },
- testModel: 'command',
- },
- Google: {
- statusUrl: 'https://status.cloud.google.com/',
- apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
- headers: {
- 'x-goog-api-key': '$GOOGLE_API_KEY',
- },
- testModel: 'gemini-pro',
- },
- HuggingFace: {
- statusUrl: 'https://status.huggingface.co/',
- apiUrl: 'https://api-inference.huggingface.co/models',
- headers: {
- Authorization: 'Bearer $HUGGINGFACE_API_KEY',
- },
- testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
- },
- Mistral: {
- statusUrl: 'https://status.mistral.ai/',
- apiUrl: 'https://api.mistral.ai/v1/models',
- headers: {
- Authorization: 'Bearer $MISTRAL_API_KEY',
- },
- testModel: 'mistral-tiny',
- },
- Perplexity: {
- statusUrl: 'https://status.perplexity.com/',
- apiUrl: 'https://api.perplexity.ai/v1/models',
- headers: {
- Authorization: 'Bearer $PERPLEXITY_API_KEY',
- },
- testModel: 'pplx-7b-chat',
- },
- Together: {
- statusUrl: 'https://status.together.ai/',
- apiUrl: 'https://api.together.xyz/v1/models',
- headers: {
- Authorization: 'Bearer $TOGETHER_API_KEY',
- },
- testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
- },
- AmazonBedrock: {
- statusUrl: 'https://health.aws.amazon.com/health/status',
- apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
- headers: {
- Authorization: 'Bearer $AWS_BEDROCK_CONFIG',
- },
- testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
- },
- Groq: {
- statusUrl: 'https://groqstatus.com/',
- apiUrl: 'https://api.groq.com/v1/models',
- headers: {
- Authorization: 'Bearer $GROQ_API_KEY',
- },
- testModel: 'mixtral-8x7b-32768',
- },
- OpenRouter: {
- statusUrl: 'https://status.openrouter.ai/',
- apiUrl: 'https://openrouter.ai/api/v1/models',
- headers: {
- Authorization: 'Bearer $OPEN_ROUTER_API_KEY',
- },
- testModel: 'anthropic/claude-3-sonnet',
- },
- XAI: {
- statusUrl: 'https://status.x.ai/',
- apiUrl: 'https://api.x.ai/v1/models',
- headers: {
- Authorization: 'Bearer $XAI_API_KEY',
- },
- testModel: 'grok-1',
- },
- Deepseek: {
- statusUrl: 'https://status.deepseek.com/',
- apiUrl: 'https://api.deepseek.com/v1/models',
- headers: {
- Authorization: 'Bearer $DEEPSEEK_API_KEY',
- },
- testModel: 'deepseek-chat',
- },
-};
-
-const PROVIDER_ICONS: Record = {
- AmazonBedrock: SiAmazon,
- Anthropic: FaBrain,
- Cohere: BiChip,
- Google: SiGoogle,
- Groq: BsCloud,
- HuggingFace: SiHuggingface,
- Mistral: TbBrain,
- OpenAI: SiOpenai,
- OpenRouter: FaCloud,
- Perplexity: SiPerplexity,
- Together: BsCloud,
- XAI: BsRobot,
- Deepseek: BiCodeBlock,
-};
-
-const ServiceStatusTab = () => {
- const [serviceStatuses, setServiceStatuses] = useState([]);
- const [loading, setLoading] = useState(true);
- const [lastRefresh, setLastRefresh] = useState(new Date());
- const [testApiKey, setTestApiKey] = useState('');
- const [testProvider, setTestProvider] = useState('');
- const [testingStatus, setTestingStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
- const settings = useSettings();
- const { success, error } = useToast();
-
- // Function to get the API key for a provider from environment variables
- const getApiKey = useCallback(
- (provider: ProviderName): string | null => {
- if (!settings.providers) {
- return null;
- }
-
- // Map provider names to environment variable names
- const envKeyMap: Record = {
- OpenAI: 'OPENAI_API_KEY',
- Anthropic: 'ANTHROPIC_API_KEY',
- Cohere: 'COHERE_API_KEY',
- Google: 'GOOGLE_GENERATIVE_AI_API_KEY',
- HuggingFace: 'HuggingFace_API_KEY',
- Mistral: 'MISTRAL_API_KEY',
- Perplexity: 'PERPLEXITY_API_KEY',
- Together: 'TOGETHER_API_KEY',
- AmazonBedrock: 'AWS_BEDROCK_CONFIG',
- Groq: 'GROQ_API_KEY',
- OpenRouter: 'OPEN_ROUTER_API_KEY',
- XAI: 'XAI_API_KEY',
- Deepseek: 'DEEPSEEK_API_KEY',
- };
-
- const envKey = envKeyMap[provider];
-
- if (!envKey) {
- return null;
- }
-
- // Get the API key from environment variables
- const apiKey = (import.meta.env[envKey] as string) || null;
-
- // Special handling for providers with base URLs
- if (provider === 'Together' && apiKey) {
- const baseUrl = import.meta.env.TOGETHER_API_BASE_URL;
-
- if (!baseUrl) {
- return null;
- }
- }
-
- return apiKey;
- },
- [settings.providers],
- );
-
- // Update provider configurations based on available API keys
- const getProviderConfig = useCallback((provider: ProviderName): ProviderConfig | null => {
- const config = PROVIDER_STATUS_URLS[provider];
-
- if (!config) {
- return null;
- }
-
- // Handle special cases for providers with base URLs
- let updatedConfig = { ...config };
- const togetherBaseUrl = import.meta.env.TOGETHER_API_BASE_URL;
-
- if (provider === 'Together' && togetherBaseUrl) {
- updatedConfig = {
- ...config,
- apiUrl: `${togetherBaseUrl}/models`,
- };
- }
-
- return updatedConfig;
- }, []);
-
- // Function to check if an API endpoint is accessible with model verification
- const checkApiEndpoint = useCallback(
- async (
- url: string,
- headers?: Record,
- testModel?: string,
- ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> => {
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 10000);
-
- const startTime = performance.now();
-
- // Add common headers
- const processedHeaders = {
- 'Content-Type': 'application/json',
- ...headers,
- };
-
- // First check if the API is accessible
- const response = await fetch(url, {
- method: 'GET',
- headers: processedHeaders,
- signal: controller.signal,
- });
-
- const endTime = performance.now();
- const responseTime = endTime - startTime;
-
- clearTimeout(timeoutId);
-
- // Get response data
- const data = (await response.json()) as ApiResponse;
-
- // Special handling for different provider responses
- if (!response.ok) {
- let errorMessage = `API returned status: ${response.status}`;
-
- // Handle provider-specific error messages
- if (data.error?.message) {
- errorMessage = data.error.message;
- } else if (data.message) {
- errorMessage = data.message;
- }
-
- return {
- ok: false,
- status: response.status,
- message: errorMessage,
- responseTime,
- };
- }
-
- // Different providers have different model list formats
- let models: string[] = [];
-
- if (Array.isArray(data)) {
- models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
- } else if (data.data && Array.isArray(data.data)) {
- models = data.data.map((model) => model.id || model.name || '');
- } else if (data.models && Array.isArray(data.models)) {
- models = data.models.map((model) => model.id || model.name || '');
- } else if (data.model) {
- // Some providers return single model info
- models = [data.model];
- }
-
- // For some providers, just having a successful response is enough
- if (!testModel || models.length > 0) {
- return {
- ok: true,
- status: response.status,
- responseTime,
- message: 'API key is valid',
- };
- }
-
- // If a specific model was requested, verify it exists
- if (testModel && !models.includes(testModel)) {
- return {
- ok: true, // Still mark as ok since API works
- status: 'model_not_found',
- message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
- responseTime,
- };
- }
-
- return {
- ok: true,
- status: response.status,
- message: 'API key is valid',
- responseTime,
- };
- } catch (error) {
- console.error(`Error checking API endpoint ${url}:`, error);
- return {
- ok: false,
- status: error instanceof Error ? error.message : 'Unknown error',
- message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
- responseTime: 0,
- };
- }
- },
- [getApiKey],
- );
-
- // Function to fetch real status from provider status pages
- const fetchPublicStatus = useCallback(
- async (
- provider: ProviderName,
- ): Promise<{
- status: ServiceStatus['status'];
- message?: string;
- incidents?: string[];
- }> => {
- try {
- // Due to CORS restrictions, we can only check if the endpoints are reachable
- const checkEndpoint = async (url: string) => {
- try {
- const response = await fetch(url, {
- mode: 'no-cors',
- headers: {
- Accept: 'text/html',
- },
- });
-
- // With no-cors, we can only know if the request succeeded
- return response.type === 'opaque' ? 'reachable' : 'unreachable';
- } catch (error) {
- console.error(`Error checking ${url}:`, error);
- return 'unreachable';
- }
- };
-
- switch (provider) {
- case 'HuggingFace': {
- const endpointStatus = await checkEndpoint('https://status.huggingface.co/');
-
- // Check API endpoint as fallback
- const apiEndpoint = 'https://api-inference.huggingface.co/models';
- const apiStatus = await checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- case 'OpenAI': {
- const endpointStatus = await checkEndpoint('https://status.openai.com/');
- const apiEndpoint = 'https://api.openai.com/v1/models';
- const apiStatus = await checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- case 'Google': {
- const endpointStatus = await checkEndpoint('https://status.cloud.google.com/');
- const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
- const apiStatus = await checkEndpoint(apiEndpoint);
-
- return {
- status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
- message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
-
- // Similar pattern for other providers...
- default:
- return {
- status: 'operational',
- message: 'Basic reachability check only',
- incidents: ['Note: Limited status information due to CORS restrictions'],
- };
- }
- } catch (error) {
- console.error(`Error fetching status for ${provider}:`, error);
- return {
- status: 'degraded',
- message: 'Unable to fetch status due to CORS restrictions',
- incidents: ['Error: Unable to check service status'],
- };
- }
- },
- [],
- );
-
- // Function to fetch status for a provider with retries
- const fetchProviderStatus = useCallback(
- async (provider: ProviderName, config: ProviderConfig): Promise => {
- const MAX_RETRIES = 2;
- const RETRY_DELAY = 2000; // 2 seconds
-
- const attemptCheck = async (attempt: number): Promise => {
- try {
- // First check the public status page if available
- const hasPublicStatus = [
- 'Anthropic',
- 'OpenAI',
- 'Google',
- 'HuggingFace',
- 'Mistral',
- 'Groq',
- 'Perplexity',
- 'Together',
- ].includes(provider);
-
- if (hasPublicStatus) {
- const publicStatus = await fetchPublicStatus(provider);
-
- return {
- provider,
- status: publicStatus.status,
- lastChecked: new Date().toISOString(),
- statusUrl: config.statusUrl,
- icon: PROVIDER_ICONS[provider],
- message: publicStatus.message,
- incidents: publicStatus.incidents,
- };
- }
-
- // For other providers, we'll show status but mark API check as separate
- const apiKey = getApiKey(provider);
- const providerConfig = getProviderConfig(provider);
-
- if (!apiKey || !providerConfig) {
- return {
- provider,
- status: 'operational',
- lastChecked: new Date().toISOString(),
- statusUrl: config.statusUrl,
- icon: PROVIDER_ICONS[provider],
- message: !apiKey
- ? 'Status operational (API key needed for usage)'
- : 'Status operational (configuration needed for usage)',
- incidents: [],
- };
- }
-
- // If we have API access, let's verify that too
- const { ok, status, message, responseTime } = await checkApiEndpoint(
- providerConfig.apiUrl,
- providerConfig.headers,
- providerConfig.testModel,
- );
-
- if (!ok && attempt < MAX_RETRIES) {
- await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
- return attemptCheck(attempt + 1);
- }
-
- return {
- provider,
- status: ok ? 'operational' : 'degraded',
- lastChecked: new Date().toISOString(),
- statusUrl: providerConfig.statusUrl,
- icon: PROVIDER_ICONS[provider],
- message: ok ? 'Service and API operational' : `Service operational (API: ${message || status})`,
- responseTime,
- incidents: [],
- };
- } catch (error) {
- console.error(`Error fetching status for ${provider} (attempt ${attempt}):`, error);
-
- if (attempt < MAX_RETRIES) {
- await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
- return attemptCheck(attempt + 1);
- }
-
- return {
- provider,
- status: 'degraded',
- lastChecked: new Date().toISOString(),
- statusUrl: config.statusUrl,
- icon: PROVIDER_ICONS[provider],
- message: 'Service operational (Status check error)',
- responseTime: 0,
- incidents: [],
- };
- }
- };
-
- return attemptCheck(1);
- },
- [checkApiEndpoint, getApiKey, getProviderConfig, fetchPublicStatus],
- );
-
- // Memoize the fetchAllStatuses function
- const fetchAllStatuses = useCallback(async () => {
- try {
- setLoading(true);
-
- const statuses = await Promise.all(
- Object.entries(PROVIDER_STATUS_URLS).map(([provider, config]) =>
- fetchProviderStatus(provider as ProviderName, config),
- ),
- );
-
- setServiceStatuses(statuses.sort((a, b) => a.provider.localeCompare(b.provider)));
- setLastRefresh(new Date());
- success('Service statuses updated successfully');
- } catch (err) {
- console.error('Error fetching all statuses:', err);
- error('Failed to update service statuses');
- } finally {
- setLoading(false);
- }
- }, [fetchProviderStatus, success, error]);
-
- useEffect(() => {
- fetchAllStatuses();
-
- // Refresh status every 2 minutes
- const interval = setInterval(fetchAllStatuses, 2 * 60 * 1000);
-
- return () => clearInterval(interval);
- }, [fetchAllStatuses]);
-
- // Function to test an API key
- const testApiKeyForProvider = useCallback(
- async (provider: ProviderName, apiKey: string) => {
- try {
- setTestingStatus('testing');
-
- const config = PROVIDER_STATUS_URLS[provider];
-
- if (!config) {
- throw new Error('Provider configuration not found');
- }
-
- const headers = { ...config.headers };
-
- // Replace the placeholder API key with the test key
- Object.keys(headers).forEach((key) => {
- if (headers[key].startsWith('$')) {
- headers[key] = headers[key].replace(/\$.*/, apiKey);
- }
- });
-
- // Special handling for certain providers
- switch (provider) {
- case 'Anthropic':
- headers['anthropic-version'] = '2024-02-29';
- break;
- case 'OpenAI':
- if (!headers.Authorization?.startsWith('Bearer ')) {
- headers.Authorization = `Bearer ${apiKey}`;
- }
-
- break;
- case 'Google': {
- // Google uses the API key directly in the URL
- const googleUrl = `${config.apiUrl}?key=${apiKey}`;
- const result = await checkApiEndpoint(googleUrl, {}, config.testModel);
-
- if (result.ok) {
- setTestingStatus('success');
- success('API key is valid!');
- } else {
- setTestingStatus('error');
- error(`API key test failed: ${result.message}`);
- }
-
- return;
- }
- }
-
- const { ok, message } = await checkApiEndpoint(config.apiUrl, headers, config.testModel);
-
- if (ok) {
- setTestingStatus('success');
- success('API key is valid!');
- } else {
- setTestingStatus('error');
- error(`API key test failed: ${message}`);
- }
- } catch (err: unknown) {
- setTestingStatus('error');
- error('Failed to test API key: ' + (err instanceof Error ? err.message : 'Unknown error'));
- } finally {
- // Reset testing status after a delay
- setTimeout(() => setTestingStatus('idle'), 3000);
- }
- },
- [checkApiEndpoint, success, error],
- );
-
- const getStatusColor = (status: ServiceStatus['status']) => {
- switch (status) {
- case 'operational':
- return 'text-green-500';
- case 'degraded':
- return 'text-yellow-500';
- case 'down':
- return 'text-red-500';
- default:
- return 'text-gray-500';
- }
- };
-
- const getStatusIcon = (status: ServiceStatus['status']) => {
- switch (status) {
- case 'operational':
- return ;
- case 'degraded':
- return ;
- case 'down':
- return ;
- default:
- return ;
- }
- };
-
- return (
-
-
-
-
-
-
-
-
-
Service Status
-
- Monitor and test the operational status of cloud LLM providers
-
-
-
-
-
- Last updated: {lastRefresh.toLocaleTimeString()}
-
-
fetchAllStatuses()}
- className={classNames(
- 'px-3 py-1.5 rounded-lg text-sm',
- 'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
- 'text-bolt-elements-textPrimary',
- 'transition-all duration-200',
- 'flex items-center gap-2',
- loading ? 'opacity-50 cursor-not-allowed' : '',
- )}
- disabled={loading}
- >
-
- {loading ? 'Refreshing...' : 'Refresh'}
-
-
-
-
- {/* API Key Test Section */}
-
-
Test API Key
-
-
setTestProvider(e.target.value as ProviderName)}
- className={classNames(
- 'flex-1 px-3 py-1.5 rounded-lg text-sm max-w-[200px]',
- 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
- 'text-bolt-elements-textPrimary',
- 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
- )}
- >
- Select Provider
- {Object.keys(PROVIDER_STATUS_URLS).map((provider) => (
-
- {provider}
-
- ))}
-
-
setTestApiKey(e.target.value)}
- placeholder="Enter API key to test"
- className={classNames(
- 'flex-1 px-3 py-1.5 rounded-lg text-sm',
- 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
- 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
- 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
- )}
- />
-
- testProvider && testApiKey && testApiKeyForProvider(testProvider as ProviderName, testApiKey)
- }
- disabled={!testProvider || !testApiKey || testingStatus === 'testing'}
- className={classNames(
- 'px-4 py-1.5 rounded-lg text-sm',
- 'bg-purple-500 hover:bg-purple-600',
- 'text-white',
- 'transition-all duration-200',
- 'flex items-center gap-2',
- !testProvider || !testApiKey || testingStatus === 'testing' ? 'opacity-50 cursor-not-allowed' : '',
- )}
- >
- {testingStatus === 'testing' ? (
- <>
-
- Testing...
- >
- ) : (
- <>
-
- Test Key
- >
- )}
-
-
-
-
- {/* Status Grid */}
- {loading && serviceStatuses.length === 0 ? (
- Loading service statuses...
- ) : (
-
- {serviceStatuses.map((service, index) => (
-
- service.statusUrl && window.open(service.statusUrl, '_blank')}
- >
-
-
- {service.icon && (
-
- {React.createElement(service.icon, {
- className: 'w-5 h-5',
- })}
-
- )}
-
-
{service.provider}
-
-
- Last checked: {new Date(service.lastChecked).toLocaleTimeString()}
-
- {service.responseTime && (
-
- Response time: {Math.round(service.responseTime)}ms
-
- )}
- {service.message && (
-
{service.message}
- )}
-
-
-
-
- {service.status}
- {getStatusIcon(service.status)}
-
-
- {service.incidents && service.incidents.length > 0 && (
-
-
Recent Incidents:
-
- {service.incidents.map((incident, i) => (
- {incident}
- ))}
-
-
- )}
-
-
- ))}
-
- )}
-
-
- );
-};
-
-// Add tab metadata
-ServiceStatusTab.tabMetadata = {
- icon: 'i-ph:activity-bold',
- description: 'Monitor and test LLM provider service status',
- category: 'services',
-};
-
-export default ServiceStatusTab;
diff --git a/app/components/@settings/tabs/settings/SettingsTab.tsx b/app/components/@settings/tabs/settings/SettingsTab.tsx
index 2079840a51..450c304479 100644
--- a/app/components/@settings/tabs/settings/SettingsTab.tsx
+++ b/app/components/@settings/tabs/settings/SettingsTab.tsx
@@ -5,6 +5,7 @@ import { classNames } from '~/utils/classNames';
import { Switch } from '~/components/ui/Switch';
import type { UserProfile } from '~/components/@settings/core/types';
import { isMac } from '~/utils/os';
+import { Palette, Languages, Bell, Clock, Globe, Keyboard } from 'lucide-react';
// Helper to get modifier key symbols/text
const getModifierSymbol = (modifier: string): string => {
@@ -69,13 +70,13 @@ export default function SettingsTab() {
transition={{ delay: 0.1 }}
>
@@ -149,13 +150,13 @@ export default function SettingsTab() {
transition={{ delay: 0.2 }}
>
diff --git a/app/components/@settings/tabs/supabase/SupabaseTab.tsx b/app/components/@settings/tabs/supabase/SupabaseTab.tsx
new file mode 100644
index 0000000000..e53d7725d1
--- /dev/null
+++ b/app/components/@settings/tabs/supabase/SupabaseTab.tsx
@@ -0,0 +1,1084 @@
+import React, { useEffect, useState } from 'react';
+import { motion } from 'framer-motion';
+import { toast } from 'react-toastify';
+import { useStore } from '@nanostores/react';
+import { classNames } from '~/utils/classNames';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import {
+ supabaseConnection,
+ isConnecting,
+ isFetchingStats,
+ isFetchingApiKeys,
+ updateSupabaseConnection,
+ fetchSupabaseStats,
+ fetchProjectApiKeys,
+ initializeSupabaseConnection,
+ type SupabaseProject,
+} from '~/lib/stores/supabase';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+interface ProjectAction {
+ name: string;
+ icon: string;
+ action: (projectId: string) => Promise;
+ requiresConfirmation?: boolean;
+ variant?: 'default' | 'destructive' | 'outline';
+}
+
+// Supabase logo SVG component
+const SupabaseLogo = () => (
+
+
+
+
+
+);
+
+export default function SupabaseTab() {
+ const connection = useStore(supabaseConnection);
+ const connecting = useStore(isConnecting);
+ const fetchingStats = useStore(isFetchingStats);
+ const fetchingApiKeys = useStore(isFetchingApiKeys);
+
+ const [tokenInput, setTokenInput] = useState('');
+ const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
+ const [connectionTest, setConnectionTest] = useState(null);
+ const [isProjectActionLoading, setIsProjectActionLoading] = useState(false);
+ const [selectedProjectId, setSelectedProjectId] = useState('');
+
+ // Connection testing function - uses server-side API to test environment token
+ const testConnection = async () => {
+ setConnectionTest({
+ status: 'testing',
+ message: 'Testing connection...',
+ });
+
+ try {
+ const response = await fetch('/api/supabase-user', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (response.ok) {
+ const data = (await response.json()) as any;
+ setConnectionTest({
+ status: 'success',
+ message: `Connected successfully using environment token. Found ${data.projects?.length || 0} projects`,
+ timestamp: Date.now(),
+ });
+ } else {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string };
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${errorData.error || `${response.status} ${response.statusText}`}`,
+ timestamp: Date.now(),
+ });
+ }
+ } catch (error) {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ timestamp: Date.now(),
+ });
+ }
+ };
+
+ // Project actions
+ const projectActions: ProjectAction[] = [
+ {
+ name: 'Get API Keys',
+ icon: 'i-ph:key',
+ action: async (projectId: string) => {
+ try {
+ await fetchProjectApiKeys(projectId, connection.token);
+ toast.success('API keys fetched successfully');
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to fetch API keys: ${error}`);
+ }
+ },
+ },
+ {
+ name: 'View Dashboard',
+ icon: 'i-ph:layout',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}`, '_blank');
+ },
+ },
+ {
+ name: 'View Database',
+ icon: 'i-ph:database',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/editor`, '_blank');
+ },
+ },
+ {
+ name: 'View Auth',
+ icon: 'i-ph:user-circle',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/auth/users`, '_blank');
+ },
+ },
+ {
+ name: 'View Storage',
+ icon: 'i-ph:folder',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/storage/buckets`, '_blank');
+ },
+ },
+ {
+ name: 'View Functions',
+ icon: 'i-ph:code',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/functions`, '_blank');
+ },
+ },
+ {
+ name: 'View Logs',
+ icon: 'i-ph:scroll',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/logs`, '_blank');
+ },
+ },
+ {
+ name: 'View Settings',
+ icon: 'i-ph:gear',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/settings`, '_blank');
+ },
+ },
+ {
+ name: 'View API Docs',
+ icon: 'i-ph:book',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/api`, '_blank');
+ },
+ },
+ {
+ name: 'View Realtime',
+ icon: 'i-ph:radio',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/realtime`, '_blank');
+ },
+ },
+ {
+ name: 'View Edge Functions',
+ icon: 'i-ph:terminal',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/functions`, '_blank');
+ },
+ },
+ ];
+
+ // Initialize connection on component mount - check server-side token first
+ useEffect(() => {
+ const initializeConnection = async () => {
+ try {
+ // First try to initialize using server-side token
+ await initializeSupabaseConnection();
+
+ // If no connection was established, the user will need to manually enter a token
+ const currentState = supabaseConnection.get();
+
+ if (!currentState.user) {
+ console.log('No server-side Supabase token available, manual connection required');
+ }
+ } catch (error) {
+ console.error('Failed to initialize Supabase connection:', error);
+ }
+ };
+ initializeConnection();
+ }, []);
+
+ useEffect(() => {
+ const fetchProjects = async () => {
+ if (connection.user && connection.token && !connection.stats) {
+ await fetchSupabaseStats(connection.token);
+ }
+ };
+ fetchProjects();
+ }, [connection.user, connection.token]);
+
+ const handleConnect = async () => {
+ if (!tokenInput) {
+ toast.error('Please enter a Supabase access token');
+ return;
+ }
+
+ isConnecting.set(true);
+
+ try {
+ await fetchSupabaseStats(tokenInput);
+ updateSupabaseConnection({
+ token: tokenInput,
+ isConnected: true,
+ });
+ toast.success('Successfully connected to Supabase');
+ setTokenInput('');
+ } catch (error) {
+ console.error('Auth error:', error);
+ toast.error('Failed to connect to Supabase');
+ updateSupabaseConnection({ user: null, token: '' });
+ } finally {
+ isConnecting.set(false);
+ }
+ };
+
+ const handleDisconnect = () => {
+ updateSupabaseConnection({
+ user: null,
+ token: '',
+ stats: undefined,
+ selectedProjectId: undefined,
+ isConnected: false,
+ project: undefined,
+ credentials: undefined,
+ });
+ setConnectionTest(null);
+ setSelectedProjectId('');
+ toast.success('Disconnected from Supabase');
+ };
+
+ const handleProjectAction = async (projectId: string, action: ProjectAction) => {
+ if (action.requiresConfirmation) {
+ if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
+ return;
+ }
+ }
+
+ setIsProjectActionLoading(true);
+ await action.action(projectId);
+ setIsProjectActionLoading(false);
+ };
+
+ const handleProjectSelect = async (projectId: string) => {
+ setSelectedProjectId(projectId);
+ updateSupabaseConnection({ selectedProjectId: projectId });
+
+ if (projectId && connection.token) {
+ try {
+ await fetchProjectApiKeys(projectId, connection.token);
+ } catch (error) {
+ console.error('Failed to fetch API keys:', error);
+ }
+ }
+ };
+
+ const renderProjects = () => {
+ if (fetchingStats) {
+ return (
+
+
+ Fetching Supabase projects...
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Your Projects ({connection.stats?.totalProjects || 0})
+
+
+
+
+
+
+
+ {/* Supabase Overview Dashboard */}
+ {connection.stats?.projects?.length ? (
+
+
Supabase Overview
+
+
+
+ {connection.stats.totalProjects}
+
+
Total Projects
+
+
+
+ {connection.stats.projects.filter((p: SupabaseProject) => p.status === 'ACTIVE_HEALTHY').length}
+
+
Active Projects
+
+
+
+ {new Set(connection.stats.projects.map((p: SupabaseProject) => p.region)).size}
+
+
Regions Used
+
+
+
+ {connection.stats.projects.filter((p: SupabaseProject) => p.status !== 'ACTIVE_HEALTHY').length}
+
+
Inactive Projects
+
+
+
+ ) : null}
+
+ {connection.stats?.projects?.length ? (
+
+ {connection.stats.projects.map((project: SupabaseProject) => (
+
handleProjectSelect(project.id)}
+ >
+
+
+
+
+ {project.name}
+
+
+
+
+ {project.region}
+
+
•
+
+
+ {new Date(project.created_at).toLocaleDateString()}
+
+
•
+
+
+ {project.status.replace('_', ' ')}
+
+
+
+ {/* Project Details Grid */}
+
+
+
+ {project.stats?.database.tables ?? '--'}
+
+
+
+
+
+ {project.stats?.storage.buckets ?? '--'}
+
+
+
+
+
+ {project.stats?.functions.deployed ?? '--'}
+
+
+
+
+
+ {project.stats?.database.size_mb ? `${project.stats.database.size_mb} MB` : '--'}
+
+
+
+
+
+
+
+ {selectedProjectId === project.id && (
+
+
+ {projectActions.map((action) => (
+
{
+ e.stopPropagation();
+ handleProjectAction(project.id, action);
+ }}
+ disabled={isProjectActionLoading || (action.name === 'Get API Keys' && fetchingApiKeys)}
+ className="flex items-center gap-1 text-xs px-2 py-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
+ >
+
+ {action.name === 'Get API Keys' && fetchingApiKeys ? 'Fetching...' : action.name}
+
+ ))}
+
+
+ {/* Project Details */}
+
+
+
+
+ Database Schema
+
+
+
+ Tables:
+ {project.stats?.database.tables ?? '--'}
+
+
+ Views:
+ {project.stats?.database.views ?? '--'}
+
+
+ Functions:
+ {project.stats?.database.functions ?? '--'}
+
+
+ Size:
+
+ {project.stats?.database.size_mb ? `${project.stats.database.size_mb} MB` : '--'}
+
+
+
+
+
+
+
+
+ Storage
+
+
+
+ Buckets:
+ {project.stats?.storage.buckets ?? '--'}
+
+
+ Files:
+ {project.stats?.storage.files ?? '--'}
+
+
+ Used:
+
+ {project.stats?.storage.used_gb ? `${project.stats.storage.used_gb} GB` : '--'}
+
+
+
+ Available:
+
+ {project.stats?.storage.available_gb
+ ? `${project.stats.storage.available_gb} GB`
+ : '--'}
+
+
+
+
+
+
+ {connection.credentials && (
+
+
+
+ Project Credentials
+
+
+
+
Supabase URL:
+
+
+
{
+ e.stopPropagation();
+
+ if (connection.credentials?.supabaseUrl) {
+ navigator.clipboard.writeText(connection.credentials.supabaseUrl);
+ toast.success('URL copied to clipboard');
+ }
+ }}
+ className="w-8 h-8"
+ >
+
+
+
+
+
+
Anon Key:
+
+
+
{
+ e.stopPropagation();
+
+ if (connection.credentials?.anonKey) {
+ navigator.clipboard.writeText(connection.credentials.anonKey);
+ toast.success('Key copied to clipboard');
+ }
+ }}
+ className="w-8 h-8"
+ >
+
+
+
+
+
+
+ )}
+
+ )}
+
+ ))}
+
+ ) : (
+
+
+ No projects found in your Supabase account
+
+ )}
+
+
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ Supabase Integration
+
+
+
+ {connection.user && (
+
+ {connectionTest?.status === 'testing' ? (
+ <>
+
+ Testing...
+ >
+ ) : (
+ <>
+
+ Test Connection
+ >
+ )}
+
+ )}
+
+
+
+
+ Connect and manage your Supabase projects with database access, authentication, and storage controls
+
+
+ {/* Connection Test Results */}
+ {connectionTest && (
+
+
+ {connectionTest.status === 'success' && (
+
+ )}
+ {connectionTest.status === 'error' && (
+
+ )}
+ {connectionTest.status === 'testing' && (
+
+ )}
+
+ {connectionTest.message}
+
+
+ {connectionTest.timestamp && (
+ {new Date(connectionTest.timestamp).toLocaleString()}
+ )}
+
+ )}
+
+ {/* Main Connection Component */}
+
+
+ {!connection.user ? (
+
+
+
+
+ Tip: You can also set the{' '}
+
+ VITE_SUPABASE_ACCESS_TOKEN
+ {' '}
+ environment variable to connect automatically.
+
+
+
+
+
Access Token
+
setTokenInput(e.target.value)}
+ disabled={connecting}
+ placeholder="Enter your Supabase access token"
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
+ 'border border-[#E5E5E5] dark:border-[#333333]',
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
+ 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
+ 'disabled:opacity-50',
+ )}
+ />
+
+
+
+
+ {connecting ? (
+ <>
+
+ Connecting...
+ >
+ ) : (
+ <>
+
+ Connect
+ >
+ )}
+
+
+ ) : (
+
+
+
+
+
+ Disconnect
+
+
+
+ Connected to Supabase
+
+
+
+
+ {connection.user && (
+
+
+
+
+
{connection.user.email}
+
+ {connection.user.role} • Member since{' '}
+ {new Date(connection.user.created_at).toLocaleDateString()}
+
+
+
+
+ {connection.stats?.totalProjects || 0} Projects
+
+
+
+ {new Set(connection.stats?.projects?.map((p: SupabaseProject) => p.region) || []).size}{' '}
+ Regions
+
+
+
+ {connection.stats?.projects?.filter((p: SupabaseProject) => p.status === 'ACTIVE_HEALTHY')
+ .length || 0}{' '}
+ Active
+
+
+
+
+
+ {/* Advanced Analytics */}
+
+
Performance Analytics
+
+
+
+
+ Database Health
+
+
+ {(() => {
+ const totalProjects = connection.stats?.totalProjects || 0;
+ const activeProjects =
+ connection.stats?.projects?.filter((p: SupabaseProject) => p.status === 'ACTIVE_HEALTHY')
+ .length || 0;
+ const healthRate =
+ totalProjects > 0 ? Math.round((activeProjects / totalProjects) * 100) : 0;
+ const avgTablesPerProject =
+ totalProjects > 0
+ ? Math.round(
+ (connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.database.tables || 0),
+ 0,
+ ) || 0) / totalProjects,
+ )
+ : 0;
+
+ return [
+ { label: 'Health Rate', value: `${healthRate}%` },
+ { label: 'Active Projects', value: activeProjects },
+ { label: 'Avg Tables/Project', value: avgTablesPerProject },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Auth & Security
+
+
+ {(() => {
+ const totalProjects = connection.stats?.totalProjects || 0;
+ const projectsWithAuth =
+ connection.stats?.projects?.filter((p) => p.stats?.auth?.users !== undefined).length || 0;
+ const authEnabledRate =
+ totalProjects > 0 ? Math.round((projectsWithAuth / totalProjects) * 100) : 0;
+ const totalUsers =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.auth?.users || 0), 0) || 0;
+
+ return [
+ { label: 'Auth Enabled', value: `${authEnabledRate}%` },
+ { label: 'Total Users', value: totalUsers },
+ {
+ label: 'Avg Users/Project',
+ value: totalProjects > 0 ? Math.round(totalUsers / totalProjects) : 0,
+ },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Regional Distribution
+
+
+ {(() => {
+ const regions =
+ connection.stats?.projects?.reduce(
+ (acc, p: SupabaseProject) => {
+ acc[p.region] = (acc[p.region] || 0) + 1;
+ return acc;
+ },
+ {} as Record
,
+ ) || {};
+
+ return Object.entries(regions)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 3)
+ .map(([region, count]) => ({ label: region.toUpperCase(), value: count }));
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+ {/* Resource Utilization */}
+
+
Resource Overview
+
+ {(() => {
+ const totalDatabase =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.database.size_mb || 0), 0) ||
+ 0;
+ const totalStorage =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.storage.used_gb || 0), 0) || 0;
+ const totalFunctions =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.functions.deployed || 0), 0) ||
+ 0;
+ const totalTables =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.database.tables || 0), 0) || 0;
+ const totalBuckets =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.storage.buckets || 0), 0) || 0;
+
+ return [
+ {
+ label: 'Database',
+ value: totalDatabase > 0 ? `${totalDatabase} MB` : '--',
+ icon: 'i-ph:database',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900/20',
+ textColor: 'text-blue-800 dark:text-blue-400',
+ },
+ {
+ label: 'Storage',
+ value: totalStorage > 0 ? `${totalStorage} GB` : '--',
+ icon: 'i-ph:folder',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900/20',
+ textColor: 'text-green-800 dark:text-green-400',
+ },
+ {
+ label: 'Functions',
+ value: totalFunctions,
+ icon: 'i-ph:code',
+ color: 'text-purple-500',
+ bgColor: 'bg-purple-100 dark:bg-purple-900/20',
+ textColor: 'text-purple-800 dark:text-purple-400',
+ },
+ {
+ label: 'Tables',
+ value: totalTables,
+ icon: 'i-ph:table',
+ color: 'text-orange-500',
+ bgColor: 'bg-orange-100 dark:bg-orange-900/20',
+ textColor: 'text-orange-800 dark:text-orange-400',
+ },
+ {
+ label: 'Buckets',
+ value: totalBuckets,
+ icon: 'i-ph:archive',
+ color: 'text-teal-500',
+ bgColor: 'bg-teal-100 dark:bg-teal-900/20',
+ textColor: 'text-teal-800 dark:text-teal-400',
+ },
+ ];
+ })().map((metric, index) => (
+
+ ))}
+
+
+
+ {/* Usage Metrics */}
+
+
+
+
+
+ Tables:{' '}
+ {connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.database.tables || 0), 0) ||
+ '--'}
+
+
+ Size:{' '}
+ {(() => {
+ const totalSize =
+ connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.database.size_mb || 0),
+ 0,
+ ) || 0;
+ return totalSize > 0 ? `${totalSize} MB` : '--';
+ })()}
+
+
+
+
+
+
+
+ Buckets:{' '}
+ {connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.storage.buckets || 0), 0) ||
+ '--'}
+
+
+ Used:{' '}
+ {(() => {
+ const totalUsed =
+ connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.storage.used_gb || 0),
+ 0,
+ ) || 0;
+ return totalUsed > 0 ? `${totalUsed} GB` : '--';
+ })()}
+
+
+
+
+
+
+
+ Deployed:{' '}
+ {connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.functions.deployed || 0),
+ 0,
+ ) || '--'}
+
+
+ Invocations:{' '}
+ {connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.functions.invocations || 0),
+ 0,
+ ) || '--'}
+
+
+
+
+
+ )}
+
+ {renderProjects()}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/vercel/VercelTab.tsx b/app/components/@settings/tabs/vercel/VercelTab.tsx
new file mode 100644
index 0000000000..a2ed511c65
--- /dev/null
+++ b/app/components/@settings/tabs/vercel/VercelTab.tsx
@@ -0,0 +1,966 @@
+import React, { useEffect, useState } from 'react';
+import { motion } from 'framer-motion';
+import { toast } from 'react-toastify';
+import { useStore } from '@nanostores/react';
+import { logStore } from '~/lib/stores/logs';
+import { classNames } from '~/utils/classNames';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import {
+ vercelConnection,
+ isConnecting,
+ isFetchingStats,
+ updateVercelConnection,
+ fetchVercelStats,
+ fetchVercelStatsViaAPI,
+ initializeVercelConnection,
+} from '~/lib/stores/vercel';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+interface ProjectAction {
+ name: string;
+ icon: string;
+ action: (projectId: string) => Promise;
+ requiresConfirmation?: boolean;
+ variant?: 'default' | 'destructive' | 'outline';
+}
+
+// Vercel logo SVG component
+const VercelLogo = () => (
+
+
+
+);
+
+export default function VercelTab() {
+ const connection = useStore(vercelConnection);
+ const connecting = useStore(isConnecting);
+ const fetchingStats = useStore(isFetchingStats);
+ const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
+ const [connectionTest, setConnectionTest] = useState(null);
+ const [isProjectActionLoading, setIsProjectActionLoading] = useState(false);
+
+ // Connection testing function - tests server-side token
+ const testConnection = async () => {
+ setConnectionTest({
+ status: 'testing',
+ message: 'Testing connection...',
+ });
+
+ try {
+ const response = await fetch('/api/vercel-user', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (response.ok) {
+ const data = (await response.json()) as any;
+ setConnectionTest({
+ status: 'success',
+ message: `Connected successfully using environment token as ${data.username || data.email || 'Vercel User'}`,
+ timestamp: Date.now(),
+ });
+ } else {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string };
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${errorData.error || `${response.status} ${response.statusText}`}`,
+ timestamp: Date.now(),
+ });
+ }
+ } catch (error) {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ timestamp: Date.now(),
+ });
+ }
+ };
+
+ // Project actions
+ const projectActions: ProjectAction[] = [
+ {
+ name: 'Redeploy',
+ icon: 'i-ph:arrows-clockwise',
+ action: async (projectId: string) => {
+ try {
+ const response = await fetch(`https://api.vercel.com/v1/deployments`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name: projectId,
+ target: 'production',
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to redeploy project');
+ }
+
+ toast.success('Project redeployment initiated');
+ await fetchVercelStats(connection.token);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to redeploy project: ${error}`);
+ }
+ },
+ },
+ {
+ name: 'View Dashboard',
+ icon: 'i-ph:layout',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}`, '_blank');
+ },
+ },
+ {
+ name: 'View Deployments',
+ icon: 'i-ph:rocket',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/deployments`, '_blank');
+ },
+ },
+ {
+ name: 'View Functions',
+ icon: 'i-ph:code',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/functions`, '_blank');
+ },
+ },
+ {
+ name: 'View Analytics',
+ icon: 'i-ph:chart-bar',
+ action: async (projectId: string) => {
+ const project = connection.stats?.projects.find((p) => p.id === projectId);
+
+ if (project) {
+ window.open(`https://vercel.com/${connection.user?.username}/${project.name}/analytics`, '_blank');
+ }
+ },
+ },
+ {
+ name: 'View Domains',
+ icon: 'i-ph:globe',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/domains`, '_blank');
+ },
+ },
+ {
+ name: 'View Settings',
+ icon: 'i-ph:gear',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/settings`, '_blank');
+ },
+ },
+ {
+ name: 'View Logs',
+ icon: 'i-ph:scroll',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/logs`, '_blank');
+ },
+ },
+ {
+ name: 'Delete Project',
+ icon: 'i-ph:trash',
+ action: async (projectId: string) => {
+ try {
+ const response = await fetch(`https://api.vercel.com/v1/projects/${projectId}`, {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete project');
+ }
+
+ toast.success('Project deleted successfully');
+ await fetchVercelStats(connection.token);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to delete project: ${error}`);
+ }
+ },
+ requiresConfirmation: true,
+ variant: 'destructive',
+ },
+ ];
+
+ // Initialize connection on component mount - check server-side token first
+ useEffect(() => {
+ const initializeConnection = async () => {
+ try {
+ // First try to initialize using server-side token
+ await initializeVercelConnection();
+
+ // If no connection was established, the user will need to manually enter a token
+ const currentState = vercelConnection.get();
+
+ if (!currentState.user) {
+ console.log('No server-side Vercel token available, manual connection required');
+ }
+ } catch (error) {
+ console.error('Failed to initialize Vercel connection:', error);
+ }
+ };
+ initializeConnection();
+ }, []);
+
+ useEffect(() => {
+ const fetchProjects = async () => {
+ if (connection.user) {
+ // Use server-side API if we have a connected user
+ try {
+ await fetchVercelStatsViaAPI();
+ } catch {
+ // Fallback to direct API if server-side fails and we have a token
+ if (connection.token) {
+ await fetchVercelStats(connection.token);
+ }
+ }
+ }
+ };
+ fetchProjects();
+ }, [connection.user, connection.token]);
+
+ const handleConnect = async (event: React.FormEvent) => {
+ event.preventDefault();
+ isConnecting.set(true);
+
+ try {
+ const response = await fetch('/api/vercel-user', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string };
+ throw new Error(errorData.error || 'Invalid token or unauthorized');
+ }
+
+ const userData = (await response.json()) as any;
+ updateVercelConnection({
+ user: userData, // Use the user data directly from the API response
+ token: connection.token,
+ });
+
+ await fetchVercelStats(connection.token);
+ toast.success('Successfully connected to Vercel');
+ } catch (error) {
+ console.error('Auth error:', error);
+ logStore.logError('Failed to authenticate with Vercel', { error });
+ toast.error('Failed to connect to Vercel');
+ updateVercelConnection({ user: null, token: '' });
+ } finally {
+ isConnecting.set(false);
+ }
+ };
+
+ const handleDisconnect = () => {
+ updateVercelConnection({ user: null, token: '' });
+ setConnectionTest(null);
+ toast.success('Disconnected from Vercel');
+ };
+
+ const handleProjectAction = async (projectId: string, action: ProjectAction) => {
+ if (action.requiresConfirmation) {
+ if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
+ return;
+ }
+ }
+
+ setIsProjectActionLoading(true);
+ await action.action(projectId);
+ setIsProjectActionLoading(false);
+ };
+
+ const renderProjects = () => {
+ if (fetchingStats) {
+ return (
+
+
+ Fetching Vercel projects...
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Your Projects ({connection.stats?.totalProjects || 0})
+
+
+
+
+
+
+
+ {/* Vercel Overview Dashboard */}
+ {connection.stats?.projects?.length ? (
+
+
Vercel Overview
+
+
+
+ {connection.stats.totalProjects}
+
+
Total Projects
+
+
+
+ {connection.stats.projects.filter((p) => p.targets?.production?.alias?.length > 0).length}
+
+
Deployed Projects
+
+
+
+ {new Set(connection.stats.projects.map((p) => p.framework).filter(Boolean)).size}
+
+
Frameworks Used
+
+
+
+ {connection.stats.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length}
+
+
Active Deployments
+
+
+
+ ) : null}
+
+ {/* Performance Analytics */}
+ {connection.stats?.projects?.length ? (
+
+
Performance Analytics
+
+
+
+
+ Deployment Health
+
+
+ {(() => {
+ const totalDeployments = connection.stats.projects.reduce(
+ (sum, p) => sum + (p.latestDeployments?.length || 0),
+ 0,
+ );
+ const readyDeployments = connection.stats.projects.filter(
+ (p) => p.latestDeployments?.[0]?.state === 'READY',
+ ).length;
+ const errorDeployments = connection.stats.projects.filter(
+ (p) => p.latestDeployments?.[0]?.state === 'ERROR',
+ ).length;
+ const successRate =
+ totalDeployments > 0
+ ? Math.round((readyDeployments / connection.stats.projects.length) * 100)
+ : 0;
+
+ return [
+ { label: 'Success Rate', value: `${successRate}%` },
+ { label: 'Active', value: readyDeployments },
+ { label: 'Failed', value: errorDeployments },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Framework Distribution
+
+
+ {(() => {
+ const frameworks = connection.stats.projects.reduce(
+ (acc, p) => {
+ if (p.framework) {
+ acc[p.framework] = (acc[p.framework] || 0) + 1;
+ }
+
+ return acc;
+ },
+ {} as Record
,
+ );
+
+ return Object.entries(frameworks)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 3)
+ .map(([framework, count]) => ({ label: framework, value: count }));
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Activity Summary
+
+
+ {(() => {
+ const now = Date.now();
+ const recentDeployments = connection.stats.projects.filter((p) => {
+ const lastDeploy = p.latestDeployments?.[0]?.created;
+ return lastDeploy && now - new Date(lastDeploy).getTime() < 7 * 24 * 60 * 60 * 1000;
+ }).length;
+ const totalDomains = connection.stats.projects.reduce(
+ (sum, p) => sum + (p.targets?.production?.alias?.length || 0),
+ 0,
+ );
+ const avgDomainsPerProject =
+ connection.stats.projects.length > 0
+ ? Math.round((totalDomains / connection.stats.projects.length) * 10) / 10
+ : 0;
+
+ return [
+ { label: 'Recent deploys', value: recentDeployments },
+ { label: 'Total domains', value: totalDomains },
+ { label: 'Avg domains/project', value: avgDomainsPerProject },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {/* Project Health Overview */}
+ {connection.stats?.projects?.length ? (
+
+
Project Health Overview
+
+ {(() => {
+ const healthyProjects = connection.stats.projects.filter(
+ (p) => p.latestDeployments?.[0]?.state === 'READY' && p.targets?.production?.alias?.length > 0,
+ ).length;
+ const needsAttention = connection.stats.projects.filter(
+ (p) =>
+ p.latestDeployments?.[0]?.state === 'ERROR' || p.latestDeployments?.[0]?.state === 'CANCELED',
+ ).length;
+ const withCustomDomain = connection.stats.projects.filter((p) =>
+ p.targets?.production?.alias?.some((alias: string) => !alias.includes('.vercel.app')),
+ ).length;
+ const buildingProjects = connection.stats.projects.filter(
+ (p) => p.latestDeployments?.[0]?.state === 'BUILDING',
+ ).length;
+
+ return [
+ {
+ label: 'Healthy',
+ value: healthyProjects,
+ icon: 'i-ph:check-circle',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900/20',
+ textColor: 'text-green-800 dark:text-green-400',
+ },
+ {
+ label: 'Custom Domain',
+ value: withCustomDomain,
+ icon: 'i-ph:globe',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900/20',
+ textColor: 'text-blue-800 dark:text-blue-400',
+ },
+ {
+ label: 'Building',
+ value: buildingProjects,
+ icon: 'i-ph:gear',
+ color: 'text-yellow-500',
+ bgColor: 'bg-yellow-100 dark:bg-yellow-900/20',
+ textColor: 'text-yellow-800 dark:text-yellow-400',
+ },
+ {
+ label: 'Issues',
+ value: needsAttention,
+ icon: 'i-ph:warning',
+ color: 'text-red-500',
+ bgColor: 'bg-red-100 dark:bg-red-900/20',
+ textColor: 'text-red-800 dark:text-red-400',
+ },
+ ];
+ })().map((metric, index) => (
+
+ ))}
+
+
+ ) : null}
+
+ {connection.stats?.projects?.length ? (
+
+ {connection.stats.projects.map((project) => (
+
+
+
+
+
+ {project.name}
+
+
+
+ {/* Project Details Grid */}
+
+
+
+ {/* Deployments - This would be fetched from API */}
+ --
+
+
+
+
+
+ {/* Domains - This would be fetched from API */}
+ --
+
+
+
+
+
+ {/* Team Members - This would be fetched from API */}
+ --
+
+
+
+
+
+ {/* Bandwidth - This would be fetched from API */}
+ --
+
+
+
+
+
+
+ {project.latestDeployments && project.latestDeployments.length > 0 && (
+
+
+ {project.latestDeployments[0].state}
+
+ )}
+ {project.framework && (
+
+
+
+ {project.framework}
+
+
+ )}
+
window.open(`https://vercel.com/dashboard/${project.id}`, '_blank')}
+ className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
+ >
+
+ View
+
+
+
+
+
+ {projectActions.map((action) => (
+
handleProjectAction(project.id, action)}
+ disabled={isProjectActionLoading}
+ className="flex items-center gap-1 text-xs px-2 py-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
+ >
+
+ {action.name}
+
+ ))}
+
+
+ ))}
+
+ ) : (
+
+
+ No projects found in your Vercel account
+
+ )}
+
+
+
+ );
+ };
+
+ console.log('connection', connection);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Vercel Integration
+
+
+
+ {connection.user && (
+
+ {connectionTest?.status === 'testing' ? (
+ <>
+
+ Testing...
+ >
+ ) : (
+ <>
+
+ Test Connection
+ >
+ )}
+
+ )}
+
+
+
+
+ Connect and manage your Vercel projects with advanced deployment controls and analytics
+
+
+ {/* Connection Test Results */}
+ {connectionTest && (
+
+
+ {connectionTest.status === 'success' && (
+
+ )}
+ {connectionTest.status === 'error' && (
+
+ )}
+ {connectionTest.status === 'testing' && (
+
+ )}
+
+ {connectionTest.message}
+
+
+ {connectionTest.timestamp && (
+ {new Date(connectionTest.timestamp).toLocaleString()}
+ )}
+
+ )}
+
+ {/* Main Connection Component */}
+
+
+ {!connection.user ? (
+
+
+
+
+ Tip: You can also set the{' '}
+
+ VITE_VERCEL_ACCESS_TOKEN
+ {' '}
+ environment variable to connect automatically.
+
+
+
+
+
Personal Access Token
+
updateVercelConnection({ ...connection, token: e.target.value })}
+ disabled={connecting}
+ placeholder="Enter your Vercel personal access token"
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
+ 'border border-[#E5E5E5] dark:border-[#333333]',
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
+ 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
+ 'disabled:opacity-50',
+ )}
+ />
+
+
+
+
+ {connecting ? (
+ <>
+
+ Connecting...
+ >
+ ) : (
+ <>
+
+ Connect
+ >
+ )}
+
+
+ ) : (
+
+
+
+
+
+ Disconnect
+
+
+
+ Connected to Vercel
+
+
+
+
+
+
+
+
+
+ {connection.user?.username || connection.user?.user?.username || 'Vercel User'}
+
+
+ {connection.user?.email || connection.user?.user?.email || 'No email available'}
+
+
+
+
+ {connection.stats?.totalProjects || 0} Projects
+
+
+
+ {connection.stats?.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length ||
+ 0}{' '}
+ Live
+
+
+
+ {/* Team size would be fetched from API */}
+ --
+
+
+
+
+
+ {/* Usage Metrics */}
+
+
+
+
+
+ Active:{' '}
+ {connection.stats?.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length ||
+ 0}
+
+
Total: {connection.stats?.totalProjects || 0}
+
+
+
+
+
+ {/* Domain usage would be fetched from API */}
+
Custom: --
+
Vercel: --
+
+
+
+
+
+ {/* Usage metrics would be fetched from API */}
+
Bandwidth: --
+
Requests: --
+
+
+
+
+
+ {renderProjects()}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/chat/APIKeyManager.tsx b/app/components/chat/APIKeyManager.tsx
index 92263363fa..fa63b02635 100644
--- a/app/components/chat/APIKeyManager.tsx
+++ b/app/components/chat/APIKeyManager.tsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import type { ProviderInfo } from '~/types/model';
import Cookies from 'js-cookie';
+import { CheckCircle, XCircle, Check, X, Pencil, Key } from 'lucide-react';
interface APIKeyManagerProps {
provider: ProviderInfo;
@@ -94,17 +95,17 @@ export const APIKeyManager: React.FC = ({ provider, apiKey,
{apiKey ? (
<>
-
+
Set via UI
>
) : isEnvKeySet ? (
<>
-
+
Set via environment variable
>
) : (
<>
-
+
Not Set (Please set via UI or ENV_VAR)
>
)}
@@ -125,40 +126,29 @@ export const APIKeyManager: React.FC
= ({ provider, apiKey,
bg-bolt-elements-prompt-background text-bolt-elements-textPrimary
focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus"
/>
-
-
+
+
- setIsEditing(false)}
- title="Cancel"
- className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
- >
-
+ setIsEditing(false)} title="Cancel" className="icon-button text-red-500">
+
) : (
<>
{
- setIsEditing(true)}
- title="Edit API Key"
- className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500"
- >
-
+ setIsEditing(true)} title="Edit API Key" className="icon-button text-blue-500">
+
}
{provider?.getApiKeyLink && !apiKey && (
window.open(provider?.getApiKeyLink)}
title="Get API Key"
- className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 flex items-center gap-2"
+ className="icon-button text-purple-500"
>
- {provider?.labelForGetApiKey || 'Get API Key'}
-
+
+ {provider?.icon ? : }
+
)}
>
diff --git a/app/components/chat/ActionPromptLibrary.tsx b/app/components/chat/ActionPromptLibrary.tsx
new file mode 100644
index 0000000000..64682a233f
--- /dev/null
+++ b/app/components/chat/ActionPromptLibrary.tsx
@@ -0,0 +1,270 @@
+import React, { useState, useMemo } from 'react';
+import { motion } from 'framer-motion';
+import { Search, X, BookOpen, ArrowRight } from 'lucide-react';
+import { Dialog, DialogRoot, DialogTitle, DialogDescription } from '~/components/ui/Dialog';
+import { Badge } from '~/components/ui/Badge';
+import { ScrollArea } from '~/components/ui/ScrollArea';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+import {
+ ACTION_PROMPTS,
+ PROMPT_CATEGORIES,
+ searchPrompts,
+ getPromptsByCategory,
+ type ActionPrompt,
+} from '~/lib/data/action-prompts';
+
+interface ActionPromptLibraryProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onPromptSelect: (prompt: string) => void;
+}
+
+export const ActionPromptLibrary: React.FC = ({ isOpen, onClose, onPromptSelect }) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState(null);
+
+ // Filter prompts based on search and category
+ const filteredPrompts = useMemo(() => {
+ let prompts = ACTION_PROMPTS;
+
+ if (selectedCategory) {
+ prompts = getPromptsByCategory(selectedCategory);
+ }
+
+ if (searchQuery.trim()) {
+ prompts = searchPrompts(searchQuery);
+
+ if (selectedCategory) {
+ prompts = prompts.filter((p) => p.category === selectedCategory);
+ }
+ }
+
+ return prompts;
+ }, [searchQuery, selectedCategory]);
+
+ // Group prompts by category for display
+ const groupedPrompts = useMemo(() => {
+ const groups: Record = {};
+
+ filteredPrompts.forEach((prompt) => {
+ if (!groups[prompt.category]) {
+ groups[prompt.category] = [];
+ }
+
+ groups[prompt.category].push(prompt);
+ });
+
+ return groups;
+ }, [filteredPrompts]);
+
+ const handlePromptSelect = (prompt: ActionPrompt) => {
+ onPromptSelect(prompt.prompt);
+ onClose();
+ };
+
+ const handleCategorySelect = (categoryId: string) => {
+ setSelectedCategory(selectedCategory === categoryId ? null : categoryId);
+ };
+
+ const resetFilters = () => {
+ setSearchQuery('');
+ setSelectedCategory(null);
+ };
+
+ const categoryData = PROMPT_CATEGORIES.find((cat) => cat.id === selectedCategory);
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+ Action Prompt Library
+
+ Choose from categorized prompts to enhance your project
+
+
+
+
+
+
+
+
+ {/* Search and Filters */}
+
+
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-10 pr-4 py-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded-lg text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
+ />
+
+
+ {/* Category Filters */}
+
+ {PROMPT_CATEGORIES.map((category) => {
+ const Icon = category.icon;
+ const isSelected = selectedCategory === category.id;
+ const count = getPromptsByCategory(category.id).length;
+
+ return (
+ handleCategorySelect(category.id)}
+ className={classNames(
+ 'theme-safe-button flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all border',
+ isSelected
+ ? '!bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent border-bolt-elements-item-contentAccent'
+ : '!bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary border-bolt-elements-borderColor hover:!bg-bolt-elements-item-backgroundActive hover:text-bolt-elements-textPrimary',
+ )}
+ >
+
+ {category.title}
+
+ {count}
+
+
+ );
+ })}
+
+ {(searchQuery || selectedCategory) && (
+
+
+ Clear filters
+
+ )}
+
+
+
+
+ {/* Content */}
+
+
+
+ {selectedCategory && categoryData && (
+
+
+
+
{categoryData.title}
+
+
{categoryData.description}
+
+ )}
+
+ {filteredPrompts.length === 0 ? (
+
+
+
+
+
No prompts found
+
+ Try adjusting your search terms or clearing the filters.
+
+
+ ) : (
+
+ {Object.entries(groupedPrompts).map(([categoryId, prompts]) => {
+ const category = PROMPT_CATEGORIES.find((cat) => cat.id === categoryId);
+
+ if (!category || prompts.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {!selectedCategory && (
+
+
+
{category.title}
+
+
{prompts.length}
+
+ )}
+
+
+ {prompts.map((prompt) => {
+ const Icon = prompt.icon;
+
+ return (
+
handlePromptSelect(prompt)}
+ >
+
+
+
+
+
+
+ {prompt.title}
+
+
+ {prompt.description}
+
+
+
+ {prompt.tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
{filteredPrompts.length} prompts available
+
+
+ Click any prompt to use it in your chat
+
+ Close
+
+
+
+
+
+
+
+ );
+};
diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx
index cef5a39794..dce85c405c 100644
--- a/app/components/chat/Artifact.tsx
+++ b/app/components/chat/Artifact.tsx
@@ -8,6 +8,7 @@ import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { WORK_DIR } from '~/utils/constants';
+import { ChevronDown, ChevronUp, Check, X, Terminal, Circle } from 'lucide-react';
const highlighterOptions = {
langs: ['shell'],
@@ -109,7 +110,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
onClick={toggleActions}
>
)}
@@ -119,7 +120,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
{allActionFinished ? (
-
+
) : (
)}
@@ -157,21 +158,27 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
});
interface ShellCodeBlockProps {
- classsName?: string;
+ className?: string;
code: string;
}
-function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
+function ShellCodeBlock({ className, code }: ShellCodeBlockProps) {
+ const combinedClassName = classNames('text-xs', className);
+ const htmlContent = shellHighlighter.codeToHtml(code, {
+ lang: 'shell',
+ theme: 'dark-plus',
+ });
+
return (
-
+ <>
+ {/* SECURITY: Syntax highlighting HTML should be sanitized by the highlighter library */}
+
+ >
);
}
@@ -218,15 +225,15 @@ const ActionList = memo(({ actions }: ActionListProps) => {
{type !== 'start' ? (
) : (
-
+
)}
>
) : status === 'pending' ? (
-
+
) : status === 'complete' ? (
-
+
) : status === 'failed' || status === 'aborted' ? (
-
+
) : null}
{type === 'file' ? (
@@ -257,7 +264,7 @@ const ActionList = memo(({ actions }: ActionListProps) => {
{(type === 'shell' || type === 'start') && (
{(codeContext || chatSummary) && (
-
}>
- {chatSummary && (
-
-
-
Summary
-
- {chatSummary}
-
-
- {codeContext && (
-
-
Context
-
- {codeContext.map((x) => {
- const normalized = normalizedFilePath(x);
- return (
-
- {
- e.preventDefault();
- e.stopPropagation();
- openArtifactInWorkbench(normalized);
- }}
- >
- {normalized}
-
-
- );
- })}
+
+
+
+
+
+ {chatSummary && (
+
+
+
Summary
+
+ {chatSummary}
- )}
-
- )}
-
+ {codeContext && (
+
+
Context
+
+ {codeContext.map((x) => {
+ const normalized = normalizedFilePath(x);
+ return (
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ openArtifactInWorkbench(normalized);
+ }}
+ >
+ {normalized}
+
+
+ );
+ })}
+
+
+ )}
+
+ )}
+
+
)}
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx
index dfe891220d..f967d550ae 100644
--- a/app/components/chat/Chat.client.tsx
+++ b/app/components/chat/Chat.client.tsx
@@ -13,6 +13,7 @@ import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat';
import Cookies from 'js-cookie';
+import { X, CheckCircle, AlertCircle } from 'lucide-react';
import { debounce } from '~/utils/debounce';
import { useSettings } from '~/lib/hooks/useSettings';
import type { ProviderInfo } from '~/types/model';
@@ -60,7 +61,7 @@ export function Chat() {
closeButton={({ closeToast }) => {
return (
-
+
);
}}
@@ -70,10 +71,10 @@ export function Chat() {
*/
switch (type) {
case 'success': {
- return
;
+ return
;
}
case 'error': {
- return
;
+ return
;
}
}
@@ -150,6 +151,11 @@ export const ChatImpl = memo(
const [selectedElement, setSelectedElement] = useState
(null);
const mcpSettings = useMCPStore((state) => state.settings);
+ // Debug logging for chatMode changes
+ useEffect(() => {
+ logger.info('ChatMode changed:', chatMode);
+ }, [chatMode]);
+
const {
messages,
isLoading,
@@ -674,6 +680,8 @@ export const ChatImpl = memo(
model,
provider,
apiKeys,
+ promptId,
+ chatMode,
);
}}
uploadedFiles={uploadedFiles}
diff --git a/app/components/chat/ChatBox.tsx b/app/components/chat/ChatBox.tsx
index 8a9fefa67c..0bfb539b16 100644
--- a/app/components/chat/ChatBox.tsx
+++ b/app/components/chat/ChatBox.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { classNames } from '~/utils/classNames';
import { PROVIDER_LIST } from '~/utils/constants';
@@ -8,10 +8,14 @@ import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
import FilePreview from './FilePreview';
import { ScreenshotStateManager } from './ScreenshotStateManager';
import { SendButton } from './SendButton.client';
+import { Paperclip, ChevronDown, ChevronRight, Sparkles, BookOpen, Loader2 } from 'lucide-react';
import { IconButton } from '~/components/ui/IconButton';
import { toast } from 'react-toastify';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import { SupabaseConnection } from './SupabaseConnection';
+import { ChatModeToggle } from './ChatModeToggle';
+import { SmartModeDetector } from './SmartModeDetector';
+import { ActionPromptLibrary } from './ActionPromptLibrary';
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
import styles from './BaseChat.module.scss';
import type { ProviderInfo } from '~/types/model';
@@ -64,6 +68,24 @@ interface ChatBoxProps {
}
export const ChatBox: React.FC = (props) => {
+ const [isPromptLibraryOpen, setIsPromptLibraryOpen] = useState(false);
+
+ const handlePromptSelect = (promptText: string) => {
+ // Set the textarea value and trigger the input change
+ if (props.textareaRef?.current) {
+ props.textareaRef.current.value = promptText;
+
+ // Create a synthetic event to trigger the input change
+ const event = new Event('input', { bubbles: true });
+ props.textareaRef.current.dispatchEvent(event);
+ }
+
+ // Also update the input state if available
+ props.handleInputChange?.({
+ target: { value: promptText },
+ } as React.ChangeEvent);
+ };
+
return (
= (props) => {
+ {/* Smart Mode Suggestion */}
+ {props.chatStarted && props.input && props.input.length > 20 && (
+
+ props.setChatMode?.(mode)}
+ />
+
+ )}
+
= (props) => {
/>
)}
-
-
+ {/* Mode Toggle - Separate row for better visibility */}
+ {props.chatStarted && (
+
+ props.setChatMode?.(mode)}
+ disabled={false}
+ />
+
+ )}
+
+
+
-
props.handleFileUpload()}>
-
+ props.handleFileUpload()}>
+
+
+ setIsPromptLibraryOpen(true)}
+ >
+
{
props.enhancePrompt?.();
toast.success('Prompt enhanced!');
}}
>
- {props.enhancingPrompt ? (
-
- ) : (
-
- )}
+ {props.enhancingPrompt ? : }
= (props) => {
onStop={props.stopListening}
disabled={props.isStreaming}
/>
- {props.chatStarted && (
- {
- props.setChatMode?.(props.chatMode === 'discuss' ? 'build' : 'discuss');
- }}
- >
-
- {props.chatMode === 'discuss' ? Discuss : }
-
- )}
= (props) => {
onClick={() => props.setIsModelSettingsCollapsed(!props.isModelSettingsCollapsed)}
disabled={!props.providerList || props.providerList.length === 0}
>
-
+ {props.isModelSettingsCollapsed ? (
+
+ ) : (
+
+ )}
{props.isModelSettingsCollapsed ? {props.model} : }
- {props.input.length > 3 ? (
-
- Use Shift +{' '}
- Return a new line
-
- ) : null}
props.setQrModalOpen(false)} />
+
+ {/* Action Prompt Library */}
+
setIsPromptLibraryOpen(false)}
+ onPromptSelect={handlePromptSelect}
+ />
);
};
diff --git a/app/components/chat/ChatModeToggle.tsx b/app/components/chat/ChatModeToggle.tsx
new file mode 100644
index 0000000000..26cf23d519
--- /dev/null
+++ b/app/components/chat/ChatModeToggle.tsx
@@ -0,0 +1,78 @@
+import React, { memo } from 'react';
+import { classNames } from '~/utils/classNames';
+import { Switch } from '~/components/ui/Switch';
+import { Code, MessageCircle } from 'lucide-react';
+
+interface ChatModeToggleProps {
+ chatMode: 'discuss' | 'build';
+ onModeChange: (mode: 'discuss' | 'build') => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+export const ChatModeToggle = memo(({ chatMode, onModeChange, disabled = false, className }: ChatModeToggleProps) => {
+ const handleToggleChange = (isDiscuss: boolean) => {
+ if (!disabled) {
+ onModeChange(isDiscuss ? 'discuss' : 'build');
+ }
+ };
+
+ const isDiscussMode = chatMode === 'discuss';
+
+ return (
+
+ {/* Build Mode Indicator */}
+
+
+ Build
+
+
+ {/* Toggle Switch */}
+
+
+ Build
+
+
+
+ Discuss
+
+
+
+ {/* Discuss Mode Indicator */}
+
+
+ Discuss
+
+
+ );
+});
+
+ChatModeToggle.displayName = 'ChatModeToggle';
diff --git a/app/components/chat/CodeBlock.tsx b/app/components/chat/CodeBlock.tsx
index e6b09f0697..ee604ba882 100644
--- a/app/components/chat/CodeBlock.tsx
+++ b/app/components/chat/CodeBlock.tsx
@@ -2,6 +2,7 @@ import { memo, useEffect, useState } from 'react';
import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki';
import { classNames } from '~/utils/classNames';
import { createScopedLogger } from '~/utils/logger';
+import { Clipboard } from 'lucide-react';
import styles from './CodeBlock.module.scss';
@@ -74,10 +75,11 @@ export const CodeBlock = memo(
title="Copy Code"
onClick={() => copyToClipboard()}
>
-
+
)}
+ {/* SECURITY: Syntax highlighting HTML should be sanitized by the code highlighter */}
);
diff --git a/app/components/chat/DicussMode.tsx b/app/components/chat/DicussMode.tsx
index 2ee1c26e36..b12e6ddf5d 100644
--- a/app/components/chat/DicussMode.tsx
+++ b/app/components/chat/DicussMode.tsx
@@ -1,5 +1,6 @@
import { classNames } from '~/utils/classNames';
import { IconButton } from '~/components/ui';
+import { MessageCircle } from 'lucide-react';
export function DiscussMode() {
return (
@@ -10,7 +11,7 @@ export function DiscussMode() {
'transition-all flex items-center gap-1 bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent',
)}
>
-
+
);
diff --git a/app/components/chat/FreeModelRecommendations.tsx b/app/components/chat/FreeModelRecommendations.tsx
new file mode 100644
index 0000000000..9a4a65b2a8
--- /dev/null
+++ b/app/components/chat/FreeModelRecommendations.tsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { AlertCircle, X, AlertTriangle, Lightbulb, Circle, Star } from 'lucide-react';
+
+interface FreeModelRecommendationsProps {
+ isVisible: boolean;
+ onClose: () => void;
+ providerName: string;
+}
+
+export const FreeModelRecommendations: React.FC
= ({
+ isVisible,
+ onClose,
+ providerName,
+}) => {
+ if (!isVisible) {
+ return null;
+ }
+
+ const recommendations = [
+ {
+ title: 'Best Practices for Free Models',
+ items: [
+ 'Use for simple tasks like code review or basic questions',
+ 'Avoid complex multi-step coding tasks',
+ 'Test with small code snippets first',
+ 'Have backup paid models ready to switch to',
+ ],
+ },
+ {
+ title: 'When to Switch to Paid Models',
+ items: [
+ 'Complex application development',
+ 'Large codebase modifications',
+ 'Performance-critical features',
+ 'When you need consistent, high-quality responses',
+ ],
+ },
+ {
+ title: 'Troubleshooting Free Model Issues',
+ items: [
+ 'Try refreshing the model list',
+ 'Switch to a different free model',
+ 'Check OpenRouter status page for outages',
+ 'Consider upgrading to paid tier for better reliability',
+ ],
+ },
+ ];
+
+ const alternativeModels = [
+ { name: 'Claude 3.5 Sonnet', provider: 'OpenRouter', reason: 'Best overall performance' },
+ { name: 'GPT-4o', provider: 'OpenRouter', reason: 'Excellent code generation' },
+ { name: 'DeepSeek Coder V2', provider: 'OpenRouter', reason: 'Great for coding tasks' },
+ { name: 'Gemini 2.0 Flash', provider: 'Google', reason: 'Fast and reliable' },
+ ];
+
+ return (
+
+
+
+
+
+ Free Model Recommendations
+
+
+
+
+
+
+
+ {/* Warning Alert */}
+
+
+
+
+
Free Model Limitations
+
+ Free models on {providerName} may experience slower response times, rate limiting, and inconsistent
+ performance compared to paid models. They're suitable for simple tasks but may struggle with complex
+ coding scenarios.
+
+
+
+
+
+ {/* Recommendations Grid */}
+
+ {recommendations.map((section, index) => (
+
+
+
+ {section.title}
+
+
+ {section.items.map((item, itemIndex) => (
+
+
+ {item}
+
+ ))}
+
+
+ ))}
+
+
+ {/* Alternative Models */}
+
+
+
+ Recommended Paid Alternatives
+
+
+ {alternativeModels.map((model, index) => (
+
+
{model.name}
+
{model.provider}
+
{model.reason}
+
+ ))}
+
+
+
+ {/* Action Buttons */}
+
+
+ Continue with Free Model
+
+
+ Switch to Paid Model
+
+
+
+
+
+ );
+};
diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx
index e0c2d26616..30358ae841 100644
--- a/app/components/chat/ImportFolderButton.tsx
+++ b/app/components/chat/ImportFolderButton.tsx
@@ -60,27 +60,27 @@ export const ImportFolderButton: React.FC = ({ classNam
);
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
- const binaryFilePaths = fileChecks
- .filter((f) => f.isBinary)
- .map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
+ const binaryFiles = fileChecks.filter((f) => f.isBinary).map((f) => f.file);
- if (textFiles.length === 0) {
- const error = new Error('No text files found');
- logStore.logError('File import failed - no text files', error, { folderName });
- toast.error('No text files found in the selected folder');
+ if (textFiles.length === 0 && binaryFiles.length === 0) {
+ const error = new Error('No valid files found');
+ logStore.logError('File import failed - no valid files', error, { folderName });
+ toast.error('No valid files found in the selected folder');
return;
}
- if (binaryFilePaths.length > 0) {
- logStore.logWarning(`Skipping binary files during import`, {
- folderName,
- binaryCount: binaryFilePaths.length,
- });
- toast.info(`Skipping ${binaryFilePaths.length} binary files`);
- }
+ const totalFiles = textFiles.length + binaryFiles.length;
+ const importSummary =
+ binaryFiles.length > 0 && textFiles.length > 0
+ ? `${totalFiles} files (${textFiles.length} text, ${binaryFiles.length} binary)`
+ : binaryFiles.length > 0
+ ? `${binaryFiles.length} binary files`
+ : `${textFiles.length} text files`;
+
+ toast.info(`Importing ${importSummary}...`);
- const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
+ const messages = await createChatFromFolder(textFiles, binaryFiles, folderName);
if (importChat) {
await importChat(folderName, [...messages]);
@@ -89,7 +89,7 @@ export const ImportFolderButton: React.FC = ({ classNam
logStore.logSystem('Folder imported successfully', {
folderName,
textFileCount: textFiles.length,
- binaryFileCount: binaryFilePaths.length,
+ binaryFileCount: binaryFiles.length,
});
toast.success('Folder imported successfully');
} catch (error) {
diff --git a/app/components/chat/LLMApiAlert.tsx b/app/components/chat/LLMApiAlert.tsx
index 12c5ada7d7..dc03e3ac07 100644
--- a/app/components/chat/LLMApiAlert.tsx
+++ b/app/components/chat/LLMApiAlert.tsx
@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion';
import type { LlmErrorAlertType } from '~/types/actions';
import { classNames } from '~/utils/classNames';
+import { Key, Clock, AlertTriangle, AlertCircle } from 'lucide-react';
interface Props {
alert: LlmErrorAlertType;
@@ -13,13 +14,13 @@ export default function LlmErrorAlert({ alert, clearAlert }: Props) {
const getErrorIcon = () => {
switch (errorType) {
case 'authentication':
- return 'i-ph:key-duotone';
+ return ;
case 'rate_limit':
- return 'i-ph:clock-duotone';
+ return ;
case 'quota':
- return 'i-ph:warning-circle-duotone';
+ return ;
default:
- return 'i-ph:warning-duotone';
+ return ;
}
};
@@ -52,7 +53,7 @@ export default function LlmErrorAlert({ alert, clearAlert }: Props) {
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
>
-
+ {getErrorIcon()}
diff --git a/app/components/chat/Markdown.tsx b/app/components/chat/Markdown.tsx
index 3471c73380..5da09fcf95 100644
--- a/app/components/chat/Markdown.tsx
+++ b/app/components/chat/Markdown.tsx
@@ -9,6 +9,7 @@ import type { Message } from 'ai';
import styles from './Markdown.module.scss';
import ThoughtBox from './ThoughtBox';
import type { ProviderInfo } from '~/types/model';
+import { File, MessageCircle, Code, Link, HelpCircle } from 'lucide-react';
const logger = createScopedLogger('MarkdownComponent');
@@ -123,15 +124,15 @@ export const Markdown = memo(
const path = dataProps['data-path'] || dataProps.dataPath;
const href = dataProps['data-href'] || dataProps.dataHref;
- const iconClassMap: Record
= {
- file: 'i-ph:file',
- message: 'i-ph:chats',
- implement: 'i-ph:code',
- link: 'i-ph:link',
+ const iconClassMap: Record> = {
+ file: File,
+ message: MessageCircle,
+ implement: Code,
+ link: Link,
};
const safeType = typeof type === 'string' ? type : '';
- const iconClass = iconClassMap[safeType] ?? 'i-ph:question';
+ const IconComponent = iconClassMap[safeType] ?? HelpCircle;
return (
-
+
{children}
);
diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx
index 2ccb9a5277..76a238bb04 100644
--- a/app/components/chat/ModelSelector.tsx
+++ b/app/components/chat/ModelSelector.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames';
+import { ChevronDown, Search, X, Gift, Loader2, Check } from 'lucide-react';
// Fuzzy search utilities
const levenshteinDistance = (str1: string, str2: string): number => {
@@ -441,9 +442,9 @@ export const ModelSelector = ({
>
{provider?.name || 'Select provider'}
-
@@ -476,7 +477,7 @@ export const ModelSelector = ({
aria-label="Search providers"
/>
-
+
{providerSearchQuery && (
-
+
)}
@@ -559,6 +560,7 @@ export const ModelSelector = ({
}}
tabIndex={focusedProviderIndex === index ? 0 : -1}
>
+ {/* SECURITY: Search highlighting HTML should be sanitized by the search library */}
{modelList.find((m) => m.name === model)?.label || 'Select model'}
-
@@ -626,18 +628,14 @@ export const ModelSelector = ({
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all',
'hover:bg-bolt-elements-background-depth-3',
showFreeModelsOnly
- ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30'
+ ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor',
)}
+ title={showFreeModelsOnly ? 'Showing only free models' : 'Show only free OpenRouter models'}
>
-
+
Free models only
- {showFreeModelsOnly && (
-
- {filteredModels.length} free model{filteredModels.length !== 1 ? 's' : ''}
-
- )}
)}
@@ -669,7 +667,7 @@ export const ModelSelector = ({
aria-label="Search models"
/>
-
+
{modelSearchQuery && (
-
+
)}
@@ -706,7 +704,7 @@ export const ModelSelector = ({
{modelLoading === 'all' || modelLoading === provider?.name ? (
@@ -759,6 +757,7 @@ export const ModelSelector = ({
+ {/* SECURITY: Search highlighting HTML should be sanitized by the search library */}
- {isModelLikelyFree(modelOption, provider?.name) && (
-
- )}
{model === modelOption.name && (
-
+
+
+
)}
diff --git a/app/components/chat/SendButton.client.tsx b/app/components/chat/SendButton.client.tsx
index 389ca3bf71..dfbe11b288 100644
--- a/app/components/chat/SendButton.client.tsx
+++ b/app/components/chat/SendButton.client.tsx
@@ -1,4 +1,5 @@
import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
+import { ArrowRight, Square } from 'lucide-react';
interface SendButtonProps {
show: boolean;
@@ -29,9 +30,7 @@ export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonP
}
}}
>
-
+
) : null}
diff --git a/app/components/chat/SmartModeDetector.tsx b/app/components/chat/SmartModeDetector.tsx
new file mode 100644
index 0000000000..2ab8cb2724
--- /dev/null
+++ b/app/components/chat/SmartModeDetector.tsx
@@ -0,0 +1,193 @@
+import React, { memo, useEffect, useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { Button } from '~/components/ui/Button';
+import { X } from 'lucide-react';
+
+interface SmartModeDetectorProps {
+ input: string;
+ currentMode: 'build' | 'discuss';
+ onModeSwitch: (mode: 'build' | 'discuss') => void;
+ className?: string;
+}
+
+// Keywords that suggest planning/discussion mode
+const DISCUSSION_KEYWORDS = [
+ 'planning',
+ 'strategy',
+ 'approach',
+ 'think',
+ 'consider',
+ 'design pattern',
+ 'best practice',
+ 'pros and cons',
+ "what's the best way",
+ 'evaluate',
+ 'research',
+ 'investigate',
+ 'brainstorm',
+];
+
+// Keywords that suggest build mode
+const BUILD_KEYWORDS = [
+ 'build',
+ 'make',
+ 'implement',
+ 'code',
+ 'write',
+ 'fix',
+ 'update',
+ 'modify',
+ 'generate',
+ 'component',
+ 'feature',
+ 'ui',
+ 'interface',
+ 'api',
+ 'database',
+ 'deploy',
+];
+
+// Question patterns that suggest discussion
+const DISCUSSION_PATTERNS = [
+ /^(what|how|why|when|where|which)\s+/i,
+ /should\s+i/i,
+ /what.*best/i,
+ /how.*approach/i,
+ /pros?\s+and\s+cons?/i,
+ /compare.*with/i,
+ /difference.*between/i,
+];
+
+function detectIntendedMode(input: string): 'build' | 'discuss' | null {
+ if (!input || input.length < 10) {
+ return null;
+ }
+
+ const lowerInput = input.toLowerCase();
+
+ // Count discussion vs build keywords
+ const discussionScore = DISCUSSION_KEYWORDS.reduce((score, keyword) => {
+ return score + (lowerInput.includes(keyword) ? 1 : 0);
+ }, 0);
+
+ const buildScore = BUILD_KEYWORDS.reduce((score, keyword) => {
+ return score + (lowerInput.includes(keyword) ? 1 : 0);
+ }, 0);
+
+ // Check for discussion patterns
+ const hasDiscussionPattern = DISCUSSION_PATTERNS.some((pattern) => pattern.test(input));
+
+ // Boost discussion score if patterns match
+ const finalDiscussionScore = discussionScore + (hasDiscussionPattern ? 2 : 0);
+
+ // Determine suggestion
+ if (finalDiscussionScore > buildScore && finalDiscussionScore >= 2) {
+ return 'discuss';
+ } else if (buildScore > finalDiscussionScore && buildScore >= 2) {
+ return 'build';
+ }
+
+ return null;
+}
+
+export const SmartModeDetector = memo(({ input, currentMode, onModeSwitch, className }: SmartModeDetectorProps) => {
+ const [suggestedMode, setSuggestedMode] = useState<'build' | 'discuss' | null>(null);
+ const [dismissed, setDismissed] = useState(false);
+
+ useEffect(() => {
+ const detectedMode = detectIntendedMode(input);
+
+ // Only suggest if detected mode differs from current mode
+ if (detectedMode && detectedMode !== currentMode && !dismissed) {
+ setSuggestedMode(detectedMode);
+ } else {
+ setSuggestedMode(null);
+ }
+ }, [input, currentMode, dismissed]);
+
+ // Reset dismissed state when mode changes
+ useEffect(() => {
+ setDismissed(false);
+ }, [currentMode]);
+
+ const handleSwitch = () => {
+ if (suggestedMode) {
+ onModeSwitch(suggestedMode);
+ setDismissed(true);
+ }
+ };
+
+ const handleDismiss = () => {
+ setDismissed(true);
+ setSuggestedMode(null);
+ };
+
+ const getSuggestionText = () => {
+ if (suggestedMode === 'discuss') {
+ return {
+ title: '💡 Discussion mode suggested',
+ description:
+ 'Your input seems like a planning or strategic question. Discussion mode is better for exploring ideas.',
+ actionText: 'Switch to Discussion',
+ };
+ } else {
+ return {
+ title: '🚀 Build mode suggested',
+ description: 'Your input looks like an implementation request. Build mode is optimized for creating code.',
+ actionText: 'Switch to Build',
+ };
+ }
+ };
+
+ if (!suggestedMode) {
+ return null;
+ }
+
+ const suggestion = getSuggestionText();
+
+ return (
+
+
+
+
+
+
{suggestion.title}
+
+
{suggestion.description}
+
+
+
+
+ {suggestion.actionText}
+
+
+
+
+
+
+
+
+ );
+});
+
+SmartModeDetector.displayName = 'SmartModeDetector';
diff --git a/app/components/chat/SpeechRecognition.tsx b/app/components/chat/SpeechRecognition.tsx
index 18c66c761b..fae0e033cb 100644
--- a/app/components/chat/SpeechRecognition.tsx
+++ b/app/components/chat/SpeechRecognition.tsx
@@ -1,6 +1,7 @@
import { IconButton } from '~/components/ui/IconButton';
import { classNames } from '~/utils/classNames';
import React from 'react';
+import { Mic, MicOff } from 'lucide-react';
export const SpeechRecognitionButton = ({
isListening,
@@ -22,7 +23,7 @@ export const SpeechRecognitionButton = ({
})}
onClick={isListening ? onStop : onStart}
>
- {isListening ?
:
}
+ {isListening ?
:
}
);
};
diff --git a/app/components/chat/SupabaseConnection.tsx b/app/components/chat/SupabaseConnection.tsx
index 64d46ef57a..82ae0ddda8 100644
--- a/app/components/chat/SupabaseConnection.tsx
+++ b/app/components/chat/SupabaseConnection.tsx
@@ -5,6 +5,7 @@ import { useStore } from '@nanostores/react';
import { chatId } from '~/lib/persistence/useChatHistory';
import { fetchSupabaseStats } from '~/lib/stores/supabase';
import { Dialog, DialogRoot, DialogClose, DialogTitle, DialogButton } from '~/components/ui/Dialog';
+import { ExternalLink, Loader2, Plug, Database, ChevronDown, RefreshCw, Plus, Check, Info } from 'lucide-react';
export function SupabaseConnection() {
const {
@@ -26,6 +27,8 @@ export function SupabaseConnection() {
const currentChatId = useStore(chatId);
+ // Note: Initialization is now handled in useSupabaseConnection hook
+
useEffect(() => {
const handleOpenConnectionDialog = () => {
setIsDialogOpen(true);
@@ -138,7 +141,7 @@ export function SupabaseConnection() {
className="text-[#3ECF8E] hover:underline inline-flex items-center gap-1"
>
Get your token
-
+
@@ -159,12 +162,12 @@ export function SupabaseConnection() {
>
{connecting ? (
<>
-
+
Connecting...
>
) : (
<>
-
+
Connect
>
)}
@@ -195,7 +198,7 @@ export function SupabaseConnection() {
{fetchingStats ? (
) : (
@@ -205,13 +208,10 @@ export function SupabaseConnection() {
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
className="bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2"
>
-
+
Your Projects ({supabaseConn.stats?.totalProjects || 0})
-
@@ -220,14 +220,14 @@ export function SupabaseConnection() {
className="px-2 py-1 rounded-md text-xs bg-[#F0F0F0] dark:bg-[#252525] text-bolt-elements-textSecondary hover:bg-[#E5E5E5] dark:hover:bg-[#333333] flex items-center gap-1"
title="Refresh projects list"
>
-
+
Refresh
handleCreateProject()}
className="px-2 py-1 rounded-md text-xs bg-[#3ECF8E] text-white hover:bg-[#3BBF84] flex items-center gap-1"
>
-
+
New Project
@@ -251,7 +251,7 @@ export function SupabaseConnection() {
-
+
{project.name}
@@ -269,7 +269,7 @@ export function SupabaseConnection() {
>
{supabaseConn.selectedProjectId === project.id ? (
-
+
Selected
) : (
@@ -282,7 +282,7 @@ export function SupabaseConnection() {
) : (
)}
@@ -296,7 +296,7 @@ export function SupabaseConnection() {
Close
-
+
Disconnect
diff --git a/app/components/chat/ThoughtBox.tsx b/app/components/chat/ThoughtBox.tsx
index 3c70e1a8b2..1b180536c8 100644
--- a/app/components/chat/ThoughtBox.tsx
+++ b/app/components/chat/ThoughtBox.tsx
@@ -1,4 +1,5 @@
import { useState, type PropsWithChildren } from 'react';
+import { Brain } from 'lucide-react';
const ThoughtBox = ({ title, children }: PropsWithChildren<{ title: string }>) => {
const [isExpanded, setIsExpanded] = useState(false);
@@ -19,7 +20,7 @@ const ThoughtBox = ({ title, children }: PropsWithChildren<{ title: string }>) =
`}
>
-
+
{title} {' '}
{!isExpanded &&
- Click to expand }
diff --git a/app/components/chat/ToolInvocations.tsx b/app/components/chat/ToolInvocations.tsx
index 280543fc08..805054c4ae 100644
--- a/app/components/chat/ToolInvocations.tsx
+++ b/app/components/chat/ToolInvocations.tsx
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { memo, useMemo, useState, useEffect } from 'react';
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
import { classNames } from '~/utils/classNames';
+import { Wrench, ChevronDown, ChevronUp, X, Check } from 'lucide-react';
import {
TOOL_EXECUTION_APPROVAL,
TOOL_EXECUTION_DENIED,
@@ -54,19 +55,22 @@ function JsonCodeBlock({ className, code, theme }: JsonCodeBlockProps) {
}
return (
-
+ <>
+ {/* SECURITY: Syntax highlighting HTML should be sanitized by the highlighter library */}
+
+ >
);
}
@@ -110,7 +114,7 @@ export const ToolInvocations = memo(({ toolInvocations, toolCallAnnotations, add
aria-label={showDetails ? 'Collapse details' : 'Expand details'}
>
@@ -134,9 +138,11 @@ export const ToolInvocations = memo(({ toolInvocations, toolCallAnnotations, add
onClick={toggleDetails}
>
-
+ {showDetails ? (
+
+ ) : (
+
+ )}
)}
@@ -230,11 +236,11 @@ const ToolResultsList = memo(({ toolInvocations, toolCallAnnotations, theme }: T
{isErrorResult ? (
) : (
)}
Server:
diff --git a/app/components/chat/VercelDeploymentLink.client.tsx b/app/components/chat/VercelDeploymentLink.client.tsx
index ecb5a587f3..a0e0d7c3fd 100644
--- a/app/components/chat/VercelDeploymentLink.client.tsx
+++ b/app/components/chat/VercelDeploymentLink.client.tsx
@@ -27,7 +27,7 @@ export function VercelDeploymentLink() {
try {
// Fetch projects directly from the API
- const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
+ const projectsResponse = await fetch('https://api.vercel.com/v13/projects', {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
@@ -50,7 +50,7 @@ export function VercelDeploymentLink() {
if (project) {
// Fetch project details including deployments
- const projectDetailsResponse = await fetch(`https://api.vercel.com/v9/projects/${project.id}`, {
+ const projectDetailsResponse = await fetch(`https://api.vercel.com/v13/projects/${project.id}`, {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
@@ -81,7 +81,7 @@ export function VercelDeploymentLink() {
// If no aliases or project details failed, try fetching deployments
const deploymentsResponse = await fetch(
- `https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
+ `https://api.vercel.com/v13/deployments?projectId=${project.id}&limit=1`,
{
headers: {
Authorization: `Bearer ${connection.token}`,
diff --git a/app/components/chat/chatExportAndImport/ExportChatButton.tsx b/app/components/chat/chatExportAndImport/ExportChatButton.tsx
index 5ac022483f..c34aab996f 100644
--- a/app/components/chat/chatExportAndImport/ExportChatButton.tsx
+++ b/app/components/chat/chatExportAndImport/ExportChatButton.tsx
@@ -1,49 +1,80 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
+import { ChevronDown, Code, MessageCircle, Download } from 'lucide-react';
export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => {
return (
-
-
-
- Export
-
-
-
+
+
+ Export
+
+
+
+
+ {
+ workbenchStore.downloadZip();
+ }}
+ >
+
+ Download Code
+
+
+ exportChat?.()}
>
- {
- workbenchStore.downloadZip();
- }}
- >
-
- Download Code
-
- exportChat?.()}
- >
-
- Export Chat
-
-
-
-
+
+
Export Chat
+
+
+
);
};
diff --git a/app/components/chat/chatExportAndImport/ImportButtons.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx
index c183558714..a3e1b5b259 100644
--- a/app/components/chat/chatExportAndImport/ImportButtons.tsx
+++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx
@@ -3,6 +3,7 @@ import { toast } from 'react-toastify';
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
import { Button } from '~/components/ui/Button';
import { classNames } from '~/utils/classNames';
+import { Upload } from 'lucide-react';
type ChatData = {
messages?: Message[]; // Standard Bolt format
@@ -75,7 +76,7 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
'transition-all duration-200 ease-in-out',
)}
>
-
+
Import Chat
-
+
+ {type === 'success' ?
: type === 'error' ?
:
}
+
{/* Content */}
@@ -79,13 +73,13 @@ export default function DeployChatAlert({ alert, clearAlert, postMessage }: Depl
)}
>
{buildStatus === 'running' ? (
-
+
) : buildStatus === 'complete' ? (
-
+
) : buildStatus === 'failed' ? (
-
+
) : (
-
1
+
1
)}
Build
@@ -114,13 +108,13 @@ export default function DeployChatAlert({ alert, clearAlert, postMessage }: Depl
)}
>
{deployStatus === 'running' ? (
-
+
) : deployStatus === 'complete' ? (
-
+
) : deployStatus === 'failed' ? (
-
+
) : (
- 2
+ 2
)}
Deploy
@@ -143,7 +137,7 @@ export default function DeployChatAlert({ alert, clearAlert, postMessage }: Depl
className="text-bolt-elements-item-contentAccent hover:underline flex items-center"
>
View deployed site
-
+
)}
@@ -171,7 +165,7 @@ export default function DeployChatAlert({ alert, clearAlert, postMessage }: Depl
'flex items-center gap-1.5',
)}
>
-
+
Ask Bolt
)}
diff --git a/app/components/deploy/DeployButton.tsx b/app/components/deploy/DeployButton.tsx
index ffdeb37e9b..8631c3038f 100644
--- a/app/components/deploy/DeployButton.tsx
+++ b/app/components/deploy/DeployButton.tsx
@@ -2,7 +2,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useStore } from '@nanostores/react';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
-import { isGitLabConnected } from '~/lib/stores/gitlabConnection';
+import { gitlabConnection } from '~/lib/stores/gitlabConnection';
import { workbenchStore } from '~/lib/stores/workbench';
import { streamingState } from '~/lib/stores/streaming';
import { classNames } from '~/utils/classNames';
@@ -31,7 +31,7 @@ export const DeployButton = ({
}: DeployButtonProps) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
- const gitlabIsConnected = useStore(isGitLabConnected);
+ const gitlabConn = useStore(gitlabConnection);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
@@ -43,10 +43,10 @@ export const DeployButton = ({
const { handleGitHubDeploy } = useGitHubDeploy();
const { handleGitLabDeploy } = useGitLabDeploy();
const [showGitHubDeploymentDialog, setShowGitHubDeploymentDialog] = useState(false);
- const [showGitLabDeploymentDialog, setShowGitLabDeploymentDialog] = useState(false);
const [githubDeploymentFiles, setGithubDeploymentFiles] = useState
| null>(null);
- const [gitlabDeploymentFiles, setGitlabDeploymentFiles] = useState | null>(null);
const [githubProjectName, setGithubProjectName] = useState('');
+ const [showGitLabDeploymentDialog, setShowGitLabDeploymentDialog] = useState(false);
+ const [gitlabDeploymentFiles, setGitlabDeploymentFiles] = useState | null>(null);
const [gitlabProjectName, setGitlabProjectName] = useState('');
const handleVercelDeployClick = async () => {
@@ -218,10 +218,10 @@ export const DeployButton = ({
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
- 'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !gitlabIsConnected,
+ 'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !gitlabConn.user,
},
)}
- disabled={isDeploying || !activePreview || !gitlabIsConnected}
+ disabled={isDeploying || !activePreview || !gitlabConn.user}
onClick={handleGitLabDeployClick}
>
- {!gitlabIsConnected ? 'No GitLab Account Connected' : 'Deploy to GitLab'}
+ {!gitlabConn.user ? 'No GitLab Account Connected' : 'Deploy to GitLab'}
(null);
- const [recentRepos, setRecentRepos] = useState([]);
- const [filteredRepos, setFilteredRepos] = useState([]);
- const [repoSearchQuery, setRepoSearchQuery] = useState('');
- const [isFetchingRepos, setIsFetchingRepos] = useState(false);
+ const [recentProjects, setRecentProjects] = useState([]);
+ const [filteredProjects, setFilteredProjects] = useState([]);
+ const [projectSearchQuery, setProjectSearchQuery] = useState('');
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
- const [createdRepoUrl, setCreatedRepoUrl] = useState('');
- const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
+ const [createdProjectUrl, setCreatedProjectUrl] = useState('');
const currentChatId = useStore(chatId);
// Load GitLab connection on mount
@@ -38,77 +34,134 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
if (isOpen) {
const connection = getLocalStorage('gitlab_connection');
- // Set a default repository name based on the project name
- setRepoName(projectName.replace(/\s+/g, '-').toLowerCase());
+ // Set a default project path based on the project name
+ setProjectPath(`${user?.username || 'user'}/${projectName.replace(/\s+/g, '-').toLowerCase()}`);
if (connection?.user && connection?.token) {
setUser(connection.user);
// Only fetch if we have both user and token
if (connection.token.trim()) {
- fetchRecentRepos(connection.token, connection.gitlabUrl || 'https://gitlab.com');
+ fetchRecentProjects(connection.token, connection.gitlabUrl || 'https://gitlab.com');
}
}
}
- }, [isOpen, projectName]);
+ }, [isOpen, projectName, user?.username]);
- // Filter repositories based on search query
+ // Filter projects based on search query
useEffect(() => {
- if (recentRepos.length === 0) {
- setFilteredRepos([]);
- return;
- }
-
- if (!repoSearchQuery.trim()) {
- setFilteredRepos(recentRepos);
- return;
- }
-
- const query = repoSearchQuery.toLowerCase().trim();
- const filtered = recentRepos.filter(
- (repo) =>
- repo.name.toLowerCase().includes(query) || (repo.description && repo.description.toLowerCase().includes(query)),
- );
-
- setFilteredRepos(filtered);
- }, [recentRepos, repoSearchQuery]);
-
- const fetchRecentRepos = async (token: string, gitlabUrl = 'https://gitlab.com') => {
- if (!token) {
- logStore.logError('No GitLab token available');
- toast.error('GitLab authentication required');
-
- return;
+ if (projectSearchQuery.trim()) {
+ const filtered = recentProjects.filter(
+ (project) =>
+ project.name.toLowerCase().includes(projectSearchQuery.toLowerCase()) ||
+ project.path_with_namespace.toLowerCase().includes(projectSearchQuery.toLowerCase()),
+ );
+ setFilteredProjects(filtered);
+ } else {
+ setFilteredProjects(recentProjects);
}
+ }, [projectSearchQuery, recentProjects]);
+ const fetchRecentProjects = async (token: string, gitlabUrl: string) => {
try {
- setIsFetchingRepos(true);
+ const response = await fetch(`${gitlabUrl}/api/v4/projects?membership=true&per_page=20`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch projects: ${response.status}`);
+ }
- const apiService = new GitLabApiService(token, gitlabUrl);
- const repos = await apiService.getProjects();
- setRecentRepos(repos);
+ const projects = (await response.json()) as GitLabProjectInfo[];
+ setRecentProjects(projects);
+ setFilteredProjects(projects);
} catch (error) {
- console.error('Failed to fetch GitLab repositories:', error);
- logStore.logError('Failed to fetch GitLab repositories', { error });
- toast.error('Failed to fetch recent repositories');
+ console.error('Failed to fetch GitLab projects:', error);
+ toast.error('Failed to fetch your GitLab projects');
} finally {
- setIsFetchingRepos(false);
}
};
- // Function to create a new repository or push to an existing one
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
+ const createNewProject = async (token: string, gitlabUrl: string) => {
+ const [namespace, name] = projectPath.split('/');
+
+ const response = await fetch(`${gitlabUrl}/api/v4/projects`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name,
+ path: name,
+ namespace_id: namespace,
+ visibility: isPrivate ? 'private' : 'public',
+ description: `Project created from Bolt.diy chat ${currentChatId}`,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = (await response.json()) as { message?: string };
+ throw new Error(errorData.message || 'Failed to create project');
+ }
- const connection = getLocalStorage('gitlab_connection');
+ return await response.json();
+ };
+
+ const pushFilesToRepository = async (projectId: number, token: string, gitlabUrl: string) => {
+ const fileEntries = Object.entries(files);
+ const pushedFilesList: { path: string; size: number }[] = [];
+
+ for (const [filePath, content] of fileEntries) {
+ try {
+ const response = await fetch(
+ `${gitlabUrl}/api/v4/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ branch: 'main',
+ content: btoa(unescape(encodeURIComponent(content))),
+ encoding: 'base64',
+ commit_message: `Add ${filePath}`,
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ console.warn(`Failed to push ${filePath}:`, await response.text());
+ continue;
+ }
- if (!connection?.token || !connection?.user) {
- toast.error('Please connect your GitLab account in Settings > Connections first');
+ pushedFilesList.push({
+ path: filePath,
+ size: new Blob([content]).size,
+ });
+ } catch (error) {
+ console.warn(`Error pushing ${filePath}:`, error);
+ continue;
+ }
+ }
+
+ return pushedFilesList;
+ };
+
+ const handleDeploy = async () => {
+ if (!user) {
+ toast.error('No GitLab user found');
return;
}
- if (!repoName.trim()) {
- toast.error('Repository name is required');
+ const connection = getLocalStorage('gitlab_connection');
+
+ if (!connection?.token) {
+ toast.error('No GitLab token found');
return;
}
@@ -116,614 +169,234 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
try {
const gitlabUrl = connection.gitlabUrl || 'https://gitlab.com';
- const apiService = new GitLabApiService(connection.token, gitlabUrl);
+ let targetProject;
// Check if project exists
- const projectPath = `${connection.user.username}/${repoName}`;
- const existingProject = await apiService.getProjectByPath(projectPath);
- const projectExists = existingProject !== null;
-
- if (projectExists && existingProject) {
- // Confirm overwrite
- const visibilityChange =
- existingProject.visibility !== (isPrivate ? 'private' : 'public')
- ? `\n\nThis will also change the repository from ${existingProject.visibility} to ${isPrivate ? 'private' : 'public'}.`
- : '';
-
- const confirmOverwrite = window.confirm(
- `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.${visibilityChange}`,
- );
-
- if (!confirmOverwrite) {
- setIsLoading(false);
- return;
- }
-
- // Update visibility if needed
- if (existingProject.visibility !== (isPrivate ? 'private' : 'public')) {
- toast.info('Updating repository visibility...');
- await apiService.updateProjectVisibility(existingProject.id, isPrivate ? 'private' : 'public');
- }
+ const projectResponse = await fetch(`${gitlabUrl}/api/v4/projects/${encodeURIComponent(projectPath)}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
- // Update project with files
- toast.info('Uploading files to existing repository...');
- await apiService.updateProjectWithFiles(existingProject.id, files);
- setCreatedRepoUrl(existingProject.http_url_to_repo);
- toast.success('Repository updated successfully!');
+ if (projectResponse.ok) {
+ targetProject = await projectResponse.json();
} else {
- // Create new project with files
- toast.info('Creating new repository...');
-
- const newProject = await apiService.createProjectWithFiles(repoName, isPrivate, files);
- setCreatedRepoUrl(newProject.http_url_to_repo);
- toast.success('Repository created successfully!');
+ // Create new project
+ targetProject = await createNewProject(connection.token, gitlabUrl);
+ toast.success('New GitLab project created!');
}
- // Set pushed files for display
- const fileList = Object.entries(files).map(([filePath, content]) => ({
- path: filePath,
- size: new TextEncoder().encode(content).length,
- }));
+ const project = targetProject as any;
- setPushedFiles(fileList);
- setShowSuccessDialog(true);
-
- // Save repository info
- localStorage.setItem(
- `gitlab-repo-${currentChatId}`,
- JSON.stringify({
- owner: connection.user.username,
- name: repoName,
- url: createdRepoUrl,
- }),
- );
+ // Push files to repository
+ await pushFilesToRepository(project.id, connection.token, gitlabUrl);
+ setCreatedProjectUrl(project.web_url);
- logStore.logInfo('GitLab deployment completed successfully', {
+ logStore.logInfo('GitLab deployment completed', {
type: 'system',
- message: `Successfully deployed ${fileList.length} files to ${projectExists ? 'existing' : 'new'} GitLab repository: ${projectPath}`,
- repoName,
- projectPath,
- filesCount: fileList.length,
- isNewProject: !projectExists,
+ message: `Successfully deployed to GitLab project: ${project.name}`,
});
- } catch (error) {
- console.error('Error pushing to GitLab:', error);
- logStore.logError('GitLab deployment failed', {
- error,
- repoName,
- projectPath: `${connection.user.username}/${repoName}`,
- });
-
- // Provide specific error messages based on error type
- let errorMessage = 'Failed to push to GitLab';
-
- if (error instanceof Error) {
- const errorMsg = error.message.toLowerCase();
-
- if (errorMsg.includes('404') || errorMsg.includes('not found')) {
- errorMessage =
- 'Repository or GitLab instance not found. Please check your GitLab URL and repository permissions.';
- } else if (errorMsg.includes('401') || errorMsg.includes('unauthorized')) {
- errorMessage = 'GitLab authentication failed. Please check your access token and permissions.';
- } else if (errorMsg.includes('403') || errorMsg.includes('forbidden')) {
- errorMessage =
- 'Access denied. Your GitLab token may not have sufficient permissions to create/modify repositories.';
- } else if (errorMsg.includes('network') || errorMsg.includes('fetch')) {
- errorMessage = 'Network error. Please check your internet connection and try again.';
- } else if (errorMsg.includes('timeout')) {
- errorMessage = 'Request timed out. Please try again or check your connection.';
- } else if (errorMsg.includes('rate limit')) {
- errorMessage = 'GitLab API rate limit exceeded. Please wait a moment and try again.';
- } else {
- errorMessage = `GitLab error: ${error.message}`;
- }
- }
- toast.error(errorMessage);
+ setShowSuccessDialog(true);
+ toast.success('Successfully deployed to GitLab!');
+ } catch (error) {
+ console.error('GitLab deployment error:', error);
+ toast.error(error instanceof Error ? error.message : 'Deployment failed');
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
- setRepoName('');
- setIsPrivate(false);
setShowSuccessDialog(false);
- setCreatedRepoUrl('');
+ setCreatedProjectUrl('');
onClose();
};
- // Success Dialog
- if (showSuccessDialog) {
- return (
- !open && handleClose()}>
-
-
-
-
-
- Successfully pushed to GitLab
-
-
-
-
-
-
- Successfully pushed to GitLab
-
-
- Your code is now available on GitLab
-
-
-
-
-
-
- Close dialog
-
-
-
-
-
-
-
- Repository URL
-
-
-
- {createdRepoUrl}
-
-
{
- navigator.clipboard.writeText(createdRepoUrl);
- toast.success('URL copied to clipboard');
- }}
- className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-4 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- >
-
-
-
-
-
-
-
-
- Pushed Files ({pushedFiles.length})
-
-
- {pushedFiles.slice(0, 100).map((file) => (
-
- {file.path}
-
- {formatSize(file.size)}
-
-
- ))}
- {pushedFiles.length > 100 && (
-
- +{pushedFiles.length - 100} more files
-
- )}
-
-
-
-
-
-
- View Repository
-
-
{
- navigator.clipboard.writeText(createdRepoUrl);
- toast.success('URL copied to clipboard');
- }}
- className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm inline-flex items-center gap-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
- whileHover={{ scale: 1.02 }}
- whileTap={{ scale: 0.98 }}
- >
-
- Copy URL
-
-
- Close
-
-
-
-
-
-
-
-
- );
- }
-
- if (!user) {
- return (
- !open && handleClose()}>
+ return (
+ <>
+
-
-
+
+
-
- GitLab Connection Required
-
-
-
-
- Close dialog
-
-
-
-
-
-
- GitLab Connection Required
-
-
- To deploy your code to GitLab, you need to connect your GitLab account first.
-
-
-
- Close
-
-
-
- Go to Settings
-
-
-
-
-
-
-
-
- );
- }
-
- return (
- !open && handleClose()}>
-
-
-
-
-
-
-
-
-
-
-
-
- Deploy to GitLab
-
-
- Deploy your code to a new or existing GitLab repository
-
-
-
-
-
- Close dialog
-
+
+ {/* Header */}
+
+
+ Deploy to GitLab
+
+
+ Close
+
+
+
-
-
- {user.avatar_url && user.avatar_url !== 'null' && user.avatar_url !== '' ? (
-
{
- // Handle CORS/COEP errors by hiding the image and showing fallback
- const target = e.target as HTMLImageElement;
- target.style.display = 'none';
-
- const fallback = target.parentElement?.querySelector('.avatar-fallback') as HTMLElement;
-
- if (fallback) {
- fallback.style.display = 'flex';
- }
- }}
- onLoad={(e) => {
- // Ensure fallback is hidden when image loads successfully
- const target = e.target as HTMLImageElement;
-
- const fallback = target.parentElement?.querySelector('.avatar-fallback') as HTMLElement;
-
- if (fallback) {
- fallback.style.display = 'none';
- }
- }}
- />
- ) : null}
-
-
- {user.name ? (
- user.name.charAt(0).toUpperCase()
- ) : user.username ? (
- user.username.charAt(0).toUpperCase()
- ) : (
-
- )}
-
-
-
+ {/* Content */}
+
+
+ {/* Project Info */}
+
+
+ Project: {projectName}
+
+
+ {Object.keys(files).length} files ready to deploy
+
-
-
-
- {user.name || user.username}
-
-
- @{user.username}
-
-
-
-
-
-
- Repository Name
-
-
-
-
-
+ {/* Project Path Input */}
+
+
+ GitLab Project Path
+
setRepoName(e.target.value)}
- placeholder="my-awesome-project"
- className="w-full pl-10 px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark focus:outline-none focus:ring-2 focus:ring-orange-500"
- required
+ value={projectPath}
+ onChange={(e) => setProjectPath(e.target.value)}
+ placeholder="namespace/project-name"
+ className="w-full px-3 py-2 bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor rounded-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background"
/>
+
+ Use format: username/project-name or group/project-name
+
-
-
-
-
- Recent Repositories
-
-
- {filteredRepos.length} of {recentRepos.length}
-
-
-
-
-
setRepoSearchQuery(e.target.value)}
- onClear={() => setRepoSearchQuery('')}
- className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-sm"
+ {/* Privacy Setting */}
+
+ setIsPrivate(e.target.checked)}
+ className="rounded border-bolt-elements-borderColor text-bolt-elements-button-primary-background focus:ring-bolt-elements-button-primary-background"
/>
+
+ Private repository
+
- {recentRepos.length === 0 && !isFetchingRepos ? (
-
- ) : (
-
- {filteredRepos.length === 0 && repoSearchQuery.trim() !== '' ? (
-
- ) : (
- filteredRepos.map((repo) => (
-
setRepoName(repo.name)}
- className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-orange-500/30"
- whileHover={{ scale: 1.01 }}
- whileTap={{ scale: 0.99 }}
+ {/* Recent Projects */}
+ {recentProjects.length > 0 && (
+
+
Recent Projects
+
setProjectSearchQuery(e.target.value)}
+ placeholder="Search projects..."
+ className="mb-4"
+ />
+
+ {filteredProjects.map((project) => (
+
setProjectPath(project.path_with_namespace)}
+ className="p-3 bg-bolt-elements-background-depth-3 rounded-md cursor-pointer hover:bg-bolt-elements-item-backgroundActive border border-bolt-elements-borderColor"
>
-
-
-
- {repo.name}
-
+
+
{project.name}
+
+ {project.path_with_namespace}
+
- {repo.visibility === 'private' && (
-
- Private
-
- )}
-
- {repo.description && (
-
- {repo.description}
-
- )}
-
-
- {repo.star_count.toLocaleString()}
-
-
- {repo.forks_count.toLocaleString()}
-
-
- {new Date(repo.updated_at).toLocaleDateString()}
+
+ {project.visibility}
-
- ))
- )}
+
+ ))}
+
)}
+
- {isFetchingRepos && (
-
-
-
- )}
+ {/* Footer */}
+
+
+
+ Cancel
+
+
+
+ {isLoading ? 'Deploying...' : 'Deploy to GitLab'}
+
+
+
+
+
+
+
-
-
-
setIsPrivate(e.target.checked)}
- className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-orange-500 focus:ring-orange-500 dark:bg-bolt-elements-background-depth-3"
- />
-
- Make repository private
-
+ {/* Success Dialog */}
+
+
+
+
+
+
+
+
+
-
- Private repositories are only visible to you and people you share them with
-
-
-
-
+ View Project on GitLab
+
+ )}
+
+
+
+ Close
+
+
-
-
-
-
-
+
+
+
+
+ >
);
}
diff --git a/app/components/git/GitUrlImport.client.tsx b/app/components/git/GitUrlImport.client.tsx
index eb1bfbbeb5..7305d7ec0c 100644
--- a/app/components/git/GitUrlImport.client.tsx
+++ b/app/components/git/GitUrlImport.client.tsx
@@ -11,14 +11,53 @@ import { createCommandsMessage, detectProjectCommands, escapeBoltTags } from '~/
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
import { toast } from 'react-toastify';
+// Binary file extensions that should be handled as binary data
+const BINARY_FILE_EXTENSIONS = [
+ '.jpg',
+ '.jpeg',
+ '.png',
+ '.gif',
+ '.bmp',
+ '.webp',
+ '.svg',
+ '.ico',
+ '.tiff',
+ '.pdf',
+ '.zip',
+ '.tar',
+ '.gz',
+ '.7z',
+ '.rar',
+ '.mp3',
+ '.mp4',
+ '.wav',
+ '.avi',
+ '.mov',
+ '.ttf',
+ '.otf',
+ '.woff',
+ '.woff2',
+ '.eot',
+ '.exe',
+ '.dll',
+ '.so',
+ '.dylib',
+ '.bin',
+ '.dat',
+ '.db',
+ '.sqlite',
+];
+
+const isBinaryFile = (filePath: string): boolean => {
+ const extension = filePath.toLowerCase().substring(filePath.lastIndexOf('.'));
+ return BINARY_FILE_EXTENSIONS.includes(extension);
+};
+
const IGNORE_PATTERNS = [
'node_modules/**',
'.git/**',
'.github/**',
'.vscode/**',
- '**/*.jpg',
- '**/*.jpeg',
- '**/*.png',
'dist/**',
'build/**',
'.next/**',
@@ -61,26 +100,87 @@ export function GitUrlImport() {
const fileContents = filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
- return {
- path: filePath,
- content:
- encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
- };
+ const isBinary = isBinaryFile(filePath);
+
+ if (isBinary && content instanceof Uint8Array) {
+ // Convert binary data to base64 without stack overflow
+ let binary = '';
+ const chunkSize = 0x8000; // 32KB chunks
+
+ for (let i = 0; i < content.length; i += chunkSize) {
+ const chunk = content.subarray(i, i + chunkSize);
+ binary += String.fromCharCode.apply(null, Array.from(chunk));
+ }
+
+ const base64 = btoa(binary);
+
+ return {
+ path: filePath,
+ content: base64,
+ isBinary: true,
+ };
+ } else if (encoding === 'utf8') {
+ // Handle UTF-8 text content
+ return {
+ path: filePath,
+ content: content as string,
+ isBinary: false,
+ };
+ } else if (content instanceof Uint8Array) {
+ // Try to decode as text, fallback to base64 if it fails
+ try {
+ const decodedText = textDecoder.decode(content);
+ return {
+ path: filePath,
+ content: decodedText,
+ isBinary: false,
+ };
+ } catch {
+ // If decoding fails, treat as binary - convert to base64 without stack overflow
+ let binary = '';
+ const chunkSize = 0x8000; // 32KB chunks
+
+ for (let i = 0; i < content.length; i += chunkSize) {
+ const chunk = content.subarray(i, i + chunkSize);
+ binary += String.fromCharCode.apply(null, Array.from(chunk));
+ }
+
+ const base64 = btoa(binary);
+
+ return {
+ path: filePath,
+ content: base64,
+ isBinary: true,
+ };
+ }
+ }
+
+ return null;
})
- .filter((f) => f.content);
+ .filter((f): f is NonNullable
=> f !== null && !!f.content);
- const commands = await detectProjectCommands(fileContents);
+ const textFiles = fileContents.filter((f) => !f.isBinary);
+ const binaryFiles = fileContents.filter((f) => f.isBinary);
+
+ const commands = await detectProjectCommands(textFiles);
const commandsMessage = createCommandsMessage(commands);
+ const importSummary =
+ binaryFiles.length > 0 && textFiles.length > 0
+ ? ` (${textFiles.length} text, ${binaryFiles.length} binary files)`
+ : binaryFiles.length > 0
+ ? ` (${binaryFiles.length} binary files)`
+ : ` (${textFiles.length} text files)`;
+
const filesMessage: Message = {
role: 'assistant',
- content: `Cloning the repo ${repoUrl} into ${workdir}
+ content: `Cloning the repo ${repoUrl} into ${workdir}${importSummary}
${fileContents
.map(
(file) =>
- `
-${escapeBoltTags(file.content)}
+ `
+${file.isBinary ? file.content : escapeBoltTags(file.content)}
`,
)
.join('\n')}
diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx
index 1d509ce82e..ec06bc4553 100644
--- a/app/components/header/Header.tsx
+++ b/app/components/header/Header.tsx
@@ -1,9 +1,11 @@
import { useStore } from '@nanostores/react';
import { ClientOnly } from 'remix-utils/client-only';
import { chatStore } from '~/lib/stores/chat';
+import { toggleSidebar } from '~/lib/stores/sidebar';
import { classNames } from '~/utils/classNames';
import { HeaderActionButtons } from './HeaderActionButtons.client';
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
+import { Sidebar } from 'lucide-react';
export function Header() {
const chat = useStore(chatStore);
@@ -16,7 +18,7 @@ export function Header() {
})}
>
-
+
{/* */}
diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx
index 41d6354ed6..a80c9be06d 100644
--- a/app/components/header/HeaderActionButtons.client.tsx
+++ b/app/components/header/HeaderActionButtons.client.tsx
@@ -2,37 +2,130 @@ import { useState } from 'react';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/lib/stores/workbench';
import { DeployButton } from '~/components/deploy/DeployButton';
+import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
+import { toast } from 'react-toastify';
+import { classNames } from '~/utils/classNames';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import { ChevronDown, Loader2, CloudDownload, Bug } from 'lucide-react';
interface HeaderActionButtonsProps {
chatStarted: boolean;
}
-export function HeaderActionButtons({ chatStarted: _chatStarted }: HeaderActionButtonsProps) {
+export function HeaderActionButtons({ chatStarted }: HeaderActionButtonsProps) {
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const shouldShowButtons = activePreview;
+ const exportChat = () => {
+ // Export chat functionality
+ toast.info('Export chat functionality would be implemented here');
+ };
+
+ const handleSyncFiles = () => {
+ // Sync functionality would be implemented here
+ toast.info('Sync functionality would be implemented here');
+ };
+
+ const isSyncing = false;
+
return (
-
+
+ {/* Export Chat Button */}
+ {chatStarted && shouldShowButtons &&
}
+
+ {/* Sync Button */}
+ {shouldShowButtons && (
+
+
+ {isSyncing ? (
+
+ ) : (
+
+ )}
+ {isSyncing ? 'Syncing...' : 'Sync'}
+
+
+
+
+
+ {isSyncing ? (
+
+ ) : (
+
+ )}
+ {isSyncing ? 'Syncing Files...' : 'Sync Files'}
+
+
+
+ )}
{/* Deploy Button */}
{shouldShowButtons &&
}
{/* Bug Report Button */}
{shouldShowButtons && (
-
-
- window.open('https://github.com/stackblitz-labs/bolt.diy/issues/new?template=bug_report.yml', '_blank')
- }
- className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.5"
- title="Report Bug"
- >
-
- Report Bug
-
-
+
+ window.open('https://github.com/stackblitz-labs/bolt.diy/issues/new?template=bug_report.yml', '_blank')
+ }
+ className={classNames(
+ 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium',
+ 'bg-bolt-elements-button-primary-background',
+ 'text-bolt-elements-button-primary-text',
+ 'border border-bolt-elements-borderColor',
+ 'transition-theme duration-150',
+ 'hover:bg-bolt-elements-button-primary-backgroundHover',
+ 'hover:text-bolt-elements-item-contentAccent',
+ 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColorActive focus:ring-offset-1',
+ 'shrink-0',
+ )}
+ title="Report Bug"
+ >
+
+ Report Bug
+
)}
);
diff --git a/app/components/prompts/PromptOnboarding.tsx b/app/components/prompts/PromptOnboarding.tsx
new file mode 100644
index 0000000000..8ecb8b2633
--- /dev/null
+++ b/app/components/prompts/PromptOnboarding.tsx
@@ -0,0 +1,94 @@
+import React, { memo, useState } from 'react';
+import { motion } from 'framer-motion';
+import { PromptSelector } from '~/components/ui/PromptSelector';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+import { PromptLibrary } from '~/lib/common/prompt-library';
+
+interface PromptOnboardingProps {
+ onComplete: (selectedPromptId: string) => void;
+ onSkip?: () => void;
+ className?: string;
+}
+
+/**
+ * Onboarding component for new users to select their preferred prompt template
+ * Can be used in welcome screens, first-time setup, or settings
+ */
+export const PromptOnboarding = memo(({ onComplete, onSkip, className }: PromptOnboardingProps) => {
+ const [selectedPrompt, setSelectedPrompt] = useState
('coding');
+
+ const handleContinue = () => {
+ onComplete(selectedPrompt);
+ };
+
+ const selectedPromptInfo = PromptLibrary.getPromptInfo(selectedPrompt);
+
+ return (
+
+
+
+ Welcome to Bolt! 🚀
+
+ Let's personalize your experience by choosing the AI assistant that matches your workflow
+
+
+
+
+
+
+ {/* Action Buttons */}
+
+ {onSkip && (
+
+ Skip for now
+
+ )}
+
+ Continue with {selectedPromptInfo?.label || 'Selected'}
+
+
+
+ {/* Selected Prompt Summary */}
+ {selectedPromptInfo && (
+
+
+
+
+ You've selected: {selectedPromptInfo.label}
+
+
{selectedPromptInfo.description}
+
+ 💡 Don't worry, you can always change this later in Settings
+
+
+
+ )}
+
+ );
+});
+
+PromptOnboarding.displayName = 'PromptOnboarding';
diff --git a/app/components/sidebar/HistoryItem.tsx b/app/components/sidebar/HistoryItem.tsx
index 422faaa764..913a1abcae 100644
--- a/app/components/sidebar/HistoryItem.tsx
+++ b/app/components/sidebar/HistoryItem.tsx
@@ -3,8 +3,10 @@ import { classNames } from '~/utils/classNames';
import { type ChatHistoryItem } from '~/lib/persistence';
import WithTooltip from '~/components/ui/Tooltip';
import { useEditChatDescription } from '~/lib/hooks';
-import { forwardRef, type ForwardedRef, useCallback } from 'react';
+import { useCallback } from 'react';
import { Checkbox } from '~/components/ui/Checkbox';
+import { Check, Download, Copy, Pencil, Trash2, MoreHorizontal } from 'lucide-react';
+import { Dropdown, DropdownItem, DropdownSeparator } from '~/components/ui/Dropdown';
interface HistoryItemProps {
item: ChatHistoryItem;
@@ -52,24 +54,11 @@ export function HistoryItem({
onToggleSelection?.(item.id);
}, [item.id, onToggleSelection]);
- const handleDeleteClick = useCallback(
- (event: React.MouseEvent) => {
- event.preventDefault();
- event.stopPropagation();
- console.log('Delete button clicked for item:', item.id);
-
- if (onDelete) {
- onDelete(event as unknown as React.UIEvent);
- }
- },
- [onDelete, item.id],
- );
-
return (
+ >
+
+
) : (
-
-
- {currentDescription}
-
-
);
}
-
-const ChatActionButton = forwardRef(
- (
- {
- toolTipContent,
- icon,
- className,
- onClick,
- }: {
- toolTipContent: string;
- icon: string;
- className?: string;
- onClick: (event: React.MouseEvent) => void;
- btnTitle?: string;
- },
- ref: ForwardedRef,
- ) => {
- return (
-
-
-
- );
- },
-);
diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx
index 36d6a8ec67..0c38e0f2a2 100644
--- a/app/components/sidebar/Menu.client.tsx
+++ b/app/components/sidebar/Menu.client.tsx
@@ -6,6 +6,7 @@ import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { ControlPanel } from '~/components/@settings/core/ControlPanel';
import { SettingsButton, HelpButton } from '~/components/ui/SettingsButton';
import { Button } from '~/components/ui/Button';
+import { Clock, User, PlusCircle, X, CheckSquare, Search } from 'lucide-react';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
import { HistoryItem } from './HistoryItem';
@@ -14,6 +15,7 @@ import { useSearchFilter } from '~/lib/hooks/useSearchFilter';
import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { profileStore } from '~/lib/stores/profile';
+import { sidebarOpen, openSidebar, closeSidebar } from '~/lib/stores/sidebar';
const menuVariants = {
closed: {
@@ -54,7 +56,7 @@ function CurrentDateTime() {
return (
-
+
{dateTime.toLocaleDateString()}
{dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@@ -67,7 +69,7 @@ export const Menu = () => {
const { duplicateCurrentChat, exportChat } = useChatHistory();
const menuRef = useRef
(null);
const [list, setList] = useState([]);
- const [open, setOpen] = useState(false);
+ const open = useStore(sidebarOpen);
const [dialogContent, setDialogContent] = useState(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const profile = useStore(profileStore);
@@ -288,11 +290,11 @@ export const Menu = () => {
}
if (event.pageX < enterThreshold) {
- setOpen(true);
+ openSidebar();
}
if (menuRef.current && event.clientX > menuRef.current.getBoundingClientRect().right + exitThreshold) {
- setOpen(false);
+ closeSidebar();
}
}
@@ -310,7 +312,7 @@ export const Menu = () => {
const handleSettingsClick = () => {
setIsSettingsOpen(true);
- setOpen(false);
+ closeSidebar();
};
const handleSettingsClose = () => {
@@ -354,7 +356,7 @@ export const Menu = () => {
decoding="sync"
/>
) : (
-
+
)}
@@ -367,7 +369,7 @@ export const Menu = () => {
href="/"
className="flex-1 flex gap-2 items-center bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-500/20 rounded-lg px-4 py-2 transition-colors"
>
-
+
Start new chat
{
)}
aria-label={selectionMode ? 'Exit selection mode' : 'Enter selection mode'}
>
-
+ {selectionMode ? : }
-
+
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = 'AccordionItem';
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/app/components/ui/Alert.tsx b/app/components/ui/Alert.tsx
new file mode 100644
index 0000000000..445bf29baa
--- /dev/null
+++ b/app/components/ui/Alert.tsx
@@ -0,0 +1,47 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { classNames } from '~/utils/classNames';
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
+ {
+ variants: {
+ variant: {
+ default: 'bg-bolt-elements-background border-bolt-elements-border',
+ destructive:
+ 'border-red-500/50 text-red-700 bg-red-50 dark:border-red-500 dark:bg-red-950 dark:text-red-400 [&>svg]:text-red-600 dark:[&>svg]:text-red-400',
+ warning:
+ 'border-yellow-500/50 text-yellow-700 bg-yellow-50 dark:border-yellow-500 dark:bg-yellow-950 dark:text-yellow-400 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400',
+ success:
+ 'border-green-500/50 text-green-700 bg-green-50 dark:border-green-500 dark:bg-green-950 dark:text-green-400 [&>svg]:text-green-600 dark:[&>svg]:text-green-400',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = 'Alert';
+
+const AlertTitle = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+AlertTitle.displayName = 'AlertTitle';
+
+const AlertDescription = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+AlertDescription.displayName = 'AlertDescription';
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/app/components/ui/AlertDialog.tsx b/app/components/ui/AlertDialog.tsx
new file mode 100644
index 0000000000..5e193c984b
--- /dev/null
+++ b/app/components/ui/AlertDialog.tsx
@@ -0,0 +1,107 @@
+import * as React from 'react';
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
+import { classNames } from '~/utils/classNames';
+import { buttonVariants } from './Button';
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = 'AlertDialogHeader';
+
+const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = 'AlertDialogFooter';
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/app/components/ui/Avatar.tsx b/app/components/ui/Avatar.tsx
new file mode 100644
index 0000000000..ef9c3e003e
--- /dev/null
+++ b/app/components/ui/Avatar.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react';
+import * as AvatarPrimitive from '@radix-ui/react-avatar';
+import { classNames } from '~/utils/classNames';
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/app/components/ui/Badge.tsx b/app/components/ui/Badge.tsx
index 14729e6b07..d64ece1d1e 100644
--- a/app/components/ui/Badge.tsx
+++ b/app/components/ui/Badge.tsx
@@ -21,7 +21,7 @@ const badgeVariants = cva(
danger: 'bg-red-500/10 text-red-600 dark:text-red-400',
info: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
subtle:
- 'border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 bg-white/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
+ 'border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 bg-bolt-elements-background-depth-2/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
},
size: {
default: 'rounded-full px-2.5 py-0.5 text-xs font-semibold',
@@ -38,13 +38,21 @@ const badgeVariants = cva(
);
export interface BadgeProps extends React.HTMLAttributes, VariantProps {
- icon?: string;
+ icon?: string | React.ComponentType<{ className?: string }>;
}
function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) {
return (
- {icon && }
+ {icon &&
+ (typeof icon === 'string' ? (
+
+ ) : (
+ (() => {
+ const IconComponent = icon;
+ return ;
+ })()
+ ))}
{children}
);
diff --git a/app/components/ui/Breadcrumbs.tsx b/app/components/ui/Breadcrumbs.tsx
index 1ba2b9356a..ae9627796a 100644
--- a/app/components/ui/Breadcrumbs.tsx
+++ b/app/components/ui/Breadcrumbs.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion';
+import { ChevronRight } from 'lucide-react';
interface BreadcrumbItem {
label: string;
@@ -12,7 +13,7 @@ interface BreadcrumbItem {
interface BreadcrumbsProps {
items: BreadcrumbItem[];
className?: string;
- separator?: string;
+ separator?: string | React.ComponentType<{ className?: string }>;
maxItems?: number;
renderItem?: (item: BreadcrumbItem, index: number, isLast: boolean) => React.ReactNode;
}
@@ -20,7 +21,7 @@ interface BreadcrumbsProps {
export function Breadcrumbs({
items,
className,
- separator = 'i-ph:caret-right',
+ separator = ChevronRight,
maxItems = 0,
renderItem,
}: BreadcrumbsProps) {
@@ -84,14 +85,24 @@ export function Breadcrumbs({
return (
{renderItem ? renderItem(item, index, isLast) : defaultRenderItem(item, index, isLast)}
- {!isLast && (
-
- )}
+ {!isLast &&
+ (() => {
+ if (typeof separator === 'string') {
+ return (
+
+ );
+ } else {
+ const SeparatorComponent = separator;
+ return (
+
+ );
+ }
+ })()}
);
})}
diff --git a/app/components/ui/CloseButton.tsx b/app/components/ui/CloseButton.tsx
index 5c8fff5e8d..6b3550fb8c 100644
--- a/app/components/ui/CloseButton.tsx
+++ b/app/components/ui/CloseButton.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
+import { X } from 'lucide-react';
interface CloseButtonProps {
onClick?: () => void;
@@ -43,7 +44,7 @@ export function CloseButton({ onClick, className, size = 'md' }: CloseButtonProp
whileTap={{ scale: 0.95 }}
aria-label="Close"
>
-
+
);
}
diff --git a/app/components/ui/CodeBlock.tsx b/app/components/ui/CodeBlock.tsx
index 71dfbc29d0..83ffc99289 100644
--- a/app/components/ui/CodeBlock.tsx
+++ b/app/components/ui/CodeBlock.tsx
@@ -3,6 +3,7 @@ import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion';
import { FileIcon } from './FileIcon';
import { Tooltip } from './Tooltip';
+import { Check, Copy } from 'lucide-react';
interface CodeBlockProps {
code: string;
@@ -68,7 +69,7 @@ export function CodeBlock({
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
- {copied ? : }
+ {copied ? : }
diff --git a/app/components/ui/ColorSchemeDialog.tsx b/app/components/ui/ColorSchemeDialog.tsx
index 674edc57e0..c343bb9275 100644
--- a/app/components/ui/ColorSchemeDialog.tsx
+++ b/app/components/ui/ColorSchemeDialog.tsx
@@ -2,8 +2,13 @@ import React, { useState, useEffect } from 'react';
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from './Dialog';
import { Button } from './Button';
import { IconButton } from './IconButton';
+import { Card, CardContent, CardHeader } from './Card';
+import { Badge } from './Badge';
+import { motion, AnimatePresence } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
import type { DesignScheme } from '~/types/design-scheme';
import { defaultDesignScheme, designFeatures, designFonts, paletteRoles } from '~/types/design-scheme';
+import { Edit, Check, Palette, Type, Wand2, Eye, Copy, RotateCcw, Sparkles, X } from 'lucide-react';
export interface ColorSchemeDialogProps {
designScheme?: DesignScheme;
@@ -23,6 +28,8 @@ export const ColorSchemeDialog: React.FC = ({ setDesignS
const [font, setFont] = useState(designScheme?.font || defaultDesignScheme.font);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeSection, setActiveSection] = useState<'colors' | 'typography' | 'features'>('colors');
+ const [previewMode, setPreviewMode] = useState(false);
+ const [copiedColor, setCopiedColor] = useState(null);
useEffect(() => {
if (designScheme) {
@@ -36,6 +43,16 @@ export const ColorSchemeDialog: React.FC = ({ setDesignS
}
}, [designScheme]);
+ const copyToClipboard = async (text: string, colorKey: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopiedColor(colorKey);
+ setTimeout(() => setCopiedColor(null), 2000);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ };
+
const handleColorChange = (role: string, value: string) => {
setPalette((prev) => ({ ...prev, [role]: value }));
};
@@ -60,319 +77,597 @@ export const ColorSchemeDialog: React.FC = ({ setDesignS
};
const renderColorSection = () => (
-
+
+ {/* Enhanced Header with controls */}
-
-
- Color Palette
-
-
-
- Reset
-
+
+
+
+
Color Palette
+
Customize your design colors
+
+
+
+ setPreviewMode(!previewMode)}
+ className={classNames(
+ 'px-3 py-2 text-xs transition-all',
+ previewMode
+ ? '!bg-purple-500/20 !text-purple-600 border border-purple-500/30'
+ : 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary !bg-transparent',
+ )}
+ >
+
+ Preview
+
+
+
+ Reset
+
+
-
- {paletteRoles.map((role) => (
-
+ {paletteRoles.map((role, index) => (
+
-
-
document.getElementById(`color-input-${role.key}`)?.click()}
- role="button"
- tabIndex={0}
- aria-label={`Change ${role.label} color`}
- />
-
handleColorChange(role.key, e.target.value)}
- className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
- tabIndex={-1}
- />
-
-
-
-
-
-
{role.label}
-
- {role.description}
-
-
- {palette[role.key]}
-
-
-
+
+
+
+ {/* Enhanced Color preview */}
+
+
+ {/* Enhanced Content */}
+
+
+
{role.label}
+
copyToClipboard(palette[role.key], role.key)}
+ className={classNames(
+ 'theme-safe-button p-1.5 rounded-md text-xs',
+ copiedColor === role.key ? '!bg-green-500/20 !text-green-600' : '',
+ )}
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ title="Copy color value"
+ >
+
+ {copiedColor === role.key ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
{role.description}
+
+
+ {palette[role.key]}
+
+
+
+
+
+
))}
-
+
+ {/* Enhanced Color Preview section */}
+
+ {previewMode && (
+
+
+
+
+
+
Color Preview
+
+
+
+
+ {Object.entries(palette)
+ .slice(0, 8)
+ .map(([key, color]) => (
+
+
+
+ {key}
+
+
+ ))}
+
+
+
+
+ )}
+
+
);
const renderTypographySection = () => (
-
-
-
- Typography
-
-
-
- {designFonts.map((f) => (
-
handleFontToggle(f.key)}
- className={`group p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColorActive ${
- font.includes(f.key)
- ? 'bg-bolt-elements-item-backgroundAccent border-bolt-elements-borderColorActive shadow-lg'
- : 'bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive hover:bg-bolt-elements-bg-depth-2'
- }`}
- >
-
-
- {f.preview}
-
-
+
+
+
+
+
+
Typography
+
Choose your font preferences
+
+
+
+
+ {designFonts.map((f, index) => {
+ const isSelected = font.includes(f.key);
+ return (
+
+ handleFontToggle(f.key)}
>
- {f.label}
-
- {font.includes(f.key) && (
-
-
-
- )}
-
-
- ))}
+
+
+
+ {f.preview}
+
+ {isSelected && (
+
+
+
+ )}
+
+
+
+
+ {f.label}
+
+
+ {f.key}
+
+
+
+
+
+ );
+ })}
-
+
+ {/* Typography preview */}
+
+ {font.length > 0 && (
+
+
+
+
+
+
Typography Preview
+
+
+
+
+
+ Heading Text
+
+
+ This is how your body text will appear with the selected typography.
+
+
+
+
+
+ )}
+
+
);
const renderFeaturesSection = () => (
-
-
-
- Design Features
-
-
-
- {designFeatures.map((f) => {
+
+
+
+
+
+
+
Design Features
+
Enable visual enhancements
+
+
+
+
+ {designFeatures.map((f, index) => {
const isSelected = features.includes(f.key);
return (
-
-
+ handleFeatureToggle(f.key)}
- className={`group relative w-full p-6 text-sm font-medium transition-all duration-200 bg-bolt-elements-background-depth-3 text-bolt-elements-item-textSecondary ${
- f.key === 'rounded'
- ? isSelected
- ? 'rounded-3xl'
- : 'rounded-xl'
- : f.key === 'border'
- ? 'rounded-lg'
- : 'rounded-xl'
- } ${
- f.key === 'border'
- ? isSelected
- ? 'border-3 border-bolt-elements-borderColorActive bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent'
- : 'border-2 border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive text-bolt-elements-textSecondary'
- : f.key === 'gradient'
- ? ''
- : isSelected
- ? 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent shadow-lg'
- : 'bg-bolt-elements-bg-depth-3 hover:bg-bolt-elements-bg-depth-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary'
- } ${f.key === 'shadow' ? (isSelected ? 'shadow-xl' : 'shadow-lg') : 'shadow-md'}`}
- style={{
- ...(f.key === 'gradient' && {
- background: isSelected
- ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
- : 'var(--bolt-elements-bg-depth-3)',
- color: isSelected ? 'white' : 'var(--bolt-elements-textSecondary)',
- }),
- }}
>
-
-
+
+ {/* Icon container */}
+
{f.key === 'rounded' && (
)}
{f.key === 'border' && (
)}
{f.key === 'gradient' && (
-
+
)}
{f.key === 'shadow' && (
)}
{f.key === 'frosted-glass' && (
)}
+
+ {/* Selection indicator */}
+ {isSelected && (
+
+
+
+ )}
-
-
{f.label}
- {isSelected &&
}
+ {/* Content */}
+
+
+ {f.label}
+
+
+
+ {f.key === 'rounded' && 'Soft appearance'}
+ {f.key === 'border' && 'Clean lines'}
+ {f.key === 'gradient' && 'Colorful appeal'}
+ {f.key === 'shadow' && 'Depth effect'}
+ {f.key === 'frosted-glass' && 'Glassmorphism'}
+
-
-
-
+
+
+
);
})}
-
+
+ {/* Active features summary */}
+
+ {features.length > 0 && (
+
+
+
+
+
+
Active Features
+
+
+
+
+ {features.map((featureKey) => {
+ const feature = designFeatures.find((f) => f.key === featureKey);
+ return (
+
+ {feature?.label}
+ {
+ e.stopPropagation();
+ handleFeatureToggle(featureKey);
+ }}
+ className="ml-1 hover:bg-purple-600/20 rounded p-0.5"
+ whileHover={{ scale: 1.1 }}
+ whileTap={{ scale: 0.9 }}
+ >
+
+
+
+ );
+ })}
+
+
+
+
+ )}
+
+
);
return (
-
setIsDialogOpen(!isDialogOpen)}>
-
-
+
setIsDialogOpen(!isDialogOpen)}
+ icon={Palette}
+ />
-
-
-
- Design Palette & Features
-
-
- Customize your color palette, typography, and design features. These preferences will guide the AI in
- creating designs that match your style.
-
-
-
- {/* Navigation Tabs */}
-
- {[
- { key: 'colors', label: 'Colors', icon: 'i-ph:palette' },
- { key: 'typography', label: 'Typography', icon: 'i-ph:text-aa' },
- { key: 'features', label: 'Features', icon: 'i-ph:magic-wand' },
- ].map((tab) => (
- setActiveSection(tab.key as any)}
- className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-all duration-200 ${
- activeSection === tab.key
- ? 'bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary shadow-md'
- : 'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-bg-depth-2'
- }`}
- >
-
- {tab.label}
-
- ))}
-
-
- {/* Content Area */}
-
- {activeSection === 'colors' && renderColorSection()}
- {activeSection === 'typography' && renderTypographySection()}
- {activeSection === 'features' && renderFeaturesSection()}
-
-
- {/* Action Buttons */}
-
-
- {Object.keys(palette).length} colors • {font.length} fonts • {features.length} features
+
+
+ {/* Enhanced Header */}
+
+
+
+
+
+ Design System
+
+
+ Customize colors, typography, and features for your design system.
+
+
+
+
+
+ {/* Enhanced Navigation Tabs */}
+
+
+ {[
+ { key: 'colors', label: 'Colors', icon: Palette, count: Object.keys(palette).length },
+ { key: 'typography', label: 'Typography', icon: Type, count: font.length },
+ { key: 'features', label: 'Features', icon: Wand2, count: features.length },
+ ].map((tab) => (
+ setActiveSection(tab.key as any)}
+ className={classNames(
+ 'flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500/50',
+ activeSection === tab.key
+ ? 'bg-purple-500/20 text-purple-600 shadow-md'
+ : 'bg-transparent text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
+ )}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+
+ {tab.label}
+
+ {tab.count}
+
+
+ ))}
+
-
-
setIsDialogOpen(false)}>
- Cancel
-
-
- Save Changes
-
+
+ {/* Enhanced Content Area */}
+
+
+ {activeSection === 'colors' && (
+
+ {renderColorSection()}
+
+ )}
+ {activeSection === 'typography' && (
+
+ {renderTypographySection()}
+
+ )}
+ {activeSection === 'features' && (
+
+ {renderFeaturesSection()}
+
+ )}
+
-
-
+
+ {/* Enhanced Action Buttons */}
+
+
+
+
+ {Object.keys(palette).length} colors
+
+
+ {font.length} fonts
+
+
+ {features.length} features
+
+
+
+ setIsDialogOpen(false)} className="px-4 py-2 text-sm">
+ Cancel
+
+
+
+
+ Apply Changes
+
+
+
+
+
+
+
-
-
);
};
diff --git a/app/components/ui/Dialog.tsx b/app/components/ui/Dialog.tsx
index ed072ddb63..90b27a8154 100644
--- a/app/components/ui/Dialog.tsx
+++ b/app/components/ui/Dialog.tsx
@@ -8,6 +8,7 @@ import { Button } from './Button';
import { FixedSizeList } from 'react-window';
import { Checkbox } from './Checkbox';
import { Label } from './Label';
+import { X } from 'lucide-react';
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
@@ -116,7 +117,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
@@ -208,7 +209,7 @@ export function ConfirmationDialog({
return (
-
+
{title}
{description}
@@ -348,7 +349,7 @@ export function SelectionDialog({
'flex items-start space-x-3 p-2 rounded-md transition-colors',
selectedItems.includes(item.id)
? 'bg-bolt-elements-item-backgroundAccent'
- : 'bg-bolt-elements-bg-depth-2 hover:bg-bolt-elements-item-backgroundActive',
+ : 'bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-item-backgroundActive',
)}
style={{
...style,
@@ -382,7 +383,7 @@ export function SelectionDialog({
return (
-
+
{title}
Select the items you want to include and click{' '}
@@ -398,14 +399,14 @@ export function SelectionDialog({
variant="ghost"
size="sm"
onClick={handleSelectAll}
- className="text-xs h-8 px-2 text-bolt-elements-textPrimary hover:text-bolt-elements-item-contentAccent hover:bg-bolt-elements-item-backgroundAccent bg-bolt-elements-bg-depth-2 dark:bg-transparent"
+ className="text-xs h-8 px-2 text-bolt-elements-textPrimary hover:text-bolt-elements-item-contentAccent hover:bg-bolt-elements-item-backgroundAccent bg-bolt-elements-background-depth-2 dark:bg-transparent"
>
{selectAll ? 'Deselect All' : 'Select All'}
;
/** Title text */
title: string;
@@ -64,7 +65,7 @@ interface EmptyStateProps {
* A component for displaying empty states with optional actions.
*/
export function EmptyState({
- icon = 'i-ph:folder-simple-dashed',
+ icon = Folder,
title,
description,
actionLabel,
@@ -100,13 +101,27 @@ export function EmptyState({
styles.icon.container,
)}
>
-
+ {typeof icon === 'string' ? (
+
+ ) : (
+ (() => {
+ const IconComponent = icon;
+ return (
+
+ );
+ })()
+ )}
{/* Title */}
diff --git a/app/components/ui/FileIcon.tsx b/app/components/ui/FileIcon.tsx
index 00d9671546..53ee81106a 100644
--- a/app/components/ui/FileIcon.tsx
+++ b/app/components/ui/FileIcon.tsx
@@ -1,5 +1,22 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
+import {
+ FileCode,
+ FileText,
+ Braces,
+ File,
+ FileVideo,
+ FileAudio,
+ FileImage,
+ FileArchive,
+ Package,
+ BookOpen,
+ Scale,
+ GitBranch,
+ Lock,
+ FileSpreadsheet,
+ Presentation,
+} from 'lucide-react';
interface FileIconProps {
filename: string;
@@ -12,156 +29,156 @@ export function FileIcon({ filename, size = 'md', className }: FileIconProps) {
return filename.split('.').pop()?.toLowerCase() || '';
};
- const getIconForExtension = (extension: string): string => {
+ const getIconForExtension = (extension: string): React.ComponentType<{ className?: string }> => {
// Code files
if (['js', 'jsx', 'ts', 'tsx'].includes(extension)) {
- return 'i-ph:file-js';
+ return FileCode;
}
if (['html', 'htm', 'xhtml'].includes(extension)) {
- return 'i-ph:file-html';
+ return FileCode;
}
if (['css', 'scss', 'sass', 'less'].includes(extension)) {
- return 'i-ph:file-css';
+ return FileCode;
}
if (['json', 'jsonc'].includes(extension)) {
- return 'i-ph:brackets-curly';
+ return Braces;
}
if (['md', 'markdown'].includes(extension)) {
- return 'i-ph:file-text';
+ return FileText;
}
if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) {
- return 'i-ph:file-py';
+ return FileCode;
}
if (['java', 'class', 'jar'].includes(extension)) {
- return 'i-ph:file-code';
+ return FileCode;
}
if (['php'].includes(extension)) {
- return 'i-ph:file-code';
+ return FileCode;
}
if (['rb', 'ruby'].includes(extension)) {
- return 'i-ph:file-rs';
+ return File;
}
if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) {
- return 'i-ph:file-cpp';
+ return FileCode;
}
if (['go'].includes(extension)) {
- return 'i-ph:file-rs';
+ return File;
}
if (['rs', 'rust'].includes(extension)) {
- return 'i-ph:file-rs';
+ return File;
}
if (['swift'].includes(extension)) {
- return 'i-ph:file-code';
+ return FileCode;
}
if (['kt', 'kotlin'].includes(extension)) {
- return 'i-ph:file-code';
+ return FileCode;
}
if (['dart'].includes(extension)) {
- return 'i-ph:file-code';
+ return FileCode;
}
// Config files
if (['yml', 'yaml'].includes(extension)) {
- return 'i-ph:file-cloud';
+ return FileCode;
}
if (['xml', 'svg'].includes(extension)) {
- return 'i-ph:file-code';
+ return FileCode;
}
if (['toml'].includes(extension)) {
- return 'i-ph:file-text';
+ return FileText;
}
if (['ini', 'conf', 'config'].includes(extension)) {
- return 'i-ph:file-text';
+ return FileText;
}
if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) {
- return 'i-ph:file-lock';
+ return Lock;
}
// Document files
if (['pdf'].includes(extension)) {
- return 'i-ph:file-pdf';
+ return File;
}
if (['doc', 'docx'].includes(extension)) {
- return 'i-ph:file-doc';
+ return FileText;
}
if (['xls', 'xlsx'].includes(extension)) {
- return 'i-ph:file-xls';
+ return FileSpreadsheet;
}
if (['ppt', 'pptx'].includes(extension)) {
- return 'i-ph:file-ppt';
+ return Presentation;
}
if (['txt'].includes(extension)) {
- return 'i-ph:file-text';
+ return FileText;
}
// Image files
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) {
- return 'i-ph:file-image';
+ return FileImage;
}
// Audio/Video files
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) {
- return 'i-ph:file-audio';
+ return FileAudio;
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) {
- return 'i-ph:file-video';
+ return FileVideo;
}
// Archive files
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
- return 'i-ph:file-zip';
+ return FileArchive;
}
// Special files
if (filename === 'package.json') {
- return 'i-ph:package';
+ return Package;
}
if (filename === 'tsconfig.json') {
- return 'i-ph:file-ts';
+ return FileCode;
}
if (filename === 'README.md') {
- return 'i-ph:book-open';
+ return BookOpen;
}
if (filename === 'LICENSE') {
- return 'i-ph:scales';
+ return Scale;
}
if (filename === '.gitignore') {
- return 'i-ph:git-branch';
+ return GitBranch;
}
if (filename.startsWith('Dockerfile')) {
- return 'i-ph:file-code';
+ return FileCode;
}
// Default
- return 'i-ph:file';
+ return File;
};
const getIconColorForExtension = (extension: string): string => {
@@ -342,5 +359,7 @@ export function FileIcon({ filename, size = 'md', className }: FileIconProps) {
const color = getIconColorForExtension(extension);
const sizeClass = getSizeClass(size);
- return
;
+ const IconComponent = icon;
+
+ return
;
}
diff --git a/app/components/ui/FilterChip.tsx b/app/components/ui/FilterChip.tsx
index 705cec20fa..505dada60a 100644
--- a/app/components/ui/FilterChip.tsx
+++ b/app/components/ui/FilterChip.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
+import { X } from 'lucide-react';
interface FilterChipProps {
/** The label text to display */
@@ -84,7 +85,7 @@ export function FilterChip({ label, value, onRemove, active = false, icon, class
)}
aria-label={`Remove ${label} filter`}
>
-
+
)}
diff --git a/app/components/ui/HoverCard.tsx b/app/components/ui/HoverCard.tsx
new file mode 100644
index 0000000000..029091791d
--- /dev/null
+++ b/app/components/ui/HoverCard.tsx
@@ -0,0 +1,26 @@
+import * as React from 'react';
+import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
+import { classNames } from '~/utils/classNames';
+
+const HoverCard = HoverCardPrimitive.Root;
+
+const HoverCardTrigger = HoverCardPrimitive.Trigger;
+
+const HoverCardContent = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
+
+));
+HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
+
+export { HoverCard, HoverCardTrigger, HoverCardContent };
diff --git a/app/components/ui/IconButton.tsx b/app/components/ui/IconButton.tsx
index cda32e25ac..9c91641c37 100644
--- a/app/components/ui/IconButton.tsx
+++ b/app/components/ui/IconButton.tsx
@@ -1,3 +1,4 @@
+import * as React from 'react';
import { memo, forwardRef, type ForwardedRef } from 'react';
import { classNames } from '~/utils/classNames';
@@ -14,7 +15,7 @@ interface BaseIconButtonProps {
}
type IconButtonWithoutChildrenProps = {
- icon: string;
+ icon: string | React.ComponentType<{ className?: string }>;
children?: undefined;
} & BaseIconButtonProps;
@@ -31,7 +32,7 @@ export const IconButton = memo(
(
{
icon,
- size = 'xl',
+ size = 'lg',
className,
iconClassName,
disabledClassName,
@@ -46,7 +47,7 @@ export const IconButton = memo(
- {children ? children :
}
+ {children ? (
+ children
+ ) : typeof icon === 'string' ? (
+
+ ) : icon ? (
+ (() => {
+ const IconComponent = icon;
+ return ;
+ })()
+ ) : null}
);
},
@@ -77,8 +87,8 @@ function getIconSize(size: IconSize) {
} else if (size === 'lg') {
return 'text-lg';
} else if (size === 'xl') {
- return 'text-xl';
+ return 'text-lg';
} else {
- return 'text-2xl';
+ return 'text-xl';
}
}
diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx
index 64762502d1..1fc425163a 100644
--- a/app/components/ui/Input.tsx
+++ b/app/components/ui/Input.tsx
@@ -8,7 +8,7 @@ const Input = forwardRef(({ className, type, ...pr
) => (
-
- {trigger}
-
-
-
- {children}
-
-
-
-
-);
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent };
diff --git a/app/components/ui/PromptInfoCard.tsx b/app/components/ui/PromptInfoCard.tsx
new file mode 100644
index 0000000000..f6eafa783d
--- /dev/null
+++ b/app/components/ui/PromptInfoCard.tsx
@@ -0,0 +1,211 @@
+import React, { memo } from 'react';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { Card, CardContent } from '~/components/ui/Card';
+import { Badge } from '~/components/ui/Badge';
+import { Feather, Scale, Rocket, CheckCircle, Settings, Brain, Check } from 'lucide-react';
+
+export interface PromptInfo {
+ id: string;
+ label: string;
+ description: string;
+ icon: React.ComponentType<{ className?: string }>;
+ features: string[];
+ bestFor: string[];
+ tokenUsage: 'low' | 'medium' | 'high';
+ complexity: 'simple' | 'moderate' | 'advanced';
+ recommended?: boolean;
+ isCustom?: boolean;
+}
+
+interface PromptInfoCardProps {
+ prompt: PromptInfo;
+ selected?: boolean;
+ onSelect?: (promptId: string) => void;
+ className?: string;
+}
+
+const tokenUsageConfig = {
+ low: {
+ label: 'Lightweight',
+ color: 'success',
+ icon: Feather,
+ description: 'Optimized for speed and minimal token usage',
+ },
+ medium: {
+ label: 'Balanced',
+ color: 'info',
+ icon: Scale,
+ description: 'Good balance of features and performance',
+ },
+ high: {
+ label: 'Full-Featured',
+ color: 'warning',
+ icon: Rocket,
+ description: 'Complete feature set with advanced capabilities',
+ },
+} as const;
+
+const complexityConfig = {
+ simple: {
+ label: 'Simple',
+ color: 'success',
+ icon: CheckCircle,
+ },
+ moderate: {
+ label: 'Moderate',
+ color: 'info',
+ icon: Settings,
+ },
+ advanced: {
+ label: 'Advanced',
+ color: 'primary',
+ icon: Brain,
+ },
+} as const;
+
+export const PromptInfoCard = memo(({ prompt, selected = false, onSelect, className }: PromptInfoCardProps) => {
+ const tokenConfig = tokenUsageConfig[prompt.tokenUsage];
+ const complexConfig = complexityConfig[prompt.complexity];
+
+ return (
+
+ onSelect?.(prompt.id)}
+ >
+ {prompt.recommended && (
+
+
+ Recommended
+
+
+ )}
+
+ {prompt.isCustom && (
+
+
+ Custom
+
+
+ )}
+
+
+ {/* Header with Icon */}
+
+
+
+
+
+ {prompt.label}
+
+
+
+
+ {/* Description */}
+
+ {prompt.description}
+
+
+ {/* Badges */}
+
+
+
+ {tokenConfig.label}
+
+
+
+ {complexConfig.label}
+
+
+
+ {/* Features */}
+
+
+ Key Features
+
+
+ {prompt.features.slice(0, 4).map((feature, index) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+ {/* Best For */}
+
+
+ Best For
+
+
+ {prompt.bestFor.slice(0, 3).map((item, index) => (
+
+ {item}
+
+ ))}
+ {prompt.bestFor.length > 3 && (
+
+ +{prompt.bestFor.length - 3} more
+
+ )}
+
+
+
+ {/* Selection Indicator */}
+ {selected && (
+
+
+
+ )}
+
+
+
+ );
+});
+
+PromptInfoCard.displayName = 'PromptInfoCard';
diff --git a/app/components/ui/PromptSelector.tsx b/app/components/ui/PromptSelector.tsx
new file mode 100644
index 0000000000..ea17621b7b
--- /dev/null
+++ b/app/components/ui/PromptSelector.tsx
@@ -0,0 +1,106 @@
+import React, { memo } from 'react';
+import { motion } from 'framer-motion';
+import { PromptLibrary } from '~/lib/common/prompt-library';
+import { PromptInfoCard } from './PromptInfoCard';
+import { classNames } from '~/utils/classNames';
+import { Lightbulb } from 'lucide-react';
+
+interface PromptSelectorProps {
+ selectedPromptId?: string;
+ onPromptSelect?: (promptId: string) => void;
+ className?: string;
+ title?: string;
+ description?: string;
+ layout?: 'grid' | 'list';
+ showTitle?: boolean;
+}
+
+export const PromptSelector = memo(
+ ({
+ selectedPromptId,
+ onPromptSelect,
+ className,
+ title = 'Choose Your AI Assistant',
+ description = 'Select the prompt template that best matches your workflow and project requirements',
+ layout = 'grid',
+ showTitle = true,
+ }: PromptSelectorProps) => {
+ const prompts = PromptLibrary.getInfoList();
+
+ return (
+
+ {showTitle && (
+
+
{title}
+
{description}
+
+ )}
+
+
+ {prompts.map((prompt, index) => (
+
+
+
+ ))}
+
+
+ {/* Additional Info */}
+
+
+
+
+
+
+
💡 Pro Tips for Prompt Selection
+
+
+ • New to Bolt? Start with "Full-Featured Coding" for the complete experience
+
+
+ • Planning a project? Use "Planning & Discussion" to explore ideas first
+
+
+ • Quick fixes? Choose "Lightweight & Fast" for speedy responses
+
+ • You can switch between prompts anytime in Settings → Features
+
+
+
+
+
+ );
+ },
+);
+
+PromptSelector.displayName = 'PromptSelector';
diff --git a/app/components/ui/RadioGroup.tsx b/app/components/ui/RadioGroup.tsx
new file mode 100644
index 0000000000..ef3a264445
--- /dev/null
+++ b/app/components/ui/RadioGroup.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react';
+import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
+import { Circle } from 'lucide-react';
+import { classNames } from '~/utils/classNames';
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return ;
+});
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ );
+});
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
+
+export { RadioGroup, RadioGroupItem };
diff --git a/app/components/ui/RepositoryStats.tsx b/app/components/ui/RepositoryStats.tsx
index 98d2bf6afc..977e67690e 100644
--- a/app/components/ui/RepositoryStats.tsx
+++ b/app/components/ui/RepositoryStats.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Badge } from './Badge';
import { classNames } from '~/utils/classNames';
import { formatSize } from '~/utils/formatSize';
+import { FileText, Database, Code, Package, FolderTree } from 'lucide-react';
interface RepositoryStatsProps {
stats: {
@@ -29,14 +30,14 @@ export function RepositoryStats({ stats, className, compact = false }: Repositor
{totalFiles !== undefined && (
-
+
Total Files: {totalFiles.toLocaleString()}
)}
{totalSize !== undefined && (
-
+
Total Size: {formatSize(totalSize)}
)}
@@ -45,7 +46,7 @@ export function RepositoryStats({ stats, className, compact = false }: Repositor
{languages && Object.keys(languages).length > 0 && (
-
+
Languages:
@@ -70,12 +71,12 @@ export function RepositoryStats({ stats, className, compact = false }: Repositor
{hasPackageJson && (
-
+
package.json
)}
{hasDependencies && (
-
+
Dependencies
)}
diff --git a/app/components/ui/SearchInput.tsx b/app/components/ui/SearchInput.tsx
index c3209218f5..df78f66fc9 100644
--- a/app/components/ui/SearchInput.tsx
+++ b/app/components/ui/SearchInput.tsx
@@ -2,6 +2,7 @@ import React, { forwardRef } from 'react';
import { classNames } from '~/utils/classNames';
import { Input } from './Input';
import { motion, AnimatePresence } from 'framer-motion';
+import { Loader2, Search, X } from 'lucide-react';
interface SearchInputProps extends React.InputHTMLAttributes {
/** Function to call when the clear button is clicked */
@@ -41,11 +42,7 @@ export const SearchInput = forwardRef(
iconClassName,
)}
>
- {loading ? (
-
- ) : (
-
- )}
+ {loading ? : }
{/* Input field */}
@@ -68,7 +65,7 @@ export const SearchInput = forwardRef
(
className="absolute right-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary p-1 rounded-full hover:bg-bolt-elements-background-depth-2"
aria-label="Clear search"
>
-
+
)}
diff --git a/app/components/ui/Select.tsx b/app/components/ui/Select.tsx
new file mode 100644
index 0000000000..ee4de42bde
--- /dev/null
+++ b/app/components/ui/Select.tsx
@@ -0,0 +1,152 @@
+import * as React from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { Check, ChevronDown, ChevronUp } from 'lucide-react';
+import { classNames } from '~/utils/classNames';
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = 'popper', ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/app/components/ui/SettingsButton.tsx b/app/components/ui/SettingsButton.tsx
index 4438f87aa5..33da74e247 100644
--- a/app/components/ui/SettingsButton.tsx
+++ b/app/components/ui/SettingsButton.tsx
@@ -1,5 +1,6 @@
import { memo } from 'react';
import { IconButton } from '~/components/ui/IconButton';
+import { Settings } from 'lucide-react';
interface SettingsButtonProps {
onClick: () => void;
}
@@ -8,7 +9,7 @@ export const SettingsButton = memo(({ onClick }: SettingsButtonProps) => {
return (
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ 'fixed z-50 gap-4 bg-bolt-elements-background-depth-1 p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
+ {
+ variants: {
+ side: {
+ top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
+ bottom:
+ 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
+ left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
+ right:
+ 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
+ },
+ },
+ defaultVariants: {
+ side: 'right',
+ },
+ },
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef, SheetContentProps>(
+ ({ side = 'right', className, children, ...props }, ref) => (
+
+
+
+ {children}
+