Revert "fix: resolve chat conversation hanging and stream interruption issues (#1971)"

This reverts commit e68593f22d.
This commit is contained in:
Stijnus
2025-09-07 00:14:13 +02:00
committed by Stijnus
parent e68593f22d
commit 37217a5c7b
61 changed files with 1432 additions and 8811 deletions

View File

@@ -3,8 +3,7 @@ import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
import type { NetlifySite, NetlifyDeploy, NetlifyBuild } from '~/types/netlify';
import { NetlifyQuickConnect } from './NetlifyQuickConnect';
import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
import {
CloudIcon,
BuildingLibraryIcon,
@@ -43,16 +42,29 @@ interface SiteAction {
}
export default function NetlifyConnection() {
console.log('NetlifyConnection component mounted');
const connection = useStore(netlifyConnection);
const [tokenInput, setTokenInput] = useState('');
const [fetchingStats, setFetchingStats] = useState(false);
const [sites, setSites] = useState<NetlifySite[]>([]);
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
console.log('NetlifyConnection initial state:', {
connection: {
user: connection.user,
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
},
envToken: import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
});
const [deploymentCount, setDeploymentCount] = useState(0);
const [lastUpdated, setLastUpdated] = useState('');
const [isStatsOpen, setIsStatsOpen] = useState(false);
const [activeSiteIndex, setActiveSiteIndex] = useState(0);
const [isActionLoading, setIsActionLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
// Add site actions
const siteActions: SiteAction[] = [
@@ -139,6 +151,8 @@ export default function NetlifyConnection() {
};
useEffect(() => {
console.log('Netlify: Running initialization useEffect');
// Initialize connection with environment token if available
initializeNetlifyConnection();
}, []);
@@ -159,6 +173,46 @@ export default function NetlifyConnection() {
}
}, [connection]);
const handleConnect = async () => {
if (!tokenInput) {
toast.error('Please enter a Netlify API token');
return;
}
setIsConnecting(true);
try {
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${tokenInput}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const userData = (await response.json()) as NetlifyUser;
// Update the connection store
updateNetlifyConnection({
user: userData,
token: tokenInput,
});
toast.success('Connected to Netlify successfully');
// Fetch stats after successful connection
fetchNetlifyStats(tokenInput);
} catch (error) {
console.error('Error connecting to Netlify:', error);
toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsConnecting(false);
setTokenInput('');
}
};
const handleDisconnect = () => {
// Clear from localStorage
localStorage.removeItem('netlify_connection');
@@ -608,15 +662,76 @@ export default function NetlifyConnection() {
{!connection.user ? (
<div className="mt-4">
<NetlifyQuickConnect
onSuccess={() => {
// Fetch stats after successful connection
if (connection.token) {
fetchNetlifyStats(connection.token);
}
}}
showInstructions={true}
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
API Token
</label>
<input
type="password"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder="Enter your Netlify API token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
{/* Debug info - remove this later */}
<div className="mt-2 text-xs text-gray-500">
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
<p>Debug: Env token: {import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '✅' : '❌'}</p>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={handleConnect}
disabled={isConnecting || !tokenInput}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#303030] text-white',
'hover:bg-[#5E41D0] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
{/* Debug button - remove this later */}
<button
onClick={async () => {
console.log('Manual Netlify auto-connect test');
await initializeNetlifyConnection();
}}
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
>
Test Auto-Connect
</button>
</div>
</div>
) : (
<div className="flex flex-col w-full gap-4 mt-4">

View File

@@ -1,226 +0,0 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { updateNetlifyConnection } from '~/lib/stores/netlify';
import { classNames } from '~/utils/classNames';
interface NetlifyQuickConnectProps {
onSuccess?: () => void;
showInstructions?: boolean;
}
export const NetlifyQuickConnect: React.FC<NetlifyQuickConnectProps> = ({ onSuccess, showInstructions = true }) => {
const [token, setToken] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const handleConnect = async () => {
if (!token.trim()) {
toast.error('Please enter your Netlify API token');
return;
}
setIsConnecting(true);
try {
// Validate token with Netlify API
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Invalid token or authentication failed');
}
const userData = (await response.json()) as any;
// Fetch initial site statistics
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
Authorization: `Bearer ${token}`,
},
});
let sites: any[] = [];
if (sitesResponse.ok) {
sites = (await sitesResponse.json()) as any[];
}
// Update the connection store
updateNetlifyConnection({
user: userData,
token,
stats: {
sites,
totalSites: sites.length,
deploys: [],
builds: [],
lastDeployTime: '',
},
});
toast.success(`Connected to Netlify as ${userData.email || userData.name || 'User'}`);
setToken(''); // Clear the token field
if (onSuccess) {
onSuccess();
}
} catch (error) {
console.error('Netlify connection error:', error);
toast.error('Failed to connect to Netlify. Please check your token.');
} finally {
setIsConnecting(false);
}
};
return (
<div className="space-y-4">
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-bolt-elements-textPrimary">Personal Access Token</label>
{showInstructions && (
<button
onClick={() => setShowHelp(!showHelp)}
className="text-xs text-accent-500 hover:text-accent-600 flex items-center gap-1"
>
<span className={classNames('i-ph:question-circle', showHelp ? 'text-accent-600' : '')} />
How to get token
</button>
)}
</div>
<div className="relative">
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && token.trim() && !isConnecting) {
handleConnect();
}
}}
placeholder="Enter your Netlify API token"
className={classNames(
'w-full px-3 py-2 pr-10 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent',
'disabled:opacity-50',
)}
disabled={isConnecting}
/>
{token && (
<button
onClick={() => setToken('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary"
>
<span className="i-ph:x text-lg" />
</button>
)}
</div>
</div>
{showHelp && showInstructions && (
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 space-y-3 animate-in fade-in-0 slide-in-from-top-2">
<div className="flex items-start gap-2">
<span className="i-ph:info text-accent-500 mt-0.5" />
<div className="space-y-2 text-sm">
<p className="font-medium text-bolt-elements-textPrimary">
Getting your Netlify Personal Access Token:
</p>
<ol className="space-y-2 text-bolt-elements-textSecondary">
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">1.</span>
<span>
Go to{' '}
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-accent-500 hover:text-accent-600 underline inline-flex items-center gap-1"
>
Netlify Account Settings
<span className="i-ph:arrow-square-out text-xs" />
</a>
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">2.</span>
<span>Navigate to "Applications" "Personal access tokens"</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">3.</span>
<span>Click "New access token"</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">4.</span>
<span>Give it a descriptive name (e.g., "bolt.diy deployment")</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent-500 font-medium">5.</span>
<span>Copy the token and paste it above</span>
</li>
</ol>
<div className="pt-2 border-t border-bolt-elements-borderColor">
<p className="text-xs text-bolt-elements-textTertiary">
<strong>Note:</strong> Keep your token safe! It provides full access to your Netlify account.
</p>
</div>
</div>
</div>
</div>
)}
<div className="flex gap-3">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2 transition-all text-sm font-medium flex items-center gap-2"
>
<span className="i-ph:arrow-square-out" />
Get Token
</a>
<button
onClick={handleConnect}
disabled={isConnecting || !token.trim()}
className={classNames(
'flex-1 px-4 py-2 rounded-lg font-medium transition-all text-sm',
'bg-accent-500 text-white',
'hover:bg-accent-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2',
)}
>
{isConnecting ? (
<>
<span className="i-svg-spinners:3-dots-scale" />
Connecting...
</>
) : (
<>
<span className="i-ph:plug-charging" />
Connect to Netlify
</>
)}
</button>
</div>
</div>
<div className="p-3 bg-accent-500/10 border border-accent-500/20 rounded-lg">
<div className="flex items-start gap-2">
<span className="i-ph:lightning text-accent-500 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-bolt-elements-textPrimary">Quick Tip</p>
<p className="text-xs text-bolt-elements-textSecondary">
Once connected, you can deploy any project with a single click directly from the editor!
</p>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,56 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from '@remix-run/react';
import { useStore } from '@nanostores/react';
import { authStore } from '~/lib/stores/auth';
import { motion } from 'framer-motion';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const navigate = useNavigate();
const authState = useStore(authStore);
useEffect(() => {
// If not loading and not authenticated, redirect to auth page
if (!authState.loading && !authState.isAuthenticated) {
navigate('/auth');
}
}, [authState.loading, authState.isAuthenticated, navigate]);
// Show loading state
if (authState.loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bolt-elements-background-depth-1">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-bolt-elements-background-depth-2 flex items-center justify-center">
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
</div>
<p className="text-bolt-elements-textSecondary">Loading your workspace...</p>
</motion.div>
</div>
);
}
// If not authenticated, don't render children (will redirect)
if (!authState.isAuthenticated) {
return null;
}
// Render protected content
return <>{children}</>;
}
// HOC for protecting pages
export function withAuth<P extends object>(wrappedComponent: React.ComponentType<P>) {
const Component = wrappedComponent;
return function ProtectedComponent(props: P) {
return (
<ProtectedRoute>
<Component {...props} />
</ProtectedRoute>
);
};
}

View File

@@ -176,18 +176,9 @@ export const AssistantMessage = memo(
</div>
</div>
</>
<div className="prose prose-invert max-w-none text-bolt-elements-textPrimary">
<Markdown
append={append}
chatMode={chatMode}
setChatMode={setChatMode}
model={model}
provider={provider}
html
>
{content}
</Markdown>
</div>
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
{content}
</Markdown>
{toolInvocations && toolInvocations.length > 0 && (
<ToolInvocations
toolInvocations={toolInvocations}

View File

@@ -1,86 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from '@remix-run/react';
import { ClientOnly } from 'remix-utils/client-only';
import { useStore } from '@nanostores/react';
import { authStore } from '~/lib/stores/auth';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
import BackgroundRays from '~/components/ui/BackgroundRays';
import { motion } from 'framer-motion';
import { UserMenu } from '~/components/header/UserMenu';
/**
* Authenticated chat component that ensures user is logged in
*/
export function AuthenticatedChat() {
const navigate = useNavigate();
const authState = useStore(authStore);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
// Check authentication status after component mounts
const checkAuth = async () => {
// Give auth store time to initialize
await new Promise((resolve) => setTimeout(resolve, 100));
const state = authStore.get();
if (!state.loading) {
if (!state.isAuthenticated) {
navigate('/auth');
} else {
setIsInitialized(true);
}
}
};
checkAuth();
}, [navigate]);
useEffect(() => {
// Subscribe to auth changes
const unsubscribe = authStore.subscribe((state) => {
if (!state.loading && !state.isAuthenticated) {
navigate('/auth');
}
});
return () => {
unsubscribe();
};
}, [navigate]);
// Show loading state
if (authState.loading || !isInitialized) {
return (
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
<BackgroundRays />
<div className="flex-1 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-bolt-elements-background-depth-2 flex items-center justify-center">
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
</div>
<p className="text-bolt-elements-textSecondary">Initializing workspace...</p>
</motion.div>
</div>
</div>
);
}
// If not authenticated, don't render (will redirect)
if (!authState.isAuthenticated) {
return null;
}
// Render authenticated content with enhanced header
return (
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
<BackgroundRays />
<Header>
<UserMenu />
</Header>
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
</div>
);
}

View File

@@ -73,28 +73,20 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
{isUserMessage ? (
<UserMessage content={content} parts={parts} />
) : (
<>
{props.model?.includes('smartai') && index === messages.length - 1 && isStreaming && (
<div className="flex items-center gap-2 mb-2 text-sm text-blue-400">
<span className="i-ph:sparkle animate-pulse" />
<span className="font-medium">SmartAI is explaining the process...</span>
</div>
)}
<AssistantMessage
content={content}
annotations={message.annotations}
messageId={messageId}
onRewind={handleRewind}
onFork={handleFork}
append={props.append}
chatMode={props.chatMode}
setChatMode={props.setChatMode}
model={props.model}
provider={props.provider}
parts={parts}
addToolResult={props.addToolResult}
/>
</>
<AssistantMessage
content={content}
annotations={message.annotations}
messageId={messageId}
onRewind={handleRewind}
onFork={handleFork}
append={props.append}
chatMode={props.chatMode}
setChatMode={props.setChatMode}
model={props.model}
provider={props.provider}
parts={parts}
addToolResult={props.addToolResult}
/>
)}
</div>
</div>

View File

@@ -1,9 +1,84 @@
import type { ProviderInfo } from '~/types/model';
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames';
// Fuzzy search utilities
const levenshteinDistance = (str1: string, str2: string): number => {
const matrix = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
}
return matrix[str2.length][str1.length];
};
const fuzzyMatch = (query: string, text: string): { score: number; matches: boolean } => {
if (!query) {
return { score: 0, matches: true };
}
if (!text) {
return { score: 0, matches: false };
}
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
// Exact substring match gets highest score
if (textLower.includes(queryLower)) {
return { score: 100 - (textLower.indexOf(queryLower) / textLower.length) * 20, matches: true };
}
// Fuzzy match with reasonable threshold
const distance = levenshteinDistance(queryLower, textLower);
const maxLen = Math.max(queryLower.length, textLower.length);
const similarity = 1 - distance / maxLen;
return {
score: similarity > 0.6 ? similarity * 80 : 0,
matches: similarity > 0.6,
};
};
const highlightText = (text: string, query: string): string => {
if (!query) {
return text;
}
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 text-current">$1</mark>');
};
const formatContextSize = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`;
}
return tokens.toString();
};
interface ModelSelectorProps {
model?: string;
setModel?: (model: string) => void;
@@ -40,12 +115,14 @@ export const ModelSelector = ({
modelLoading,
}: ModelSelectorProps) => {
const [modelSearchQuery, setModelSearchQuery] = useState('');
const [debouncedModelSearchQuery, setDebouncedModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [focusedModelIndex, setFocusedModelIndex] = useState(-1);
const modelSearchInputRef = useRef<HTMLInputElement>(null);
const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const [providerSearchQuery, setProviderSearchQuery] = useState('');
const [debouncedProviderSearchQuery, setDebouncedProviderSearchQuery] = useState('');
const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false);
const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1);
const providerSearchInputRef = useRef<HTMLInputElement>(null);
@@ -53,6 +130,23 @@ export const ModelSelector = ({
const providerDropdownRef = useRef<HTMLDivElement>(null);
const [showFreeModelsOnly, setShowFreeModelsOnly] = useState(false);
// Debounce search queries
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedModelSearchQuery(modelSearchQuery);
}, 150);
return () => clearTimeout(timer);
}, [modelSearchQuery]);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedProviderSearchQuery(providerSearchQuery);
}, 150);
return () => clearTimeout(timer);
}, [providerSearchQuery]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
@@ -71,24 +165,64 @@ export const ModelSelector = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredModels = [...modelList]
.filter((e) => e.provider === provider?.name && e.name)
.filter((model) => {
// Apply free models filter
if (showFreeModelsOnly && !isModelLikelyFree(model, provider?.name)) {
return false;
}
const filteredModels = useMemo(() => {
const baseModels = [...modelList].filter((e) => e.provider === provider?.name && e.name);
// Apply search filter
return (
model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase())
);
});
return baseModels
.filter((model) => {
// Apply free models filter
if (showFreeModelsOnly && !isModelLikelyFree(model, provider?.name)) {
return false;
}
const filteredProviders = providerList.filter((p) =>
p.name.toLowerCase().includes(providerSearchQuery.toLowerCase()),
);
return true;
})
.map((model) => {
// Calculate search scores for fuzzy matching
const labelMatch = fuzzyMatch(debouncedModelSearchQuery, model.label);
const nameMatch = fuzzyMatch(debouncedModelSearchQuery, model.name);
const contextMatch = fuzzyMatch(debouncedModelSearchQuery, formatContextSize(model.maxTokenAllowed));
const bestScore = Math.max(labelMatch.score, nameMatch.score, contextMatch.score);
const matches = labelMatch.matches || nameMatch.matches || contextMatch.matches || !debouncedModelSearchQuery; // Show all if no query
return {
...model,
searchScore: bestScore,
searchMatches: matches,
highlightedLabel: highlightText(model.label, debouncedModelSearchQuery),
highlightedName: highlightText(model.name, debouncedModelSearchQuery),
};
})
.filter((model) => model.searchMatches)
.sort((a, b) => {
// Sort by search score (highest first), then by label
if (debouncedModelSearchQuery) {
return b.searchScore - a.searchScore;
}
return a.label.localeCompare(b.label);
});
}, [modelList, provider?.name, showFreeModelsOnly, debouncedModelSearchQuery]);
const filteredProviders = useMemo(() => {
if (!debouncedProviderSearchQuery) {
return providerList;
}
return providerList
.map((provider) => {
const match = fuzzyMatch(debouncedProviderSearchQuery, provider.name);
return {
...provider,
searchScore: match.score,
searchMatches: match.matches,
highlightedName: highlightText(provider.name, debouncedProviderSearchQuery),
};
})
.filter((provider) => provider.searchMatches)
.sort((a, b) => b.searchScore - a.searchScore);
}, [providerList, debouncedProviderSearchQuery]);
// Reset free models filter when provider changes
useEffect(() => {
@@ -97,11 +231,30 @@ export const ModelSelector = ({
useEffect(() => {
setFocusedModelIndex(-1);
}, [modelSearchQuery, isModelDropdownOpen, showFreeModelsOnly]);
}, [debouncedModelSearchQuery, isModelDropdownOpen, showFreeModelsOnly]);
useEffect(() => {
setFocusedProviderIndex(-1);
}, [providerSearchQuery, isProviderDropdownOpen]);
}, [debouncedProviderSearchQuery, isProviderDropdownOpen]);
// Clear search functions
const clearModelSearch = useCallback(() => {
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
if (modelSearchInputRef.current) {
modelSearchInputRef.current.focus();
}
}, []);
const clearProviderSearch = useCallback(() => {
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
if (providerSearchInputRef.current) {
providerSearchInputRef.current.focus();
}
}, []);
useEffect(() => {
if (isModelDropdownOpen && modelSearchInputRef.current) {
@@ -137,6 +290,7 @@ export const ModelSelector = ({
setModel?.(selectedModel.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
}
break;
@@ -144,12 +298,20 @@ export const ModelSelector = ({
e.preventDefault();
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) {
setIsModelDropdownOpen(false);
}
break;
case 'k':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
clearModelSearch();
}
break;
}
};
@@ -186,6 +348,7 @@ export const ModelSelector = ({
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
}
break;
@@ -193,12 +356,20 @@ export const ModelSelector = ({
e.preventDefault();
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedProviderIndex === filteredProviders.length - 1) {
setIsProviderDropdownOpen(false);
}
break;
case 'k':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
clearProviderSearch();
}
break;
}
};
@@ -292,9 +463,9 @@ export const ModelSelector = ({
type="text"
value={providerSearchQuery}
onChange={(e) => setProviderSearchQuery(e.target.value)}
placeholder="Search providers..."
placeholder="Search providers... (⌘K to clear)"
className={classNames(
'w-full pl-2 py-1.5 rounded-md text-sm',
'w-full pl-8 pr-8 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',
@@ -307,6 +478,19 @@ export const ModelSelector = ({
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
{providerSearchQuery && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearProviderSearch();
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
aria-label="Clear search"
>
<span className="i-ph:x text-bolt-elements-textTertiary text-xs" />
</button>
)}
</div>
</div>
@@ -327,7 +511,18 @@ export const ModelSelector = ({
)}
>
{filteredProviders.length === 0 ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No providers found</div>
<div className="px-3 py-3 text-sm">
<div className="text-bolt-elements-textTertiary mb-1">
{debouncedProviderSearchQuery
? `No providers match "${debouncedProviderSearchQuery}"`
: 'No providers found'}
</div>
{debouncedProviderSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try searching for provider names like "OpenAI", "Anthropic", or "Google"
</div>
)}
</div>
) : (
filteredProviders.map((providerOption, index) => (
<div
@@ -360,10 +555,15 @@ export const ModelSelector = ({
setIsProviderDropdownOpen(false);
setProviderSearchQuery('');
setDebouncedProviderSearchQuery('');
}}
tabIndex={focusedProviderIndex === index ? 0 : -1}
>
{providerOption.name}
<div
dangerouslySetInnerHTML={{
__html: (providerOption as any).highlightedName || providerOption.name,
}}
/>
</div>
))
)}
@@ -396,15 +596,7 @@ export const ModelSelector = ({
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 truncate">
<span className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</span>
{modelList.find((m) => m.name === model)?.isSmartAIEnabled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30">
<span className="i-ph:sparkle text-xs text-blue-400" />
<span className="text-xs text-blue-400 font-medium">Active</span>
</span>
)}
</div>
<div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
@@ -449,6 +641,14 @@ export const ModelSelector = ({
</div>
)}
{/* Search Result Count */}
{debouncedModelSearchQuery && filteredModels.length > 0 && (
<div className="text-xs text-bolt-elements-textTertiary px-1">
{filteredModels.length} model{filteredModels.length !== 1 ? 's' : ''} found
{filteredModels.length > 5 && ' (showing best matches)'}
</div>
)}
{/* Search Input */}
<div className="relative">
<input
@@ -456,9 +656,9 @@ export const ModelSelector = ({
type="text"
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder="Search models..."
placeholder="Search models... (⌘K to clear)"
className={classNames(
'w-full pl-2 py-1.5 rounded-md text-sm',
'w-full pl-8 pr-8 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',
@@ -471,6 +671,19 @@ export const ModelSelector = ({
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
{modelSearchQuery && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearModelSearch();
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
aria-label="Clear search"
>
<span className="i-ph:x text-bolt-elements-textTertiary text-xs" />
</button>
)}
</div>
</div>
@@ -491,16 +704,37 @@ export const ModelSelector = ({
)}
>
{modelLoading === 'all' || modelLoading === provider?.name ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">Loading...</div>
<div className="px-3 py-3 text-sm">
<div className="flex items-center gap-2 text-bolt-elements-textTertiary">
<span className="i-ph:spinner animate-spin" />
Loading models...
</div>
</div>
) : filteredModels.length === 0 ? (
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">
{showFreeModelsOnly ? 'No free models found' : 'No models found'}
<div className="px-3 py-3 text-sm">
<div className="text-bolt-elements-textTertiary mb-1">
{debouncedModelSearchQuery
? `No models match "${debouncedModelSearchQuery}"${showFreeModelsOnly ? ' (free only)' : ''}`
: showFreeModelsOnly
? 'No free models available'
: 'No models available'}
</div>
{debouncedModelSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try searching for model names, context sizes (e.g., "128k", "1M"), or capabilities
</div>
)}
{showFreeModelsOnly && !debouncedModelSearchQuery && (
<div className="text-xs text-bolt-elements-textTertiary">
Try disabling the "Free models only" filter to see all available models
</div>
)}
</div>
) : (
filteredModels.map((modelOption, index) => (
<div
ref={(el) => (modelOptionsRef.current[index] = el)}
key={index} // Consider using modelOption.name if unique
key={modelOption.name}
role="option"
aria-selected={model === modelOption.name}
className={classNames(
@@ -518,22 +752,38 @@ export const ModelSelector = ({
setModel?.(modelOption.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
setDebouncedModelSearchQuery('');
}}
tabIndex={focusedModelIndex === index ? 0 : -1}
>
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
{modelOption.label}
{modelOption.isSmartAIEnabled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30">
<span className="i-ph:sparkle text-xs text-blue-400" />
<span className="text-xs text-blue-400 font-medium">SmartAI</span>
<div className="flex-1 min-w-0">
<div className="truncate">
<span
dangerouslySetInnerHTML={{
__html: (modelOption as any).highlightedLabel || modelOption.label,
}}
/>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-bolt-elements-textTertiary">
{formatContextSize(modelOption.maxTokenAllowed)} tokens
</span>
{debouncedModelSearchQuery && (modelOption as any).searchScore > 70 && (
<span className="text-xs text-green-500 font-medium">
{(modelOption as any).searchScore.toFixed(0)}% match
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{isModelLikelyFree(modelOption, provider?.name) && (
<span className="i-ph:gift text-xs text-purple-400" title="Free model" />
)}
</span>
{isModelLikelyFree(modelOption, provider?.name) && (
<span className="i-ph:gift text-xs text-purple-400 ml-2" title="Free model" />
)}
{model === modelOption.name && (
<span className="i-ph:check text-xs text-green-500" title="Selected" />
)}
</div>
</div>
</div>
))

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
import type { ModelInfo } from '~/lib/modules/llm/types';
import type { ProviderInfo } from '~/types/model';
interface SmartAIToggleProps {
enabled: boolean;
onToggle: (enabled: boolean) => void;
provider?: ProviderInfo;
model?: string;
modelList: ModelInfo[];
}
export const SmartAiToggle: React.FC<SmartAIToggleProps> = ({ enabled, onToggle, provider, model, modelList }) => {
// Check if current model supports SmartAI
const currentModel = modelList.find((m) => m.name === model);
const isSupported = currentModel?.supportsSmartAI && (provider?.name === 'Anthropic' || provider?.name === 'OpenAI');
if (!isSupported) {
return null;
}
return (
<button
onClick={() => onToggle(!enabled)}
className={classNames(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all',
'border border-bolt-elements-borderColor',
enabled
? 'bg-gradient-to-r from-blue-500/20 to-purple-500/20 border-blue-500/30'
: 'bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3',
)}
title="Toggle SmartAI for detailed conversational feedback"
>
<span
className={classNames('i-ph:sparkle text-sm', enabled ? 'text-blue-400' : 'text-bolt-elements-textSecondary')}
/>
<span
className={classNames('text-xs font-medium', enabled ? 'text-blue-400' : 'text-bolt-elements-textSecondary')}
>
SmartAI {enabled ? 'ON' : 'OFF'}
</span>
</button>
);
};

View File

@@ -1,90 +0,0 @@
import React from 'react';
import { useStore } from '@nanostores/react';
import { authStore } from '~/lib/stores/auth';
import { motion } from 'framer-motion';
const EXAMPLE_PROMPTS = [
{ text: 'Create a mobile app about bolt.diy' },
{ 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' },
];
interface WelcomeMessageProps {
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
}
export function WelcomeMessage({ sendMessage }: WelcomeMessageProps) {
const authState = useStore(authStore);
const timeOfDay = new Date().getHours();
const getGreeting = () => {
if (timeOfDay < 12) {
return 'Good morning';
}
if (timeOfDay < 17) {
return 'Good afternoon';
}
return 'Good evening';
};
return (
<div className="relative flex flex-col gap-6 w-full max-w-3xl mx-auto mt-8">
{/* Personalized Greeting */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h1 className="text-3xl font-bold text-bolt-elements-textPrimary mb-2">
{getGreeting()}, {authState.user?.firstName || 'Developer'}!
</h1>
<p className="text-lg text-bolt-elements-textSecondary">What would you like to build today?</p>
</motion.div>
{/* Example Prompts */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="flex flex-col gap-3"
>
<p className="text-sm text-bolt-elements-textTertiary text-center">Try one of these examples to get started:</p>
<div className="flex flex-wrap justify-center gap-2">
{EXAMPLE_PROMPTS.map((examplePrompt, index) => (
<motion.button
key={index}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.3 + index * 0.05 }}
onClick={(event) => 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-all hover:scale-105"
>
{examplePrompt.text}
</motion.button>
))}
</div>
</motion.div>
{/* User Stats */}
{authState.user && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="text-center text-xs text-bolt-elements-textTertiary"
>
<p>
Logged in as{' '}
<span className="text-bolt-elements-textSecondary font-medium">@{authState.user.username}</span>
</p>
</motion.div>
)}
</div>
);
}

View File

@@ -1,29 +1,277 @@
import { useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useStore } from '@nanostores/react';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { isGitLabConnected } from '~/lib/stores/gitlabConnection';
import { workbenchStore } from '~/lib/stores/workbench';
import { streamingState } from '~/lib/stores/streaming';
import { DeployDialog } from './DeployDialog';
import { classNames } from '~/utils/classNames';
import { useState } from 'react';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
import { useGitHubDeploy } from '~/components/deploy/GitHubDeploy.client';
import { useGitLabDeploy } from '~/components/deploy/GitLabDeploy.client';
import { GitHubDeploymentDialog } from '~/components/deploy/GitHubDeploymentDialog';
import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog';
export const DeployButton = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
interface DeployButtonProps {
onVercelDeploy?: () => Promise<void>;
onNetlifyDeploy?: () => Promise<void>;
onGitHubDeploy?: () => Promise<void>;
onGitLabDeploy?: () => Promise<void>;
}
export const DeployButton = ({
onVercelDeploy,
onNetlifyDeploy,
onGitHubDeploy,
onGitLabDeploy,
}: DeployButtonProps) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const gitlabIsConnected = useStore(isGitLabConnected);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'github' | 'gitlab' | null>(null);
const isStreaming = useStore(streamingState);
const { handleVercelDeploy } = useVercelDeploy();
const { handleNetlifyDeploy } = useNetlifyDeploy();
const { handleGitHubDeploy } = useGitHubDeploy();
const { handleGitLabDeploy } = useGitLabDeploy();
const [showGitHubDeploymentDialog, setShowGitHubDeploymentDialog] = useState(false);
const [showGitLabDeploymentDialog, setShowGitLabDeploymentDialog] = useState(false);
const [githubDeploymentFiles, setGithubDeploymentFiles] = useState<Record<string, string> | null>(null);
const [gitlabDeploymentFiles, setGitlabDeploymentFiles] = useState<Record<string, string> | null>(null);
const [githubProjectName, setGithubProjectName] = useState('');
const [gitlabProjectName, setGitlabProjectName] = useState('');
const handleVercelDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('vercel');
try {
if (onVercelDeploy) {
await onVercelDeploy();
} else {
await handleVercelDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleNetlifyDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('netlify');
try {
if (onNetlifyDeploy) {
await onNetlifyDeploy();
} else {
await handleNetlifyDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleGitHubDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('github');
try {
if (onGitHubDeploy) {
await onGitHubDeploy();
} else {
const result = await handleGitHubDeploy();
if (result && result.success && result.files) {
setGithubDeploymentFiles(result.files);
setGithubProjectName(result.projectName);
setShowGitHubDeploymentDialog(true);
}
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleGitLabDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('gitlab');
try {
if (onGitLabDeploy) {
await onGitLabDeploy();
} else {
const result = await handleGitLabDeploy();
if (result && result.success && result.files) {
setGitlabDeploymentFiles(result.files);
setGitlabProjectName(result.projectName);
setShowGitLabDeploymentDialog(true);
}
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return (
<>
<button
onClick={() => setIsDialogOpen(true)}
disabled={!activePreview || isStreaming}
className="px-4 py-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2 text-sm font-medium"
title="Deploy your project"
>
<span className="i-ph:rocket-launch text-lg" />
Deploy
</button>
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
<DropdownMenu.Root>
<DropdownMenu.Trigger
disabled={isDeploying || !activePreview || isStreaming}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
<span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'z-[250]',
'bg-bolt-elements-background-depth-2',
'rounded-lg shadow-lg',
'border border-bolt-elements-borderColor',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !netlifyConn.user,
},
)}
disabled={isDeploying || !activePreview || !netlifyConn.user}
onClick={handleNetlifyDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
</span>
{netlifyConn.user && <NetlifyDeploymentLink />}
</DropdownMenu.Item>
<DeployDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} />
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !vercelConn.user,
},
)}
disabled={isDeploying || !activePreview || !vercelConn.user}
onClick={handleVercelDeployClick}
>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
{vercelConn.user && <VercelDeploymentLink />}
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview,
},
)}
disabled={isDeploying || !activePreview}
onClick={handleGitHubDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/github"
alt="github"
/>
<span className="mx-auto">Deploy to GitHub</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !gitlabIsConnected,
},
)}
disabled={isDeploying || !activePreview || !gitlabIsConnected}
onClick={handleGitLabDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/gitlab"
alt="gitlab"
/>
<span className="mx-auto">{!gitlabIsConnected ? 'No GitLab Account Connected' : 'Deploy to GitLab'}</span>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="cloudflare"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/* GitHub Deployment Dialog */}
{showGitHubDeploymentDialog && githubDeploymentFiles && (
<GitHubDeploymentDialog
isOpen={showGitHubDeploymentDialog}
onClose={() => setShowGitHubDeploymentDialog(false)}
projectName={githubProjectName}
files={githubDeploymentFiles}
/>
)}
{/* GitLab Deployment Dialog */}
{showGitLabDeploymentDialog && gitlabDeploymentFiles && (
<GitLabDeploymentDialog
isOpen={showGitLabDeploymentDialog}
onClose={() => setShowGitLabDeploymentDialog(false)}
projectName={gitlabProjectName}
files={gitlabDeploymentFiles}
/>
)}
</>
);
};

View File

@@ -1,466 +0,0 @@
import React, { useState } from 'react';
import * as RadixDialog from '@radix-ui/react-dialog';
import { Dialog, DialogTitle, DialogDescription } from '~/components/ui/Dialog';
import { useStore } from '@nanostores/react';
import { netlifyConnection, updateNetlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { useNetlifyDeploy } from './NetlifyDeploy.client';
import { useVercelDeploy } from './VercelDeploy.client';
import { useGitHubDeploy } from './GitHubDeploy.client';
import { GitHubDeploymentDialog } from './GitHubDeploymentDialog';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
interface DeployDialogProps {
isOpen: boolean;
onClose: () => void;
}
interface DeployProvider {
id: 'netlify' | 'vercel' | 'github' | 'cloudflare';
name: string;
iconClass: string;
iconColor?: string;
connected: boolean;
comingSoon?: boolean;
description: string;
features: string[];
}
const NetlifyConnectForm: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
const [token, setToken] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const handleConnect = async () => {
if (!token.trim()) {
toast.error('Please enter your Netlify API token');
return;
}
setIsConnecting(true);
try {
// Validate token with Netlify API
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Invalid token or authentication failed');
}
const userData = (await response.json()) as any;
// Update the connection store
updateNetlifyConnection({
user: userData,
token,
});
toast.success(`Connected to Netlify as ${userData.email || userData.name || 'User'}`);
onSuccess();
} catch (error) {
console.error('Netlify connection error:', error);
toast.error('Failed to connect to Netlify. Please check your token.');
} finally {
setIsConnecting(false);
}
};
return (
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent">
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-2">Connect to Netlify</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
To deploy your project to Netlify, you need to connect your account using a Personal Access Token.
</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-1">Personal Access Token</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your Netlify API token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent',
'disabled:opacity-50',
)}
disabled={isConnecting}
/>
</div>
<div className="flex items-center justify-between">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-accent-500 hover:text-accent-600 inline-flex items-center gap-1"
>
Get your token from Netlify
<span className="i-ph:arrow-square-out text-xs" />
</a>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-3 space-y-2">
<p className="text-xs text-bolt-elements-textSecondary font-medium">How to get your token:</p>
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside">
<li>Go to your Netlify account settings</li>
<li>Navigate to "Applications" "Personal access tokens"</li>
<li>Click "New access token"</li>
<li>Give it a descriptive name (e.g., "bolt.diy deployment")</li>
<li>Copy the token and paste it here</li>
</ol>
</div>
<div className="flex gap-3">
<button
onClick={handleConnect}
disabled={isConnecting || !token.trim()}
className={classNames(
'flex-1 px-4 py-2 rounded-lg font-medium transition-all',
'bg-accent-500 text-white',
'hover:bg-accent-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2',
)}
>
{isConnecting ? (
<>
<span className="i-svg-spinners:3-dots-scale" />
Connecting...
</>
) : (
<>
<span className="i-ph:plug-charging" />
Connect Account
</>
)}
</button>
</div>
</div>
</div>
);
};
export const DeployDialog: React.FC<DeployDialogProps> = ({ isOpen, onClose }) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const [selectedProvider, setSelectedProvider] = useState<'netlify' | 'vercel' | 'github' | null>(null);
const [isDeploying, setIsDeploying] = useState(false);
const [showGitHubDialog, setShowGitHubDialog] = useState(false);
const [githubFiles, setGithubFiles] = useState<Record<string, string> | null>(null);
const [githubProjectName, setGithubProjectName] = useState('');
const { handleNetlifyDeploy } = useNetlifyDeploy();
const { handleVercelDeploy } = useVercelDeploy();
const { handleGitHubDeploy } = useGitHubDeploy();
const providers: DeployProvider[] = [
{
id: 'netlify',
name: 'Netlify',
iconClass: 'i-simple-icons:netlify',
iconColor: 'text-[#00C7B7]',
connected: !!netlifyConn.user,
description: 'Deploy your site with automatic SSL, global CDN, and continuous deployment',
features: [
'Automatic SSL certificates',
'Global CDN',
'Instant rollbacks',
'Deploy previews',
'Form handling',
'Serverless functions',
],
},
{
id: 'vercel',
name: 'Vercel',
iconClass: 'i-simple-icons:vercel',
connected: !!vercelConn.user,
description: 'Deploy with the platform built for frontend developers',
features: [
'Zero-config deployments',
'Edge Functions',
'Analytics',
'Web Vitals monitoring',
'Preview deployments',
'Automatic HTTPS',
],
},
{
id: 'github',
name: 'GitHub',
iconClass: 'i-simple-icons:github',
connected: true, // GitHub doesn't require separate auth
description: 'Deploy to GitHub Pages or create a repository',
features: [
'Free hosting with GitHub Pages',
'Version control integration',
'Collaborative development',
'Actions & Workflows',
'Issue tracking',
'Pull requests',
],
},
{
id: 'cloudflare',
name: 'Cloudflare Pages',
iconClass: 'i-simple-icons:cloudflare',
iconColor: 'text-[#F38020]',
connected: false,
comingSoon: true,
description: "Deploy on Cloudflare's global network",
features: [
'Unlimited bandwidth',
'DDoS protection',
'Web Analytics',
'Edge Workers',
'Custom domains',
'Automatic builds',
],
},
];
const handleDeploy = async (provider: 'netlify' | 'vercel' | 'github') => {
setIsDeploying(true);
try {
let success = false;
if (provider === 'netlify') {
success = await handleNetlifyDeploy();
} else if (provider === 'vercel') {
success = await handleVercelDeploy();
} else if (provider === 'github') {
const result = await handleGitHubDeploy();
if (result && typeof result === 'object' && result.success && result.files) {
setGithubFiles(result.files);
setGithubProjectName(result.projectName);
setShowGitHubDialog(true);
onClose();
return;
}
success = result && typeof result === 'object' ? result.success : false;
}
if (success) {
toast.success(
`Successfully deployed to ${provider === 'netlify' ? 'Netlify' : provider === 'vercel' ? 'Vercel' : 'GitHub'}`,
);
onClose();
}
} catch (error) {
console.error('Deployment error:', error);
toast.error(
`Failed to deploy to ${provider === 'netlify' ? 'Netlify' : provider === 'vercel' ? 'Vercel' : 'GitHub'}`,
);
} finally {
setIsDeploying(false);
}
};
const renderProviderContent = () => {
if (!selectedProvider) {
return (
<div className="grid gap-4">
{providers.map((provider) => (
<button
key={provider.id}
onClick={() =>
!provider.comingSoon && setSelectedProvider(provider.id as 'netlify' | 'vercel' | 'github')
}
disabled={provider.comingSoon}
className={classNames(
'p-4 rounded-lg border-2 transition-all text-left',
'hover:border-accent-500 hover:bg-bolt-elements-background-depth-2',
provider.comingSoon
? 'border-bolt-elements-borderColor opacity-50 cursor-not-allowed'
: 'border-bolt-elements-borderColor cursor-pointer',
)}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-bolt-elements-background-depth-1 flex items-center justify-center flex-shrink-0">
<span
className={classNames(
provider.iconClass,
provider.iconColor || 'text-bolt-elements-textPrimary',
'text-2xl',
)}
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
{provider.connected && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-500">Connected</span>
)}
{provider.comingSoon && (
<span className="text-xs px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary">
Coming Soon
</span>
)}
</div>
<p className="text-sm text-bolt-elements-textSecondary mb-2">{provider.description}</p>
<div className="flex flex-wrap gap-2">
{provider.features.slice(0, 3).map((feature, index) => (
<span
key={index}
className="text-xs px-2 py-1 rounded bg-bolt-elements-background-depth-1 text-bolt-elements-textTertiary"
>
{feature}
</span>
))}
{provider.features.length > 3 && (
<span className="text-xs px-2 py-1 text-bolt-elements-textTertiary">
+{provider.features.length - 3} more
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
);
}
const provider = providers.find((p) => p.id === selectedProvider);
if (!provider) {
return null;
}
// If provider is not connected, show connection form
if (!provider.connected) {
if (selectedProvider === 'netlify') {
return (
<NetlifyConnectForm
onSuccess={() => {
handleDeploy('netlify');
}}
/>
);
}
// Add Vercel connection form here if needed
return <div>Vercel connection form coming soon...</div>;
}
// If connected, show deployment confirmation
return (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-bolt-elements-background-depth-2 rounded-lg">
<span
className={classNames(
provider.iconClass,
provider.iconColor || 'text-bolt-elements-textPrimary',
'text-3xl',
)}
/>
<div className="flex-1">
<h3 className="font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
<p className="text-sm text-bolt-elements-textSecondary">Ready to deploy to your {provider.name} account</p>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-500">Connected</span>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 space-y-3">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Deployment Features:</h4>
<ul className="space-y-2">
{provider.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-bolt-elements-textSecondary">
<span className="i-ph:check-circle text-green-500 mt-0.5" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<div className="flex gap-3">
<button
onClick={() => setSelectedProvider(null)}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"
>
Back
</button>
<button
onClick={() => handleDeploy(selectedProvider as 'netlify' | 'vercel' | 'github')}
disabled={isDeploying}
className={classNames(
'flex-1 px-4 py-2 rounded-lg font-medium transition-all',
'bg-accent-500 text-white',
'hover:bg-accent-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2',
)}
>
{isDeploying ? (
<>
<span className="i-svg-spinners:3-dots-scale" />
Deploying...
</>
) : (
<>
<span className="i-ph:rocket-launch" />
Deploy Now
</>
)}
</button>
</div>
</div>
);
};
return (
<>
<RadixDialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog className="max-w-2xl max-h-[90vh] flex flex-col">
<div className="p-6 flex flex-col max-h-[90vh]">
<div className="flex-shrink-0">
<DialogTitle className="text-xl font-bold mb-1">Deploy Your Project</DialogTitle>
<DialogDescription className="mb-6">
Choose a deployment platform to publish your project to the web
</DialogDescription>
</div>
<div className="flex-1 overflow-y-auto min-h-0 pr-2 -mr-2 scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent hover:scrollbar-thumb-bolt-elements-textTertiary">
{renderProviderContent()}
</div>
{!selectedProvider && (
<div className="flex-shrink-0 mt-6 pt-6 border-t border-bolt-elements-borderColor">
<button
onClick={onClose}
className="w-full px-4 py-2 rounded-lg border border-bolt-elements-borderColor text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"
>
Cancel
</button>
</div>
)}
</div>
</Dialog>
</RadixDialog.Root>
{/* GitHub Deployment Dialog */}
{showGitHubDialog && githubFiles && (
<GitHubDeploymentDialog
isOpen={showGitHubDialog}
onClose={() => setShowGitHubDialog(false)}
projectName={githubProjectName}
files={githubFiles}
/>
)}
</>
);
};

View File

@@ -1,210 +0,0 @@
/**
* Enhanced Deploy Button with Quick Deploy Option
* Contributed by Keoma Wright
*
* This component provides both authenticated and quick deployment options
*/
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useStore } from '@nanostores/react';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { workbenchStore } from '~/lib/stores/workbench';
import { streamingState } from '~/lib/stores/streaming';
import { classNames } from '~/utils/classNames';
import { useState } from 'react';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
import { QuickNetlifyDeploy } from '~/components/deploy/QuickNetlifyDeploy.client';
import * as Dialog from '@radix-ui/react-dialog';
interface EnhancedDeployButtonProps {
onVercelDeploy?: () => Promise<void>;
onNetlifyDeploy?: () => Promise<void>;
}
export const EnhancedDeployButton = ({ onVercelDeploy, onNetlifyDeploy }: EnhancedDeployButtonProps) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'quick' | null>(null);
const [showQuickDeploy, setShowQuickDeploy] = useState(false);
const isStreaming = useStore(streamingState);
const { handleVercelDeploy } = useVercelDeploy();
const { handleNetlifyDeploy } = useNetlifyDeploy();
const handleVercelDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('vercel');
try {
if (onVercelDeploy) {
await onVercelDeploy();
} else {
await handleVercelDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleNetlifyDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('netlify');
try {
if (onNetlifyDeploy) {
await onNetlifyDeploy();
} else {
await handleNetlifyDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return (
<>
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
<DropdownMenu.Root>
<DropdownMenu.Trigger
disabled={isDeploying || !activePreview || isStreaming}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
{isDeploying ? `Deploying${deployingTo ? ` to ${deployingTo}` : ''}...` : 'Deploy'}
<span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'z-[250]',
'bg-bolt-elements-background-depth-2',
'rounded-lg shadow-lg',
'border border-bolt-elements-borderColor',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
{/* Quick Deploy Option - Always Available */}
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview,
},
)}
disabled={isDeploying || !activePreview}
onClick={() => setShowQuickDeploy(true)}
>
<div className="relative">
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
alt="Netlify Quick Deploy"
/>
<span className="absolute -top-1 -right-1 bg-green-500 text-white text-[8px] px-1 rounded">NEW</span>
</div>
<span className="mx-auto font-medium">Quick Deploy to Netlify (No Login)</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-bolt-elements-borderColor my-1" />
{/* Authenticated Netlify Deploy */}
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !netlifyConn.user,
},
)}
disabled={isDeploying || !activePreview || !netlifyConn.user}
onClick={handleNetlifyDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
</span>
{netlifyConn.user && <NetlifyDeploymentLink />}
</DropdownMenu.Item>
{/* Vercel Deploy */}
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !vercelConn.user,
},
)}
disabled={isDeploying || !activePreview || !vercelConn.user}
onClick={handleVercelDeployClick}
>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
{vercelConn.user && <VercelDeploymentLink />}
</DropdownMenu.Item>
{/* Cloudflare - Coming Soon */}
<DropdownMenu.Item
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="cloudflare"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/* Quick Deploy Dialog */}
<Dialog.Root open={showQuickDeploy} onOpenChange={setShowQuickDeploy}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-[999] animate-in fade-in-0" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[1000] w-full max-w-2xl animate-in fade-in-0 zoom-in-95">
<div className="bg-bolt-elements-background rounded-lg shadow-xl border border-bolt-elements-borderColor">
<div className="flex items-center justify-between p-4 border-b border-bolt-elements-borderColor">
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Quick Deploy to Netlify</h2>
<Dialog.Close className="p-1 rounded hover:bg-bolt-elements-item-backgroundActive transition-colors">
<span className="i-ph:x text-lg text-bolt-elements-textSecondary" />
</Dialog.Close>
</div>
<div className="p-4">
<QuickNetlifyDeploy />
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</>
);
};

View File

@@ -1,362 +0,0 @@
/**
* Quick Netlify Deployment Component
* Contributed by Keoma Wright
*
* This component provides a streamlined one-click deployment to Netlify
* with automatic build detection and configuration.
*/
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/lib/stores/workbench';
import { webcontainer } from '~/lib/webcontainer';
import { path } from '~/utils/path';
import { chatId } from '~/lib/persistence/useChatHistory';
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
interface QuickDeployConfig {
framework?: 'react' | 'vue' | 'angular' | 'svelte' | 'next' | 'nuxt' | 'gatsby' | 'static';
buildCommand?: string;
outputDirectory?: string;
nodeVersion?: string;
}
export function QuickNetlifyDeploy() {
const [isDeploying, setIsDeploying] = useState(false);
const [deployUrl, setDeployUrl] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const currentChatId = useStore(chatId);
const detectFramework = async (): Promise<QuickDeployConfig> => {
try {
const container = await webcontainer;
// Read package.json to detect framework
let packageJson: any = {};
try {
const packageContent = await container.fs.readFile('/package.json', 'utf-8');
packageJson = JSON.parse(packageContent);
} catch {
console.log('No package.json found, assuming static site');
return {
framework: 'static',
buildCommand: '',
outputDirectory: '/',
nodeVersion: '18',
};
}
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
const scripts = packageJson.scripts || {};
// Detect framework based on dependencies
const config: QuickDeployConfig = {
nodeVersion: '18',
};
if (deps.next) {
config.framework = 'next';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = '.next';
} else if (deps.nuxt || deps.nuxt3) {
config.framework = 'nuxt';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = '.output/public';
} else if (deps.gatsby) {
config.framework = 'gatsby';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'public';
} else if (deps['@angular/core']) {
config.framework = 'angular';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'dist';
} else if (deps.vue) {
config.framework = 'vue';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'dist';
} else if (deps.svelte) {
config.framework = 'svelte';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'public';
} else if (deps.react) {
config.framework = 'react';
config.buildCommand = scripts.build || 'npm run build';
config.outputDirectory = 'build';
// Check for Vite
if (deps.vite) {
config.outputDirectory = 'dist';
}
} else {
config.framework = 'static';
config.buildCommand = scripts.build || '';
config.outputDirectory = '/';
}
return config;
} catch (error) {
console.error('Error detecting framework:', error);
return {
framework: 'static',
buildCommand: '',
outputDirectory: '/',
nodeVersion: '18',
};
}
};
const handleQuickDeploy = async (): Promise<string | null> => {
if (!currentChatId) {
toast.error('No active project found');
return null;
}
try {
setIsDeploying(true);
setDeployUrl(null);
const artifact = workbenchStore.firstArtifact;
if (!artifact) {
throw new Error('No active project found');
}
// Detect framework and configuration
const config = await detectFramework();
toast.info(`Detected ${config.framework || 'static'} project. Starting deployment...`);
// Create deployment artifact for visual feedback
const deploymentId = `quick-deploy-${Date.now()}`;
workbenchStore.addArtifact({
id: deploymentId,
messageId: deploymentId,
title: 'Quick Netlify Deployment',
type: 'standalone',
});
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
// Build the project if needed
if (config.buildCommand) {
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'netlify' });
const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = {
messageId: 'quick-netlify-build',
artifactId: artifact.id,
actionId,
action: {
type: 'build' as const,
content: config.buildCommand,
},
};
artifact.runner.addAction(actionData);
await artifact.runner.runAction(actionData);
if (!artifact.runner.buildOutput) {
deployArtifact.runner.handleDeployAction('building', 'failed', {
error: 'Build failed. Check the terminal for details.',
source: 'netlify',
});
throw new Error('Build failed');
}
}
// Prepare deployment
deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'netlify' });
const container = await webcontainer;
// Determine the output directory
let outputPath = config.outputDirectory || '/';
if (artifact.runner.buildOutput && artifact.runner.buildOutput.path) {
outputPath = artifact.runner.buildOutput.path.replace('/home/project', '');
}
// Collect files for deployment
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {};
try {
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
// Skip node_modules and other build artifacts
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.cache') {
continue;
}
if (entry.isFile()) {
try {
const content = await container.fs.readFile(fullPath, 'utf-8');
const deployPath = fullPath.replace(outputPath, '');
files[deployPath] = content;
} catch (e) {
console.warn(`Could not read file ${fullPath}:`, e);
}
} else if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath);
Object.assign(files, subFiles);
}
}
} catch (e) {
console.error(`Error reading directory ${dirPath}:`, e);
}
return files;
}
const fileContents = await getAllFiles(outputPath);
// Create netlify.toml configuration
const netlifyConfig = `
[build]
publish = "${config.outputDirectory || '/'}"
${config.buildCommand ? `command = "${config.buildCommand}"` : ''}
[build.environment]
NODE_VERSION = "${config.nodeVersion}"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
X-Content-Type-Options = "nosniff"
`;
fileContents['/netlify.toml'] = netlifyConfig;
// Deploy to Netlify using the quick deploy endpoint
const response = await fetch('/api/netlify-quick-deploy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: fileContents,
chatId: currentChatId,
framework: config.framework,
}),
});
const data = (await response.json()) as { success: boolean; url?: string; siteId?: string; error?: string };
if (!response.ok || !data.success) {
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
error: data.error || 'Deployment failed',
source: 'netlify',
});
throw new Error(data.error || 'Deployment failed');
}
// Deployment successful
setDeployUrl(data.url || null);
deployArtifact.runner.handleDeployAction('complete', 'complete', {
url: data.url || '',
source: 'netlify',
});
toast.success('Deployment successful! Your app is live.');
// Store deployment info
if (data.siteId) {
localStorage.setItem(`netlify-quick-site-${currentChatId}`, data.siteId);
}
return data.url || null;
} catch (error) {
console.error('Quick deploy error:', error);
toast.error(error instanceof Error ? error.message : 'Deployment failed');
return null;
} finally {
setIsDeploying(false);
}
};
return (
<div className="flex flex-col gap-4 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img className="w-5 h-5" src="https://cdn.simpleicons.org/netlify" alt="Netlify" />
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Quick Deploy to Netlify</h3>
</div>
{deployUrl && (
<a
href={deployUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-bolt-elements-link-text hover:text-bolt-elements-link-textHover underline"
>
View Live Site
</a>
)}
</div>
<p className="text-sm text-bolt-elements-textSecondary">
Deploy your project to Netlify instantly with automatic framework detection and configuration.
</p>
<button
onClick={handleQuickDeploy}
disabled={isDeploying}
className="px-6 py-3 rounded-lg bg-accent-500 text-white font-medium hover:bg-accent-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center gap-2"
>
{isDeploying ? (
<>
<span className="i-ph:spinner-gap animate-spin w-5 h-5" />
Deploying...
</>
) : (
<>
<span className="i-ph:rocket-launch w-5 h-5" />
Deploy Now
</>
)}
</button>
{deployUrl && (
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<p className="text-sm text-green-600 dark:text-green-400">
Your app is live at:{' '}
<a href={deployUrl} target="_blank" rel="noopener noreferrer" className="underline font-medium">
{deployUrl}
</a>
</p>
</div>
)}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors flex items-center gap-1"
>
<span className={`i-ph:caret-right transform transition-transform ${showAdvanced ? 'rotate-90' : ''}`} />
Advanced Options
</button>
{showAdvanced && (
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-2 text-sm text-bolt-elements-textSecondary space-y-2">
<p> Automatic framework detection (React, Vue, Next.js, etc.)</p>
<p> Smart build command configuration</p>
<p> Optimized output directory selection</p>
<p> SSL/HTTPS enabled by default</p>
<p> Global CDN distribution</p>
</div>
)}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { classNames } from '~/utils/classNames';
import { HeaderActionButtons } from './HeaderActionButtons.client';
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
export function Header({ children }: { children?: React.ReactNode }) {
export function Header() {
const chat = useStore(chatStore);
return (
@@ -37,8 +37,6 @@ export function Header({ children }: { children?: React.ReactNode }) {
</ClientOnly>
</>
)}
{!chat.started && <div className="flex-1" />}
{children}
</header>
);
}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/lib/stores/workbench';
import { DeployButton } from '~/components/deploy/DeployButton';
import { MultiUserToggle } from '~/components/multiuser/MultiUserToggle';
interface HeaderActionButtonsProps {
chatStarted: boolean;
@@ -16,10 +15,7 @@ export function HeaderActionButtons({ chatStarted: _chatStarted }: HeaderActionB
const shouldShowButtons = activePreview;
return (
<div className="flex items-center gap-2">
{/* Multi-User Sessions Toggle (Bolt.gives Exclusive) */}
<MultiUserToggle />
<div className="flex items-center gap-1">
{/* Deploy Button */}
{shouldShowButtons && <DeployButton />}

View File

@@ -1,176 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from '@remix-run/react';
import { useStore } from '@nanostores/react';
import { authStore, logout } from '~/lib/stores/auth';
import { motion, AnimatePresence } from 'framer-motion';
import { classNames } from '~/utils/classNames';
export function UserMenu() {
const navigate = useNavigate();
const authState = useStore(authStore);
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleLogout = async () => {
await logout();
navigate('/auth');
};
const handleManageUsers = () => {
setIsOpen(false);
navigate('/admin/users');
};
const handleSettings = () => {
setIsOpen(false);
// Open settings modal or navigate to settings
};
if (!authState.isAuthenticated || !authState.user) {
return null;
}
return (
<div ref={menuRef} className="relative">
{/* User Avatar Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={classNames(
'flex items-center gap-2 px-3 py-2 rounded-lg',
'hover:bg-bolt-elements-background-depth-2',
'transition-colors',
)}
>
<div className="w-8 h-8 rounded-full bg-bolt-elements-background-depth-2 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
{authState.user.avatar ? (
<img src={authState.user.avatar} alt={authState.user.firstName} className="w-full h-full object-cover" />
) : (
<span className="text-sm font-medium text-bolt-elements-textPrimary">
{authState.user.firstName[0].toUpperCase()}
</span>
)}
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-medium text-bolt-elements-textPrimary">{authState.user.firstName}</p>
<p className="text-xs text-bolt-elements-textSecondary">@{authState.user.username}</p>
</div>
<span
className={classNames(
'i-ph:caret-down text-bolt-elements-textSecondary transition-transform',
isOpen ? 'rotate-180' : '',
)}
/>
</button>
{/* Dropdown Menu */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className={classNames(
'absolute right-0 mt-2 w-64',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'rounded-lg shadow-lg',
'overflow-hidden',
'z-50',
)}
>
{/* User Info */}
<div className="p-4 border-b border-bolt-elements-borderColor">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-bolt-elements-background-depth-2 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
{authState.user.avatar ? (
<img
src={authState.user.avatar}
alt={authState.user.firstName}
className="w-full h-full object-cover"
/>
) : (
<span className="text-lg font-medium text-bolt-elements-textPrimary">
{authState.user.firstName[0].toUpperCase()}
</span>
)}
</div>
<div className="flex-1">
<p className="font-medium text-bolt-elements-textPrimary">{authState.user.firstName}</p>
<p className="text-sm text-bolt-elements-textSecondary">@{authState.user.username}</p>
</div>
</div>
</div>
{/* Menu Items */}
<div className="py-2">
<button
onClick={handleSettings}
className={classNames(
'w-full px-4 py-2 text-left',
'text-sm text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-2',
'transition-colors',
'flex items-center gap-3',
)}
>
<span className="i-ph:gear text-lg" />
<span>Settings</span>
</button>
<button
onClick={handleManageUsers}
className={classNames(
'w-full px-4 py-2 text-left',
'text-sm text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-2',
'transition-colors',
'flex items-center gap-3',
)}
>
<span className="i-ph:users text-lg" />
<span>Manage Users</span>
</button>
<div className="my-1 border-t border-bolt-elements-borderColor" />
<button
onClick={handleLogout}
className={classNames(
'w-full px-4 py-2 text-left',
'text-sm text-red-500',
'hover:bg-red-500/10',
'transition-colors',
'flex items-center gap-3',
)}
>
<span className="i-ph:sign-out text-lg" />
<span>Sign Out</span>
</button>
</div>
{/* Footer */}
<div className="px-4 py-2 bg-bolt-elements-background-depth-2 border-t border-bolt-elements-borderColor">
<p className="text-xs text-bolt-elements-textTertiary">
Member since {new Date(authState.user.createdAt).toLocaleDateString()}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import React, { useState, useCallback } from 'react';
import { ImportProjectDialog } from './ImportProjectDialog';
import { workbenchStore } from '~/lib/stores/workbench';
import { toast } from 'react-toastify';
import { useHotkeys } from 'react-hotkeys-hook';
export const ImportProjectButton: React.FC = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Add keyboard shortcut
useHotkeys('ctrl+shift+i, cmd+shift+i', (e) => {
e.preventDefault();
setIsDialogOpen(true);
});
const handleImport = useCallback(async (files: Map<string, string>) => {
try {
console.log('[ImportProject] Starting import of', files.size, 'files');
// Add files to workbench
for (const [path, content] of files.entries()) {
// Ensure path starts with /
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
console.log('[ImportProject] Adding file:', normalizedPath);
// Add file to workbench file system
workbenchStore.files.setKey(normalizedPath, {
type: 'file',
content,
isBinary: false,
});
}
// Open the first file in the editor if any
const firstFile = Array.from(files.keys())[0];
if (firstFile) {
const normalizedPath = firstFile.startsWith('/') ? firstFile : `/${firstFile}`;
workbenchStore.setSelectedFile(normalizedPath);
}
toast.success(`Successfully imported ${files.size} files`, {
position: 'bottom-right',
autoClose: 3000,
});
setIsDialogOpen(false);
} catch (error) {
console.error('[ImportProject] Import failed:', error);
toast.error('Failed to import project files', {
position: 'bottom-right',
autoClose: 5000,
});
}
}, []);
return (
<>
<button
onClick={() => setIsDialogOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text transition-colors duration-200"
title="Import existing project (Ctrl+Shift+I)"
>
<div className="i-ph:upload-simple text-lg" />
<span className="text-sm font-medium">Import Project</span>
</button>
<ImportProjectDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} onImport={handleImport} />
</>
);
};

View File

@@ -1,567 +0,0 @@
import React, { useState, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import JSZip from 'jszip';
import { toast } from 'react-toastify';
import * as RadixDialog from '@radix-ui/react-dialog';
import { Dialog, DialogTitle, DialogDescription } from '~/components/ui/Dialog';
import { classNames } from '~/utils/classNames';
interface ImportProjectDialogProps {
isOpen: boolean;
onClose: () => void;
onImport?: (files: Map<string, string>) => void;
}
interface FileStructure {
[path: string]: string | ArrayBuffer;
}
interface ImportStats {
totalFiles: number;
totalSize: number;
fileTypes: Map<string, number>;
directories: Set<string>;
}
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB max per file
const MAX_TOTAL_SIZE = 200 * 1024 * 1024; // 200MB max total
const IGNORED_PATTERNS = [
/node_modules\//,
/\.git\//,
/\.next\//,
/dist\//,
/build\//,
/\.cache\//,
/\.vscode\//,
/\.idea\//,
/\.DS_Store$/,
/Thumbs\.db$/,
/\.env\.local$/,
/\.env\.production$/,
];
const BINARY_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
'.ico',
'.pdf',
'.zip',
'.tar',
'.gz',
'.rar',
'.mp3',
'.mp4',
'.avi',
'.mov',
'.exe',
'.dll',
'.so',
'.dylib',
'.woff',
'.woff2',
'.ttf',
'.eot',
];
export const ImportProjectDialog: React.FC<ImportProjectDialogProps> = ({ isOpen, onClose, onImport }) => {
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importStats, setImportStats] = useState<ImportStats | null>(null);
const [selectedFiles, setSelectedFiles] = useState<FileStructure>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
const resetState = useCallback(() => {
setSelectedFiles({});
setImportStats(null);
setImportProgress(0);
setErrorMessage(null);
setIsProcessing(false);
}, []);
const shouldIgnoreFile = (path: string): boolean => {
return IGNORED_PATTERNS.some((pattern) => pattern.test(path));
};
const isBinaryFile = (filename: string): boolean => {
return BINARY_EXTENSIONS.some((ext) => filename.toLowerCase().endsWith(ext));
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const processZipFile = async (file: File): Promise<FileStructure> => {
const zip = new JSZip();
const zipData = await zip.loadAsync(file);
const files: FileStructure = {};
const stats: ImportStats = {
totalFiles: 0,
totalSize: 0,
fileTypes: new Map(),
directories: new Set(),
};
const filePromises: Promise<void>[] = [];
zipData.forEach((relativePath, zipEntry) => {
if (!zipEntry.dir && !shouldIgnoreFile(relativePath)) {
const promise = (async () => {
try {
const content = await zipEntry.async(isBinaryFile(relativePath) ? 'arraybuffer' : 'string');
files[relativePath] = content;
stats.totalFiles++;
// Use a safe method to get uncompressed size
const size = (zipEntry as any)._data?.uncompressedSize || 0;
stats.totalSize += size;
const ext = relativePath.split('.').pop() || 'unknown';
stats.fileTypes.set(ext, (stats.fileTypes.get(ext) || 0) + 1);
const dir = relativePath.substring(0, relativePath.lastIndexOf('/'));
if (dir) {
stats.directories.add(dir);
}
setImportProgress((prev) => Math.min(prev + 100 / Object.keys(zipData.files).length, 100));
} catch (err) {
console.error(`Failed to process ${relativePath}:`, err);
}
})();
filePromises.push(promise);
}
});
await Promise.all(filePromises);
setImportStats(stats);
return files;
};
const processFileList = async (fileList: FileList): Promise<FileStructure> => {
const files: FileStructure = {};
const stats: ImportStats = {
totalFiles: 0,
totalSize: 0,
fileTypes: new Map(),
directories: new Set(),
};
let totalSize = 0;
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const path = (file as any).webkitRelativePath || file.name;
if (shouldIgnoreFile(path)) {
continue;
}
if (file.size > MAX_FILE_SIZE) {
toast.warning(`Skipping ${file.name}: File too large (${formatFileSize(file.size)})`);
continue;
}
totalSize += file.size;
if (totalSize > MAX_TOTAL_SIZE) {
toast.error('Total size exceeds 200MB limit');
break;
}
try {
const content = await (isBinaryFile(file.name) ? file.arrayBuffer() : file.text());
files[path] = content;
stats.totalFiles++;
stats.totalSize += file.size;
const ext = file.name.split('.').pop() || 'unknown';
stats.fileTypes.set(ext, (stats.fileTypes.get(ext) || 0) + 1);
const dir = path.substring(0, path.lastIndexOf('/'));
if (dir) {
stats.directories.add(dir);
}
setImportProgress(((i + 1) / fileList.length) * 100);
} catch (err) {
console.error(`Failed to read ${file.name}:`, err);
}
}
setImportStats(stats);
return files;
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) {
return;
}
setIsProcessing(true);
setErrorMessage(null);
setImportProgress(0);
try {
let processedFiles: FileStructure = {};
if (files.length === 1 && files[0].name.endsWith('.zip')) {
processedFiles = await processZipFile(files[0]);
} else {
processedFiles = await processFileList(files);
}
if (Object.keys(processedFiles).length === 0) {
toast.warning('No valid files found to import');
setIsProcessing(false);
return;
}
setSelectedFiles(processedFiles);
toast.info(`Ready to import ${Object.keys(processedFiles).length} files`);
} catch (error) {
console.error('Error processing files:', error);
setErrorMessage(error instanceof Error ? error.message : 'Failed to process files');
toast.error('Failed to process files');
} finally {
setIsProcessing(false);
setImportProgress(0);
}
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const input = fileInputRef.current;
if (input) {
const dataTransfer = new DataTransfer();
Array.from(files).forEach((file) => dataTransfer.items.add(file));
input.files = dataTransfer.files;
handleFileSelect({ target: input } as any);
}
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target) {
setIsDragging(false);
}
}, []);
const getFileExtension = (filename: string): string => {
const parts = filename.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'file';
};
const getFileIcon = (filename: string): string => {
const ext = getFileExtension(filename);
const iconMap: { [key: string]: string } = {
js: 'i-vscode-icons:file-type-js',
jsx: 'i-vscode-icons:file-type-reactjs',
ts: 'i-vscode-icons:file-type-typescript',
tsx: 'i-vscode-icons:file-type-reactts',
css: 'i-vscode-icons:file-type-css',
scss: 'i-vscode-icons:file-type-scss',
html: 'i-vscode-icons:file-type-html',
json: 'i-vscode-icons:file-type-json',
md: 'i-vscode-icons:file-type-markdown',
py: 'i-vscode-icons:file-type-python',
vue: 'i-vscode-icons:file-type-vue',
svg: 'i-vscode-icons:file-type-svg',
git: 'i-vscode-icons:file-type-git',
folder: 'i-vscode-icons:default-folder',
};
return iconMap[ext] || 'i-vscode-icons:default-file';
};
const handleImportClick = useCallback(async () => {
if (Object.keys(selectedFiles).length === 0) {
return;
}
setIsProcessing(true);
try {
const fileMap = new Map<string, string>();
for (const [path, content] of Object.entries(selectedFiles)) {
if (typeof content === 'string') {
fileMap.set(path, content);
} else if (content instanceof ArrayBuffer) {
// Convert ArrayBuffer to base64 string for binary files
const bytes = new Uint8Array(content);
const binary = String.fromCharCode(...bytes);
const base64 = btoa(binary);
fileMap.set(path, base64);
}
}
if (onImport) {
// Use the provided onImport callback
await onImport(fileMap);
}
toast.success(`Successfully imported ${importStats?.totalFiles || 0} files`, {
position: 'bottom-right',
autoClose: 3000,
});
resetState();
onClose();
} catch (error) {
toast.error('Failed to import project', { position: 'bottom-right' });
setErrorMessage(error instanceof Error ? error.message : 'Import failed');
} finally {
setIsProcessing(false);
}
}, [selectedFiles, importStats, onImport, onClose, resetState]);
return (
<RadixDialog.Root open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
<Dialog className="max-w-3xl" showCloseButton={false}>
<div className="p-6">
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<div className="i-ph:upload-duotone text-3xl text-accent-500" />
Import Existing Project
</DialogTitle>
<DialogDescription>
Upload your project files or drag and drop them here. Supports individual files, folders, or ZIP archives.
</DialogDescription>
<div className="mt-6">
<AnimatePresence mode="wait">
{!Object.keys(selectedFiles).length ? (
<motion.div
key="dropzone"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<div
ref={dropZoneRef}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={classNames(
'relative border-2 border-dashed rounded-xl p-12 text-center transition-all duration-200',
isDragging
? 'border-accent-500 bg-accent-500/10 scale-[1.02]'
: 'border-bolt-elements-borderColor hover:border-accent-400/50',
isProcessing ? 'pointer-events-none opacity-50' : '',
)}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".zip,*"
onChange={handleFileSelect}
className="hidden"
{...({ webkitdirectory: 'true', directory: 'true' } as any)}
/>
<div className="space-y-4">
<div className="flex justify-center">
<motion.div
animate={isDragging ? { scale: 1.1, rotate: 5 } : { scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 300 }}
className="i-ph:cloud-arrow-up-duotone text-6xl text-accent-500"
/>
</div>
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-2">
{isDragging ? 'Drop your project here' : 'Drag & Drop your project'}
</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Support for folders, multiple files, or ZIP archives
</p>
</div>
<div className="flex gap-3 justify-center">
<button
onClick={() => fileInputRef.current?.click()}
disabled={isProcessing}
className="px-6 py-2.5 bg-accent-500 hover:bg-accent-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Browse Files
</button>
<button
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip';
input.onchange = (e) => {
const target = e.target as HTMLInputElement;
if (target.files) {
handleFileSelect({ target } as any);
}
};
input.click();
}}
disabled={isProcessing}
className="px-6 py-2.5 bg-transparent border border-bolt-elements-borderColor hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textPrimary rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Upload ZIP
</button>
</div>
</div>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-bolt-elements-background-depth-1/80 rounded-xl">
<div className="text-center">
<div className="i-svg-spinners:3-dots-scale text-4xl text-accent-500 mb-2" />
<p className="text-sm text-bolt-elements-textSecondary">
Processing files... {Math.round(importProgress)}%
</p>
</div>
</div>
)}
</div>
{errorMessage && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg"
>
<p className="text-sm text-red-400">{errorMessage}</p>
</motion.div>
)}
</motion.div>
) : (
<motion.div
key="preview"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
{importStats && (
<div className="grid grid-cols-3 gap-4 p-4 bg-bolt-elements-item-backgroundActive rounded-lg">
<div>
<p className="text-xs text-bolt-elements-textSecondary">Total Files</p>
<p className="text-lg font-semibold text-bolt-elements-textPrimary">{importStats.totalFiles}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Total Size</p>
<p className="text-lg font-semibold text-bolt-elements-textPrimary">
{formatFileSize(importStats.totalSize)}
</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Directories</p>
<p className="text-lg font-semibold text-bolt-elements-textPrimary">
{importStats.directories.size}
</p>
</div>
</div>
)}
<div className="border border-bolt-elements-borderColor rounded-lg overflow-hidden">
<div className="bg-bolt-elements-background-depth-2 px-4 py-2 border-b border-bolt-elements-borderColor">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Files to Import</h4>
</div>
<div className="max-h-64 overflow-y-auto">
{Object.keys(selectedFiles)
.slice(0, 50)
.map((path, index) => (
<div
key={index}
className="flex items-center gap-2 px-4 py-2 hover:bg-bolt-elements-item-backgroundActive transition-colors"
>
<div className={getFileIcon(path)} />
<span className="text-sm text-bolt-elements-textPrimary truncate">{path}</span>
</div>
))}
{Object.keys(selectedFiles).length > 50 && (
<div className="px-4 py-2 text-sm text-bolt-elements-textSecondary">
... and {Object.keys(selectedFiles).length - 50} more files
</div>
)}
</div>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
resetState();
}}
className="px-4 py-2 text-sm text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
>
Clear Selection
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-bolt-elements-borderColor rounded-lg hover:bg-bolt-elements-item-backgroundActive transition-colors"
>
Cancel
</button>
<button
onClick={handleImportClick}
disabled={isProcessing}
className="px-6 py-2 bg-accent-500 hover:bg-accent-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<>
<span className="i-svg-spinners:3-dots-scale mr-2" />
Importing...
</>
) : (
`Import ${Object.keys(selectedFiles).length} Files`
)}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</Dialog>
</RadixDialog.Root>
);
};

View File

@@ -1,346 +0,0 @@
import React, { useState, useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import { Dialog } from '~/components/ui/Dialog';
import * as RadixDialog from '@radix-ui/react-dialog';
import { Button } from '~/components/ui/Button';
import { toast } from 'react-toastify';
import Cookies from 'js-cookie';
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'developer' | 'viewer' | 'guest';
status: 'active' | 'idle' | 'offline';
lastActivity: string;
avatar?: string;
}
interface Session {
userId: string;
sessionId: string;
startTime: string;
lastActivity: string;
ipAddress: string;
device: string;
}
export const MultiUserSessionManager: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [activeUsers, setActiveUsers] = useState<User[]>([]);
const [sessions, setSessions] = useState<Session[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'developer' | 'viewer'>('developer');
useEffect(() => {
loadSessionData();
const interval = setInterval(loadSessionData, 5000);
// Refresh every 5 seconds
return () => clearInterval(interval);
}, []);
const loadSessionData = async () => {
try {
// Get current user
const token = Cookies.get('auth_token');
if (token) {
const userResponse = await fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${token}` },
});
if (userResponse.ok) {
const userData = await userResponse.json();
setCurrentUser(userData as User);
}
}
// Get active users (mock data for demo)
const mockUsers: User[] = [
{
id: '1',
email: 'admin@example.com',
name: 'Admin User',
role: 'admin',
status: 'active',
lastActivity: new Date().toISOString(),
},
{
id: '2',
email: 'dev@example.com',
name: 'Developer',
role: 'developer',
status: 'idle',
lastActivity: new Date(Date.now() - 5 * 60000).toISOString(),
},
];
setActiveUsers(mockUsers);
// Get active sessions (mock data for demo)
const mockSessions: Session[] = [
{
userId: '1',
sessionId: 'session-1',
startTime: new Date(Date.now() - 30 * 60000).toISOString(),
lastActivity: new Date().toISOString(),
ipAddress: '192.168.1.1',
device: 'Chrome on Windows',
},
{
userId: '2',
sessionId: 'session-2',
startTime: new Date(Date.now() - 60 * 60000).toISOString(),
lastActivity: new Date(Date.now() - 5 * 60000).toISOString(),
ipAddress: '192.168.1.2',
device: 'Safari on Mac',
},
];
setSessions(mockSessions);
} catch (error) {
console.error('Failed to load session data:', error);
}
};
const handleInviteUser = async () => {
if (!inviteEmail.trim()) {
toast.error('Please enter an email address');
return;
}
try {
// Send invitation
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: inviteEmail,
role: inviteRole,
action: 'invite',
}),
});
if (response.ok) {
toast.success(`Invitation sent to ${inviteEmail}`);
setInviteEmail('');
} else {
toast.error('Failed to send invitation');
}
} catch (error) {
console.error('Invite error:', error);
toast.error('Failed to send invitation');
}
};
const handleRemoveUser = async (userId: string) => {
if (!window.confirm('Are you sure you want to remove this user?')) {
return;
}
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
});
if (response.ok) {
toast.success('User removed successfully');
loadSessionData();
} else {
toast.error('Failed to remove user');
}
} catch (error) {
console.error('Remove user error:', error);
toast.error('Failed to remove user');
}
};
/**
* const handleTerminateSession = async (_sessionId: string) => {
* if (!window.confirm('Are you sure you want to terminate this session?')) {
* return;
* }
*
* try {
* // Terminate session
* toast.success('Session terminated');
* loadSessionData();
* } catch (error) {
* console.error('Terminate session error:', error);
* toast.error('Failed to terminate session');
* }
* };
*/
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'developer':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'viewer':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'guest':
return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
default:
return 'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'active':
return 'i-ph:circle-fill text-green-400';
case 'idle':
return 'i-ph:circle-fill text-yellow-400';
case 'offline':
return 'i-ph:circle-fill text-gray-400';
default:
return 'i-ph:circle text-gray-400';
}
};
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) {
return 'Just now';
}
if (diff < 3600) {
return `${Math.floor(diff / 60)} min ago`;
}
if (diff < 86400) {
return `${Math.floor(diff / 3600)} hours ago`;
}
return `${Math.floor(diff / 86400)} days ago`;
};
const multiUserEnabled = localStorage.getItem('multiUserEnabled') === 'true';
if (!multiUserEnabled) {
return null;
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:bg-bolt-elements-background-depth-2 transition-all"
title="Manage Sessions"
>
<span className="i-ph:users-three text-sm text-bolt-elements-textSecondary" />
<span className="text-xs font-medium text-bolt-elements-textPrimary">{activeUsers.length} Active</span>
</button>
{isOpen && (
<RadixDialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog className="max-w-4xl" onClose={() => setIsOpen(false)}>
<div className="p-6">
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary mb-6">Multi-User Session Manager</h2>
{/* Tabs */}
<div className="flex gap-4 mb-6 border-b border-bolt-elements-borderColor">
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textPrimary border-b-2 border-blue-500">
Active Users ({activeUsers.length})
</button>
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary">
Sessions ({sessions.length})
</button>
<button className="px-4 py-2 text-sm font-medium text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary">
Invite Users
</button>
</div>
{/* Active Users List */}
<div className="space-y-3 mb-6">
{activeUsers.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-4 bg-bolt-elements-background-depth-2 rounded-lg"
>
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
<span className="text-white font-semibold">{user.name.charAt(0).toUpperCase()}</span>
</div>
{/* User Info */}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-bolt-elements-textPrimary">{user.name}</span>
<span className={classNames('text-xs', getStatusIcon(user.status))} />
<span
className={classNames(
'px-2 py-0.5 text-xs font-medium rounded-full border',
getRoleBadgeColor(user.role),
)}
>
{user.role}
</span>
</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-bolt-elements-textSecondary">{user.email}</span>
<span className="text-xs text-bolt-elements-textTertiary">
Active {formatTimeAgo(user.lastActivity)}
</span>
</div>
</div>
</div>
{/* Actions */}
{currentUser?.role === 'admin' && user.id !== currentUser.id && (
<div className="flex items-center gap-2">
<button
onClick={() => handleRemoveUser(user.id)}
className="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
title="Remove User"
>
<span className="i-ph:trash text-sm" />
</button>
</div>
)}
</div>
))}
</div>
{/* Invite User Section */}
<div className="p-4 bg-bolt-elements-background-depth-2 rounded-lg">
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Invite New User</h3>
<div className="flex gap-2">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address"
className="flex-1 px-3 py-1.5 text-sm bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary"
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'developer' | 'viewer')}
className="px-3 py-1.5 text-sm bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg text-bolt-elements-textPrimary"
>
<option value="developer">Developer</option>
<option value="viewer">Viewer</option>
</select>
<Button
variant="default"
onClick={handleInviteUser}
className="bg-gradient-to-r from-green-500 to-blue-500"
>
Send Invite
</Button>
</div>
</div>
</div>
</Dialog>
</RadixDialog.Root>
)}
</>
);
};

