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;