+ {/* Quick Stats Banner */}
+
+ {/* Errors Card */}
+
+
+
+ 0 ? 'text-red-500' : 'text-green-500')}
+ >
+ {errorLogs.length}
+
+
+
+
0 ? 'i-ph:warning text-red-500' : 'i-ph:check-circle text-green-500',
+ )}
+ />
+ {errorLogs.length > 0 ? 'Errors detected' : 'No errors detected'}
+
+
+
+ {/* Memory Usage Card */}
+
+
+
+ 80
+ ? 'text-red-500'
+ : (systemInfo?.memory?.percentage ?? 0) > 60
+ ? 'text-yellow-500'
+ : 'text-green-500',
+ )}
+ >
+ {systemInfo?.memory?.percentage ?? 0}%
+
+
+
80
+ ? '[&>div]:bg-red-500'
+ : (systemInfo?.memory?.percentage ?? 0) > 60
+ ? '[&>div]:bg-yellow-500'
+ : '[&>div]:bg-green-500',
+ )}
+ />
+
+
+ Used: {systemInfo?.memory.used ?? '0 GB'} / {systemInfo?.memory.total ?? '0 GB'}
+
+
+
+ {/* Page Load Time Card */}
+
+
+
+ 2000
+ ? 'text-red-500'
+ : (systemInfo?.performance.timing.loadTime ?? 0) > 1000
+ ? 'text-yellow-500'
+ : 'text-green-500',
+ )}
+ >
+ {systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) : '-'}s
+
+
+
+
+ DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
+
+
+
+ {/* Network Speed Card */}
+
+
+
+
+ {systemInfo?.network.downlink ?? '-'} Mbps
+
+
+
+
+ RTT: {systemInfo?.network.rtt ?? '-'} ms
+
+
+
+ {/* Ollama Service Card - Now spans all 4 columns */}
+
+
+
+
+
+
Ollama Service
+
{status.message}
+
+
+
+
+
+
+ {status.status}
+
+
+
+
+ {ollamaStatus.lastChecked.toLocaleTimeString()}
+
+
+
+
+
+ {status.status === 'Running' && ollamaStatus.models && ollamaStatus.models.length > 0 ? (
+ <>
+
+
+
+
Installed Models
+
+ {ollamaStatus.models.length}
+
+
+
+
+
+ {ollamaStatus.models.map((model) => (
+
+
+
+ {Math.round(parseInt(model.size) / 1024 / 1024)}MB
+
+
+ ))}
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+ {/* Action Buttons */}
+
+
+ {loading.systemInfo ? (
+
+ ) : (
+
+ )}
+ Update System Info
+
+
+
+ {loading.performance ? (
+
+ ) : (
+
+ )}
+ Log Performance
+
+
+
+ {loading.errors ? (
+
+ ) : (
+
+ )}
+ Check Errors
+
+
+
+ {loading.webAppInfo ? (
+
+ ) : (
+
+ )}
+ Fetch WebApp Info
+
+
+
+
+
+ {/* System Information */}
+
setOpenSections((prev) => ({ ...prev, system: open }))}
+ className="w-full"
+ >
+
+
+
+
+
+
+ {systemInfo ? (
+
+
+
+
+
OS:
+
{systemInfo.os}
+
+
+
+
Platform:
+
{systemInfo.platform}
+
+
+
+
Architecture:
+
{systemInfo.arch}
+
+
+
+
CPU Cores:
+
{systemInfo.cpus}
+
+
+
+
Node Version:
+
{systemInfo.node}
+
+
+
+
Network Type:
+
+ {systemInfo.network.type} ({systemInfo.network.effectiveType})
+
+
+
+
+
Network Speed:
+
+ {systemInfo.network.downlink}Mbps (RTT: {systemInfo.network.rtt}ms)
+
+
+ {systemInfo.battery && (
+
+
+
Battery:
+
+ {systemInfo.battery.level.toFixed(1)}% {systemInfo.battery.charging ? '(Charging)' : ''}
+
+
+ )}
+
+
+
Storage:
+
+ {(systemInfo.storage.usage / (1024 * 1024 * 1024)).toFixed(2)}GB /{' '}
+ {(systemInfo.storage.quota / (1024 * 1024 * 1024)).toFixed(2)}GB
+
+
+
+
+
+
+
Memory Usage:
+
+ {systemInfo.memory.used} / {systemInfo.memory.total} ({systemInfo.memory.percentage}%)
+
+
+
+
+
Browser:
+
+ {systemInfo.browser.name} {systemInfo.browser.version}
+
+
+
+
+
Screen:
+
+ {systemInfo.screen.width}x{systemInfo.screen.height} ({systemInfo.screen.pixelRatio}x)
+
+
+
+
+
Timezone:
+
{systemInfo.time.timezone}
+
+
+
+
Language:
+
{systemInfo.browser.language}
+
+
+
+
JS Heap:
+
+ {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
+ {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB (
+ {systemInfo.performance.memory.usagePercentage.toFixed(1)}%)
+
+
+
+
+
Page Load:
+
+ {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
+
+
+
+
+
DOM Ready:
+
+ {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
+
+
+
+
+ ) : (
+
Loading system information...
+ )}
+
+
+
+
+ {/* Performance Metrics */}
+
setOpenSections((prev) => ({ ...prev, performance: open }))}
+ className="w-full"
+ >
+
+
+
+
+
Performance Metrics
+
+
+
+
+
+
+
+ {systemInfo && (
+
+
+
+ Page Load Time:
+
+ {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
+
+
+
+ DOM Ready Time:
+
+ {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
+
+
+
+ Request Time:
+
+ {(systemInfo.performance.timing.requestTime / 1000).toFixed(2)}s
+
+
+
+ Redirect Time:
+
+ {(systemInfo.performance.timing.redirectTime / 1000).toFixed(2)}s
+
+
+
+
+
+ JS Heap Usage:
+
+ {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
+ {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB
+
+
+
+ Heap Utilization:
+
+ {systemInfo.performance.memory.usagePercentage.toFixed(1)}%
+
+
+
+ Navigation Type:
+
+ {systemInfo.performance.navigation.type === 0
+ ? 'Navigate'
+ : systemInfo.performance.navigation.type === 1
+ ? 'Reload'
+ : systemInfo.performance.navigation.type === 2
+ ? 'Back/Forward'
+ : 'Other'}
+
+
+
+ Redirects:
+
+ {systemInfo.performance.navigation.redirectCount}
+
+
+
+
+ )}
+
+
+
+
+ {/* WebApp Information */}
+
setOpenSections((prev) => ({ ...prev, webapp: open }))}
+ className="w-full"
+ >
+
+
+
+
+
WebApp Information
+ {loading.webAppInfo &&
}
+
+
+
+
+
+
+
+ {loading.webAppInfo ? (
+
+
+
+ ) : !webAppInfo ? (
+
+
+
Failed to load WebApp information
+
getWebAppInfo()}
+ className="mt-4 px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
+ >
+ Retry
+
+
+ ) : (
+
+
+
Basic Information
+
+
+
+
Name:
+
{webAppInfo.name}
+
+
+
+
Version:
+
{webAppInfo.version}
+
+
+
+
License:
+
{webAppInfo.license}
+
+
+
+
Environment:
+
{webAppInfo.environment}
+
+
+
+
Node Version:
+
{webAppInfo.runtimeInfo.nodeVersion}
+
+
+
+
+
+
Git Information
+
+
+
+
Branch:
+
{webAppInfo.gitInfo.local.branch}
+
+
+
+
Commit:
+
{webAppInfo.gitInfo.local.commitHash}
+
+
+
+
Author:
+
{webAppInfo.gitInfo.local.author}
+
+
+
+
Commit Time:
+
{webAppInfo.gitInfo.local.commitTime}
+
+
+ {webAppInfo.gitInfo.github && (
+ <>
+
+
+
+
Repository:
+
+ {webAppInfo.gitInfo.github.currentRepo.fullName}
+ {webAppInfo.gitInfo.isForked && ' (fork)'}
+
+
+
+
+
+
+
+ {webAppInfo.gitInfo.github.currentRepo.stars}
+
+
+
+
+
+ {webAppInfo.gitInfo.github.currentRepo.forks}
+
+
+
+
+
+ {webAppInfo.gitInfo.github.currentRepo.openIssues}
+
+
+
+
+
+ {webAppInfo.gitInfo.github.upstream && (
+
+
+
+
Upstream:
+
+ {webAppInfo.gitInfo.github.upstream.fullName}
+
+
+
+
+
+
+
+ {webAppInfo.gitInfo.github.upstream.stars}
+
+
+
+
+
+ {webAppInfo.gitInfo.github.upstream.forks}
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+ )}
+
+ {webAppInfo && (
+
+
Dependencies
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Error Check */}
+
setOpenSections((prev) => ({ ...prev, errors: open }))}
+ className="w-full"
+ >
+
+
+
+
+
Error Check
+ {errorLogs.length > 0 && (
+
+ {errorLogs.length} Errors
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Checks for:
+
+ Unhandled JavaScript errors
+ Unhandled Promise rejections
+ Runtime exceptions
+ Network errors
+
+
+
+ Status:
+
+ {loading.errors
+ ? 'Checking...'
+ : errorLogs.length > 0
+ ? `${errorLogs.length} errors found`
+ : 'No errors found'}
+
+
+ {errorLogs.length > 0 && (
+
+
Recent Errors:
+
+ {errorLogs.map((error) => (
+
+
{error.message}
+ {error.source && (
+
+ Source: {error.source}
+ {error.details?.lineNumber && `:${error.details.lineNumber}`}
+
+ )}
+ {error.stack && (
+
{error.stack}
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/event-logs/EventLogsTab.tsx b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8d28c26ebe60bc1febf8b1d68cf94479c9b7520a
--- /dev/null
+++ b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx
@@ -0,0 +1,1013 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { motion } from 'framer-motion';
+import { Switch } from '~/components/ui/Switch';
+import { logStore, type LogEntry } from '~/lib/stores/logs';
+import { useStore } from '@nanostores/react';
+import { classNames } from '~/utils/classNames';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
+import { jsPDF } from 'jspdf';
+import { toast } from 'react-toastify';
+
+interface SelectOption {
+ value: string;
+ label: string;
+ icon?: string;
+ color?: string;
+}
+
+const logLevelOptions: SelectOption[] = [
+ {
+ value: 'all',
+ label: 'All Types',
+ icon: 'i-ph:funnel',
+ color: '#9333ea',
+ },
+ {
+ value: 'provider',
+ label: 'LLM',
+ icon: 'i-ph:robot',
+ color: '#10b981',
+ },
+ {
+ value: 'api',
+ label: 'API',
+ icon: 'i-ph:cloud',
+ color: '#3b82f6',
+ },
+ {
+ value: 'error',
+ label: 'Errors',
+ icon: 'i-ph:warning-circle',
+ color: '#ef4444',
+ },
+ {
+ value: 'warning',
+ label: 'Warnings',
+ icon: 'i-ph:warning',
+ color: '#f59e0b',
+ },
+ {
+ value: 'info',
+ label: 'Info',
+ icon: 'i-ph:info',
+ color: '#3b82f6',
+ },
+ {
+ value: 'debug',
+ label: 'Debug',
+ icon: 'i-ph:bug',
+ color: '#6b7280',
+ },
+];
+
+interface LogEntryItemProps {
+ log: LogEntry;
+ isExpanded: boolean;
+ use24Hour: boolean;
+ showTimestamp: boolean;
+}
+
+const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => {
+ const [localExpanded, setLocalExpanded] = useState(forceExpanded);
+
+ useEffect(() => {
+ setLocalExpanded(forceExpanded);
+ }, [forceExpanded]);
+
+ const timestamp = useMemo(() => {
+ const date = new Date(log.timestamp);
+ return date.toLocaleTimeString('en-US', { hour12: !use24Hour });
+ }, [log.timestamp, use24Hour]);
+
+ const style = useMemo(() => {
+ if (log.category === 'provider') {
+ return {
+ icon: 'i-ph:robot',
+ color: 'text-emerald-500 dark:text-emerald-400',
+ bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20',
+ badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10',
+ };
+ }
+
+ if (log.category === 'api') {
+ return {
+ icon: 'i-ph:cloud',
+ color: 'text-blue-500 dark:text-blue-400',
+ bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
+ badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
+ };
+ }
+
+ switch (log.level) {
+ case 'error':
+ return {
+ icon: 'i-ph:warning-circle',
+ color: 'text-red-500 dark:text-red-400',
+ bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
+ badge: 'text-red-500 bg-red-50 dark:bg-red-500/10',
+ };
+ case 'warning':
+ return {
+ icon: 'i-ph:warning',
+ color: 'text-yellow-500 dark:text-yellow-400',
+ bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
+ badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10',
+ };
+ case 'debug':
+ return {
+ icon: 'i-ph:bug',
+ color: 'text-gray-500 dark:text-gray-400',
+ bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
+ badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10',
+ };
+ default:
+ return {
+ icon: 'i-ph:info',
+ color: 'text-blue-500 dark:text-blue-400',
+ bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
+ badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
+ };
+ }
+ }, [log.level, log.category]);
+
+ const renderDetails = (details: any) => {
+ if (log.category === 'provider') {
+ return (
+
+
+ Model: {details.model}
+ •
+ Tokens: {details.totalTokens}
+ •
+ Duration: {details.duration}ms
+
+ {details.prompt && (
+
+
Prompt:
+
+ {details.prompt}
+
+
+ )}
+ {details.response && (
+
+
Response:
+
+ {details.response}
+
+
+ )}
+
+ );
+ }
+
+ if (log.category === 'api') {
+ return (
+
+
+ {details.method}
+ •
+ Status: {details.statusCode}
+ •
+ Duration: {details.duration}ms
+
+
{details.url}
+ {details.request && (
+
+
Request:
+
+ {JSON.stringify(details.request, null, 2)}
+
+
+ )}
+ {details.response && (
+
+
Response:
+
+ {JSON.stringify(details.response, null, 2)}
+
+
+ )}
+ {details.error && (
+
+
Error:
+
+ {JSON.stringify(details.error, null, 2)}
+
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {JSON.stringify(details, null, 2)}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
{log.message}
+ {log.details && (
+ <>
+
setLocalExpanded(!localExpanded)}
+ className="text-xs text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
+ >
+ {localExpanded ? 'Hide' : 'Show'} Details
+
+ {localExpanded && renderDetails(log.details)}
+ >
+ )}
+
+
+ {log.level}
+
+ {log.category && (
+
+ {log.category}
+
+ )}
+
+
+
+ {showTimestamp &&
{timestamp} }
+
+
+ );
+};
+
+interface ExportFormat {
+ id: string;
+ label: string;
+ icon: string;
+ handler: () => void;
+}
+
+export function EventLogsTab() {
+ const logs = useStore(logStore.logs);
+ const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [use24Hour, setUse24Hour] = useState(false);
+ const [autoExpand, setAutoExpand] = useState(false);
+ const [showTimestamps, setShowTimestamps] = useState(true);
+ const [showLevelFilter, setShowLevelFilter] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const levelFilterRef = useRef
(null);
+
+ const filteredLogs = useMemo(() => {
+ const allLogs = Object.values(logs);
+
+ if (selectedLevel === 'all') {
+ return allLogs.filter((log) =>
+ searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true,
+ );
+ }
+
+ return allLogs.filter((log) => {
+ const matchesType = log.category === selectedLevel || log.level === selectedLevel;
+ const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true;
+
+ return matchesType && matchesSearch;
+ });
+ }, [logs, selectedLevel, searchQuery]);
+
+ // Add performance tracking on mount
+ useEffect(() => {
+ const startTime = performance.now();
+
+ logStore.logInfo('Event Logs tab mounted', {
+ type: 'component_mount',
+ message: 'Event Logs tab component mounted',
+ component: 'EventLogsTab',
+ });
+
+ return () => {
+ const duration = performance.now() - startTime;
+ logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration);
+ };
+ }, []);
+
+ // Log filter changes
+ const handleLevelFilterChange = useCallback(
+ (newLevel: string) => {
+ logStore.logInfo('Log level filter changed', {
+ type: 'filter_change',
+ message: `Log level filter changed from ${selectedLevel} to ${newLevel}`,
+ component: 'EventLogsTab',
+ previousLevel: selectedLevel,
+ newLevel,
+ });
+ setSelectedLevel(newLevel as string);
+ setShowLevelFilter(false);
+ },
+ [selectedLevel],
+ );
+
+ // Log search changes with debounce
+ useEffect(() => {
+ const timeoutId = setTimeout(() => {
+ if (searchQuery) {
+ logStore.logInfo('Log search performed', {
+ type: 'search',
+ message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`,
+ component: 'EventLogsTab',
+ query: searchQuery,
+ resultsCount: filteredLogs.length,
+ });
+ }
+ }, 1000);
+
+ return () => clearTimeout(timeoutId);
+ }, [searchQuery, filteredLogs.length]);
+
+ // Enhanced refresh handler
+ const handleRefresh = useCallback(async () => {
+ const startTime = performance.now();
+ setIsRefreshing(true);
+
+ try {
+ await logStore.refreshLogs();
+
+ const duration = performance.now() - startTime;
+
+ logStore.logSuccess('Logs refreshed successfully', {
+ type: 'refresh',
+ message: `Successfully refreshed ${Object.keys(logs).length} logs`,
+ component: 'EventLogsTab',
+ duration,
+ logsCount: Object.keys(logs).length,
+ });
+ } catch (error) {
+ logStore.logError('Failed to refresh logs', error, {
+ type: 'refresh_error',
+ message: 'Failed to refresh logs',
+ component: 'EventLogsTab',
+ });
+ } finally {
+ setTimeout(() => setIsRefreshing(false), 500);
+ }
+ }, [logs]);
+
+ // Log preference changes
+ const handlePreferenceChange = useCallback((type: string, value: boolean) => {
+ logStore.logInfo('Log preference changed', {
+ type: 'preference_change',
+ message: `Log preference "${type}" changed to ${value}`,
+ component: 'EventLogsTab',
+ preference: type,
+ value,
+ });
+
+ switch (type) {
+ case 'timestamps':
+ setShowTimestamps(value);
+ break;
+ case '24hour':
+ setUse24Hour(value);
+ break;
+ case 'autoExpand':
+ setAutoExpand(value);
+ break;
+ }
+ }, []);
+
+ // Close filters when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) {
+ setShowLevelFilter(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel);
+
+ // Export functions
+ const exportAsJSON = () => {
+ try {
+ const exportData = {
+ timestamp: new Date().toISOString(),
+ logs: filteredLogs,
+ filters: {
+ level: selectedLevel,
+ searchQuery,
+ },
+ preferences: {
+ use24Hour,
+ showTimestamps,
+ autoExpand,
+ },
+ };
+
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `bolt-event-logs-${new Date().toISOString()}.json`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ toast.success('Event logs exported successfully as JSON');
+ } catch (error) {
+ console.error('Failed to export JSON:', error);
+ toast.error('Failed to export event logs as JSON');
+ }
+ };
+
+ const exportAsCSV = () => {
+ try {
+ // Convert logs to CSV format
+ const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Details'];
+ const csvData = [
+ headers,
+ ...filteredLogs.map((log) => [
+ new Date(log.timestamp).toISOString(),
+ log.level,
+ log.category || '',
+ log.message,
+ log.details ? JSON.stringify(log.details) : '',
+ ]),
+ ];
+
+ const csvContent = csvData
+ .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
+ .join('\n');
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `bolt-event-logs-${new Date().toISOString()}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ toast.success('Event logs exported successfully as CSV');
+ } catch (error) {
+ console.error('Failed to export CSV:', error);
+ toast.error('Failed to export event logs as CSV');
+ }
+ };
+
+ const exportAsPDF = () => {
+ try {
+ // Create new PDF document
+ const doc = new jsPDF();
+ const lineHeight = 7;
+ let yPos = 20;
+ const margin = 20;
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const maxLineWidth = pageWidth - 2 * margin;
+
+ // Helper function to add section header
+ const addSectionHeader = (title: string) => {
+ // Check if we need a new page
+ if (yPos > doc.internal.pageSize.getHeight() - 30) {
+ doc.addPage();
+ yPos = margin;
+ }
+
+ doc.setFillColor('#F3F4F6');
+ doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor('#111827');
+ doc.setFontSize(12);
+ doc.text(title.toUpperCase(), margin, yPos);
+ yPos += lineHeight * 2;
+ };
+
+ // Add title and header
+ doc.setFillColor('#6366F1');
+ doc.rect(0, 0, pageWidth, 50, 'F');
+ doc.setTextColor('#FFFFFF');
+ doc.setFontSize(24);
+ doc.setFont('helvetica', 'bold');
+ doc.text('Event Logs Report', margin, 35);
+
+ // Add subtitle with bolt.diy
+ doc.setFontSize(12);
+ doc.setFont('helvetica', 'normal');
+ doc.text('bolt.diy - AI Development Platform', margin, 45);
+ yPos = 70;
+
+ // Add report summary section
+ addSectionHeader('Report Summary');
+
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor('#374151');
+
+ const summaryItems = [
+ { label: 'Generated', value: new Date().toLocaleString() },
+ { label: 'Total Logs', value: filteredLogs.length.toString() },
+ { label: 'Filter Applied', value: selectedLevel === 'all' ? 'All Types' : selectedLevel },
+ { label: 'Search Query', value: searchQuery || 'None' },
+ { label: 'Time Format', value: use24Hour ? '24-hour' : '12-hour' },
+ ];
+
+ summaryItems.forEach((item) => {
+ doc.setFont('helvetica', 'bold');
+ doc.text(`${item.label}:`, margin, yPos);
+ doc.setFont('helvetica', 'normal');
+ doc.text(item.value, margin + 60, yPos);
+ yPos += lineHeight;
+ });
+
+ yPos += lineHeight * 2;
+
+ // Add statistics section
+ addSectionHeader('Log Statistics');
+
+ // Calculate statistics
+ const stats = {
+ error: filteredLogs.filter((log) => log.level === 'error').length,
+ warning: filteredLogs.filter((log) => log.level === 'warning').length,
+ info: filteredLogs.filter((log) => log.level === 'info').length,
+ debug: filteredLogs.filter((log) => log.level === 'debug').length,
+ provider: filteredLogs.filter((log) => log.category === 'provider').length,
+ api: filteredLogs.filter((log) => log.category === 'api').length,
+ };
+
+ // Create two columns for statistics
+ const leftStats = [
+ { label: 'Error Logs', value: stats.error, color: '#DC2626' },
+ { label: 'Warning Logs', value: stats.warning, color: '#F59E0B' },
+ { label: 'Info Logs', value: stats.info, color: '#3B82F6' },
+ ];
+
+ const rightStats = [
+ { label: 'Debug Logs', value: stats.debug, color: '#6B7280' },
+ { label: 'LLM Logs', value: stats.provider, color: '#10B981' },
+ { label: 'API Logs', value: stats.api, color: '#3B82F6' },
+ ];
+
+ const colWidth = (pageWidth - 2 * margin) / 2;
+
+ // Draw statistics in two columns
+ leftStats.forEach((stat, index) => {
+ doc.setTextColor(stat.color);
+ doc.setFont('helvetica', 'bold');
+ doc.text(stat.value.toString(), margin, yPos);
+ doc.setTextColor('#374151');
+ doc.setFont('helvetica', 'normal');
+ doc.text(stat.label, margin + 20, yPos);
+
+ if (rightStats[index]) {
+ doc.setTextColor(rightStats[index].color);
+ doc.setFont('helvetica', 'bold');
+ doc.text(rightStats[index].value.toString(), margin + colWidth, yPos);
+ doc.setTextColor('#374151');
+ doc.setFont('helvetica', 'normal');
+ doc.text(rightStats[index].label, margin + colWidth + 20, yPos);
+ }
+
+ yPos += lineHeight;
+ });
+
+ yPos += lineHeight * 2;
+
+ // Add logs section
+ addSectionHeader('Event Logs');
+
+ // Helper function to add a log entry with improved formatting
+ const addLogEntry = (log: LogEntry) => {
+ const entryHeight = 20 + (log.details ? 40 : 0); // Estimate entry height
+
+ // Check if we need a new page
+ if (yPos + entryHeight > doc.internal.pageSize.getHeight() - 20) {
+ doc.addPage();
+ yPos = margin;
+ }
+
+ // Add timestamp and level
+ const timestamp = new Date(log.timestamp).toLocaleString(undefined, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: !use24Hour,
+ });
+
+ // Draw log level badge background
+ const levelColors: Record = {
+ error: '#FEE2E2',
+ warning: '#FEF3C7',
+ info: '#DBEAFE',
+ debug: '#F3F4F6',
+ };
+
+ const textColors: Record = {
+ error: '#DC2626',
+ warning: '#F59E0B',
+ info: '#3B82F6',
+ debug: '#6B7280',
+ };
+
+ const levelWidth = doc.getTextWidth(log.level.toUpperCase()) + 10;
+ doc.setFillColor(levelColors[log.level] || '#F3F4F6');
+ doc.roundedRect(margin, yPos - 4, levelWidth, lineHeight + 4, 1, 1, 'F');
+
+ // Add log level text
+ doc.setTextColor(textColors[log.level] || '#6B7280');
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(8);
+ doc.text(log.level.toUpperCase(), margin + 5, yPos);
+
+ // Add timestamp
+ doc.setTextColor('#6B7280');
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(9);
+ doc.text(timestamp, margin + levelWidth + 10, yPos);
+
+ // Add category if present
+ if (log.category) {
+ const categoryX = margin + levelWidth + doc.getTextWidth(timestamp) + 20;
+ doc.setFillColor('#F3F4F6');
+
+ const categoryWidth = doc.getTextWidth(log.category) + 10;
+ doc.roundedRect(categoryX, yPos - 4, categoryWidth, lineHeight + 4, 2, 2, 'F');
+ doc.setTextColor('#6B7280');
+ doc.text(log.category, categoryX + 5, yPos);
+ }
+
+ yPos += lineHeight * 1.5;
+
+ // Add message
+ doc.setTextColor('#111827');
+ doc.setFontSize(10);
+
+ const messageLines = doc.splitTextToSize(log.message, maxLineWidth - 10);
+ doc.text(messageLines, margin + 5, yPos);
+ yPos += messageLines.length * lineHeight;
+
+ // Add details if present
+ if (log.details) {
+ doc.setTextColor('#6B7280');
+ doc.setFontSize(8);
+
+ const detailsStr = JSON.stringify(log.details, null, 2);
+ const detailsLines = doc.splitTextToSize(detailsStr, maxLineWidth - 15);
+
+ // Add details background
+ doc.setFillColor('#F9FAFB');
+ doc.roundedRect(margin + 5, yPos - 2, maxLineWidth - 10, detailsLines.length * lineHeight + 8, 1, 1, 'F');
+
+ doc.text(detailsLines, margin + 10, yPos + 4);
+ yPos += detailsLines.length * lineHeight + 10;
+ }
+
+ // Add separator line
+ doc.setDrawColor('#E5E7EB');
+ doc.setLineWidth(0.1);
+ doc.line(margin, yPos, pageWidth - margin, yPos);
+ yPos += lineHeight * 1.5;
+ };
+
+ // Add all logs
+ filteredLogs.forEach((log) => {
+ addLogEntry(log);
+ });
+
+ // Add footer to all pages
+ const totalPages = doc.internal.pages.length - 1;
+
+ for (let i = 1; i <= totalPages; i++) {
+ doc.setPage(i);
+ doc.setFontSize(8);
+ doc.setTextColor('#9CA3AF');
+
+ // Add page numbers
+ doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
+ align: 'center',
+ });
+
+ // Add footer text
+ doc.text('Generated by bolt.diy', margin, doc.internal.pageSize.getHeight() - 10);
+
+ const dateStr = new Date().toLocaleDateString();
+ doc.text(dateStr, pageWidth - margin, doc.internal.pageSize.getHeight() - 10, { align: 'right' });
+ }
+
+ // Save the PDF
+ doc.save(`bolt-event-logs-${new Date().toISOString()}.pdf`);
+ toast.success('Event logs exported successfully as PDF');
+ } catch (error) {
+ console.error('Failed to export PDF:', error);
+ toast.error('Failed to export event logs as PDF');
+ }
+ };
+
+ const exportAsText = () => {
+ try {
+ const textContent = filteredLogs
+ .map((log) => {
+ const timestamp = new Date(log.timestamp).toLocaleString();
+ let content = `[${timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`;
+
+ if (log.category) {
+ content += `Category: ${log.category}\n`;
+ }
+
+ if (log.details) {
+ content += `Details:\n${JSON.stringify(log.details, null, 2)}\n`;
+ }
+
+ return content + '-'.repeat(80) + '\n';
+ })
+ .join('\n');
+
+ const blob = new Blob([textContent], { type: 'text/plain' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `bolt-event-logs-${new Date().toISOString()}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ toast.success('Event logs exported successfully as text file');
+ } catch (error) {
+ console.error('Failed to export text file:', error);
+ toast.error('Failed to export event logs as text file');
+ }
+ };
+
+ const exportFormats: ExportFormat[] = [
+ {
+ id: 'json',
+ label: 'Export as JSON',
+ icon: 'i-ph:file-json',
+ handler: exportAsJSON,
+ },
+ {
+ id: 'csv',
+ label: 'Export as CSV',
+ icon: 'i-ph:file-csv',
+ handler: exportAsCSV,
+ },
+ {
+ id: 'pdf',
+ label: 'Export as PDF',
+ icon: 'i-ph:file-pdf',
+ handler: exportAsPDF,
+ },
+ {
+ id: 'txt',
+ label: 'Export as Text',
+ icon: 'i-ph:file-text',
+ handler: exportAsText,
+ },
+ ];
+
+ const ExportButton = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleOpenChange = useCallback((open: boolean) => {
+ setIsOpen(open);
+ }, []);
+
+ const handleFormatClick = useCallback((handler: () => void) => {
+ handler();
+ setIsOpen(false);
+ }, []);
+
+ return (
+
+ setIsOpen(true)}
+ 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',
+ 'transition-all duration-200',
+ )}
+ >
+
+ Export
+
+
+
+
+
+
+ Export Event Logs
+
+
+
+ {exportFormats.map((format) => (
+
handleFormatClick(format.handler)}
+ className={classNames(
+ 'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
+ 'bg-white dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
+ 'text-bolt-elements-textPrimary',
+ )}
+ >
+
+
+
{format.label}
+
+ {format.id === 'json' && 'Export as a structured JSON file'}
+ {format.id === 'csv' && 'Export as a CSV spreadsheet'}
+ {format.id === 'pdf' && 'Export as a formatted PDF document'}
+ {format.id === 'txt' && 'Export as a formatted text file'}
+
+
+
+ ))}
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ {selectedLevelOption?.label || 'All Types'}
+
+
+
+
+
+
+ {logLevelOptions.map((option) => (
+ handleLevelFilterChange(option.value)}
+ >
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+ handlePreferenceChange('timestamps', value)}
+ className="data-[state=checked]:bg-purple-500"
+ />
+ Show Timestamps
+
+
+
+ handlePreferenceChange('24hour', value)}
+ className="data-[state=checked]:bg-purple-500"
+ />
+ 24h Time
+
+
+
+ handlePreferenceChange('autoExpand', value)}
+ className="data-[state=checked]:bg-purple-500"
+ />
+ Auto Expand
+
+
+
+
+
+
+ Refresh
+
+
+
+
+
+
+
+
+
setSearchQuery(e.target.value)}
+ className={classNames(
+ 'w-full px-4 py-2 pl-10 rounded-lg',
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500',
+ 'transition-all duration-200',
+ )}
+ />
+
+
+
+ {filteredLogs.length === 0 ? (
+
+
+
+
No Logs Found
+
Try adjusting your search or filters
+
+
+ ) : (
+ filteredLogs.map((log) => (
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/features/FeaturesTab.tsx b/app/components/@settings/tabs/features/FeaturesTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3b14a7565dd0de742d8a1278e1312bcc264f44ce
--- /dev/null
+++ b/app/components/@settings/tabs/features/FeaturesTab.tsx
@@ -0,0 +1,295 @@
+// Remove unused imports
+import React, { memo, useCallback } from 'react';
+import { motion } from 'framer-motion';
+import { Switch } from '~/components/ui/Switch';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { classNames } from '~/utils/classNames';
+import { toast } from 'react-toastify';
+import { PromptLibrary } from '~/lib/common/prompt-library';
+
+interface FeatureToggle {
+ id: string;
+ title: string;
+ description: string;
+ icon: string;
+ enabled: boolean;
+ beta?: boolean;
+ experimental?: boolean;
+ tooltip?: string;
+}
+
+const FeatureCard = memo(
+ ({
+ feature,
+ index,
+ onToggle,
+ }: {
+ feature: FeatureToggle;
+ index: number;
+ onToggle: (id: string, enabled: boolean) => void;
+ }) => (
+
+
+
+
+
+
+
{feature.title}
+ {feature.beta && (
+ Beta
+ )}
+ {feature.experimental && (
+
+ Experimental
+
+ )}
+
+
+
onToggle(feature.id, checked)} />
+
+
{feature.description}
+ {feature.tooltip &&
{feature.tooltip}
}
+
+
+ ),
+);
+
+const FeatureSection = memo(
+ ({
+ title,
+ features,
+ icon,
+ description,
+ onToggleFeature,
+ }: {
+ title: string;
+ features: FeatureToggle[];
+ icon: string;
+ description: string;
+ onToggleFeature: (id: string, enabled: boolean) => void;
+ }) => (
+
+
+
+
+
{title}
+
{description}
+
+
+
+
+ {features.map((feature, index) => (
+
+ ))}
+
+
+ ),
+);
+
+export default function FeaturesTab() {
+ const {
+ autoSelectTemplate,
+ isLatestBranch,
+ contextOptimizationEnabled,
+ eventLogs,
+ setAutoSelectTemplate,
+ enableLatestBranch,
+ enableContextOptimization,
+ setEventLogs,
+ setPromptId,
+ promptId,
+ } = useSettings();
+
+ // Enable features by default on first load
+ React.useEffect(() => {
+ // Only set defaults if values are undefined
+ if (isLatestBranch === undefined) {
+ enableLatestBranch(false); // Default: OFF - Don't auto-update from main branch
+ }
+
+ if (contextOptimizationEnabled === undefined) {
+ enableContextOptimization(true); // Default: ON - Enable context optimization
+ }
+
+ if (autoSelectTemplate === undefined) {
+ setAutoSelectTemplate(true); // Default: ON - Enable auto-select templates
+ }
+
+ if (promptId === undefined) {
+ setPromptId('default'); // Default: 'default'
+ }
+
+ if (eventLogs === undefined) {
+ setEventLogs(true); // Default: ON - Enable event logging
+ }
+ }, []); // Only run once on component mount
+
+ const handleToggleFeature = useCallback(
+ (id: string, enabled: boolean) => {
+ switch (id) {
+ case 'latestBranch': {
+ enableLatestBranch(enabled);
+ toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
+ break;
+ }
+
+ case 'autoSelectTemplate': {
+ setAutoSelectTemplate(enabled);
+ toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
+ break;
+ }
+
+ case 'contextOptimization': {
+ enableContextOptimization(enabled);
+ toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
+ break;
+ }
+
+ case 'eventLogs': {
+ setEventLogs(enabled);
+ toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
+ break;
+ }
+
+ default:
+ break;
+ }
+ },
+ [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
+ );
+
+ const features = {
+ stable: [
+ {
+ id: 'latestBranch',
+ title: 'Main Branch Updates',
+ description: 'Get the latest updates from the main branch',
+ icon: 'i-ph:git-branch',
+ enabled: isLatestBranch,
+ tooltip: 'Enabled by default to receive updates from the main development branch',
+ },
+ {
+ id: 'autoSelectTemplate',
+ title: 'Auto Select Template',
+ description: 'Automatically select starter template',
+ icon: 'i-ph:selection',
+ enabled: autoSelectTemplate,
+ tooltip: 'Enabled by default to automatically select the most appropriate starter template',
+ },
+ {
+ id: 'contextOptimization',
+ title: 'Context Optimization',
+ description: 'Optimize context for better responses',
+ icon: 'i-ph:brain',
+ enabled: contextOptimizationEnabled,
+ tooltip: 'Enabled by default for improved AI responses',
+ },
+ {
+ id: 'eventLogs',
+ title: 'Event Logging',
+ description: 'Enable detailed event logging and history',
+ icon: 'i-ph:list-bullets',
+ enabled: eventLogs,
+ tooltip: 'Enabled by default to record detailed logs of system events and user actions',
+ },
+ ],
+ beta: [],
+ };
+
+ return (
+
+
+
+ {features.beta.length > 0 && (
+
+ )}
+
+
+
+
+
+
+ Prompt Library
+
+
+ Choose a prompt from the library to use as the system prompt
+
+
+
{
+ 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/notifications/NotificationsTab.tsx b/app/components/@settings/tabs/notifications/NotificationsTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cb5f3da1c7de5a0bcaa4423f9a7334d7eaa05d57
--- /dev/null
+++ b/app/components/@settings/tabs/notifications/NotificationsTab.tsx
@@ -0,0 +1,300 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { logStore } from '~/lib/stores/logs';
+import { useStore } from '@nanostores/react';
+import { formatDistanceToNow } from 'date-fns';
+import { classNames } from '~/utils/classNames';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+
+interface NotificationDetails {
+ type?: string;
+ message?: string;
+ currentVersion?: string;
+ latestVersion?: string;
+ branch?: string;
+ updateUrl?: string;
+}
+
+type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network';
+
+const NotificationsTab = () => {
+ const [filter, setFilter] = useState('all');
+ const logs = useStore(logStore.logs);
+
+ useEffect(() => {
+ const startTime = performance.now();
+
+ return () => {
+ const duration = performance.now() - startTime;
+ logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration);
+ };
+ }, []);
+
+ const handleClearNotifications = () => {
+ const count = Object.keys(logs).length;
+ logStore.logInfo('Cleared notifications', {
+ type: 'notification_clear',
+ message: `Cleared ${count} notifications`,
+ clearedCount: count,
+ component: 'notifications',
+ });
+ logStore.clearLogs();
+ };
+
+ const handleUpdateAction = (updateUrl: string) => {
+ logStore.logInfo('Update link clicked', {
+ type: 'update_click',
+ message: 'User clicked update link',
+ updateUrl,
+ component: 'notifications',
+ });
+ window.open(updateUrl, '_blank');
+ };
+
+ const handleFilterChange = (newFilter: FilterType) => {
+ logStore.logInfo('Notification filter changed', {
+ type: 'filter_change',
+ message: `Filter changed to ${newFilter}`,
+ previousFilter: filter,
+ newFilter,
+ component: 'notifications',
+ });
+ setFilter(newFilter);
+ };
+
+ const filteredLogs = Object.values(logs)
+ .filter((log) => {
+ if (filter === 'all') {
+ return true;
+ }
+
+ if (filter === 'update') {
+ return log.details?.type === 'update';
+ }
+
+ if (filter === 'system') {
+ return log.category === 'system';
+ }
+
+ if (filter === 'provider') {
+ return log.category === 'provider';
+ }
+
+ if (filter === 'network') {
+ return log.category === 'network';
+ }
+
+ return log.level === filter;
+ })
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
+
+ const getNotificationStyle = (level: string, type?: string) => {
+ if (type === 'update') {
+ return {
+ icon: 'i-ph:arrow-circle-up',
+ color: 'text-purple-500 dark:text-purple-400',
+ bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
+ };
+ }
+
+ switch (level) {
+ case 'error':
+ return {
+ icon: 'i-ph:warning-circle',
+ 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',
+ 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',
+ 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',
+ color: 'text-gray-500 dark:text-gray-400',
+ bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
+ };
+ }
+ };
+
+ const renderNotificationDetails = (details: NotificationDetails) => {
+ if (details.type === 'update') {
+ return (
+
+
{details.message}
+
+
Current Version: {details.currentVersion}
+
Latest Version: {details.latestVersion}
+
Branch: {details.branch}
+
+
details.updateUrl && handleUpdateAction(details.updateUrl)}
+ className={classNames(
+ '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',
+ 'transition-all duration-200',
+ )}
+ >
+
+ View Changes
+
+
+ );
+ }
+
+ 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' },
+ ];
+
+ return (
+
+
+
+
+
+ opt.id === filter)?.icon || 'i-ph:funnel')}
+ style={{ color: filterOptions.find((opt) => opt.id === filter)?.color }}
+ />
+ {filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
+
+
+
+
+
+
+ {filterOptions.map((option) => (
+ handleFilterChange(option.id)}
+ >
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+ Clear All
+
+
+
+
+ {filteredLogs.length === 0 ? (
+
+
+
+
No Notifications
+
You're all caught up!
+
+
+ ) : (
+ filteredLogs.map((log) => {
+ const style = getNotificationStyle(log.level, log.details?.type);
+ return (
+
+
+
+
+
+
{log.message}
+ {log.details && renderNotificationDetails(log.details as NotificationDetails)}
+
+ Category: {log.category}
+ {log.subCategory ? ` > ${log.subCategory}` : ''}
+
+
+
+
+ {formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
+
+
+
+ );
+ })
+ )}
+
+
+ );
+};
+
+export default NotificationsTab;
diff --git a/app/components/@settings/tabs/profile/ProfileTab.tsx b/app/components/@settings/tabs/profile/ProfileTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6783a9072d740f4fcbfdc99015a3f521e30375f1
--- /dev/null
+++ b/app/components/@settings/tabs/profile/ProfileTab.tsx
@@ -0,0 +1,181 @@
+import { useState, useCallback } from 'react';
+import { useStore } from '@nanostores/react';
+import { classNames } from '~/utils/classNames';
+import { profileStore, updateProfile } from '~/lib/stores/profile';
+import { toast } from 'react-toastify';
+import { debounce } from '~/utils/debounce';
+
+export default function ProfileTab() {
+ const profile = useStore(profileStore);
+ const [isUploading, setIsUploading] = useState(false);
+
+ // Create debounced update functions
+ const debouncedUpdate = useCallback(
+ debounce((field: 'username' | 'bio', value: string) => {
+ updateProfile({ [field]: value });
+ toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`);
+ }, 1000),
+ [],
+ );
+
+ const handleAvatarUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+
+ if (!file) {
+ return;
+ }
+
+ try {
+ setIsUploading(true);
+
+ // Convert the file to base64
+ const reader = new FileReader();
+
+ reader.onloadend = () => {
+ const base64String = reader.result as string;
+ updateProfile({ avatar: base64String });
+ setIsUploading(false);
+ toast.success('Profile picture updated');
+ };
+
+ reader.onerror = () => {
+ console.error('Error reading file:', reader.error);
+ setIsUploading(false);
+ toast.error('Failed to update profile picture');
+ };
+ reader.readAsDataURL(file);
+ } catch (error) {
+ console.error('Error uploading avatar:', error);
+ setIsUploading(false);
+ toast.error('Failed to update profile picture');
+ }
+ };
+
+ const handleProfileUpdate = (field: 'username' | 'bio', value: string) => {
+ // Update the store immediately for UI responsiveness
+ updateProfile({ [field]: value });
+
+ // Debounce the toast notification
+ debouncedUpdate(field, value);
+ };
+
+ return (
+
+
+ {/* Personal Information Section */}
+
+ {/* Avatar Upload */}
+
+
+ {profile.avatar ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isUploading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Profile Picture
+
+
Upload a profile picture or avatar
+
+
+
+ {/* Username Input */}
+
+
Username
+
+
+
handleProfileUpdate('username', e.target.value)}
+ className={classNames(
+ 'w-full pl-11 pr-4 py-2.5 rounded-xl',
+ 'bg-white dark:bg-gray-800/50',
+ 'border border-gray-200 dark:border-gray-700/50',
+ 'text-gray-900 dark:text-white',
+ 'placeholder-gray-400 dark:placeholder-gray-500',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
+ 'transition-all duration-300 ease-out',
+ )}
+ placeholder="Enter your username"
+ />
+
+
+
+ {/* Bio Input */}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx b/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9f85b766631009af0e13173e947d7aefc412b2b2
--- /dev/null
+++ b/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx
@@ -0,0 +1,305 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { Switch } from '~/components/ui/Switch';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
+import type { IProviderConfig } from '~/types/model';
+import { logStore } from '~/lib/stores/logs';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { toast } from 'react-toastify';
+import { providerBaseUrlEnvKeys } from '~/utils/constants';
+import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
+import { BsRobot, BsCloud } from 'react-icons/bs';
+import { TbBrain, TbCloudComputing } from 'react-icons/tb';
+import { BiCodeBlock, BiChip } from 'react-icons/bi';
+import { FaCloud, FaBrain } from 'react-icons/fa';
+import type { IconType } from 'react-icons';
+
+// Add type for provider names to ensure type safety
+type ProviderName =
+ | 'AmazonBedrock'
+ | 'Anthropic'
+ | 'Cohere'
+ | 'Deepseek'
+ | 'Google'
+ | 'Groq'
+ | 'HuggingFace'
+ | 'Hyperbolic'
+ | 'Mistral'
+ | 'OpenAI'
+ | 'OpenRouter'
+ | 'Perplexity'
+ | 'Together'
+ | 'XAI';
+
+// Update the PROVIDER_ICONS type to use the ProviderName type
+const PROVIDER_ICONS: Record = {
+ AmazonBedrock: SiAmazon,
+ Anthropic: FaBrain,
+ Cohere: BiChip,
+ Deepseek: BiCodeBlock,
+ Google: SiGoogle,
+ Groq: BsCloud,
+ HuggingFace: SiHuggingface,
+ Hyperbolic: TbCloudComputing,
+ Mistral: TbBrain,
+ OpenAI: SiOpenai,
+ OpenRouter: FaCloud,
+ Perplexity: SiPerplexity,
+ Together: BsCloud,
+ XAI: BsRobot,
+};
+
+// Update PROVIDER_DESCRIPTIONS to use the same type
+const PROVIDER_DESCRIPTIONS: Partial> = {
+ Anthropic: 'Access Claude and other Anthropic models',
+ OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
+};
+
+const CloudProvidersTab = () => {
+ const settings = useSettings();
+ const [editingProvider, setEditingProvider] = useState(null);
+ const [filteredProviders, setFilteredProviders] = useState([]);
+ const [categoryEnabled, setCategoryEnabled] = useState(false);
+
+ // Load and filter providers
+ useEffect(() => {
+ const newFilteredProviders = Object.entries(settings.providers || {})
+ .filter(([key]) => !['Ollama', 'LMStudio', 'OpenAILike'].includes(key))
+ .map(([key, value]) => ({
+ name: key,
+ settings: value.settings,
+ staticModels: value.staticModels || [],
+ getDynamicModels: value.getDynamicModels,
+ getApiKeyLink: value.getApiKeyLink,
+ labelForGetApiKey: value.labelForGetApiKey,
+ icon: value.icon,
+ }));
+
+ const sorted = newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
+ setFilteredProviders(sorted);
+
+ // Update category enabled state
+ const allEnabled = newFilteredProviders.every((p) => p.settings.enabled);
+ setCategoryEnabled(allEnabled);
+ }, [settings.providers]);
+
+ const handleToggleCategory = useCallback(
+ (enabled: boolean) => {
+ // Update all providers
+ filteredProviders.forEach((provider) => {
+ settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
+ });
+
+ setCategoryEnabled(enabled);
+ toast.success(enabled ? 'All cloud providers enabled' : 'All cloud providers disabled');
+ },
+ [filteredProviders, settings],
+ );
+
+ const handleToggleProvider = useCallback(
+ (provider: IProviderConfig, enabled: boolean) => {
+ // Update the provider settings in the store
+ settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
+
+ if (enabled) {
+ logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
+ toast.success(`${provider.name} enabled`);
+ } else {
+ logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
+ toast.success(`${provider.name} disabled`);
+ }
+ },
+ [settings],
+ );
+
+ const handleUpdateBaseUrl = useCallback(
+ (provider: IProviderConfig, baseUrl: string) => {
+ const newBaseUrl: string | undefined = baseUrl.trim() || undefined;
+
+ // Update the provider settings in the store
+ settings.updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
+
+ logStore.logProvider(`Base URL updated for ${provider.name}`, {
+ provider: provider.name,
+ baseUrl: newBaseUrl,
+ });
+ toast.success(`${provider.name} base URL updated`);
+ setEditingProvider(null);
+ },
+ [settings],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
Cloud Providers
+
Connect to cloud-based AI models and services
+
+
+
+
+ Enable All Cloud
+
+
+
+
+
+ {filteredProviders.map((provider, index) => (
+
+
+ {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
+
+ Configurable
+
+ )}
+
+
+
+
+
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ className: 'w-full h-full',
+ 'aria-label': `${provider.name} logo`,
+ })}
+
+
+
+
+
+
+
+ {provider.name}
+
+
+ {PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] ||
+ (URL_CONFIGURABLE_PROVIDERS.includes(provider.name)
+ ? 'Configure custom endpoint for this provider'
+ : 'Standard AI provider integration')}
+
+
+
handleToggleProvider(provider, checked)}
+ />
+
+
+ {provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
+
+
+ {editingProvider === provider.name ? (
+
{
+ if (e.key === 'Enter') {
+ handleUpdateBaseUrl(provider, e.currentTarget.value);
+ } else if (e.key === 'Escape') {
+ setEditingProvider(null);
+ }
+ }}
+ onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
+ autoFocus
+ />
+ ) : (
+
setEditingProvider(provider.name)}
+ >
+
+
+
+ {provider.settings.baseUrl || 'Click to set base URL'}
+
+
+
+ )}
+
+
+ {providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
+
+
+
+
Environment URL set in .env file
+
+
+ )}
+
+ )}
+
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default CloudProvidersTab;
diff --git a/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx b/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..70e8d2f5177c0e914fd1438d887df1dad11b9e9a
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
@@ -0,0 +1,777 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { Switch } from '~/components/ui/Switch';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
+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 { 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';
+
+// 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,
+};
+
+// Update PROVIDER_DESCRIPTIONS to use the same type
+const PROVIDER_DESCRIPTIONS: Record = {
+ Ollama: 'Run open-source models locally on your machine',
+ LMStudio: 'Local model inference with LM Studio',
+ OpenAILike: 'Connect to OpenAI-compatible API endpoints',
+};
+
+// Add a constant for the Ollama API base URL
+const OLLAMA_API_URL = 'http://127.0.0.1:11434';
+
+interface OllamaModel {
+ name: string;
+ digest: string;
+ size: number;
+ modified_at: string;
+ details?: {
+ family: string;
+ parameter_size: string;
+ quantization_level: string;
+ };
+ status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
+ error?: string;
+ newDigest?: string;
+ progress?: {
+ current: number;
+ total: number;
+ status: string;
+ };
+}
+
+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();
+ const [filteredProviders, setFilteredProviders] = useState([]);
+ const [categoryEnabled, setCategoryEnabled] = useState(false);
+ const [ollamaModels, setOllamaModels] = useState([]);
+ const [isLoadingModels, setIsLoadingModels] = useState(false);
+ const [editingProvider, setEditingProvider] = useState(null);
+ const { toast } = useToast();
+
+ // Effect to filter and sort providers
+ useEffect(() => {
+ const newFilteredProviders = Object.entries(providers || {})
+ .filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
+ .map(([key, value]) => {
+ const provider = value as IProviderConfig;
+ const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
+ const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
+
+ // Set base URL if provided by environment
+ if (envUrl && !provider.settings.baseUrl) {
+ updateProviderSettings(key, {
+ ...provider.settings,
+ baseUrl: envUrl,
+ });
+ }
+
+ return {
+ name: key,
+ settings: {
+ ...provider.settings,
+ baseUrl: provider.settings.baseUrl || envUrl,
+ },
+ staticModels: provider.staticModels || [],
+ getDynamicModels: provider.getDynamicModels,
+ getApiKeyLink: provider.getApiKeyLink,
+ labelForGetApiKey: provider.labelForGetApiKey,
+ icon: provider.icon,
+ } as IProviderConfig;
+ });
+
+ // Custom sort function to ensure LMStudio appears before OpenAILike
+ const sorted = newFilteredProviders.sort((a, b) => {
+ if (a.name === 'LMStudio') {
+ return -1;
+ }
+
+ if (b.name === 'LMStudio') {
+ return 1;
+ }
+
+ if (a.name === 'OpenAILike') {
+ return 1;
+ }
+
+ if (b.name === 'OpenAILike') {
+ return -1;
+ }
+
+ return a.name.localeCompare(b.name);
+ });
+ setFilteredProviders(sorted);
+ }, [providers, updateProviderSettings]);
+
+ // Add effect to update category toggle state based on provider states
+ useEffect(() => {
+ const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
+ setCategoryEnabled(newCategoryState);
+ }, [filteredProviders]);
+
+ // Fetch Ollama models when enabled
+ useEffect(() => {
+ const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
+
+ if (ollamaProvider?.settings.enabled) {
+ fetchOllamaModels();
+ }
+ }, [filteredProviders]);
+
+ const fetchOllamaModels = async () => {
+ try {
+ setIsLoadingModels(true);
+
+ const response = await fetch('http://127.0.0.1:11434/api/tags');
+ const data = (await response.json()) as { models: OllamaModel[] };
+
+ setOllamaModels(
+ data.models.map((model) => ({
+ ...model,
+ status: 'idle' as const,
+ })),
+ );
+ } catch (error) {
+ console.error('Error fetching Ollama models:', error);
+ } finally {
+ setIsLoadingModels(false);
+ }
+ };
+
+ const updateOllamaModel = async (modelName: string): Promise => {
+ try {
+ const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: modelName }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to update ${modelName}`);
+ }
+
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('No response reader available');
+ }
+
+ 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) {
+ const rawData = JSON.parse(line);
+
+ if (!isOllamaPullResponse(rawData)) {
+ console.error('Invalid response format:', rawData);
+ continue;
+ }
+
+ setOllamaModels((current) =>
+ current.map((m) =>
+ m.name === modelName
+ ? {
+ ...m,
+ progress: {
+ current: rawData.completed || 0,
+ total: rawData.total || 0,
+ status: rawData.status,
+ },
+ newDigest: rawData.digest,
+ }
+ : m,
+ ),
+ );
+ }
+ }
+
+ const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
+ const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
+ const updatedModel = updatedData.models.find((m) => m.name === modelName);
+
+ return updatedModel !== undefined;
+ } catch (error) {
+ console.error(`Error updating ${modelName}:`, error);
+ return false;
+ }
+ };
+
+ const handleToggleCategory = useCallback(
+ async (enabled: boolean) => {
+ filteredProviders.forEach((provider) => {
+ updateProviderSettings(provider.name, { ...provider.settings, enabled });
+ });
+ toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
+ },
+ [filteredProviders, updateProviderSettings],
+ );
+
+ const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
+ updateProviderSettings(provider.name, {
+ ...provider.settings,
+ enabled,
+ });
+
+ if (enabled) {
+ logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
+ toast(`${provider.name} enabled`);
+ } else {
+ logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
+ toast(`${provider.name} disabled`);
+ }
+ };
+
+ const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => {
+ updateProviderSettings(provider.name, {
+ ...provider.settings,
+ baseUrl: newBaseUrl,
+ });
+ toast(`${provider.name} base URL updated`);
+ setEditingProvider(null);
+ };
+
+ const handleUpdateOllamaModel = async (modelName: string) => {
+ const updateSuccess = await updateOllamaModel(modelName);
+
+ if (updateSuccess) {
+ toast(`Updated ${modelName}`);
+ } else {
+ toast(`Failed to update ${modelName}`);
+ }
+ };
+
+ const handleDeleteOllamaModel = async (modelName: string) => {
+ try {
+ const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ name: modelName }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to delete ${modelName}`);
+ }
+
+ setOllamaModels((current) => current.filter((m) => m.name !== modelName));
+ toast(`Deleted ${modelName}`);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
+ console.error(`Error deleting ${modelName}:`, errorMessage);
+ toast(`Failed to delete ${modelName}`);
+ }
+ };
+
+ // Update model details display
+ const ModelDetails = ({ model }: { model: OllamaModel }) => (
+
+
+
+
{model.digest.substring(0, 7)}
+
+ {model.details && (
+ <>
+
+
+
{model.details.parameter_size}
+
+
+
+
{model.details.quantization_level}
+
+ >
+ )}
+
+ );
+
+ // Update model actions to not use Tooltip
+ const ModelActions = ({
+ model,
+ onUpdate,
+ onDelete,
+ }: {
+ model: OllamaModel;
+ onUpdate: () => void;
+ onDelete: () => void;
+ }) => (
+
+
+ {model.status === 'updating' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+
+ return (
+
+
+ {/* Header section */}
+
+
+
+
+
+
+
+
Local AI Models
+
+
Configure and manage your local AI providers
+
+
+
+
+ Enable All
+
+
+
+
+ {/* Ollama Section */}
+ {filteredProviders
+ .filter((provider) => provider.name === 'Ollama')
+ .map((provider) => (
+
+ {/* Provider Header */}
+
+
+
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ className: 'w-7 h-7',
+ 'aria-label': `${provider.name} icon`,
+ })}
+
+
+
+
{provider.name}
+ Local
+
+
+ {PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
+
+
+
+
handleToggleProvider(provider, checked)}
+ aria-label={`Toggle ${provider.name} provider`}
+ />
+
+
+ {/* URL Configuration Section */}
+
+ {provider.settings.enabled && (
+
+
+
API Endpoint
+ {editingProvider === provider.name ? (
+
{
+ if (e.key === 'Enter') {
+ handleUpdateBaseUrl(provider, e.currentTarget.value);
+ } else if (e.key === 'Escape') {
+ setEditingProvider(null);
+ }
+ }}
+ onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
+ autoFocus
+ />
+ ) : (
+
setEditingProvider(provider.name)}
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
+ 'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
+ 'transition-all duration-200',
+ )}
+ >
+
+
+
{provider.settings.baseUrl || OLLAMA_API_URL}
+
+
+ )}
+
+
+ )}
+
+
+ {/* Ollama Models Section */}
+ {provider.settings.enabled && (
+
+
+
+ {isLoadingModels ? (
+
+ ) : (
+
+ {ollamaModels.length} models available
+
+ )}
+
+
+
+ {isLoadingModels ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : ollamaModels.length === 0 ? (
+
+
+
No models installed yet
+
+ Browse models at{' '}
+
+ ollama.com/library
+
+ {' '}
+ and copy model names to install
+
+
+ ) : (
+ ollamaModels.map((model) => (
+
+
+
+
handleUpdateOllamaModel(model.name)}
+ onDelete={() => {
+ if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
+ handleDeleteOllamaModel(model.name);
+ }
+ }}
+ />
+
+ {model.progress && (
+
+
+
+ {model.progress.status}
+ {Math.round((model.progress.current / model.progress.total) * 100)}%
+
+
+ )}
+
+ ))
+ )}
+
+
+ {/* Model Installation Section */}
+
+
+ )}
+
+ ))}
+
+ {/* Other Providers Section */}
+
+
Other Local Providers
+
+ {filteredProviders
+ .filter((provider) => provider.name !== 'Ollama')
+ .map((provider, index) => (
+
+ {/* Provider Header */}
+
+
+
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
+ className: 'w-7 h-7',
+ 'aria-label': `${provider.name} icon`,
+ })}
+
+
+
+
{provider.name}
+
+
+ Local
+
+ {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
+
+ Configurable
+
+ )}
+
+
+
+ {PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
+
+
+
+
handleToggleProvider(provider, checked)}
+ aria-label={`Toggle ${provider.name} provider`}
+ />
+
+
+ {/* URL Configuration Section */}
+
+ {provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
+
+
+
API Endpoint
+ {editingProvider === provider.name ? (
+
{
+ if (e.key === 'Enter') {
+ handleUpdateBaseUrl(provider, e.currentTarget.value);
+ } else if (e.key === 'Escape') {
+ setEditingProvider(null);
+ }
+ }}
+ onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
+ autoFocus
+ />
+ ) : (
+
setEditingProvider(provider.name)}
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
+ 'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
+ 'transition-all duration-200',
+ )}
+ >
+
+
+
{provider.settings.baseUrl || 'Click to set base URL'}
+
+
+ )}
+
+
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// Helper component for model status badge
+function ModelStatusBadge({ status }: { status?: string }) {
+ if (!status || status === 'idle') {
+ return null;
+ }
+
+ const statusConfig = {
+ updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' },
+ updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' },
+ error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' },
+ };
+
+ const config = statusConfig[status as keyof typeof statusConfig];
+
+ if (!config) {
+ return null;
+ }
+
+ return (
+
+ {config.label}
+
+ );
+}
diff --git a/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx b/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9568076f2489edc2e8d539d369003da440c0dbb5
--- /dev/null
+++ b/app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx
@@ -0,0 +1,603 @@
+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/service-status/ServiceStatusTab.tsx b/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..401bd42fe9b6e1d39fff4936f50c79a09d007451
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx
@@ -0,0 +1,135 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..dde4bd318bcabdf1221dd7f8332f73d6727cef3b
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/base-provider.ts
@@ -0,0 +1,121 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..3887781aeb3b708b0affab132423f716c3a6248a
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/provider-factory.ts
@@ -0,0 +1,154 @@
+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';
+
+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',
+ },
+ 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 '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
new file mode 100644
index 0000000000000000000000000000000000000000..dff9d9a1fb0e2020b175f803b4c5f56db69710f6
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts
@@ -0,0 +1,76 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..dccbf66b39f8934fc666bc7cc21bc61cf1da55f9
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/anthropic.ts
@@ -0,0 +1,80 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..7707f7377d944a75c4e4ae92dc8f1e7aa451783f
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/cohere.ts
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..7aa88bac4285411dc4eea27a91d885e0bb1097d1
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/deepseek.ts
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..80b5ecf81c46db54f03f0a4b8d719f8955a3c770
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/google.ts
@@ -0,0 +1,77 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..c465cedd811edf61b7b2de41177b92ccc5ace4cf
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/groq.ts
@@ -0,0 +1,72 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..80dcfe848d60a29deb4ad87b0d4c43f4dc72a346
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/huggingface.ts
@@ -0,0 +1,98 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..6dca268fb798f46c8c8cb27e4baf24845d285b34
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..5966682cff56d3b3f669ea2d33f16d6a6bf53027
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/mistral.ts
@@ -0,0 +1,76 @@
+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/openai.ts b/app/components/@settings/tabs/providers/service-status/providers/openai.ts
new file mode 100644
index 0000000000000000000000000000000000000000..252c16ea1b441a7822c7fdb59d0810f4b4fd25a0
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/openai.ts
@@ -0,0 +1,99 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..f05edb98a67d2971ebfc7f801d0a18a5612d8243
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/openrouter.ts
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..31a8088e3c3f61f7f8098704284c064d87bcca32
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/perplexity.ts
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..77abce9810087968d4e15782a65dbe86dce3e6b2
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/together.ts
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..7b98c6a3822d0817e6417f73a53841e9e4b6461e
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/providers/xai.ts
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..188a474a8d395e4741e72cfd656992c31a661880
--- /dev/null
+++ b/app/components/@settings/tabs/providers/service-status/types.ts
@@ -0,0 +1,55 @@
+import type { IconType } from 'react-icons';
+
+export type ProviderName =
+ | 'AmazonBedrock'
+ | 'Cohere'
+ | 'Deepseek'
+ | 'Google'
+ | 'Groq'
+ | 'HuggingFace'
+ | 'Hyperbolic'
+ | 'Mistral'
+ | '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
new file mode 100644
index 0000000000000000000000000000000000000000..b61ed043593dcf43c9979d2157b1cf10b76593f3
--- /dev/null
+++ b/app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx
@@ -0,0 +1,886 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..2079840a51cfb402f9b09ba67038681a30e0ea3f
--- /dev/null
+++ b/app/components/@settings/tabs/settings/SettingsTab.tsx
@@ -0,0 +1,215 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { toast } from 'react-toastify';
+import { classNames } from '~/utils/classNames';
+import { Switch } from '~/components/ui/Switch';
+import type { UserProfile } from '~/components/@settings/core/types';
+import { isMac } from '~/utils/os';
+
+// Helper to get modifier key symbols/text
+const getModifierSymbol = (modifier: string): string => {
+ switch (modifier) {
+ case 'meta':
+ return isMac ? '⌘' : 'Win';
+ case 'alt':
+ return isMac ? '⌥' : 'Alt';
+ case 'shift':
+ return '⇧';
+ default:
+ return modifier;
+ }
+};
+
+export default function SettingsTab() {
+ const [currentTimezone, setCurrentTimezone] = useState('');
+ const [settings, setSettings] = useState(() => {
+ const saved = localStorage.getItem('bolt_user_profile');
+ return saved
+ ? JSON.parse(saved)
+ : {
+ notifications: true,
+ language: 'en',
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ };
+ });
+
+ useEffect(() => {
+ setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
+ }, []);
+
+ // Save settings automatically when they change
+ useEffect(() => {
+ try {
+ // Get existing profile data
+ const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
+
+ // Merge with new settings
+ const updatedProfile = {
+ ...existingProfile,
+ notifications: settings.notifications,
+ language: settings.language,
+ timezone: settings.timezone,
+ };
+
+ localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
+ toast.success('Settings updated');
+ } catch (error) {
+ console.error('Error saving settings:', error);
+ toast.error('Failed to update settings');
+ }
+ }, [settings]);
+
+ return (
+
+ {/* Language & Notifications */}
+
+
+
+
+
+
setSettings((prev) => ({ ...prev, language: e.target.value }))}
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-bolt-elements-textPrimary',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
+ 'transition-all duration-200',
+ )}
+ >
+ English
+ Español
+ Français
+ Deutsch
+ Italiano
+ Português
+ Русский
+ 中文
+ 日本語
+ 한국어
+
+
+
+
+
+
+
+ {settings.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
+
+ {
+ // Update local state
+ setSettings((prev) => ({ ...prev, notifications: checked }));
+
+ // Update localStorage immediately
+ const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
+ const updatedProfile = {
+ ...existingProfile,
+ notifications: checked,
+ };
+ localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
+
+ // Dispatch storage event for other components
+ window.dispatchEvent(
+ new StorageEvent('storage', {
+ key: 'bolt_user_profile',
+ newValue: JSON.stringify(updatedProfile),
+ }),
+ );
+
+ toast.success(`Notifications ${checked ? 'enabled' : 'disabled'}`);
+ }}
+ />
+
+
+
+
+ {/* Timezone */}
+
+
+
+
+
+
setSettings((prev) => ({ ...prev, timezone: e.target.value }))}
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-bolt-elements-textPrimary',
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
+ 'transition-all duration-200',
+ )}
+ >
+ {currentTimezone}
+
+
+
+
+ {/* Simplified Keyboard Shortcuts */}
+
+
+
+
+
+
+ Toggle Theme
+ Switch between light and dark mode
+
+
+
+ {getModifierSymbol('meta')}
+
+
+ {getModifierSymbol('alt')}
+
+
+ {getModifierSymbol('shift')}
+
+
+ D
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx b/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..48c26b5b3fe6b52da3a84310802b21af35f734aa
--- /dev/null
+++ b/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx
@@ -0,0 +1,1265 @@
+import * as React from 'react';
+import { useEffect, useState, useRef, useCallback } from 'react';
+import { classNames } from '~/utils/classNames';
+import { Line } from 'react-chartjs-2';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import { toast } from 'react-toastify'; // Import toast
+import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
+import { tabConfigurationStore, type TabConfig } from '~/lib/stores/tabConfigurationStore';
+import { useStore } from 'zustand';
+
+// Register ChartJS components
+ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
+
+interface BatteryManager extends EventTarget {
+ charging: boolean;
+ chargingTime: number;
+ dischargingTime: number;
+ level: number;
+}
+
+interface SystemMetrics {
+ cpu: {
+ usage: number;
+ cores: number[];
+ temperature?: number;
+ frequency?: number;
+ };
+ memory: {
+ used: number;
+ total: number;
+ percentage: number;
+ heap: {
+ used: number;
+ total: number;
+ limit: number;
+ };
+ cache?: number;
+ };
+ uptime: number;
+ battery?: {
+ level: number;
+ charging: boolean;
+ timeRemaining?: number;
+ temperature?: number;
+ cycles?: number;
+ health?: number;
+ };
+ network: {
+ downlink: number;
+ uplink?: number;
+ latency: number;
+ type: string;
+ activeConnections?: number;
+ bytesReceived: number;
+ bytesSent: number;
+ };
+ performance: {
+ fps: number;
+ pageLoad: number;
+ domReady: number;
+ resources: {
+ total: number;
+ size: number;
+ loadTime: number;
+ };
+ timing: {
+ ttfb: number;
+ fcp: number;
+ lcp: number;
+ };
+ };
+ health: {
+ score: number;
+ issues: string[];
+ suggestions: string[];
+ };
+}
+
+interface MetricsHistory {
+ timestamps: string[];
+ cpu: number[];
+ memory: number[];
+ battery: number[];
+ network: number[];
+}
+
+interface EnergySavings {
+ updatesReduced: number;
+ timeInSaverMode: number;
+ estimatedEnergySaved: number; // in mWh (milliwatt-hours)
+}
+
+interface PowerProfile {
+ name: string;
+ description: string;
+ settings: {
+ updateInterval: number;
+ enableAnimations: boolean;
+ backgroundProcessing: boolean;
+ networkThrottling: boolean;
+ };
+}
+
+interface PerformanceAlert {
+ type: 'warning' | 'error' | 'info';
+ message: string;
+ timestamp: number;
+ metric: string;
+ threshold: number;
+ value: number;
+}
+
+declare global {
+ interface Navigator {
+ getBattery(): Promise;
+ }
+ interface Performance {
+ memory?: {
+ jsHeapSizeLimit: number;
+ totalJSHeapSize: number;
+ usedJSHeapSize: number;
+ };
+ }
+}
+
+// Constants for update intervals
+const UPDATE_INTERVALS = {
+ normal: {
+ metrics: 1000, // 1 second
+ animation: 16, // ~60fps
+ },
+ energySaver: {
+ metrics: 5000, // 5 seconds
+ animation: 32, // ~30fps
+ },
+};
+
+// Constants for performance thresholds
+const PERFORMANCE_THRESHOLDS = {
+ cpu: {
+ warning: 70,
+ critical: 90,
+ },
+ memory: {
+ warning: 80,
+ critical: 95,
+ },
+ fps: {
+ warning: 30,
+ critical: 15,
+ },
+};
+
+// Constants for energy calculations
+const ENERGY_COSTS = {
+ update: 0.1, // mWh per update
+};
+
+// Default power profiles
+const POWER_PROFILES: PowerProfile[] = [
+ {
+ name: 'Performance',
+ description: 'Maximum performance with frequent updates',
+ settings: {
+ updateInterval: UPDATE_INTERVALS.normal.metrics,
+ enableAnimations: true,
+ backgroundProcessing: true,
+ networkThrottling: false,
+ },
+ },
+ {
+ name: 'Balanced',
+ description: 'Optimal balance between performance and energy efficiency',
+ settings: {
+ updateInterval: 2000,
+ enableAnimations: true,
+ backgroundProcessing: true,
+ networkThrottling: false,
+ },
+ },
+ {
+ name: 'Energy Saver',
+ description: 'Maximum energy efficiency with reduced updates',
+ settings: {
+ updateInterval: UPDATE_INTERVALS.energySaver.metrics,
+ enableAnimations: false,
+ backgroundProcessing: false,
+ networkThrottling: true,
+ },
+ },
+];
+
+// Default metrics state
+const DEFAULT_METRICS_STATE: SystemMetrics = {
+ cpu: {
+ usage: 0,
+ cores: [],
+ },
+ memory: {
+ used: 0,
+ total: 0,
+ percentage: 0,
+ heap: {
+ used: 0,
+ total: 0,
+ limit: 0,
+ },
+ },
+ uptime: 0,
+ network: {
+ downlink: 0,
+ latency: 0,
+ type: 'unknown',
+ bytesReceived: 0,
+ bytesSent: 0,
+ },
+ performance: {
+ fps: 0,
+ pageLoad: 0,
+ domReady: 0,
+ resources: {
+ total: 0,
+ size: 0,
+ loadTime: 0,
+ },
+ timing: {
+ ttfb: 0,
+ fcp: 0,
+ lcp: 0,
+ },
+ },
+ health: {
+ score: 0,
+ issues: [],
+ suggestions: [],
+ },
+};
+
+// Default metrics history
+const DEFAULT_METRICS_HISTORY: MetricsHistory = {
+ timestamps: Array(10).fill(new Date().toLocaleTimeString()),
+ cpu: Array(10).fill(0),
+ memory: Array(10).fill(0),
+ battery: Array(10).fill(0),
+ network: Array(10).fill(0),
+};
+
+// Battery threshold for auto energy saver mode
+const BATTERY_THRESHOLD = 20; // percentage
+
+// Maximum number of history points to keep
+const MAX_HISTORY_POINTS = 10;
+
+const TaskManagerTab: React.FC = () => {
+ // Initialize metrics state with defaults
+ const [metrics, setMetrics] = useState(() => DEFAULT_METRICS_STATE);
+ const [metricsHistory, setMetricsHistory] = useState(() => DEFAULT_METRICS_HISTORY);
+ const [energySaverMode, setEnergySaverMode] = useState(false);
+ const [autoEnergySaver, setAutoEnergySaver] = useState(false);
+ const [energySavings, setEnergySavings] = useState(() => ({
+ updatesReduced: 0,
+ timeInSaverMode: 0,
+ estimatedEnergySaved: 0,
+ }));
+ const [selectedProfile, setSelectedProfile] = useState(() => POWER_PROFILES[1]);
+ const [alerts, setAlerts] = useState([]);
+ const saverModeStartTime = useRef(null);
+
+ // Get update status and tab configuration
+ const { hasUpdate } = useUpdateCheck();
+ const tabConfig = useStore(tabConfigurationStore);
+
+ const resetTabConfiguration = useCallback(() => {
+ tabConfig.reset();
+ return tabConfig.get();
+ }, [tabConfig]);
+
+ // Effect to handle tab visibility
+ useEffect(() => {
+ const handleTabVisibility = () => {
+ const currentConfig = tabConfig.get();
+ const controlledTabs = ['debug', 'update'];
+
+ // Update visibility based on conditions
+ const updatedTabs = currentConfig.userTabs.map((tab: TabConfig) => {
+ if (controlledTabs.includes(tab.id)) {
+ return {
+ ...tab,
+ visible: tab.id === 'debug' ? metrics.cpu.usage > 80 : hasUpdate,
+ };
+ }
+
+ return tab;
+ });
+
+ tabConfig.set({
+ ...currentConfig,
+ userTabs: updatedTabs,
+ });
+ };
+
+ const checkInterval = setInterval(handleTabVisibility, 5000);
+
+ return () => {
+ clearInterval(checkInterval);
+ };
+ }, [metrics.cpu.usage, hasUpdate, tabConfig]);
+
+ // Effect to handle reset and initialization
+ useEffect(() => {
+ const resetToDefaults = () => {
+ console.log('TaskManagerTab: Resetting to defaults');
+
+ // Reset metrics and local state
+ setMetrics(DEFAULT_METRICS_STATE);
+ setMetricsHistory(DEFAULT_METRICS_HISTORY);
+ setEnergySaverMode(false);
+ setAutoEnergySaver(false);
+ setEnergySavings({
+ updatesReduced: 0,
+ timeInSaverMode: 0,
+ estimatedEnergySaved: 0,
+ });
+ setSelectedProfile(POWER_PROFILES[1]);
+ setAlerts([]);
+ saverModeStartTime.current = null;
+
+ // Reset tab configuration to ensure proper visibility
+ const defaultConfig = resetTabConfiguration();
+ console.log('TaskManagerTab: Reset tab configuration:', defaultConfig);
+ };
+
+ // Listen for both storage changes and custom reset event
+ const handleReset = (event: Event | StorageEvent) => {
+ if (event instanceof StorageEvent) {
+ if (event.key === 'tabConfiguration' && event.newValue === null) {
+ resetToDefaults();
+ }
+ } else if (event instanceof CustomEvent && event.type === 'tabConfigReset') {
+ resetToDefaults();
+ }
+ };
+
+ // Initial setup
+ const initializeTab = async () => {
+ try {
+ // Load saved preferences
+ const savedEnergySaver = localStorage.getItem('energySaverMode');
+ const savedAutoSaver = localStorage.getItem('autoEnergySaver');
+ const savedProfile = localStorage.getItem('selectedProfile');
+
+ if (savedEnergySaver) {
+ setEnergySaverMode(JSON.parse(savedEnergySaver));
+ }
+
+ if (savedAutoSaver) {
+ setAutoEnergySaver(JSON.parse(savedAutoSaver));
+ }
+
+ if (savedProfile) {
+ const profile = POWER_PROFILES.find((p) => p.name === savedProfile);
+
+ if (profile) {
+ setSelectedProfile(profile);
+ }
+ }
+
+ await updateMetrics();
+ } catch (error) {
+ console.error('Failed to initialize TaskManagerTab:', error);
+ resetToDefaults();
+ }
+ };
+
+ window.addEventListener('storage', handleReset);
+ window.addEventListener('tabConfigReset', handleReset);
+ initializeTab();
+
+ return () => {
+ window.removeEventListener('storage', handleReset);
+ window.removeEventListener('tabConfigReset', handleReset);
+ };
+ }, []);
+
+ // Get detailed performance metrics
+ const getPerformanceMetrics = async (): Promise> => {
+ try {
+ // Get FPS
+ const fps = await measureFrameRate();
+
+ // Get page load metrics
+ const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
+ const pageLoad = navigation.loadEventEnd - navigation.startTime;
+ const domReady = navigation.domContentLoadedEventEnd - navigation.startTime;
+
+ // Get resource metrics
+ const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
+ const resourceMetrics = {
+ total: resources.length,
+ size: resources.reduce((total, r) => total + (r.transferSize || 0), 0),
+ loadTime: Math.max(0, ...resources.map((r) => r.duration)),
+ };
+
+ // Get Web Vitals
+ const ttfb = navigation.responseStart - navigation.requestStart;
+ const paintEntries = performance.getEntriesByType('paint');
+ const fcp = paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime || 0;
+ const lcpEntry = await getLargestContentfulPaint();
+
+ return {
+ fps,
+ pageLoad,
+ domReady,
+ resources: resourceMetrics,
+ timing: {
+ ttfb,
+ fcp,
+ lcp: lcpEntry?.startTime || 0,
+ },
+ };
+ } catch (error) {
+ console.error('Failed to get performance metrics:', error);
+ return {};
+ }
+ };
+
+ // Single useEffect for metrics updates
+ useEffect(() => {
+ let isComponentMounted = true;
+
+ const updateMetricsWrapper = async () => {
+ if (!isComponentMounted) {
+ return;
+ }
+
+ try {
+ await updateMetrics();
+ } catch (error) {
+ console.error('Failed to update metrics:', error);
+ }
+ };
+
+ // Initial update
+ updateMetricsWrapper();
+
+ // Set up interval with immediate assignment
+ const metricsInterval = setInterval(
+ updateMetricsWrapper,
+ energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
+ );
+
+ // Cleanup function
+ return () => {
+ isComponentMounted = false;
+ clearInterval(metricsInterval);
+ };
+ }, [energySaverMode]); // Only depend on energySaverMode
+
+ // Handle energy saver mode changes
+ const handleEnergySaverChange = (checked: boolean) => {
+ setEnergySaverMode(checked);
+ localStorage.setItem('energySaverMode', JSON.stringify(checked));
+ toast.success(checked ? 'Energy Saver mode enabled' : 'Energy Saver mode disabled');
+ };
+
+ // Handle auto energy saver changes
+ const handleAutoEnergySaverChange = (checked: boolean) => {
+ setAutoEnergySaver(checked);
+ localStorage.setItem('autoEnergySaver', JSON.stringify(checked));
+ toast.success(checked ? 'Auto Energy Saver enabled' : 'Auto Energy Saver disabled');
+
+ if (!checked) {
+ // When disabling auto mode, also disable energy saver mode
+ setEnergySaverMode(false);
+ localStorage.setItem('energySaverMode', 'false');
+ }
+ };
+
+ // Update energy savings calculation
+ const updateEnergySavings = useCallback(() => {
+ if (!energySaverMode) {
+ saverModeStartTime.current = null;
+ setEnergySavings({
+ updatesReduced: 0,
+ timeInSaverMode: 0,
+ estimatedEnergySaved: 0,
+ });
+
+ return;
+ }
+
+ if (!saverModeStartTime.current) {
+ saverModeStartTime.current = Date.now();
+ }
+
+ const timeInSaverMode = Math.max(0, (Date.now() - (saverModeStartTime.current || Date.now())) / 1000);
+
+ const normalUpdatesPerMinute = 60 / (UPDATE_INTERVALS.normal.metrics / 1000);
+ const saverUpdatesPerMinute = 60 / (UPDATE_INTERVALS.energySaver.metrics / 1000);
+ const updatesReduced = Math.floor((normalUpdatesPerMinute - saverUpdatesPerMinute) * (timeInSaverMode / 60));
+
+ const energyPerUpdate = ENERGY_COSTS.update;
+ const energySaved = (updatesReduced * energyPerUpdate) / 3600;
+
+ setEnergySavings({
+ updatesReduced,
+ timeInSaverMode,
+ estimatedEnergySaved: energySaved,
+ });
+ }, [energySaverMode]);
+
+ // Add interval for energy savings updates
+ useEffect(() => {
+ const interval = setInterval(updateEnergySavings, 1000);
+ return () => clearInterval(interval);
+ }, [updateEnergySavings]);
+
+ // Measure frame rate
+ const measureFrameRate = async (): Promise => {
+ return new Promise((resolve) => {
+ const frameCount = { value: 0 };
+ const startTime = performance.now();
+
+ const countFrame = (time: number) => {
+ frameCount.value++;
+
+ if (time - startTime >= 1000) {
+ resolve(Math.round((frameCount.value * 1000) / (time - startTime)));
+ } else {
+ requestAnimationFrame(countFrame);
+ }
+ };
+
+ requestAnimationFrame(countFrame);
+ });
+ };
+
+ // Get Largest Contentful Paint
+ const getLargestContentfulPaint = async (): Promise => {
+ return new Promise((resolve) => {
+ new PerformanceObserver((list) => {
+ const entries = list.getEntries();
+ resolve(entries[entries.length - 1]);
+ }).observe({ entryTypes: ['largest-contentful-paint'] });
+
+ // Resolve after 3 seconds if no LCP entry is found
+ setTimeout(() => resolve(undefined), 3000);
+ });
+ };
+
+ // Analyze system health
+ const analyzeSystemHealth = (currentMetrics: SystemMetrics): SystemMetrics['health'] => {
+ const issues: string[] = [];
+ const suggestions: string[] = [];
+ let score = 100;
+
+ // CPU analysis
+ if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.critical) {
+ score -= 30;
+ issues.push('Critical CPU usage');
+ suggestions.push('Consider closing resource-intensive applications');
+ } else if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.warning) {
+ score -= 15;
+ issues.push('High CPU usage');
+ suggestions.push('Monitor system processes for unusual activity');
+ }
+
+ // Memory analysis
+ if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.critical) {
+ score -= 30;
+ issues.push('Critical memory usage');
+ suggestions.push('Close unused applications to free up memory');
+ } else if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.warning) {
+ score -= 15;
+ issues.push('High memory usage');
+ suggestions.push('Consider freeing up memory by closing background applications');
+ }
+
+ // Performance analysis
+ if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical) {
+ score -= 20;
+ issues.push('Very low frame rate');
+ suggestions.push('Disable animations or switch to power saver mode');
+ } else if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.warning) {
+ score -= 10;
+ issues.push('Low frame rate');
+ suggestions.push('Consider reducing visual effects');
+ }
+
+ // Battery analysis
+ if (currentMetrics.battery && !currentMetrics.battery.charging && currentMetrics.battery.level < 20) {
+ score -= 10;
+ issues.push('Low battery');
+ suggestions.push('Connect to power source or enable power saver mode');
+ }
+
+ return {
+ score: Math.max(0, score),
+ issues,
+ suggestions,
+ };
+ };
+
+ // Update metrics with enhanced data
+ const updateMetrics = async () => {
+ try {
+ // Get memory info using Performance API
+ const memory = performance.memory || {
+ jsHeapSizeLimit: 0,
+ totalJSHeapSize: 0,
+ usedJSHeapSize: 0,
+ };
+ const totalMem = memory.totalJSHeapSize / (1024 * 1024);
+ const usedMem = memory.usedJSHeapSize / (1024 * 1024);
+ const memPercentage = (usedMem / totalMem) * 100;
+
+ // Get CPU usage using Performance API
+ const cpuUsage = await getCPUUsage();
+
+ // Get battery info
+ let batteryInfo: SystemMetrics['battery'] | undefined;
+
+ try {
+ const battery = await navigator.getBattery();
+ batteryInfo = {
+ level: battery.level * 100,
+ charging: battery.charging,
+ timeRemaining: battery.charging ? battery.chargingTime : battery.dischargingTime,
+ };
+ } catch {
+ console.log('Battery API not available');
+ }
+
+ // Get network info using Network Information API
+ const connection =
+ (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
+ const networkInfo = {
+ downlink: connection?.downlink || 0,
+ uplink: connection?.uplink,
+ latency: connection?.rtt || 0,
+ type: connection?.type || 'unknown',
+ activeConnections: connection?.activeConnections,
+ bytesReceived: connection?.bytesReceived || 0,
+ bytesSent: connection?.bytesSent || 0,
+ };
+
+ // Get enhanced performance metrics
+ const performanceMetrics = await getPerformanceMetrics();
+
+ const metrics: SystemMetrics = {
+ cpu: { usage: cpuUsage, cores: [], temperature: undefined, frequency: undefined },
+ memory: {
+ used: Math.round(usedMem),
+ total: Math.round(totalMem),
+ percentage: Math.round(memPercentage),
+ heap: {
+ used: Math.round(usedMem),
+ total: Math.round(totalMem),
+ limit: Math.round(totalMem),
+ },
+ },
+ uptime: performance.now() / 1000,
+ battery: batteryInfo,
+ network: networkInfo,
+ performance: performanceMetrics as SystemMetrics['performance'],
+ health: { score: 0, issues: [], suggestions: [] },
+ };
+
+ // Analyze system health
+ metrics.health = analyzeSystemHealth(metrics);
+
+ // Check for alerts
+ checkPerformanceAlerts(metrics);
+
+ setMetrics(metrics);
+
+ // Update metrics history
+ const now = new Date().toLocaleTimeString();
+ setMetricsHistory((prev) => {
+ const timestamps = [...prev.timestamps, now].slice(-MAX_HISTORY_POINTS);
+ const cpu = [...prev.cpu, metrics.cpu.usage].slice(-MAX_HISTORY_POINTS);
+ const memory = [...prev.memory, metrics.memory.percentage].slice(-MAX_HISTORY_POINTS);
+ const battery = [...prev.battery, batteryInfo?.level || 0].slice(-MAX_HISTORY_POINTS);
+ const network = [...prev.network, networkInfo.downlink].slice(-MAX_HISTORY_POINTS);
+
+ return { timestamps, cpu, memory, battery, network };
+ });
+ } catch (error) {
+ console.error('Failed to update system metrics:', error);
+ }
+ };
+
+ // Get real CPU usage using Performance API
+ const getCPUUsage = async (): Promise => {
+ try {
+ const t0 = performance.now();
+
+ // Create some actual work to measure and use the result
+ let result = 0;
+
+ for (let i = 0; i < 10000; i++) {
+ result += Math.random();
+ }
+
+ // Use result to prevent optimization
+ if (result < 0) {
+ console.log('Unexpected negative result');
+ }
+
+ const t1 = performance.now();
+ const timeTaken = t1 - t0;
+
+ /*
+ * Normalize to percentage (0-100)
+ * Lower time = higher CPU availability
+ */
+ const maxExpectedTime = 50; // baseline in ms
+ const cpuAvailability = Math.max(0, Math.min(100, ((maxExpectedTime - timeTaken) / maxExpectedTime) * 100));
+
+ return 100 - cpuAvailability; // Convert availability to usage
+ } catch (error) {
+ console.error('Failed to get CPU usage:', error);
+ return 0;
+ }
+ };
+
+ // Add network change listener
+ useEffect(() => {
+ const connection =
+ (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
+
+ if (!connection) {
+ return;
+ }
+
+ const updateNetworkInfo = () => {
+ setMetrics((prev) => ({
+ ...prev,
+ network: {
+ downlink: connection.downlink || 0,
+ latency: connection.rtt || 0,
+ type: connection.type || 'unknown',
+ bytesReceived: connection.bytesReceived || 0,
+ bytesSent: connection.bytesSent || 0,
+ },
+ }));
+ };
+
+ connection.addEventListener('change', updateNetworkInfo);
+
+ // eslint-disable-next-line consistent-return
+ return () => connection.removeEventListener('change', updateNetworkInfo);
+ }, []);
+
+ // Remove all animation and process monitoring
+ useEffect(() => {
+ const metricsInterval = setInterval(
+ () => {
+ if (!energySaverMode) {
+ updateMetrics();
+ }
+ },
+ energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
+ );
+
+ return () => {
+ clearInterval(metricsInterval);
+ };
+ }, [energySaverMode]);
+
+ const getUsageColor = (usage: number): string => {
+ if (usage > 80) {
+ return 'text-red-500';
+ }
+
+ if (usage > 50) {
+ return 'text-yellow-500';
+ }
+
+ return 'text-gray-500';
+ };
+
+ const renderUsageGraph = (data: number[], label: string, color: string) => {
+ const chartData = {
+ labels: metricsHistory.timestamps,
+ datasets: [
+ {
+ label,
+ data,
+ borderColor: color,
+ fill: false,
+ tension: 0.4,
+ },
+ ],
+ };
+
+ const options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ y: {
+ beginAtZero: true,
+ max: 100,
+ grid: {
+ color: 'rgba(255, 255, 255, 0.1)',
+ },
+ },
+ x: {
+ grid: {
+ display: false,
+ },
+ },
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ },
+ animation: {
+ duration: 0,
+ } as const,
+ };
+
+ return (
+
+
+
+ );
+ };
+
+ useEffect((): (() => void) | undefined => {
+ if (!autoEnergySaver) {
+ // If auto mode is disabled, clear any forced energy saver state
+ setEnergySaverMode(false);
+ return undefined;
+ }
+
+ const checkBatteryStatus = async () => {
+ try {
+ const battery = await navigator.getBattery();
+ const shouldEnableSaver = !battery.charging && battery.level * 100 <= BATTERY_THRESHOLD;
+ setEnergySaverMode(shouldEnableSaver);
+ } catch {
+ console.log('Battery API not available');
+ }
+ };
+
+ checkBatteryStatus();
+
+ const batteryCheckInterval = setInterval(checkBatteryStatus, 60000);
+
+ return () => clearInterval(batteryCheckInterval);
+ }, [autoEnergySaver]);
+
+ // Check for performance alerts
+ const checkPerformanceAlerts = (currentMetrics: SystemMetrics) => {
+ const newAlerts: PerformanceAlert[] = [];
+
+ // CPU alert
+ if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.critical) {
+ newAlerts.push({
+ type: 'error',
+ message: 'Critical CPU usage detected',
+ timestamp: Date.now(),
+ metric: 'cpu',
+ threshold: PERFORMANCE_THRESHOLDS.cpu.critical,
+ value: currentMetrics.cpu.usage,
+ });
+ }
+
+ // Memory alert
+ if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.critical) {
+ newAlerts.push({
+ type: 'error',
+ message: 'Critical memory usage detected',
+ timestamp: Date.now(),
+ metric: 'memory',
+ threshold: PERFORMANCE_THRESHOLDS.memory.critical,
+ value: currentMetrics.memory.percentage,
+ });
+ }
+
+ // Performance alert
+ if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical) {
+ newAlerts.push({
+ type: 'warning',
+ message: 'Very low frame rate detected',
+ timestamp: Date.now(),
+ metric: 'fps',
+ threshold: PERFORMANCE_THRESHOLDS.fps.critical,
+ value: currentMetrics.performance.fps,
+ });
+ }
+
+ if (newAlerts.length > 0) {
+ setAlerts((prev) => [...prev, ...newAlerts]);
+ newAlerts.forEach((alert) => {
+ toast.warning(alert.message);
+ });
+ }
+ };
+
+ return (
+
+ {/* Power Profile Selection */}
+
+
+
Power Management
+
+
+
handleAutoEnergySaverChange(e.target.checked)}
+ className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700"
+ />
+
+
+ Auto Energy Saver
+
+
+
+
!autoEnergySaver && handleEnergySaverChange(e.target.checked)}
+ disabled={autoEnergySaver}
+ className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700 disabled:opacity-50"
+ />
+
+
+ Energy Saver
+ {energySaverMode && Active }
+
+
+
+
{
+ const profile = POWER_PROFILES.find((p) => p.name === e.target.value);
+
+ if (profile) {
+ setSelectedProfile(profile);
+ toast.success(`Switched to ${profile.name} power profile`);
+ }
+ }}
+ className="pl-8 pr-8 py-1.5 rounded-md bg-bolt-background-secondary dark:bg-[#1E1E1E] border border-bolt-border dark:border-bolt-borderDark text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark hover:border-bolt-action-primary dark:hover:border-bolt-action-primary focus:outline-none focus:ring-1 focus:ring-bolt-action-primary appearance-none min-w-[160px] cursor-pointer transition-colors duration-150"
+ style={{ WebkitAppearance: 'none', MozAppearance: 'none' }}
+ >
+ {POWER_PROFILES.map((profile) => (
+
+ {profile.name}
+
+ ))}
+
+
+
+
+
+
+
{selectedProfile.description}
+
+
+ {/* System Health Score */}
+
+
System Health
+
+
+
+ Health Score
+ = 80,
+ 'text-yellow-500': metrics.health.score >= 60 && metrics.health.score < 80,
+ 'text-red-500': metrics.health.score < 60,
+ })}
+ >
+ {metrics.health.score}%
+
+
+ {metrics.health.issues.length > 0 && (
+
+
Issues:
+
+ {metrics.health.issues.map((issue, index) => (
+
+
+ {issue}
+
+ ))}
+
+
+ )}
+ {metrics.health.suggestions.length > 0 && (
+
+
Suggestions:
+
+ {metrics.health.suggestions.map((suggestion, index) => (
+
+
+ {suggestion}
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* System Metrics */}
+
+
System Metrics
+
+ {/* CPU Usage */}
+
+
+ CPU Usage
+
+ {Math.round(metrics.cpu.usage)}%
+
+
+ {renderUsageGraph(metricsHistory.cpu, 'CPU', '#9333ea')}
+ {metrics.cpu.temperature && (
+
+ Temperature: {metrics.cpu.temperature}°C
+
+ )}
+ {metrics.cpu.frequency && (
+
+ Frequency: {(metrics.cpu.frequency / 1000).toFixed(1)} GHz
+
+ )}
+
+
+ {/* Memory Usage */}
+
+
+ Memory Usage
+
+ {Math.round(metrics.memory.percentage)}%
+
+
+ {renderUsageGraph(metricsHistory.memory, 'Memory', '#2563eb')}
+
+ Used: {formatBytes(metrics.memory.used)}
+
+
Total: {formatBytes(metrics.memory.total)}
+
+ Heap: {formatBytes(metrics.memory.heap.used)} / {formatBytes(metrics.memory.heap.total)}
+
+
+
+ {/* Performance */}
+
+
+ Performance
+ = PERFORMANCE_THRESHOLDS.fps.warning,
+ })}
+ >
+ {Math.round(metrics.performance.fps)} FPS
+
+
+
+ Page Load: {(metrics.performance.pageLoad / 1000).toFixed(2)}s
+
+
+ DOM Ready: {(metrics.performance.domReady / 1000).toFixed(2)}s
+
+
+ TTFB: {(metrics.performance.timing.ttfb / 1000).toFixed(2)}s
+
+
+ Resources: {metrics.performance.resources.total} ({formatBytes(metrics.performance.resources.size)})
+
+
+
+ {/* Network */}
+
+
+ Network
+
+ {metrics.network.downlink.toFixed(1)} Mbps
+
+
+ {renderUsageGraph(metricsHistory.network, 'Network', '#f59e0b')}
+
Type: {metrics.network.type}
+
Latency: {metrics.network.latency}ms
+
+ Received: {formatBytes(metrics.network.bytesReceived)}
+
+
+ Sent: {formatBytes(metrics.network.bytesSent)}
+
+
+
+
+ {/* Battery Section */}
+ {metrics.battery && (
+
+
+
Battery
+
+ {metrics.battery.charging &&
}
+
20 ? 'text-bolt-elements-textPrimary' : 'text-red-500',
+ )}
+ >
+ {Math.round(metrics.battery.level)}%
+
+
+
+ {renderUsageGraph(metricsHistory.battery, 'Battery', '#22c55e')}
+ {metrics.battery.timeRemaining && (
+
+ {metrics.battery.charging ? 'Time to full: ' : 'Time remaining: '}
+ {formatTime(metrics.battery.timeRemaining)}
+
+ )}
+ {metrics.battery.temperature && (
+
+ Temperature: {metrics.battery.temperature}°C
+
+ )}
+ {metrics.battery.cycles && (
+
Charge cycles: {metrics.battery.cycles}
+ )}
+ {metrics.battery.health && (
+
Battery health: {metrics.battery.health}%
+ )}
+
+ )}
+
+ {/* Performance Alerts */}
+ {alerts.length > 0 && (
+
+
+ Recent Alerts
+ setAlerts([])}
+ className="text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
+ >
+ Clear All
+
+
+
+ {alerts.slice(-5).map((alert, index) => (
+
+
+
{alert.message}
+
+ {new Date(alert.timestamp).toLocaleTimeString()}
+
+
+ ))}
+
+
+ )}
+
+ {/* Energy Savings */}
+ {energySaverMode && (
+
+
Energy Savings
+
+
+
Updates Reduced
+
{energySavings.updatesReduced}
+
+
+
Time in Saver Mode
+
+ {Math.floor(energySavings.timeInSaverMode / 60)}m {Math.floor(energySavings.timeInSaverMode % 60)}s
+
+
+
+
Energy Saved
+
+ {energySavings.estimatedEnergySaved.toFixed(2)} mWh
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default React.memo(TaskManagerTab);
+
+// Helper function to format bytes
+const formatBytes = (bytes: number): string => {
+ if (bytes === 0) {
+ return '0 B';
+ }
+
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+};
+
+// Helper function to format time
+const formatTime = (seconds: number): string => {
+ if (!isFinite(seconds) || seconds === 0) {
+ return 'Unknown';
+ }
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ }
+
+ return `${minutes}m`;
+};
diff --git a/app/components/@settings/tabs/update/UpdateTab.tsx b/app/components/@settings/tabs/update/UpdateTab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..53c05d0fbab234fa7db702973d02ff56b3a68765
--- /dev/null
+++ b/app/components/@settings/tabs/update/UpdateTab.tsx
@@ -0,0 +1,628 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { useSettings } from '~/lib/hooks/useSettings';
+import { logStore } from '~/lib/stores/logs';
+import { toast } from 'react-toastify';
+import { Dialog, DialogRoot, DialogTitle, DialogDescription, DialogButton } from '~/components/ui/Dialog';
+import { classNames } from '~/utils/classNames';
+import { Markdown } from '~/components/chat/Markdown';
+
+interface UpdateProgress {
+ stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete';
+ message: string;
+ progress?: number;
+ error?: string;
+ details?: {
+ changedFiles?: string[];
+ additions?: number;
+ deletions?: number;
+ commitMessages?: string[];
+ totalSize?: string;
+ currentCommit?: string;
+ remoteCommit?: string;
+ updateReady?: boolean;
+ changelog?: string;
+ compareUrl?: string;
+ };
+}
+
+interface UpdateSettings {
+ autoUpdate: boolean;
+ notifyInApp: boolean;
+ checkInterval: number;
+}
+
+const ProgressBar = ({ progress }: { progress: number }) => (
+
+
+
+);
+
+const UpdateProgressDisplay = ({ progress }: { progress: UpdateProgress }) => (
+
+
+ {progress.message}
+ {progress.progress}%
+
+
+ {progress.details && (
+
+ {progress.details.changedFiles && progress.details.changedFiles.length > 0 && (
+
+
Changed Files:
+
+ {/* Group files by type */}
+ {['Modified', 'Added', 'Deleted'].map((type) => {
+ const filesOfType = progress.details?.changedFiles?.filter((file) => file.startsWith(type)) || [];
+
+ if (filesOfType.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {type} ({filesOfType.length})
+
+
+ {filesOfType.map((file, index) => {
+ const fileName = file.split(': ')[1];
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+ )}
+ {progress.details.totalSize &&
Total size: {progress.details.totalSize}
}
+ {progress.details.additions !== undefined && progress.details.deletions !== undefined && (
+
+ Changes: +{progress.details.additions} {' '}
+ -{progress.details.deletions}
+
+ )}
+ {progress.details.currentCommit && progress.details.remoteCommit && (
+
+ Updating from {progress.details.currentCommit} to {progress.details.remoteCommit}
+
+ )}
+
+ )}
+
+);
+
+const UpdateTab = () => {
+ const { isLatestBranch } = useSettings();
+ const [isChecking, setIsChecking] = useState(false);
+ const [error, setError] = useState(null);
+ const [updateSettings, setUpdateSettings] = useState(() => {
+ const stored = localStorage.getItem('update_settings');
+ return stored
+ ? JSON.parse(stored)
+ : {
+ autoUpdate: false,
+ notifyInApp: true,
+ checkInterval: 24,
+ };
+ });
+ const [showUpdateDialog, setShowUpdateDialog] = useState(false);
+ const [updateProgress, setUpdateProgress] = useState(null);
+
+ useEffect(() => {
+ localStorage.setItem('update_settings', JSON.stringify(updateSettings));
+ }, [updateSettings]);
+
+ const checkForUpdates = async () => {
+ console.log('Starting update check...');
+ setIsChecking(true);
+ setError(null);
+ setUpdateProgress(null);
+
+ try {
+ const branchToCheck = isLatestBranch ? 'main' : 'stable';
+
+ // Start the update check with streaming progress
+ const response = await fetch('/api/update', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ branch: branchToCheck,
+ autoUpdate: updateSettings.autoUpdate,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Update check failed: ${response.statusText}`);
+ }
+
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('No response stream available');
+ }
+
+ // Read the stream
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ // Convert the chunk to text and parse the JSON
+ const chunk = new TextDecoder().decode(value);
+ const lines = chunk.split('\n').filter(Boolean);
+
+ for (const line of lines) {
+ try {
+ const progress = JSON.parse(line) as UpdateProgress;
+ setUpdateProgress(progress);
+
+ if (progress.error) {
+ setError(progress.error);
+ }
+
+ // If we're done, update the UI accordingly
+ if (progress.stage === 'complete') {
+ setIsChecking(false);
+
+ if (!progress.error) {
+ // Update check completed
+ toast.success('Update check completed');
+
+ // Show update dialog only if there are changes and auto-update is disabled
+ if (progress.details?.changedFiles?.length && progress.details.updateReady) {
+ setShowUpdateDialog(true);
+ }
+ }
+ }
+ } catch (e) {
+ console.error('Error parsing progress update:', e);
+ }
+ }
+ }
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Unknown error occurred');
+ logStore.logWarning('Update Check Failed', {
+ type: 'update',
+ message: error instanceof Error ? error.message : 'Unknown error occurred',
+ });
+ } finally {
+ setIsChecking(false);
+ }
+ };
+
+ const handleUpdate = async () => {
+ setShowUpdateDialog(false);
+
+ try {
+ const branchToCheck = isLatestBranch ? 'main' : 'stable';
+
+ // Start the update with autoUpdate set to true to force the update
+ const response = await fetch('/api/update', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ branch: branchToCheck,
+ autoUpdate: true,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Update failed: ${response.statusText}`);
+ }
+
+ // Handle the update progress stream
+ const reader = response.body?.getReader();
+
+ if (!reader) {
+ throw new Error('No response stream available');
+ }
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ const chunk = new TextDecoder().decode(value);
+ const lines = chunk.split('\n').filter(Boolean);
+
+ for (const line of lines) {
+ try {
+ const progress = JSON.parse(line) as UpdateProgress;
+ setUpdateProgress(progress);
+
+ if (progress.error) {
+ setError(progress.error);
+ toast.error('Update failed');
+ }
+
+ if (progress.stage === 'complete' && !progress.error) {
+ toast.success('Update completed successfully');
+ }
+ } catch (e) {
+ console.error('Error parsing update progress:', e);
+ }
+ }
+ }
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Unknown error occurred');
+ toast.error('Update failed');
+ }
+ };
+
+ return (
+
+
+
+
+
Updates
+
Check for and manage application updates
+
+
+
+ {/* Update Settings Card */}
+
+
+
+
+
+
+
Automatic Updates
+
+ Automatically check and apply updates when available
+
+
+
setUpdateSettings((prev) => ({ ...prev, autoUpdate: !prev.autoUpdate }))}
+ className={classNames(
+ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
+ updateSettings.autoUpdate ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
+ )}
+ >
+
+
+
+
+
+
+
In-App Notifications
+
Show notifications when updates are available
+
+
setUpdateSettings((prev) => ({ ...prev, notifyInApp: !prev.notifyInApp }))}
+ className={classNames(
+ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
+ updateSettings.notifyInApp ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
+ )}
+ >
+
+
+
+
+
+
+
Check Interval
+
How often to check for updates
+
+
setUpdateSettings((prev) => ({ ...prev, checkInterval: Number(e.target.value) }))}
+ className={classNames(
+ 'px-3 py-2 rounded-lg text-sm',
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
+ 'text-bolt-elements-textPrimary',
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#2A2A2A]',
+ 'transition-colors duration-200',
+ )}
+ >
+ 6 hours
+ 12 hours
+ 24 hours
+ 48 hours
+
+
+
+
+
+ {/* Update Status Card */}
+
+
+
+
+ {updateProgress?.details?.updateReady && !updateSettings.autoUpdate && (
+
+
+ Update Now
+
+ )}
+
{
+ setError(null);
+ checkForUpdates();
+ }}
+ className={classNames(
+ 'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
+ 'hover:bg-purple-500/10 hover:text-purple-500',
+ 'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
+ 'text-bolt-elements-textPrimary',
+ 'transition-colors duration-200',
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
+ )}
+ disabled={isChecking}
+ >
+ {isChecking ? (
+
+
+ Checking...
+
+ ) : (
+ <>
+
+ Check for Updates
+ >
+ )}
+
+
+
+
+ {/* Show progress information */}
+ {updateProgress && }
+
+ {error && {error}
}
+
+ {/* Show update source information */}
+ {updateProgress?.details?.currentCommit && updateProgress?.details?.remoteCommit && (
+
+
+
+
+ Updates are fetched from: stackblitz-labs/bolt.diy (
+ {isLatestBranch ? 'main' : 'stable'} branch)
+
+
+ Current version: {updateProgress.details.currentCommit}
+ →
+ Latest version: {updateProgress.details.remoteCommit}
+
+
+ {updateProgress?.details?.compareUrl && (
+
+
+ View Changes on GitHub
+
+ )}
+
+ {updateProgress?.details?.additions !== undefined && updateProgress?.details?.deletions !== undefined && (
+
+
+ Changes:
+{updateProgress.details.additions} {' '}
+
-{updateProgress.details.deletions}
+
+ )}
+
+ )}
+
+ {/* Add this before the changed files section */}
+ {updateProgress?.details?.changelog && (
+
+
+
+
+ {updateProgress.details.changelog}
+
+
+
+ )}
+
+ {/* Add this in the update status card, after the commit info */}
+ {updateProgress?.details?.compareUrl && (
+
+ )}
+
+ {updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
+
+
Changes in this Update:
+
+
+ {updateProgress.details.commitMessages.map((section, index) => (
+ {section}
+ ))}
+
+
+
+ )}
+
+
+ {/* Update dialog */}
+
+
+ Update Available
+
+
+
+ A new version is available from stackblitz-labs/bolt.diy (
+ {isLatestBranch ? 'main' : 'stable'} branch)
+
+
+ {updateProgress?.details?.compareUrl && (
+
+ )}
+
+ {updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
+
+
Commit Messages:
+
+ {updateProgress.details.commitMessages.map((msg, index) => (
+
+ ))}
+
+
+ )}
+
+ {updateProgress?.details?.totalSize && (
+
+
+
+ Total size: {updateProgress.details.totalSize}
+
+ {updateProgress?.details?.additions !== undefined &&
+ updateProgress?.details?.deletions !== undefined && (
+
+
+ Changes:
+{updateProgress.details.additions} {' '}
+
-{updateProgress.details.deletions}
+
+ )}
+
+ )}
+
+
+
+ setShowUpdateDialog(false)}>
+ Cancel
+
+
+ Update Now
+
+
+
+
+
+ );
+};
+
+export default UpdateTab;
diff --git a/app/components/@settings/utils/animations.ts b/app/components/@settings/utils/animations.ts
new file mode 100644
index 0000000000000000000000000000000000000000..48d27e8be98132923fd9963e13e0d479da53a2ef
--- /dev/null
+++ b/app/components/@settings/utils/animations.ts
@@ -0,0 +1,41 @@
+import type { Variants } from 'framer-motion';
+
+export const fadeIn: Variants = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0 },
+};
+
+export const slideIn: Variants = {
+ initial: { opacity: 0, y: 20 },
+ animate: { opacity: 1, y: 0 },
+ exit: { opacity: 0, y: -20 },
+};
+
+export const scaleIn: Variants = {
+ initial: { opacity: 0, scale: 0.8 },
+ animate: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, scale: 0.8 },
+};
+
+export const tabAnimation: Variants = {
+ initial: { opacity: 0, scale: 0.8, y: 20 },
+ animate: { opacity: 1, scale: 1, y: 0 },
+ exit: { opacity: 0, scale: 0.8, y: -20 },
+};
+
+export const overlayAnimation: Variants = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0 },
+};
+
+export const modalAnimation: Variants = {
+ initial: { opacity: 0, scale: 0.95, y: 20 },
+ animate: { opacity: 1, scale: 1, y: 0 },
+ exit: { opacity: 0, scale: 0.95, y: 20 },
+};
+
+export const transition = {
+ duration: 0.2,
+};
diff --git a/app/components/@settings/utils/tab-helpers.ts b/app/components/@settings/utils/tab-helpers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7a55eca7fa0fa3814fdfa09a4d5f75a81f54ee4f
--- /dev/null
+++ b/app/components/@settings/utils/tab-helpers.ts
@@ -0,0 +1,89 @@
+import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types';
+import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
+
+export const getVisibleTabs = (
+ tabConfiguration: { userTabs: TabVisibilityConfig[]; developerTabs?: TabVisibilityConfig[] },
+ isDeveloperMode: boolean,
+ notificationsEnabled: boolean,
+): TabVisibilityConfig[] => {
+ if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
+ console.warn('Invalid tab configuration, using defaults');
+ return DEFAULT_TAB_CONFIG as TabVisibilityConfig[];
+ }
+
+ // In developer mode, show ALL tabs without restrictions
+ if (isDeveloperMode) {
+ // Combine all unique tabs from both user and developer configurations
+ const allTabs = new Set([
+ ...DEFAULT_TAB_CONFIG.map((tab) => tab.id),
+ ...tabConfiguration.userTabs.map((tab) => tab.id),
+ ...(tabConfiguration.developerTabs || []).map((tab) => tab.id),
+ 'task-manager' as TabType, // Always include task-manager in developer mode
+ ]);
+
+ // Create a complete tab list with all tabs visible
+ const devTabs = Array.from(allTabs).map((tabId) => {
+ // Try to find existing configuration for this tab
+ const existingTab =
+ tabConfiguration.developerTabs?.find((t) => t.id === tabId) ||
+ tabConfiguration.userTabs?.find((t) => t.id === tabId) ||
+ DEFAULT_TAB_CONFIG.find((t) => t.id === tabId);
+
+ return {
+ id: tabId as TabType,
+ visible: true,
+ window: 'developer' as const,
+ order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId),
+ } as TabVisibilityConfig;
+ });
+
+ return devTabs.sort((a, b) => a.order - b.order);
+ }
+
+ // In user mode, only show visible user tabs
+ return tabConfiguration.userTabs
+ .filter((tab) => {
+ if (!tab || typeof tab.id !== 'string') {
+ console.warn('Invalid tab entry:', tab);
+ return false;
+ }
+
+ // Hide notifications tab if notifications are disabled
+ if (tab.id === 'notifications' && !notificationsEnabled) {
+ return false;
+ }
+
+ // Always show task-manager in user mode if it's configured as visible
+ if (tab.id === 'task-manager') {
+ return tab.visible;
+ }
+
+ // Only show tabs that are explicitly visible and assigned to the user window
+ return tab.visible && tab.window === 'user';
+ })
+ .sort((a, b) => a.order - b.order);
+};
+
+export const reorderTabs = (
+ tabs: TabVisibilityConfig[],
+ startIndex: number,
+ endIndex: number,
+): TabVisibilityConfig[] => {
+ const result = Array.from(tabs);
+ const [removed] = result.splice(startIndex, 1);
+ result.splice(endIndex, 0, removed);
+
+ // Update order property
+ return result.map((tab, index) => ({
+ ...tab,
+ order: index,
+ }));
+};
+
+export const resetToDefaultConfig = (isDeveloperMode: boolean): TabVisibilityConfig[] => {
+ return DEFAULT_TAB_CONFIG.map((tab) => ({
+ ...tab,
+ visible: isDeveloperMode ? true : tab.window === 'user',
+ window: isDeveloperMode ? 'developer' : tab.window,
+ })) as TabVisibilityConfig[];
+};
diff --git a/app/components/chat/APIKeyManager.tsx b/app/components/chat/APIKeyManager.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9c10b6fc097bb1c3dfb617f0aa30f323f9fbbae5
--- /dev/null
+++ b/app/components/chat/APIKeyManager.tsx
@@ -0,0 +1,195 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { IconButton } from '~/components/ui/IconButton';
+import { Switch } from '~/components/ui/Switch';
+import type { ProviderInfo } from '~/types/model';
+import Cookies from 'js-cookie';
+interface APIKeyManagerProps {
+ provider: ProviderInfo;
+ apiKey: string;
+ setApiKey: (key: string) => void;
+ getApiKeyLink?: string;
+ labelForGetApiKey?: string;
+}
+
+// cache which stores whether the provider's API key is set via environment variable
+const providerEnvKeyStatusCache: Record = {};
+
+const apiKeyMemoizeCache: { [k: string]: Record } = {};
+
+export function getApiKeysFromCookies() {
+ const storedApiKeys = Cookies.get('apiKeys');
+ let parsedKeys: Record = {};
+
+ if (storedApiKeys) {
+ parsedKeys = apiKeyMemoizeCache[storedApiKeys];
+
+ if (!parsedKeys) {
+ parsedKeys = apiKeyMemoizeCache[storedApiKeys] = JSON.parse(storedApiKeys);
+ }
+ }
+
+ return parsedKeys;
+}
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const APIKeyManager: React.FC = ({ provider, apiKey, setApiKey }) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [tempKey, setTempKey] = useState(apiKey);
+ const [isPromptCachingEnabled, setIsPromptCachingEnabled] = useState(() => {
+ // Read initial state from localStorage, defaulting to true
+ const savedState = localStorage.getItem('PROMPT_CACHING_ENABLED');
+ return savedState !== null ? JSON.parse(savedState) : true;
+ });
+ const [isEnvKeySet, setIsEnvKeySet] = useState(false);
+
+ useEffect(() => {
+ // Update localStorage whenever the prompt caching state changes
+ localStorage.setItem('PROMPT_CACHING_ENABLED', JSON.stringify(isPromptCachingEnabled));
+ }, [isPromptCachingEnabled]);
+
+ // Reset states and load saved key when provider changes
+ useEffect(() => {
+ // Load saved API key from cookies for this provider
+ const savedKeys = getApiKeysFromCookies();
+ const savedKey = savedKeys[provider.name] || '';
+
+ setTempKey(savedKey);
+ setApiKey(savedKey);
+ setIsEditing(false);
+ }, [provider.name]);
+
+ const checkEnvApiKey = useCallback(async () => {
+ // Check cache first
+ if (providerEnvKeyStatusCache[provider.name] !== undefined) {
+ setIsEnvKeySet(providerEnvKeyStatusCache[provider.name]);
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/check-env-key?provider=${encodeURIComponent(provider.name)}`);
+ const data = await response.json();
+ const isSet = (data as { isSet: boolean }).isSet;
+
+ // Cache the result
+ providerEnvKeyStatusCache[provider.name] = isSet;
+ setIsEnvKeySet(isSet);
+ } catch (error) {
+ console.error('Failed to check environment API key:', error);
+ setIsEnvKeySet(false);
+ }
+ }, [provider.name]);
+
+ useEffect(() => {
+ checkEnvApiKey();
+ }, [checkEnvApiKey]);
+
+ const handleSave = () => {
+ // Save to parent state
+ setApiKey(tempKey);
+
+ // Save to cookies
+ const currentKeys = getApiKeysFromCookies();
+ const newKeys = { ...currentKeys, [provider.name]: tempKey };
+ Cookies.set('apiKeys', JSON.stringify(newKeys));
+
+ setIsEditing(false);
+ };
+
+ return (
+
+
+
+
+
{provider?.name} API Key:
+ {!isEditing && (
+
+ {apiKey ? (
+ <>
+
+
Set via UI
+ >
+ ) : isEnvKeySet ? (
+ <>
+
+
Set via environment variable
+ >
+ ) : (
+ <>
+
+
Not Set (Please set via UI or ENV_VAR)
+ >
+ )}
+
+ )}
+
+
+
+
+ {isEditing ? (
+
+
setTempKey(e.target.value)}
+ className="w-[300px] px-3 py-1.5 text-sm rounded border border-bolt-elements-borderColor
+ 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(true)}
+ title="Edit API Key"
+ className="bg-blue-500/10 hover:bg-blue-500/20 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"
+ >
+ {provider?.labelForGetApiKey || 'Get API Key'}
+
+
+ )}
+ >
+ )}
+
+
+
+ {provider?.name === 'Anthropic' && (
+
+
+
+
+ Enable Prompt Caching
+
+
+
+ When enabled, generates 10x cheaper responses if re-prompted within 5 mins (Recommended)
+
+
+ )}
+
+ );
+};
diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5f0c99106df922b915de3bf5a64d49d94c2a0864
--- /dev/null
+++ b/app/components/chat/Artifact.tsx
@@ -0,0 +1,263 @@
+import { useStore } from '@nanostores/react';
+import { AnimatePresence, motion } from 'framer-motion';
+import { computed } from 'nanostores';
+import { memo, useEffect, useRef, useState } from 'react';
+import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
+import type { ActionState } from '~/lib/runtime/action-runner';
+import { workbenchStore } from '~/lib/stores/workbench';
+import { classNames } from '~/utils/classNames';
+import { cubicEasingFn } from '~/utils/easings';
+import { WORK_DIR } from '~/utils/constants';
+
+const highlighterOptions = {
+ langs: ['shell'],
+ themes: ['light-plus', 'dark-plus'],
+};
+
+const shellHighlighter: HighlighterGeneric =
+ import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
+
+if (import.meta.hot) {
+ import.meta.hot.data.shellHighlighter = shellHighlighter;
+}
+
+interface ArtifactProps {
+ messageId: string;
+}
+
+export const Artifact = memo(({ messageId }: ArtifactProps) => {
+ const userToggledActions = useRef(false);
+ const [showActions, setShowActions] = useState(false);
+ const [allActionFinished, setAllActionFinished] = useState(false);
+
+ const artifacts = useStore(workbenchStore.artifacts);
+ const artifact = artifacts[messageId];
+
+ const actions = useStore(
+ computed(artifact.runner.actions, (actions) => {
+ return Object.values(actions);
+ }),
+ );
+
+ const toggleActions = () => {
+ userToggledActions.current = true;
+ setShowActions(!showActions);
+ };
+
+ useEffect(() => {
+ if (actions.length && !showActions && !userToggledActions.current) {
+ setShowActions(true);
+ }
+
+ if (actions.length !== 0 && artifact.type === 'bundled') {
+ const finished = !actions.find((action) => action.status !== 'complete');
+
+ if (allActionFinished !== finished) {
+ setAllActionFinished(finished);
+ }
+ }
+ }, [actions]);
+
+ return (
+
+
+
{
+ const showWorkbench = workbenchStore.showWorkbench.get();
+ workbenchStore.showWorkbench.set(!showWorkbench);
+ }}
+ >
+ {artifact.type == 'bundled' && (
+ <>
+
+ {allActionFinished ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ )}
+
+
{artifact?.title}
+
Click to open Workbench
+
+
+
+
+ {actions.length && artifact.type !== 'bundled' && (
+
+
+
+ )}
+
+
+
+ {artifact.type !== 'bundled' && showActions && actions.length > 0 && (
+
+
+
+
+
+ )}
+
+
+ );
+});
+
+interface ShellCodeBlockProps {
+ classsName?: string;
+ code: string;
+}
+
+function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
+ return (
+
+ );
+}
+
+interface ActionListProps {
+ actions: ActionState[];
+}
+
+const actionVariants = {
+ hidden: { opacity: 0, y: 20 },
+ visible: { opacity: 1, y: 0 },
+};
+
+function openArtifactInWorkbench(filePath: any) {
+ if (workbenchStore.currentView.get() !== 'code') {
+ workbenchStore.currentView.set('code');
+ }
+
+ workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
+}
+
+const ActionList = memo(({ actions }: ActionListProps) => {
+ return (
+
+
+ {actions.map((action, index) => {
+ const { status, type, content } = action;
+ const isLast = index === actions.length - 1;
+
+ return (
+
+
+ {(type === 'shell' || type === 'start') && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+});
+
+function getIconColor(status: ActionState['status']) {
+ switch (status) {
+ case 'pending': {
+ return 'text-bolt-elements-textTertiary';
+ }
+ case 'running': {
+ return 'text-bolt-elements-loader-progress';
+ }
+ case 'complete': {
+ return 'text-bolt-elements-icon-success';
+ }
+ case 'aborted': {
+ return 'text-bolt-elements-textSecondary';
+ }
+ case 'failed': {
+ return 'text-bolt-elements-icon-error';
+ }
+ default: {
+ return undefined;
+ }
+ }
+}
diff --git a/app/components/chat/AssistantMessage.tsx b/app/components/chat/AssistantMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4eab51950e58ab154caee9ddf22d447e281d58a8
--- /dev/null
+++ b/app/components/chat/AssistantMessage.tsx
@@ -0,0 +1,120 @@
+import { memo } from 'react';
+import { Markdown } from './Markdown';
+import type { JSONValue } from 'ai';
+import Popover from '~/components/ui/Popover';
+import { workbenchStore } from '~/lib/stores/workbench';
+import { WORK_DIR } from '~/utils/constants';
+
+interface AssistantMessageProps {
+ content: string;
+ annotations?: JSONValue[];
+}
+
+function openArtifactInWorkbench(filePath: string) {
+ filePath = normalizedFilePath(filePath);
+
+ if (workbenchStore.currentView.get() !== 'code') {
+ workbenchStore.currentView.set('code');
+ }
+
+ workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
+}
+
+function normalizedFilePath(path: string) {
+ let normalizedPath = path;
+
+ if (normalizedPath.startsWith(WORK_DIR)) {
+ normalizedPath = path.replace(WORK_DIR, '');
+ }
+
+ if (normalizedPath.startsWith('/')) {
+ normalizedPath = normalizedPath.slice(1);
+ }
+
+ return normalizedPath;
+}
+
+export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
+ const filteredAnnotations = (annotations?.filter(
+ (annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
+ ) || []) as { type: string; value: any } & { [key: string]: any }[];
+
+ let chatSummary: string | undefined = undefined;
+
+ if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
+ chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
+ }
+
+ let codeContext: string[] | undefined = undefined;
+
+ if (filteredAnnotations.find((annotation) => annotation.type === 'codeContext')) {
+ codeContext = filteredAnnotations.find((annotation) => annotation.type === 'codeContext')?.files;
+ }
+
+ const usage: {
+ completionTokens: number;
+ promptTokens: number;
+ totalTokens: number;
+ isCacheHit?: boolean;
+ isCacheMiss?: boolean;
+ } = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value ?? undefined;
+
+ const cacheHitMsg = usage?.isCacheHit ? ' [Cache Hit]' : '';
+ const cacheMissMsg = usage?.isCacheMiss ? ' [Cache Miss]' : '';
+
+ return (
+
+ <>
+
+ {(codeContext || chatSummary) && (
+
}>
+ {chatSummary && (
+
+
+
Summary
+
+ {chatSummary}
+
+
+ {codeContext && (
+
+
Context
+
+ {codeContext.map((x) => {
+ const normalized = normalizedFilePath(x);
+ return (
+ <>
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ openArtifactInWorkbench(normalized);
+ }}
+ >
+ {normalized}
+
+ >
+ );
+ })}
+
+
+ )}
+
+ )}
+
+
+ )}
+ {usage && (
+
+ Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
+ {cacheHitMsg}
+ {cacheMissMsg}
+
+ )}
+
+ >
+
{content}
+
+ );
+});
diff --git a/app/components/chat/BaseChat.module.scss b/app/components/chat/BaseChat.module.scss
new file mode 100644
index 0000000000000000000000000000000000000000..4908e34e05d90eb08c7da48946631d5a68ee7e2e
--- /dev/null
+++ b/app/components/chat/BaseChat.module.scss
@@ -0,0 +1,47 @@
+.BaseChat {
+ &[data-chat-visible='false'] {
+ --workbench-inner-width: 100%;
+ --workbench-left: 0;
+
+ .Chat {
+ --at-apply: bolt-ease-cubic-bezier;
+ transition-property: transform, opacity;
+ transition-duration: 0.3s;
+ will-change: transform, opacity;
+ transform: translateX(-50%);
+ opacity: 0;
+ }
+ }
+}
+
+.Chat {
+ opacity: 1;
+}
+
+.PromptEffectContainer {
+ --prompt-container-offset: 50px;
+ --prompt-line-stroke-width: 1px;
+ position: absolute;
+ pointer-events: none;
+ inset: calc(var(--prompt-container-offset) / -2);
+ width: calc(100% + var(--prompt-container-offset));
+ height: calc(100% + var(--prompt-container-offset));
+}
+
+.PromptEffectLine {
+ width: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
+ height: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
+ x: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
+ y: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
+ rx: calc(8px - var(--prompt-line-stroke-width));
+ fill: transparent;
+ stroke-width: var(--prompt-line-stroke-width);
+ stroke: url(#line-gradient);
+ stroke-dasharray: 35px 65px;
+ stroke-dashoffset: 10;
+}
+
+.PromptShine {
+ fill: url(#shine-gradient);
+ mix-blend-mode: overlay;
+}
diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..12929b10fb420f35bf12ae39ba3797757abd2463
--- /dev/null
+++ b/app/components/chat/BaseChat.tsx
@@ -0,0 +1,630 @@
+/*
+ * @ts-nocheck
+ * Preventing TS checks with files presented in the video for a better presentation.
+ */
+import type { JSONValue, Message } from 'ai';
+import React, { type RefCallback, useEffect, useState } from 'react';
+import { ClientOnly } from 'remix-utils/client-only';
+import { Menu } from '~/components/sidebar/Menu.client';
+import { IconButton } from '~/components/ui/IconButton';
+import { Workbench } from '~/components/workbench/Workbench.client';
+import { classNames } from '~/utils/classNames';
+import { PROVIDER_LIST } from '~/utils/constants';
+import { Messages } from './Messages.client';
+import { SendButton } from './SendButton.client';
+import { APIKeyManager, getApiKeysFromCookies } from './APIKeyManager';
+import Cookies from 'js-cookie';
+import * as Tooltip from '@radix-ui/react-tooltip';
+
+import styles from './BaseChat.module.scss';
+import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
+import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
+import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
+import GitCloneButton from './GitCloneButton';
+
+import FilePreview from './FilePreview';
+import { ModelSelector } from '~/components/chat/ModelSelector';
+import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
+import type { ProviderInfo } from '~/types/model';
+import { ScreenshotStateManager } from './ScreenshotStateManager';
+import { toast } from 'react-toastify';
+import StarterTemplates from './StarterTemplates';
+import type { ActionAlert } from '~/types/actions';
+import ChatAlert from './ChatAlert';
+import type { ModelInfo } from '~/lib/modules/llm/types';
+import ProgressCompilation from './ProgressCompilation';
+import type { ProgressAnnotation } from '~/types/context';
+import type { ActionRunner } from '~/lib/runtime/action-runner';
+import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
+
+const TEXTAREA_MIN_HEIGHT = 76;
+
+interface BaseChatProps {
+ textareaRef?: React.RefObject | undefined;
+ messageRef?: RefCallback | undefined;
+ scrollRef?: RefCallback | undefined;
+ showChat?: boolean;
+ chatStarted?: boolean;
+ isStreaming?: boolean;
+ onStreamingChange?: (streaming: boolean) => void;
+ messages?: Message[];
+ description?: string;
+ enhancingPrompt?: boolean;
+ promptEnhanced?: boolean;
+ input?: string;
+ model?: string;
+ setModel?: (model: string) => void;
+ provider?: ProviderInfo;
+ setProvider?: (provider: ProviderInfo) => void;
+ providerList?: ProviderInfo[];
+ handleStop?: () => void;
+ sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
+ handleInputChange?: (event: React.ChangeEvent) => void;
+ enhancePrompt?: () => void;
+ importChat?: (description: string, messages: Message[]) => Promise;
+ exportChat?: () => void;
+ uploadedFiles?: File[];
+ setUploadedFiles?: (files: File[]) => void;
+ imageDataList?: string[];
+ setImageDataList?: (dataList: string[]) => void;
+ actionAlert?: ActionAlert;
+ clearAlert?: () => void;
+ data?: JSONValue[] | undefined;
+ actionRunner?: ActionRunner;
+}
+
+export const BaseChat = React.forwardRef(
+ (
+ {
+ textareaRef,
+ messageRef,
+ scrollRef,
+ showChat = true,
+ chatStarted = false,
+ isStreaming = false,
+ onStreamingChange,
+ model,
+ setModel,
+ provider,
+ setProvider,
+ providerList,
+ input = '',
+ enhancingPrompt,
+ handleInputChange,
+
+ // promptEnhanced,
+ enhancePrompt,
+ sendMessage,
+ handleStop,
+ importChat,
+ exportChat,
+ uploadedFiles = [],
+ setUploadedFiles,
+ imageDataList = [],
+ setImageDataList,
+ messages,
+ actionAlert,
+ clearAlert,
+ data,
+ actionRunner,
+ },
+ ref,
+ ) => {
+ const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
+ const [apiKeys, setApiKeys] = useState>(getApiKeysFromCookies());
+ const [modelList, setModelList] = useState([]);
+ const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
+ const [isListening, setIsListening] = useState(false);
+ const [recognition, setRecognition] = useState(null);
+ const [transcript, setTranscript] = useState('');
+ const [isModelLoading, setIsModelLoading] = useState('all');
+ const [progressAnnotations, setProgressAnnotations] = useState([]);
+ useEffect(() => {
+ if (data) {
+ const progressList = data.filter(
+ (x) => typeof x === 'object' && (x as any).type === 'progress',
+ ) as ProgressAnnotation[];
+ setProgressAnnotations(progressList);
+ }
+ }, [data]);
+ useEffect(() => {
+ console.log(transcript);
+ }, [transcript]);
+
+ useEffect(() => {
+ onStreamingChange?.(isStreaming);
+ }, [isStreaming, onStreamingChange]);
+
+ useEffect(() => {
+ if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+ const recognition = new SpeechRecognition();
+ recognition.continuous = true;
+ recognition.interimResults = true;
+
+ recognition.onresult = (event) => {
+ const transcript = Array.from(event.results)
+ .map((result) => result[0])
+ .map((result) => result.transcript)
+ .join('');
+
+ setTranscript(transcript);
+
+ if (handleInputChange) {
+ const syntheticEvent = {
+ target: { value: transcript },
+ } as React.ChangeEvent;
+ handleInputChange(syntheticEvent);
+ }
+ };
+
+ recognition.onerror = (event) => {
+ console.error('Speech recognition error:', event.error);
+ setIsListening(false);
+ };
+
+ setRecognition(recognition);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ let parsedApiKeys: Record | undefined = {};
+
+ try {
+ parsedApiKeys = getApiKeysFromCookies();
+ setApiKeys(parsedApiKeys);
+ } catch (error) {
+ console.error('Error loading API keys from cookies:', error);
+ Cookies.remove('apiKeys');
+ }
+
+ setIsModelLoading('all');
+ fetch('/api/models')
+ .then((response) => response.json())
+ .then((data) => {
+ const typedData = data as { modelList: ModelInfo[] };
+ setModelList(typedData.modelList);
+ })
+ .catch((error) => {
+ console.error('Error fetching model list:', error);
+ })
+ .finally(() => {
+ setIsModelLoading(undefined);
+ });
+ }
+ }, [providerList, provider]);
+
+ const onApiKeysChange = async (providerName: string, apiKey: string) => {
+ const newApiKeys = { ...apiKeys, [providerName]: apiKey };
+ setApiKeys(newApiKeys);
+ Cookies.set('apiKeys', JSON.stringify(newApiKeys));
+
+ setIsModelLoading(providerName);
+
+ let providerModels: ModelInfo[] = [];
+
+ try {
+ const response = await fetch(`/api/models/${encodeURIComponent(providerName)}`);
+ const data = await response.json();
+ providerModels = (data as { modelList: ModelInfo[] }).modelList;
+ } catch (error) {
+ console.error('Error loading dynamic models for:', providerName, error);
+ }
+
+ // Only update models for the specific provider
+ setModelList((prevModels) => {
+ const otherModels = prevModels.filter((model) => model.provider !== providerName);
+ return [...otherModels, ...providerModels];
+ });
+ setIsModelLoading(undefined);
+ };
+
+ const startListening = () => {
+ if (recognition) {
+ recognition.start();
+ setIsListening(true);
+ }
+ };
+
+ const stopListening = () => {
+ if (recognition) {
+ recognition.stop();
+ setIsListening(false);
+ }
+ };
+
+ const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
+ if (sendMessage) {
+ sendMessage(event, messageInput);
+
+ if (recognition) {
+ recognition.abort(); // Stop current recognition
+ setTranscript(''); // Clear transcript
+ setIsListening(false);
+
+ // Clear the input by triggering handleInputChange with empty value
+ if (handleInputChange) {
+ const syntheticEvent = {
+ target: { value: '' },
+ } as React.ChangeEvent;
+ handleInputChange(syntheticEvent);
+ }
+ }
+ }
+ };
+
+ const handleFileUpload = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+
+ if (file) {
+ const reader = new FileReader();
+
+ reader.onload = (e) => {
+ const base64Image = e.target?.result as string;
+ setUploadedFiles?.([...uploadedFiles, file]);
+ setImageDataList?.([...imageDataList, base64Image]);
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
+ input.click();
+ };
+
+ const handlePaste = async (e: React.ClipboardEvent) => {
+ const items = e.clipboardData?.items;
+
+ if (!items) {
+ return;
+ }
+
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ e.preventDefault();
+
+ const file = item.getAsFile();
+
+ if (file) {
+ const reader = new FileReader();
+
+ reader.onload = (e) => {
+ const base64Image = e.target?.result as string;
+ setUploadedFiles?.([...uploadedFiles, file]);
+ setImageDataList?.([...imageDataList, base64Image]);
+ };
+ reader.readAsDataURL(file);
+ }
+
+ break;
+ }
+ }
+ };
+
+ const baseChat = (
+
+
{() => }
+
+
+ {!chatStarted && (
+
+
+ Where ideas begin
+
+
+ Bring ideas to life in seconds or get help on existing projects.
+
+
+ )}
+
+
+ {() => {
+ return chatStarted ? (
+
+ ) : null;
+ }}
+
+
+
+ {actionAlert && (
+ clearAlert?.()}
+ postMessage={(message) => {
+ sendMessage?.({} as any, message);
+ clearAlert?.();
+ }}
+ />
+ )}
+
+ {progressAnnotations &&
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {() => (
+
+
+ {(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && (
+
{
+ onApiKeysChange(provider.name, key);
+ }}
+ />
+ )}
+
+ )}
+
+
+
{
+ setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
+ setImageDataList?.(imageDataList.filter((_, i) => i !== index));
+ }}
+ />
+
+ {() => (
+
+ )}
+
+
+
+
+
+
+ {!chatStarted && (
+
+ {ImportButtons(importChat)}
+
+
+ )}
+ {!chatStarted &&
+ ExamplePrompts((event, messageInput) => {
+ if (isStreaming) {
+ handleStop?.();
+ return;
+ }
+
+ handleSendMessage?.(event, messageInput);
+ })}
+ {!chatStarted &&
}
+
+
+
+ {() => (
+
+ )}
+
+
+
+ );
+
+ return {baseChat} ;
+ },
+);
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6e016ca7140bd3bdb70ccf9d52bf6991d67a41d2
--- /dev/null
+++ b/app/components/chat/Chat.client.tsx
@@ -0,0 +1,544 @@
+/*
+ * @ts-nocheck
+ * Preventing TS checks with files presented in the video for a better presentation.
+ */
+import { useStore } from '@nanostores/react';
+import type { Message } from 'ai';
+import { useChat } from 'ai/react';
+import { useAnimate } from 'framer-motion';
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { cssTransition, toast, ToastContainer } from 'react-toastify';
+import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
+import { description, useChatHistory } from '~/lib/persistence';
+import { chatStore } from '~/lib/stores/chat';
+import { workbenchStore } from '~/lib/stores/workbench';
+import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
+import { cubicEasingFn } from '~/utils/easings';
+import { createScopedLogger, renderLogger } from '~/utils/logger';
+import { BaseChat } from './BaseChat';
+import Cookies from 'js-cookie';
+import { debounce } from '~/utils/debounce';
+import { useSettings } from '~/lib/hooks/useSettings';
+import type { ProviderInfo } from '~/types/model';
+import { useSearchParams } from '@remix-run/react';
+import { createSampler } from '~/utils/sampler';
+import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
+import { logStore } from '~/lib/stores/logs';
+import { streamingState } from '~/lib/stores/streaming';
+import { filesToArtifacts } from '~/utils/fileUtils';
+
+const toastAnimation = cssTransition({
+ enter: 'animated fadeInRight',
+ exit: 'animated fadeOutRight',
+});
+
+const logger = createScopedLogger('Chat');
+
+export function Chat() {
+ renderLogger.trace('Chat');
+
+ const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
+ const title = useStore(description);
+ useEffect(() => {
+ workbenchStore.setReloadedMessages(initialMessages.map((m) => m.id));
+ }, [initialMessages]);
+
+ return (
+ <>
+ {ready && (
+
+ )}
+ {
+ return (
+
+
+
+ );
+ }}
+ icon={({ type }) => {
+ /**
+ * @todo Handle more types if we need them. This may require extra color palettes.
+ */
+ switch (type) {
+ case 'success': {
+ return
;
+ }
+ case 'error': {
+ return
;
+ }
+ }
+
+ return undefined;
+ }}
+ position="bottom-right"
+ pauseOnFocusLoss
+ transition={toastAnimation}
+ />
+ >
+ );
+}
+
+const processSampledMessages = createSampler(
+ (options: {
+ messages: Message[];
+ initialMessages: Message[];
+ isLoading: boolean;
+ parseMessages: (messages: Message[], isLoading: boolean) => void;
+ storeMessageHistory: (messages: Message[]) => Promise;
+ }) => {
+ const { messages, initialMessages, isLoading, parseMessages, storeMessageHistory } = options;
+ parseMessages(messages, isLoading);
+
+ if (messages.length > initialMessages.length) {
+ storeMessageHistory(messages).catch((error) => toast.error(error.message));
+ }
+ },
+ 50,
+);
+
+interface ChatProps {
+ initialMessages: Message[];
+ storeMessageHistory: (messages: Message[]) => Promise;
+ importChat: (description: string, messages: Message[]) => Promise;
+ exportChat: () => void;
+ description?: string;
+}
+
+export const ChatImpl = memo(
+ ({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
+ useShortcuts();
+
+ const textareaRef = useRef(null);
+ const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
+ const [uploadedFiles, setUploadedFiles] = useState([]);
+ const [imageDataList, setImageDataList] = useState([]);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [fakeLoading, setFakeLoading] = useState(false);
+ const files = useStore(workbenchStore.files);
+ const actionAlert = useStore(workbenchStore.alert);
+ const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
+
+ function isPromptCachingEnabled(): boolean {
+ // Server-side default
+ if (typeof window === 'undefined') {
+ console.log('Server-side: isPromptCachingEnabled: window undefined');
+ return false;
+ }
+
+ try {
+ // Read from localStorage in browser
+ const savedState = localStorage.getItem('PROMPT_CACHING_ENABLED');
+ console.log('Saved prompt caching state:', savedState);
+
+ return savedState !== null ? JSON.parse(savedState) : false;
+ } catch (error) {
+ console.error('Error reading prompt caching setting:', error);
+ return false; // Default to true if reading fails
+ }
+ }
+
+ const [model, setModel] = useState(() => {
+ const savedModel = Cookies.get('selectedModel');
+ return savedModel || DEFAULT_MODEL;
+ });
+ const [provider, setProvider] = useState(() => {
+ const savedProvider = Cookies.get('selectedProvider');
+ return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
+ });
+
+ const { showChat } = useStore(chatStore);
+
+ const [animationScope, animate] = useAnimate();
+
+ const [apiKeys, setApiKeys] = useState>({});
+
+ const {
+ messages,
+ isLoading,
+ input,
+ handleInputChange,
+ setInput,
+ stop,
+ append,
+ setMessages,
+ reload,
+ error,
+ data: chatData,
+ setData,
+ } = useChat({
+ api: '/api/chat',
+ body: {
+ apiKeys,
+ files,
+ promptId,
+ contextOptimization: contextOptimizationEnabled,
+ isPromptCachingEnabled: provider.name === 'Anthropic' && isPromptCachingEnabled(),
+ },
+ sendExtraMessageFields: true,
+ onError: (e) => {
+ logger.error('Request failed\n\n', e, error);
+ logStore.logError('Chat request failed', e, {
+ component: 'Chat',
+ action: 'request',
+ error: e.message,
+ });
+ toast.error(
+ 'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
+ );
+ },
+ onFinish: (message, response) => {
+ const usage = response.usage;
+ setData(undefined);
+
+ if (usage) {
+ console.log('Token usage:', usage);
+ logStore.logProvider('Chat response completed', {
+ component: 'Chat',
+ action: 'response',
+ model,
+ provider: provider.name,
+ usage,
+ messageLength: message.content.length,
+ });
+ }
+
+ logger.debug('Finished streaming');
+ },
+ initialMessages,
+ initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '',
+ });
+ useEffect(() => {
+ const prompt = searchParams.get('prompt');
+
+ // console.log(prompt, searchParams, model, provider);
+
+ if (prompt) {
+ setSearchParams({});
+ runAnimation();
+ append({
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`,
+ },
+ ] as any, // Type assertion to bypass compiler check
+ });
+ }
+ }, [model, provider, searchParams]);
+
+ const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
+ const { parsedMessages, parseMessages } = useMessageParser();
+
+ const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
+
+ useEffect(() => {
+ chatStore.setKey('started', initialMessages.length > 0);
+ }, []);
+
+ useEffect(() => {
+ processSampledMessages({
+ messages,
+ initialMessages,
+ isLoading,
+ parseMessages,
+ storeMessageHistory,
+ });
+ }, [messages, isLoading, parseMessages]);
+
+ const scrollTextArea = () => {
+ const textarea = textareaRef.current;
+
+ if (textarea) {
+ textarea.scrollTop = textarea.scrollHeight;
+ }
+ };
+
+ const abort = () => {
+ stop();
+ chatStore.setKey('aborted', true);
+ workbenchStore.abortAllActions();
+
+ logStore.logProvider('Chat response aborted', {
+ component: 'Chat',
+ action: 'abort',
+ model,
+ provider: provider.name,
+ });
+ };
+
+ useEffect(() => {
+ const textarea = textareaRef.current;
+
+ if (textarea) {
+ textarea.style.height = 'auto';
+
+ const scrollHeight = textarea.scrollHeight;
+
+ textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
+ textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
+ }
+ }, [input, textareaRef]);
+
+ const runAnimation = async () => {
+ if (chatStarted) {
+ return;
+ }
+
+ await Promise.all([
+ animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
+ animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
+ ]);
+
+ chatStore.setKey('started', true);
+
+ setChatStarted(true);
+ };
+
+ const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
+ const messageContent = messageInput || input;
+
+ if (!messageContent?.trim()) {
+ return;
+ }
+
+ if (isLoading) {
+ abort();
+ return;
+ }
+
+ runAnimation();
+
+ if (!chatStarted) {
+ setFakeLoading(true);
+
+ if (autoSelectTemplate) {
+ const { template, title } = await selectStarterTemplate({
+ message: messageContent,
+ model,
+ provider,
+ });
+
+ if (template !== 'blank') {
+ const temResp = await getTemplates(template, title).catch((e) => {
+ if (e.message.includes('rate limit')) {
+ toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
+ } else {
+ toast.warning('Failed to import starter template\n Continuing with blank template');
+ }
+
+ return null;
+ });
+
+ if (temResp) {
+ const { assistantMessage, userMessage } = temResp;
+ setMessages([
+ {
+ id: `1-${new Date().getTime()}`,
+ role: 'user',
+ content: messageContent,
+ },
+ {
+ id: `2-${new Date().getTime()}`,
+ role: 'assistant',
+ content: assistantMessage,
+ },
+ {
+ id: `3-${new Date().getTime()}`,
+ role: 'user',
+ content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
+ annotations: ['hidden'],
+ },
+ ]);
+ reload();
+ setFakeLoading(false);
+
+ return;
+ }
+ }
+ }
+
+ // If autoSelectTemplate is disabled or template selection failed, proceed with normal message
+ setMessages([
+ {
+ id: `${new Date().getTime()}`,
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
+ },
+ ...imageDataList.map((imageData) => ({
+ type: 'image',
+ image: imageData,
+ })),
+ ] as any,
+ },
+ ]);
+ reload();
+ setFakeLoading(false);
+
+ return;
+ }
+
+ if (error != null) {
+ setMessages(messages.slice(0, -1));
+ }
+
+ const modifiedFiles = workbenchStore.getModifiedFiles();
+
+ chatStore.setKey('aborted', false);
+
+ if (modifiedFiles !== undefined) {
+ const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
+ append({
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${messageContent}`,
+ },
+ ...imageDataList.map((imageData) => ({
+ type: 'image',
+ image: imageData,
+ })),
+ ] as any,
+ });
+
+ workbenchStore.resetAllFileModifications();
+ } else {
+ append({
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
+ },
+ ...imageDataList.map((imageData) => ({
+ type: 'image',
+ image: imageData,
+ })),
+ ] as any,
+ });
+ }
+
+ setInput('');
+ Cookies.remove(PROMPT_COOKIE_KEY);
+
+ setUploadedFiles([]);
+ setImageDataList([]);
+
+ resetEnhancer();
+
+ textareaRef.current?.blur();
+ };
+
+ /**
+ * Handles the change event for the textarea and updates the input state.
+ * @param event - The change event from the textarea.
+ */
+ const onTextareaChange = (event: React.ChangeEvent) => {
+ handleInputChange(event);
+ };
+
+ /**
+ * Debounced function to cache the prompt in cookies.
+ * Caches the trimmed value of the textarea input after a delay to optimize performance.
+ */
+ const debouncedCachePrompt = useCallback(
+ debounce((event: React.ChangeEvent) => {
+ const trimmedValue = event.target.value.trim();
+ Cookies.set(PROMPT_COOKIE_KEY, trimmedValue, { expires: 30 });
+ }, 1000),
+ [],
+ );
+
+ const [messageRef, scrollRef] = useSnapScroll();
+
+ useEffect(() => {
+ const storedApiKeys = Cookies.get('apiKeys');
+
+ if (storedApiKeys) {
+ setApiKeys(JSON.parse(storedApiKeys));
+ }
+ }, []);
+
+ const handleModelChange = (newModel: string) => {
+ setModel(newModel);
+ Cookies.set('selectedModel', newModel, { expires: 30 });
+ };
+
+ const handleProviderChange = (newProvider: ProviderInfo) => {
+ setProvider(newProvider);
+ Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
+ };
+
+ return (
+ {
+ streamingState.set(streaming);
+ }}
+ enhancingPrompt={enhancingPrompt}
+ promptEnhanced={promptEnhanced}
+ sendMessage={sendMessage}
+ model={model}
+ setModel={handleModelChange}
+ provider={provider}
+ setProvider={handleProviderChange}
+ providerList={activeProviders}
+ messageRef={messageRef}
+ scrollRef={scrollRef}
+ handleInputChange={(e) => {
+ onTextareaChange(e);
+ debouncedCachePrompt(e);
+ }}
+ handleStop={abort}
+ description={description}
+ importChat={importChat}
+ exportChat={exportChat}
+ messages={messages.map((message, i) => {
+ if (message.role === 'user') {
+ return message;
+ }
+
+ return {
+ ...message,
+ content: parsedMessages[i] || '',
+ };
+ })}
+ enhancePrompt={() => {
+ enhancePrompt(
+ input,
+ (input) => {
+ setInput(input);
+ scrollTextArea();
+ },
+ model,
+ provider,
+ apiKeys,
+ );
+ }}
+ uploadedFiles={uploadedFiles}
+ setUploadedFiles={setUploadedFiles}
+ imageDataList={imageDataList}
+ setImageDataList={setImageDataList}
+ actionAlert={actionAlert}
+ clearAlert={() => workbenchStore.clearAlert()}
+ data={chatData}
+ />
+ );
+ },
+);
diff --git a/app/components/chat/ChatAlert.tsx b/app/components/chat/ChatAlert.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5aeb08c776019966e651e1246e4c80e12c7f735d
--- /dev/null
+++ b/app/components/chat/ChatAlert.tsx
@@ -0,0 +1,108 @@
+import { AnimatePresence, motion } from 'framer-motion';
+import type { ActionAlert } from '~/types/actions';
+import { classNames } from '~/utils/classNames';
+
+interface Props {
+ alert: ActionAlert;
+ clearAlert: () => void;
+ postMessage: (message: string) => void;
+}
+
+export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
+ const { description, content, source } = alert;
+
+ const isPreview = source === 'preview';
+ const title = isPreview ? 'Preview Error' : 'Terminal Error';
+ const message = isPreview
+ ? 'We encountered an error while running the preview. Would you like Bolt to analyze and help resolve this issue?'
+ : 'We encountered an error while running terminal commands. Would you like Bolt to analyze and help resolve this issue?';
+
+ return (
+
+
+
+ {/* Icon */}
+
+
+
+ {/* Content */}
+
+
+ {title}
+
+
+ {message}
+ {description && (
+
+ Error: {description}
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+ postMessage(
+ `*Fix this ${isPreview ? 'preview' : 'terminal'} error* \n\`\`\`${isPreview ? 'js' : 'sh'}\n${content}\n\`\`\`\n`,
+ )
+ }
+ className={classNames(
+ `px-2 py-1.5 rounded-md text-sm font-medium`,
+ 'bg-bolt-elements-button-primary-background',
+ 'hover:bg-bolt-elements-button-primary-backgroundHover',
+ 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-danger-background',
+ 'text-bolt-elements-button-primary-text',
+ 'flex items-center gap-1.5',
+ )}
+ >
+
+ Ask Bolt
+
+
+ Dismiss
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/chat/CodeBlock.module.scss b/app/components/chat/CodeBlock.module.scss
new file mode 100644
index 0000000000000000000000000000000000000000..3f817a978ae817d832d45985b10ca16f77483134
--- /dev/null
+++ b/app/components/chat/CodeBlock.module.scss
@@ -0,0 +1,10 @@
+.CopyButtonContainer {
+ button:before {
+ content: 'Copied';
+ font-size: 12px;
+ position: absolute;
+ left: -53px;
+ padding: 2px 6px;
+ height: 30px;
+ }
+}
diff --git a/app/components/chat/CodeBlock.tsx b/app/components/chat/CodeBlock.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bc20dc2cdd248bf954061edf0fa629ca9e3ca90a
--- /dev/null
+++ b/app/components/chat/CodeBlock.tsx
@@ -0,0 +1,82 @@
+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 styles from './CodeBlock.module.scss';
+
+const logger = createScopedLogger('CodeBlock');
+
+interface CodeBlockProps {
+ className?: string;
+ code: string;
+ language?: BundledLanguage | SpecialLanguage;
+ theme?: 'light-plus' | 'dark-plus';
+ disableCopy?: boolean;
+}
+
+export const CodeBlock = memo(
+ ({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => {
+ const [html, setHTML] = useState(undefined);
+ const [copied, setCopied] = useState(false);
+
+ const copyToClipboard = () => {
+ if (copied) {
+ return;
+ }
+
+ navigator.clipboard.writeText(code);
+
+ setCopied(true);
+
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ };
+
+ useEffect(() => {
+ if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
+ logger.warn(`Unsupported language '${language}'`);
+ }
+
+ logger.trace(`Language = ${language}`);
+
+ const processCode = async () => {
+ setHTML(await codeToHtml(code, { lang: language, theme }));
+ };
+
+ processCode();
+ }, [code]);
+
+ return (
+
+
+ {!disableCopy && (
+
copyToClipboard()}
+ >
+
+
+ )}
+
+
+
+ );
+ },
+);
diff --git a/app/components/chat/ExamplePrompts.tsx b/app/components/chat/ExamplePrompts.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4ef117fcf3308ea30b5db616cb8845e2f3c4802d
--- /dev/null
+++ b/app/components/chat/ExamplePrompts.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+
+const EXAMPLE_PROMPTS = [
+ { text: 'Build a todo app in React using Tailwind' },
+ { text: 'Build a simple blog using Astro' },
+ { text: 'Create a cookie consent form using Material UI' },
+ { text: 'Make a space invaders game' },
+ { text: 'Make a Tic Tac Toe game in html, css and js only' },
+];
+
+export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) {
+ return (
+
+
+ {EXAMPLE_PROMPTS.map((examplePrompt, index: number) => {
+ return (
+ {
+ sendMessage?.(event, examplePrompt.text);
+ }}
+ className="border border-bolt-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary px-3 py-1 text-xs transition-theme"
+ >
+ {examplePrompt.text}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/app/components/chat/FilePreview.tsx b/app/components/chat/FilePreview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0500d03b916699e7ae2fd17b95c3bcb13736fc4d
--- /dev/null
+++ b/app/components/chat/FilePreview.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+
+interface FilePreviewProps {
+ files: File[];
+ imageDataList: string[];
+ onRemove: (index: number) => void;
+}
+
+const FilePreview: React.FC = ({ files, imageDataList, onRemove }) => {
+ if (!files || files.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {files.map((file, index) => (
+
+ {imageDataList[index] && (
+
+
+
onRemove(index)}
+ className="absolute top-1 right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
+ >
+
+
+
+ )}
+
+ ))}
+
+ );
+};
+
+export default FilePreview;
diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..28aa169d193b25f5fbb8a06006973fd0727c4de6
--- /dev/null
+++ b/app/components/chat/GitCloneButton.tsx
@@ -0,0 +1,181 @@
+import ignore from 'ignore';
+import { useGit } from '~/lib/hooks/useGit';
+import type { Message } from 'ai';
+import { detectProjectCommands, createCommandsMessage, escapeBoltTags } from '~/utils/projectCommands';
+import { generateId } from '~/utils/fileUtils';
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
+import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog';
+import { classNames } from '~/utils/classNames';
+import { Button } from '~/components/ui/Button';
+import type { IChatMetadata } from '~/lib/persistence/db';
+
+const IGNORE_PATTERNS = [
+ 'node_modules/**',
+ '.git/**',
+ '.github/**',
+ '.vscode/**',
+ 'dist/**',
+ 'build/**',
+ '.next/**',
+ 'coverage/**',
+ '.cache/**',
+ '.idea/**',
+ '**/*.log',
+ '**/.DS_Store',
+ '**/npm-debug.log*',
+ '**/yarn-debug.log*',
+ '**/yarn-error.log*',
+ '**/*lock.json',
+ '**/*lock.yaml',
+];
+
+const ig = ignore().add(IGNORE_PATTERNS);
+
+const MAX_FILE_SIZE = 100 * 1024; // 100KB limit per file
+const MAX_TOTAL_SIZE = 500 * 1024; // 500KB total limit
+
+interface GitCloneButtonProps {
+ className?: string;
+ importChat?: (description: string, messages: Message[], metadata?: IChatMetadata) => Promise;
+}
+
+export default function GitCloneButton({ importChat, className }: GitCloneButtonProps) {
+ const { ready, gitClone } = useGit();
+ const [loading, setLoading] = useState(false);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const handleClone = async (repoUrl: string) => {
+ if (!ready) {
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const { workdir, data } = await gitClone(repoUrl);
+
+ if (importChat) {
+ const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
+ const textDecoder = new TextDecoder('utf-8');
+
+ let totalSize = 0;
+ const skippedFiles: string[] = [];
+ const fileContents = [];
+
+ for (const filePath of filePaths) {
+ const { data: content, encoding } = data[filePath];
+
+ // Skip binary files
+ if (
+ content instanceof Uint8Array &&
+ !filePath.match(/\.(txt|md|astro|mjs|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
+ ) {
+ skippedFiles.push(filePath);
+ continue;
+ }
+
+ try {
+ const textContent =
+ encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '';
+
+ if (!textContent) {
+ continue;
+ }
+
+ // Check file size
+ const fileSize = new TextEncoder().encode(textContent).length;
+
+ if (fileSize > MAX_FILE_SIZE) {
+ skippedFiles.push(`${filePath} (too large: ${Math.round(fileSize / 1024)}KB)`);
+ continue;
+ }
+
+ // Check total size
+ if (totalSize + fileSize > MAX_TOTAL_SIZE) {
+ skippedFiles.push(`${filePath} (would exceed total size limit)`);
+ continue;
+ }
+
+ totalSize += fileSize;
+ fileContents.push({
+ path: filePath,
+ content: textContent,
+ });
+ } catch (e: any) {
+ skippedFiles.push(`${filePath} (error: ${e.message})`);
+ }
+ }
+
+ const commands = await detectProjectCommands(fileContents);
+ const commandsMessage = createCommandsMessage(commands);
+
+ const filesMessage: Message = {
+ role: 'assistant',
+ content: `Cloning the repo ${repoUrl} into ${workdir}
+${
+ skippedFiles.length > 0
+ ? `\nSkipped files (${skippedFiles.length}):
+${skippedFiles.map((f) => `- ${f}`).join('\n')}`
+ : ''
+}
+
+
+${fileContents
+ .map(
+ (file) =>
+ `
+${escapeBoltTags(file.content)}
+ `,
+ )
+ .join('\n')}
+ `,
+ id: generateId(),
+ createdAt: new Date(),
+ };
+
+ const messages = [filesMessage];
+
+ if (commandsMessage) {
+ messages.push(commandsMessage);
+ }
+
+ await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
+ }
+ } catch (error) {
+ console.error('Error during import:', error);
+ toast.error('Failed to import repository');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <>
+ setIsDialogOpen(true)}
+ title="Clone a Git Repo"
+ variant="outline"
+ size="lg"
+ className={classNames(
+ 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
+ 'text-bolt-elements-textPrimary dark:text-white',
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
+ 'border-[#E5E5E5] dark:border-[#333333]',
+ 'h-10 px-4 py-2 min-w-[120px] justify-center',
+ 'transition-all duration-200 ease-in-out',
+ className,
+ )}
+ disabled={!ready || loading}
+ >
+
+ Clone a Git Repo
+
+
+ setIsDialogOpen(false)} onSelect={handleClone} />
+
+ {loading && }
+ >
+ );
+}
diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..27887113bcb5316a640c6b17b884e4c30f6dd445
--- /dev/null
+++ b/app/components/chat/ImportFolderButton.tsx
@@ -0,0 +1,141 @@
+import React, { useState } from 'react';
+import type { Message } from 'ai';
+import { toast } from 'react-toastify';
+import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
+import { createChatFromFolder } from '~/utils/folderImport';
+import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+
+interface ImportFolderButtonProps {
+ className?: string;
+ importChat?: (description: string, messages: Message[]) => Promise;
+}
+
+export const ImportFolderButton: React.FC = ({ className, importChat }) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const allFiles = Array.from(e.target.files || []);
+
+ const filteredFiles = allFiles.filter((file) => {
+ const path = file.webkitRelativePath.split('/').slice(1).join('/');
+ const include = shouldIncludeFile(path);
+
+ return include;
+ });
+
+ if (filteredFiles.length === 0) {
+ const error = new Error('No valid files found');
+ logStore.logError('File import failed - no valid files', error, { folderName: 'Unknown Folder' });
+ toast.error('No files found in the selected folder');
+
+ return;
+ }
+
+ if (filteredFiles.length > MAX_FILES) {
+ const error = new Error(`Too many files: ${filteredFiles.length}`);
+ logStore.logError('File import failed - too many files', error, {
+ fileCount: filteredFiles.length,
+ maxFiles: MAX_FILES,
+ });
+ toast.error(
+ `This folder contains ${filteredFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
+ );
+
+ return;
+ }
+
+ const folderName = filteredFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
+ setIsLoading(true);
+
+ const loadingToast = toast.loading(`Importing ${folderName}...`);
+
+ try {
+ const fileChecks = await Promise.all(
+ filteredFiles.map(async (file) => ({
+ file,
+ isBinary: await isBinaryFile(file),
+ })),
+ );
+
+ 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('/'));
+
+ 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');
+
+ return;
+ }
+
+ if (binaryFilePaths.length > 0) {
+ logStore.logWarning(`Skipping binary files during import`, {
+ folderName,
+ binaryCount: binaryFilePaths.length,
+ });
+ toast.info(`Skipping ${binaryFilePaths.length} binary files`);
+ }
+
+ const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
+
+ if (importChat) {
+ await importChat(folderName, [...messages]);
+ }
+
+ logStore.logSystem('Folder imported successfully', {
+ folderName,
+ textFileCount: textFiles.length,
+ binaryFileCount: binaryFilePaths.length,
+ });
+ toast.success('Folder imported successfully');
+ } catch (error) {
+ logStore.logError('Failed to import folder', error, { folderName });
+ console.error('Failed to import folder:', error);
+ toast.error('Failed to import folder');
+ } finally {
+ setIsLoading(false);
+ toast.dismiss(loadingToast);
+ e.target.value = ''; // Reset file input
+ }
+ };
+
+ return (
+ <>
+
+ {
+ const input = document.getElementById('folder-import');
+ input?.click();
+ }}
+ title="Import Folder"
+ variant="outline"
+ size="lg"
+ className={classNames(
+ 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
+ 'text-bolt-elements-textPrimary dark:text-white',
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
+ 'border-[#E5E5E5] dark:border-[#333333]',
+ 'h-10 px-4 py-2 min-w-[120px] justify-center',
+ 'transition-all duration-200 ease-in-out',
+ className,
+ )}
+ disabled={isLoading}
+ >
+
+ {isLoading ? 'Importing...' : 'Import Folder'}
+
+ >
+ );
+};
diff --git a/app/components/chat/Markdown.module.scss b/app/components/chat/Markdown.module.scss
new file mode 100644
index 0000000000000000000000000000000000000000..3da3861c6df8ca5fcaf730f4223e5cbb8e594873
--- /dev/null
+++ b/app/components/chat/Markdown.module.scss
@@ -0,0 +1,171 @@
+$font-mono: ui-monospace, 'Fira Code', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+$code-font-size: 13px;
+
+@mixin not-inside-actions {
+ &:not(:has(:global(.actions)), :global(.actions *)) {
+ @content;
+ }
+}
+
+.MarkdownContent {
+ line-height: 1.6;
+ color: var(--bolt-elements-textPrimary);
+
+ > *:not(:last-child) {
+ margin-block-end: 16px;
+ }
+
+ :global(.artifact) {
+ margin: 1.5em 0;
+ }
+
+ :is(h1, h2, h3, h4, h5, h6) {
+ @include not-inside-actions {
+ margin-block-start: 24px;
+ margin-block-end: 16px;
+ font-weight: 600;
+ line-height: 1.25;
+ color: var(--bolt-elements-textPrimary);
+ }
+ }
+
+ h1 {
+ font-size: 2em;
+ border-bottom: 1px solid var(--bolt-elements-borderColor);
+ padding-bottom: 0.3em;
+ }
+
+ h2 {
+ font-size: 1.5em;
+ border-bottom: 1px solid var(--bolt-elements-borderColor);
+ padding-bottom: 0.3em;
+ }
+
+ h3 {
+ font-size: 1.25em;
+ }
+
+ h4 {
+ font-size: 1em;
+ }
+
+ h5 {
+ font-size: 0.875em;
+ }
+
+ h6 {
+ font-size: 0.85em;
+ color: #6a737d;
+ }
+
+ p {
+ white-space: pre-wrap;
+
+ &:not(:last-of-type) {
+ margin-block-start: 0;
+ margin-block-end: 16px;
+ }
+ }
+
+ a {
+ color: var(--bolt-elements-messages-linkColor);
+ text-decoration: none;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ :not(pre) > code {
+ font-family: $font-mono;
+ font-size: $code-font-size;
+
+ @include not-inside-actions {
+ border-radius: 6px;
+ padding: 0.2em 0.4em;
+ background-color: var(--bolt-elements-messages-inlineCode-background);
+ color: var(--bolt-elements-messages-inlineCode-text);
+ }
+ }
+
+ pre {
+ padding: 20px 16px;
+ border-radius: 6px;
+ }
+
+ pre:has(> code) {
+ font-family: $font-mono;
+ font-size: $code-font-size;
+ background: transparent;
+ overflow-x: auto;
+ min-width: 0;
+ }
+
+ blockquote {
+ margin: 0;
+ padding: 0 1em;
+ color: var(--bolt-elements-textTertiary);
+ border-left: 0.25em solid var(--bolt-elements-borderColor);
+ }
+
+ :is(ul, ol) {
+ @include not-inside-actions {
+ padding-left: 2em;
+ margin-block-start: 0;
+ margin-block-end: 16px;
+ }
+ }
+
+ ul {
+ @include not-inside-actions {
+ list-style-type: disc;
+ }
+ }
+
+ ol {
+ @include not-inside-actions {
+ list-style-type: decimal;
+ }
+ }
+
+ li {
+ @include not-inside-actions {
+ & + li {
+ margin-block-start: 8px;
+ }
+
+ > *:not(:last-child) {
+ margin-block-end: 16px;
+ }
+ }
+ }
+
+ img {
+ max-width: 100%;
+ box-sizing: border-box;
+ }
+
+ hr {
+ height: 0.25em;
+ padding: 0;
+ margin: 24px 0;
+ background-color: var(--bolt-elements-borderColor);
+ border: 0;
+ }
+
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-block-end: 16px;
+
+ :is(th, td) {
+ padding: 6px 13px;
+ border: 1px solid #dfe2e5;
+ }
+
+ tr:nth-child(2n) {
+ background-color: #f6f8fa;
+ }
+ }
+}
diff --git a/app/components/chat/Markdown.spec.ts b/app/components/chat/Markdown.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..238177892585a119fadf3e1de9434eea1a1b5ea1
--- /dev/null
+++ b/app/components/chat/Markdown.spec.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import { stripCodeFenceFromArtifact } from './Markdown';
+
+describe('stripCodeFenceFromArtifact', () => {
+ it('should remove code fences around artifact element', () => {
+ const input = "```xml\n
\n```";
+ const expected = "\n
\n";
+ expect(stripCodeFenceFromArtifact(input)).toBe(expected);
+ });
+
+ it('should handle code fence with language specification', () => {
+ const input = "```typescript\n
\n```";
+ const expected = "\n
\n";
+ expect(stripCodeFenceFromArtifact(input)).toBe(expected);
+ });
+
+ it('should not modify content without artifacts', () => {
+ const input = '```\nregular code block\n```';
+ expect(stripCodeFenceFromArtifact(input)).toBe(input);
+ });
+
+ it('should handle empty input', () => {
+ expect(stripCodeFenceFromArtifact('')).toBe('');
+ });
+
+ it('should handle artifact without code fences', () => {
+ const input = "
";
+ expect(stripCodeFenceFromArtifact(input)).toBe(input);
+ });
+
+ it('should handle multiple artifacts but only remove fences around them', () => {
+ const input = [
+ 'Some text',
+ '```typescript',
+ "
",
+ '```',
+ '```',
+ 'regular code',
+ '```',
+ ].join('\n');
+
+ const expected = ['Some text', '', "
", '', '```', 'regular code', '```'].join(
+ '\n',
+ );
+
+ expect(stripCodeFenceFromArtifact(input)).toBe(expected);
+ });
+});
diff --git a/app/components/chat/Markdown.tsx b/app/components/chat/Markdown.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..90ba6b7f7874fa889afbe6b760bb41b3d02462c9
--- /dev/null
+++ b/app/components/chat/Markdown.tsx
@@ -0,0 +1,123 @@
+import { memo, useMemo } from 'react';
+import ReactMarkdown, { type Components } from 'react-markdown';
+import type { BundledLanguage } from 'shiki';
+import { createScopedLogger } from '~/utils/logger';
+import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
+import { Artifact } from './Artifact';
+import { CodeBlock } from './CodeBlock';
+
+import styles from './Markdown.module.scss';
+import ThoughtBox from './ThoughtBox';
+
+const logger = createScopedLogger('MarkdownComponent');
+
+interface MarkdownProps {
+ children: string;
+ html?: boolean;
+ limitedMarkdown?: boolean;
+}
+
+export const Markdown = memo(({ children, html = false, limitedMarkdown = false }: MarkdownProps) => {
+ logger.trace('Render');
+
+ const components = useMemo(() => {
+ return {
+ div: ({ className, children, node, ...props }) => {
+ if (className?.includes('__boltArtifact__')) {
+ const messageId = node?.properties.dataMessageId as string;
+
+ if (!messageId) {
+ logger.error(`Invalid message id ${messageId}`);
+ }
+
+ return ;
+ }
+
+ if (className?.includes('__boltThought__')) {
+ return {children} ;
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+ pre: (props) => {
+ const { children, node, ...rest } = props;
+
+ const [firstChild] = node?.children ?? [];
+
+ if (
+ firstChild &&
+ firstChild.type === 'element' &&
+ firstChild.tagName === 'code' &&
+ firstChild.children[0].type === 'text'
+ ) {
+ const { className, ...rest } = firstChild.properties;
+ const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
+
+ return ;
+ }
+
+ return {children} ;
+ },
+ } satisfies Components;
+ }, []);
+
+ return (
+
+ {stripCodeFenceFromArtifact(children)}
+
+ );
+});
+
+/**
+ * Removes code fence markers (```) surrounding an artifact element while preserving the artifact content.
+ * This is necessary because artifacts should not be wrapped in code blocks when rendered for rendering action list.
+ *
+ * @param content - The markdown content to process
+ * @returns The processed content with code fence markers removed around artifacts
+ *
+ * @example
+ * // Removes code fences around artifact
+ * const input = "```xml\n
\n```";
+ * stripCodeFenceFromArtifact(input);
+ * // Returns: "\n
\n"
+ *
+ * @remarks
+ * - Only removes code fences that directly wrap an artifact (marked with __boltArtifact__ class)
+ * - Handles code fences with optional language specifications (e.g. ```xml, ```typescript)
+ * - Preserves original content if no artifact is found
+ * - Safely handles edge cases like empty input or artifacts at start/end of content
+ */
+export const stripCodeFenceFromArtifact = (content: string) => {
+ if (!content || !content.includes('__boltArtifact__')) {
+ return content;
+ }
+
+ const lines = content.split('\n');
+ const artifactLineIndex = lines.findIndex((line) => line.includes('__boltArtifact__'));
+
+ // Return original content if artifact line not found
+ if (artifactLineIndex === -1) {
+ return content;
+ }
+
+ // Check previous line for code fence
+ if (artifactLineIndex > 0 && lines[artifactLineIndex - 1]?.trim().match(/^```\w*$/)) {
+ lines[artifactLineIndex - 1] = '';
+ }
+
+ if (artifactLineIndex < lines.length - 1 && lines[artifactLineIndex + 1]?.trim().match(/^```$/)) {
+ lines[artifactLineIndex + 1] = '';
+ }
+
+ return lines.join('\n');
+};
diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4d7e2a65fda92f2c04fded3a024097bdfa8fab8d
--- /dev/null
+++ b/app/components/chat/Messages.client.tsx
@@ -0,0 +1,132 @@
+import type { Message } from 'ai';
+import { Fragment } from 'react';
+import { classNames } from '~/utils/classNames';
+import { AssistantMessage } from './AssistantMessage';
+import { UserMessage } from './UserMessage';
+import { useLocation } from '@remix-run/react';
+import { db, chatId } from '~/lib/persistence/useChatHistory';
+import { forkChat } from '~/lib/persistence/db';
+import { toast } from 'react-toastify';
+import WithTooltip from '~/components/ui/Tooltip';
+import { useStore } from '@nanostores/react';
+import { profileStore } from '~/lib/stores/profile';
+import { forwardRef } from 'react';
+import type { ForwardedRef } from 'react';
+
+interface MessagesProps {
+ id?: string;
+ className?: string;
+ isStreaming?: boolean;
+ messages?: Message[];
+}
+
+export const Messages = forwardRef(
+ (props: MessagesProps, ref: ForwardedRef | undefined) => {
+ const { id, isStreaming = false, messages = [] } = props;
+ const location = useLocation();
+ const profile = useStore(profileStore);
+
+ const handleRewind = (messageId: string) => {
+ const searchParams = new URLSearchParams(location.search);
+ searchParams.set('rewindTo', messageId);
+ window.location.search = searchParams.toString();
+ };
+
+ const handleFork = async (messageId: string) => {
+ try {
+ if (!db || !chatId.get()) {
+ toast.error('Chat persistence is not available');
+ return;
+ }
+
+ const urlId = await forkChat(db, chatId.get()!, messageId);
+ window.location.href = `/chat/${urlId}`;
+ } catch (error) {
+ toast.error('Failed to fork chat: ' + (error as Error).message);
+ }
+ };
+
+ return (
+
+ {messages.length > 0
+ ? messages.map((message, index) => {
+ const { role, content, id: messageId, annotations } = message;
+ const isUserMessage = role === 'user';
+ const isFirst = index === 0;
+ const isLast = index === messages.length - 1;
+ const isHidden = annotations?.includes('hidden');
+
+ if (isHidden) {
+ return
;
+ }
+
+ return (
+
+ {isUserMessage && (
+
+ {profile?.avatar ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {isUserMessage ? (
+
+ ) : (
+
+ )}
+
+ {!isUserMessage && (
+
+ {messageId && (
+
+ handleRewind(messageId)}
+ key="i-ph:arrow-u-up-left"
+ className={classNames(
+ 'i-ph:arrow-u-up-left',
+ 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
+ )}
+ />
+
+ )}
+
+
+ handleFork(messageId)}
+ key="i-ph:git-fork"
+ className={classNames(
+ 'i-ph:git-fork',
+ 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
+ )}
+ />
+
+
+ )}
+
+ );
+ })
+ : null}
+ {isStreaming && (
+
+ )}
+
+ );
+ },
+);
diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b80bfc8b8614a9808c2a27fe05ed45e21cde26b5
--- /dev/null
+++ b/app/components/chat/ModelSelector.tsx
@@ -0,0 +1,312 @@
+import type { ProviderInfo } from '~/types/model';
+import { useEffect, useState, useRef } from 'react';
+import type { KeyboardEvent } from 'react';
+import type { ModelInfo } from '~/lib/modules/llm/types';
+import { classNames } from '~/utils/classNames';
+import * as React from 'react';
+
+interface ModelSelectorProps {
+ model?: string;
+ setModel?: (model: string) => void;
+ provider?: ProviderInfo;
+ setProvider?: (provider: ProviderInfo) => void;
+ modelList: ModelInfo[];
+ providerList: ProviderInfo[];
+ apiKeys: Record;
+ modelLoading?: string;
+}
+
+export const ModelSelector = ({
+ model,
+ setModel,
+ provider,
+ setProvider,
+ modelList,
+ providerList,
+ modelLoading,
+}: ModelSelectorProps) => {
+ const [modelSearchQuery, setModelSearchQuery] = useState('');
+ const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
+ const [focusedIndex, setFocusedIndex] = useState(-1);
+ const searchInputRef = useRef(null);
+ const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsModelDropdownOpen(false);
+ setModelSearchQuery('');
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // Filter models based on search query
+ const filteredModels = [...modelList]
+ .filter((e) => e.provider === provider?.name && e.name)
+ .filter(
+ (model) =>
+ model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
+ model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
+ );
+
+ // Reset focused index when search query changes or dropdown opens/closes
+ useEffect(() => {
+ setFocusedIndex(-1);
+ }, [modelSearchQuery, isModelDropdownOpen]);
+
+ // Focus search input when dropdown opens
+ useEffect(() => {
+ if (isModelDropdownOpen && searchInputRef.current) {
+ searchInputRef.current.focus();
+ }
+ }, [isModelDropdownOpen]);
+
+ // Handle keyboard navigation
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (!isModelDropdownOpen) {
+ return;
+ }
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ setFocusedIndex((prev) => {
+ const next = prev + 1;
+
+ if (next >= filteredModels.length) {
+ return 0;
+ }
+
+ return next;
+ });
+ break;
+
+ case 'ArrowUp':
+ e.preventDefault();
+ setFocusedIndex((prev) => {
+ const next = prev - 1;
+
+ if (next < 0) {
+ return filteredModels.length - 1;
+ }
+
+ return next;
+ });
+ break;
+
+ case 'Enter':
+ e.preventDefault();
+
+ if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
+ const selectedModel = filteredModels[focusedIndex];
+ setModel?.(selectedModel.name);
+ setIsModelDropdownOpen(false);
+ setModelSearchQuery('');
+ }
+
+ break;
+
+ case 'Escape':
+ e.preventDefault();
+ setIsModelDropdownOpen(false);
+ setModelSearchQuery('');
+ break;
+
+ case 'Tab':
+ if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
+ setIsModelDropdownOpen(false);
+ }
+
+ break;
+ }
+ };
+
+ // Focus the selected option
+ useEffect(() => {
+ if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
+ optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
+ }
+ }, [focusedIndex]);
+
+ // Update enabled providers when cookies change
+ useEffect(() => {
+ // If current provider is disabled, switch to first enabled provider
+ if (providerList.length === 0) {
+ return;
+ }
+
+ if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
+ const firstEnabledProvider = providerList[0];
+ setProvider?.(firstEnabledProvider);
+
+ // Also update the model to the first available one for the new provider
+ const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
+
+ if (firstModel) {
+ setModel?.(firstModel.name);
+ }
+ }
+ }, [providerList, provider, setProvider, modelList, setModel]);
+
+ if (providerList.length === 0) {
+ return (
+
+
+ No providers are currently enabled. Please enable at least one provider in the settings to start using the
+ chat.
+
+
+ );
+ }
+
+ return (
+
+
{
+ const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
+
+ if (newProvider && setProvider) {
+ setProvider(newProvider);
+ }
+
+ const firstModel = [...modelList].find((m) => m.provider === e.target.value);
+
+ if (firstModel && setModel) {
+ setModel(firstModel.name);
+ }
+ }}
+ className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
+ >
+ {providerList.map((provider: ProviderInfo) => (
+
+ {provider.name}
+
+ ))}
+
+
+
+
setIsModelDropdownOpen(!isModelDropdownOpen)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setIsModelDropdownOpen(!isModelDropdownOpen);
+ }
+ }}
+ role="combobox"
+ aria-expanded={isModelDropdownOpen}
+ aria-controls="model-listbox"
+ aria-haspopup="listbox"
+ tabIndex={0}
+ >
+
+
{modelList.find((m) => m.name === model)?.label || 'Select model'}
+
+
+
+
+ {isModelDropdownOpen && (
+
+
+
+
setModelSearchQuery(e.target.value)}
+ placeholder="Search models..."
+ className={classNames(
+ 'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
+ 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
+ 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
+ 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
+ 'transition-all',
+ )}
+ onClick={(e) => e.stopPropagation()}
+ role="searchbox"
+ aria-label="Search models"
+ />
+
+
+
+
+
+
+
+ {modelLoading === 'all' || modelLoading === provider?.name ? (
+
Loading...
+ ) : filteredModels.length === 0 ? (
+
No models found
+ ) : (
+ filteredModels.map((modelOption, index) => (
+
(optionsRef.current[index] = el)}
+ key={index}
+ role="option"
+ aria-selected={model === modelOption.name}
+ className={classNames(
+ 'px-3 py-2 text-sm cursor-pointer',
+ 'hover:bg-bolt-elements-background-depth-3',
+ 'text-bolt-elements-textPrimary',
+ 'outline-none',
+ model === modelOption.name || focusedIndex === index
+ ? 'bg-bolt-elements-background-depth-2'
+ : undefined,
+ focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
+ )}
+ onClick={(e) => {
+ e.stopPropagation();
+ setModel?.(modelOption.name);
+ setIsModelDropdownOpen(false);
+ setModelSearchQuery('');
+ }}
+ tabIndex={focusedIndex === index ? 0 : -1}
+ >
+ {modelOption.label}
+
+ ))
+ )}
+
+
+ )}
+
+
+ );
+};
diff --git a/app/components/chat/NetlifyDeploymentLink.client.tsx b/app/components/chat/NetlifyDeploymentLink.client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..da8e0b413b0f458ae6cdcbe06e8d9c0e994f1aff
--- /dev/null
+++ b/app/components/chat/NetlifyDeploymentLink.client.tsx
@@ -0,0 +1,51 @@
+import { useStore } from '@nanostores/react';
+import { netlifyConnection, fetchNetlifyStats } from '~/lib/stores/netlify';
+import { chatId } from '~/lib/persistence/useChatHistory';
+import * as Tooltip from '@radix-ui/react-tooltip';
+import { useEffect } from 'react';
+
+export function NetlifyDeploymentLink() {
+ const connection = useStore(netlifyConnection);
+ const currentChatId = useStore(chatId);
+
+ useEffect(() => {
+ if (connection.token && currentChatId) {
+ fetchNetlifyStats(connection.token);
+ }
+ }, [connection.token, currentChatId]);
+
+ const deployedSite = connection.stats?.sites?.find((site) => site.name.includes(`bolt-diy-${currentChatId}`));
+
+ if (!deployedSite) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ e.stopPropagation(); // Add this to prevent click from bubbling up
+ }}
+ >
+
+
+
+
+
+ {deployedSite.url}
+
+
+
+
+
+ );
+}
diff --git a/app/components/chat/ProgressCompilation.tsx b/app/components/chat/ProgressCompilation.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..68ae3388def2edceb51dff32daae21753215a45b
--- /dev/null
+++ b/app/components/chat/ProgressCompilation.tsx
@@ -0,0 +1,110 @@
+import { AnimatePresence, motion } from 'framer-motion';
+import React, { useState } from 'react';
+import type { ProgressAnnotation } from '~/types/context';
+import { classNames } from '~/utils/classNames';
+import { cubicEasingFn } from '~/utils/easings';
+
+export default function ProgressCompilation({ data }: { data?: ProgressAnnotation[] }) {
+ const [progressList, setProgressList] = React.useState([]);
+ const [expanded, setExpanded] = useState(false);
+ React.useEffect(() => {
+ if (!data || data.length == 0) {
+ setProgressList([]);
+ return;
+ }
+
+ const progressMap = new Map();
+ data.forEach((x) => {
+ const existingProgress = progressMap.get(x.label);
+
+ if (existingProgress && existingProgress.status === 'complete') {
+ return;
+ }
+
+ progressMap.set(x.label, x);
+ });
+
+ const newData = Array.from(progressMap.values());
+ newData.sort((a, b) => a.order - b.order);
+ setProgressList(newData);
+ }, [data]);
+
+ if (progressList.length === 0) {
+ return <>>;
+ }
+
+ return (
+
+
+
+
+
+ {expanded ? (
+
+ {progressList.map((x, i) => {
+ return ;
+ })}
+
+ ) : (
+
+ )}
+
+
+
setExpanded((v) => !v)}
+ >
+
+
+
+
+
+ );
+}
+
+const ProgressItem = ({ progress }: { progress: ProgressAnnotation }) => {
+ return (
+
+
+
+ {progress.status === 'in-progress' ? (
+
+ ) : progress.status === 'complete' ? (
+
+ ) : null}
+
+ {/* {x.label} */}
+
+ {progress.message}
+
+ );
+};
diff --git a/app/components/chat/ScreenshotStateManager.tsx b/app/components/chat/ScreenshotStateManager.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7dfdd76831b9744f38df22e36da7ebfd5c7a216c
--- /dev/null
+++ b/app/components/chat/ScreenshotStateManager.tsx
@@ -0,0 +1,33 @@
+import { useEffect } from 'react';
+
+interface ScreenshotStateManagerProps {
+ setUploadedFiles?: (files: File[]) => void;
+ setImageDataList?: (dataList: string[]) => void;
+ uploadedFiles: File[];
+ imageDataList: string[];
+}
+
+export const ScreenshotStateManager = ({
+ setUploadedFiles,
+ setImageDataList,
+ uploadedFiles,
+ imageDataList,
+}: ScreenshotStateManagerProps) => {
+ useEffect(() => {
+ if (setUploadedFiles && setImageDataList) {
+ (window as any).__BOLT_SET_UPLOADED_FILES__ = setUploadedFiles;
+ (window as any).__BOLT_SET_IMAGE_DATA_LIST__ = setImageDataList;
+ (window as any).__BOLT_UPLOADED_FILES__ = uploadedFiles;
+ (window as any).__BOLT_IMAGE_DATA_LIST__ = imageDataList;
+ }
+
+ return () => {
+ delete (window as any).__BOLT_SET_UPLOADED_FILES__;
+ delete (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
+ delete (window as any).__BOLT_UPLOADED_FILES__;
+ delete (window as any).__BOLT_IMAGE_DATA_LIST__;
+ };
+ }, [setUploadedFiles, setImageDataList, uploadedFiles, imageDataList]);
+
+ return null;
+};
diff --git a/app/components/chat/SendButton.client.tsx b/app/components/chat/SendButton.client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..389ca3bf71a02882b625524db5ed92c9cfe04a27
--- /dev/null
+++ b/app/components/chat/SendButton.client.tsx
@@ -0,0 +1,39 @@
+import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
+
+interface SendButtonProps {
+ show: boolean;
+ isStreaming?: boolean;
+ disabled?: boolean;
+ onClick?: (event: React.MouseEvent) => void;
+ onImagesSelected?: (images: File[]) => void;
+}
+
+const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
+
+export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonProps) => {
+ return (
+
+ {show ? (
+ {
+ event.preventDefault();
+
+ if (!disabled) {
+ onClick?.(event);
+ }
+ }}
+ >
+
+
+ ) : null}
+
+ );
+};
diff --git a/app/components/chat/SpeechRecognition.tsx b/app/components/chat/SpeechRecognition.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..18c66c761bbd58c06489396c6260d01ff8123563
--- /dev/null
+++ b/app/components/chat/SpeechRecognition.tsx
@@ -0,0 +1,28 @@
+import { IconButton } from '~/components/ui/IconButton';
+import { classNames } from '~/utils/classNames';
+import React from 'react';
+
+export const SpeechRecognitionButton = ({
+ isListening,
+ onStart,
+ onStop,
+ disabled,
+}: {
+ isListening: boolean;
+ onStart: () => void;
+ onStop: () => void;
+ disabled: boolean;
+}) => {
+ return (
+
+ {isListening ?
:
}
+
+ );
+};
diff --git a/app/components/chat/StarterTemplates.tsx b/app/components/chat/StarterTemplates.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fa51961bbe955c25e5a6ddb61dc15dfce54c989e
--- /dev/null
+++ b/app/components/chat/StarterTemplates.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import type { Template } from '~/types/template';
+import { STARTER_TEMPLATES } from '~/utils/constants';
+
+interface FrameworkLinkProps {
+ template: Template;
+}
+
+const FrameworkLink: React.FC = ({ template }) => (
+
+
+
+);
+
+const StarterTemplates: React.FC = () => {
+ // Debug: Log available templates and their icons
+ React.useEffect(() => {
+ console.log(
+ 'Available templates:',
+ STARTER_TEMPLATES.map((t) => ({ name: t.name, icon: t.icon })),
+ );
+ }, []);
+
+ return (
+
+
or start a blank app with your favorite stack
+
+
+ {STARTER_TEMPLATES.map((template) => (
+
+ ))}
+
+
+
+ );
+};
+
+export default StarterTemplates;
diff --git a/app/components/chat/ThoughtBox.tsx b/app/components/chat/ThoughtBox.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3c70e1a8b20a4b43fab9c36e6212bbdc4a97fb43
--- /dev/null
+++ b/app/components/chat/ThoughtBox.tsx
@@ -0,0 +1,43 @@
+import { useState, type PropsWithChildren } from 'react';
+
+const ThoughtBox = ({ title, children }: PropsWithChildren<{ title: string }>) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ return (
+ setIsExpanded(!isExpanded)}
+ className={`
+ bg-bolt-elements-background-depth-2
+ shadow-md
+ rounded-lg
+ cursor-pointer
+ transition-all
+ duration-300
+ ${isExpanded ? 'max-h-96' : 'max-h-13'}
+ overflow-auto
+ border border-bolt-elements-borderColor
+ `}
+ >
+
+
+
+ {title} {' '}
+ {!isExpanded && - Click to expand }
+
+
+
+ {children}
+
+
+ );
+};
+
+export default ThoughtBox;
diff --git a/app/components/chat/UserMessage.tsx b/app/components/chat/UserMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e7ef54a6199e0a516518e55862d86b17fa689d74
--- /dev/null
+++ b/app/components/chat/UserMessage.tsx
@@ -0,0 +1,48 @@
+/*
+ * @ts-nocheck
+ * Preventing TS checks with files presented in the video for a better presentation.
+ */
+import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
+import { Markdown } from './Markdown';
+
+interface UserMessageProps {
+ content: string | Array<{ type: string; text?: string; image?: string }>;
+}
+
+export function UserMessage({ content }: UserMessageProps) {
+ if (Array.isArray(content)) {
+ const textItem = content.find((item) => item.type === 'text');
+ const textContent = stripMetadata(textItem?.text || '');
+ const images = content.filter((item) => item.type === 'image' && item.image);
+
+ return (
+
+
+ {textContent &&
{textContent} }
+ {images.map((item, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ const textContent = stripMetadata(content);
+
+ return (
+
+ {textContent}
+
+ );
+}
+
+function stripMetadata(content: string) {
+ const artifactRegex = /]*>[\s\S]*?<\/boltArtifact>/gm;
+ return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').replace(artifactRegex, '');
+}
diff --git a/app/components/chat/chatExportAndImport/ExportChatButton.tsx b/app/components/chat/chatExportAndImport/ExportChatButton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6ab294bc15be28afd9f8bc4f94e713f94faa441d
--- /dev/null
+++ b/app/components/chat/chatExportAndImport/ExportChatButton.tsx
@@ -0,0 +1,13 @@
+import WithTooltip from '~/components/ui/Tooltip';
+import { IconButton } from '~/components/ui/IconButton';
+import React from 'react';
+
+export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => {
+ return (
+
+ exportChat?.()}>
+
+
+
+ );
+};
diff --git a/app/components/chat/chatExportAndImport/ImportButtons.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b91aab3557feb336fa252573d8a70910d17591eb
--- /dev/null
+++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx
@@ -0,0 +1,96 @@
+import type { Message } from 'ai';
+import { toast } from 'react-toastify';
+import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+
+type ChatData = {
+ messages?: Message[]; // Standard Bolt format
+ description?: string; // Optional description
+};
+
+export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise) | undefined) {
+ return (
+
+
{
+ const file = e.target.files?.[0];
+
+ if (file && importChat) {
+ try {
+ const reader = new FileReader();
+
+ reader.onload = async (e) => {
+ try {
+ const content = e.target?.result as string;
+ const data = JSON.parse(content) as ChatData;
+
+ // Standard format
+ if (Array.isArray(data.messages)) {
+ await importChat(data.description || 'Imported Chat', data.messages);
+ toast.success('Chat imported successfully');
+
+ return;
+ }
+
+ toast.error('Invalid chat file format');
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ toast.error('Failed to parse chat file: ' + error.message);
+ } else {
+ toast.error('Failed to parse chat file');
+ }
+ }
+ };
+ reader.onerror = () => toast.error('Failed to read chat file');
+ reader.readAsText(file);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Failed to import chat');
+ }
+ e.target.value = ''; // Reset file input
+ } else {
+ toast.error('Something went wrong');
+ }
+ }}
+ />
+
+
+ {
+ const input = document.getElementById('chat-import');
+ input?.click();
+ }}
+ variant="outline"
+ size="lg"
+ className={classNames(
+ 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
+ 'text-bolt-elements-textPrimary dark:text-white',
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
+ 'border-[#E5E5E5] dark:border-[#333333]',
+ 'h-10 px-4 py-2 min-w-[120px] justify-center',
+ 'transition-all duration-200 ease-in-out',
+ )}
+ >
+
+ Import Chat
+
+
+
+
+
+ );
+}
diff --git a/app/components/editor/codemirror/BinaryContent.tsx b/app/components/editor/codemirror/BinaryContent.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0a4251161fa071128034926093d7778498926bfb
--- /dev/null
+++ b/app/components/editor/codemirror/BinaryContent.tsx
@@ -0,0 +1,7 @@
+export function BinaryContent() {
+ return (
+
+ File format cannot be displayed.
+
+ );
+}
diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8e9f3a3fe9b2ff032604e9ea8577edae391facb3
--- /dev/null
+++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx
@@ -0,0 +1,461 @@
+import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';
+import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
+import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
+import { searchKeymap } from '@codemirror/search';
+import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
+import {
+ drawSelection,
+ dropCursor,
+ EditorView,
+ highlightActiveLine,
+ highlightActiveLineGutter,
+ keymap,
+ lineNumbers,
+ scrollPastEnd,
+ showTooltip,
+ tooltips,
+ type Tooltip,
+} from '@codemirror/view';
+import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
+import type { Theme } from '~/types/theme';
+import { classNames } from '~/utils/classNames';
+import { debounce } from '~/utils/debounce';
+import { createScopedLogger, renderLogger } from '~/utils/logger';
+import { BinaryContent } from './BinaryContent';
+import { getTheme, reconfigureTheme } from './cm-theme';
+import { indentKeyBinding } from './indent';
+import { getLanguage } from './languages';
+
+const logger = createScopedLogger('CodeMirrorEditor');
+
+export interface EditorDocument {
+ value: string;
+ isBinary: boolean;
+ filePath: string;
+ scroll?: ScrollPosition;
+}
+
+export interface EditorSettings {
+ fontSize?: string;
+ gutterFontSize?: string;
+ tabSize?: number;
+}
+
+type TextEditorDocument = EditorDocument & {
+ value: string;
+};
+
+export interface ScrollPosition {
+ top: number;
+ left: number;
+}
+
+export interface EditorUpdate {
+ selection: EditorSelection;
+ content: string;
+}
+
+export type OnChangeCallback = (update: EditorUpdate) => void;
+export type OnScrollCallback = (position: ScrollPosition) => void;
+export type OnSaveCallback = () => void;
+
+interface Props {
+ theme: Theme;
+ id?: unknown;
+ doc?: EditorDocument;
+ editable?: boolean;
+ debounceChange?: number;
+ debounceScroll?: number;
+ autoFocusOnDocumentChange?: boolean;
+ onChange?: OnChangeCallback;
+ onScroll?: OnScrollCallback;
+ onSave?: OnSaveCallback;
+ className?: string;
+ settings?: EditorSettings;
+}
+
+type EditorStates = Map;
+
+const readOnlyTooltipStateEffect = StateEffect.define();
+
+const editableTooltipField = StateField.define({
+ create: () => [],
+ update(_tooltips, transaction) {
+ if (!transaction.state.readOnly) {
+ return [];
+ }
+
+ for (const effect of transaction.effects) {
+ if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
+ return getReadOnlyTooltip(transaction.state);
+ }
+ }
+
+ return [];
+ },
+ provide: (field) => {
+ return showTooltip.computeN([field], (state) => state.field(field));
+ },
+});
+
+const editableStateEffect = StateEffect.define();
+
+const editableStateField = StateField.define({
+ create() {
+ return true;
+ },
+ update(value, transaction) {
+ for (const effect of transaction.effects) {
+ if (effect.is(editableStateEffect)) {
+ return effect.value;
+ }
+ }
+
+ return value;
+ },
+});
+
+export const CodeMirrorEditor = memo(
+ ({
+ id,
+ doc,
+ debounceScroll = 100,
+ debounceChange = 150,
+ autoFocusOnDocumentChange = false,
+ editable = true,
+ onScroll,
+ onChange,
+ onSave,
+ theme,
+ settings,
+ className = '',
+ }: Props) => {
+ renderLogger.trace('CodeMirrorEditor');
+
+ const [languageCompartment] = useState(new Compartment());
+
+ const containerRef = useRef(null);
+ const viewRef = useRef();
+ const themeRef = useRef();
+ const docRef = useRef();
+ const editorStatesRef = useRef();
+ const onScrollRef = useRef(onScroll);
+ const onChangeRef = useRef(onChange);
+ const onSaveRef = useRef(onSave);
+
+ /**
+ * This effect is used to avoid side effects directly in the render function
+ * and instead the refs are updated after each render.
+ */
+ useEffect(() => {
+ onScrollRef.current = onScroll;
+ onChangeRef.current = onChange;
+ onSaveRef.current = onSave;
+ docRef.current = doc;
+ themeRef.current = theme;
+ });
+
+ useEffect(() => {
+ const onUpdate = debounce((update: EditorUpdate) => {
+ onChangeRef.current?.(update);
+ }, debounceChange);
+
+ const view = new EditorView({
+ parent: containerRef.current!,
+ dispatchTransactions(transactions) {
+ const previousSelection = view.state.selection;
+
+ view.update(transactions);
+
+ const newSelection = view.state.selection;
+
+ const selectionChanged =
+ newSelection !== previousSelection &&
+ (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
+
+ if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
+ onUpdate({
+ selection: view.state.selection,
+ content: view.state.doc.toString(),
+ });
+
+ editorStatesRef.current!.set(docRef.current.filePath, view.state);
+ }
+ },
+ });
+
+ viewRef.current = view;
+
+ return () => {
+ viewRef.current?.destroy();
+ viewRef.current = undefined;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!viewRef.current) {
+ return;
+ }
+
+ viewRef.current.dispatch({
+ effects: [reconfigureTheme(theme)],
+ });
+ }, [theme]);
+
+ useEffect(() => {
+ editorStatesRef.current = new Map();
+ }, [id]);
+
+ useEffect(() => {
+ const editorStates = editorStatesRef.current!;
+ const view = viewRef.current!;
+ const theme = themeRef.current!;
+
+ if (!doc) {
+ const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
+ languageCompartment.of([]),
+ ]);
+
+ view.setState(state);
+
+ setNoDocument(view);
+
+ return;
+ }
+
+ if (doc.isBinary) {
+ return;
+ }
+
+ if (doc.filePath === '') {
+ logger.warn('File path should not be empty');
+ }
+
+ let state = editorStates.get(doc.filePath);
+
+ if (!state) {
+ state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
+ languageCompartment.of([]),
+ ]);
+
+ editorStates.set(doc.filePath, state);
+ }
+
+ view.setState(state);
+
+ setEditorDocument(
+ view,
+ theme,
+ editable,
+ languageCompartment,
+ autoFocusOnDocumentChange,
+ doc as TextEditorDocument,
+ );
+ }, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
+
+ return (
+
+ {doc?.isBinary &&
}
+
+
+ );
+ },
+);
+
+export default CodeMirrorEditor;
+
+CodeMirrorEditor.displayName = 'CodeMirrorEditor';
+
+function newEditorState(
+ content: string,
+ theme: Theme,
+ settings: EditorSettings | undefined,
+ onScrollRef: MutableRefObject,
+ debounceScroll: number,
+ onFileSaveRef: MutableRefObject,
+ extensions: Extension[],
+) {
+ return EditorState.create({
+ doc: content,
+ extensions: [
+ EditorView.domEventHandlers({
+ scroll: debounce((event, view) => {
+ if (event.target !== view.scrollDOM) {
+ return;
+ }
+
+ onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
+ }, debounceScroll),
+ keydown: (event, view) => {
+ if (view.state.readOnly) {
+ view.dispatch({
+ effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
+ });
+
+ return true;
+ }
+
+ return false;
+ },
+ }),
+ getTheme(theme, settings),
+ history(),
+ keymap.of([
+ ...defaultKeymap,
+ ...historyKeymap,
+ ...searchKeymap,
+ { key: 'Tab', run: acceptCompletion },
+ {
+ key: 'Mod-s',
+ preventDefault: true,
+ run: () => {
+ onFileSaveRef.current?.();
+ return true;
+ },
+ },
+ indentKeyBinding,
+ ]),
+ indentUnit.of('\t'),
+ autocompletion({
+ closeOnBlur: false,
+ }),
+ tooltips({
+ position: 'absolute',
+ parent: document.body,
+ tooltipSpace: (view) => {
+ const rect = view.dom.getBoundingClientRect();
+
+ return {
+ top: rect.top - 50,
+ left: rect.left,
+ bottom: rect.bottom,
+ right: rect.right + 10,
+ };
+ },
+ }),
+ closeBrackets(),
+ lineNumbers(),
+ scrollPastEnd(),
+ dropCursor(),
+ drawSelection(),
+ bracketMatching(),
+ EditorState.tabSize.of(settings?.tabSize ?? 2),
+ indentOnInput(),
+ editableTooltipField,
+ editableStateField,
+ EditorState.readOnly.from(editableStateField, (editable) => !editable),
+ highlightActiveLineGutter(),
+ highlightActiveLine(),
+ foldGutter({
+ markerDOM: (open) => {
+ const icon = document.createElement('div');
+
+ icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`;
+
+ return icon;
+ },
+ }),
+ ...extensions,
+ ],
+ });
+}
+
+function setNoDocument(view: EditorView) {
+ view.dispatch({
+ selection: { anchor: 0 },
+ changes: {
+ from: 0,
+ to: view.state.doc.length,
+ insert: '',
+ },
+ });
+
+ view.scrollDOM.scrollTo(0, 0);
+}
+
+function setEditorDocument(
+ view: EditorView,
+ theme: Theme,
+ editable: boolean,
+ languageCompartment: Compartment,
+ autoFocus: boolean,
+ doc: TextEditorDocument,
+) {
+ if (doc.value !== view.state.doc.toString()) {
+ view.dispatch({
+ selection: { anchor: 0 },
+ changes: {
+ from: 0,
+ to: view.state.doc.length,
+ insert: doc.value,
+ },
+ });
+ }
+
+ view.dispatch({
+ effects: [editableStateEffect.of(editable && !doc.isBinary)],
+ });
+
+ getLanguage(doc.filePath).then((languageSupport) => {
+ if (!languageSupport) {
+ return;
+ }
+
+ view.dispatch({
+ effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
+ });
+
+ requestAnimationFrame(() => {
+ const currentLeft = view.scrollDOM.scrollLeft;
+ const currentTop = view.scrollDOM.scrollTop;
+ const newLeft = doc.scroll?.left ?? 0;
+ const newTop = doc.scroll?.top ?? 0;
+
+ const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
+
+ if (autoFocus && editable) {
+ if (needsScrolling) {
+ // we have to wait until the scroll position was changed before we can set the focus
+ view.scrollDOM.addEventListener(
+ 'scroll',
+ () => {
+ view.focus();
+ },
+ { once: true },
+ );
+ } else {
+ // if the scroll position is still the same we can focus immediately
+ view.focus();
+ }
+ }
+
+ view.scrollDOM.scrollTo(newLeft, newTop);
+ });
+ });
+}
+
+function getReadOnlyTooltip(state: EditorState) {
+ if (!state.readOnly) {
+ return [];
+ }
+
+ return state.selection.ranges
+ .filter((range) => {
+ return range.empty;
+ })
+ .map((range) => {
+ return {
+ pos: range.head,
+ above: true,
+ strictSide: true,
+ arrow: true,
+ create: () => {
+ const divElement = document.createElement('div');
+ divElement.className = 'cm-readonly-tooltip';
+ divElement.textContent = 'Cannot edit file while AI response is being generated';
+
+ return { dom: divElement };
+ },
+ };
+ });
+}
diff --git a/app/components/editor/codemirror/cm-theme.ts b/app/components/editor/codemirror/cm-theme.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6f3f3639ff3fe3a2979e0ecd20e7c6230d02f939
--- /dev/null
+++ b/app/components/editor/codemirror/cm-theme.ts
@@ -0,0 +1,192 @@
+import { Compartment, type Extension } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
+import type { Theme } from '~/types/theme.js';
+import type { EditorSettings } from './CodeMirrorEditor.js';
+
+export const darkTheme = EditorView.theme({}, { dark: true });
+export const themeSelection = new Compartment();
+
+export function getTheme(theme: Theme, settings: EditorSettings = {}): Extension {
+ return [
+ getEditorTheme(settings),
+ theme === 'dark' ? themeSelection.of([getDarkTheme()]) : themeSelection.of([getLightTheme()]),
+ ];
+}
+
+export function reconfigureTheme(theme: Theme) {
+ return themeSelection.reconfigure(theme === 'dark' ? getDarkTheme() : getLightTheme());
+}
+
+function getEditorTheme(settings: EditorSettings) {
+ return EditorView.theme({
+ '&': {
+ fontSize: settings.fontSize ?? '12px',
+ },
+ '&.cm-editor': {
+ height: '100%',
+ background: 'var(--cm-backgroundColor)',
+ color: 'var(--cm-textColor)',
+ },
+ '.cm-cursor': {
+ borderLeft: 'var(--cm-cursor-width) solid var(--cm-cursor-backgroundColor)',
+ },
+ '.cm-scroller': {
+ lineHeight: '1.5',
+ '&:focus-visible': {
+ outline: 'none',
+ },
+ },
+ '.cm-line': {
+ padding: '0 0 0 4px',
+ },
+ '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
+ backgroundColor: 'var(--cm-selection-backgroundColorFocused) !important',
+ opacity: 'var(--cm-selection-backgroundOpacityFocused, 0.3)',
+ },
+ '&:not(.cm-focused) > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
+ backgroundColor: 'var(--cm-selection-backgroundColorBlured)',
+ opacity: 'var(--cm-selection-backgroundOpacityBlured, 0.3)',
+ },
+ '&.cm-focused > .cm-scroller .cm-matchingBracket': {
+ backgroundColor: 'var(--cm-matching-bracket)',
+ },
+ '.cm-activeLine': {
+ background: 'var(--cm-activeLineBackgroundColor)',
+ },
+ '.cm-gutters': {
+ background: 'var(--cm-gutter-backgroundColor)',
+ borderRight: 0,
+ color: 'var(--cm-gutter-textColor)',
+ },
+ '.cm-gutter': {
+ '&.cm-lineNumbers': {
+ fontFamily: 'Roboto Mono, monospace',
+ fontSize: settings.gutterFontSize ?? settings.fontSize ?? '12px',
+ minWidth: '40px',
+ },
+ '& .cm-activeLineGutter': {
+ background: 'transparent',
+ color: 'var(--cm-gutter-activeLineTextColor)',
+ },
+ '&.cm-foldGutter .cm-gutterElement > .fold-icon': {
+ cursor: 'pointer',
+ color: 'var(--cm-foldGutter-textColor)',
+ transform: 'translateY(2px)',
+ '&:hover': {
+ color: 'var(--cm-foldGutter-textColorHover)',
+ },
+ },
+ },
+ '.cm-foldGutter .cm-gutterElement': {
+ padding: '0 4px',
+ },
+ '.cm-tooltip-autocomplete > ul > li': {
+ minHeight: '18px',
+ },
+ '.cm-panel.cm-search label': {
+ marginLeft: '2px',
+ fontSize: '12px',
+ },
+ '.cm-panel.cm-search .cm-button': {
+ fontSize: '12px',
+ },
+ '.cm-panel.cm-search .cm-textfield': {
+ fontSize: '12px',
+ },
+ '.cm-panel.cm-search input[type=checkbox]': {
+ position: 'relative',
+ transform: 'translateY(2px)',
+ marginRight: '4px',
+ },
+ '.cm-panels': {
+ borderColor: 'var(--cm-panels-borderColor)',
+ },
+ '.cm-panels-bottom': {
+ borderTop: '1px solid var(--cm-panels-borderColor)',
+ backgroundColor: 'transparent',
+ },
+ '.cm-panel.cm-search': {
+ background: 'var(--cm-search-backgroundColor)',
+ color: 'var(--cm-search-textColor)',
+ padding: '8px',
+ },
+ '.cm-search .cm-button': {
+ background: 'var(--cm-search-button-backgroundColor)',
+ borderColor: 'var(--cm-search-button-borderColor)',
+ color: 'var(--cm-search-button-textColor)',
+ borderRadius: '4px',
+ '&:hover': {
+ color: 'var(--cm-search-button-textColorHover)',
+ },
+ '&:focus-visible': {
+ outline: 'none',
+ borderColor: 'var(--cm-search-button-borderColorFocused)',
+ },
+ '&:hover:not(:focus-visible)': {
+ background: 'var(--cm-search-button-backgroundColorHover)',
+ borderColor: 'var(--cm-search-button-borderColorHover)',
+ },
+ '&:hover:focus-visible': {
+ background: 'var(--cm-search-button-backgroundColorHover)',
+ borderColor: 'var(--cm-search-button-borderColorFocused)',
+ },
+ },
+ '.cm-panel.cm-search [name=close]': {
+ top: '6px',
+ right: '6px',
+ padding: '0 6px',
+ fontSize: '1rem',
+ backgroundColor: 'var(--cm-search-closeButton-backgroundColor)',
+ color: 'var(--cm-search-closeButton-textColor)',
+ '&:hover': {
+ 'border-radius': '6px',
+ color: 'var(--cm-search-closeButton-textColorHover)',
+ backgroundColor: 'var(--cm-search-closeButton-backgroundColorHover)',
+ },
+ },
+ '.cm-search input': {
+ background: 'var(--cm-search-input-backgroundColor)',
+ borderColor: 'var(--cm-search-input-borderColor)',
+ color: 'var(--cm-search-input-textColor)',
+ outline: 'none',
+ borderRadius: '4px',
+ '&:focus-visible': {
+ borderColor: 'var(--cm-search-input-borderColorFocused)',
+ },
+ },
+ '.cm-tooltip': {
+ background: 'var(--cm-tooltip-backgroundColor)',
+ border: '1px solid transparent',
+ borderColor: 'var(--cm-tooltip-borderColor)',
+ color: 'var(--cm-tooltip-textColor)',
+ },
+ '.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
+ background: 'var(--cm-tooltip-backgroundColorSelected)',
+ color: 'var(--cm-tooltip-textColorSelected)',
+ },
+ '.cm-searchMatch': {
+ backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
+ },
+ '.cm-tooltip.cm-readonly-tooltip': {
+ padding: '4px',
+ whiteSpace: 'nowrap',
+ backgroundColor: 'var(--bolt-elements-bg-depth-2)',
+ borderColor: 'var(--bolt-elements-borderColorActive)',
+ '& .cm-tooltip-arrow:before': {
+ borderTopColor: 'var(--bolt-elements-borderColorActive)',
+ },
+ '& .cm-tooltip-arrow:after': {
+ borderTopColor: 'transparent',
+ },
+ },
+ });
+}
+
+function getLightTheme() {
+ return vscodeLight;
+}
+
+function getDarkTheme() {
+ return vscodeDark;
+}
diff --git a/app/components/editor/codemirror/indent.ts b/app/components/editor/codemirror/indent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..019a066e08f0cc7c19b05fdeb317175141441281
--- /dev/null
+++ b/app/components/editor/codemirror/indent.ts
@@ -0,0 +1,68 @@
+import { indentLess } from '@codemirror/commands';
+import { indentUnit } from '@codemirror/language';
+import { EditorSelection, EditorState, Line, type ChangeSpec } from '@codemirror/state';
+import { EditorView, type KeyBinding } from '@codemirror/view';
+
+export const indentKeyBinding: KeyBinding = {
+ key: 'Tab',
+ run: indentMore,
+ shift: indentLess,
+};
+
+function indentMore({ state, dispatch }: EditorView) {
+ if (state.readOnly) {
+ return false;
+ }
+
+ dispatch(
+ state.update(
+ changeBySelectedLine(state, (from, to, changes) => {
+ changes.push({ from, to, insert: state.facet(indentUnit) });
+ }),
+ { userEvent: 'input.indent' },
+ ),
+ );
+
+ return true;
+}
+
+function changeBySelectedLine(
+ state: EditorState,
+ cb: (from: number, to: number | undefined, changes: ChangeSpec[], line: Line) => void,
+) {
+ return state.changeByRange((range) => {
+ const changes: ChangeSpec[] = [];
+
+ const line = state.doc.lineAt(range.from);
+
+ // just insert single indent unit at the current cursor position
+ if (range.from === range.to) {
+ cb(range.from, undefined, changes, line);
+ }
+ // handle the case when multiple characters are selected in a single line
+ else if (range.from < range.to && range.to <= line.to) {
+ cb(range.from, range.to, changes, line);
+ } else {
+ let atLine = -1;
+
+ // handle the case when selection spans multiple lines
+ for (let pos = range.from; pos <= range.to; ) {
+ const line = state.doc.lineAt(pos);
+
+ if (line.number > atLine && (range.empty || range.to > line.from)) {
+ cb(line.from, undefined, changes, line);
+ atLine = line.number;
+ }
+
+ pos = line.to + 1;
+ }
+ }
+
+ const changeSet = state.changes(changes);
+
+ return {
+ changes,
+ range: EditorSelection.range(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1)),
+ };
+ });
+}
diff --git a/app/components/editor/codemirror/languages.ts b/app/components/editor/codemirror/languages.ts
new file mode 100644
index 0000000000000000000000000000000000000000..94d3072f9203a3ca53795d3286c5185889208fa7
--- /dev/null
+++ b/app/components/editor/codemirror/languages.ts
@@ -0,0 +1,112 @@
+import { LanguageDescription } from '@codemirror/language';
+
+export const supportedLanguages = [
+ LanguageDescription.of({
+ name: 'VUE',
+ extensions: ['vue'],
+ async load() {
+ return import('@codemirror/lang-vue').then((module) => module.vue());
+ },
+ }),
+ LanguageDescription.of({
+ name: 'TS',
+ extensions: ['ts'],
+ async load() {
+ return import('@codemirror/lang-javascript').then((module) => module.javascript({ typescript: true }));
+ },
+ }),
+ LanguageDescription.of({
+ name: 'JS',
+ extensions: ['js', 'mjs', 'cjs'],
+ async load() {
+ return import('@codemirror/lang-javascript').then((module) => module.javascript());
+ },
+ }),
+ LanguageDescription.of({
+ name: 'TSX',
+ extensions: ['tsx'],
+ async load() {
+ return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true, typescript: true }));
+ },
+ }),
+ LanguageDescription.of({
+ name: 'JSX',
+ extensions: ['jsx'],
+ async load() {
+ return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true }));
+ },
+ }),
+ LanguageDescription.of({
+ name: 'HTML',
+ extensions: ['html'],
+ async load() {
+ return import('@codemirror/lang-html').then((module) => module.html());
+ },
+ }),
+ LanguageDescription.of({
+ name: 'CSS',
+ extensions: ['css'],
+ async load() {
+ return import('@codemirror/lang-css').then((module) => module.css());
+ },
+ }),
+ LanguageDescription.of({
+ name: 'SASS',
+ extensions: ['sass'],
+ async load() {
+ return import('@codemirror/lang-sass').then((module) => module.sass({ indented: true }));
+ },
+ }),
+ LanguageDescription.of({
+ name: 'SCSS',
+ extensions: ['scss'],
+ async load() {
+ return import('@codemirror/lang-sass').then((module) => module.sass({ indented: false }));
+ },
+ }),
+ LanguageDescription.of({
+ name: 'JSON',
+ extensions: ['json'],
+ async load() {
+ return import('@codemirror/lang-json').then((module) => module.json());
+ },
+ }),
+ LanguageDescription.of({
+ name: 'Markdown',
+ extensions: ['md'],
+ async load() {
+ return import('@codemirror/lang-markdown').then((module) => module.markdown());
+ },
+ }),
+ LanguageDescription.of({
+ name: 'Wasm',
+ extensions: ['wat'],
+ async load() {
+ return import('@codemirror/lang-wast').then((module) => module.wast());
+ },
+ }),
+ LanguageDescription.of({
+ name: 'Python',
+ extensions: ['py'],
+ async load() {
+ return import('@codemirror/lang-python').then((module) => module.python());
+ },
+ }),
+ LanguageDescription.of({
+ name: 'C++',
+ extensions: ['cpp'],
+ async load() {
+ return import('@codemirror/lang-cpp').then((module) => module.cpp());
+ },
+ }),
+];
+
+export async function getLanguage(fileName: string) {
+ const languageDescription = LanguageDescription.matchFilename(supportedLanguages, fileName);
+
+ if (languageDescription) {
+ return await languageDescription.load();
+ }
+
+ return undefined;
+}
diff --git a/app/components/git/GitUrlImport.client.tsx b/app/components/git/GitUrlImport.client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6053acdc32b2199cc1e472e16a5036a83023051f
--- /dev/null
+++ b/app/components/git/GitUrlImport.client.tsx
@@ -0,0 +1,146 @@
+import { useSearchParams } from '@remix-run/react';
+import { generateId, type Message } from 'ai';
+import ignore from 'ignore';
+import { useEffect, useState } from 'react';
+import { ClientOnly } from 'remix-utils/client-only';
+import { BaseChat } from '~/components/chat/BaseChat';
+import { Chat } from '~/components/chat/Chat.client';
+import { useGit } from '~/lib/hooks/useGit';
+import { useChatHistory } from '~/lib/persistence';
+import { createCommandsMessage, detectProjectCommands, escapeBoltTags } from '~/utils/projectCommands';
+import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
+import { toast } from 'react-toastify';
+
+const IGNORE_PATTERNS = [
+ 'node_modules/**',
+ '.git/**',
+ '.github/**',
+ '.vscode/**',
+ '**/*.jpg',
+ '**/*.jpeg',
+ '**/*.png',
+ 'dist/**',
+ 'build/**',
+ '.next/**',
+ 'coverage/**',
+ '.cache/**',
+ '.vscode/**',
+ '.idea/**',
+ '**/*.log',
+ '**/.DS_Store',
+ '**/npm-debug.log*',
+ '**/yarn-debug.log*',
+ '**/yarn-error.log*',
+ '**/*lock.json',
+ '**/*lock.yaml',
+];
+
+export function GitUrlImport() {
+ const [searchParams] = useSearchParams();
+ const { ready: historyReady, importChat } = useChatHistory();
+ const { ready: gitReady, gitClone } = useGit();
+ const [imported, setImported] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const importRepo = async (repoUrl?: string) => {
+ if (!gitReady && !historyReady) {
+ return;
+ }
+
+ if (repoUrl) {
+ const ig = ignore().add(IGNORE_PATTERNS);
+
+ try {
+ const { workdir, data } = await gitClone(repoUrl);
+
+ if (importChat) {
+ const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
+ const textDecoder = new TextDecoder('utf-8');
+
+ const fileContents = filePaths
+ .map((filePath) => {
+ const { data: content, encoding } = data[filePath];
+ return {
+ path: filePath,
+ content:
+ encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
+ };
+ })
+ .filter((f) => f.content);
+
+ const commands = await detectProjectCommands(fileContents);
+ const commandsMessage = createCommandsMessage(commands);
+
+ const filesMessage: Message = {
+ role: 'assistant',
+ content: `Cloning the repo ${repoUrl} into ${workdir}
+
+${fileContents
+ .map(
+ (file) =>
+ `
+${escapeBoltTags(file.content)}
+ `,
+ )
+ .join('\n')}
+ `,
+ id: generateId(),
+ createdAt: new Date(),
+ };
+
+ const messages = [filesMessage];
+
+ if (commandsMessage) {
+ messages.push({
+ role: 'user',
+ id: generateId(),
+ content: 'Setup the codebase and Start the application',
+ });
+ messages.push(commandsMessage);
+ }
+
+ await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages, { gitUrl: repoUrl });
+ }
+ } catch (error) {
+ console.error('Error during import:', error);
+ toast.error('Failed to import repository');
+ setLoading(false);
+ window.location.href = '/';
+
+ return;
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (!historyReady || !gitReady || imported) {
+ return;
+ }
+
+ const url = searchParams.get('url');
+
+ if (!url) {
+ window.location.href = '/';
+ return;
+ }
+
+ importRepo(url).catch((error) => {
+ console.error('Error importing repo:', error);
+ toast.error('Failed to import repository');
+ setLoading(false);
+ window.location.href = '/';
+ });
+ setImported(true);
+ }, [searchParams, historyReady, gitReady, imported]);
+
+ return (
+ }>
+ {() => (
+ <>
+
+ {loading && }
+ >
+ )}
+
+ );
+}
diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ce46702a66a673df88ab47c96ca7fa945231d2dc
--- /dev/null
+++ b/app/components/header/Header.tsx
@@ -0,0 +1,42 @@
+import { useStore } from '@nanostores/react';
+import { ClientOnly } from 'remix-utils/client-only';
+import { chatStore } from '~/lib/stores/chat';
+import { classNames } from '~/utils/classNames';
+import { HeaderActionButtons } from './HeaderActionButtons.client';
+import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
+
+export function Header() {
+ const chat = useStore(chatStore);
+
+ return (
+
+
+ {chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
+ <>
+
+ {() => }
+
+
+ {() => (
+
+
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d6273f57f2fe5e524f50aa56ebebbb463dd5175d
--- /dev/null
+++ b/app/components/header/HeaderActionButtons.client.tsx
@@ -0,0 +1,336 @@
+import { useStore } from '@nanostores/react';
+import { toast } from 'react-toastify';
+import useViewport from '~/lib/hooks';
+import { chatStore } from '~/lib/stores/chat';
+import { netlifyConnection } from '~/lib/stores/netlify';
+import { workbenchStore } from '~/lib/stores/workbench';
+import { webcontainer } from '~/lib/webcontainer';
+import { classNames } from '~/utils/classNames';
+import { path } from '~/utils/path';
+import { useEffect, useRef, useState } from 'react';
+import type { ActionCallbackData } from '~/lib/runtime/message-parser';
+import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import
+import { streamingState } from '~/lib/stores/streaming';
+import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
+
+interface HeaderActionButtonsProps {}
+
+export function HeaderActionButtons({}: HeaderActionButtonsProps) {
+ const showWorkbench = useStore(workbenchStore.showWorkbench);
+ const { showChat } = useStore(chatStore);
+ const connection = useStore(netlifyConnection);
+ const [activePreviewIndex] = useState(0);
+ const previews = useStore(workbenchStore.previews);
+ const activePreview = previews[activePreviewIndex];
+ const [isDeploying, setIsDeploying] = useState(false);
+ const isSmallViewport = useViewport(1024);
+ const canHideChat = showWorkbench || !showChat;
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const dropdownRef = useRef(null);
+ const isStreaming = useStore(streamingState);
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsDropdownOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const currentChatId = useStore(chatId);
+
+ const handleDeploy = async () => {
+ if (!connection.user || !connection.token) {
+ toast.error('Please connect to Netlify first in the settings tab!');
+ return;
+ }
+
+ if (!currentChatId) {
+ toast.error('No active chat found');
+ return;
+ }
+
+ try {
+ setIsDeploying(true);
+
+ const artifact = workbenchStore.firstArtifact;
+
+ if (!artifact) {
+ throw new Error('No active project found');
+ }
+
+ const actionId = 'build-' + Date.now();
+ const actionData: ActionCallbackData = {
+ messageId: 'netlify build',
+ artifactId: artifact.id,
+ actionId,
+ action: {
+ type: 'build' as const,
+ content: 'npm run build',
+ },
+ };
+
+ // Add the action first
+ artifact.runner.addAction(actionData);
+
+ // Then run it
+ await artifact.runner.runAction(actionData);
+
+ if (!artifact.runner.buildOutput) {
+ throw new Error('Build failed');
+ }
+
+ // Get the build files
+ const container = await webcontainer;
+
+ // Remove /home/project from buildPath if it exists
+ const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
+
+ // Get all files recursively
+ async function getAllFiles(dirPath: string): Promise> {
+ const files: Record = {};
+ const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(dirPath, entry.name);
+
+ if (entry.isFile()) {
+ const content = await container.fs.readFile(fullPath, 'utf-8');
+
+ // Remove /dist prefix from the path
+ const deployPath = fullPath.replace(buildPath, '');
+ files[deployPath] = content;
+ } else if (entry.isDirectory()) {
+ const subFiles = await getAllFiles(fullPath);
+ Object.assign(files, subFiles);
+ }
+ }
+
+ return files;
+ }
+
+ const fileContents = await getAllFiles(buildPath);
+
+ // Use chatId instead of artifact.id
+ const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
+
+ // Deploy using the API route with file contents
+ const response = await fetch('/api/deploy', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ siteId: existingSiteId || undefined,
+ files: fileContents,
+ token: connection.token,
+ chatId: currentChatId, // Use chatId instead of artifact.id
+ }),
+ });
+
+ const data = (await response.json()) as any;
+
+ if (!response.ok || !data.deploy || !data.site) {
+ console.error('Invalid deploy response:', data);
+ throw new Error(data.error || 'Invalid deployment response');
+ }
+
+ // Poll for deployment status
+ const maxAttempts = 20; // 2 minutes timeout
+ let attempts = 0;
+ let deploymentStatus;
+
+ while (attempts < maxAttempts) {
+ try {
+ const statusResponse = await fetch(
+ `https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
+ {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ },
+ );
+
+ deploymentStatus = (await statusResponse.json()) as any;
+
+ if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
+ break;
+ }
+
+ if (deploymentStatus.state === 'error') {
+ throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
+ }
+
+ attempts++;
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ } catch (error) {
+ console.error('Status check error:', error);
+ attempts++;
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+ }
+
+ if (attempts >= maxAttempts) {
+ throw new Error('Deployment timed out');
+ }
+
+ // Store the site ID if it's a new site
+ if (data.site) {
+ localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id);
+ }
+
+ toast.success(
+ ,
+ );
+ } catch (error) {
+ console.error('Deploy error:', error);
+ toast.error(error instanceof Error ? error.message : 'Deployment failed');
+ } finally {
+ setIsDeploying(false);
+ }
+ };
+
+ return (
+
+
+
+
setIsDropdownOpen(!isDropdownOpen)}
+ className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
+ >
+ {isDeploying ? 'Deploying...' : 'Deploy'}
+
+
+
+
+ {isDropdownOpen && (
+
+
{
+ handleDeploy();
+ setIsDropdownOpen(false);
+ }}
+ disabled={isDeploying || !activePreview || !connection.user}
+ className="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"
+ >
+
+ {!connection.user ? 'No Account Connected' : 'Deploy to Netlify'}
+ {connection.user && }
+
+
+ Coming Soon
+
+ Deploy to Vercel (Coming Soon)
+
+
+ Coming Soon
+
+ Deploy to Cloudflare (Coming Soon)
+
+
+ )}
+
+
+
{
+ if (canHideChat) {
+ chatStore.setKey('showChat', !showChat);
+ }
+ }}
+ >
+
+
+
+
{
+ if (showWorkbench && !showChat) {
+ chatStore.setKey('showChat', true);
+ }
+
+ workbenchStore.showWorkbench.set(!showWorkbench);
+ }}
+ >
+
+
+
+
+ );
+}
+
+interface ButtonProps {
+ active?: boolean;
+ disabled?: boolean;
+ children?: any;
+ onClick?: VoidFunction;
+ className?: string;
+}
+
+function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/components/sidebar/HistoryItem.tsx b/app/components/sidebar/HistoryItem.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a6f0d0a56559b3b8715b6d2f9d8a8e7d0058bce0
--- /dev/null
+++ b/app/components/sidebar/HistoryItem.tsx
@@ -0,0 +1,132 @@
+import { useParams } from '@remix-run/react';
+import { classNames } from '~/utils/classNames';
+import * as Dialog from '@radix-ui/react-dialog';
+import { type ChatHistoryItem } from '~/lib/persistence';
+import WithTooltip from '~/components/ui/Tooltip';
+import { useEditChatDescription } from '~/lib/hooks';
+import { forwardRef, type ForwardedRef } from 'react';
+
+interface HistoryItemProps {
+ item: ChatHistoryItem;
+ onDelete?: (event: React.UIEvent) => void;
+ onDuplicate?: (id: string) => void;
+ exportChat: (id?: string) => void;
+}
+
+export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
+ const { id: urlId } = useParams();
+ const isActiveChat = urlId === item.urlId;
+
+ const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
+ useEditChatDescription({
+ initialDescription: item.description,
+ customChatId: item.id,
+ syncWithGlobalStore: isActiveChat,
+ });
+
+ return (
+
+ );
+}
+
+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
new file mode 100644
index 0000000000000000000000000000000000000000..59c1fc2219a262a5079b3aa041b40fa603cf6a89
--- /dev/null
+++ b/app/components/sidebar/Menu.client.tsx
@@ -0,0 +1,289 @@
+import { motion, type Variants } from 'framer-motion';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { toast } from 'react-toastify';
+import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
+import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
+import { ControlPanel } from '~/components/@settings/core/ControlPanel';
+import { SettingsButton } from '~/components/ui/SettingsButton';
+import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
+import { cubicEasingFn } from '~/utils/easings';
+import { logger } from '~/utils/logger';
+import { HistoryItem } from './HistoryItem';
+import { binDates } from './date-binning';
+import { useSearchFilter } from '~/lib/hooks/useSearchFilter';
+import { classNames } from '~/utils/classNames';
+import { useStore } from '@nanostores/react';
+import { profileStore } from '~/lib/stores/profile';
+
+const menuVariants = {
+ closed: {
+ opacity: 0,
+ visibility: 'hidden',
+ left: '-340px',
+ transition: {
+ duration: 0.2,
+ ease: cubicEasingFn,
+ },
+ },
+ open: {
+ opacity: 1,
+ visibility: 'initial',
+ left: 0,
+ transition: {
+ duration: 0.2,
+ ease: cubicEasingFn,
+ },
+ },
+} satisfies Variants;
+
+type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
+
+function CurrentDateTime() {
+ const [dateTime, setDateTime] = useState(new Date());
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setDateTime(new Date());
+ }, 60000);
+
+ return () => clearInterval(timer);
+ }, []);
+
+ return (
+
+
+
+ {dateTime.toLocaleDateString()}
+ {dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+ );
+}
+
+export const Menu = () => {
+ const { duplicateCurrentChat, exportChat } = useChatHistory();
+ const menuRef = useRef(null);
+ const [list, setList] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [dialogContent, setDialogContent] = useState(null);
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
+ const profile = useStore(profileStore);
+
+ const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({
+ items: list,
+ searchFields: ['description'],
+ });
+
+ const loadEntries = useCallback(() => {
+ if (db) {
+ getAll(db)
+ .then((list) => list.filter((item) => item.urlId && item.description))
+ .then(setList)
+ .catch((error) => toast.error(error.message));
+ }
+ }, []);
+
+ const deleteItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => {
+ event.preventDefault();
+
+ if (db) {
+ deleteById(db, item.id)
+ .then(() => {
+ loadEntries();
+
+ if (chatId.get() === item.id) {
+ // hard page navigation to clear the stores
+ window.location.pathname = '/';
+ }
+ })
+ .catch((error) => {
+ toast.error('Failed to delete conversation');
+ logger.error(error);
+ });
+ }
+ }, []);
+
+ const closeDialog = () => {
+ setDialogContent(null);
+ };
+
+ useEffect(() => {
+ if (open) {
+ loadEntries();
+ }
+ }, [open]);
+
+ useEffect(() => {
+ const enterThreshold = 40;
+ const exitThreshold = 40;
+
+ function onMouseMove(event: MouseEvent) {
+ if (isSettingsOpen) {
+ return;
+ }
+
+ if (event.pageX < enterThreshold) {
+ setOpen(true);
+ }
+
+ if (menuRef.current && event.clientX > menuRef.current.getBoundingClientRect().right + exitThreshold) {
+ setOpen(false);
+ }
+ }
+
+ window.addEventListener('mousemove', onMouseMove);
+
+ return () => {
+ window.removeEventListener('mousemove', onMouseMove);
+ };
+ }, [isSettingsOpen]);
+
+ const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
+ event.preventDefault();
+ setDialogContent({ type: 'delete', item });
+ };
+
+ const handleDuplicate = async (id: string) => {
+ await duplicateCurrentChat(id);
+ loadEntries(); // Reload the list after duplication
+ };
+
+ const handleSettingsClick = () => {
+ setIsSettingsOpen(true);
+ setOpen(false);
+ };
+
+ const handleSettingsClose = () => {
+ setIsSettingsOpen(false);
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {profile?.username || 'Guest User'}
+
+
+ {profile?.avatar ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
Your Chats
+
+ {filteredList.length === 0 && (
+
+ {list.length === 0 ? 'No previous conversations' : 'No matches found'}
+
+ )}
+
+ {binDates(filteredList).map(({ category, items }) => (
+
+
+ {category}
+
+
+ {items.map((item) => (
+ handleDeleteClick(event, item)}
+ onDuplicate={() => handleDuplicate(item.id)}
+ />
+ ))}
+
+
+ ))}
+
+ {dialogContent?.type === 'delete' && (
+ <>
+
+
Delete Chat?
+
+
+ You are about to delete{' '}
+
+ {dialogContent.item.description}
+
+
+ Are you sure you want to delete this chat?
+
+
+
+
+ Cancel
+
+ {
+ deleteItem(event, dialogContent.item);
+ closeDialog();
+ }}
+ >
+ Delete
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/app/components/sidebar/date-binning.ts b/app/components/sidebar/date-binning.ts
new file mode 100644
index 0000000000000000000000000000000000000000..693f1a36d83302c368f9f103e683de96a35b4e0d
--- /dev/null
+++ b/app/components/sidebar/date-binning.ts
@@ -0,0 +1,59 @@
+import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns';
+import type { ChatHistoryItem } from '~/lib/persistence';
+
+type Bin = { category: string; items: ChatHistoryItem[] };
+
+export function binDates(_list: ChatHistoryItem[]) {
+ const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
+
+ const binLookup: Record = {};
+ const bins: Array = [];
+
+ list.forEach((item) => {
+ const category = dateCategory(new Date(item.timestamp));
+
+ if (!(category in binLookup)) {
+ const bin = {
+ category,
+ items: [item],
+ };
+
+ binLookup[category] = bin;
+
+ bins.push(bin);
+ } else {
+ binLookup[category].items.push(item);
+ }
+ });
+
+ return bins;
+}
+
+function dateCategory(date: Date) {
+ if (isToday(date)) {
+ return 'Today';
+ }
+
+ if (isYesterday(date)) {
+ return 'Yesterday';
+ }
+
+ if (isThisWeek(date)) {
+ // e.g., "Mon" instead of "Monday"
+ return format(date, 'EEE');
+ }
+
+ const thirtyDaysAgo = subDays(new Date(), 30);
+
+ if (isAfter(date, thirtyDaysAgo)) {
+ return 'Past 30 Days';
+ }
+
+ if (isThisYear(date)) {
+ // e.g., "Jan" instead of "January"
+ return format(date, 'LLL');
+ }
+
+ // e.g., "Jan 2023" instead of "January 2023"
+ return format(date, 'LLL yyyy');
+}
diff --git a/app/components/ui/BackgroundRays/index.tsx b/app/components/ui/BackgroundRays/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ac4ed86d032e210a47ab957ee54a3d176dd4b4fa
--- /dev/null
+++ b/app/components/ui/BackgroundRays/index.tsx
@@ -0,0 +1,18 @@
+import styles from './styles.module.scss';
+
+const BackgroundRays = () => {
+ return (
+
+ );
+};
+
+export default BackgroundRays;
diff --git a/app/components/ui/BackgroundRays/styles.module.scss b/app/components/ui/BackgroundRays/styles.module.scss
new file mode 100644
index 0000000000000000000000000000000000000000..bac4c8aa5db039e88416044e14ce3976960ececa
--- /dev/null
+++ b/app/components/ui/BackgroundRays/styles.module.scss
@@ -0,0 +1,246 @@
+.rayContainer {
+ // Theme-specific colors
+ --ray-color-primary: color-mix(in srgb, var(--primary-color), transparent 30%);
+ --ray-color-secondary: color-mix(in srgb, var(--secondary-color), transparent 30%);
+ --ray-color-accent: color-mix(in srgb, var(--accent-color), transparent 30%);
+
+ // Theme-specific gradients
+ --ray-gradient-primary: radial-gradient(var(--ray-color-primary) 0%, transparent 70%);
+ --ray-gradient-secondary: radial-gradient(var(--ray-color-secondary) 0%, transparent 70%);
+ --ray-gradient-accent: radial-gradient(var(--ray-color-accent) 0%, transparent 70%);
+
+ position: fixed;
+ inset: 0;
+ overflow: hidden;
+ animation: fadeIn 1.5s ease-out;
+ pointer-events: none;
+ z-index: 0;
+ // background-color: transparent;
+
+ :global(html[data-theme='dark']) & {
+ mix-blend-mode: screen;
+ }
+
+ :global(html[data-theme='light']) & {
+ mix-blend-mode: multiply;
+ }
+}
+
+.lightRay {
+ position: absolute;
+ border-radius: 100%;
+
+ :global(html[data-theme='dark']) & {
+ mix-blend-mode: screen;
+ }
+
+ :global(html[data-theme='light']) & {
+ mix-blend-mode: multiply;
+ opacity: 0.4;
+ }
+}
+
+.ray1 {
+ width: 600px;
+ height: 800px;
+ background: var(--ray-gradient-primary);
+ transform: rotate(65deg);
+ top: -500px;
+ left: -100px;
+ filter: blur(80px);
+ opacity: 0.6;
+ animation: float1 15s infinite ease-in-out;
+}
+
+.ray2 {
+ width: 400px;
+ height: 600px;
+ background: var(--ray-gradient-secondary);
+ transform: rotate(-30deg);
+ top: -300px;
+ left: 200px;
+ filter: blur(60px);
+ opacity: 0.6;
+ animation: float2 18s infinite ease-in-out;
+}
+
+.ray3 {
+ width: 500px;
+ height: 400px;
+ background: var(--ray-gradient-accent);
+ top: -320px;
+ left: 500px;
+ filter: blur(65px);
+ opacity: 0.5;
+ animation: float3 20s infinite ease-in-out;
+}
+
+.ray4 {
+ width: 400px;
+ height: 450px;
+ background: var(--ray-gradient-secondary);
+ top: -350px;
+ left: 800px;
+ filter: blur(55px);
+ opacity: 0.55;
+ animation: float4 17s infinite ease-in-out;
+}
+
+.ray5 {
+ width: 350px;
+ height: 500px;
+ background: var(--ray-gradient-primary);
+ transform: rotate(-45deg);
+ top: -250px;
+ left: 1000px;
+ filter: blur(45px);
+ opacity: 0.6;
+ animation: float5 16s infinite ease-in-out;
+}
+
+.ray6 {
+ width: 300px;
+ height: 700px;
+ background: var(--ray-gradient-accent);
+ transform: rotate(75deg);
+ top: -400px;
+ left: 600px;
+ filter: blur(75px);
+ opacity: 0.45;
+ animation: float6 19s infinite ease-in-out;
+}
+
+.ray7 {
+ width: 450px;
+ height: 600px;
+ background: var(--ray-gradient-primary);
+ transform: rotate(45deg);
+ top: -450px;
+ left: 350px;
+ filter: blur(65px);
+ opacity: 0.55;
+ animation: float7 21s infinite ease-in-out;
+}
+
+.ray8 {
+ width: 380px;
+ height: 550px;
+ background: var(--ray-gradient-secondary);
+ transform: rotate(-60deg);
+ top: -380px;
+ left: 750px;
+ filter: blur(58px);
+ opacity: 0.6;
+ animation: float8 14s infinite ease-in-out;
+}
+
+@keyframes float1 {
+ 0%,
+ 100% {
+ transform: rotate(65deg) translate(0, 0);
+ }
+ 25% {
+ transform: rotate(70deg) translate(30px, 20px);
+ }
+ 50% {
+ transform: rotate(60deg) translate(-20px, 40px);
+ }
+ 75% {
+ transform: rotate(68deg) translate(-40px, 10px);
+ }
+}
+
+@keyframes float2 {
+ 0%,
+ 100% {
+ transform: rotate(-30deg) scale(1);
+ }
+ 33% {
+ transform: rotate(-25deg) scale(1.1);
+ }
+ 66% {
+ transform: rotate(-35deg) scale(0.95);
+ }
+}
+
+@keyframes float3 {
+ 0%,
+ 100% {
+ transform: translate(0, 0) rotate(0deg);
+ }
+ 25% {
+ transform: translate(40px, 20px) rotate(5deg);
+ }
+ 75% {
+ transform: translate(-30px, 40px) rotate(-5deg);
+ }
+}
+
+@keyframes float4 {
+ 0%,
+ 100% {
+ transform: scale(1) rotate(0deg);
+ }
+ 50% {
+ transform: scale(1.15) rotate(10deg);
+ }
+}
+
+@keyframes float5 {
+ 0%,
+ 100% {
+ transform: rotate(-45deg) translate(0, 0);
+ }
+ 33% {
+ transform: rotate(-40deg) translate(25px, -20px);
+ }
+ 66% {
+ transform: rotate(-50deg) translate(-25px, 20px);
+ }
+}
+
+@keyframes float6 {
+ 0%,
+ 100% {
+ transform: rotate(75deg) scale(1);
+ filter: blur(75px);
+ }
+ 50% {
+ transform: rotate(85deg) scale(1.1);
+ filter: blur(65px);
+ }
+}
+
+@keyframes float7 {
+ 0%,
+ 100% {
+ transform: rotate(45deg) translate(0, 0);
+ opacity: 0.55;
+ }
+ 50% {
+ transform: rotate(40deg) translate(-30px, 30px);
+ opacity: 0.65;
+ }
+}
+
+@keyframes float8 {
+ 0%,
+ 100% {
+ transform: rotate(-60deg) scale(1);
+ }
+ 25% {
+ transform: rotate(-55deg) scale(1.05);
+ }
+ 75% {
+ transform: rotate(-65deg) scale(0.95);
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/app/components/ui/Badge.tsx b/app/components/ui/Badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5f2ccdb21b787ed537cc8092fe9e049152ea836e
--- /dev/null
+++ b/app/components/ui/Badge.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { classNames } from '~/utils/classNames';
+
+const badgeVariants = cva(
+ 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background/80',
+ secondary:
+ 'border-transparent bg-bolt-elements-background text-bolt-elements-textSecondary hover:bg-bolt-elements-background/80',
+ destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20',
+ outline: 'text-bolt-elements-textPrimary',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+export interface BadgeProps extends React.HTMLAttributes, VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return
;
+}
+
+export { Badge, badgeVariants };
diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..14cc562ca4f28cd36cd2115f05f631969a04bd4e
--- /dev/null
+++ b/app/components/ui/Button.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { classNames } from '~/utils/classNames';
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-bolt-elements-borderColor disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default: 'bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
+ destructive: 'bg-red-500 text-white hover:bg-red-600',
+ outline:
+ 'border border-input bg-transparent hover:bg-bolt-elements-background-depth-2 hover:text-bolt-elements-textPrimary',
+ secondary:
+ 'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
+ ghost: 'hover:bg-bolt-elements-background-depth-1 hover:text-bolt-elements-textPrimary',
+ link: 'text-bolt-elements-textPrimary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2',
+ sm: 'h-8 rounded-md px-3 text-xs',
+ lg: 'h-10 rounded-md px-8',
+ icon: 'h-9 w-9',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ _asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, _asChild = false, ...props }, ref) => {
+ return ;
+ },
+);
+Button.displayName = 'Button';
+
+export { Button, buttonVariants };
diff --git a/app/components/ui/Card.tsx b/app/components/ui/Card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..512647e4b4c70d7814ef29d5c470619d5dfd67a9
--- /dev/null
+++ b/app/components/ui/Card.tsx
@@ -0,0 +1,55 @@
+import { forwardRef } from 'react';
+import { classNames } from '~/utils/classNames';
+
+export interface CardProps extends React.HTMLAttributes {}
+
+const Card = forwardRef(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+Card.displayName = 'Card';
+
+const CardHeader = forwardRef(({ className, ...props }, ref) => {
+ return
;
+});
+CardHeader.displayName = 'CardHeader';
+
+const CardTitle = forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+CardTitle.displayName = 'CardTitle';
+
+const CardDescription = forwardRef>(
+ ({ className, ...props }, ref) => {
+ return
;
+ },
+);
+CardDescription.displayName = 'CardDescription';
+
+const CardContent = forwardRef(({ className, ...props }, ref) => {
+ return
;
+});
+CardContent.displayName = 'CardContent';
+
+const CardFooter = forwardRef>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = 'CardFooter';
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/app/components/ui/Collapsible.tsx b/app/components/ui/Collapsible.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..61ddbbb6d5256b8013de2effd3c6f9b878baf077
--- /dev/null
+++ b/app/components/ui/Collapsible.tsx
@@ -0,0 +1,9 @@
+'use client';
+
+import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
+
+const Collapsible = CollapsiblePrimitive.Root;
+const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
+const CollapsibleContent = CollapsiblePrimitive.Content;
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/app/components/ui/Dialog.tsx b/app/components/ui/Dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5d5b26ce0a2d799056087774590f86b79c91da2f
--- /dev/null
+++ b/app/components/ui/Dialog.tsx
@@ -0,0 +1,144 @@
+import * as RadixDialog from '@radix-ui/react-dialog';
+import { motion, type Variants } from 'framer-motion';
+import React, { memo, type ReactNode } from 'react';
+import { classNames } from '~/utils/classNames';
+import { cubicEasingFn } from '~/utils/easings';
+import { IconButton } from './IconButton';
+
+export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
+
+interface DialogButtonProps {
+ type: 'primary' | 'secondary' | 'danger';
+ children: ReactNode;
+ onClick?: (event: React.MouseEvent) => void;
+ disabled?: boolean;
+}
+
+export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
+ return (
+
+ {children}
+
+ );
+});
+
+export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
+ return (
+
+ {children}
+
+ );
+});
+
+export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
+ return (
+
+ {children}
+
+ );
+});
+
+const transition = {
+ duration: 0.15,
+ ease: cubicEasingFn,
+};
+
+export const dialogBackdropVariants = {
+ closed: {
+ opacity: 0,
+ transition,
+ },
+ open: {
+ opacity: 1,
+ transition,
+ },
+} satisfies Variants;
+
+export const dialogVariants = {
+ closed: {
+ x: '-50%',
+ y: '-40%',
+ scale: 0.96,
+ opacity: 0,
+ transition,
+ },
+ open: {
+ x: '-50%',
+ y: '-50%',
+ scale: 1,
+ opacity: 1,
+ transition,
+ },
+} satisfies Variants;
+
+interface DialogProps {
+ children: ReactNode;
+ className?: string;
+ showCloseButton?: boolean;
+ onClose?: () => void;
+ onBackdrop?: () => void;
+}
+
+export const Dialog = memo(({ children, className, showCloseButton = true, onClose, onBackdrop }: DialogProps) => {
+ return (
+
+
+
+
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+
+
+
+ );
+});
diff --git a/app/components/ui/Dropdown.tsx b/app/components/ui/Dropdown.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..89e9776111462081b3359112f5c45958c30fc626
--- /dev/null
+++ b/app/components/ui/Dropdown.tsx
@@ -0,0 +1,63 @@
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import { type ReactNode } from 'react';
+import { classNames } from '~/utils/classNames';
+
+interface DropdownProps {
+ trigger: ReactNode;
+ children: ReactNode;
+ align?: 'start' | 'center' | 'end';
+ sideOffset?: number;
+}
+
+interface DropdownItemProps {
+ children: ReactNode;
+ onSelect?: () => void;
+ className?: string;
+}
+
+export const DropdownItem = ({ children, onSelect, className }: DropdownItemProps) => (
+
+ {children}
+
+);
+
+export const DropdownSeparator = () => ;
+
+export const Dropdown = ({ trigger, children, align = 'end', sideOffset = 5 }: DropdownProps) => {
+ return (
+
+ {trigger}
+
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/app/components/ui/IconButton.tsx b/app/components/ui/IconButton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1e830bb5075d0b20800975a8da94dd6427b0fd59
--- /dev/null
+++ b/app/components/ui/IconButton.tsx
@@ -0,0 +1,84 @@
+import { memo, forwardRef, type ForwardedRef } from 'react';
+import { classNames } from '~/utils/classNames';
+
+type IconSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
+
+interface BaseIconButtonProps {
+ size?: IconSize;
+ className?: string;
+ iconClassName?: string;
+ disabledClassName?: string;
+ title?: string;
+ disabled?: boolean;
+ onClick?: (event: React.MouseEvent) => void;
+}
+
+type IconButtonWithoutChildrenProps = {
+ icon: string;
+ children?: undefined;
+} & BaseIconButtonProps;
+
+type IconButtonWithChildrenProps = {
+ icon?: undefined;
+ children: string | JSX.Element | JSX.Element[];
+} & BaseIconButtonProps;
+
+type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
+
+// Componente IconButton com suporte a refs
+export const IconButton = memo(
+ forwardRef(
+ (
+ {
+ icon,
+ size = 'xl',
+ className,
+ iconClassName,
+ disabledClassName,
+ disabled = false,
+ title,
+ onClick,
+ children,
+ }: IconButtonProps,
+ ref: ForwardedRef,
+ ) => {
+ return (
+ {
+ if (disabled) {
+ return;
+ }
+
+ onClick?.(event);
+ }}
+ >
+ {children ? children :
}
+
+ );
+ },
+ ),
+);
+
+function getIconSize(size: IconSize) {
+ if (size === 'sm') {
+ return 'text-sm';
+ } else if (size === 'md') {
+ return 'text-md';
+ } else if (size === 'lg') {
+ return 'text-lg';
+ } else if (size === 'xl') {
+ return 'text-xl';
+ } else {
+ return 'text-2xl';
+ }
+}
diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..64762502d19f00f36c144ffb0c0189c9f36a0db4
--- /dev/null
+++ b/app/components/ui/Input.tsx
@@ -0,0 +1,22 @@
+import { forwardRef } from 'react';
+import { classNames } from '~/utils/classNames';
+
+export interface InputProps extends React.InputHTMLAttributes {}
+
+const Input = forwardRef(({ className, type, ...props }, ref) => {
+ return (
+
+ );
+});
+
+Input.displayName = 'Input';
+
+export { Input };
diff --git a/app/components/ui/Label.tsx b/app/components/ui/Label.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1fb33fd65a93cc633faac0a578f576ef052e21ef
--- /dev/null
+++ b/app/components/ui/Label.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import * as LabelPrimitive from '@radix-ui/react-label';
+import { classNames } from '~/utils/classNames';
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/app/components/ui/LoadingDots.tsx b/app/components/ui/LoadingDots.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..47d28b7b8ae7140d44a936acf5c736d8f1a8cf30
--- /dev/null
+++ b/app/components/ui/LoadingDots.tsx
@@ -0,0 +1,27 @@
+import { memo, useEffect, useState } from 'react';
+
+interface LoadingDotsProps {
+ text: string;
+}
+
+export const LoadingDots = memo(({ text }: LoadingDotsProps) => {
+ const [dotCount, setDotCount] = useState(0);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setDotCount((prevDotCount) => (prevDotCount + 1) % 4);
+ }, 500);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+
+ {text}
+ {'.'.repeat(dotCount)}
+ ...
+
+
+ );
+});
diff --git a/app/components/ui/LoadingOverlay.tsx b/app/components/ui/LoadingOverlay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2ade83b0ffd3a27a1ae929582a82bd656b357d41
--- /dev/null
+++ b/app/components/ui/LoadingOverlay.tsx
@@ -0,0 +1,32 @@
+export const LoadingOverlay = ({
+ message = 'Loading...',
+ progress,
+ progressText,
+}: {
+ message?: string;
+ progress?: number;
+ progressText?: string;
+}) => {
+ return (
+
+
+
+
{message}
+ {progress !== undefined && (
+
+
+ {progressText &&
{progressText}
}
+
+ )}
+
+
+ );
+};
diff --git a/app/components/ui/PanelHeader.tsx b/app/components/ui/PanelHeader.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..78ab3082b46b13e4b786ec962269a1a31281a28d
--- /dev/null
+++ b/app/components/ui/PanelHeader.tsx
@@ -0,0 +1,20 @@
+import { memo } from 'react';
+import { classNames } from '~/utils/classNames';
+
+interface PanelHeaderProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
+ return (
+
+ {children}
+
+ );
+});
diff --git a/app/components/ui/PanelHeaderButton.tsx b/app/components/ui/PanelHeaderButton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9faea1cc45d388a4d1c917b78e40afb791be60d0
--- /dev/null
+++ b/app/components/ui/PanelHeaderButton.tsx
@@ -0,0 +1,36 @@
+import { memo } from 'react';
+import { classNames } from '~/utils/classNames';
+
+interface PanelHeaderButtonProps {
+ className?: string;
+ disabledClassName?: string;
+ disabled?: boolean;
+ children: string | JSX.Element | Array;
+ onClick?: (event: React.MouseEvent) => void;
+}
+
+export const PanelHeaderButton = memo(
+ ({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => {
+ return (
+ {
+ if (disabled) {
+ return;
+ }
+
+ onClick?.(event);
+ }}
+ >
+ {children}
+
+ );
+ },
+);
diff --git a/app/components/ui/Popover.tsx b/app/components/ui/Popover.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7aab92e832dda79d55c16b9c6f338ae6f7d88dea
--- /dev/null
+++ b/app/components/ui/Popover.tsx
@@ -0,0 +1,29 @@
+import * as Popover from '@radix-ui/react-popover';
+import type { PropsWithChildren, ReactNode } from 'react';
+
+export default ({
+ children,
+ trigger,
+ side,
+ align,
+}: PropsWithChildren<{
+ trigger: ReactNode;
+ side: 'top' | 'right' | 'bottom' | 'left' | undefined;
+ align: 'center' | 'start' | 'end' | undefined;
+}>) => (
+
+ {trigger}
+
+
+
+ {children}
+
+
+
+
+);
diff --git a/app/components/ui/Progress.tsx b/app/components/ui/Progress.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f50b39e6d2b2c4b093de1df5fbbfbeb4051d95da
--- /dev/null
+++ b/app/components/ui/Progress.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+import { classNames } from '~/utils/classNames';
+
+interface ProgressProps extends React.HTMLAttributes {
+ value?: number;
+}
+
+const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
+
+));
+Progress.displayName = 'Progress';
+
+export { Progress };
diff --git a/app/components/ui/ScrollArea.tsx b/app/components/ui/ScrollArea.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..38176a28d096de66390710a0e79fe5d3ce66ef52
--- /dev/null
+++ b/app/components/ui/ScrollArea.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import * as React from 'react';
+import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
+import { classNames } from '~/utils/classNames';
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = 'vertical', ...props }, ref) => (
+
+
+
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+
+export { ScrollArea, ScrollBar };
diff --git a/app/components/ui/Separator.tsx b/app/components/ui/Separator.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8ea43a5ec02e584a9745b75d15ffdbcdd71a4c9f
--- /dev/null
+++ b/app/components/ui/Separator.tsx
@@ -0,0 +1,22 @@
+import * as SeparatorPrimitive from '@radix-ui/react-separator';
+import { classNames } from '~/utils/classNames';
+
+interface SeparatorProps {
+ className?: string;
+ orientation?: 'horizontal' | 'vertical';
+}
+
+export const Separator = ({ className, orientation = 'horizontal' }: SeparatorProps) => {
+ return (
+
+ );
+};
+
+export default Separator;
diff --git a/app/components/ui/SettingsButton.tsx b/app/components/ui/SettingsButton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dc26ddb6511f7188164b6077252ae5e20ba720e8
--- /dev/null
+++ b/app/components/ui/SettingsButton.tsx
@@ -0,0 +1,17 @@
+import { memo } from 'react';
+import { IconButton } from '~/components/ui/IconButton';
+interface SettingsButtonProps {
+ onClick: () => void;
+}
+
+export const SettingsButton = memo(({ onClick }: SettingsButtonProps) => {
+ return (
+
+ );
+});
diff --git a/app/components/ui/Slider.tsx b/app/components/ui/Slider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..70ec95391f40548e144fec979ca0215471f49884
--- /dev/null
+++ b/app/components/ui/Slider.tsx
@@ -0,0 +1,73 @@
+import { motion } from 'framer-motion';
+import { memo } from 'react';
+import { classNames } from '~/utils/classNames';
+import { cubicEasingFn } from '~/utils/easings';
+import { genericMemo } from '~/utils/react';
+
+export type SliderOptions = {
+ left: { value: T; text: string };
+ middle?: { value: T; text: string };
+ right: { value: T; text: string };
+};
+
+interface SliderProps {
+ selected: T;
+ options: SliderOptions;
+ setSelected?: (selected: T) => void;
+}
+
+export const Slider = genericMemo(({ selected, options, setSelected }: SliderProps) => {
+ const hasMiddle = !!options.middle;
+ const isLeftSelected = hasMiddle ? selected === options.left.value : selected === options.left.value;
+ const isMiddleSelected = hasMiddle && options.middle ? selected === options.middle.value : false;
+
+ return (
+
+ setSelected?.(options.left.value)}>
+ {options.left.text}
+
+
+ {options.middle && (
+ setSelected?.(options.middle!.value)}>
+ {options.middle.text}
+
+ )}
+
+ setSelected?.(options.right.value)}
+ >
+ {options.right.text}
+
+
+ );
+});
+
+interface SliderButtonProps {
+ selected: boolean;
+ children: string | JSX.Element | Array;
+ setSelected: () => void;
+}
+
+const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
+ return (
+
+ {children}
+ {selected && (
+
+ )}
+
+ );
+});
diff --git a/app/components/ui/Switch.tsx b/app/components/ui/Switch.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..55d2745b7f2baf4167c3edaa25845c0bf02969f7
--- /dev/null
+++ b/app/components/ui/Switch.tsx
@@ -0,0 +1,37 @@
+import { memo } from 'react';
+import * as SwitchPrimitive from '@radix-ui/react-switch';
+import { classNames } from '~/utils/classNames';
+
+interface SwitchProps {
+ className?: string;
+ checked?: boolean;
+ onCheckedChange?: (event: boolean) => void;
+}
+
+export const Switch = memo(({ className, onCheckedChange, checked }: SwitchProps) => {
+ return (
+ onCheckedChange?.(e)}
+ >
+
+
+ );
+});
diff --git a/app/components/ui/Tabs.tsx b/app/components/ui/Tabs.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..018d8434d1807bd90805ceb7d09db2ac8196c7d4
--- /dev/null
+++ b/app/components/ui/Tabs.tsx
@@ -0,0 +1,52 @@
+import * as React from 'react';
+import * as TabsPrimitive from '@radix-ui/react-tabs';
+import { classNames } from '~/utils/classNames';
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/app/components/ui/ThemeSwitch.tsx b/app/components/ui/ThemeSwitch.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a46da2b2e012464f6c892b01f790c21c5b7bb92a
--- /dev/null
+++ b/app/components/ui/ThemeSwitch.tsx
@@ -0,0 +1,29 @@
+import { useStore } from '@nanostores/react';
+import { memo, useEffect, useState } from 'react';
+import { themeStore, toggleTheme } from '~/lib/stores/theme';
+import { IconButton } from './IconButton';
+
+interface ThemeSwitchProps {
+ className?: string;
+}
+
+export const ThemeSwitch = memo(({ className }: ThemeSwitchProps) => {
+ const theme = useStore(themeStore);
+ const [domLoaded, setDomLoaded] = useState(false);
+
+ useEffect(() => {
+ setDomLoaded(true);
+ }, []);
+
+ return (
+ domLoaded && (
+
+ )
+ );
+});
diff --git a/app/components/ui/Tooltip.tsx b/app/components/ui/Tooltip.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..278fa1ea5b744bddf65dd221b2784134282080b2
--- /dev/null
+++ b/app/components/ui/Tooltip.tsx
@@ -0,0 +1,79 @@
+import * as Tooltip from '@radix-ui/react-tooltip';
+import { forwardRef, type ForwardedRef, type ReactElement } from 'react';
+
+interface TooltipProps {
+ tooltip: React.ReactNode;
+ children: ReactElement;
+ sideOffset?: number;
+ className?: string;
+ arrowClassName?: string;
+ tooltipStyle?: React.CSSProperties;
+ position?: 'top' | 'bottom' | 'left' | 'right';
+ maxWidth?: number;
+ delay?: number;
+}
+
+const WithTooltip = forwardRef(
+ (
+ {
+ tooltip,
+ children,
+ sideOffset = 5,
+ className = '',
+ arrowClassName = '',
+ tooltipStyle = {},
+ position = 'top',
+ maxWidth = 250,
+ delay = 0,
+ }: TooltipProps,
+ _ref: ForwardedRef,
+ ) => {
+ return (
+
+ {children}
+
+
+ {tooltip}
+
+
+
+
+ );
+ },
+);
+
+export default WithTooltip;
diff --git a/app/components/ui/use-toast.ts b/app/components/ui/use-toast.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cadbfef3e63991b6693757c2043786ec33c52d93
--- /dev/null
+++ b/app/components/ui/use-toast.ts
@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+import { toast as toastify } from 'react-toastify';
+
+interface ToastOptions {
+ type?: 'success' | 'error' | 'info' | 'warning';
+ duration?: number;
+}
+
+export function useToast() {
+ const toast = useCallback((message: string, options: ToastOptions = {}) => {
+ const { type = 'info', duration = 3000 } = options;
+
+ toastify[type](message, {
+ position: 'bottom-right',
+ autoClose: duration,
+ hideProgressBar: false,
+ closeOnClick: true,
+ pauseOnHover: true,
+ draggable: true,
+ progress: undefined,
+ theme: 'dark',
+ });
+ }, []);
+
+ const success = useCallback(
+ (message: string, options: Omit = {}) => {
+ toast(message, { ...options, type: 'success' });
+ },
+ [toast],
+ );
+
+ const error = useCallback(
+ (message: string, options: Omit = {}) => {
+ toast(message, { ...options, type: 'error' });
+ },
+ [toast],
+ );
+
+ return { toast, success, error };
+}
diff --git a/app/components/workbench/DiffView.tsx b/app/components/workbench/DiffView.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..aa635ba6cd76b42f0b2485c15b553b480afef255
--- /dev/null
+++ b/app/components/workbench/DiffView.tsx
@@ -0,0 +1,737 @@
+import { memo, useMemo, useState, useEffect, useCallback } from 'react';
+import { useStore } from '@nanostores/react';
+import { workbenchStore } from '~/lib/stores/workbench';
+import type { FileMap } from '~/lib/stores/files';
+import type { EditorDocument } from '~/components/editor/codemirror/CodeMirrorEditor';
+import { diffLines, type Change } from 'diff';
+import { getHighlighter } from 'shiki';
+import '~/styles/diff-view.css';
+import { diffFiles, extractRelativePath } from '~/utils/diff';
+import { ActionRunner } from '~/lib/runtime/action-runner';
+import type { FileHistory } from '~/types/actions';
+import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
+import { themeStore } from '~/lib/stores/theme';
+
+interface CodeComparisonProps {
+ beforeCode: string;
+ afterCode: string;
+ language: string;
+ filename: string;
+ lightTheme: string;
+ darkTheme: string;
+}
+
+interface DiffBlock {
+ lineNumber: number;
+ content: string;
+ type: 'added' | 'removed' | 'unchanged';
+ correspondingLine?: number;
+ charChanges?: Array<{
+ value: string;
+ type: 'added' | 'removed' | 'unchanged';
+ }>;
+}
+
+interface FullscreenButtonProps {
+ onClick: () => void;
+ isFullscreen: boolean;
+}
+
+const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => (
+
+
+
+));
+
+const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => {
+ if (!isFullscreen) {
+ return <>{children}>;
+ }
+
+ return (
+
+ );
+});
+
+const MAX_FILE_SIZE = 1024 * 1024; // 1MB
+const BINARY_REGEX = /[\x00-\x08\x0E-\x1F]/;
+
+const isBinaryFile = (content: string) => {
+ return content.length > MAX_FILE_SIZE || BINARY_REGEX.test(content);
+};
+
+const processChanges = (beforeCode: string, afterCode: string) => {
+ try {
+ if (isBinaryFile(beforeCode) || isBinaryFile(afterCode)) {
+ return {
+ beforeLines: [],
+ afterLines: [],
+ hasChanges: false,
+ lineChanges: { before: new Set(), after: new Set() },
+ unifiedBlocks: [],
+ isBinary: true,
+ };
+ }
+
+ // Normalize line endings and content
+ const normalizeContent = (content: string): string[] => {
+ return content
+ .replace(/\r\n/g, '\n')
+ .split('\n')
+ .map((line) => line.trimEnd());
+ };
+
+ const beforeLines = normalizeContent(beforeCode);
+ const afterLines = normalizeContent(afterCode);
+
+ // Early return if files are identical
+ if (beforeLines.join('\n') === afterLines.join('\n')) {
+ return {
+ beforeLines,
+ afterLines,
+ hasChanges: false,
+ lineChanges: { before: new Set(), after: new Set() },
+ unifiedBlocks: [],
+ isBinary: false,
+ };
+ }
+
+ const lineChanges = {
+ before: new Set(),
+ after: new Set(),
+ };
+
+ const unifiedBlocks: DiffBlock[] = [];
+
+ // Compare lines directly for more accurate diff
+ let i = 0,
+ j = 0;
+
+ while (i < beforeLines.length || j < afterLines.length) {
+ if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
+ // Unchanged line
+ unifiedBlocks.push({
+ lineNumber: j,
+ content: afterLines[j],
+ type: 'unchanged',
+ correspondingLine: i,
+ });
+ i++;
+ j++;
+ } else {
+ // Look ahead for potential matches
+ let matchFound = false;
+ const lookAhead = 3; // Number of lines to look ahead
+
+ // Try to find matching lines ahead
+ for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) {
+ if (beforeLines[i + k] === afterLines[j]) {
+ // Found match in after lines - mark lines as removed
+ for (let l = 0; l < k; l++) {
+ lineChanges.before.add(i + l);
+ unifiedBlocks.push({
+ lineNumber: i + l,
+ content: beforeLines[i + l],
+ type: 'removed',
+ correspondingLine: j,
+ charChanges: [{ value: beforeLines[i + l], type: 'removed' }],
+ });
+ }
+ i += k;
+ matchFound = true;
+ break;
+ } else if (beforeLines[i] === afterLines[j + k]) {
+ // Found match in before lines - mark lines as added
+ for (let l = 0; l < k; l++) {
+ lineChanges.after.add(j + l);
+ unifiedBlocks.push({
+ lineNumber: j + l,
+ content: afterLines[j + l],
+ type: 'added',
+ correspondingLine: i,
+ charChanges: [{ value: afterLines[j + l], type: 'added' }],
+ });
+ }
+ j += k;
+ matchFound = true;
+ break;
+ }
+ }
+
+ if (!matchFound) {
+ // No match found - try to find character-level changes
+ if (i < beforeLines.length && j < afterLines.length) {
+ const beforeLine = beforeLines[i];
+ const afterLine = afterLines[j];
+
+ // Find common prefix and suffix
+ let prefixLength = 0;
+
+ while (
+ prefixLength < beforeLine.length &&
+ prefixLength < afterLine.length &&
+ beforeLine[prefixLength] === afterLine[prefixLength]
+ ) {
+ prefixLength++;
+ }
+
+ let suffixLength = 0;
+
+ while (
+ suffixLength < beforeLine.length - prefixLength &&
+ suffixLength < afterLine.length - prefixLength &&
+ beforeLine[beforeLine.length - 1 - suffixLength] === afterLine[afterLine.length - 1 - suffixLength]
+ ) {
+ suffixLength++;
+ }
+
+ const prefix = beforeLine.slice(0, prefixLength);
+ const beforeMiddle = beforeLine.slice(prefixLength, beforeLine.length - suffixLength);
+ const afterMiddle = afterLine.slice(prefixLength, afterLine.length - suffixLength);
+ const suffix = beforeLine.slice(beforeLine.length - suffixLength);
+
+ if (beforeMiddle || afterMiddle) {
+ // There are character-level changes
+ if (beforeMiddle) {
+ lineChanges.before.add(i);
+ unifiedBlocks.push({
+ lineNumber: i,
+ content: beforeLine,
+ type: 'removed',
+ correspondingLine: j,
+ charChanges: [
+ { value: prefix, type: 'unchanged' },
+ { value: beforeMiddle, type: 'removed' },
+ { value: suffix, type: 'unchanged' },
+ ],
+ });
+ i++;
+ }
+
+ if (afterMiddle) {
+ lineChanges.after.add(j);
+ unifiedBlocks.push({
+ lineNumber: j,
+ content: afterLine,
+ type: 'added',
+ correspondingLine: i - 1,
+ charChanges: [
+ { value: prefix, type: 'unchanged' },
+ { value: afterMiddle, type: 'added' },
+ { value: suffix, type: 'unchanged' },
+ ],
+ });
+ j++;
+ }
+ } else {
+ // No character-level changes found, treat as regular line changes
+ if (i < beforeLines.length) {
+ lineChanges.before.add(i);
+ unifiedBlocks.push({
+ lineNumber: i,
+ content: beforeLines[i],
+ type: 'removed',
+ correspondingLine: j,
+ charChanges: [{ value: beforeLines[i], type: 'removed' }],
+ });
+ i++;
+ }
+
+ if (j < afterLines.length) {
+ lineChanges.after.add(j);
+ unifiedBlocks.push({
+ lineNumber: j,
+ content: afterLines[j],
+ type: 'added',
+ correspondingLine: i - 1,
+ charChanges: [{ value: afterLines[j], type: 'added' }],
+ });
+ j++;
+ }
+ }
+ } else {
+ // Handle remaining lines
+ if (i < beforeLines.length) {
+ lineChanges.before.add(i);
+ unifiedBlocks.push({
+ lineNumber: i,
+ content: beforeLines[i],
+ type: 'removed',
+ correspondingLine: j,
+ charChanges: [{ value: beforeLines[i], type: 'removed' }],
+ });
+ i++;
+ }
+
+ if (j < afterLines.length) {
+ lineChanges.after.add(j);
+ unifiedBlocks.push({
+ lineNumber: j,
+ content: afterLines[j],
+ type: 'added',
+ correspondingLine: i - 1,
+ charChanges: [{ value: afterLines[j], type: 'added' }],
+ });
+ j++;
+ }
+ }
+ }
+ }
+ }
+
+ // Sort blocks by line number
+ const processedBlocks = unifiedBlocks.sort((a, b) => a.lineNumber - b.lineNumber);
+
+ return {
+ beforeLines,
+ afterLines,
+ hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
+ lineChanges,
+ unifiedBlocks: processedBlocks,
+ isBinary: false,
+ };
+ } catch (error) {
+ console.error('Error processing changes:', error);
+ return {
+ beforeLines: [],
+ afterLines: [],
+ hasChanges: false,
+ lineChanges: { before: new Set(), after: new Set() },
+ unifiedBlocks: [],
+ error: true,
+ isBinary: false,
+ };
+ }
+};
+
+const lineNumberStyles =
+ 'w-9 shrink-0 pl-2 py-1 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1';
+const lineContentStyles =
+ 'px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary';
+const diffPanelStyles = 'h-full overflow-auto diff-panel-content';
+
+// Updated color styles for better consistency
+const diffLineStyles = {
+ added: 'bg-green-500/10 dark:bg-green-500/20 border-l-4 border-green-500',
+ removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500',
+ unchanged: '',
+};
+
+const changeColorStyles = {
+ added: 'text-green-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20',
+ removed: 'text-red-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20',
+ unchanged: 'text-bolt-elements-textPrimary',
+};
+
+const renderContentWarning = (type: 'binary' | 'error') => (
+
+
+
+
+ {type === 'binary' ? 'Binary file detected' : 'Error processing file'}
+
+
+ {type === 'binary' ? 'Diff view is not available for binary files' : 'Could not generate diff preview'}
+
+
+
+);
+
+const NoChangesView = memo(
+ ({
+ beforeCode,
+ language,
+ highlighter,
+ theme,
+ }: {
+ beforeCode: string;
+ language: string;
+ highlighter: any;
+ theme: string;
+ }) => (
+
+
+
+
Files are identical
+
Both versions match exactly
+
+
+
+ Current Content
+
+
+ {beforeCode.split('\n').map((line, index) => (
+
+
{index + 1}
+
+
+ ]*>/g, '')
+ .replace(/<\/?code[^>]*>/g, '')
+ : line,
+ }}
+ />
+
+
+ ))}
+
+
+
+ ),
+);
+
+// Otimização do processamento de diferenças com memoização
+const useProcessChanges = (beforeCode: string, afterCode: string) => {
+ return useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]);
+};
+
+// Componente otimizado para renderização de linhas de código
+const CodeLine = memo(
+ ({
+ lineNumber,
+ content,
+ type,
+ highlighter,
+ language,
+ block,
+ theme,
+ }: {
+ lineNumber: number;
+ content: string;
+ type: 'added' | 'removed' | 'unchanged';
+ highlighter: any;
+ language: string;
+ block: DiffBlock;
+ theme: string;
+ }) => {
+ const bgColor = diffLineStyles[type];
+
+ const renderContent = () => {
+ if (type === 'unchanged' || !block.charChanges) {
+ const highlightedCode = highlighter
+ ? highlighter
+ .codeToHtml(content, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
+ .replace(/<\/?pre[^>]*>/g, '')
+ .replace(/<\/?code[^>]*>/g, '')
+ : content;
+ return ;
+ }
+
+ return (
+ <>
+ {block.charChanges.map((change, index) => {
+ const changeClass = changeColorStyles[change.type];
+
+ const highlightedCode = highlighter
+ ? highlighter
+ .codeToHtml(change.value, {
+ lang: language,
+ theme: theme === 'dark' ? 'github-dark' : 'github-light',
+ })
+ .replace(/<\/?pre[^>]*>/g, '')
+ .replace(/<\/?code[^>]*>/g, '')
+ : change.value;
+
+ return ;
+ })}
+ >
+ );
+ };
+
+ return (
+
+
{lineNumber + 1}
+
+
+ {type === 'added' && + }
+ {type === 'removed' && - }
+ {type === 'unchanged' && ' '}
+
+ {renderContent()}
+
+
+ );
+ },
+);
+
+// Componente para exibir informações sobre o arquivo
+const FileInfo = memo(
+ ({
+ filename,
+ hasChanges,
+ onToggleFullscreen,
+ isFullscreen,
+ beforeCode,
+ afterCode,
+ }: {
+ filename: string;
+ hasChanges: boolean;
+ onToggleFullscreen: () => void;
+ isFullscreen: boolean;
+ beforeCode: string;
+ afterCode: string;
+ }) => {
+ // Calculate additions and deletions from the current document
+ const { additions, deletions } = useMemo(() => {
+ if (!hasChanges) {
+ return { additions: 0, deletions: 0 };
+ }
+
+ const changes = diffLines(beforeCode, afterCode, {
+ newlineIsToken: false,
+ ignoreWhitespace: true,
+ ignoreCase: false,
+ });
+
+ return changes.reduce(
+ (acc: { additions: number; deletions: number }, change: Change) => {
+ if (change.added) {
+ acc.additions += change.value.split('\n').length;
+ }
+
+ if (change.removed) {
+ acc.deletions += change.value.split('\n').length;
+ }
+
+ return acc;
+ },
+ { additions: 0, deletions: 0 },
+ );
+ }, [hasChanges, beforeCode, afterCode]);
+
+ const showStats = additions > 0 || deletions > 0;
+
+ return (
+
+
+
{filename}
+
+ {hasChanges ? (
+ <>
+ {showStats && (
+
+ {additions > 0 && +{additions} }
+ {deletions > 0 && -{deletions} }
+
+ )}
+ Modified
+ {new Date().toLocaleTimeString()}
+ >
+ ) : (
+ No Changes
+ )}
+
+
+
+ );
+ },
+);
+
+const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language }: CodeComparisonProps) => {
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ const [highlighter, setHighlighter] = useState(null);
+ const theme = useStore(themeStore);
+
+ const toggleFullscreen = useCallback(() => {
+ setIsFullscreen((prev) => !prev);
+ }, []);
+
+ const { unifiedBlocks, hasChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
+
+ useEffect(() => {
+ getHighlighter({
+ themes: ['github-dark', 'github-light'],
+ langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx'],
+ }).then(setHighlighter);
+ }, []);
+
+ if (isBinary || error) {
+ return renderContentWarning(isBinary ? 'binary' : 'error');
+ }
+
+ return (
+
+
+
+
+ {hasChanges ? (
+
+ {unifiedBlocks.map((block, index) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+ );
+});
+
+interface DiffViewProps {
+ fileHistory: Record;
+ setFileHistory: React.Dispatch>>;
+ actionRunner: ActionRunner;
+}
+
+export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) => {
+ const files = useStore(workbenchStore.files) as FileMap;
+ const selectedFile = useStore(workbenchStore.selectedFile);
+ const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
+ const unsavedFiles = useStore(workbenchStore.unsavedFiles);
+
+ useEffect(() => {
+ if (selectedFile && currentDocument) {
+ const file = files[selectedFile];
+
+ if (!file || !('content' in file)) {
+ return;
+ }
+
+ const existingHistory = fileHistory[selectedFile];
+ const currentContent = currentDocument.value;
+
+ // Normalizar o conteúdo para comparação
+ const normalizedCurrentContent = currentContent.replace(/\r\n/g, '\n').trim();
+ const normalizedOriginalContent = (existingHistory?.originalContent || file.content)
+ .replace(/\r\n/g, '\n')
+ .trim();
+
+ // Se não há histórico existente, criar um novo apenas se houver diferenças
+ if (!existingHistory) {
+ if (normalizedCurrentContent !== normalizedOriginalContent) {
+ const newChanges = diffLines(file.content, currentContent);
+ setFileHistory((prev) => ({
+ ...prev,
+ [selectedFile]: {
+ originalContent: file.content,
+ lastModified: Date.now(),
+ changes: newChanges,
+ versions: [
+ {
+ timestamp: Date.now(),
+ content: currentContent,
+ },
+ ],
+ changeSource: 'auto-save',
+ },
+ }));
+ }
+
+ return;
+ }
+
+ // Se já existe histórico, verificar se há mudanças reais desde a última versão
+ const lastVersion = existingHistory.versions[existingHistory.versions.length - 1];
+ const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
+
+ if (normalizedCurrentContent === normalizedLastContent) {
+ return; // Não criar novo histórico se o conteúdo é o mesmo
+ }
+
+ // Verificar se há mudanças significativas usando diffFiles
+ const relativePath = extractRelativePath(selectedFile);
+ const unifiedDiff = diffFiles(relativePath, existingHistory.originalContent, currentContent);
+
+ if (unifiedDiff) {
+ const newChanges = diffLines(existingHistory.originalContent, currentContent);
+
+ // Verificar se as mudanças são significativas
+ const hasSignificantChanges = newChanges.some(
+ (change) => (change.added || change.removed) && change.value.trim().length > 0,
+ );
+
+ if (hasSignificantChanges) {
+ const newHistory: FileHistory = {
+ originalContent: existingHistory.originalContent,
+ lastModified: Date.now(),
+ changes: [...existingHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças
+ versions: [
+ ...existingHistory.versions,
+ {
+ timestamp: Date.now(),
+ content: currentContent,
+ },
+ ].slice(-10), // Manter apenas as 10 últimas versões
+ changeSource: 'auto-save',
+ };
+
+ setFileHistory((prev) => ({ ...prev, [selectedFile]: newHistory }));
+ }
+ }
+ }
+ }, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
+
+ if (!selectedFile || !currentDocument) {
+ return (
+
+ Select a file to view differences
+
+ );
+ }
+
+ const file = files[selectedFile];
+ const originalContent = file && 'content' in file ? file.content : '';
+ const currentContent = currentDocument.value;
+
+ const history = fileHistory[selectedFile];
+ const effectiveOriginalContent = history?.originalContent || originalContent;
+ const language = getLanguageFromExtension(selectedFile.split('.').pop() || '');
+
+ try {
+ return (
+
+
+
+ );
+ } catch (error) {
+ console.error('DiffView render error:', error);
+ return (
+
+
+
+
Failed to render diff view
+
+
+ );
+ }
+});
diff --git a/app/components/workbench/EditorPanel.tsx b/app/components/workbench/EditorPanel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ef9e018b59af4ff2316fee736d490764f4137aad
--- /dev/null
+++ b/app/components/workbench/EditorPanel.tsx
@@ -0,0 +1,137 @@
+import { useStore } from '@nanostores/react';
+import { memo, useMemo } from 'react';
+import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
+import {
+ CodeMirrorEditor,
+ type EditorDocument,
+ type EditorSettings,
+ type OnChangeCallback as OnEditorChange,
+ type OnSaveCallback as OnEditorSave,
+ type OnScrollCallback as OnEditorScroll,
+} from '~/components/editor/codemirror/CodeMirrorEditor';
+import { PanelHeader } from '~/components/ui/PanelHeader';
+import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
+import type { FileMap } from '~/lib/stores/files';
+import type { FileHistory } from '~/types/actions';
+import { themeStore } from '~/lib/stores/theme';
+import { WORK_DIR } from '~/utils/constants';
+import { renderLogger } from '~/utils/logger';
+import { isMobile } from '~/utils/mobile';
+import { FileBreadcrumb } from './FileBreadcrumb';
+import { FileTree } from './FileTree';
+import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
+import { workbenchStore } from '~/lib/stores/workbench';
+
+interface EditorPanelProps {
+ files?: FileMap;
+ unsavedFiles?: Set;
+ editorDocument?: EditorDocument;
+ selectedFile?: string | undefined;
+ isStreaming?: boolean;
+ fileHistory?: Record;
+ onEditorChange?: OnEditorChange;
+ onEditorScroll?: OnEditorScroll;
+ onFileSelect?: (value?: string) => void;
+ onFileSave?: OnEditorSave;
+ onFileReset?: () => void;
+}
+
+const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
+
+const editorSettings: EditorSettings = { tabSize: 2 };
+
+export const EditorPanel = memo(
+ ({
+ files,
+ unsavedFiles,
+ editorDocument,
+ selectedFile,
+ isStreaming,
+ fileHistory,
+ onFileSelect,
+ onEditorChange,
+ onEditorScroll,
+ onFileSave,
+ onFileReset,
+ }: EditorPanelProps) => {
+ renderLogger.trace('EditorPanel');
+
+ const theme = useStore(themeStore);
+ const showTerminal = useStore(workbenchStore.showTerminal);
+
+ const activeFileSegments = useMemo(() => {
+ if (!editorDocument) {
+ return undefined;
+ }
+
+ return editorDocument.filePath.split('/');
+ }, [editorDocument]);
+
+ const activeFileUnsaved = useMemo(() => {
+ return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
+ }, [editorDocument, unsavedFiles]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {activeFileSegments?.length && (
+
+
+ {activeFileUnsaved && (
+
+
+
+ Save
+
+
+
+ Reset
+
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+);
diff --git a/app/components/workbench/FileBreadcrumb.tsx b/app/components/workbench/FileBreadcrumb.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..46a39485906d4cade7e1eb1399809b8281df8439
--- /dev/null
+++ b/app/components/workbench/FileBreadcrumb.tsx
@@ -0,0 +1,150 @@
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import { AnimatePresence, motion, type Variants } from 'framer-motion';
+import { memo, useEffect, useRef, useState } from 'react';
+import type { FileMap } from '~/lib/stores/files';
+import { classNames } from '~/utils/classNames';
+import { WORK_DIR } from '~/utils/constants';
+import { cubicEasingFn } from '~/utils/easings';
+import { renderLogger } from '~/utils/logger';
+import FileTree from './FileTree';
+
+const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
+
+interface FileBreadcrumbProps {
+ files?: FileMap;
+ pathSegments?: string[];
+ onFileSelect?: (filePath: string) => void;
+}
+
+const contextMenuVariants = {
+ open: {
+ y: 0,
+ opacity: 1,
+ transition: {
+ duration: 0.15,
+ ease: cubicEasingFn,
+ },
+ },
+ close: {
+ y: 6,
+ opacity: 0,
+ transition: {
+ duration: 0.15,
+ ease: cubicEasingFn,
+ },
+ },
+} satisfies Variants;
+
+export const FileBreadcrumb = memo(({ files, pathSegments = [], onFileSelect }) => {
+ renderLogger.trace('FileBreadcrumb');
+
+ const [activeIndex, setActiveIndex] = useState(null);
+
+ const contextMenuRef = useRef(null);
+ const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
+
+ const handleSegmentClick = (index: number) => {
+ setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
+ };
+
+ useEffect(() => {
+ const handleOutsideClick = (event: MouseEvent) => {
+ if (
+ activeIndex !== null &&
+ !contextMenuRef.current?.contains(event.target as Node) &&
+ !segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
+ ) {
+ setActiveIndex(null);
+ }
+ };
+
+ document.addEventListener('mousedown', handleOutsideClick);
+
+ return () => {
+ document.removeEventListener('mousedown', handleOutsideClick);
+ };
+ }, [activeIndex]);
+
+ if (files === undefined || pathSegments.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {pathSegments.map((segment, index) => {
+ const isLast = index === pathSegments.length - 1;
+
+ const path = pathSegments.slice(0, index).join('/');
+
+ if (!WORK_DIR_REGEX.test(path)) {
+ return null;
+ }
+
+ const isActive = activeIndex === index;
+
+ return (
+
+
+
+ {
+ segmentRefs.current[index] = ref;
+ }}
+ className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
+ 'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
+ 'text-bolt-elements-textPrimary underline': isActive,
+ 'pr-4': isLast,
+ })}
+ onClick={() => handleSegmentClick(index)}
+ >
+ {isLast &&
}
+ {segment}
+
+
+ {index > 0 && !isLast && }
+
+ {isActive && (
+
+
+
+
+
+ {
+ setActiveIndex(null);
+ onFileSelect?.(filePath);
+ }}
+ />
+
+
+
+
+
+
+ )}
+
+
+
+ );
+ })}
+
+ );
+});
diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..eed791eff6811021ad83ea7b63b9fdc438cb9e71
--- /dev/null
+++ b/app/components/workbench/FileTree.tsx
@@ -0,0 +1,549 @@
+import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
+import type { FileMap } from '~/lib/stores/files';
+import { classNames } from '~/utils/classNames';
+import { createScopedLogger, renderLogger } from '~/utils/logger';
+import * as ContextMenu from '@radix-ui/react-context-menu';
+import type { FileHistory } from '~/types/actions';
+import { diffLines, type Change } from 'diff';
+
+const logger = createScopedLogger('FileTree');
+
+const NODE_PADDING_LEFT = 8;
+const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
+
+interface Props {
+ files?: FileMap;
+ selectedFile?: string;
+ onFileSelect?: (filePath: string) => void;
+ rootFolder?: string;
+ hideRoot?: boolean;
+ collapsed?: boolean;
+ allowFolderSelection?: boolean;
+ hiddenFiles?: Array;
+ unsavedFiles?: Set;
+ fileHistory?: Record;
+ className?: string;
+}
+
+export const FileTree = memo(
+ ({
+ files = {},
+ onFileSelect,
+ selectedFile,
+ rootFolder,
+ hideRoot = false,
+ collapsed = false,
+ allowFolderSelection = false,
+ hiddenFiles,
+ className,
+ unsavedFiles,
+ fileHistory = {},
+ }: Props) => {
+ renderLogger.trace('FileTree');
+
+ const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
+
+ const fileList = useMemo(() => {
+ return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
+ }, [files, rootFolder, hideRoot, computedHiddenFiles]);
+
+ const [collapsedFolders, setCollapsedFolders] = useState(() => {
+ return collapsed
+ ? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
+ : new Set();
+ });
+
+ useEffect(() => {
+ if (collapsed) {
+ setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
+ return;
+ }
+
+ setCollapsedFolders((prevCollapsed) => {
+ const newCollapsed = new Set();
+
+ for (const folder of fileList) {
+ if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
+ newCollapsed.add(folder.fullPath);
+ }
+ }
+
+ return newCollapsed;
+ });
+ }, [fileList, collapsed]);
+
+ const filteredFileList = useMemo(() => {
+ const list = [];
+
+ let lastDepth = Number.MAX_SAFE_INTEGER;
+
+ for (const fileOrFolder of fileList) {
+ const depth = fileOrFolder.depth;
+
+ // if the depth is equal we reached the end of the collaped group
+ if (lastDepth === depth) {
+ lastDepth = Number.MAX_SAFE_INTEGER;
+ }
+
+ // ignore collapsed folders
+ if (collapsedFolders.has(fileOrFolder.fullPath)) {
+ lastDepth = Math.min(lastDepth, depth);
+ }
+
+ // ignore files and folders below the last collapsed folder
+ if (lastDepth < depth) {
+ continue;
+ }
+
+ list.push(fileOrFolder);
+ }
+
+ return list;
+ }, [fileList, collapsedFolders]);
+
+ const toggleCollapseState = (fullPath: string) => {
+ setCollapsedFolders((prevSet) => {
+ const newSet = new Set(prevSet);
+
+ if (newSet.has(fullPath)) {
+ newSet.delete(fullPath);
+ } else {
+ newSet.add(fullPath);
+ }
+
+ return newSet;
+ });
+ };
+
+ const onCopyPath = (fileOrFolder: FileNode | FolderNode) => {
+ try {
+ navigator.clipboard.writeText(fileOrFolder.fullPath);
+ } catch (error) {
+ logger.error(error);
+ }
+ };
+
+ const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => {
+ try {
+ navigator.clipboard.writeText(fileOrFolder.fullPath.substring((rootFolder || '').length));
+ } catch (error) {
+ logger.error(error);
+ }
+ };
+
+ return (
+
+ {filteredFileList.map((fileOrFolder) => {
+ switch (fileOrFolder.kind) {
+ case 'file': {
+ return (
+ {
+ onCopyPath(fileOrFolder);
+ }}
+ onCopyRelativePath={() => {
+ onCopyRelativePath(fileOrFolder);
+ }}
+ onClick={() => {
+ onFileSelect?.(fileOrFolder.fullPath);
+ }}
+ />
+ );
+ }
+ case 'folder': {
+ return (
+ {
+ onCopyPath(fileOrFolder);
+ }}
+ onCopyRelativePath={() => {
+ onCopyRelativePath(fileOrFolder);
+ }}
+ onClick={() => {
+ toggleCollapseState(fileOrFolder.fullPath);
+ }}
+ />
+ );
+ }
+ default: {
+ return undefined;
+ }
+ }
+ })}
+
+ );
+ },
+);
+
+export default FileTree;
+
+interface FolderProps {
+ folder: FolderNode;
+ collapsed: boolean;
+ selected?: boolean;
+ onCopyPath: () => void;
+ onCopyRelativePath: () => void;
+ onClick: () => void;
+}
+
+interface FolderContextMenuProps {
+ onCopyPath?: () => void;
+ onCopyRelativePath?: () => void;
+ children: ReactNode;
+}
+
+function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) {
+ return (
+
+ {children}
+
+
+
+ Copy path
+ Copy relative path
+
+
+
+
+ );
+}
+
+function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
+ return (
+
+
+ {folder.name}
+
+
+ );
+}
+
+interface FileProps {
+ file: FileNode;
+ selected: boolean;
+ unsavedChanges?: boolean;
+ fileHistory?: Record;
+ onCopyPath: () => void;
+ onCopyRelativePath: () => void;
+ onClick: () => void;
+}
+
+function File({
+ file: { depth, name, fullPath },
+ onClick,
+ onCopyPath,
+ onCopyRelativePath,
+ selected,
+ unsavedChanges = false,
+ fileHistory = {},
+}: FileProps) {
+ const fileModifications = fileHistory[fullPath];
+
+ // const hasModifications = fileModifications !== undefined;
+
+ // Calculate added and removed lines from the most recent changes
+ const { additions, deletions } = useMemo(() => {
+ if (!fileModifications?.originalContent) {
+ return { additions: 0, deletions: 0 };
+ }
+
+ // Usar a mesma lógica do DiffView para processar as mudanças
+ const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n');
+ const normalizedCurrent =
+ fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
+
+ if (normalizedOriginal === normalizedCurrent) {
+ return { additions: 0, deletions: 0 };
+ }
+
+ const changes = diffLines(normalizedOriginal, normalizedCurrent, {
+ newlineIsToken: false,
+ ignoreWhitespace: true,
+ ignoreCase: false,
+ });
+
+ return changes.reduce(
+ (acc: { additions: number; deletions: number }, change: Change) => {
+ if (change.added) {
+ acc.additions += change.value.split('\n').length;
+ }
+
+ if (change.removed) {
+ acc.deletions += change.value.split('\n').length;
+ }
+
+ return acc;
+ },
+ { additions: 0, deletions: 0 },
+ );
+ }, [fileModifications]);
+
+ const showStats = additions > 0 || deletions > 0;
+
+ return (
+
+
+
+
{name}
+
+ {showStats && (
+
+ {additions > 0 && +{additions} }
+ {deletions > 0 && -{deletions} }
+
+ )}
+ {unsavedChanges &&
}
+
+
+
+
+ );
+}
+
+interface ButtonProps {
+ depth: number;
+ iconClasses: string;
+ children: ReactNode;
+ className?: string;
+ onClick?: () => void;
+}
+
+function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
+ return (
+ onClick?.()}
+ >
+
+ {children}
+
+ );
+}
+
+type Node = FileNode | FolderNode;
+
+interface BaseNode {
+ id: number;
+ depth: number;
+ name: string;
+ fullPath: string;
+}
+
+interface FileNode extends BaseNode {
+ kind: 'file';
+}
+
+interface FolderNode extends BaseNode {
+ kind: 'folder';
+}
+
+function buildFileList(
+ files: FileMap,
+ rootFolder = '/',
+ hideRoot: boolean,
+ hiddenFiles: Array,
+): Node[] {
+ const folderPaths = new Set();
+ const fileList: Node[] = [];
+
+ let defaultDepth = 0;
+
+ if (rootFolder === '/' && !hideRoot) {
+ defaultDepth = 1;
+ fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
+ }
+
+ for (const [filePath, dirent] of Object.entries(files)) {
+ const segments = filePath.split('/').filter((segment) => segment);
+ const fileName = segments.at(-1);
+
+ if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
+ continue;
+ }
+
+ let currentPath = '';
+
+ let i = 0;
+ let depth = 0;
+
+ while (i < segments.length) {
+ const name = segments[i];
+ const fullPath = (currentPath += `/${name}`);
+
+ if (!fullPath.startsWith(rootFolder) || (hideRoot && fullPath === rootFolder)) {
+ i++;
+ continue;
+ }
+
+ if (i === segments.length - 1 && dirent?.type === 'file') {
+ fileList.push({
+ kind: 'file',
+ id: fileList.length,
+ name,
+ fullPath,
+ depth: depth + defaultDepth,
+ });
+ } else if (!folderPaths.has(fullPath)) {
+ folderPaths.add(fullPath);
+
+ fileList.push({
+ kind: 'folder',
+ id: fileList.length,
+ name,
+ fullPath,
+ depth: depth + defaultDepth,
+ });
+ }
+
+ i++;
+ depth++;
+ }
+ }
+
+ return sortFileList(rootFolder, fileList, hideRoot);
+}
+
+function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array) {
+ return hiddenFiles.some((pathOrRegex) => {
+ if (typeof pathOrRegex === 'string') {
+ return fileName === pathOrRegex;
+ }
+
+ return pathOrRegex.test(filePath);
+ });
+}
+
+/**
+ * Sorts the given list of nodes into a tree structure (still a flat list).
+ *
+ * This function organizes the nodes into a hierarchical structure based on their paths,
+ * with folders appearing before files and all items sorted alphabetically within their level.
+ *
+ * @note This function mutates the given `nodeList` array for performance reasons.
+ *
+ * @param rootFolder - The path of the root folder to start the sorting from.
+ * @param nodeList - The list of nodes to be sorted.
+ *
+ * @returns A new array of nodes sorted in depth-first order.
+ */
+function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] {
+ logger.trace('sortFileList');
+
+ const nodeMap = new Map();
+ const childrenMap = new Map();
+
+ // pre-sort nodes by name and type
+ nodeList.sort((a, b) => compareNodes(a, b));
+
+ for (const node of nodeList) {
+ nodeMap.set(node.fullPath, node);
+
+ const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf('/'));
+
+ if (parentPath !== rootFolder.slice(0, rootFolder.lastIndexOf('/'))) {
+ if (!childrenMap.has(parentPath)) {
+ childrenMap.set(parentPath, []);
+ }
+
+ childrenMap.get(parentPath)?.push(node);
+ }
+ }
+
+ const sortedList: Node[] = [];
+
+ const depthFirstTraversal = (path: string): void => {
+ const node = nodeMap.get(path);
+
+ if (node) {
+ sortedList.push(node);
+ }
+
+ const children = childrenMap.get(path);
+
+ if (children) {
+ for (const child of children) {
+ if (child.kind === 'folder') {
+ depthFirstTraversal(child.fullPath);
+ } else {
+ sortedList.push(child);
+ }
+ }
+ }
+ };
+
+ if (hideRoot) {
+ // if root is hidden, start traversal from its immediate children
+ const rootChildren = childrenMap.get(rootFolder) || [];
+
+ for (const child of rootChildren) {
+ depthFirstTraversal(child.fullPath);
+ }
+ } else {
+ depthFirstTraversal(rootFolder);
+ }
+
+ return sortedList;
+}
+
+function compareNodes(a: Node, b: Node): number {
+ if (a.kind !== b.kind) {
+ return a.kind === 'folder' ? -1 : 1;
+ }
+
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
+}
diff --git a/app/components/workbench/PortDropdown.tsx b/app/components/workbench/PortDropdown.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..13457b2d6f18c02622823fbd750ac0675672eb35
--- /dev/null
+++ b/app/components/workbench/PortDropdown.tsx
@@ -0,0 +1,83 @@
+import { memo, useEffect, useRef } from 'react';
+import { IconButton } from '~/components/ui/IconButton';
+import type { PreviewInfo } from '~/lib/stores/previews';
+
+interface PortDropdownProps {
+ activePreviewIndex: number;
+ setActivePreviewIndex: (index: number) => void;
+ isDropdownOpen: boolean;
+ setIsDropdownOpen: (value: boolean) => void;
+ setHasSelectedPreview: (value: boolean) => void;
+ previews: PreviewInfo[];
+}
+
+export const PortDropdown = memo(
+ ({
+ activePreviewIndex,
+ setActivePreviewIndex,
+ isDropdownOpen,
+ setIsDropdownOpen,
+ setHasSelectedPreview,
+ previews,
+ }: PortDropdownProps) => {
+ const dropdownRef = useRef(null);
+
+ // sort previews, preserving original index
+ const sortedPreviews = previews
+ .map((previewInfo, index) => ({ ...previewInfo, index }))
+ .sort((a, b) => a.port - b.port);
+
+ // close dropdown if user clicks outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsDropdownOpen(false);
+ }
+ };
+
+ if (isDropdownOpen) {
+ window.addEventListener('mousedown', handleClickOutside);
+ } else {
+ window.removeEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ window.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isDropdownOpen]);
+
+ return (
+
+
setIsDropdownOpen(!isDropdownOpen)} />
+ {isDropdownOpen && (
+
+
+ Ports
+
+ {sortedPreviews.map((preview) => (
+
{
+ setActivePreviewIndex(preview.index);
+ setIsDropdownOpen(false);
+ setHasSelectedPreview(true);
+ }}
+ >
+
+ {preview.port}
+
+
+ ))}
+
+ )}
+
+ );
+ },
+);
diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..93a0b85c44c63b9b9b706a17296972526328cc50
--- /dev/null
+++ b/app/components/workbench/Preview.tsx
@@ -0,0 +1,450 @@
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { useStore } from '@nanostores/react';
+import { IconButton } from '~/components/ui/IconButton';
+import { workbenchStore } from '~/lib/stores/workbench';
+import { PortDropdown } from './PortDropdown';
+import { ScreenshotSelector } from './ScreenshotSelector';
+
+type ResizeSide = 'left' | 'right' | null;
+
+interface WindowSize {
+ name: string;
+ width: number;
+ height: number;
+ icon: string;
+}
+
+const WINDOW_SIZES: WindowSize[] = [
+ { name: 'Mobile', width: 375, height: 667, icon: 'i-ph:device-mobile' },
+ { name: 'Tablet', width: 768, height: 1024, icon: 'i-ph:device-tablet' },
+ { name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop' },
+ { name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor' },
+];
+
+export const Preview = memo(() => {
+ const iframeRef = useRef(null);
+ const containerRef = useRef(null);
+ const inputRef = useRef(null);
+
+ const [activePreviewIndex, setActivePreviewIndex] = useState(0);
+ const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ const [isPreviewOnly, setIsPreviewOnly] = useState(false);
+ const hasSelectedPreview = useRef(false);
+ const previews = useStore(workbenchStore.previews);
+ const activePreview = previews[activePreviewIndex];
+
+ const [url, setUrl] = useState('');
+ const [iframeUrl, setIframeUrl] = useState();
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
+
+ // Toggle between responsive mode and device mode
+ const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
+
+ // Use percentage for width
+ const [widthPercent, setWidthPercent] = useState(37.5);
+
+ const resizingState = useRef({
+ isResizing: false,
+ side: null as ResizeSide,
+ startX: 0,
+ startWidthPercent: 37.5,
+ windowWidth: window.innerWidth,
+ });
+
+ const SCALING_FACTOR = 2;
+
+ const [isWindowSizeDropdownOpen, setIsWindowSizeDropdownOpen] = useState(false);
+ const [selectedWindowSize, setSelectedWindowSize] = useState(WINDOW_SIZES[0]);
+
+ useEffect(() => {
+ if (!activePreview) {
+ setUrl('');
+ setIframeUrl(undefined);
+
+ return;
+ }
+
+ const { baseUrl } = activePreview;
+ setUrl(baseUrl);
+ setIframeUrl(baseUrl);
+ }, [activePreview]);
+
+ const validateUrl = useCallback(
+ (value: string) => {
+ if (!activePreview) {
+ return false;
+ }
+
+ const { baseUrl } = activePreview;
+
+ if (value === baseUrl) {
+ return true;
+ } else if (value.startsWith(baseUrl)) {
+ return ['/', '?', '#'].includes(value.charAt(baseUrl.length));
+ }
+
+ return false;
+ },
+ [activePreview],
+ );
+
+ const findMinPortIndex = useCallback(
+ (minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => {
+ return preview.port < array[minIndex].port ? index : minIndex;
+ },
+ [],
+ );
+
+ useEffect(() => {
+ if (previews.length > 1 && !hasSelectedPreview.current) {
+ const minPortIndex = previews.reduce(findMinPortIndex, 0);
+ setActivePreviewIndex(minPortIndex);
+ }
+ }, [previews, findMinPortIndex]);
+
+ const reloadPreview = () => {
+ if (iframeRef.current) {
+ iframeRef.current.src = iframeRef.current.src;
+ }
+ };
+
+ const toggleFullscreen = async () => {
+ if (!isFullscreen && containerRef.current) {
+ await containerRef.current.requestFullscreen();
+ } else if (document.fullscreenElement) {
+ await document.exitFullscreen();
+ }
+ };
+
+ useEffect(() => {
+ const handleFullscreenChange = () => {
+ setIsFullscreen(!!document.fullscreenElement);
+ };
+
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
+
+ return () => {
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
+ };
+ }, []);
+
+ const toggleDeviceMode = () => {
+ setIsDeviceModeOn((prev) => !prev);
+ };
+
+ const startResizing = (e: React.MouseEvent, side: ResizeSide) => {
+ if (!isDeviceModeOn) {
+ return;
+ }
+
+ document.body.style.userSelect = 'none';
+
+ resizingState.current.isResizing = true;
+ resizingState.current.side = side;
+ resizingState.current.startX = e.clientX;
+ resizingState.current.startWidthPercent = widthPercent;
+ resizingState.current.windowWidth = window.innerWidth;
+
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+
+ e.preventDefault();
+ };
+
+ const onMouseMove = (e: MouseEvent) => {
+ if (!resizingState.current.isResizing) {
+ return;
+ }
+
+ const dx = e.clientX - resizingState.current.startX;
+ const windowWidth = resizingState.current.windowWidth;
+
+ const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR;
+
+ let newWidthPercent = resizingState.current.startWidthPercent;
+
+ if (resizingState.current.side === 'right') {
+ newWidthPercent = resizingState.current.startWidthPercent + dxPercent;
+ } else if (resizingState.current.side === 'left') {
+ newWidthPercent = resizingState.current.startWidthPercent - dxPercent;
+ }
+
+ newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
+
+ setWidthPercent(newWidthPercent);
+ };
+
+ const onMouseUp = () => {
+ resizingState.current.isResizing = false;
+ resizingState.current.side = null;
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+
+ document.body.style.userSelect = '';
+ };
+
+ useEffect(() => {
+ const handleWindowResize = () => {
+ // Optional: Adjust widthPercent if necessary
+ };
+
+ window.addEventListener('resize', handleWindowResize);
+
+ return () => {
+ window.removeEventListener('resize', handleWindowResize);
+ };
+ }, []);
+
+ const GripIcon = () => (
+
+ );
+
+ const openInNewWindow = (size: WindowSize) => {
+ if (activePreview?.baseUrl) {
+ const match = activePreview.baseUrl.match(/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/);
+
+ if (match) {
+ const previewId = match[1];
+ const previewUrl = `/webcontainer/preview/${previewId}`;
+ const newWindow = window.open(
+ previewUrl,
+ '_blank',
+ `noopener,noreferrer,width=${size.width},height=${size.height},menubar=no,toolbar=no,location=no,status=no`,
+ );
+
+ if (newWindow) {
+ newWindow.focus();
+ }
+ } else {
+ console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl);
+ }
+ }
+ };
+
+ return (
+
+ {isPortDropdownOpen && (
+
setIsPortDropdownOpen(false)} />
+ )}
+
+
+
+ setIsSelectionMode(!isSelectionMode)}
+ className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
+ />
+
+
+
+ {
+ setUrl(event.target.value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && validateUrl(url)) {
+ setIframeUrl(url);
+
+ if (inputRef.current) {
+ inputRef.current.blur();
+ }
+ }
+ }}
+ />
+
+
+
+ {previews.length > 1 && (
+
(hasSelectedPreview.current = value)}
+ setIsDropdownOpen={setIsPortDropdownOpen}
+ previews={previews}
+ />
+ )}
+
+
+
+ setIsPreviewOnly(!isPreviewOnly)}
+ title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'}
+ />
+
+
+
+
+