View File

@@ -1,399 +0,0 @@
import React, { useState, useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import { Dialog } from '~/components/ui/Dialog';
import * as RadixDialog from '@radix-ui/react-dialog';
import { Button } from '~/components/ui/Button';
import { Input } from '~/components/ui/Input';
import { toast } from 'react-toastify';
import { MultiUserSessionManager } from './MultiUserSessionManager';
interface MultiUserToggleProps {
className?: string;
}
export const MultiUserToggle: React.FC<MultiUserToggleProps> = ({ className }) => {
const [isEnabled, setIsEnabled] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const [currentStep, setCurrentStep] = useState(1);
const [organizationName, setOrganizationName] = useState('');
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [maxUsers, setMaxUsers] = useState('10');
const [sessionTimeout, setSessionTimeout] = useState('30');
const [allowGuestAccess, setAllowGuestAccess] = useState(false);
// Check if this is bolt.gives (exclusive feature)
const isBoltGives = window.location.hostname === 'bolt.openweb.live' || window.location.hostname === 'localhost';
useEffect(() => {
// Check if multi-user is already enabled
const multiUserEnabled = localStorage.getItem('multiUserEnabled') === 'true';
setIsEnabled(multiUserEnabled);
}, []);
const handleToggle = () => {
if (!isBoltGives) {
toast.error('Multi-User Sessions is a Bolt.gives exclusive feature');
return;
}
if (!isEnabled) {
// Show wizard to set up multi-user
setShowWizard(true);
setCurrentStep(1);
} else {
// Confirm disable
if (window.confirm('Are you sure you want to disable Multi-User Sessions?')) {
setIsEnabled(false);
localStorage.setItem('multiUserEnabled', 'false');
toast.success('Multi-User Sessions disabled');
}
}
};
const handleNextStep = () => {
if (currentStep === 1) {
if (!organizationName.trim()) {
toast.error('Please enter an organization name');
return;
}
} else if (currentStep === 2) {
if (!adminEmail.trim() || !adminPassword.trim()) {
toast.error('Please enter admin credentials');
return;
}
if (adminPassword.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
}
if (currentStep < 4) {
setCurrentStep(currentStep + 1);
} else {
// Complete setup
handleCompleteSetup();
}
};
const handleCompleteSetup = async () => {
try {
// Save configuration
const config = {
organizationName,
adminEmail,
maxUsers: parseInt(maxUsers),
sessionTimeout: parseInt(sessionTimeout),
allowGuestAccess,
enabled: true,
setupDate: new Date().toISOString(),
};
// Store in localStorage (in production, this would be server-side)
localStorage.setItem('multiUserConfig', JSON.stringify(config));
localStorage.setItem('multiUserEnabled', 'true');
// Create admin user
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: adminEmail,
password: adminPassword,
role: 'admin',
organization: organizationName,
}),
});
if (response.ok) {
setIsEnabled(true);
setShowWizard(false);
toast.success('Multi-User Sessions enabled successfully!');
// Auto-login the admin
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: adminEmail,
password: adminPassword,
}),
});
if (loginResponse.ok) {
window.location.reload();
}
} else {
const error = (await response.json()) as { message?: string };
toast.error(error.message || 'Failed to setup Multi-User Sessions');
}
} catch (error) {
console.error('Setup error:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to setup Multi-User Sessions';
toast.error(errorMessage);
}
};
if (!isBoltGives) {
return null; // Feature not available for non-bolt.gives deployments
}
// If multi-user is enabled, show the session manager instead
if (isEnabled) {
return (
<div className="flex items-center gap-2">
<MultiUserSessionManager />
<button
onClick={handleToggle}
className="p-1.5 text-xs text-bolt-elements-textSecondary hover:text-red-400 transition-all"
title="Disable Multi-User Sessions"
>
<span className="i-ph:power text-sm" />
</button>
</div>
);
}
return (
<>
<button
onClick={handleToggle}
className={classNames(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all',
'border border-bolt-elements-borderColor',
'hover:bg-bolt-elements-background-depth-2',
isEnabled
? 'bg-gradient-to-r from-green-500/20 to-blue-500/20 border-green-500/30'
: 'bg-bolt-elements-background-depth-1',
className,
)}
title={isEnabled ? 'Multi-User Sessions Active' : 'Enable Multi-User Sessions'}
>
<span
className={classNames(
'text-sm',
isEnabled ? 'i-ph:users-three-fill text-green-400' : 'i-ph:users-three text-bolt-elements-textSecondary',
)}
/>
<span
className={classNames(
'text-xs font-medium hidden sm:inline',
isEnabled ? 'text-green-400' : 'text-bolt-elements-textSecondary',
)}
>
{isEnabled ? 'Multi-User' : 'Single User'}
</span>
</button>
{showWizard && (
<RadixDialog.Root open={showWizard} onOpenChange={setShowWizard}>
<Dialog className="max-w-md" onClose={() => setShowWizard(false)}>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Setup Multi-User Sessions</h2>
<span className="text-xs text-bolt-elements-textSecondary">Step {currentStep} of 4</span>
</div>
{/* Progress Bar */}
<div className="flex gap-1 mb-6">
{[1, 2, 3, 4].map((step) => (
<div
key={step}
className={classNames(
'flex-1 h-1 rounded-full transition-all',
step <= currentStep
? 'bg-gradient-to-r from-green-500 to-blue-500'
: 'bg-bolt-elements-borderColor',
)}
/>
))}
</div>
{/* Step 1: Organization Setup */}
{currentStep === 1 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Organization Setup</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Configure your organization for multi-user collaboration
</p>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
Organization Name
</label>
<Input
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="e.g., Acme Corp"
className="w-full"
/>
</div>
</div>
)}
{/* Step 2: Admin Account */}
{currentStep === 2 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Admin Account</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Create the administrator account for managing users
</p>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">Admin Email</label>
<Input
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="admin@example.com"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
Admin Password
</label>
<Input
type="password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
placeholder="Minimum 8 characters"
className="w-full"
/>
</div>
</div>
)}
{/* Step 3: Session Settings */}
{currentStep === 3 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Session Settings</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Configure session limits and security
</p>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
Maximum Concurrent Users
</label>
<Input
type="number"
value={maxUsers}
onChange={(e) => setMaxUsers(e.target.value)}
min="2"
max="100"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
Session Timeout (minutes)
</label>
<Input
type="number"
value={sessionTimeout}
onChange={(e) => setSessionTimeout(e.target.value)}
min="5"
max="1440"
className="w-full"
/>
</div>
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={allowGuestAccess}
onChange={(e) => setAllowGuestAccess(e.target.checked)}
className="rounded border-bolt-elements-borderColor"
/>
<span className="text-sm text-bolt-elements-textPrimary">Allow guest access (read-only)</span>
</label>
</div>
</div>
)}
{/* Step 4: Review & Confirm */}
{currentStep === 4 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">Review Configuration</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Please review your settings before enabling Multi-User Sessions
</p>
</div>
<div className="space-y-2 p-4 bg-bolt-elements-background-depth-2 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Organization:</span>
<span className="text-bolt-elements-textPrimary font-medium">{organizationName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Admin Email:</span>
<span className="text-bolt-elements-textPrimary font-medium">{adminEmail}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Max Users:</span>
<span className="text-bolt-elements-textPrimary font-medium">{maxUsers}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Session Timeout:</span>
<span className="text-bolt-elements-textPrimary font-medium">{sessionTimeout} minutes</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">Guest Access:</span>
<span className="text-bolt-elements-textPrimary font-medium">
{allowGuestAccess ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<p className="text-xs text-blue-400">
<span className="font-semibold">Note:</span> Multi-User Sessions is a Bolt.gives exclusive
feature. You can manage users, sessions, and permissions from the admin panel after setup.
</p>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-between mt-6">
<Button
variant="secondary"
onClick={() => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
} else {
setShowWizard(false);
}
}}
>
{currentStep === 1 ? 'Cancel' : 'Back'}
</Button>
<Button
variant="default"
onClick={handleNextStep}
className="bg-gradient-to-r from-green-500 to-blue-500"
>
{currentStep === 4 ? 'Enable Multi-User' : 'Next'}
</Button>
</div>
</div>
</Dialog>
</RadixDialog.Root>
)}
</>
);
};

