diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx
index cef5a39794..94987377d4 100644
--- a/app/components/chat/Artifact.tsx
+++ b/app/components/chat/Artifact.tsx
@@ -23,15 +23,16 @@ if (import.meta.hot) {
interface ArtifactProps {
messageId: string;
+ artifactId: string;
}
-export const Artifact = memo(({ messageId }: ArtifactProps) => {
+export const Artifact = memo(({ artifactId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const [allActionFinished, setAllActionFinished] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
- const artifact = artifacts[messageId];
+ const artifact = artifacts[artifactId];
const actions = useStore(
computed(artifact.runner.actions, (actions) => {
diff --git a/app/components/chat/Markdown.tsx b/app/components/chat/Markdown.tsx
index 3471c73380..fd0d770a23 100644
--- a/app/components/chat/Markdown.tsx
+++ b/app/components/chat/Markdown.tsx
@@ -34,12 +34,17 @@ export const Markdown = memo(
if (className?.includes('__boltArtifact__')) {
const messageId = node?.properties.dataMessageId as string;
+ const artifactId = node?.properties.dataArtifactId as string;
if (!messageId) {
logger.error(`Invalid message id ${messageId}`);
}
- return ;
+ if (!artifactId) {
+ logger.error(`Invalid artifact id ${artifactId}`);
+ }
+
+ return ;
}
if (className?.includes('__boltSelectedElement__')) {
diff --git a/app/lib/hooks/useMessageParser.ts b/app/lib/hooks/useMessageParser.ts
index 92e3a9673a..ff65fe7991 100644
--- a/app/lib/hooks/useMessageParser.ts
+++ b/app/lib/hooks/useMessageParser.ts
@@ -22,7 +22,10 @@ const messageParser = new EnhancedStreamingMessageParser({
onActionOpen: (data) => {
logger.trace('onActionOpen', data.action);
- // we only add shell actions when when the close tag got parsed because only then we have the content
+ /*
+ * File actions are streamed, so we add them immediately to show progress
+ * Shell actions are complete when created by enhanced parser, so we wait for close
+ */
if (data.action.type === 'file') {
workbenchStore.addAction(data);
}
@@ -30,6 +33,10 @@ const messageParser = new EnhancedStreamingMessageParser({
onActionClose: (data) => {
logger.trace('onActionClose', data.action);
+ /*
+ * Add non-file actions (shell, build, start, etc.) when they close
+ * Enhanced parser creates complete shell actions, so they're ready to execute
+ */
if (data.action.type !== 'file') {
workbenchStore.addAction(data);
}
diff --git a/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap b/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap
index 4b60f10bd1..8e6ed9b0f1 100644
--- a/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap
+++ b/app/lib/runtime/__snapshots__/message-parser.spec.ts.snap
@@ -7,7 +7,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"type": "shell",
},
"actionId": "0",
- "artifactId": "artifact_1",
+ "artifactId": "message_1-0",
"messageId": "message_1",
}
`;
@@ -19,14 +19,15 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"type": "shell",
},
"actionId": "0",
- "artifactId": "artifact_1",
+ "artifactId": "message_1-0",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -35,7 +36,8 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -49,7 +51,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"type": "shell",
},
"actionId": "0",
- "artifactId": "artifact_1",
+ "artifactId": "message_1-0",
"messageId": "message_1",
}
`;
@@ -63,7 +65,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"type": "file",
},
"actionId": "1",
- "artifactId": "artifact_1",
+ "artifactId": "message_1-0",
"messageId": "message_1",
}
`;
@@ -75,7 +77,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"type": "shell",
},
"actionId": "0",
- "artifactId": "artifact_1",
+ "artifactId": "message_1-0",
"messageId": "message_1",
}
`;
@@ -88,14 +90,15 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"type": "file",
},
"actionId": "1",
- "artifactId": "artifact_1",
+ "artifactId": "message_1-0",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -104,7 +107,8 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -113,7 +117,8 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -122,7 +127,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -131,7 +137,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": "bundled",
@@ -140,7 +147,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": "bundled",
@@ -149,7 +157,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (2) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -158,7 +167,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (2) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -167,7 +177,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (3) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -176,7 +187,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (3) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -185,7 +197,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (4) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -194,7 +207,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (4) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -203,7 +217,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (5) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -212,7 +227,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (5) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -221,7 +237,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (6) > onArtifactClose 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
@@ -230,7 +247,8 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (6) > onArtifactOpen 1`] = `
{
- "id": "artifact_1",
+ "artifactId": "message_1-0",
+ "id": "message_1-0",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts
index 0c3e0f7e48..b14d3a89b0 100644
--- a/app/lib/runtime/action-runner.ts
+++ b/app/lib/runtime/action-runner.ts
@@ -140,7 +140,7 @@ export class ActionRunner {
return this.#executeAction(actionId, isStreaming);
})
.catch((error) => {
- console.error('Action failed:', error);
+ logger.error('Action execution promise failed:', error);
});
await this.#currentExecutionPromise;
@@ -259,6 +259,14 @@ export class ActionRunner {
unreachable('Shell terminal not found');
}
+ // Pre-validate command for common issues
+ const validationResult = await this.#validateShellCommand(action.content);
+
+ if (validationResult.shouldModify && validationResult.modifiedCommand) {
+ logger.debug(`Modified command: ${action.content} -> ${validationResult.modifiedCommand}`);
+ action.content = validationResult.modifiedCommand;
+ }
+
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
action.abort();
@@ -266,7 +274,8 @@ export class ActionRunner {
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);
if (resp?.exitCode != 0) {
- throw new ActionCommandError(`Failed To Execute Shell Command`, resp?.output || 'No Output Available');
+ const enhancedError = this.#createEnhancedShellError(action.content, resp?.exitCode, resp?.output);
+ throw new ActionCommandError(enhancedError.title, enhancedError.details);
}
}
@@ -549,4 +558,188 @@ export class ActionRunner {
source: details?.source || 'netlify',
});
}
+
+ async #validateShellCommand(command: string): Promise<{
+ shouldModify: boolean;
+ modifiedCommand?: string;
+ warning?: string;
+ }> {
+ const trimmedCommand = command.trim();
+
+ // Handle rm commands that might fail due to missing files
+ if (trimmedCommand.startsWith('rm ') && !trimmedCommand.includes(' -f')) {
+ const rmMatch = trimmedCommand.match(/^rm\s+(.+)$/);
+
+ if (rmMatch) {
+ const filePaths = rmMatch[1].split(/\s+/);
+
+ // Check if any of the files exist using WebContainer
+ try {
+ const webcontainer = await this.#webcontainer;
+ const existingFiles = [];
+
+ for (const filePath of filePaths) {
+ if (filePath.startsWith('-')) {
+ continue;
+ } // Skip flags
+
+ try {
+ await webcontainer.fs.readFile(filePath);
+ existingFiles.push(filePath);
+ } catch {
+ // File doesn't exist, skip it
+ }
+ }
+
+ if (existingFiles.length === 0) {
+ // No files exist, modify command to use -f flag to avoid error
+ return {
+ shouldModify: true,
+ modifiedCommand: `rm -f ${filePaths.join(' ')}`,
+ warning: 'Added -f flag to rm command as target files do not exist',
+ };
+ } else if (existingFiles.length < filePaths.length) {
+ // Some files don't exist, modify to only remove existing ones with -f for safety
+ return {
+ shouldModify: true,
+ modifiedCommand: `rm -f ${filePaths.join(' ')}`,
+ warning: 'Added -f flag to rm command as some target files do not exist',
+ };
+ }
+ } catch (error) {
+ logger.debug('Could not validate rm command files:', error);
+ }
+ }
+ }
+
+ // Handle cd commands to non-existent directories
+ if (trimmedCommand.startsWith('cd ')) {
+ const cdMatch = trimmedCommand.match(/^cd\s+(.+)$/);
+
+ if (cdMatch) {
+ const targetDir = cdMatch[1].trim();
+
+ try {
+ const webcontainer = await this.#webcontainer;
+ await webcontainer.fs.readdir(targetDir);
+ } catch {
+ return {
+ shouldModify: true,
+ modifiedCommand: `mkdir -p ${targetDir} && cd ${targetDir}`,
+ warning: 'Directory does not exist, created it first',
+ };
+ }
+ }
+ }
+
+ // Handle cp/mv commands with missing source files
+ if (trimmedCommand.match(/^(cp|mv)\s+/)) {
+ const parts = trimmedCommand.split(/\s+/);
+
+ if (parts.length >= 3) {
+ const sourceFile = parts[1];
+
+ try {
+ const webcontainer = await this.#webcontainer;
+ await webcontainer.fs.readFile(sourceFile);
+ } catch {
+ return {
+ shouldModify: false,
+ warning: `Source file '${sourceFile}' does not exist`,
+ };
+ }
+ }
+ }
+
+ return { shouldModify: false };
+ }
+
+ #createEnhancedShellError(
+ command: string,
+ exitCode: number | undefined,
+ output: string | undefined,
+ ): {
+ title: string;
+ details: string;
+ } {
+ const trimmedCommand = command.trim();
+ const firstWord = trimmedCommand.split(/\s+/)[0];
+
+ // Common error patterns and their explanations
+ const errorPatterns = [
+ {
+ pattern: /cannot remove.*No such file or directory/,
+ title: 'File Not Found',
+ getMessage: () => {
+ const fileMatch = output?.match(/'([^']+)'/);
+ const fileName = fileMatch ? fileMatch[1] : 'file';
+
+ return `The file '${fileName}' does not exist and cannot be removed.\n\nSuggestion: Use 'ls' to check what files exist, or use 'rm -f' to ignore missing files.`;
+ },
+ },
+ {
+ pattern: /No such file or directory/,
+ title: 'File or Directory Not Found',
+ getMessage: () => {
+ if (trimmedCommand.startsWith('cd ')) {
+ const dirMatch = trimmedCommand.match(/cd\s+(.+)/);
+ const dirName = dirMatch ? dirMatch[1] : 'directory';
+
+ return `The directory '${dirName}' does not exist.\n\nSuggestion: Use 'mkdir -p ${dirName}' to create it first, or check available directories with 'ls'.`;
+ }
+
+ return `The specified file or directory does not exist.\n\nSuggestion: Check the path and use 'ls' to see available files.`;
+ },
+ },
+ {
+ pattern: /Permission denied/,
+ title: 'Permission Denied',
+ getMessage: () =>
+ `Permission denied for '${firstWord}'.\n\nSuggestion: The file may not be executable. Try 'chmod +x filename' first.`,
+ },
+ {
+ pattern: /command not found/,
+ title: 'Command Not Found',
+ getMessage: () =>
+ `The command '${firstWord}' is not available in WebContainer.\n\nSuggestion: Check available commands or use a package manager to install it.`,
+ },
+ {
+ pattern: /Is a directory/,
+ title: 'Target is a Directory',
+ getMessage: () =>
+ `Cannot perform this operation - target is a directory.\n\nSuggestion: Use 'ls' to list directory contents or add appropriate flags.`,
+ },
+ {
+ pattern: /File exists/,
+ title: 'File Already Exists',
+ getMessage: () => `File already exists.\n\nSuggestion: Use a different name or add '-f' flag to overwrite.`,
+ },
+ ];
+
+ // Try to match known error patterns
+ for (const errorPattern of errorPatterns) {
+ if (output && errorPattern.pattern.test(output)) {
+ return {
+ title: errorPattern.title,
+ details: errorPattern.getMessage(),
+ };
+ }
+ }
+
+ // Generic error with suggestions based on command type
+ let suggestion = '';
+
+ if (trimmedCommand.startsWith('npm ')) {
+ suggestion = '\n\nSuggestion: Try running "npm install" first or check package.json.';
+ } else if (trimmedCommand.startsWith('git ')) {
+ suggestion = "\n\nSuggestion: Check if you're in a git repository or if remote is configured.";
+ } else if (trimmedCommand.match(/^(ls|cat|rm|cp|mv)/)) {
+ suggestion = '\n\nSuggestion: Check file paths and use "ls" to see available files.';
+ }
+
+ return {
+ title: `Command Failed (exit code: ${exitCode})`,
+ details: `Command: ${trimmedCommand}\n\nOutput: ${output || 'No output available'}${suggestion}`,
+ };
+ }
}
diff --git a/app/lib/runtime/enhanced-message-parser.ts b/app/lib/runtime/enhanced-message-parser.ts
index 9820302df0..0c96313687 100644
--- a/app/lib/runtime/enhanced-message-parser.ts
+++ b/app/lib/runtime/enhanced-message-parser.ts
@@ -12,6 +12,22 @@ export class EnhancedStreamingMessageParser extends StreamingMessageParser {
private _processedCodeBlocks = new Map>();
private _artifactCounter = 0;
+ // Optimized command pattern lookup
+ private _commandPatternMap = new Map([
+ ['npm', /^(npm|yarn|pnpm)\s+(install|run|start|build|dev|test|init|create|add|remove)/],
+ ['git', /^(git)\s+(add|commit|push|pull|clone|status|checkout|branch|merge|rebase|init|remote|fetch|log)/],
+ ['docker', /^(docker|docker-compose)\s+/],
+ ['build', /^(make|cmake|gradle|mvn|cargo|go)\s+/],
+ ['network', /^(curl|wget|ping|ssh|scp|rsync)\s+/],
+ ['webcontainer', /^(cat|chmod|cp|echo|hostname|kill|ln|ls|mkdir|mv|ps|pwd|rm|rmdir|xxd)\s*/],
+ ['webcontainer-extended', /^(alias|cd|clear|env|false|getconf|head|sort|tail|touch|true|uptime|which)\s*/],
+ ['interpreters', /^(node|python|python3|java|go|rust|ruby|php|perl)\s+/],
+ ['text-processing', /^(grep|sed|awk|cut|tr|sort|uniq|wc|diff)\s+/],
+ ['archive', /^(tar|zip|unzip|gzip|gunzip)\s+/],
+ ['process', /^(ps|top|htop|kill|killall|jobs|nohup)\s*/],
+ ['system', /^(df|du|free|uname|whoami|id|groups|date|uptime)\s*/],
+ ]);
+
constructor(options: StreamingMessageParserOptions = {}) {
super(options);
}
@@ -46,32 +62,49 @@ export class EnhancedStreamingMessageParser extends StreamingMessageParser {
const processed = this._processedCodeBlocks.get(messageId)!;
- // Regex patterns for detecting code blocks with file indicators
- const patterns = [
- // Pattern 1: Explicit file creation/modification mentions
- /(?:create|update|modify|edit|write|add|generate|here'?s?|file:?)\s+(?:a\s+)?(?:new\s+)?(?:file\s+)?(?:called\s+)?[`'"]*([\/\w\-\.]+\.\w+)[`'"]*:?\s*\n+```(\w*)\n([\s\S]*?)```/gi,
+ let enhanced = input;
- // Pattern 2: Code blocks with filename comments
- /```(\w*)\n(?:\/\/|#|