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;
+ });
+}