diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 69a80197a7..ad601db74c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -173,6 +173,8 @@ } let iframeLoaded = $state(false) // @hmr:keep + let lastFilesSentHash = $state(0) + let sendFilesTimeout: ReturnType | undefined function populateFiles() { setFilesInIframe(initFiles) @@ -181,20 +183,46 @@ const files = Object.fromEntries( Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/')) ) - console.log('[SEND] Sending files to iframe', { - fileCount: Object.keys(files).length, - paths: Object.keys(files), - fileSizes: Object.fromEntries( - Object.entries(files).map(([path, content]) => [path, content.length]) - ) - }) - iframe?.contentWindow?.postMessage( - { - type: 'setFiles', - files: files - }, - '*' + + // Calculate hash to detect if content actually changed + const contentHash = JSON.stringify( + Object.entries(files) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, content]) => [path, content.length, content.slice(0, 100)]) ) + .split('') + .reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0) + + // Clear any pending send + if (sendFilesTimeout) { + clearTimeout(sendFilesTimeout) + } + + // Debounce: only send after 50ms of no changes + sendFilesTimeout = setTimeout(() => { + if (contentHash === lastFilesSentHash) { + console.log('[SEND] Skipping duplicate send (hash:', contentHash, ')') + return + } + + lastFilesSentHash = contentHash + console.log('[SEND] Sending files to iframe', { + fileCount: Object.keys(files).length, + hash: contentHash, + paths: Object.keys(files), + fileSizes: Object.fromEntries( + Object.entries(files).map(([path, content]) => [path, content.length]) + ) + }) + + iframe?.contentWindow?.postMessage( + { + type: 'setFiles', + files: files + }, + '*' + ) + }, 50) } function populateRunnables() { @@ -310,15 +338,15 @@ files = {} } files[path] = content - console.log('[AI] Setting ignoreNextIframeEcho flag') - ignoreNextIframeEcho = true setFilesInIframe(files) selectedDocument = path handleSelectFile(path) console.log('[AI] File set complete', { path, currentLength: files[path]?.length, - currentHash: files[path]?.split('').reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0) + currentHash: files[path] + ?.split('') + .reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0) }) return lint() }, @@ -599,7 +627,6 @@ let inspectorElement: InspectorElementInfo | undefined = $state(undefined) let selectionExcludedFromPrompt: boolean = $state(false) let codeSelection: AppCodeSelectionElement | undefined = $state(undefined) - let ignoreNextIframeEcho: boolean = false function toggleSelectionExcluded() { selectionExcludedFromPrompt = !selectionExcludedFromPrompt @@ -620,18 +647,11 @@ function listener(e: MessageEvent) { if (e.data.type === 'setFiles') { console.log('[IFRAME] Received setFiles message', { - fileCount: Object.keys(e.data.files || {}).length, - ignoreFlag: ignoreNextIframeEcho + fileCount: Object.keys(e.data.files || {}).length }) // Prevent corruption from iframe echo after AI sets file - if (ignoreNextIframeEcho) { - console.log('[IFRAME] Ignoring iframe echo to prevent corruption') - ignoreNextIframeEcho = false - return - } // Normalize Windows-style path separators to Linux-style const normalizedFiles = normalizeFilePaths(e.data.files) - // Only mark pending changes if files actually changed (ignore echo from setFilesInIframe) if (!deepEqual(files, normalizedFiles)) { console.log('[IFRAME] Files changed, updating from iframe') files = normalizedFiles @@ -700,6 +720,7 @@ }) }) $effect(() => { + console.log('dbg populateFiles') iframe && iframeLoaded && initFiles && populateFiles() }) $effect(() => { diff --git a/frontend/src/lib/components/raw_apps/__tests__/debug-file-corruption.js b/frontend/src/lib/components/raw_apps/__tests__/debug-file-corruption.js new file mode 100644 index 0000000000..92ee7da35b --- /dev/null +++ b/frontend/src/lib/components/raw_apps/__tests__/debug-file-corruption.js @@ -0,0 +1,179 @@ +/** + * Browser console script to debug file corruption + * + * USAGE: + * 1. Open the raw app editor page + * 2. Paste this script in the browser console + * 3. Ask AI to create/edit a file + * 4. Watch the console for detailed logs + */ + +(function() { + console.log('[CORRUPTION DEBUG] Installing debugging hooks...') + + // Store original postMessage + const originalPostMessage = window.postMessage + const iframeMessages = [] + const parentMessages = [] + + // Hook window.postMessage to track messages to iframe + window.postMessage = function(message, targetOrigin, transfer) { + if (message && message.type === 'setFiles') { + console.log('[DEBUG] Parent -> Iframe: setFiles', { + fileCount: Object.keys(message.files || {}).length, + timestamp: Date.now(), + files: Object.fromEntries( + Object.entries(message.files || {}).map(([path, content]) => [ + path, + { + length: content.length, + hash: hashString(content), + preview: content.substring(0, 100) + } + ]) + ) + }) + iframeMessages.push({ + type: 'outgoing', + timestamp: Date.now(), + message: JSON.parse(JSON.stringify(message)) + }) + } + return originalPostMessage.call(this, message, targetOrigin, transfer) + } + + // Hook addEventListener to track messages from iframe + const originalAddEventListener = window.addEventListener + window.addEventListener = function(type, listener, options) { + if (type === 'message') { + const wrappedListener = function(event) { + if (event.data && event.data.type === 'setFiles') { + console.log('[DEBUG] Iframe -> Parent: setFiles', { + fileCount: Object.keys(event.data.files || {}).length, + timestamp: Date.now(), + files: Object.fromEntries( + Object.entries(event.data.files || {}).map(([path, content]) => [ + path, + { + length: content.length, + hash: hashString(content), + preview: content.substring(0, 100) + } + ]) + ) + }) + parentMessages.push({ + type: 'incoming', + timestamp: Date.now(), + message: JSON.parse(JSON.stringify(event.data)) + }) + + // Check for corruption + if (iframeMessages.length > 0) { + const lastSent = iframeMessages[iframeMessages.length - 1].message + checkForCorruption(lastSent.files, event.data.files) + } + } + return listener.call(this, event) + } + return originalAddEventListener.call(this, type, wrappedListener, options) + } + return originalAddEventListener.call(this, type, listener, options) + } + + function hashString(str) { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash // Convert to 32bit integer + } + return hash + } + + function checkForCorruption(sent, received) { + console.group('[CORRUPTION CHECK]') + + const sentPaths = Object.keys(sent || {}) + const receivedPaths = Object.keys(received || {}) + + console.log('Files sent:', sentPaths.length) + console.log('Files received:', receivedPaths.length) + + // Check each file + for (const path of sentPaths) { + const sentContent = sent[path] + const receivedContent = received[path] + + if (!receivedContent) { + console.warn(`āŒ File missing in response: ${path}`) + continue + } + + const sentHash = hashString(sentContent) + const receivedHash = hashString(receivedContent) + + if (sentHash !== receivedHash) { + console.error(`šŸ”“ CORRUPTION DETECTED: ${path}`) + console.log('Sent length:', sentContent.length) + console.log('Received length:', receivedContent.length) + console.log('Sent hash:', sentHash) + console.log('Received hash:', receivedHash) + + // Find first difference + const maxLen = Math.min(sentContent.length, receivedContent.length) + for (let i = 0; i < maxLen; i++) { + if (sentContent[i] !== receivedContent[i]) { + console.log(`First diff at position ${i}:`) + console.log(` Sent: "${sentContent.substring(Math.max(0, i-20), i+50)}"`) + console.log(` Received: "${receivedContent.substring(Math.max(0, i-20), i+50)}"`) + break + } + } + + // Save for inspection + window.__corruptedFile = { + path, + sent: sentContent, + received: receivedContent + } + console.log('šŸ’¾ Saved to window.__corruptedFile for inspection') + } else { + console.log(`āœ… ${path}: No corruption`) + } + } + + console.groupEnd() + } + + // Export utilities + window.__debugFileCorruption = { + iframeMessages, + parentMessages, + getLastSent: () => iframeMessages[iframeMessages.length - 1], + getLastReceived: () => parentMessages[parentMessages.length - 1], + compare: (sentIdx = -1, receivedIdx = -1) => { + const sent = iframeMessages[sentIdx < 0 ? iframeMessages.length + sentIdx : sentIdx] + const received = parentMessages[receivedIdx < 0 ? parentMessages.length + receivedIdx : receivedIdx] + + if (!sent || !received) { + console.error('Invalid indices') + return + } + + checkForCorruption(sent.message.files, received.message.files) + }, + clear: () => { + iframeMessages.length = 0 + parentMessages.length = 0 + console.log('Cleared message history') + } + } + + console.log('[CORRUPTION DEBUG] Hooks installed! āœ…') + console.log('Use window.__debugFileCorruption to inspect messages') + console.log(' .iframeMessages - Messages sent to iframe') + console.log(' .parentMessages - Messages received from iframe') + console.log(' .compare() - Compare last sent/received') + console.log(' .clear() - Clear history') +})() diff --git a/frontend/src/lib/components/raw_apps/__tests__/file-corruption-test.ts b/frontend/src/lib/components/raw_apps/__tests__/file-corruption-test.ts new file mode 100644 index 0000000000..8d2d8d7c48 --- /dev/null +++ b/frontend/src/lib/components/raw_apps/__tests__/file-corruption-test.ts @@ -0,0 +1,229 @@ +/** + * Test to investigate file corruption issue when AI sets frontend files + * + * The issue: When AI calls set_frontend_file with correct content, + * the file gets corrupted with duplicated/mangled code. + * + * Hypothesis: The iframe postMessage roundtrip is corrupting the data. + */ + +interface SetFilesMessage { + type: 'setFiles' + files: Record +} + +/** + * Simulates what happens when AI sets a file + */ +export function simulateAiSetFile( + path: string, + content: string, + existingFiles: Record +): { + sentToIframe: Record + log: string[] +} { + const log: string[] = [] + + // Step 1: AI tool calls setFrontendFile + log.push(`[AI] Setting file: ${path} (${content.length} chars)`) + + // Step 2: Update files object + const files = { ...existingFiles } + files[path] = content + log.push(`[State] Files object updated`) + + // Step 3: Send to iframe + const filesToSend = Object.fromEntries(Object.entries(files).filter(([p, _]) => !p.endsWith('/'))) + log.push(`[Send] Sending ${Object.keys(filesToSend).length} files to iframe`) + + return { + sentToIframe: filesToSend, + log + } +} + +/** + * Simulates what the iframe might do when it receives setFiles + * This is where corruption might be happening + */ +export function simulateIframeProcessing(receivedFiles: Record): { + echoedBack: Record + log: string[] +} { + const log: string[] = [] + log.push(`[Iframe] Received ${Object.keys(receivedFiles).length} files`) + + // The iframe might: + // 1. Parse the files + // 2. Load into Monaco editor + // 3. Format/process somehow + // 4. Echo back to parent + + // TODO: This is where we need to investigate what actually happens + // For now, just echo back unchanged + const echoedBack = { ...receivedFiles } + + log.push(`[Iframe] Echoing back ${Object.keys(echoedBack).length} files`) + + return { + echoedBack, + log + } +} + +/** + * Test the full flow + */ +export function testFileSetFlow() { + const originalContent = ` + +
+

