From 33305c4326a5c18874f858f55b0198fee295309d Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Fri, 4 Apr 2025 11:22:56 +0100 Subject: [PATCH] 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. --- app/components/chat/BaseChat.tsx | 17 +- app/components/chat/Chat.client.tsx | 3 + app/components/deploy/DeployAlert.tsx | 197 ++++++++++++++++++ .../deploy/NetlifyDeploy.client.tsx | 45 ++++ app/components/deploy/VercelDeploy.client.tsx | 34 +++ app/lib/runtime/action-runner.ts | 117 ++++++++++- app/lib/stores/workbench.ts | 20 +- app/types/actions.ts | 12 ++ 8 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 app/components/deploy/DeployAlert.tsx diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 168fbed..a33058c 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -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( messages, actionAlert, clearAlert, + deployAlert, + clearDeployAlert, supabaseAlert, clearSupabaseAlert, data, @@ -349,6 +354,16 @@ export const BaseChat = React.forwardRef( ) : null; }} + {deployAlert && ( + clearDeployAlert?.()} + postMessage={(message: string | undefined) => { + sendMessage?.({} as any, message); + clearSupabaseAlert?.(); + }} + /> + )} {supabaseAlert && ( 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} /> ); diff --git a/app/components/deploy/DeployAlert.tsx b/app/components/deploy/DeployAlert.tsx new file mode 100644 index 0000000..adedb77 --- /dev/null +++ b/app/components/deploy/DeployAlert.tsx @@ -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 ( + + +
+ {/* Icon */} + +
+
+ {/* Content */} +
+ + {title} + + +

{description}

+ + {/* Deployment Progress Visualization */} + {showProgress && ( +
+
+ {/* Build Step */} +
+
+ {buildStatus === 'running' ? ( +
+ ) : buildStatus === 'complete' ? ( +
+ ) : buildStatus === 'failed' ? ( +
+ ) : ( + 1 + )} +
+ Build +
+ + {/* Connector Line */} +
+ + {/* Deploy Step */} +
+
+ {deployStatus === 'running' ? ( +
+ ) : deployStatus === 'complete' ? ( +
+ ) : deployStatus === 'failed' ? ( +
+ ) : ( + 2 + )} +
+ Deploy +
+
+
+ )} + + {content && ( +
+ {content} +
+ )} + {url && type === 'success' && ( + + )} +
+ + {/* Actions */} + +
+ {type === 'error' && ( + + )} + +
+
+
+
+
+
+ ); +} diff --git a/app/components/deploy/NetlifyDeploy.client.tsx b/app/components/deploy/NetlifyDeploy.client.tsx index 7b7695a..f88751c 100644 --- a/app/components/deploy/NetlifyDeploy.client.tsx +++ b/app/components/deploy/NetlifyDeploy.client.tsx @@ -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(
Deployed successfully!{' '} diff --git a/app/components/deploy/VercelDeploy.client.tsx b/app/components/deploy/VercelDeploy.client.tsx index 74952f8..a6b53a4 100644 --- a/app/components/deploy/VercelDeploy.client.tsx +++ b/app/components/deploy/VercelDeploy.client.tsx @@ -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(
Deployed successfully to Vercel!{' '} diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index a04a3de..a3436a9 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -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', + }); + } } diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 051d45a..68d83e2 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -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(undefined); supabaseAlert: WritableAtom = import.meta.hot?.data.unsavedFiles ?? atom(undefined); + deployAlert: WritableAtom = + import.meta.hot?.data.unsavedFiles ?? atom(undefined); modifiedFiles = new Set(); 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); + }, ), }); } diff --git a/app/types/actions.ts b/app/types/actions.ts index 63b8426..0e1411d 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -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;