View File

@@ -1,299 +0,0 @@
import { memo, useState, useEffect } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import * as Switch from '@radix-ui/react-switch';
import * as Slider from '@radix-ui/react-slider';
import { classNames } from '~/utils/classNames';
import { motion, AnimatePresence } from 'framer-motion';
interface AutoSaveSettingsProps {
onSettingsChange?: (settings: AutoSaveConfig) => void;
trigger?: React.ReactNode;
}
export interface AutoSaveConfig {
enabled: boolean;
interval: number; // in seconds
minChanges: number;
saveOnBlur: boolean;
saveBeforeRun: boolean;
showNotifications: boolean;
}
const DEFAULT_CONFIG: AutoSaveConfig = {
enabled: false,
interval: 30,
minChanges: 1,
saveOnBlur: true,
saveBeforeRun: true,
showNotifications: true,
};
const PRESET_INTERVALS = [
{ label: '10s', value: 10 },
{ label: '30s', value: 30 },
{ label: '1m', value: 60 },
{ label: '2m', value: 120 },
{ label: '5m', value: 300 },
];
export const AutoSaveSettings = memo(({ onSettingsChange, trigger }: AutoSaveSettingsProps) => {
const [isOpen, setIsOpen] = useState(false);
const [config, setConfig] = useState<AutoSaveConfig>(() => {
// Load from localStorage if available
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bolt-autosave-config');
if (saved) {
try {
return JSON.parse(saved);
} catch {
// Invalid JSON, use defaults
}
}
}
return DEFAULT_CONFIG;
});
// Save to localStorage whenever config changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('bolt-autosave-config', JSON.stringify(config));
}
onSettingsChange?.(config);
}, [config, onSettingsChange]);
const updateConfig = <K extends keyof AutoSaveConfig>(key: K, value: AutoSaveConfig[K]) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
{trigger || (
<button className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary transition-colors">
<div className="i-ph:gear-duotone" />
<span className="text-sm">Auto-save Settings</span>
</button>
)}
</Dialog.Trigger>
<AnimatePresence>
{isOpen && (
<Dialog.Portal>
<Dialog.Overlay asChild>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
/>
</Dialog.Overlay>
<Dialog.Content asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md"
>
<div className="bg-bolt-elements-background-depth-1 rounded-xl shadow-2xl border border-bolt-elements-borderColor">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-bolt-elements-borderColor">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary">
Auto-save Settings
</Dialog.Title>
<Dialog.Close className="p-1 rounded-lg hover:bg-bolt-elements-background-depth-2 transition-colors">
<div className="i-ph:x text-xl text-bolt-elements-textTertiary" />
</Dialog.Close>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Enable/Disable Auto-save */}
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">Enable Auto-save</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Automatically save files at regular intervals
</p>
</div>
<Switch.Root
checked={config.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.enabled ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
{/* Save Interval */}
<div
className={classNames(
'space-y-3 transition-opacity',
!config.enabled ? 'opacity-50 pointer-events-none' : '',
)}
>
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Save Interval: {config.interval}s
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">How often to save changes</p>
</div>
<Slider.Root
value={[config.interval]}
onValueChange={([value]) => updateConfig('interval', value)}
min={5}
max={300}
step={5}
className="relative flex items-center select-none touch-none w-full h-5"
>
<Slider.Track className="bg-bolt-elements-background-depth-3 relative grow rounded-full h-1">
<Slider.Range className="absolute bg-accent-500 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 bg-white rounded-full shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent-500" />
</Slider.Root>
{/* Preset buttons */}
<div className="flex gap-2">
{PRESET_INTERVALS.map((preset) => (
<button
key={preset.value}
onClick={() => updateConfig('interval', preset.value)}
className={classNames(
'px-2 py-1 text-xs rounded-md transition-colors',
config.interval === preset.value
? 'bg-accent-500 text-white'
: 'bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary hover:bg-bolt-elements-background-depth-3',
)}
>
{preset.label}
</button>
))}
</div>
</div>
{/* Minimum Changes */}
<div
className={classNames(
'space-y-3 transition-opacity',
!config.enabled ? 'opacity-50 pointer-events-none' : '',
)}
>
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Minimum Changes: {config.minChanges}
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Minimum number of files to trigger auto-save
</p>
</div>
<Slider.Root
value={[config.minChanges]}
onValueChange={([value]) => updateConfig('minChanges', value)}
min={1}
max={10}
step={1}
className="relative flex items-center select-none touch-none w-full h-5"
>
<Slider.Track className="bg-bolt-elements-background-depth-3 relative grow rounded-full h-1">
<Slider.Range className="absolute bg-accent-500 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 bg-white rounded-full shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent-500" />
</Slider.Root>
</div>
{/* Additional Options */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Save on Tab Switch
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Save when switching to another tab
</p>
</div>
<Switch.Root
checked={config.saveOnBlur}
onCheckedChange={(checked) => updateConfig('saveOnBlur', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.saveOnBlur ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">Save Before Run</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Save all files before running commands
</p>
</div>
<Switch.Root
checked={config.saveBeforeRun}
onCheckedChange={(checked) => updateConfig('saveBeforeRun', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.saveBeforeRun ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Show Notifications
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Display toast notifications on save
</p>
</div>
<Switch.Root
checked={config.showNotifications}
onCheckedChange={(checked) => updateConfig('showNotifications', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.showNotifications ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-bolt-elements-borderColor">
<button
onClick={() => setConfig(DEFAULT_CONFIG)}
className="px-4 py-2 text-sm text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
>
Reset to Defaults
</button>
<Dialog.Close className="px-4 py-2 text-sm bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors">
Done
</Dialog.Close>
</div>
</div>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
});
AutoSaveSettings.displayName = 'AutoSaveSettings';

View File

@@ -1,145 +0,0 @@
import { useStore } from '@nanostores/react';
import { memo, useMemo } from 'react';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion';
interface FileStatusIndicatorProps {
className?: string;
showDetails?: boolean;
}
export const FileStatusIndicator = memo(({ className = '', showDetails = true }: FileStatusIndicatorProps) => {
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const stats = useMemo(() => {
let totalFiles = 0;
let totalFolders = 0;
let totalSize = 0;
Object.entries(files).forEach(([_path, dirent]) => {
if (dirent?.type === 'file') {
totalFiles++;
totalSize += dirent.content?.length || 0;
} else if (dirent?.type === 'folder') {
totalFolders++;
}
});
return {
totalFiles,
totalFolders,
unsavedCount: unsavedFiles.size,
totalSize: formatFileSize(totalSize),
modifiedPercentage: totalFiles > 0 ? Math.round((unsavedFiles.size / totalFiles) * 100) : 0,
};
}, [files, unsavedFiles]);
function formatFileSize(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]}`;
}
const getStatusColor = () => {
if (stats.unsavedCount === 0) {
return 'text-green-500';
}
if (stats.modifiedPercentage > 50) {
return 'text-red-500';
}
if (stats.modifiedPercentage > 20) {
return 'text-yellow-500';
}
return 'text-orange-500';
};
return (
<div
className={classNames(
'flex items-center gap-4 px-3 py-1.5 rounded-lg',
'bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor',
'text-xs text-bolt-elements-textTertiary',
className,
)}
>
{/* Status dot with pulse animation */}
<div className="flex items-center gap-2">
<motion.div
animate={{
scale: stats.unsavedCount > 0 ? [1, 1.2, 1] : 1,
}}
transition={{
duration: 2,
repeat: stats.unsavedCount > 0 ? Infinity : 0,
repeatType: 'loop',
}}
className={classNames(
'w-2 h-2 rounded-full',
getStatusColor(),
stats.unsavedCount > 0 ? 'bg-current' : 'bg-green-500',
)}
/>
<span className={getStatusColor()}>
{stats.unsavedCount === 0 ? 'All saved' : `${stats.unsavedCount} unsaved`}
</span>
</div>
{showDetails && (
<>
{/* File count */}
<div className="flex items-center gap-1.5">
<div className="i-ph:file-duotone" />
<span>{stats.totalFiles} files</span>
</div>
{/* Folder count */}
<div className="flex items-center gap-1.5">
<div className="i-ph:folder-duotone" />
<span>{stats.totalFolders} folders</span>
</div>
{/* Total size */}
<div className="flex items-center gap-1.5">
<div className="i-ph:database-duotone" />
<span>{stats.totalSize}</span>
</div>
{/* Progress bar for unsaved files */}
{stats.unsavedCount > 0 && (
<div className="flex items-center gap-2 ml-auto">
<span className="text-xs">{stats.modifiedPercentage}% modified</span>
<div className="w-20 h-1.5 bg-bolt-elements-background-depth-2 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${stats.modifiedPercentage}%` }}
transition={{ duration: 0.3 }}
className={classNames(
'h-full rounded-full',
stats.modifiedPercentage > 50
? 'bg-red-500'
: stats.modifiedPercentage > 20
? 'bg-yellow-500'
: 'bg-orange-500',
)}
/>
</div>
</div>
)}
</>
)}
</div>
);
});
FileStatusIndicator.displayName = 'FileStatusIndicator';

