feat(deploy): add deploy alert system for build and deployment status

Introduce a new `DeployAlert` interface and related components to provide visual feedback on build and deployment stages. This includes status updates for Vercel and Netlify deployments, with progress visualization and error handling. The changes enhance user experience by offering real-time updates during the deployment process.
This commit is contained in:
KevIsDev 2025-04-04 11:22:56 +01:00
parent cdbf9ba730
commit 33305c4326
8 changed files with 440 additions and 5 deletions

View File

@ -29,7 +29,8 @@ import type { ProviderInfo } from '~/types/model';
import { ScreenshotStateManager } from './ScreenshotStateManager';
import { toast } from 'react-toastify';
import StarterTemplates from './StarterTemplates';
import type { ActionAlert, SupabaseAlert } from '~/types/actions';
import type { ActionAlert, SupabaseAlert, DeployAlert } from '~/types/actions';
import DeployChatAlert from '~/components/deploy/DeployAlert';
import ChatAlert from './ChatAlert';
import type { ModelInfo } from '~/lib/modules/llm/types';
import ProgressCompilation from './ProgressCompilation';
@ -73,6 +74,8 @@ interface BaseChatProps {
clearAlert?: () => void;
supabaseAlert?: SupabaseAlert;
clearSupabaseAlert?: () => void;
deployAlert?: DeployAlert;
clearDeployAlert?: () => void;
data?: JSONValue[] | undefined;
actionRunner?: ActionRunner;
}
@ -109,6 +112,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
messages,
actionAlert,
clearAlert,
deployAlert,
clearDeployAlert,
supabaseAlert,
clearSupabaseAlert,
data,
@ -349,6 +354,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
) : null;
}}
</ClientOnly>
{deployAlert && (
<DeployChatAlert
alert={deployAlert}
clearAlert={() => clearDeployAlert?.()}
postMessage={(message: string | undefined) => {
sendMessage?.({} as any, message);
clearSupabaseAlert?.();
}}
/>
)}
{supabaseAlert && (
<SupabaseChatAlert
alert={supabaseAlert}

View File

@ -124,6 +124,7 @@ export const ChatImpl = memo(
const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files);
const actionAlert = useStore(workbenchStore.alert);
const deployAlert = useStore(workbenchStore.deployAlert);
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
const selectedProject = supabaseConn.stats?.projects?.find(
(project) => project.id === supabaseConn.selectedProjectId,
@ -560,6 +561,8 @@ export const ChatImpl = memo(
clearAlert={() => workbenchStore.clearAlert()}
supabaseAlert={supabaseAlert}
clearSupabaseAlert={() => workbenchStore.clearSupabaseAlert()}
deployAlert={deployAlert}
clearDeployAlert={() => workbenchStore.clearDeployAlert()}
data={chatData}
/>
);

View File

@ -0,0 +1,197 @@
import { AnimatePresence, motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import type { DeployAlert } from '~/types/actions';
interface DeployAlertProps {
alert: DeployAlert;
clearAlert: () => void;
postMessage: (message: string) => void;
}
export default function DeployChatAlert({ alert, clearAlert, postMessage }: DeployAlertProps) {
const { type, title, description, content, url, stage, buildStatus, deployStatus } = alert;
// Determine if we should show the deployment progress
const showProgress = stage && (buildStatus || deployStatus);
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`}
>
<div className="flex items-start">
{/* Icon */}
<motion.div
className="flex-shrink-0"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
>
<div
className={classNames(
'text-xl',
type === 'success'
? 'i-ph:check-circle-duotone text-bolt-elements-icon-success'
: type === 'error'
? 'i-ph:warning-duotone text-bolt-elements-button-danger-text'
: 'i-ph:info-duotone text-bolt-elements-loader-progress',
)}
></div>
</motion.div>
{/* Content */}
<div className="ml-3 flex-1">
<motion.h3
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className={`text-sm font-medium text-bolt-elements-textPrimary`}
>
{title}
</motion.h3>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className={`mt-2 text-sm text-bolt-elements-textSecondary`}
>
<p>{description}</p>
{/* Deployment Progress Visualization */}
{showProgress && (
<div className="mt-4 mb-2">
<div className="flex items-center space-x-2 mb-3">
{/* Build Step */}
<div className="flex items-center">
<div
className={classNames(
'w-6 h-6 rounded-full flex items-center justify-center',
buildStatus === 'running'
? 'bg-bolt-elements-loader-progress'
: buildStatus === 'complete'
? 'bg-bolt-elements-icon-success'
: buildStatus === 'failed'
? 'bg-bolt-elements-button-danger-background'
: 'bg-bolt-elements-textTertiary',
)}
>
{buildStatus === 'running' ? (
<div className="i-svg-spinners:90-ring-with-bg text-white text-xs"></div>
) : buildStatus === 'complete' ? (
<div className="i-ph:check text-white text-xs"></div>
) : buildStatus === 'failed' ? (
<div className="i-ph:x text-white text-xs"></div>
) : (
<span className="text-white text-xs">1</span>
)}
</div>
<span className="ml-2">Build</span>
</div>
{/* Connector Line */}
<div
className={classNames(
'h-0.5 w-8',
buildStatus === 'complete' ? 'bg-bolt-elements-icon-success' : 'bg-bolt-elements-textTertiary',
)}
></div>
{/* Deploy Step */}
<div className="flex items-center">
<div
className={classNames(
'w-6 h-6 rounded-full flex items-center justify-center',
deployStatus === 'running'
? 'bg-bolt-elements-loader-progress'
: deployStatus === 'complete'
? 'bg-bolt-elements-icon-success'
: deployStatus === 'failed'
? 'bg-bolt-elements-button-danger-background'
: 'bg-bolt-elements-textTertiary',
)}
>
{deployStatus === 'running' ? (
<div className="i-svg-spinners:90-ring-with-bg text-white text-xs"></div>
) : deployStatus === 'complete' ? (
<div className="i-ph:check text-white text-xs"></div>
) : deployStatus === 'failed' ? (
<div className="i-ph:x text-white text-xs"></div>
) : (
<span className="text-white text-xs">2</span>
)}
</div>
<span className="ml-2">Deploy</span>
</div>
</div>
</div>
)}
{content && (
<div className="text-xs text-bolt-elements-textSecondary p-2 bg-bolt-elements-background-depth-3 rounded mt-4 mb-4">
{content}
</div>
)}
{url && type === 'success' && (
<div className="mt-2">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-item-contentAccent hover:underline flex items-center"
>
<span className="mr-1">View deployed site</span>
<div className="i-ph:arrow-square-out"></div>
</a>
</div>
)}
</motion.div>
{/* Actions */}
<motion.div
className="mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className={classNames('flex gap-2')}>
{type === 'error' && (
<button
onClick={() =>
postMessage(`*Fix this deployment error*\n\`\`\`\n${content || description}\n\`\`\`\n`)
}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-primary-background',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-danger-background',
'text-bolt-elements-button-primary-text',
'flex items-center gap-1.5',
)}
>
<div className="i-ph:chat-circle-duotone"></div>
Ask Bolt
</button>
)}
<button
onClick={clearAlert}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-secondary-background',
'hover:bg-bolt-elements-button-secondary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
'text-bolt-elements-button-secondary-text',
)}
>
Dismiss
</button>
</div>
</motion.div>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@ -33,6 +33,21 @@ export function useNetlifyDeploy() {
throw new Error('No active project found');
}
// Create a deployment artifact for visual feedback
const deploymentId = `deploy-artifact`;
workbenchStore.addArtifact({
id: deploymentId,
messageId: deploymentId,
title: 'Netlify Deployment',
type: 'standalone',
});
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
// Notify that build is starting
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'netlify' });
// Set up build action
const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = {
messageId: 'netlify build',
@ -51,9 +66,17 @@ export function useNetlifyDeploy() {
await artifact.runner.runAction(actionData);
if (!artifact.runner.buildOutput) {
// Notify that build failed
deployArtifact.runner.handleDeployAction('building', 'failed', {
error: 'Build failed. Check the terminal for details.',
source: 'netlify',
});
throw new Error('Build failed');
}
// Notify that build succeeded and deployment is starting
deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'netlify' });
// Get the build files
const container = await webcontainer;
@ -133,6 +156,12 @@ export function useNetlifyDeploy() {
if (!response.ok || !data.deploy || !data.site) {
console.error('Invalid deploy response:', data);
// Notify that deployment failed
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
error: data.error || 'Invalid deployment response',
source: 'netlify',
});
throw new Error(data.error || 'Invalid deployment response');
}
@ -158,6 +187,11 @@ export function useNetlifyDeploy() {
}
if (deploymentStatus.state === 'error') {
// Notify that deployment failed
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
error: 'Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'),
source: 'netlify',
});
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
}
@ -171,6 +205,11 @@ export function useNetlifyDeploy() {
}
if (attempts >= maxAttempts) {
// Notify that deployment timed out
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
error: 'Deployment timed out',
source: 'netlify',
});
throw new Error('Deployment timed out');
}
@ -179,6 +218,12 @@ export function useNetlifyDeploy() {
localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id);
}
// Notify that deployment completed successfully
deployArtifact.runner.handleDeployAction('complete', 'complete', {
url: deploymentStatus.ssl_url || deploymentStatus.url,
source: 'netlify',
});
toast.success(
<div>
Deployed successfully!{' '}

View File

@ -33,6 +33,20 @@ export function useVercelDeploy() {
throw new Error('No active project found');
}
// Create a deployment artifact for visual feedback
const deploymentId = `deploy-vercel-project`;
workbenchStore.addArtifact({
id: deploymentId,
messageId: deploymentId,
title: 'Vercel Deployment',
type: 'standalone',
});
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
// Notify that build is starting
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'vercel' });
const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = {
messageId: 'vercel build',
@ -51,9 +65,17 @@ export function useVercelDeploy() {
await artifact.runner.runAction(actionData);
if (!artifact.runner.buildOutput) {
// Notify that build failed
deployArtifact.runner.handleDeployAction('building', 'failed', {
error: 'Build failed. Check the terminal for details.',
source: 'vercel',
});
throw new Error('Build failed');
}
// Notify that build succeeded and deployment is starting
deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'vercel' });
// Get the build files
const container = await webcontainer;
@ -133,6 +155,12 @@ export function useVercelDeploy() {
if (!response.ok || !data.deploy || !data.project) {
console.error('Invalid deploy response:', data);
// Notify that deployment failed
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
error: data.error || 'Invalid deployment response',
source: 'vercel',
});
throw new Error(data.error || 'Invalid deployment response');
}
@ -140,6 +168,12 @@ export function useVercelDeploy() {
localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id);
}
// Notify that deployment completed successfully
deployArtifact.runner.handleDeployAction('complete', 'complete', {
url: data.deploy.url,
source: 'vercel',
});
toast.success(
<div>
Deployed successfully to Vercel!{' '}

View File

@ -1,7 +1,7 @@
import type { WebContainer } from '@webcontainer/api';
import { path as nodePath } from '~/utils/path';
import { atom, map, type MapStore } from 'nanostores';
import type { ActionAlert, BoltAction, FileHistory, SupabaseAction, SupabaseAlert } from '~/types/actions';
import type { ActionAlert, BoltAction, DeployAlert, FileHistory, SupabaseAction, SupabaseAlert } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
@ -71,6 +71,7 @@ export class ActionRunner {
actions: ActionsMap = map({});
onAlert?: (alert: ActionAlert) => void;
onSupabaseAlert?: (alert: SupabaseAlert) => void;
onDeployAlert?: (alert: DeployAlert) => void;
buildOutput?: { path: string; exitCode: number; output: string };
constructor(
@ -78,11 +79,13 @@ export class ActionRunner {
getShellTerminal: () => BoltShell,
onAlert?: (alert: ActionAlert) => void,
onSupabaseAlert?: (alert: SupabaseAlert) => void,
onDeployAlert?: (alert: DeployAlert) => void,
) {
this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
this.onAlert = onAlert;
this.onSupabaseAlert = onSupabaseAlert;
this.onDeployAlert = onDeployAlert;
}
addAction(data: ActionCallbackData) {
@ -366,6 +369,17 @@ export class ActionRunner {
unreachable('Expected build action');
}
// Trigger build started alert
this.onDeployAlert?.({
type: 'info',
title: 'Building Application',
description: 'Building your application...',
stage: 'building',
buildStatus: 'running',
deployStatus: 'pending',
source: 'netlify',
});
const webcontainer = await this.#webcontainer;
// Create a new terminal specifically for the build
@ -383,11 +397,57 @@ export class ActionRunner {
const exitCode = await buildProcess.exit;
if (exitCode !== 0) {
// Trigger build failed alert
this.onDeployAlert?.({
type: 'error',
title: 'Build Failed',
description: 'Your application build failed',
content: output || 'No build output available',
stage: 'building',
buildStatus: 'failed',
deployStatus: 'pending',
source: 'netlify',
});
throw new ActionCommandError('Build Failed', output || 'No Output Available');
}
// Get the build output directory path
const buildDir = nodePath.join(webcontainer.workdir, 'dist');
// Trigger build success alert
this.onDeployAlert?.({
type: 'success',
title: 'Build Completed',
description: 'Your application was built successfully',
stage: 'deploying',
buildStatus: 'complete',
deployStatus: 'running',
source: 'netlify',
});
// Check for common build directories
const commonBuildDirs = ['dist', 'build', 'out', 'output', '.next', 'public'];
let buildDir = '';
// Try to find the first existing build directory
for (const dir of commonBuildDirs) {
const dirPath = nodePath.join(webcontainer.workdir, dir);
try {
await webcontainer.fs.readdir(dirPath);
buildDir = dirPath;
logger.debug(`Found build directory: ${buildDir}`);
break;
} catch (error) {
// Directory doesn't exist, try the next one
logger.debug(`Build directory ${dir} not found, trying next option. ${error}`);
}
}
// If no build directory was found, use the default (dist)
if (!buildDir) {
buildDir = nodePath.join(webcontainer.workdir, 'dist');
logger.debug(`No build directory found, defaulting to: ${buildDir}`);
}
return {
path: buildDir,
@ -441,4 +501,55 @@ export class ActionRunner {
throw new Error(`Unknown operation: ${operation}`);
}
}
// Add this method declaration to the class
handleDeployAction(
stage: 'building' | 'deploying' | 'complete',
status: ActionStatus,
details?: {
url?: string;
error?: string;
source?: 'netlify' | 'vercel' | 'github';
},
): void {
if (!this.onDeployAlert) {
logger.debug('No deploy alert handler registered');
return;
}
const alertType = status === 'failed' ? 'error' : status === 'complete' ? 'success' : 'info';
const title =
stage === 'building'
? 'Building Application'
: stage === 'deploying'
? 'Deploying Application'
: 'Deployment Complete';
const description =
status === 'failed'
? `${stage === 'building' ? 'Build' : 'Deployment'} failed`
: status === 'running'
? `${stage === 'building' ? 'Building' : 'Deploying'} your application...`
: status === 'complete'
? `${stage === 'building' ? 'Build' : 'Deployment'} completed successfully`
: `Preparing to ${stage === 'building' ? 'build' : 'deploy'} your application`;
const buildStatus =
stage === 'building' ? status : stage === 'deploying' || stage === 'complete' ? 'complete' : 'pending';
const deployStatus = stage === 'building' ? 'pending' : status;
this.onDeployAlert({
type: alertType,
title,
description,
content: details?.error || '',
url: details?.url,
stage,
buildStatus: buildStatus as any,
deployStatus: deployStatus as any,
source: details?.source || 'netlify',
});
}
}

View File

@ -17,7 +17,7 @@ import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler';
import type { ActionAlert, SupabaseAlert } from '~/types/actions';
import type { ActionAlert, DeployAlert, SupabaseAlert } from '~/types/actions';
const { saveAs } = fileSaver;
@ -52,6 +52,8 @@ export class WorkbenchStore {
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
supabaseAlert: WritableAtom<SupabaseAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
deployAlert: WritableAtom<DeployAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<DeployAlert | undefined>(undefined);
modifiedFiles = new Set<string>();
artifactIdList: string[] = [];
#globalExecutionQueue = Promise.resolve();
@ -63,6 +65,7 @@ export class WorkbenchStore {
import.meta.hot.data.currentView = this.currentView;
import.meta.hot.data.actionAlert = this.actionAlert;
import.meta.hot.data.supabaseAlert = this.supabaseAlert;
import.meta.hot.data.deployAlert = this.deployAlert;
// Ensure binary files are properly preserved across hot reloads
const filesMap = this.files.get();
@ -125,6 +128,14 @@ export class WorkbenchStore {
this.supabaseAlert.set(undefined);
}
get DeployAlert() {
return this.deployAlert;
}
clearDeployAlert() {
this.deployAlert.set(undefined);
}
toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value);
}
@ -423,6 +434,13 @@ export class WorkbenchStore {
this.supabaseAlert.set(alert);
},
(alert) => {
if (this.#reloadedMessages.has(messageId)) {
return;
}
this.deployAlert.set(alert);
},
),
});
}

View File

@ -50,6 +50,18 @@ export interface SupabaseAlert {
source?: 'supabase';
}
export interface DeployAlert {
type: 'success' | 'error' | 'info';
title: string;
description: string;
content?: string;
url?: string;
stage?: 'building' | 'deploying' | 'complete';
buildStatus?: 'pending' | 'running' | 'complete' | 'failed';
deployStatus?: 'pending' | 'running' | 'complete' | 'failed';
source?: 'vercel' | 'netlify' | 'github';
}
export interface FileHistory {
originalContent: string;
lastModified: number;