Smart Context: deep (#1527)

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Introduce a new "deep" Smart Context mode that supplies versioned
files (by commit) to the engine, adds code search rendering, stores
source commit hashes, improves search-replace recovery, and updates
UI/tests.
> 
> - **Smart Context (deep)**:
> - Replace `conservative` with `deep`; limit context to ~200 turns;
send `sourceCommitHash` per message.
> - Build and pass `versioned_files` (hash-id map + per-message file
refs) and `app_id` to engine.
> - **DB**:
>   - Add `messages.source_commit_hash` (+ migration/snapshot).
> - **Engine/Processing**:
> - Retry Turbo Edits v2: first re-read then fallback to `dyad-write` if
search-replace fails.
> - Include provider options and versioned files in requests; add
`getCurrentCommitHash`/`getFileAtCommit`.
> - **UI**:
>   - Pro mode selector: new `deep` option; tooltips polish.
> - Add `DyadCodeSearch` and `DyadCodeSearchResult` components; parser
supports new tags.
> - **Tests/E2E**:
> - New `smart_context_deep` e2e; update snapshots to include `app_id`
and deep mode; adjust Playwright timeout.
>   - Unit tests for versioned codebase context.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e3d3bffabb2bc6caf52103461f9d6f2d5ad39df8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Will Chen
2025-11-06 10:45:39 -08:00
committed by GitHub
parent ae1ec68453
commit 06ad1a7546
46 changed files with 3623 additions and 560 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `messages` ADD `source_commit_hash` text;

View File

@@ -0,0 +1,760 @@
{
"version": "6",
"dialect": "sqlite",
"id": "c0a49147-ac92-4046-afe8-42f20df9314b",
"prevId": "41549b62-b247-48d5-90e1-6bfc70f02040",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_branch": {
"name": "github_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_parent_project_id": {
"name": "supabase_parent_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_project_id": {
"name": "neon_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_development_branch_id": {
"name": "neon_development_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_preview_branch_id": {
"name": "neon_preview_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"install_command": {
"name": "install_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_command": {
"name": "start_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_servers": {
"name": "mcp_servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transport": {
"name": "transport",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"args": {
"name": "args",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"env_json": {
"name": "env_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_tool_consents": {
"name": "mcp_tool_consents",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consent": {
"name": "consent",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ask'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"uniq_mcp_consent": {
"name": "uniq_mcp_consent",
"columns": [
"server_id",
"tool_name"
],
"isUnique": true
}
},
"foreignKeys": {
"mcp_tool_consents_server_id_mcp_servers_id_fk": {
"name": "mcp_tool_consents_server_id_mcp_servers_id_fk",
"tableFrom": "mcp_tool_consents",
"tableTo": "mcp_servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_commit_hash": {
"name": "source_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prompts": {
"name": "prompts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -113,6 +113,13 @@
"when": 1760474402750, "when": 1760474402750,
"tag": "0015_complete_old_lace", "tag": "0015_complete_old_lace",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1762297039106,
"tag": "0016_petite_thanos",
"breakpoints": true
} }
] ]
} }

View File

@@ -9,23 +9,6 @@ testSkipIfWindows("send message to engine", async ({ po }) => {
await po.snapshotMessages({ replaceDumpPath: true }); await po.snapshotMessages({ replaceDumpPath: true });
}); });
testSkipIfWindows(
"send message to engine - smart context conservative",
async ({ po }) => {
await po.setUpDyadPro();
const proModesDialog = await po.openProModesDialog({
location: "home-chat-input-container",
});
await proModesDialog.setSmartContextMode("conservative");
await proModesDialog.close();
await po.selectModel({ provider: "Google", model: "Gemini 2.5 Pro" });
await po.sendPrompt("[dump] tc=turbo-edits");
await po.snapshotServerDump("request");
await po.snapshotMessages({ replaceDumpPath: true });
},
);
testSkipIfWindows("send message to engine - openai gpt-5", async ({ po }) => { testSkipIfWindows("send message to engine - openai gpt-5", async ({ po }) => {
await po.setUpDyadPro(); await po.setUpDyadPro();
// By default, it's using auto which points to Flash 2.5 and doesn't // By default, it's using auto which points to Flash 2.5 and doesn't

View File

@@ -0,0 +1,3 @@
Read the index page:
<dyad-read path="src/pages/Index.tsx"></dyad-read>
Done.

View File

@@ -0,0 +1,4 @@
First read
<dyad-write path="src/pages/Index.tsx" description="replace file">
// this file has been replaced
</dyad-write>

View File

@@ -71,7 +71,7 @@ class ProModesDialog {
public close: () => Promise<void>, public close: () => Promise<void>,
) {} ) {}
async setSmartContextMode(mode: "balanced" | "off" | "conservative") { async setSmartContextMode(mode: "balanced" | "off" | "deep") {
await this.page await this.page
.getByTestId("smart-context-selector") .getByTestId("smart-context-selector")
.getByRole("button", { .getByRole("button", {

View File

@@ -0,0 +1,18 @@
import { testSkipIfWindows } from "./helpers/test_helper";
testSkipIfWindows("smart context deep - read write read", async ({ po }) => {
await po.setUpDyadPro({ autoApprove: true });
const proModesDialog = await po.openProModesDialog({
location: "home-chat-input-container",
});
await proModesDialog.setSmartContextMode("deep");
await proModesDialog.close();
await po.sendPrompt("tc=read-index");
await po.sendPrompt("tc=update-index-1");
await po.sendPrompt("tc=read-index");
await po.sendPrompt("[dump]");
await po.snapshotServerDump("request");
await po.snapshotMessages({ replaceDumpPath: true });
});

View File

@@ -10,6 +10,6 @@ test("switching smart context mode saves the right setting", async ({ po }) => {
await po.snapshotSettings(); await po.snapshotSettings();
await proModesDialog.setSmartContextMode("off"); await proModesDialog.setSmartContextMode("off");
await po.snapshotSettings(); await po.snapshotSettings();
await proModesDialog.setSmartContextMode("conservative"); await proModesDialog.setSmartContextMode("deep");
await po.snapshotSettings(); await po.snapshotSettings();
}); });

View File

@@ -99,7 +99,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -69,7 +69,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -57,7 +57,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": false, "enable_smart_files_context": false,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -115,7 +115,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": false, "enable_smart_files_context": false,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -414,7 +414,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": false, "enable_smart_files_context": false,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -409,7 +409,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -409,7 +409,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -1,6 +1,10 @@
- paragraph: "[dump] tc=turbo-edits" - paragraph: "[dump] tc=turbo-edits"
- paragraph: "[[dyad-dump-path=*]]" - paragraph: "[[dyad-dump-path=*]]"
- button:
- img
- img - img
- text: less than a minute ago - text: less than a minute ago
- button "Request ID":
- img
- button "Retry": - button "Retry":
- img - img

File diff suppressed because one or more lines are too long

View File

@@ -414,7 +414,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -414,7 +414,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -415,6 +415,7 @@
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced", "smart_context_mode": "balanced",
"app_id": 2,
"mentioned_apps": [ "mentioned_apps": [
{ {
"appName": "minimal-with-ai-rules", "appName": "minimal-with-ai-rules",

View File

@@ -0,0 +1,56 @@
- paragraph: tc=read-index
- paragraph: "Read the index page:"
- img
- text: Index.tsx Read src/pages/Index.tsx
- paragraph: Done.
- button:
- img
- img
- text: Approved
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=update-index-1
- paragraph: First read
- img
- text: Index.tsx
- button "Edit":
- img
- img
- text: "src/pages/Index.tsx Summary: replace file"
- button:
- img
- img
- text: Approved
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- button "Request ID":
- img
- paragraph: tc=read-index
- paragraph: "Read the index page:"
- img
- text: Index.tsx Read src/pages/Index.tsx
- paragraph: Done.
- button:
- img
- img
- text: Approved
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: "[dump]"
- paragraph: "[[dyad-dump-path=*]]"
- button:
- img
- img
- text: Approved
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,7 @@
"lastShownReleaseNotesVersion": "[scrubbed]", "lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"proSmartContextOption": "conservative", "proSmartContextOption": "deep",
"selectedTemplateId": "react", "selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,

View File

@@ -422,7 +422,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -430,7 +430,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -438,7 +438,8 @@
], ],
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -414,7 +414,8 @@
], ],
"enable_lazy_edits": false, "enable_lazy_edits": false,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -18,7 +18,15 @@
}, },
{ {
"role": "user", "role": "user",
"content": "There was an issue with the following `dyad-search-replace` tags. Please fix them by generating the code changes using `dyad-write` tags instead.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file" "content": "There was an issue with the following `dyad-search-replace` tags. Make sure you use `dyad-read` to read the latest version of the file and then trying to do search & replace again.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file"
},
{
"role": "assistant",
"content": "<dyad-read path=\"src/pages/Index.tsx\"></dyad-read>\n\n<dyad-search-replace path=\"src/pages/Index.tsx\">\n<<<<<<< SEARCH\n // STILL Intentionally DO NOT MATCH ANYTHING TO TRIGGER FALLBACK\n <h1 className=\"text-4xl font-bold mb-4\">Welcome to Your Blank App</h1>\n=======\n <h1 className=\"text-4xl font-bold mb-4\">Welcome to the UPDATED App</h1>\n>>>>>>> REPLACE\n</dyad-search-replace>\n\n\n[[dyad-dump-path=*]]"
},
{
"role": "user",
"content": "There was an issue with the following `dyad-search-replace` tags. Please fix the errors by generating the code changes using `dyad-write` tags instead.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file"
} }
], ],
"stream": true, "stream": true,
@@ -422,7 +430,8 @@
], ],
"enable_lazy_edits": false, "enable_lazy_edits": false,
"enable_smart_files_context": true, "enable_smart_files_context": true,
"smart_context_mode": "balanced" "smart_context_mode": "balanced",
"app_id": 1
} }
}, },
"headers": { "headers": {

View File

@@ -7,7 +7,7 @@ const config: PlaywrightTestConfig = {
testDir: "./e2e-tests", testDir: "./e2e-tests",
workers: 1, workers: 1,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
timeout: process.env.CI ? 180_000 : 30_000, timeout: process.env.CI ? 180_000 : 45_000,
// Use a custom snapshot path template because Playwright's default // Use a custom snapshot path template because Playwright's default
// is platform-specific which isn't necessary for Dyad e2e tests // is platform-specific which isn't necessary for Dyad e2e tests
// which should be platform agnostic (we don't do screenshots; only textual diffs). // which should be platform agnostic (we don't do screenshots; only textual diffs).

View File

@@ -0,0 +1,976 @@
import {
parseFilesFromMessage,
processChatMessagesWithVersionedFiles,
} from "@/ipc/utils/versioned_codebase_context";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ModelMessage } from "@ai-sdk/provider-utils";
import type { CodebaseFile } from "@/utils/codebase";
import crypto from "node:crypto";
// Mock git_utils
vi.mock("@/ipc/utils/git_utils", () => ({
getFileAtCommit: vi.fn(),
}));
// Mock electron-log
vi.mock("electron-log", () => ({
default: {
scope: () => ({
warn: vi.fn(),
error: vi.fn(),
}),
},
}));
describe("parseFilesFromMessage", () => {
describe("dyad-read tags", () => {
it("should parse a single dyad-read tag", () => {
const input = '<dyad-read path="src/components/Button.tsx"></dyad-read>';
const result = parseFilesFromMessage(input);
expect(result).toEqual(["src/components/Button.tsx"]);
});
it("should parse multiple dyad-read tags", () => {
const input = `
<dyad-read path="src/components/Button.tsx"></dyad-read>
<dyad-read path="src/utils/helpers.ts"></dyad-read>
<dyad-read path="src/styles/main.css"></dyad-read>
`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Button.tsx",
"src/utils/helpers.ts",
"src/styles/main.css",
]);
});
it("should trim whitespace from file paths in dyad-read tags", () => {
const input =
'<dyad-read path=" src/components/Button.tsx "></dyad-read>';
const result = parseFilesFromMessage(input);
expect(result).toEqual(["src/components/Button.tsx"]);
});
it("should skip empty path attributes", () => {
const input = `
<dyad-read path="src/components/Button.tsx"></dyad-read>
<dyad-read path=""></dyad-read>
<dyad-read path="src/utils/helpers.ts"></dyad-read>
`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Button.tsx",
"src/utils/helpers.ts",
]);
});
it("should handle file paths with special characters", () => {
const input =
'<dyad-read path="src/components/@special/Button-v2.tsx"></dyad-read>';
const result = parseFilesFromMessage(input);
expect(result).toEqual(["src/components/@special/Button-v2.tsx"]);
});
});
describe("dyad-code-search-result tags", () => {
it("should parse a single file from dyad-code-search-result", () => {
const input = `<dyad-code-search-result>
src/components/Button.tsx
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual(["src/components/Button.tsx"]);
});
it("should parse multiple files from dyad-code-search-result", () => {
const input = `<dyad-code-search-result>
src/components/Button.tsx
src/components/Input.tsx
src/utils/helpers.ts
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Button.tsx",
"src/components/Input.tsx",
"src/utils/helpers.ts",
]);
});
it("should trim whitespace from each line", () => {
const input = `<dyad-code-search-result>
src/components/Button.tsx
src/components/Input.tsx
src/utils/helpers.ts
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Button.tsx",
"src/components/Input.tsx",
"src/utils/helpers.ts",
]);
});
it("should skip empty lines in dyad-code-search-result", () => {
const input = `<dyad-code-search-result>
src/components/Button.tsx
src/components/Input.tsx
src/utils/helpers.ts
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Button.tsx",
"src/components/Input.tsx",
"src/utils/helpers.ts",
]);
});
it("should skip lines that look like tags (starting with < or >)", () => {
const input = `<dyad-code-search-result>
src/components/Button.tsx
<some-tag>
src/components/Input.tsx
>some-line
src/utils/helpers.ts
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Button.tsx",
"src/components/Input.tsx",
"src/utils/helpers.ts",
]);
});
it("should handle multiple dyad-code-search-result tags", () => {
const input = `<dyad-code-search-result>
src/components/Button.tsx
src/components/Input.tsx
</dyad-code-search-result>
Some text in between
<dyad-code-search-result>
src/utils/helpers.ts
src/styles/main.css
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Button.tsx",
"src/components/Input.tsx",
"src/utils/helpers.ts",
"src/styles/main.css",
]);
});
});
describe("mixed tags", () => {
it("should parse both dyad-read and dyad-code-search-result tags", () => {
const input = `
<dyad-read path="src/config/app.ts"></dyad-read>
<dyad-code-search-result>
src/components/Button.tsx
src/components/Input.tsx
</dyad-code-search-result>
<dyad-read path="src/utils/helpers.ts"></dyad-read>
`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/config/app.ts",
"src/components/Button.tsx",
"src/components/Input.tsx",
"src/utils/helpers.ts",
]);
});
it("should deduplicate file paths", () => {
const input = `
<dyad-read path="src/components/Button.tsx"></dyad-read>
<dyad-read path="src/components/Button.tsx"></dyad-read>
<dyad-code-search-result>
src/components/Button.tsx
src/utils/helpers.ts
</dyad-code-search-result>
`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Button.tsx",
"src/utils/helpers.ts",
]);
});
it("should handle complex real-world example", () => {
const input = `
Here's what I found:
<dyad-read path="src/components/Header.tsx"></dyad-read>
I also searched for related files:
<dyad-code-search-result>
src/components/Header.tsx
src/components/Footer.tsx
src/styles/layout.css
</dyad-code-search-result>
Let me also check the config:
<dyad-read path="src/config/site.ts"></dyad-read>
And finally:
<dyad-code-search-result>
src/utils/navigation.ts
src/utils/theme.ts
</dyad-code-search-result>
`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/components/Header.tsx",
"src/components/Footer.tsx",
"src/styles/layout.css",
"src/config/site.ts",
"src/utils/navigation.ts",
"src/utils/theme.ts",
]);
});
});
describe("edge cases", () => {
it("should return empty array for empty string", () => {
const input = "";
const result = parseFilesFromMessage(input);
expect(result).toEqual([]);
});
it("should return empty array when no tags present", () => {
const input = "This is just some regular text without any tags.";
const result = parseFilesFromMessage(input);
expect(result).toEqual([]);
});
it("should handle malformed tags gracefully", () => {
const input = `
<dyad-read path="src/file1.ts"
<dyad-code-search-result>
src/file2.ts
`;
const result = parseFilesFromMessage(input);
// Should not match unclosed tags
expect(result).toEqual([]);
});
it("should handle nested angle brackets in file paths", () => {
const input =
'<dyad-read path="src/components/Generic<T>.tsx"></dyad-read>';
const result = parseFilesFromMessage(input);
expect(result).toEqual(["src/components/Generic<T>.tsx"]);
});
it("should preserve file path case sensitivity", () => {
const input = `<dyad-code-search-result>
src/Components/Button.tsx
src/components/button.tsx
SRC/COMPONENTS/BUTTON.TSX
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"src/Components/Button.tsx",
"src/components/button.tsx",
"SRC/COMPONENTS/BUTTON.TSX",
]);
});
it("should handle very long file paths", () => {
const longPath =
"src/very/deeply/nested/directory/structure/with/many/levels/components/Button.tsx";
const input = `<dyad-read path="${longPath}"></dyad-read>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([longPath]);
});
it("should handle file paths with dots", () => {
const input = `<dyad-code-search-result>
./src/components/Button.tsx
../utils/helpers.ts
../../config/app.config.ts
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"./src/components/Button.tsx",
"../utils/helpers.ts",
"../../config/app.config.ts",
]);
});
it("should handle absolute paths", () => {
const input = `<dyad-code-search-result>
/absolute/path/to/file.tsx
/another/absolute/path.ts
</dyad-code-search-result>`;
const result = parseFilesFromMessage(input);
expect(result).toEqual([
"/absolute/path/to/file.tsx",
"/another/absolute/path.ts",
]);
});
});
});
describe("processChatMessagesWithVersionedFiles", () => {
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
});
// Helper to compute SHA-256 hash
const hashContent = (content: string): string => {
return crypto.createHash("sha256").update(content).digest("hex");
};
describe("basic functionality", () => {
it("should process files parameter and create fileIdToContent and fileReferences", async () => {
const files: CodebaseFile[] = [
{
path: "src/components/Button.tsx",
content: "export const Button = () => <button>Click</button>;",
},
{
path: "src/utils/helpers.ts",
content: "export const add = (a: number, b: number) => a + b;",
},
];
const chatMessages: ModelMessage[] = [];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
// Check fileIdToContent contains hashed content
const buttonHash = hashContent(files[0].content);
const helperHash = hashContent(files[1].content);
expect(result.fileIdToContent[buttonHash]).toBe(files[0].content);
expect(result.fileIdToContent[helperHash]).toBe(files[1].content);
// Check fileReferences
expect(result.fileReferences).toHaveLength(2);
expect(result.fileReferences[0]).toEqual({
path: "src/components/Button.tsx",
fileId: buttonHash,
});
expect(result.fileReferences[1]).toEqual({
path: "src/utils/helpers.ts",
fileId: helperHash,
});
// messageIndexToFilePathToFileId should be empty
expect(result.messageIndexToFilePathToFileId).toEqual({});
});
it("should handle empty files array", async () => {
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(result.fileIdToContent).toEqual({});
expect(result.fileReferences).toEqual([]);
expect(result.messageIndexToFilePathToFileId).toEqual({});
});
});
describe("processing assistant messages", () => {
it("should process assistant messages with sourceCommitHash", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const fileContent = "const oldVersion = 'content';";
mockGetFileAtCommit.mockResolvedValue(fileContent);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content:
'I found this file: <dyad-read path="src/old.ts"></dyad-read>',
providerOptions: {
"dyad-engine": {
sourceCommitHash: "abc123",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
// Verify getFileAtCommit was called correctly
expect(mockGetFileAtCommit).toHaveBeenCalledWith({
path: appPath,
filePath: "src/old.ts",
commitHash: "abc123",
});
// Check fileIdToContent
const fileHash = hashContent(fileContent);
expect(result.fileIdToContent[fileHash]).toBe(fileContent);
// Check messageIndexToFilePathToFileId
expect(result.messageIndexToFilePathToFileId[0]).toEqual({
"src/old.ts": fileHash,
});
});
it("should process messages with array content type", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const fileContent = "const arrayContent = 'test';";
mockGetFileAtCommit.mockResolvedValue(fileContent);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: [
{
type: "text",
text: 'Here is the file: <dyad-read path="src/array.ts"></dyad-read>',
},
{
type: "text",
text: "Additional text",
},
],
providerOptions: {
"dyad-engine": {
sourceCommitHash: "def456",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).toHaveBeenCalledWith({
path: appPath,
filePath: "src/array.ts",
commitHash: "def456",
});
const fileHash = hashContent(fileContent);
expect(result.fileIdToContent[fileHash]).toBe(fileContent);
expect(result.messageIndexToFilePathToFileId[0]["src/array.ts"]).toBe(
fileHash,
);
});
it("should skip user messages", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "user",
content:
'Check this: <dyad-read path="src/user-file.ts"></dyad-read>',
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
// getFileAtCommit should not be called for user messages
expect(mockGetFileAtCommit).not.toHaveBeenCalled();
expect(result.messageIndexToFilePathToFileId).toEqual({});
});
it("should skip assistant messages without sourceCommitHash", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: 'File here: <dyad-read path="src/no-commit.ts"></dyad-read>',
// No providerOptions
},
{
role: "assistant",
content:
'Another file: <dyad-read path="src/no-commit2.ts"></dyad-read>',
providerOptions: {
// dyad-engine not set
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).not.toHaveBeenCalled();
expect(result.messageIndexToFilePathToFileId).toEqual({});
});
it("should skip messages with non-text content", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: [],
providerOptions: {
"dyad-engine": {
sourceCommitHash: "abc123",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).not.toHaveBeenCalled();
expect(result.messageIndexToFilePathToFileId).toEqual({});
});
});
describe("parsing multiple file paths", () => {
it("should process multiple files from dyad-code-search-result", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const file1Content = "file1 content";
const file2Content = "file2 content";
mockGetFileAtCommit
.mockResolvedValueOnce(file1Content)
.mockResolvedValueOnce(file2Content);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: `<dyad-code-search-result>
src/file1.ts
src/file2.ts
</dyad-code-search-result>`,
providerOptions: {
"dyad-engine": {
sourceCommitHash: "commit1",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).toHaveBeenCalledTimes(2);
expect(mockGetFileAtCommit).toHaveBeenCalledWith({
path: appPath,
filePath: "src/file1.ts",
commitHash: "commit1",
});
expect(mockGetFileAtCommit).toHaveBeenCalledWith({
path: appPath,
filePath: "src/file2.ts",
commitHash: "commit1",
});
const file1Hash = hashContent(file1Content);
const file2Hash = hashContent(file2Content);
expect(result.fileIdToContent[file1Hash]).toBe(file1Content);
expect(result.fileIdToContent[file2Hash]).toBe(file2Content);
expect(result.messageIndexToFilePathToFileId[0]).toEqual({
"src/file1.ts": file1Hash,
"src/file2.ts": file2Hash,
});
});
it("should process mixed dyad-read and dyad-code-search-result tags", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
mockGetFileAtCommit
.mockResolvedValueOnce("file1")
.mockResolvedValueOnce("file2")
.mockResolvedValueOnce("file3");
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: `
<dyad-read path="src/file1.ts"></dyad-read>
<dyad-code-search-result>
src/file2.ts
src/file3.ts
</dyad-code-search-result>
`,
providerOptions: {
"dyad-engine": {
sourceCommitHash: "hash1",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).toHaveBeenCalledTimes(3);
expect(Object.keys(result.messageIndexToFilePathToFileId[0])).toEqual([
"src/file1.ts",
"src/file2.ts",
"src/file3.ts",
]);
});
});
describe("error handling", () => {
it("should handle file not found (returns null)", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
// Simulate file not found
mockGetFileAtCommit.mockResolvedValue(null);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content:
'Missing file: <dyad-read path="src/missing.ts"></dyad-read>',
providerOptions: {
"dyad-engine": {
sourceCommitHash: "commit1",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).toHaveBeenCalled();
// File should not be in results
expect(result.fileIdToContent).toEqual({});
expect(result.messageIndexToFilePathToFileId[0]).toEqual({});
});
it("should handle getFileAtCommit throwing an error", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
// Simulate error
mockGetFileAtCommit.mockRejectedValue(new Error("Git error"));
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: 'Error file: <dyad-read path="src/error.ts"></dyad-read>',
providerOptions: {
"dyad-engine": {
sourceCommitHash: "commit1",
},
},
},
];
const appPath = "/test/app";
// Should not throw - errors are caught and logged
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).toHaveBeenCalled();
expect(result.fileIdToContent).toEqual({});
expect(result.messageIndexToFilePathToFileId[0]).toEqual({});
});
it("should process some files successfully and skip others that error", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const successContent = "success file";
mockGetFileAtCommit
.mockResolvedValueOnce(successContent)
.mockRejectedValueOnce(new Error("Error"))
.mockResolvedValueOnce(null);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: `<dyad-code-search-result>
src/success.ts
src/error.ts
src/missing.ts
</dyad-code-search-result>`,
providerOptions: {
"dyad-engine": {
sourceCommitHash: "commit1",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).toHaveBeenCalledTimes(3);
// Only the successful file should be in results
const successHash = hashContent(successContent);
expect(result.fileIdToContent[successHash]).toBe(successContent);
expect(result.messageIndexToFilePathToFileId[0]).toEqual({
"src/success.ts": successHash,
});
});
});
describe("multiple messages", () => {
it("should process multiple messages with different commits", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const file1AtCommit1 = "file1 at commit1";
const file1AtCommit2 = "file1 at commit2 - different content";
mockGetFileAtCommit
.mockResolvedValueOnce(file1AtCommit1)
.mockResolvedValueOnce(file1AtCommit2);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "user",
content: "Show me file1",
},
{
role: "assistant",
content: 'Here it is: <dyad-read path="src/file1.ts"></dyad-read>',
providerOptions: {
"dyad-engine": {
sourceCommitHash: "commit1",
},
},
},
{
role: "user",
content: "Show me it again",
},
{
role: "assistant",
content:
'Here it is again: <dyad-read path="src/file1.ts"></dyad-read>',
providerOptions: {
"dyad-engine": {
sourceCommitHash: "commit2",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
expect(mockGetFileAtCommit).toHaveBeenCalledTimes(2);
expect(mockGetFileAtCommit).toHaveBeenNthCalledWith(1, {
path: appPath,
filePath: "src/file1.ts",
commitHash: "commit1",
});
expect(mockGetFileAtCommit).toHaveBeenNthCalledWith(2, {
path: appPath,
filePath: "src/file1.ts",
commitHash: "commit2",
});
const hash1 = hashContent(file1AtCommit1);
const hash2 = hashContent(file1AtCommit2);
// Both versions should be in fileIdToContent
expect(result.fileIdToContent[hash1]).toBe(file1AtCommit1);
expect(result.fileIdToContent[hash2]).toBe(file1AtCommit2);
// Message index 1 (first assistant message)
expect(result.messageIndexToFilePathToFileId[1]).toEqual({
"src/file1.ts": hash1,
});
// Message index 3 (second assistant message)
expect(result.messageIndexToFilePathToFileId[3]).toEqual({
"src/file1.ts": hash2,
});
});
});
describe("integration with files parameter", () => {
it("should combine files parameter with versioned files from messages", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const versionedContent = "old version from git";
mockGetFileAtCommit.mockResolvedValue(versionedContent);
const files: CodebaseFile[] = [
{
path: "src/current.ts",
content: "current version",
},
];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: 'Old version: <dyad-read path="src/old.ts"></dyad-read>',
providerOptions: {
"dyad-engine": {
sourceCommitHash: "abc123",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
const currentHash = hashContent("current version");
const oldHash = hashContent(versionedContent);
// Both should be present
expect(result.fileIdToContent[currentHash]).toBe("current version");
expect(result.fileIdToContent[oldHash]).toBe(versionedContent);
// fileReferences should only include files from the files parameter
expect(result.fileReferences).toHaveLength(1);
expect(result.fileReferences[0].path).toBe("src/current.ts");
// messageIndexToFilePathToFileId should have the versioned file
expect(result.messageIndexToFilePathToFileId[0]).toEqual({
"src/old.ts": oldHash,
});
});
});
describe("content hashing", () => {
it("should deduplicate identical content with same hash", async () => {
const { getFileAtCommit } = await import("@/ipc/utils/git_utils");
const mockGetFileAtCommit = vi.mocked(getFileAtCommit);
const sameContent = "identical content";
// Both files have the same content
mockGetFileAtCommit
.mockResolvedValueOnce(sameContent)
.mockResolvedValueOnce(sameContent);
const files: CodebaseFile[] = [];
const chatMessages: ModelMessage[] = [
{
role: "assistant",
content: `<dyad-code-search-result>
src/file1.ts
src/file2.ts
</dyad-code-search-result>`,
providerOptions: {
"dyad-engine": {
sourceCommitHash: "commit1",
},
},
},
];
const appPath = "/test/app";
const result = await processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
});
const hash = hashContent(sameContent);
// fileIdToContent should only have one entry for the hash
expect(Object.keys(result.fileIdToContent)).toHaveLength(1);
expect(result.fileIdToContent[hash]).toBe(sameContent);
// Both files should point to the same hash
expect(result.messageIndexToFilePathToFileId[0]).toEqual({
"src/file1.ts": hash,
"src/file2.ts": hash,
});
});
});
});

View File

@@ -32,18 +32,16 @@ export function ProModeSelector() {
}); });
}; };
const handleSmartContextChange = ( const handleSmartContextChange = (newValue: "off" | "deep" | "balanced") => {
newValue: "off" | "conservative" | "balanced",
) => {
if (newValue === "off") { if (newValue === "off") {
updateSettings({ updateSettings({
enableProSmartFilesContextMode: false, enableProSmartFilesContextMode: false,
proSmartContextOption: undefined, proSmartContextOption: undefined,
}); });
} else if (newValue === "conservative") { } else if (newValue === "deep") {
updateSettings({ updateSettings({
enableProSmartFilesContextMode: true, enableProSmartFilesContextMode: true,
proSmartContextOption: "conservative", proSmartContextOption: "deep",
}); });
} else if (newValue === "balanced") { } else if (newValue === "balanced") {
updateSettings({ updateSettings({
@@ -90,16 +88,23 @@ export function ProModeSelector() {
</div> </div>
{!hasProKey && ( {!hasProKey && (
<div className="text-sm text-center text-muted-foreground"> <div className="text-sm text-center text-muted-foreground">
<a <Tooltip>
className="inline-flex items-center justify-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" <TooltipTrigger asChild>
onClick={() => { <a
IpcClient.getInstance().openExternalUrl( className="inline-flex items-center justify-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
"https://dyad.sh/pro#ai", onClick={() => {
); IpcClient.getInstance().openExternalUrl(
}} "https://dyad.sh/pro#ai",
> );
Unlock Pro modes }}
</a> >
Unlock Pro modes
</a>
</TooltipTrigger>
<TooltipContent>
Visit dyad.sh/pro to unlock Pro features
</TooltipContent>
</Tooltip>
</div> </div>
)} )}
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
@@ -239,33 +244,52 @@ function TurboEditsSelector({
className="inline-flex rounded-md border border-input" className="inline-flex rounded-md border border-input"
data-testid="turbo-edits-selector" data-testid="turbo-edits-selector"
> >
<Button <Tooltip>
variant={currentValue === "off" ? "default" : "ghost"} <TooltipTrigger asChild>
size="sm" <Button
onClick={() => onValueChange("off")} variant={currentValue === "off" ? "default" : "ghost"}
disabled={!isTogglable} size="sm"
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0" onClick={() => onValueChange("off")}
> disabled={!isTogglable}
Off className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
</Button> >
<Button Off
variant={currentValue === "v1" ? "default" : "ghost"} </Button>
size="sm" </TooltipTrigger>
onClick={() => onValueChange("v1")} <TooltipContent>Disable Turbo Edits</TooltipContent>
disabled={!isTogglable} </Tooltip>
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0" <Tooltip>
> <TooltipTrigger asChild>
Classic <Button
</Button> variant={currentValue === "v1" ? "default" : "ghost"}
<Button size="sm"
variant={currentValue === "v2" ? "default" : "ghost"} onClick={() => onValueChange("v1")}
size="sm" disabled={!isTogglable}
onClick={() => onValueChange("v2")} className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
disabled={!isTogglable} >
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0" Classic
> </Button>
Search & replace </TooltipTrigger>
</Button> <TooltipContent>
Uses a smaller model to complete edits
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "v2" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("v2")}
disabled={!isTogglable}
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
>
Search & replace
</Button>
</TooltipTrigger>
<TooltipContent>
Find and replaces specific text blocks
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
); );
@@ -278,19 +302,19 @@ function SmartContextSelector({
}: { }: {
isTogglable: boolean; isTogglable: boolean;
settings: UserSettings | null; settings: UserSettings | null;
onValueChange: (value: "off" | "conservative" | "balanced") => void; onValueChange: (value: "off" | "balanced" | "deep") => void;
}) { }) {
// Determine current value based on settings // Determine current value based on settings
const getCurrentValue = (): "off" | "conservative" | "balanced" => { const getCurrentValue = (): "off" | "conservative" | "balanced" | "deep" => {
if (!settings?.enableProSmartFilesContextMode) { if (!settings?.enableProSmartFilesContextMode) {
return "off"; return "off";
} }
if (settings?.proSmartContextOption === "deep") {
return "deep";
}
if (settings?.proSmartContextOption === "balanced") { if (settings?.proSmartContextOption === "balanced") {
return "balanced"; return "balanced";
} }
if (settings?.proSmartContextOption === "conservative") {
return "conservative";
}
// Keep in sync with getModelClient in get_model_client.ts // Keep in sync with getModelClient in get_model_client.ts
// If enabled but no option set (undefined/falsey), it's balanced // If enabled but no option set (undefined/falsey), it's balanced
return "balanced"; return "balanced";
@@ -320,33 +344,53 @@ function SmartContextSelector({
className="inline-flex rounded-md border border-input" className="inline-flex rounded-md border border-input"
data-testid="smart-context-selector" data-testid="smart-context-selector"
> >
<Button <Tooltip>
variant={currentValue === "off" ? "default" : "ghost"} <TooltipTrigger asChild>
size="sm" <Button
onClick={() => onValueChange("off")} variant={currentValue === "off" ? "default" : "ghost"}
disabled={!isTogglable} size="sm"
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0" onClick={() => onValueChange("off")}
> disabled={!isTogglable}
Off className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
</Button> >
<Button Off
variant={currentValue === "conservative" ? "default" : "ghost"} </Button>
size="sm" </TooltipTrigger>
onClick={() => onValueChange("conservative")} <TooltipContent>Disable Smart Context</TooltipContent>
disabled={!isTogglable} </Tooltip>
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0" <Tooltip>
> <TooltipTrigger asChild>
Conservative <Button
</Button> variant={currentValue === "balanced" ? "default" : "ghost"}
<Button size="sm"
variant={currentValue === "balanced" ? "default" : "ghost"} onClick={() => onValueChange("balanced")}
size="sm" disabled={!isTogglable}
onClick={() => onValueChange("balanced")} className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
disabled={!isTogglable} >
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0" Balanced
> </Button>
Balanced </TooltipTrigger>
</Button> <TooltipContent>
Selects most relevant files with balanced context size
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "deep" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("deep")}
disabled={!isTogglable}
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
>
Deep
</Button>
</TooltipTrigger>
<TooltipContent>
<b>Experimental:</b> Keeps full conversation history for maximum
context and cache-optimized to control costs
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,31 @@
import type React from "react";
import type { ReactNode } from "react";
import { FileCode } from "lucide-react";
interface DyadCodeSearchProps {
children?: ReactNode;
node?: any;
query?: string;
}
export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
children,
node: _node,
query: queryProp,
}) => {
const query = queryProp || (typeof children === "string" ? children : "");
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileCode size={16} className="text-purple-600" />
<div className="text-xs text-purple-600 font-medium">Code Search</div>
</div>
</div>
<div className="text-sm italic text-gray-600 dark:text-gray-300 mt-2">
{query || children}
</div>
</div>
);
};

View File

@@ -0,0 +1,123 @@
import React, { useState, useMemo } from "react";
import { ChevronDown, ChevronUp, FileCode, FileText } from "lucide-react";
interface DyadCodeSearchResultProps {
node?: any;
children?: React.ReactNode;
}
export const DyadCodeSearchResult: React.FC<DyadCodeSearchResultProps> = ({
children,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
// Parse file paths from children content
const files = useMemo(() => {
if (typeof children !== "string") {
return [];
}
const filePaths: string[] = [];
const lines = children.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines and lines that look like tags
if (
trimmedLine &&
!trimmedLine.startsWith("<") &&
!trimmedLine.startsWith(">")
) {
filePaths.push(trimmedLine);
}
}
return filePaths;
}, [children]);
return (
<div
className="relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border border-border my-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
role="button"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
{/* Top-left label badge */}
<div
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-purple-600 bg-white dark:bg-zinc-900"
style={{ zIndex: 1 }}
>
<FileCode size={16} className="text-purple-600" />
<span>Code Search Result</span>
</div>
{/* File count when collapsed */}
{files.length > 0 && (
<div className="absolute top-2 left-44 flex items-center">
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-zinc-800 text-xs rounded text-gray-600 dark:text-gray-300">
Found {files.length} file{files.length !== 1 ? "s" : ""}
</span>
</div>
)}
{/* Indicator icon */}
<div className="absolute top-2 right-2 p-1 text-gray-500">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
{/* Main content with smooth transition */}
<div
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "1000px" : "0px",
opacity: isExpanded ? 1 : 0,
marginBottom: isExpanded ? "0" : "-6px",
}}
>
{/* File list when expanded */}
{files.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-2 mt-2">
{files.map((file, index) => {
const filePath = file.trim();
const fileName = filePath.split("/").pop() || filePath;
const pathPart =
filePath.substring(0, filePath.length - fileName.length) ||
"";
return (
<div
key={index}
className="px-2 py-1 bg-gray-100 dark:bg-zinc-800 rounded-lg"
>
<div className="flex items-center gap-1.5">
<FileText
size={14}
className="text-gray-500 dark:text-gray-400 flex-shrink-0"
/>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
{fileName}
</div>
</div>
{pathPart && (
<div className="text-xs text-gray-500 dark:text-gray-400 ml-5">
{pathPart}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -23,6 +23,8 @@ import { DyadMcpToolResult } from "./DyadMcpToolResult";
import { DyadWebSearchResult } from "./DyadWebSearchResult"; import { DyadWebSearchResult } from "./DyadWebSearchResult";
import { DyadWebSearch } from "./DyadWebSearch"; import { DyadWebSearch } from "./DyadWebSearch";
import { DyadWebCrawl } from "./DyadWebCrawl"; import { DyadWebCrawl } from "./DyadWebCrawl";
import { DyadCodeSearchResult } from "./DyadCodeSearchResult";
import { DyadCodeSearch } from "./DyadCodeSearch";
import { DyadRead } from "./DyadRead"; import { DyadRead } from "./DyadRead";
import { mapActionToButton } from "./ChatInput"; import { mapActionToButton } from "./ChatInput";
import { SuggestedAction } from "@/lib/schemas"; import { SuggestedAction } from "@/lib/schemas";
@@ -210,6 +212,8 @@ function parseCustomTags(content: string): ContentPiece[] {
"dyad-web-search-result", "dyad-web-search-result",
"dyad-web-search", "dyad-web-search",
"dyad-web-crawl", "dyad-web-crawl",
"dyad-code-search-result",
"dyad-code-search",
"dyad-read", "dyad-read",
"think", "think",
"dyad-command", "dyad-command",
@@ -332,6 +336,26 @@ function renderCustomTag(
{content} {content}
</DyadWebCrawl> </DyadWebCrawl>
); );
case "dyad-code-search":
return (
<DyadCodeSearch
node={{
properties: {},
}}
>
{content}
</DyadCodeSearch>
);
case "dyad-code-search-result":
return (
<DyadCodeSearchResult
node={{
properties: {},
}}
>
{content}
</DyadCodeSearchResult>
);
case "dyad-web-search-result": case "dyad-web-search-result":
return ( return (
<DyadWebSearchResult <DyadWebSearchResult

View File

@@ -72,6 +72,9 @@ export const messages = sqliteTable("messages", {
approvalState: text("approval_state", { approvalState: text("approval_state", {
enum: ["approved", "rejected"], enum: ["approved", "rejected"],
}), }),
// The commit hash of the codebase at the time the message was created
sourceCommitHash: text("source_commit_hash"),
// The commit hash of the codebase at the time the message was sent
commitHash: text("commit_hash"), commitHash: text("commit_hash"),
requestId: text("request_id"), requestId: text("request_id"),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })

View File

@@ -81,6 +81,11 @@ import { mcpManager } from "../utils/mcp_manager";
import z from "zod"; import z from "zod";
import { isTurboEditsV2Enabled } from "@/lib/schemas"; import { isTurboEditsV2Enabled } from "@/lib/schemas";
import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts"; import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import { getCurrentCommitHash } from "../utils/git_utils";
import {
processChatMessagesWithVersionedFiles as getVersionedFiles,
VersionedFiles as VersionedFiles,
} from "../utils/versioned_codebase_context";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>; type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -407,6 +412,9 @@ ${componentSnippet}
role: "assistant", role: "assistant",
content: "", // Start with empty content content: "", // Start with empty content
requestId: dyadRequestId, requestId: dyadRequestId,
sourceCommitHash: await getCurrentCommitHash({
path: getDyadAppPath(chat.app.path),
}),
}) })
.returning(); .returning();
@@ -523,12 +531,20 @@ ${componentSnippet}
const messageHistory = updatedChat.messages.map((message) => ({ const messageHistory = updatedChat.messages.map((message) => ({
role: message.role as "user" | "assistant" | "system", role: message.role as "user" | "assistant" | "system",
content: message.content, content: message.content,
sourceCommitHash: message.sourceCommitHash,
})); }));
// For Dyad Pro + Deep Context, we set to 200 chat turns (+1)
// this is to enable more cache hits. Practically, users should
// rarely go over this limit because they will hit the model's
// context window limit.
//
// Limit chat history based on maxChatTurnsInContext setting // Limit chat history based on maxChatTurnsInContext setting
// We add 1 because the current prompt counts as a turn. // We add 1 because the current prompt counts as a turn.
const maxChatTurns = const maxChatTurns =
(settings.maxChatTurnsInContext || MAX_CHAT_TURNS_IN_CONTEXT) + 1; isEngineEnabled && settings.proSmartContextOption === "deep"
? 201
: (settings.maxChatTurnsInContext || MAX_CHAT_TURNS_IN_CONTEXT) + 1;
// If we need to limit the context, we take only the most recent turns // If we need to limit the context, we take only the most recent turns
let limitedMessageHistory = messageHistory; let limitedMessageHistory = messageHistory;
@@ -713,6 +729,11 @@ This conversation includes one or more image attachments. When the user uploads
settings.selectedChatMode === "ask" settings.selectedChatMode === "ask"
? removeDyadTags(removeNonEssentialTags(msg.content)) ? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content), : removeNonEssentialTags(msg.content),
providerOptions: {
"dyad-engine": {
sourceCommitHash: msg.sourceCommitHash,
},
},
})); }));
let chatMessages: ModelMessage[] = [ let chatMessages: ModelMessage[] = [
@@ -776,12 +797,22 @@ This conversation includes one or more image attachments. When the user uploads
} else { } else {
logger.log("sending AI request"); logger.log("sending AI request");
} }
let versionedFiles: VersionedFiles | undefined;
if (isEngineEnabled && settings.proSmartContextOption === "deep") {
versionedFiles = await getVersionedFiles({
files,
chatMessages,
appPath,
});
}
// Build provider options with correct Google/Vertex thinking config gating // Build provider options with correct Google/Vertex thinking config gating
const providerOptions: Record<string, any> = { const providerOptions: Record<string, any> = {
"dyad-engine": { "dyad-engine": {
dyadAppId: updatedChat.app.id,
dyadRequestId, dyadRequestId,
dyadDisableFiles, dyadDisableFiles,
dyadFiles: files, dyadFiles: versionedFiles ? undefined : files,
dyadVersionedFiles: versionedFiles,
dyadMentionedApps: mentionedAppsCodebases.map( dyadMentionedApps: mentionedAppsCodebases.map(
({ files, appName }) => ({ ({ files, appName }) => ({
appName, appName,
@@ -979,21 +1010,48 @@ This conversation includes one or more image attachments. When the user uploads
settings.selectedChatMode !== "ask" && settings.selectedChatMode !== "ask" &&
isTurboEditsV2Enabled(settings) isTurboEditsV2Enabled(settings)
) { ) {
const issues = await dryRunSearchReplace({ let issues = await dryRunSearchReplace({
fullResponse, fullResponse,
appPath: getDyadAppPath(updatedChat.app.path), appPath: getDyadAppPath(updatedChat.app.path),
}); });
if (issues.length > 0) {
let searchReplaceFixAttempts = 0;
const originalFullResponse = fullResponse;
const previousAttempts: ModelMessage[] = [];
while (
issues.length > 0 &&
searchReplaceFixAttempts < 2 &&
!abortController.signal.aborted
) {
logger.warn( logger.warn(
`Detected search-replace issues: ${issues.map((i) => i.error).join(", ")}`, `Detected search-replace issues (attempt #${searchReplaceFixAttempts + 1}): ${issues.map((i) => i.error).join(", ")}`,
); );
const formattedSearchReplaceIssues = issues const formattedSearchReplaceIssues = issues
.map(({ filePath, error }) => { .map(({ filePath, error }) => {
return `File path: ${filePath}\nError: ${error}`; return `File path: ${filePath}\nError: ${error}`;
}) })
.join("\n\n"); .join("\n\n");
const originalFullResponse = fullResponse;
fullResponse += `<dyad-output type="warning" message="Could not apply Turbo Edits properly for some of the files; re-generating code...">${formattedSearchReplaceIssues}</dyad-output>`; fullResponse += `<dyad-output type="warning" message="Could not apply Turbo Edits properly for some of the files; re-generating code...">${formattedSearchReplaceIssues}</dyad-output>`;
await processResponseChunkUpdate({
fullResponse,
});
logger.info(
`Attempting to fix search-replace issues, attempt #${searchReplaceFixAttempts + 1}`,
);
const fixSearchReplacePrompt =
searchReplaceFixAttempts === 0
? `There was an issue with the following \`dyad-search-replace\` tags. Make sure you use \`dyad-read\` to read the latest version of the file and then trying to do search & replace again.`
: `There was an issue with the following \`dyad-search-replace\` tags. Please fix the errors by generating the code changes using \`dyad-write\` tags instead.`;
searchReplaceFixAttempts++;
const userPrompt = {
role: "user",
content: `${fixSearchReplacePrompt}
${formattedSearchReplaceIssues}`,
} as const;
const { fullStream: fixSearchReplaceStream } = const { fullStream: fixSearchReplaceStream } =
await simpleStreamText({ await simpleStreamText({
@@ -1001,16 +1059,13 @@ This conversation includes one or more image attachments. When the user uploads
chatMessages: [ chatMessages: [
...chatMessages, ...chatMessages,
{ role: "assistant", content: originalFullResponse }, { role: "assistant", content: originalFullResponse },
{ ...previousAttempts,
role: "user", userPrompt,
content: `There was an issue with the following \`dyad-search-replace\` tags. Please fix them by generating the code changes using \`dyad-write\` tags instead.
${formattedSearchReplaceIssues}`,
},
], ],
modelClient, modelClient,
files: files, files: files,
}); });
previousAttempts.push(userPrompt);
const result = await processStreamChunks({ const result = await processStreamChunks({
fullStream: fixSearchReplaceStream, fullStream: fixSearchReplaceStream,
fullResponse, fullResponse,
@@ -1019,6 +1074,16 @@ ${formattedSearchReplaceIssues}`,
processResponseChunkUpdate, processResponseChunkUpdate,
}); });
fullResponse = result.fullResponse; fullResponse = result.fullResponse;
previousAttempts.push({
role: "assistant",
content: removeNonEssentialTags(result.incrementalResponse),
});
// Re-check for issues after the fix attempt
issues = await dryRunSearchReplace({
fullResponse: result.incrementalResponse,
appPath: getDyadAppPath(updatedChat.app.path),
});
} }
} }

View File

@@ -368,7 +368,7 @@ export async function processFullResponseActions(
const original = await readFile(fullFilePath, "utf8"); const original = await readFile(fullFilePath, "utf8");
const result = applySearchReplace(original, tag.content); const result = applySearchReplace(original, tag.content);
if (!result.success || typeof result.content !== "string") { if (!result.success || typeof result.content !== "string") {
// Do not show warning to user because we already attempt to do a <dyad-write> tag to fix it. // Do not show warning to user because we already attempt to do a <dyad-write> and/or a subsequent <dyad-search-replace> tag to fix it.
logger.warn( logger.warn(
`Failed to apply search-replace to ${filePath}: ${result.error ?? "unknown"}`, `Failed to apply search-replace to ${filePath}: ${result.error ?? "unknown"}`,
); );

View File

@@ -6,7 +6,8 @@ import pathModule from "node:path";
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { readSettings } from "../../main/settings"; import { readSettings } from "../../main/settings";
import log from "electron-log";
const logger = log.scope("git_utils");
const execAsync = promisify(exec); const execAsync = promisify(exec);
async function verboseExecAsync( async function verboseExecAsync(
@@ -26,6 +27,18 @@ async function verboseExecAsync(
} }
} }
export async function getCurrentCommitHash({
path,
}: {
path: string;
}): Promise<string> {
return await git.resolveRef({
fs,
dir: path,
ref: "HEAD",
});
}
export async function gitCommit({ export async function gitCommit({
path, path,
message, message,
@@ -166,3 +179,45 @@ export async function gitAddAll({ path }: { path: string }): Promise<void> {
return git.add({ fs, dir: path, filepath: "." }); return git.add({ fs, dir: path, filepath: "." });
} }
} }
export async function getFileAtCommit({
path,
filePath,
commitHash,
}: {
path: string;
filePath: string;
commitHash: string;
}): Promise<string | null> {
const settings = readSettings();
if (settings.enableNativeGit) {
try {
const { stdout } = await execAsync(
`git -C "${path}" show "${commitHash}:${filePath}"`,
);
return stdout;
} catch (error: any) {
logger.error(
`Error getting file at commit ${commitHash}: ${error.message}`,
);
// File doesn't exist at this commit
return null;
}
} else {
try {
const { blob } = await git.readBlob({
fs,
dir: path,
oid: commitHash,
filepath: filePath,
});
return Buffer.from(blob).toString("utf-8");
} catch (error: any) {
logger.error(
`Error getting file at commit ${commitHash}: ${error.message}`,
);
// File doesn't exist at this commit
return null;
}
}
}

View File

@@ -42,7 +42,7 @@ or to provide a custom fetch implementation for e.g. testing.
enableLazyEdits?: boolean; enableLazyEdits?: boolean;
enableSmartFilesContext?: boolean; enableSmartFilesContext?: boolean;
enableWebSearch?: boolean; enableWebSearch?: boolean;
smartContextMode?: "balanced" | "conservative"; smartContextMode?: "balanced" | "conservative" | "deep";
}; };
settings: UserSettings; settings: UserSettings;
} }
@@ -125,6 +125,10 @@ export function createDyadEngine(
options.settings, options.settings,
), ),
}; };
const dyadVersionedFiles = parsedBody.dyadVersionedFiles;
if ("dyadVersionedFiles" in parsedBody) {
delete parsedBody.dyadVersionedFiles;
}
const dyadFiles = parsedBody.dyadFiles; const dyadFiles = parsedBody.dyadFiles;
if ("dyadFiles" in parsedBody) { if ("dyadFiles" in parsedBody) {
delete parsedBody.dyadFiles; delete parsedBody.dyadFiles;
@@ -133,6 +137,10 @@ export function createDyadEngine(
if ("dyadRequestId" in parsedBody) { if ("dyadRequestId" in parsedBody) {
delete parsedBody.dyadRequestId; delete parsedBody.dyadRequestId;
} }
const dyadAppId = parsedBody.dyadAppId;
if ("dyadAppId" in parsedBody) {
delete parsedBody.dyadAppId;
}
const dyadDisableFiles = parsedBody.dyadDisableFiles; const dyadDisableFiles = parsedBody.dyadDisableFiles;
if ("dyadDisableFiles" in parsedBody) { if ("dyadDisableFiles" in parsedBody) {
delete parsedBody.dyadDisableFiles; delete parsedBody.dyadDisableFiles;
@@ -151,14 +159,16 @@ export function createDyadEngine(
} }
// Add files to the request if they exist // Add files to the request if they exist
if (dyadFiles?.length && !dyadDisableFiles) { if (!dyadDisableFiles) {
parsedBody.dyad_options = { parsedBody.dyad_options = {
files: dyadFiles, files: dyadFiles,
versioned_files: dyadVersionedFiles,
enable_lazy_edits: options.dyadOptions.enableLazyEdits, enable_lazy_edits: options.dyadOptions.enableLazyEdits,
enable_smart_files_context: enable_smart_files_context:
options.dyadOptions.enableSmartFilesContext, options.dyadOptions.enableSmartFilesContext,
smart_context_mode: options.dyadOptions.smartContextMode, smart_context_mode: options.dyadOptions.smartContextMode,
enable_web_search: options.dyadOptions.enableWebSearch, enable_web_search: options.dyadOptions.enableWebSearch,
app_id: dyadAppId,
}; };
if (dyadMentionedApps?.length) { if (dyadMentionedApps?.length) {
parsedBody.dyad_options.mentioned_apps = dyadMentionedApps; parsedBody.dyad_options.mentioned_apps = dyadMentionedApps;

View File

@@ -0,0 +1,219 @@
import { CodebaseFile, CodebaseFileReference } from "@/utils/codebase";
import { ModelMessage } from "@ai-sdk/provider-utils";
import crypto from "node:crypto";
import log from "electron-log";
import { getFileAtCommit } from "./git_utils";
import { normalizePath } from "../../../shared/normalizePath";
const logger = log.scope("versioned_codebase_context");
export interface VersionedFiles {
fileIdToContent: Record<string, string>;
fileReferences: CodebaseFileReference[];
messageIndexToFilePathToFileId: Record<number, Record<string, string>>;
}
interface DyadEngineProviderOptions {
sourceCommitHash: string;
}
/**
* Parse file paths from assistant message content.
* Extracts files from <dyad-read> and <dyad-code-search-result> tags.
*/
export function parseFilesFromMessage(content: string): string[] {
const filePaths: string[] = [];
const seenPaths = new Set<string>();
// Create an array of matches with their positions to maintain order
interface TagMatch {
index: number;
filePaths: string[];
}
const matches: TagMatch[] = [];
// Parse <dyad-read path="$filePath"></dyad-read>
const dyadReadRegex = /<dyad-read\s+path="([^"]+)"\s*><\/dyad-read>/gs;
let match: RegExpExecArray | null;
while ((match = dyadReadRegex.exec(content)) !== null) {
const filePath = normalizePath(match[1].trim());
if (filePath) {
matches.push({
index: match.index,
filePaths: [filePath],
});
}
}
// Parse <dyad-code-search-result>...</dyad-code-search-result>
const codeSearchRegex =
/<dyad-code-search-result>(.*?)<\/dyad-code-search-result>/gs;
while ((match = codeSearchRegex.exec(content)) !== null) {
const innerContent = match[1];
const paths: string[] = [];
// Split by newlines and extract each file path
const lines = innerContent.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (
trimmedLine &&
!trimmedLine.startsWith("<") &&
!trimmedLine.startsWith(">")
) {
paths.push(normalizePath(trimmedLine));
}
}
if (paths.length > 0) {
matches.push({
index: match.index,
filePaths: paths,
});
}
}
// Sort matches by their position in the original content
matches.sort((a, b) => a.index - b.index);
// Add file paths in order, deduplicating as we go
for (const match of matches) {
for (const path of match.filePaths) {
if (!seenPaths.has(path)) {
seenPaths.add(path);
filePaths.push(path);
}
}
}
return filePaths;
}
export async function processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
}: {
files: CodebaseFile[];
chatMessages: ModelMessage[];
appPath: string;
}): Promise<VersionedFiles> {
const fileIdToContent: Record<string, string> = {};
const fileReferences: CodebaseFileReference[] = [];
const messageIndexToFilePathToFileId: Record<
number,
Record<string, string>
> = {};
for (const file of files) {
// Generate SHA-256 hash of content as fileId
const fileId = crypto
.createHash("sha256")
.update(file.content)
.digest("hex");
fileIdToContent[fileId] = file.content;
const { content: _content, ...restOfFile } = file;
fileReferences.push({
...restOfFile,
fileId,
});
}
for (
let messageIndex = 0;
messageIndex < chatMessages.length;
messageIndex++
) {
const message = chatMessages[messageIndex];
// Only process assistant messages
if (message.role !== "assistant") {
continue;
}
// Extract sourceCommitHash from providerOptions
const engineOptions = message.providerOptions?.[
"dyad-engine"
] as unknown as DyadEngineProviderOptions;
const sourceCommitHash = engineOptions?.sourceCommitHash;
// Skip messages without sourceCommitHash
if (!sourceCommitHash) {
continue;
}
// Get message content as text
const content = message.content;
let textContent: string;
if (typeof content !== "string") {
// Handle array of parts (text, images, etc.)
textContent = content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
if (!textContent) {
continue;
}
} else {
// Message content is already a string
textContent = content;
}
// Parse file paths from message content
const filePaths = parseFilesFromMessage(textContent);
const filePathsToFileIds: Record<string, string> = {};
messageIndexToFilePathToFileId[messageIndex] = filePathsToFileIds;
// Parallelize file content fetching
const fileContentPromises = filePaths.map((filePath) =>
getFileAtCommit({
path: appPath,
filePath,
commitHash: sourceCommitHash,
}).then(
(content) => ({ filePath, content, status: "fulfilled" as const }),
(error) => ({ filePath, error, status: "rejected" as const }),
),
);
const results = await Promise.all(fileContentPromises);
for (const result of results) {
if (result.status === "rejected") {
logger.error(
`Error reading file ${result.filePath} at commit ${sourceCommitHash}:`,
result.error,
);
continue;
}
const { filePath, content: fileContent } = result;
if (fileContent === null) {
logger.warn(
`File ${filePath} not found at commit ${sourceCommitHash} for message ${messageIndex}`,
);
continue;
}
// Generate SHA-256 hash of content as fileId
const fileId = crypto
.createHash("sha256")
.update(fileContent)
.digest("hex");
// Store in fileIdToContent
fileIdToContent[fileId] = fileContent;
// Add to this message's file IDs
filePathsToFileIds[filePath] = fileId;
}
}
return {
fileIdToContent,
fileReferences,
messageIndexToFilePathToFileId,
};
}

View File

@@ -235,7 +235,9 @@ export const UserSettingsSchema = z.object({
proLazyEditsMode: z.enum(["off", "v1", "v2"]).optional(), proLazyEditsMode: z.enum(["off", "v1", "v2"]).optional(),
enableProSmartFilesContextMode: z.boolean().optional(), enableProSmartFilesContextMode: z.boolean().optional(),
enableProWebSearch: z.boolean().optional(), enableProWebSearch: z.boolean().optional(),
proSmartContextOption: z.enum(["balanced", "conservative"]).optional(), proSmartContextOption: z
.enum(["balanced", "conservative", "deep"])
.optional(),
selectedTemplateId: z.string(), selectedTemplateId: z.string(),
enableSupabaseWriteSqlMigration: z.boolean().optional(), enableSupabaseWriteSqlMigration: z.boolean().optional(),
selectedChatMode: ChatModeSchema.optional(), selectedChatMode: ChatModeSchema.optional(),

View File

@@ -134,7 +134,10 @@ export function readSettings(): UserSettings {
// Validate and merge with defaults // Validate and merge with defaults
const validatedSettings = UserSettingsSchema.parse(combinedSettings); const validatedSettings = UserSettingsSchema.parse(combinedSettings);
// "conservative" is deprecated, use undefined to use the default value
if (validatedSettings.proSmartContextOption === "conservative") {
validatedSettings.proSmartContextOption = undefined;
}
return validatedSettings; return validatedSettings;
} catch (error) { } catch (error) {
logger.error("Error reading settings:", error); logger.error("Error reading settings:", error);

View File

@@ -411,12 +411,19 @@ ${content}
} }
} }
export type CodebaseFile = { export interface BaseFile {
path: string; path: string;
content: string;
focused?: boolean; focused?: boolean;
force?: boolean; force?: boolean;
}; }
export interface CodebaseFile extends BaseFile {
content: string;
}
export interface CodebaseFileReference extends BaseFile {
fileId: string;
}
/** /**
* Extract and format codebase files as a string to be included in prompts * Extract and format codebase files as a string to be included in prompts

View File

@@ -131,17 +131,36 @@ export default Index;
lastMessage && lastMessage &&
typeof lastMessage.content === "string" && typeof lastMessage.content === "string" &&
lastMessage.content.startsWith( lastMessage.content.startsWith(
"There was an issue with the following `dyad-search-replace` tags", "There was an issue with the following `dyad-search-replace` tags.",
) )
) { ) {
// Fix errors in create-ts-errors.md and introduce a new error if (lastMessage.content.includes("Make sure you use `dyad-read`")) {
messageContent = // Fix errors in create-ts-errors.md and introduce a new error
` messageContent =
`
<dyad-read path="src/pages/Index.tsx"></dyad-read>
<dyad-search-replace path="src/pages/Index.tsx">
<<<<<<< SEARCH
// STILL Intentionally DO NOT MATCH ANYTHING TO TRIGGER FALLBACK
<h1 className="text-4xl font-bold mb-4">Welcome to Your Blank App</h1>
=======
<h1 className="text-4xl font-bold mb-4">Welcome to the UPDATED App</h1>
>>>>>>> REPLACE
</dyad-search-replace>
` +
"\n\n" +
generateDump(req);
} else {
// Fix errors in create-ts-errors.md and introduce a new error
messageContent =
`
<dyad-write path="src/pages/Index.tsx" description="Rewrite file."> <dyad-write path="src/pages/Index.tsx" description="Rewrite file.">
// FILE IS REPLACED WITH FALLBACK WRITE. // FILE IS REPLACED WITH FALLBACK WRITE.
</dyad-write>` + </dyad-write>` +
"\n\n" + "\n\n" +
generateDump(req); generateDump(req);
}
} }
console.error("LASTMESSAGE", lastMessage); console.error("LASTMESSAGE", lastMessage);