diff --git a/e2e-tests/dyad_tags_parsing.spec.ts b/e2e-tests/dyad_tags_parsing.spec.ts new file mode 100644 index 0000000..1936149 --- /dev/null +++ b/e2e-tests/dyad_tags_parsing.spec.ts @@ -0,0 +1,8 @@ +import { testSkipIfWindows } from "./helpers/test_helper"; + +testSkipIfWindows("dyad tags handles nested < tags", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + await po.sendPrompt("tc=dyad-write-angle"); + await po.snapshotAppFiles(); +}); diff --git a/e2e-tests/fixtures/dyad-write-angle.md b/e2e-tests/fixtures/dyad-write-angle.md new file mode 100644 index 0000000..6712537 --- /dev/null +++ b/e2e-tests/fixtures/dyad-write-angle.md @@ -0,0 +1,5 @@ +BEFORE TAG + +// BEGINNING OF FILE + +AFTER TAG diff --git a/e2e-tests/snapshots/dyad_tags_parsing.spec.ts_dyad-tags-handles-nested-tags-1.txt b/e2e-tests/snapshots/dyad_tags_parsing.spec.ts_dyad-tags-handles-nested-tags-1.txt new file mode 100644 index 0000000..18cf85d --- /dev/null +++ b/e2e-tests/snapshots/dyad_tags_parsing.spec.ts_dyad-tags-handles-nested-tags-1.txt @@ -0,0 +1,191 @@ +=== .gitignore === +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + + +=== file1.txt === +A file (2) + +=== index.html === + + + + + + dyad-generated-app + + + +
+ + + + + +=== package.json === +{ + "name": "vite_react_shadcn_ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.5.5", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.9.0", + "typescript": "^5.5.3", + "vite": "^6.3.4" + }, + "packageManager": "" +} + +=== src/App.tsx === +const App = () =>
Minimal imported app
; + +export default App; + + +=== src/foo/bar.tsx === +// BEGINNING OF FILE + +=== src/main.tsx === +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render(); + + +=== src/vite-env.d.ts === +/// + + +=== tsconfig.app.json === +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} + + +=== tsconfig.json === +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "noImplicitAny": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "allowJs": true, + "noUnusedLocals": false, + "strictNullChecks": false + } +} + + +=== tsconfig.node.json === +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} + + +=== vite.config.ts === +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +export default defineConfig(() => ({ + server: { + host: "::", + port: 8080, + }, + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +})); diff --git a/src/__tests__/chat_stream_handlers.test.ts b/src/__tests__/chat_stream_handlers.test.ts index 78fb08a..312001a 100644 --- a/src/__tests__/chat_stream_handlers.test.ts +++ b/src/__tests__/chat_stream_handlers.test.ts @@ -9,6 +9,7 @@ import { import fs from "node:fs"; import git from "isomorphic-git"; import { db } from "../db"; +import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse"; // Mock fs with default export vi.mock("node:fs", async () => { @@ -139,6 +140,110 @@ console.log("TodoItem");`, ]); }); + it("should handle missing description", () => { + const result = getDyadWriteTags(` + +import React from 'react'; + + `); + expect(result).toEqual([ + { + path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx", + description: undefined, + content: `import React from 'react';`, + }, + ]); + }); + + it("should handle extra space", () => { + const result = getDyadWriteTags( + cleanFullResponse(` + +import React from 'react'; + + `), + ); + expect(result).toEqual([ + { + path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx", + description: "Updating Highlands neighborhood page to use <a> tags.", + content: `import React from 'react';`, + }, + ]); + }); + + it("should handle nested tags", () => { + const result = getDyadWriteTags( + cleanFullResponse(` + BEFORE TAG + +import React from 'react'; + +AFTER TAG + `), + ); + expect(result).toEqual([ + { + path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx", + description: "Updating Highlands neighborhood page to use <a> tags.", + content: `import React from 'react';`, + }, + ]); + }); + + it("should handle nested tags after preprocessing", () => { + // Simulate the preprocessing step that cleanFullResponse would do + const inputWithNestedTags = ` + BEFORE TAG + +import React from 'react'; + +AFTER TAG + `; + + const cleanedInput = cleanFullResponse(inputWithNestedTags); + + const result = getDyadWriteTags(cleanedInput); + expect(result).toEqual([ + { + path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx", + description: "Updating Highlands neighborhood page to use <a> tags.", + content: `import React from 'react';`, + }, + ]); + }); + + it("should handle multiple nested tags after preprocessing", () => { + const inputWithMultipleNestedTags = `content`; + + // This simulates what cleanFullResponse should do + const cleanedInput = cleanFullResponse(inputWithMultipleNestedTags); + const result = getDyadWriteTags(cleanedInput); + expect(result).toEqual([ + { + path: "src/file.tsx", + description: "Testing <div> and <span> and <a> tags.", + content: `content`, + }, + ]); + }); + + it("should handle nested tags in multiple attributes", () => { + const inputWithNestedInMultipleAttrs = `content`; + + // This simulates what cleanFullResponse should do + const cleanedInput = cleanFullResponse(inputWithNestedInMultipleAttrs); + + const result = getDyadWriteTags(cleanedInput); + expect(result).toEqual([ + { + path: "src/<component>.tsx", + description: "Testing <div> tags.", + content: `content`, + }, + ]); + }); + it("should return an array of dyad-write tags", () => { const result = getDyadWriteTags( `I'll create a simple todo list app using React, TypeScript, and shadcn/ui components. Let's get started! diff --git a/src/__tests__/cleanFullResponse.test.ts b/src/__tests__/cleanFullResponse.test.ts new file mode 100644 index 0000000..a784a7b --- /dev/null +++ b/src/__tests__/cleanFullResponse.test.ts @@ -0,0 +1,89 @@ +import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse"; +import { describe, it, expect } from "vitest"; + +describe("cleanFullResponse", () => { + it("should replace < characters in dyad-write attributes", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should replace < characters in multiple attributes", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle multiple nested HTML tags in a single attribute", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle complex example with mixed content", () => { + const input = ` + BEFORE TAG + +import React from 'react'; + +AFTER TAG + `; + + const expected = ` + BEFORE TAG + +import React from 'react'; + +AFTER TAG + `; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle other dyad tag types", () => { + const input = ``; + const expected = ``; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle dyad-delete tags", () => { + const input = ``; + const expected = ``; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should not affect content outside dyad tags", () => { + const input = `Some text with HTML tags. content More here.`; + const expected = `Some text with HTML tags. content More here.`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle empty attributes", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle attributes without < characters", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); +}); diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts index 88a0de1..6f8c413 100644 --- a/src/ipc/handlers/chat_stream_handlers.ts +++ b/src/ipc/handlers/chat_stream_handlers.ts @@ -25,7 +25,7 @@ import { getSupabaseClientCode, } from "../../supabase_admin/supabase_context"; import { SUMMARIZE_CHAT_SYSTEM_PROMPT } from "../../prompts/summarize_chat_system_prompt"; -import * as fs from "fs"; +import fs from "node:fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; @@ -38,6 +38,7 @@ import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google"; import { getExtraProviderOptions } from "../utils/thinking_utils"; import { safeSend } from "../utils/safe_sender"; +import { cleanFullResponse } from "../utils/cleanFullResponse"; const logger = log.scope("chat_stream_handlers"); @@ -258,7 +259,6 @@ ${componentSnippet} abortController, updatedChat, ); - fullResponse = cleanThinkingByEscapingDyadTags(fullResponse); } else { // Normal AI processing for non-test prompts const settings = readSettings(); @@ -515,6 +515,7 @@ This conversation includes one or more image attachments. When the user uploads } fullResponse += chunk; + fullResponse = cleanFullResponse(fullResponse); if ( fullResponse.includes("$$SUPABASE_CLIENT_CODE$$") && @@ -815,25 +816,6 @@ async function prepareMessageWithAttachments( }; } -function cleanThinkingByEscapingDyadTags(text: string): string { - // Extract content inside tags - const thinkRegex = /([\s\S]*?)<\/think>/g; - - return text.replace(thinkRegex, (match, content) => { - // We are replacing the opening tag with a look-alike character - // to avoid issues where thinking content includes dyad tags - // and are mishandled by: - // 1. FE markdown parser - // 2. Main process response processor - const processedContent = content - .replace(/${processedContent}`; - }); -} - function removeThinkingTags(text: string): string { const thinkRegex = /([\s\S]*?)<\/think>/g; return text.replace(thinkRegex, "").trim(); diff --git a/src/ipc/handlers/testing_chat_handlers.ts b/src/ipc/handlers/testing_chat_handlers.ts index 13fe746..780b8fc 100644 --- a/src/ipc/handlers/testing_chat_handlers.ts +++ b/src/ipc/handlers/testing_chat_handlers.ts @@ -1,4 +1,5 @@ import { safeSend } from "../utils/safe_sender"; +import { cleanFullResponse } from "../utils/cleanFullResponse"; // e.g. [dyad-qa=add-dep] // Canned responses for test prompts @@ -18,6 +19,12 @@ const TEST_RESPONSES: Record = { EOM`, + "string-literal-leak": `BEFORE TAG + +import React from 'react'; + +AFTER TAG +`, }; /** @@ -64,6 +71,7 @@ export async function streamTestResponse( // Add the word plus a space fullResponse += chunk + " "; + fullResponse = cleanFullResponse(fullResponse); // Send the current accumulated response safeSend(event.sender, "chat:response:chunk", { diff --git a/src/ipc/utils/cleanFullResponse.ts b/src/ipc/utils/cleanFullResponse.ts new file mode 100644 index 0000000..bf21b80 --- /dev/null +++ b/src/ipc/utils/cleanFullResponse.ts @@ -0,0 +1,15 @@ +export function cleanFullResponse(text: string): string { + // Replace < characters inside dyad-* attributes with fullwidth less-than sign < + // This prevents parsing issues when attributes contain HTML tags like or
+ return text.replace(/]*(?:"[^"]*"[^<>]*)*>/g, (match: string) => { + // Find all attribute values (content within quotes) and replace < with < and > with > + const processedMatch = match.replace( + /="([^"]*)"/g, + (attrMatch: string, attrValue: string) => { + const cleanedValue = attrValue.replace(//g, ">"); + return `="${cleanedValue}"`; + }, + ); + return processedMatch; + }); +}