From 2ce58efe9c4a0bcb773b5a12f8af07920ba75d97 Mon Sep 17 00:00:00 2001 From: xKevIsDev Date: Fri, 25 Jul 2025 01:23:32 +0100 Subject: [PATCH 1/2] refactor: update styling and structure in ToolInvocations and ToolCallsList components - Changed background classes for better visual consistency. - Simplified the structure of ToolCallsList, enhancing readability and layout. - Improved button styles for better user interaction and accessibility. --- app/components/chat/ToolInvocations.tsx | 70 ++++++------------------- 1 file changed, 16 insertions(+), 54 deletions(-) diff --git a/app/components/chat/ToolInvocations.tsx b/app/components/chat/ToolInvocations.tsx index e61a8e0017..280543fc08 100644 --- a/app/components/chat/ToolInvocations.tsx +++ b/app/components/chat/ToolInvocations.tsx @@ -105,14 +105,14 @@ export const ToolInvocations = memo(({ toolInvocations, toolCallAnnotations, add
- Calling MCP tool{' '} - +
+
+
+ {toolName} + + {annotation?.toolDescription} +
- {expanded[toolCallId] && ( -
-
-
-
- Description:{' '} - - {annotation?.toolDescription} - -
-
-
- -
-
-
-
- )} -
+
@@ -239,6 +239,16 @@ const ActionList = memo(({ actions }: ActionListProps) => { {action.filePath}
+ ) : type === 'edit' ? ( +
+ Edit{' '} + openArtifactInWorkbench(action.filePath)} + > + {action.filePath} + +
) : type === 'shell' ? (
Run command @@ -255,7 +265,7 @@ const ActionList = memo(({ actions }: ActionListProps) => { ) : null}
- {(type === 'shell' || type === 'start') && ( + {(type === 'shell' || type === 'start' || type === 'edit') && ( `${i + 1}|${v}`) + .map((v, i) => `${i + 1}|${v}`) .join('\n'); let filePath = path; diff --git a/app/lib/common/prompts/new-prompt.ts b/app/lib/common/prompts/new-prompt.ts index 63f564ae9a..0c0f0a7fca 100644 --- a/app/lib/common/prompts/new-prompt.ts +++ b/app/lib/common/prompts/new-prompt.ts @@ -168,6 +168,7 @@ The year is 2025. - shell: Running commands (use --yes for npx/npm create, && for sequences, NEVER re-run dev servers) - start: Starting project (use ONLY for project startup, LAST action) - file: Creating/updating files (add filePath and contentType attributes) + - edit: Modifying existing files with diffs (add filePath and contentType attributes) File Action Rules: - Only include new/modified files @@ -175,6 +176,90 @@ The year is 2025. - NEVER use diffs for new files or SQL migrations - FORBIDDEN: Binary files, base64 assets + + + + Your goal is to produce accurate, clean diff patches that apply seamlessly to existing files. Ensure the changes are logically grouped, correctly formatted, and maintain consistency throughout the file. + + For each file that you edit, write out the changes similar to a unified diff like 'diff -u' would produce. + CRITICAL: ONLY diff content should be provided, no other text. + + General Principles: + - Changes should be atomic and logically related + - Avoid making any unrelated modifications + - Include diffType="unified" attribute + - Format: + + Hunk Organization Guidelines: + - Before writing any hunks, think step-by-step and plan all necessary changes holistically + - Changes within a hunk must be logically related—never jump between unrelated parts of the file + - If moving code, delete it from the original location first, then add it to the new location + + Specific Formatting Rules: + - Start each hunk with '@@ .. @@' + - NEVER include line numbers or timestamps, these are not needed for the user's patch tool + - CRITICAL: You MUST PREFIX unchanged lines with a single space (' ') to ensure the user's patch tool can interpret them correctly as context lines + - Use '-' to indicate lines to be removed and '+' for lines to be added + - Indentation and whitespace MUST match EXACTLY + - You MUST include full lines of code; DO NOT include partial lines + - EVERY line in the diff must start with either ' ' (context), '-' (removal), or '+' (addition) + + Providing Enough Context: + - Always provide sufficient context lines around your changes to ensure they apply correctly + - Context lines are CRUCIAL for guaranteeing that your diff integrates seamlessly + - Include as many unchanged lines as necessary to clearly identify where the edits belong and to avoid ambiguity during patching + - The goal is to provide enough context so that the change can be applied even if minor + - Example of correct context lines: + @@ -5,6 +5,7 @@ + import React from 'react' + import { Camera, Heart, Star } from 'lucide-react' + +import { CheckCircle, Mail, MapPin, Phone } from 'lucide-react' + + function App() { + return ( + + Handling Blocks of Code: + - When editing any code block (function, class, loop, component, etc.), choose ONE of these approaches: + + 1. If only modifying the internal content (block structure remains the same): + + \`\`\` + @@ .. @@ + if (condition) { + - doSomething(); + - doSomethingElse(); + + doSomethingBetter(); + } + \`\`\` + + 2. If changing the block structure, replace the ENTIRE block: + + \`\`\` + @@ .. @@ + -if (condition) { + - doSomething(); + - doSomethingElse(); + -} + +if (condition && otherCondition) { + + doSomethingBetter(); + +} else { + + handleError(); + +} + \`\`\` + + - NEVER leave a block partially complete - always include matching opening/closing brackets or make sure they are preserved + - When replacing an entire block, you MUST include ALL lines from opening to closing brackets + + Common Pitfalls to Avoid: + - Do NOT introduce unnecessary hunks + - NEVER duplicate imports + - ALWAYS maintain existing formatting and indentation + - Ensure each diff applies cleanly without causing compilation or runtime errors + + Consistency and Completeness: + - Check for any related changes that need to be made to ensure consistency (e.g., changing all occurrences of a renamed variable) + + Action Order: - Create files BEFORE shell commands that depend on them - Update package.json FIRST, then install dependencies @@ -297,6 +382,39 @@ npm run dev The development server is now running. Ready for your next instructions. + + Add a console.log statement to the main.js file + I'll add a console.log statement to the main.js file using the edit action. + + + +@@ -1,3 +1,4 @@ + import './style.css' + import javascriptLogo from './javascript.svg' ++console.log('Hello from main.js!') + document.querySelector('#app').innerHTML = \` + + + +The console.log statement has been added to main.js. + + + Replace the console.log with a different message + I'll replace the console.log message in the main.js file. + + + +@@ -1,4 +1,4 @@ + import './style.css' + import javascriptLogo from './javascript.svg' +-console.log('Hello from main.js!') ++console.log('Updated message from main.js!') + document.querySelector('#app').innerHTML = \` + + + +The console.log message has been updated. + `; export const CONTINUE_PROMPT = stripIndents` diff --git a/app/lib/hooks/useMessageParser.ts b/app/lib/hooks/useMessageParser.ts index e08e77e91e..5a0faa03ad 100644 --- a/app/lib/hooks/useMessageParser.ts +++ b/app/lib/hooks/useMessageParser.ts @@ -23,14 +23,14 @@ const messageParser = new StreamingMessageParser({ logger.trace('onActionOpen', data.action); // we only add shell actions when when the close tag got parsed because only then we have the content - if (data.action.type === 'file') { + if (data.action.type === 'file' || data.action.type === 'edit') { workbenchStore.addAction(data); } }, onActionClose: (data) => { logger.trace('onActionClose', data.action); - if (data.action.type !== 'file') { + if (data.action.type !== 'file' && data.action.type !== 'edit') { workbenchStore.addAction(data); } diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 58de741732..20074df576 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -6,6 +6,7 @@ import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; import type { ActionCallbackData } from './message-parser'; import type { BoltShell } from '~/utils/shell'; +import { applyPatch } from '~/utils/diff'; const logger = createScopedLogger('ActionRunner'); @@ -163,6 +164,10 @@ export class ActionRunner { await this.#runFileAction(action); break; } + case 'edit': { + await this.#runEditAction(action); + break; + } case 'supabase': { try { await this.handleSupabaseAction(action as SupabaseAction); @@ -329,6 +334,69 @@ export class ActionRunner { } } + async #runEditAction(action: ActionState) { + if (action.type !== 'edit') { + unreachable('Expected edit action'); + } + + const webcontainer = await this.#webcontainer; + const relativePath = nodePath.relative(webcontainer.workdir, action.filePath); + + // Check if the file exists + try { + await webcontainer.fs.readFile(relativePath, 'utf-8'); + } catch { + throw new Error(`File ${relativePath} does not exist. Use 'file' action to create new files.`); + } + + // Read the current file content + const currentContent = await webcontainer.fs.readFile(relativePath, 'utf-8'); + + logger.debug(`Applying patch to ${relativePath}`, { + originalLines: currentContent.split('\n').length, + diffContent: action.content, + }); + + // Apply the diff/patch to the current content + let newContent: string; + + if (action.diffType === 'unified') { + // Handle unified diff format + newContent = this.#applyUnifiedDiff(currentContent, action.content); + } else { + // Handle structured diff or default to unified + newContent = this.#applyUnifiedDiff(currentContent, action.content); + } + + logger.debug(`Patch applied successfully to ${relativePath}`, { + newLines: newContent.split('\n').length, + changed: newContent !== currentContent, + newContent, + }); + + // Write the modified content back to the file + try { + await webcontainer.fs.writeFile(relativePath, newContent); + logger.debug(`File edited ${relativePath}`); + } catch (error) { + logger.error('Failed to write edited file\n\n', error); + throw error; + } + } + + #applyUnifiedDiff(originalContent: string, diffContent: string): string { + try { + // Use the existing applyPatch utility from utils/diff + const result = applyPatch(originalContent, diffContent); + + // applyPatch now always returns a string (original content if it fails) + return result; + } catch (error) { + logger.error('Failed to apply diff patch\n\n', error); + throw new Error(`Failed to apply diff patch: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + #updateAction(id: string, newState: ActionStateUpdate) { const actions = this.actions.get(); diff --git a/app/lib/runtime/message-parser.ts b/app/lib/runtime/message-parser.ts index cfe65268a8..47f3257d78 100644 --- a/app/lib/runtime/message-parser.ts +++ b/app/lib/runtime/message-parser.ts @@ -1,4 +1,12 @@ -import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction, SupabaseAction } from '~/types/actions'; +import type { + ActionType, + BoltAction, + BoltActionData, + FileAction, + EditAction, + ShellAction, + SupabaseAction, +} from '~/types/actions'; import type { BoltArtifactData } from '~/types/artifact'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; @@ -194,6 +202,24 @@ export class StreamingMessageParser { filePath: currentAction.filePath, }, }); + } else if ('type' in currentAction && currentAction.type === 'edit') { + let content = input.slice(i); + + if (!currentAction.filePath.endsWith('.md')) { + content = cleanoutMarkdownSyntax(content); + content = cleanEscapedTags(content); + } + + this._options.callbacks?.onActionStream?.({ + artifactId: currentArtifact.id, + messageId, + actionId: String(state.actionId - 1), + action: { + ...(currentAction as EditAction), + content, + filePath: currentAction.filePath, + }, + }); } break; @@ -356,11 +382,25 @@ export class StreamingMessageParser { } (actionAttributes as FileAction).filePath = filePath; + } else if (actionType === 'edit') { + const filePath = this.#extractAttribute(actionTag, 'filePath') as string; + const diffType = this.#extractAttribute(actionTag, 'diffType') as 'unified' | 'structured'; + + if (!filePath) { + logger.warn('Edit action requires a filePath'); + throw new Error('Edit action requires a filePath'); + } + + (actionAttributes as EditAction).filePath = filePath; + + if (diffType) { + (actionAttributes as EditAction).diffType = diffType; + } } else if (!['shell', 'start'].includes(actionType)) { logger.warn(`Unknown action type '${actionType}'`); } - return actionAttributes as FileAction | ShellAction; + return actionAttributes as FileAction | EditAction | ShellAction; } #extractAttribute(tag: string, attributeName: string): string | undefined { diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index e7b69db17d..b32526c5ba 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -591,6 +591,27 @@ export class WorkbenchStore { await artifact.runner.runAction(data); this.resetAllFileModifications(); } + } else if (data.action.type === 'edit') { + const wc = await webcontainer; + const fullPath = path.join(wc.workdir, data.action.filePath); + + if (this.selectedFile.value !== fullPath) { + this.setSelectedFile(fullPath); + } + + if (this.currentView.value !== 'code') { + this.currentView.set('code'); + } + + /* + * For edit actions, we don't update the editor content immediately + * The action runner will handle applying the diff + */ + await artifact.runner.runAction(data, isStreaming); + + if (!isStreaming) { + this.resetAllFileModifications(); + } } else { await artifact.runner.runAction(data); } diff --git a/app/types/actions.ts b/app/types/actions.ts index 95c75ba661..f605e70629 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -1,6 +1,6 @@ import type { Change } from 'diff'; -export type ActionType = 'file' | 'shell' | 'supabase'; +export type ActionType = 'file' | 'edit' | 'shell' | 'supabase'; export interface BaseAction { content: string; @@ -11,6 +11,17 @@ export interface FileAction extends BaseAction { filePath: string; } +export interface EditAction extends BaseAction { + type: 'edit'; + filePath: string; + + /* + * The content field contains the diff/patch content + * This could be unified diff format, or a structured diff object + */ + diffType?: 'unified' | 'structured'; +} + export interface ShellAction extends BaseAction { type: 'shell'; } @@ -30,7 +41,7 @@ export interface SupabaseAction extends BaseAction { projectId?: string; } -export type BoltAction = FileAction | ShellAction | StartAction | BuildAction | SupabaseAction; +export type BoltAction = FileAction | EditAction | ShellAction | StartAction | BuildAction | SupabaseAction; export type BoltActionData = BoltAction | BaseAction; diff --git a/app/utils/diff.ts b/app/utils/diff.ts index 25cde26f07..a7b61999c5 100644 --- a/app/utils/diff.ts +++ b/app/utils/diff.ts @@ -115,3 +115,210 @@ export function fileModificationsToHTML(modifications: FileModifications) { return result.join('\n'); } + +/** + * Applies a unified diff patch to the original content. + * Robust implementation that handles various diff formats and context mismatches gracefully. + * + * @param originalContent - The original file content + * @param diffContent - The unified diff content + * @returns The modified content + */ +export function applyPatch(originalContent: string, diffContent: string): string { + try { + const originalLines = originalContent.split('\n'); + const diffLines = diffContent.split('\n'); + const result: string[] = []; + + let originalIndex = 0; + let diffIndex = 0; + + while (diffIndex < diffLines.length) { + const diffLine = diffLines[diffIndex]; + + // Skip empty lines + if (!diffLine.trim()) { + diffIndex++; + continue; + } + + /* + * Parse the hunk header - support both formats: + * 1. @@ -line,count +line,count @@ (with line numbers) + * 2. @@ .. @@ (without line numbers, as mentioned in prompt) + */ + const hunkMatch = diffLine.match(/^@@ -(\d+),?(\d+)? \+(\d+),?(\d+)? @@/) || diffLine.match(/^@@ \.\. @@/); + + if (hunkMatch) { + let oldStart = 0; + let oldCount = 0; + let newCount = 0; + + // If we have line numbers, use them; otherwise, start from current position + if (hunkMatch[1] && hunkMatch[3]) { + oldStart = parseInt(hunkMatch[1], 10); + oldCount = parseInt(hunkMatch[2] || '1', 10); + newCount = parseInt(hunkMatch[4] || '1', 10); + + // Add all lines before this hunk (unchanged) + while (originalIndex < oldStart - 1) { + if (originalIndex < originalLines.length) { + result.push(originalLines[originalIndex]); + } + + originalIndex++; + } + } + + diffIndex++; + + // Process the hunk content + let addedLines = 0; + let removedLines = 0; + + while (diffIndex < diffLines.length) { + const line = diffLines[diffIndex]; + + // Check if we've reached the next hunk + if ( + line.startsWith('@@') && + (line.match(/^@@ -(\d+),?(\d+)? \+(\d+),?(\d+)? @@/) || line.match(/^@@ \.\. @@/)) + ) { + break; + } + + if (line.startsWith(' ')) { + // Context line - should match the original at this position + if (originalIndex < originalLines.length) { + const expectedLine = line.slice(1); + const actualLine = originalLines[originalIndex]; + + // Verify the context line matches (with flexibility for whitespace) + if (actualLine === expectedLine || actualLine.trim() === expectedLine.trim()) { + result.push(originalLines[originalIndex]); + originalIndex++; + } else { + // Context mismatch - try to find the line nearby + let found = false; + const searchRange = 5; // Look within 5 lines + + for ( + let i = Math.max(0, originalIndex - searchRange); + i < Math.min(originalLines.length, originalIndex + searchRange); + i++ + ) { + if (originalLines[i] === expectedLine || originalLines[i].trim() === expectedLine.trim()) { + // Found the context line nearby, add skipped lines and continue + while (originalIndex < i) { + result.push(originalLines[originalIndex]); + originalIndex++; + } + result.push(originalLines[originalIndex]); + originalIndex++; + found = true; + break; + } + } + + if (!found) { + /* + * If we can't find the context, just add the original line and continue + * This prevents the patch from failing due to minor mismatches + */ + result.push(originalLines[originalIndex]); + originalIndex++; + } + } + } else { + // We've reached the end of the original file + console.warn('Reached end of file while processing context'); + } + } else if (line.startsWith('-')) { + // Remove line - should match the original at this position + if (originalIndex < originalLines.length) { + const expectedLine = line.slice(1); + const actualLine = originalLines[originalIndex]; + + // Verify the line to remove matches (with flexibility for whitespace) + if (actualLine === expectedLine || actualLine.trim() === expectedLine.trim()) { + originalIndex++; // Skip this line (remove it) + removedLines++; + } else { + // Line doesn't match exactly - try to find it nearby + let found = false; + const searchRange = 5; // Look within 5 lines + + for ( + let i = Math.max(0, originalIndex - searchRange); + i < Math.min(originalLines.length, originalIndex + searchRange); + i++ + ) { + if (originalLines[i] === expectedLine || originalLines[i].trim() === expectedLine.trim()) { + // Found the line to remove nearby, add skipped lines and skip the target + while (originalIndex < i) { + result.push(originalLines[originalIndex]); + originalIndex++; + } + originalIndex++; // Skip the line to remove + removedLines++; + found = true; + break; + } + } + + if (!found) { + /* + * If we can't find the line to remove, just skip it and continue + * This prevents the patch from failing due to minor mismatches + */ + console.warn('Line to remove not found, skipping', { + expected: expectedLine, + lineNumber: originalIndex + 1, + }); + } + } + } else { + // We've reached the end of the original file + console.warn('Reached end of file while processing deletion'); + } + } else if (line.startsWith('+')) { + // Add line - always add it + result.push(line.slice(1)); + addedLines++; + } + + diffIndex++; + } + + // Log statistics for debugging but don't fail + if (oldCount > 0 && (Math.abs(removedLines - oldCount) > 2 || Math.abs(addedLines - newCount) > 2)) { + console.warn('Hunk statistics mismatch (continuing anyway)', { + removedLines, + oldCount, + addedLines, + newCount, + hunkHeader: diffLine, + }); + } + + continue; + } + + // If we reach here, it's not a hunk header - skip it + diffIndex++; + } + + // Add remaining original lines + while (originalIndex < originalLines.length) { + result.push(originalLines[originalIndex]); + originalIndex++; + } + + return result.join('\n'); + } catch (error) { + console.error('Failed to apply patch:', error); + + // Return the original content if patch application fails + return originalContent; + } +}