Todo List

+
+ +` + + const existingFiles = { + '/index.tsx': 'export default function App() {}', + '/wmill.d.ts': 'export const backend: any' + } + + console.log('=== Testing File Corruption Flow ===\n') + + // Step 1: AI sets the file + const { sentToIframe, log: aiLog } = simulateAiSetFile( + '/App.svelte', + originalContent, + existingFiles + ) + + aiLog.forEach((line) => console.log(line)) + + console.log('\n--- Content sent to iframe ---') + console.log('Path: /App.svelte') + console.log('Length:', sentToIframe['/App.svelte']?.length) + console.log('First 100 chars:', sentToIframe['/App.svelte']?.substring(0, 100)) + + // Step 2: Iframe processes and echoes back + const { echoedBack, log: iframeLog } = simulateIframeProcessing(sentToIframe) + + console.log('\n') + iframeLog.forEach((line) => console.log(line)) + + console.log('\n--- Content echoed back ---') + console.log('Path: /App.svelte') + console.log('Length:', echoedBack['/App.svelte']?.length) + console.log('First 100 chars:', echoedBack['/App.svelte']?.substring(0, 100)) + + // Step 3: Compare + const original = sentToIframe['/App.svelte'] + const echoed = echoedBack['/App.svelte'] + + console.log('\n=== Comparison ===') + console.log('Lengths match:', original.length === echoed.length) + console.log('Content match:', original === echoed) + + if (original !== echoed) { + console.log('\nšŸ”“ CORRUPTION DETECTED!') + console.log('Original length:', original.length) + console.log('Echoed length:', echoed.length) + + // Find first difference + for (let i = 0; i < Math.min(original.length, echoed.length); i++) { + if (original[i] !== echoed[i]) { + console.log(`First difference at position ${i}:`) + console.log(` Original: "${original.substring(i, i + 50)}"`) + console.log(` Echoed: "${echoed.substring(i, i + 50)}"`) + break + } + } + } else { + console.log('\nāœ… No corruption in test simulation') + console.log('(This means corruption happens in actual iframe, not in our data flow)') + } +} + +/** + * Helper to detect corruption patterns in content + */ +export function detectCorruptionPatterns(content: string): { + duplicatedLines: number + randomChars: number + brokenSyntax: boolean + patterns: string[] +} { + const lines = content.split('\n') + const patterns: string[] = [] + + // Check for duplicated lines + const lineSet = new Set(lines) + const duplicatedLines = lines.length - lineSet.size + if (duplicatedLines > lines.length * 0.2) { + patterns.push(`High duplication: ${duplicatedLines}/${lines.length} lines`) + } + + // Check for character corruption (random missing chars) + const randomCharMatches = content.match(/\w{2,}[^\w\s]{1}\w{2,}/g) || [] + const randomChars = randomCharMatches.length + if (randomChars > 10) { + patterns.push(`Possible char corruption: ${randomChars} instances`) + } + + // Check for broken syntax (unmatched brackets) + const openBraces = (content.match(/\{/g) || []).length + const closeBraces = (content.match(/\}/g) || []).length + const openParens = (content.match(/\(/g) || []).length + const closeParens = (content.match(/\)/g) || []).length + const brokenSyntax = + Math.abs(openBraces - closeBraces) > 5 || Math.abs(openParens - closeParens) > 5 + if (brokenSyntax) { + patterns.push(`Unbalanced: {${openBraces}/${closeBraces} }${openParens}/${closeParens}`) + } + + return { + duplicatedLines, + randomChars, + brokenSyntax, + patterns + } +} + +// Run the test +if (typeof window === 'undefined') { + testFileSetFlow() +}