View File

@@ -1,43 +0,0 @@
import { useEffect } from 'react';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
export function useKeyboardSaveAll() {
useEffect(() => {
const handleKeyPress = async (e: KeyboardEvent) => {
// Ctrl+Shift+S or Cmd+Shift+S to save all
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
const unsavedFiles = workbenchStore.unsavedFiles.get();
if (unsavedFiles.size === 0) {
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
return;
}
try {
const count = unsavedFiles.size;
await workbenchStore.saveAllFiles();
toast.success(`Saved ${count} file${count > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
} catch {
toast.error('Failed to save some files', {
position: 'bottom-right',
autoClose: 3000,
});
}
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
}

View File

@@ -1,305 +0,0 @@
import { useStore } from '@nanostores/react';
import { memo, useCallback, useEffect, useState, useRef } from 'react';
import { toast } from 'react-toastify';
import * as Tooltip from '@radix-ui/react-tooltip';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
interface SaveAllButtonProps {
className?: string;
variant?: 'icon' | 'button';
showCount?: boolean;
autoSave?: boolean;
autoSaveInterval?: number;
}
export const SaveAllButton = memo(
({
className = '',
variant = 'icon',
showCount = true,
autoSave = false,
autoSaveInterval = 30000,
}: SaveAllButtonProps) => {
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [timeUntilAutoSave, setTimeUntilAutoSave] = useState<number | null>(null);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const countdownTimerRef = useRef<NodeJS.Timeout | null>(null);
const unsavedCount = unsavedFiles.size;
const hasUnsavedFiles = unsavedCount > 0;
// Log unsaved files state changes
useEffect(() => {
console.log('[SaveAllButton] Unsaved files changed:', {
count: unsavedCount,
files: Array.from(unsavedFiles),
hasUnsavedFiles,
});
}, [unsavedFiles, unsavedCount, hasUnsavedFiles]);
// Auto-save logic
useEffect(() => {
if (!autoSave || !hasUnsavedFiles) {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
setTimeUntilAutoSave(null);
return;
}
// Set up auto-save timer
console.log('[SaveAllButton] Setting up auto-save timer for', autoSaveInterval, 'ms');
autoSaveTimerRef.current = setTimeout(async () => {
if (hasUnsavedFiles && !isSaving) {
console.log('[SaveAllButton] Auto-save triggered');
await handleSaveAll(true);
}
}, autoSaveInterval);
// Set up countdown timer
const startTime = Date.now();
setTimeUntilAutoSave(Math.ceil(autoSaveInterval / 1000));
countdownTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, autoSaveInterval - elapsed);
setTimeUntilAutoSave(Math.ceil(remaining / 1000));
if (remaining <= 0 && countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
}, 1000);
}, [autoSave, hasUnsavedFiles, autoSaveInterval, isSaving]);
// Cleanup effect
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
}
};
}, []);
const handleSaveAll = useCallback(
async (isAutoSave = false) => {
if (!hasUnsavedFiles || isSaving) {
console.log('[SaveAllButton] Skipping save:', { hasUnsavedFiles, isSaving });
return;
}
console.log('[SaveAllButton] Starting save:', {
unsavedCount,
isAutoSave,
files: Array.from(unsavedFiles),
});
setIsSaving(true);
const startTime = performance.now();
const savedFiles: string[] = [];
const failedFiles: string[] = [];
try {
// Save each file individually with detailed logging
for (const filePath of unsavedFiles) {
try {
console.log(`[SaveAllButton] Saving file: ${filePath}`);
await workbenchStore.saveFile(filePath);
savedFiles.push(filePath);
console.log(`[SaveAllButton] Successfully saved: ${filePath}`);
} catch (error) {
console.error(`[SaveAllButton] Failed to save ${filePath}:`, error);
failedFiles.push(filePath);
}
}
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
setLastSaved(new Date());
// Check final state
const remainingUnsaved = workbenchStore.unsavedFiles.get();
console.log('[SaveAllButton] Save complete:', {
savedCount: savedFiles.length,
failedCount: failedFiles.length,
remainingUnsaved: Array.from(remainingUnsaved),
duration,
});
// Show appropriate feedback
if (failedFiles.length === 0) {
const message = isAutoSave
? `Auto-saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`
: `Saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`;
toast.success(message, {
position: 'bottom-right',
autoClose: 2000,
});
} else {
toast.warning(`Saved ${savedFiles.length} files, ${failedFiles.length} failed`, {
position: 'bottom-right',
autoClose: 3000,
});
}
} catch (error) {
console.error('[SaveAllButton] Critical error during save:', error);
toast.error('Failed to save files', {
position: 'bottom-right',
autoClose: 3000,
});
} finally {
setIsSaving(false);
}
},
[hasUnsavedFiles, isSaving, unsavedCount, unsavedFiles],
);
// Keyboard shortcut
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
handleSaveAll(false);
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [handleSaveAll]);
const formatLastSaved = () => {
if (!lastSaved) {
return null;
}
const now = new Date();
const diff = Math.floor((now.getTime() - lastSaved.getTime()) / 1000);
if (diff < 60) {
return `${diff}s ago`;
}
if (diff < 3600) {
return `${Math.floor(diff / 60)}m ago`;
}
return `${Math.floor(diff / 3600)}h ago`;
};
// Icon-only variant for header
if (variant === 'icon') {
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => handleSaveAll(false)}
disabled={!hasUnsavedFiles || isSaving}
className={classNames(
'relative p-1.5 rounded-md transition-colors',
hasUnsavedFiles
? 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive'
: 'text-bolt-elements-textTertiary cursor-not-allowed opacity-50',
className,
)}
>
<div className="relative">
<div className={isSaving ? 'animate-spin' : ''}>
<div className="i-ph:floppy-disk text-lg" />
</div>
{hasUnsavedFiles && showCount && !isSaving && (
<div className="absolute -top-1 -right-1 min-w-[12px] h-[12px] bg-red-500 text-white rounded-full flex items-center justify-center text-[8px] font-bold">
{unsavedCount}
</div>
)}
</div>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary px-3 py-2 rounded-lg shadow-lg border border-bolt-elements-borderColor z-[9999]"
sideOffset={5}
>
<div className="text-xs space-y-1">
<div className="font-semibold">
{hasUnsavedFiles ? `${unsavedCount} unsaved file${unsavedCount > 1 ? 's' : ''}` : 'All files saved'}
</div>
{lastSaved && <div className="text-bolt-elements-textTertiary">Last saved: {formatLastSaved()}</div>}
{autoSave && hasUnsavedFiles && timeUntilAutoSave && (
<div className="text-bolt-elements-textTertiary">Auto-save in: {timeUntilAutoSave}s</div>
)}
<div className="border-t border-bolt-elements-borderColor pt-1 mt-1">
<kbd className="text-xs">Ctrl+Shift+S</kbd> to save all
</div>
</div>
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
// Button variant
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => handleSaveAll(false)}
disabled={!hasUnsavedFiles || isSaving}
className={classNames(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
hasUnsavedFiles
? 'bg-accent-500 hover:bg-accent-600 text-white'
: 'bg-bolt-elements-background-depth-1 text-bolt-elements-textTertiary border border-bolt-elements-borderColor cursor-not-allowed opacity-60',
className,
)}
>
<div className={isSaving ? 'animate-spin' : ''}>
<div className="i-ph:floppy-disk" />
</div>
<span>
{isSaving ? 'Saving...' : `Save All${showCount && hasUnsavedFiles ? ` (${unsavedCount})` : ''}`}
</span>
{autoSave && timeUntilAutoSave && hasUnsavedFiles && (
<span className="text-xs opacity-75">({timeUntilAutoSave}s)</span>
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary px-3 py-2 rounded-lg shadow-lg border border-bolt-elements-borderColor z-[9999]"
sideOffset={5}
>
<div className="text-xs">
<kbd>Ctrl+Shift+S</kbd> to save all
</div>
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
},
);
SaveAllButton.displayName = 'SaveAllButton';

View File

@@ -13,7 +13,6 @@ import {
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/components/ui/Slider';
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
@@ -23,11 +22,13 @@ import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
// import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { usePreviewStore } from '~/lib/stores/previews';
import { chatStore } from '~/lib/stores/chat';
import type { ElementInfo } from './Inspector';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { useChatHistory } from '~/lib/persistence';
import { streamingState } from '~/lib/stores/streaming';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
interface WorkspaceProps {
chatStarted?: boolean;
@@ -279,298 +280,239 @@ const FileModifiedDropdown = memo(
},
);
export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }: WorkspaceProps) => {
renderLogger.trace('Workbench');
export const Workbench = memo(
({
chatStarted,
isStreaming,
metadata: _metadata,
updateChatMestaData: _updateChatMestaData,
setSelectedElement,
}: WorkspaceProps) => {
renderLogger.trace('Workbench');
const [isSyncing, setIsSyncing] = useState(false);
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
// const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
// Keyboard shortcut for Save All (Ctrl+Shift+S)
useEffect(() => {
const handleKeyPress = async (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const unsavedFiles = workbenchStore.unsavedFiles.get();
const isSmallViewport = useViewport(1024);
const streaming = useStore(streamingState);
const { exportChat } = useChatHistory();
const [isSyncing, setIsSyncing] = useState(false);
if (unsavedFiles.size > 0) {
try {
await workbenchStore.saveAllFiles();
toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
} catch {
toast.error('Failed to save some files', {
position: 'bottom-right',
autoClose: 3000,
});
}
} else {
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
}
}
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const isSmallViewport = useViewport(1024);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
const onFileSave = useCallback(() => {
workbenchStore
.saveCurrentDocument()
.then(() => {
// Explicitly refresh all previews after a file save
const previewStore = usePreviewStore();
previewStore.refreshAllPreviews();
})
.catch(() => {
toast.error('Failed to update file content');
});
}, []);
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const handleSelectFile = useCallback((filePath: string) => {
workbenchStore.setSelectedFile(filePath);
workbenchStore.currentView.set('diff');
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} catch (error) {
console.error('Error syncing files:', error);
toast.error('Failed to sync files');
} finally {
setIsSyncing(false);
}
}, []);
const onFileSave = useCallback(() => {
workbenchStore
.saveCurrentDocument()
.then(() => {
// Explicitly refresh all previews after a file save
const previewStore = usePreviewStore();
previewStore.refreshAllPreviews();
})
.catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} catch {
console.error('Error syncing files');
toast.error('Failed to sync files');
} finally {
setIsSyncing(false);
}
}, []);
const handleSelectFile = useCallback((filePath: string) => {
workbenchStore.setSelectedFile(filePath);
workbenchStore.currentView.set('diff');
}, []);
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.2rem)] bottom-6 w-[var(--workbench-inner-width)] z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport,
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div className="absolute inset-0 px-2 lg:px-4">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1.5">
<button
className={`${showChat ? 'i-ph:sidebar-simple-fill' : 'i-ph:sidebar-simple'} text-lg text-bolt-elements-textSecondary mr-1`}
disabled={!canHideChat || isSmallViewport}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
/>
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
<PanelHeaderButton
className="mr-1 text-sm"
onClick={async () => {
console.log('[SaveAll] Button clicked');
const unsavedFiles = workbenchStore.unsavedFiles.get();
console.log('[SaveAll] Unsaved files:', Array.from(unsavedFiles));
if (unsavedFiles.size > 0) {
try {
console.log('[SaveAll] Starting save...');
await workbenchStore.saveAllFiles();
toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
console.log('[SaveAll] Save successful');
} catch {
console.error('[SaveAll] Save failed');
toast.error('Failed to save files', {
position: 'bottom-right',
autoClose: 3000,
});
}
} else {
console.log('[SaveAll] No unsaved files');
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
}
}}
>
<div className="i-ph:floppy-disk" />
Save All
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
<div className="i-ph:box-arrow-up" />
Sync
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={handleSyncFiles}
disabled={isSyncing}
>
<div className="flex items-center gap-2">
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
</div>
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={() => {
/* GitHub push temporarily disabled */
}}
>
<div className="flex items-center gap-2">
<div className="i-ph:git-branch" />
Push to GitHub
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
)}
{selectedView === 'diff' && (
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.2rem)] bottom-6 w-[var(--workbench-inner-width)] z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport,
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
>
<div className="absolute inset-0 px-2 lg:px-4">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1.5">
<button
className={`${showChat ? 'i-ph:sidebar-simple-fill' : 'i-ph:sidebar-simple'} text-lg text-bolt-elements-textSecondary mr-1`}
disabled={!canHideChat || isSmallViewport}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
/>
</View>
<View
initial={{ x: '100%' }}
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
>
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} />
</View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview setSelectedElement={setSelectedElement} />
</View>
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
{/* Export Chat Button */}
<ExportChatButton exportChat={exportChat} />
{/* Sync Button */}
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden ml-1">
<DropdownMenu.Root>
<DropdownMenu.Trigger
disabled={isSyncing || streaming}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
{isSyncing ? 'Syncing...' : 'Sync'}
<span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={handleSyncFiles}
disabled={isSyncing}
>
<div className="flex items-center gap-2">
{isSyncing ? (
<div className="i-ph:spinner" />
) : (
<div className="i-ph:cloud-arrow-down" />
)}
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/* Toggle Terminal Button */}
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden ml-1">
<button
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
<div className="i-ph:terminal" />
Toggle Terminal
</button>
</div>
</div>
)}
{selectedView === 'diff' && (
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
/>
</View>
<View
initial={{ x: '100%' }}
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
>
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} />
</View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview setSelectedElement={setSelectedElement} />
</View>
</div>
</div>
</div>
</div>
</div>
{/* GitHub push dialog temporarily disabled during merge - will be re-enabled with new GitLab integration */}
</motion.div>
)
);
});
</motion.div>
)
);
},
);
// View component for rendering content with motion transitions
interface ViewProps extends HTMLMotionProps<'div'> {