sonance-brand-mcp 1.3.22 → 1.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,50 +55,63 @@ interface BackupManifest {
55
55
 
56
56
  const BACKUP_ROOT = ".sonance-backups";
57
57
 
58
- const VISION_SYSTEM_PROMPT = `You are a React/Tailwind CSS expert with vision capabilities for the Sonance brand system.
58
+ const VISION_SYSTEM_PROMPT = `You are an expert React/TypeScript developer with vision capabilities. You can see screenshots and modify code.
59
59
 
60
- You can see screenshots of web pages and understand their visual layout, then modify code to implement requested changes.
60
+ ═══════════════════════════════════════════════════════════════════════════════
61
+ UNDERSTAND THE USER'S REQUEST NATURALLY
62
+ ═══════════════════════════════════════════════════════════════════════════════
63
+
64
+ Read the user's request and do exactly what they ask. You know React, TypeScript, Tailwind CSS, and JSX - use that knowledge naturally.
65
+
66
+ - If they say "add X", ADD X to the code (don't replace or remove anything else)
67
+ - If they say "change X to Y", find X and change it to Y
68
+ - If they say "make X bigger/smaller/different color", adjust the relevant properties
69
+ - If they say "remove X", remove X
70
+ - If they say "wrap X with Y", add Y as a parent around X
71
+
72
+ For any change:
73
+ 1. Read the code context provided
74
+ 2. Understand what the user wants
75
+ 3. Generate patches that accomplish exactly that
76
+ 4. If adding a component/icon requires an import, include a patch for the import too
77
+
78
+ Don't overthink - just make the change the user requested.
61
79
 
62
80
  ═══════════════════════════════════════════════════════════════════════════════
63
- CRITICAL RULES
81
+ PATCH FORMAT
64
82
  ═══════════════════════════════════════════════════════════════════════════════
65
83
 
66
- **ANALYSIS:**
67
- 1. CAREFULLY analyze the screenshot to understand the current visual state
68
- 2. Identify elements mentioned in the user's request
69
- 3. Understand the current styling and layout
70
- 4. Consider how changes will affect the overall design
71
-
72
- **PRESERVATION RULES:**
73
- 5. NEVER delete or remove existing content, children, or JSX elements
74
- 6. NEVER change component structure unless specifically requested
75
- 7. NEVER modify TypeScript types, imports, or exports unless necessary
76
- 8. NEVER remove data-sonance-* attributes
77
-
78
- **CHANGE RULES:**
79
- 9. Make ONLY the changes requested by the user
80
- 10. Modify the minimum amount of code necessary
81
- 11. Use semantic Tailwind classes (bg-primary, text-foreground, etc.)
82
- 12. Maintain dark mode compatibility with CSS variables
83
- 13. Keep the cn() utility for className merging
84
+ Return search/replace patches (NOT full files). The system applies your patches to the original.
85
+
86
+ **Patch Rules:**
87
+ - "search" must match the original code EXACTLY (including whitespace/indentation)
88
+ - "replace" contains your modified version
89
+ - Include 2-4 lines of context in "search" to make it unique
90
+ - You may ONLY edit files provided in the PAGE CONTEXT section
84
91
 
85
92
  **SONANCE BRAND COLORS:**
86
- - Charcoal: #333F48, #343D46 (primary)
87
- - Silver: #E2E2E2, #D1D1D6 (secondary)
93
+ - Charcoal: #333F48 (primary text)
88
94
  - IPORT Orange: #FC4C02
89
- - IPORT Dark: #0F161D
90
95
  - Blaze Blue: #00A3E1
91
- - Blaze Red: #C02B0A
92
96
 
93
97
  **RESPONSE FORMAT:**
94
- Return ONLY a valid JSON object. Do not include any conversational text before or after the JSON.
95
- The JSON must include:
96
- - "reasoning": Brief explanation of what you see in the screenshot and your plan
97
- - "modifications": Array of file modifications, each with:
98
- - "filePath": Path to the file
99
- - "modifiedContent": Complete updated file content
100
- - "explanation": What changed in this file
101
- - "explanation": Overall summary of changes`;
98
+ Return ONLY valid JSON:
99
+ {
100
+ "reasoning": "What you understood from the request and your plan",
101
+ "modifications": [
102
+ {
103
+ "filePath": "path/to/file.tsx",
104
+ "patches": [
105
+ {
106
+ "search": "exact code to find",
107
+ "replace": "the replacement code",
108
+ "explanation": "what this patch does"
109
+ }
110
+ ]
111
+ }
112
+ ],
113
+ "explanation": "summary of changes made"
114
+ }`;
102
115
 
103
116
  export async function POST(request: Request) {
104
117
  // Only allow in development
@@ -234,12 +247,19 @@ GLOBALS.CSS (relevant theme variables):
234
247
  ${pageContext.globalsCSS.substring(0, 2000)}${pageContext.globalsCSS.length > 2000 ? "\n/* ... (truncated) */" : ""}
235
248
  \`\`\`
236
249
 
250
+ VALID FILES YOU MAY EDIT:
251
+ ${pageContext.pageFile ? `- ${pageContext.pageFile}` : ""}
252
+ ${pageContext.componentSources.map((c) => `- ${c.path}`).join("\n")}
253
+
237
254
  INSTRUCTIONS:
238
255
  1. Look at the screenshot and identify elements mentioned in the user's request
239
256
  2. Review the code to understand current implementation
240
- 3. Determine which files need modifications
241
- 4. Generate complete modified code for each file
242
- 5. Return as JSON in the specified format`;
257
+ 3. Make SURGICAL EDITS - change only the specific lines needed
258
+ 4. PRESERVE all existing logic, hooks, API calls, and error handling
259
+ 5. Return the FULL file content (no truncation, no "// ... existing ..." comments)
260
+ 6. Only use file paths from the VALID FILES list above
261
+
262
+ CRITICAL: Your modified file should have approximately the same number of lines as the original. If the original has 200 lines and your output has 50 lines, you have made a mistake.`;
243
263
 
244
264
  messageContent.push({
245
265
  type: "text",
@@ -270,13 +290,15 @@ INSTRUCTIONS:
270
290
  );
271
291
  }
272
292
 
273
- // Parse AI response
293
+ // Parse AI response - now expecting patches instead of full file content
274
294
  let aiResponse: {
275
295
  reasoning?: string;
276
296
  modifications: Array<{
277
297
  filePath: string;
278
- modifiedContent: string;
279
- explanation: string;
298
+ patches?: Patch[];
299
+ // Legacy support for modifiedContent (will be deprecated)
300
+ modifiedContent?: string;
301
+ explanation?: string;
280
302
  }>;
281
303
  explanation?: string;
282
304
  };
@@ -318,8 +340,10 @@ INSTRUCTIONS:
318
340
  });
319
341
  }
320
342
 
321
- // Read original content and prepare modifications
343
+ // Process modifications - apply patches to get modified content
322
344
  const modifications: VisionFileModification[] = [];
345
+ const patchErrors: string[] = [];
346
+
323
347
  for (const mod of aiResponse.modifications) {
324
348
  const fullPath = path.join(projectRoot, mod.filePath);
325
349
  let originalContent = "";
@@ -327,15 +351,77 @@ INSTRUCTIONS:
327
351
  originalContent = fs.readFileSync(fullPath, "utf-8");
328
352
  }
329
353
 
354
+ let modifiedContent: string;
355
+ let explanation = mod.explanation || "";
356
+
357
+ // Check if AI returned patches (new format) or modifiedContent (legacy)
358
+ if (mod.patches && mod.patches.length > 0) {
359
+ // New patch-based approach
360
+ console.log(`[Apply-First] Applying ${mod.patches.length} patches to ${mod.filePath}`);
361
+
362
+ const patchResult = applyPatches(originalContent, mod.patches);
363
+
364
+ if (!patchResult.success) {
365
+ const failedMessages = patchResult.failedPatches.map(
366
+ (f) => `Patch failed: ${f.error}`
367
+ ).join("\n");
368
+ patchErrors.push(`${mod.filePath}:\n${failedMessages}`);
369
+
370
+ // If some patches succeeded, use partial result
371
+ if (patchResult.appliedPatches > 0) {
372
+ console.warn(`[Apply-First] ${patchResult.appliedPatches}/${mod.patches.length} patches applied to ${mod.filePath}`);
373
+ modifiedContent = patchResult.modifiedContent;
374
+ explanation += ` (${patchResult.appliedPatches}/${mod.patches.length} patches applied)`;
375
+ } else {
376
+ continue; // Skip this file entirely if no patches worked
377
+ }
378
+ } else {
379
+ modifiedContent = patchResult.modifiedContent;
380
+ console.log(`[Apply-First] All ${mod.patches.length} patches applied successfully to ${mod.filePath}`);
381
+ }
382
+ } else if (mod.modifiedContent) {
383
+ // Legacy: AI returned full file content
384
+ console.warn(`[Apply-First] Legacy modifiedContent received for ${mod.filePath} - patch-based format preferred`);
385
+ modifiedContent = mod.modifiedContent;
386
+
387
+ // Validate the modification using legacy validation
388
+ const validation = validateModification(originalContent, modifiedContent, mod.filePath);
389
+ if (!validation.valid) {
390
+ patchErrors.push(`${mod.filePath}: ${validation.error}`);
391
+ continue;
392
+ }
393
+ } else {
394
+ // No patches and no modifiedContent - skip
395
+ console.warn(`[Apply-First] No patches or modifiedContent for ${mod.filePath}`);
396
+ continue;
397
+ }
398
+
330
399
  modifications.push({
331
400
  filePath: mod.filePath,
332
401
  originalContent,
333
- modifiedContent: mod.modifiedContent,
334
- diff: generateSimpleDiff(originalContent, mod.modifiedContent),
335
- explanation: mod.explanation,
402
+ modifiedContent,
403
+ diff: generateSimpleDiff(originalContent, modifiedContent),
404
+ explanation,
336
405
  });
337
406
  }
338
407
 
408
+ // If all modifications failed, return error
409
+ if (patchErrors.length > 0 && modifications.length === 0) {
410
+ console.error("All AI patches failed:", patchErrors);
411
+ return NextResponse.json(
412
+ {
413
+ success: false,
414
+ error: `Patch application failed:\n\n${patchErrors.join("\n\n")}`,
415
+ },
416
+ { status: 400 }
417
+ );
418
+ }
419
+
420
+ // Log patch errors as warnings if some modifications succeeded
421
+ if (patchErrors.length > 0) {
422
+ console.warn("Some patches failed:", patchErrors);
423
+ }
424
+
339
425
  // Create backups and apply changes atomically
340
426
  const applyResult = await applyChangesWithBackup(
341
427
  modifications,
@@ -650,3 +736,175 @@ function generateSimpleDiff(original: string, modified: string): string {
650
736
 
651
737
  return diff.join("\n");
652
738
  }
739
+
740
+ /**
741
+ * Patch interface for search/replace operations
742
+ */
743
+ interface Patch {
744
+ search: string;
745
+ replace: string;
746
+ explanation: string;
747
+ }
748
+
749
+ /**
750
+ * Result of applying patches to a file
751
+ */
752
+ interface ApplyPatchesResult {
753
+ success: boolean;
754
+ modifiedContent: string;
755
+ appliedPatches: number;
756
+ failedPatches: { patch: Patch; error: string }[];
757
+ }
758
+
759
+ /**
760
+ * Apply search/replace patches to file content
761
+ * This is the core of the patch-based editing system
762
+ */
763
+ function applyPatches(originalContent: string, patches: Patch[]): ApplyPatchesResult {
764
+ let content = originalContent;
765
+ let appliedPatches = 0;
766
+ const failedPatches: { patch: Patch; error: string }[] = [];
767
+
768
+ for (const patch of patches) {
769
+ // Normalize line endings for matching
770
+ const normalizedSearch = patch.search.replace(/\\n/g, "\n");
771
+ const normalizedReplace = patch.replace.replace(/\\n/g, "\n");
772
+
773
+ // Check if search string exists in content
774
+ if (!content.includes(normalizedSearch)) {
775
+ // Try with different whitespace normalization
776
+ const flexibleSearch = normalizedSearch.replace(/\s+/g, "\\s+");
777
+ const regex = new RegExp(flexibleSearch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\s\+/g, "\\s+"));
778
+
779
+ if (!regex.test(content)) {
780
+ failedPatches.push({
781
+ patch,
782
+ error: `Search string not found in file. First 50 chars of search: "${normalizedSearch.substring(0, 50)}..."`,
783
+ });
784
+ continue;
785
+ }
786
+
787
+ // If regex matched, use regex replace
788
+ content = content.replace(regex, normalizedReplace);
789
+ appliedPatches++;
790
+ } else {
791
+ // Exact match found - apply the replacement
792
+ // Only replace the first occurrence to be safe
793
+ const index = content.indexOf(normalizedSearch);
794
+ content =
795
+ content.substring(0, index) +
796
+ normalizedReplace +
797
+ content.substring(index + normalizedSearch.length);
798
+ appliedPatches++;
799
+ }
800
+ }
801
+
802
+ return {
803
+ success: failedPatches.length === 0,
804
+ modifiedContent: content,
805
+ appliedPatches,
806
+ failedPatches,
807
+ };
808
+ }
809
+
810
+ /**
811
+ * Validate that AI modifications are surgical edits, not complete rewrites
812
+ */
813
+ interface ValidationResult {
814
+ valid: boolean;
815
+ error?: string;
816
+ warnings: string[];
817
+ }
818
+
819
+ function validateModification(
820
+ originalContent: string,
821
+ modifiedContent: string,
822
+ filePath: string
823
+ ): ValidationResult {
824
+ const warnings: string[] = [];
825
+
826
+ // Skip validation for new files (no original content)
827
+ if (!originalContent || originalContent.trim() === "") {
828
+ return { valid: true, warnings: ["New file - no original to compare"] };
829
+ }
830
+
831
+ const originalLines = originalContent.split("\n");
832
+ const modifiedLines = modifiedContent.split("\n");
833
+
834
+ // Check 1: Truncation detection - look for placeholder comments
835
+ const truncationPatterns = [
836
+ /\/\/\s*\.\.\.\s*existing/i,
837
+ /\/\/\s*\.\.\.\s*rest\s*of/i,
838
+ /\/\/\s*\.\.\.\s*more\s*code/i,
839
+ /\/\*\s*\.\.\.\s*\*\//,
840
+ /\/\/\s*\.\.\./,
841
+ ];
842
+
843
+ for (const pattern of truncationPatterns) {
844
+ if (pattern.test(modifiedContent)) {
845
+ return {
846
+ valid: false,
847
+ error: `File ${filePath} contains truncation placeholder (e.g., "// ... existing code"). The AI must return the complete file content. Please try again.`,
848
+ warnings,
849
+ };
850
+ }
851
+ }
852
+
853
+ // Check 2: Line count shrinkage - reject if file shrinks by more than 30%
854
+ const lineDelta = modifiedLines.length - originalLines.length;
855
+ const shrinkagePercent = (lineDelta / originalLines.length) * 100;
856
+
857
+ if (shrinkagePercent < -30) {
858
+ return {
859
+ valid: false,
860
+ error: `File ${filePath} shrank from ${originalLines.length} to ${modifiedLines.length} lines (${Math.abs(shrinkagePercent).toFixed(0)}% reduction). This suggests the AI rewrote the file instead of making surgical edits. Please try a more specific request.`,
861
+ warnings,
862
+ };
863
+ }
864
+
865
+ if (shrinkagePercent < -15) {
866
+ warnings.push(`File shrank by ${Math.abs(shrinkagePercent).toFixed(0)}% - review carefully`);
867
+ }
868
+
869
+ // Check 3: Change percentage - warn if too many lines are different
870
+ let changedLines = 0;
871
+ const minLines = Math.min(originalLines.length, modifiedLines.length);
872
+
873
+ for (let i = 0; i < minLines; i++) {
874
+ if (originalLines[i] !== modifiedLines[i]) {
875
+ changedLines++;
876
+ }
877
+ }
878
+
879
+ // Add lines that were added or removed
880
+ changedLines += Math.abs(originalLines.length - modifiedLines.length);
881
+
882
+ const changePercent = (changedLines / originalLines.length) * 100;
883
+
884
+ if (changePercent > 50) {
885
+ return {
886
+ valid: false,
887
+ error: `File ${filePath} has ${changePercent.toFixed(0)}% of lines changed. This suggests the AI rewrote the file instead of making surgical edits. For safety, this change has been rejected. Please try a more specific request.`,
888
+ warnings,
889
+ };
890
+ }
891
+
892
+ if (changePercent > 30) {
893
+ warnings.push(`${changePercent.toFixed(0)}% of lines changed - larger than expected for a surgical edit`);
894
+ }
895
+
896
+ // Check 4: Import preservation - ensure imports aren't removed
897
+ const importRegex = /^import\s+/gm;
898
+ const originalImports = (originalContent.match(importRegex) || []).length;
899
+ const modifiedImports = (modifiedContent.match(importRegex) || []).length;
900
+
901
+ if (modifiedImports < originalImports * 0.5 && originalImports > 2) {
902
+ return {
903
+ valid: false,
904
+ error: `File ${filePath} went from ${originalImports} imports to ${modifiedImports}. Imports should not be removed. Please try again.`,
905
+ warnings,
906
+ };
907
+ }
908
+
909
+ return { valid: true, warnings };
910
+ }
@@ -56,59 +56,65 @@ interface VisionEditResponse {
56
56
  error?: string;
57
57
  }
58
58
 
59
- const VISION_SYSTEM_PROMPT = `You are a React/Tailwind CSS expert with vision capabilities for the Sonance brand system.
59
+ const VISION_SYSTEM_PROMPT = `You are an expert React/TypeScript developer with vision capabilities. You can see screenshots and modify code.
60
60
 
61
- You can see screenshots of web pages and understand their visual layout, then modify code to implement requested changes.
61
+ ═══════════════════════════════════════════════════════════════════════════════
62
+ UNDERSTAND THE USER'S REQUEST NATURALLY
63
+ ═══════════════════════════════════════════════════════════════════════════════
64
+
65
+ Read the user's request and do exactly what they ask. You know React, TypeScript, Tailwind CSS, and JSX - use that knowledge naturally.
66
+
67
+ - If they say "add X", ADD X to the code (don't replace or remove anything else)
68
+ - If they say "change X to Y", find X and change it to Y
69
+ - If they say "make X bigger/smaller/different color", adjust the relevant properties
70
+ - If they say "remove X", remove X
71
+ - If they say "wrap X with Y", add Y as a parent around X
72
+
73
+ For any change:
74
+ 1. Read the code context provided
75
+ 2. Understand what the user wants
76
+ 3. Generate patches that accomplish exactly that
77
+ 4. If adding a component/icon requires an import, include a patch for the import too
78
+
79
+ Don't overthink - just make the change the user requested.
62
80
 
63
81
  ═══════════════════════════════════════════════════════════════════════════════
64
- CRITICAL RULES
82
+ PATCH FORMAT
65
83
  ═══════════════════════════════════════════════════════════════════════════════
66
84
 
67
- **FILE RULES (MOST IMPORTANT):**
68
- 1. You may ONLY edit files that are provided in the PAGE CONTEXT section
69
- 2. NEVER create new files - only modify existing ones shown to you
70
- 3. The filePath in your response MUST exactly match one of the provided file paths
71
- 4. If you cannot find the right file to edit, explain this in your response instead of creating a new file
72
-
73
- **ANALYSIS:**
74
- 5. CAREFULLY analyze the screenshot to understand the current visual state
75
- 6. Identify elements mentioned in the user's request
76
- 7. Understand the current styling and layout
77
- 8. Consider how changes will affect the overall design
78
-
79
- **PRESERVATION RULES:**
80
- 9. NEVER delete or remove existing content, children, or JSX elements
81
- 10. NEVER change component structure unless specifically requested
82
- 11. NEVER modify TypeScript types, imports, or exports unless necessary
83
- 12. NEVER remove data-sonance-* attributes
84
-
85
- **CHANGE RULES:**
86
- 13. Make ONLY the changes requested by the user
87
- 14. Modify the minimum amount of code necessary
88
- 15. Use semantic Tailwind classes (bg-primary, text-foreground, etc.)
89
- 16. Maintain dark mode compatibility with CSS variables
90
- 17. Keep the cn() utility for className merging
91
- 18. CRITICAL: You MUST return the FULL file content in "modifiedContent". Do NOT use comments like "// ... existing code ..." or "// ... rest of file ...". Return every single line of code, even if unchanged.
92
-
93
- **SONANCE BRAND COLORS:**
94
- - Charcoal: #333F48, #343D46 (primary)
95
- - Silver: #E2E2E2, #D1D1D6 (secondary)
85
+ Return search/replace patches (NOT full files). The system applies your patches to the original.
86
+
87
+ **Patch Rules:**
88
+ - "search" must match the original code EXACTLY (including whitespace/indentation)
89
+ - "replace" contains your modified version
90
+ - Include 2-4 lines of context in "search" to make it unique
91
+ - You may ONLY edit files provided in the PAGE CONTEXT section
92
+
93
+ **SONANCE BRAND COLORS:**
94
+ - Charcoal: #333F48 (primary text)
96
95
  - IPORT Orange: #FC4C02
97
- - IPORT Dark: #0F161D
98
96
  - Blaze Blue: #00A3E1
99
- - Blaze Red: #C02B0A
100
97
 
101
98
  **RESPONSE FORMAT:**
102
- Return ONLY a valid JSON object. Do not include any conversational text before or after the JSON.
103
- The JSON must include:
104
- - "reasoning": Brief explanation of what you see in the screenshot and your plan
105
- - "modifications": Array of file modifications, each with:
106
- - "filePath": Path to the file
107
- - "modifiedContent": Complete updated file content (MUST BE FULL CONTENT, NO TRUNCATION)
108
- - "explanation": What changed in this file
109
- - "previewCSS": CSS for live preview (use [data-sonance-name="ComponentName"] selectors)
110
- - "aggregatedPreviewCSS": Combined CSS for all changes
111
- - "explanation": Overall summary of changes`;
99
+ Return ONLY valid JSON:
100
+ {
101
+ "reasoning": "What you understood from the request and your plan",
102
+ "modifications": [
103
+ {
104
+ "filePath": "path/to/file.tsx",
105
+ "patches": [
106
+ {
107
+ "search": "exact code to find",
108
+ "replace": "the replacement code",
109
+ "explanation": "what this patch does"
110
+ }
111
+ ],
112
+ "previewCSS": "optional CSS for live preview"
113
+ }
114
+ ],
115
+ "aggregatedPreviewCSS": "combined CSS for all changes",
116
+ "explanation": "summary of changes made"
117
+ }`;
112
118
 
113
119
  export async function POST(request: Request) {
114
120
  // Only allow in development
@@ -297,13 +303,15 @@ CRITICAL: Only use file paths from the VALID FILES list above. Do NOT create new
297
303
  );
298
304
  }
299
305
 
300
- // Parse AI response
306
+ // Parse AI response - now expecting patches instead of full file content
301
307
  let aiResponse: {
302
308
  reasoning?: string;
303
309
  modifications: Array<{
304
310
  filePath: string;
305
- modifiedContent: string;
306
- explanation: string;
311
+ patches?: Patch[];
312
+ // Legacy support for modifiedContent (will be deprecated)
313
+ modifiedContent?: string;
314
+ explanation?: string;
307
315
  previewCSS?: string;
308
316
  }>;
309
317
  aggregatedPreviewCSS?: string;
@@ -368,8 +376,10 @@ CRITICAL: Only use file paths from the VALID FILES list above. Do NOT create new
368
376
  );
369
377
  }
370
378
 
371
- // Read original content and generate diffs for each modification
379
+ // Process modifications - apply patches to get modified content
372
380
  const modificationsWithOriginals: VisionFileModification[] = [];
381
+ const patchErrors: string[] = [];
382
+
373
383
  for (const mod of aiResponse.modifications || []) {
374
384
  const fullPath = path.join(projectRoot, mod.filePath);
375
385
  let originalContent = "";
@@ -377,16 +387,78 @@ CRITICAL: Only use file paths from the VALID FILES list above. Do NOT create new
377
387
  originalContent = fs.readFileSync(fullPath, "utf-8");
378
388
  }
379
389
 
390
+ let modifiedContent: string;
391
+ let explanation = mod.explanation || "";
392
+
393
+ // Check if AI returned patches (new format) or modifiedContent (legacy)
394
+ if (mod.patches && mod.patches.length > 0) {
395
+ // New patch-based approach
396
+ console.log(`[Vision Mode] Applying ${mod.patches.length} patches to ${mod.filePath}`);
397
+
398
+ const patchResult = applyPatches(originalContent, mod.patches);
399
+
400
+ if (!patchResult.success) {
401
+ const failedMessages = patchResult.failedPatches.map(
402
+ (f) => `Patch failed: ${f.error}`
403
+ ).join("\n");
404
+ patchErrors.push(`${mod.filePath}:\n${failedMessages}`);
405
+
406
+ // If some patches succeeded, use partial result
407
+ if (patchResult.appliedPatches > 0) {
408
+ console.warn(`[Vision Mode] ${patchResult.appliedPatches}/${mod.patches.length} patches applied to ${mod.filePath}`);
409
+ modifiedContent = patchResult.modifiedContent;
410
+ explanation += ` (${patchResult.appliedPatches}/${mod.patches.length} patches applied)`;
411
+ } else {
412
+ continue; // Skip this file entirely if no patches worked
413
+ }
414
+ } else {
415
+ modifiedContent = patchResult.modifiedContent;
416
+ console.log(`[Vision Mode] All ${mod.patches.length} patches applied successfully to ${mod.filePath}`);
417
+ }
418
+ } else if (mod.modifiedContent) {
419
+ // Legacy: AI returned full file content
420
+ console.warn(`[Vision Mode] Legacy modifiedContent received for ${mod.filePath} - patch-based format preferred`);
421
+ modifiedContent = mod.modifiedContent;
422
+
423
+ // Validate the modification using legacy validation
424
+ const validation = validateModification(originalContent, modifiedContent, mod.filePath);
425
+ if (!validation.valid) {
426
+ patchErrors.push(`${mod.filePath}: ${validation.error}`);
427
+ continue;
428
+ }
429
+ } else {
430
+ // No patches and no modifiedContent - skip
431
+ console.warn(`[Vision Mode] No patches or modifiedContent for ${mod.filePath}`);
432
+ continue;
433
+ }
434
+
380
435
  modificationsWithOriginals.push({
381
436
  filePath: mod.filePath,
382
437
  originalContent,
383
- modifiedContent: mod.modifiedContent,
384
- diff: generateSimpleDiff(originalContent, mod.modifiedContent),
385
- explanation: mod.explanation,
438
+ modifiedContent,
439
+ diff: generateSimpleDiff(originalContent, modifiedContent),
440
+ explanation,
386
441
  previewCSS: mod.previewCSS,
387
442
  });
388
443
  }
389
444
 
445
+ // If all modifications failed, return error
446
+ if (patchErrors.length > 0 && modificationsWithOriginals.length === 0) {
447
+ console.error("All AI patches failed:", patchErrors);
448
+ return NextResponse.json(
449
+ {
450
+ success: false,
451
+ error: `Patch application failed:\n\n${patchErrors.join("\n\n")}`,
452
+ } as VisionEditResponse,
453
+ { status: 400 }
454
+ );
455
+ }
456
+
457
+ // Log patch errors as warnings if some modifications succeeded
458
+ if (patchErrors.length > 0) {
459
+ console.warn("Some patches failed:", patchErrors);
460
+ }
461
+
390
462
  // Aggregate preview CSS
391
463
  const aggregatedCSS = modificationsWithOriginals
392
464
  .filter((m) => m.previewCSS)
@@ -655,3 +727,175 @@ function generateSimpleDiff(original: string, modified: string): string {
655
727
 
656
728
  return diff.join("\n");
657
729
  }
730
+
731
+ /**
732
+ * Patch interface for search/replace operations
733
+ */
734
+ interface Patch {
735
+ search: string;
736
+ replace: string;
737
+ explanation: string;
738
+ }
739
+
740
+ /**
741
+ * Result of applying patches to a file
742
+ */
743
+ interface ApplyPatchesResult {
744
+ success: boolean;
745
+ modifiedContent: string;
746
+ appliedPatches: number;
747
+ failedPatches: { patch: Patch; error: string }[];
748
+ }
749
+
750
+ /**
751
+ * Apply search/replace patches to file content
752
+ * This is the core of the patch-based editing system
753
+ */
754
+ function applyPatches(originalContent: string, patches: Patch[]): ApplyPatchesResult {
755
+ let content = originalContent;
756
+ let appliedPatches = 0;
757
+ const failedPatches: { patch: Patch; error: string }[] = [];
758
+
759
+ for (const patch of patches) {
760
+ // Normalize line endings for matching
761
+ const normalizedSearch = patch.search.replace(/\\n/g, "\n");
762
+ const normalizedReplace = patch.replace.replace(/\\n/g, "\n");
763
+
764
+ // Check if search string exists in content
765
+ if (!content.includes(normalizedSearch)) {
766
+ // Try with different whitespace normalization
767
+ const flexibleSearch = normalizedSearch.replace(/\s+/g, "\\s+");
768
+ const regex = new RegExp(flexibleSearch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\s\+/g, "\\s+"));
769
+
770
+ if (!regex.test(content)) {
771
+ failedPatches.push({
772
+ patch,
773
+ error: `Search string not found in file. First 50 chars of search: "${normalizedSearch.substring(0, 50)}..."`,
774
+ });
775
+ continue;
776
+ }
777
+
778
+ // If regex matched, use regex replace
779
+ content = content.replace(regex, normalizedReplace);
780
+ appliedPatches++;
781
+ } else {
782
+ // Exact match found - apply the replacement
783
+ // Only replace the first occurrence to be safe
784
+ const index = content.indexOf(normalizedSearch);
785
+ content =
786
+ content.substring(0, index) +
787
+ normalizedReplace +
788
+ content.substring(index + normalizedSearch.length);
789
+ appliedPatches++;
790
+ }
791
+ }
792
+
793
+ return {
794
+ success: failedPatches.length === 0,
795
+ modifiedContent: content,
796
+ appliedPatches,
797
+ failedPatches,
798
+ };
799
+ }
800
+
801
+ /**
802
+ * Validate that AI modifications are surgical edits, not complete rewrites
803
+ */
804
+ interface ValidationResult {
805
+ valid: boolean;
806
+ error?: string;
807
+ warnings: string[];
808
+ }
809
+
810
+ function validateModification(
811
+ originalContent: string,
812
+ modifiedContent: string,
813
+ filePath: string
814
+ ): ValidationResult {
815
+ const warnings: string[] = [];
816
+
817
+ // Skip validation for new files (no original content)
818
+ if (!originalContent || originalContent.trim() === "") {
819
+ return { valid: true, warnings: ["New file - no original to compare"] };
820
+ }
821
+
822
+ const originalLines = originalContent.split("\n");
823
+ const modifiedLines = modifiedContent.split("\n");
824
+
825
+ // Check 1: Truncation detection - look for placeholder comments
826
+ const truncationPatterns = [
827
+ /\/\/\s*\.\.\.\s*existing/i,
828
+ /\/\/\s*\.\.\.\s*rest\s*of/i,
829
+ /\/\/\s*\.\.\.\s*more\s*code/i,
830
+ /\/\*\s*\.\.\.\s*\*\//,
831
+ /\/\/\s*\.\.\./,
832
+ ];
833
+
834
+ for (const pattern of truncationPatterns) {
835
+ if (pattern.test(modifiedContent)) {
836
+ return {
837
+ valid: false,
838
+ error: `File ${filePath} contains truncation placeholder (e.g., "// ... existing code"). The AI must return the complete file content. Please try again.`,
839
+ warnings,
840
+ };
841
+ }
842
+ }
843
+
844
+ // Check 2: Line count shrinkage - reject if file shrinks by more than 30%
845
+ const lineDelta = modifiedLines.length - originalLines.length;
846
+ const shrinkagePercent = (lineDelta / originalLines.length) * 100;
847
+
848
+ if (shrinkagePercent < -30) {
849
+ return {
850
+ valid: false,
851
+ error: `File ${filePath} shrank from ${originalLines.length} to ${modifiedLines.length} lines (${Math.abs(shrinkagePercent).toFixed(0)}% reduction). This suggests the AI rewrote the file instead of making surgical edits. Please try a more specific request.`,
852
+ warnings,
853
+ };
854
+ }
855
+
856
+ if (shrinkagePercent < -15) {
857
+ warnings.push(`File shrank by ${Math.abs(shrinkagePercent).toFixed(0)}% - review carefully`);
858
+ }
859
+
860
+ // Check 3: Change percentage - warn if too many lines are different
861
+ let changedLines = 0;
862
+ const minLines = Math.min(originalLines.length, modifiedLines.length);
863
+
864
+ for (let i = 0; i < minLines; i++) {
865
+ if (originalLines[i] !== modifiedLines[i]) {
866
+ changedLines++;
867
+ }
868
+ }
869
+
870
+ // Add lines that were added or removed
871
+ changedLines += Math.abs(originalLines.length - modifiedLines.length);
872
+
873
+ const changePercent = (changedLines / originalLines.length) * 100;
874
+
875
+ if (changePercent > 50) {
876
+ return {
877
+ valid: false,
878
+ error: `File ${filePath} has ${changePercent.toFixed(0)}% of lines changed. This suggests the AI rewrote the file instead of making surgical edits. For safety, this change has been rejected. Please try a more specific request.`,
879
+ warnings,
880
+ };
881
+ }
882
+
883
+ if (changePercent > 30) {
884
+ warnings.push(`${changePercent.toFixed(0)}% of lines changed - larger than expected for a surgical edit`);
885
+ }
886
+
887
+ // Check 4: Import preservation - ensure imports aren't removed
888
+ const importRegex = /^import\s+/gm;
889
+ const originalImports = (originalContent.match(importRegex) || []).length;
890
+ const modifiedImports = (modifiedContent.match(importRegex) || []).length;
891
+
892
+ if (modifiedImports < originalImports * 0.5 && originalImports > 2) {
893
+ return {
894
+ valid: false,
895
+ error: `File ${filePath} went from ${originalImports} imports to ${modifiedImports}. Imports should not be removed. Please try again.`,
896
+ warnings,
897
+ };
898
+ }
899
+
900
+ return { valid: true, warnings };
901
+ }
@@ -57,6 +57,7 @@ export function ChatInterface({
57
57
  const [input, setInput] = useState("");
58
58
  const [isProcessing, setIsProcessing] = useState(false);
59
59
  const messagesEndRef = useRef<HTMLDivElement>(null);
60
+ const inputRef = useRef<HTMLInputElement>(null);
60
61
 
61
62
  // Scroll to bottom when messages change
62
63
  useEffect(() => {
@@ -126,6 +127,7 @@ export function ChatInterface({
126
127
 
127
128
  setMessages((prev) => [...prev, userMessage]);
128
129
  setInput("");
130
+ if (inputRef.current) inputRef.current.value = "";
129
131
  setIsProcessing(true);
130
132
 
131
133
  try {
@@ -216,11 +218,14 @@ export function ChatInterface({
216
218
  };
217
219
 
218
220
  const handleSend = async (prompt: string) => {
219
- if (!prompt.trim() || isProcessing) return;
221
+ // Fallback: read from DOM if React state is empty (browser automation compatibility)
222
+ const actualPrompt = prompt || inputRef.current?.value || "";
223
+
224
+ if (!actualPrompt.trim() || isProcessing) return;
220
225
 
221
226
  // Use vision mode handler if vision mode is active
222
227
  if (visionMode) {
223
- return handleVisionEdit(prompt);
228
+ return handleVisionEdit(actualPrompt);
224
229
  }
225
230
 
226
231
  // If no component is selected, intercept the request
@@ -228,11 +233,12 @@ export function ChatInterface({
228
233
  const userMessage: ChatMessage = {
229
234
  id: `msg-${Date.now()}`,
230
235
  role: "user",
231
- content: prompt,
236
+ content: actualPrompt,
232
237
  timestamp: new Date(),
233
238
  };
234
239
  setMessages((prev) => [...prev, userMessage]);
235
240
  setInput("");
241
+ if (inputRef.current) inputRef.current.value = "";
236
242
 
237
243
  setTimeout(() => {
238
244
  const assistantMessage: ChatMessage = {
@@ -249,12 +255,13 @@ export function ChatInterface({
249
255
  const userMessage: ChatMessage = {
250
256
  id: `msg-${Date.now()}`,
251
257
  role: "user",
252
- content: prompt,
258
+ content: actualPrompt,
253
259
  timestamp: new Date(),
254
260
  };
255
261
 
256
262
  setMessages((prev) => [...prev, userMessage]);
257
263
  setInput("");
264
+ if (inputRef.current) inputRef.current.value = "";
258
265
  setIsProcessing(true);
259
266
 
260
267
  try {
@@ -285,7 +292,7 @@ export function ChatInterface({
285
292
  componentType,
286
293
  filePath,
287
294
  currentCode: sourceData.content,
288
- userRequest: prompt,
295
+ userRequest: actualPrompt,
289
296
  // Variant-scoped editing context
290
297
  editScope,
291
298
  variantId: editScope === "variant" ? variantId : undefined,
@@ -390,13 +397,14 @@ export function ChatInterface({
390
397
  {/* Input */}
391
398
  <div className="flex gap-2">
392
399
  <input
400
+ ref={inputRef}
393
401
  type="text"
394
402
  value={input}
395
403
  onChange={(e) => setInput(e.target.value)}
396
404
  onKeyDown={(e) => {
397
405
  if (e.key === "Enter" && !e.shiftKey) {
398
406
  e.preventDefault();
399
- handleSend(input);
407
+ handleSend(input || inputRef.current?.value || "");
400
408
  }
401
409
  }}
402
410
  placeholder={
@@ -418,8 +426,8 @@ export function ChatInterface({
418
426
  )}
419
427
  />
420
428
  <button
421
- onClick={() => handleSend(input)}
422
- disabled={isProcessing || !input.trim()}
429
+ onClick={() => handleSend(input || inputRef.current?.value || "")}
430
+ disabled={isProcessing}
423
431
  className={cn(
424
432
  "px-3 py-2 rounded transition-colors",
425
433
  visionMode
@@ -582,6 +582,9 @@ export function ComponentsPanel({
582
582
  )}
583
583
 
584
584
  {/* AI Chat Interface - hide when any pending edit is present */}
585
+ {/* Apply-First mode: Files written immediately with backups for instant HMR preview
586
+ User sees structural + CSS changes live, then clicks Accept or Revert
587
+ Original files are always backed up and can be restored */}
585
588
  {!pendingEdit && !visionPendingEdit && !applyFirstSession && (
586
589
  <ChatInterface
587
590
  componentType={selectedComponentType}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonance-brand-mcp",
3
- "version": "1.3.22",
3
+ "version": "1.3.25",
4
4
  "description": "MCP Server for Sonance Brand Guidelines and Component Library - gives Claude instant access to brand colors, typography, and UI components.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",