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,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 = (
newValue: "off" | "conservative" | "balanced",
) => {
const handleSmartContextChange = (newValue: "off" | "deep" | "balanced") => {
if (newValue === "off") {
updateSettings({
enableProSmartFilesContextMode: false,
proSmartContextOption: undefined,
});
} else if (newValue === "conservative") {
} else if (newValue === "deep") {
updateSettings({
enableProSmartFilesContextMode: true,
proSmartContextOption: "conservative",
proSmartContextOption: "deep",
});
} else if (newValue === "balanced") {
updateSettings({
@@ -90,16 +88,23 @@ export function ProModeSelector() {
</div>
{!hasProKey && (
<div className="text-sm text-center text-muted-foreground">
<a
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"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://dyad.sh/pro#ai",
);
}}
>
Unlock Pro modes
</a>
<Tooltip>
<TooltipTrigger asChild>
<a
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"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://dyad.sh/pro#ai",
);
}}
>
Unlock Pro modes
</a>
</TooltipTrigger>
<TooltipContent>
Visit dyad.sh/pro to unlock Pro features
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex flex-col gap-5">
@@ -239,33 +244,52 @@ function TurboEditsSelector({
className="inline-flex rounded-md border border-input"
data-testid="turbo-edits-selector"
>
<Button
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("off")}
disabled={!isTogglable}
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Off
</Button>
<Button
variant={currentValue === "v1" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("v1")}
disabled={!isTogglable}
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Classic
</Button>
<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>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("off")}
disabled={!isTogglable}
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Off
</Button>
</TooltipTrigger>
<TooltipContent>Disable Turbo Edits</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "v1" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("v1")}
disabled={!isTogglable}
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Classic
</Button>
</TooltipTrigger>
<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>
);
@@ -278,19 +302,19 @@ function SmartContextSelector({
}: {
isTogglable: boolean;
settings: UserSettings | null;
onValueChange: (value: "off" | "conservative" | "balanced") => void;
onValueChange: (value: "off" | "balanced" | "deep") => void;
}) {
// Determine current value based on settings
const getCurrentValue = (): "off" | "conservative" | "balanced" => {
const getCurrentValue = (): "off" | "conservative" | "balanced" | "deep" => {
if (!settings?.enableProSmartFilesContextMode) {
return "off";
}
if (settings?.proSmartContextOption === "deep") {
return "deep";
}
if (settings?.proSmartContextOption === "balanced") {
return "balanced";
}
if (settings?.proSmartContextOption === "conservative") {
return "conservative";
}
// Keep in sync with getModelClient in get_model_client.ts
// If enabled but no option set (undefined/falsey), it's balanced
return "balanced";
@@ -320,33 +344,53 @@ function SmartContextSelector({
className="inline-flex rounded-md border border-input"
data-testid="smart-context-selector"
>
<Button
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("off")}
disabled={!isTogglable}
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Off
</Button>
<Button
variant={currentValue === "conservative" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("conservative")}
disabled={!isTogglable}
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Conservative
</Button>
<Button
variant={currentValue === "balanced" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("balanced")}
disabled={!isTogglable}
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
>
Balanced
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("off")}
disabled={!isTogglable}
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Off
</Button>
</TooltipTrigger>
<TooltipContent>Disable Smart Context</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "balanced" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("balanced")}
disabled={!isTogglable}
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Balanced
</Button>
</TooltipTrigger>
<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>
);

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

View File

@@ -72,6 +72,9 @@ export const messages = sqliteTable("messages", {
approvalState: text("approval_state", {
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"),
requestId: text("request_id"),
createdAt: integer("created_at", { mode: "timestamp" })

View File

@@ -81,6 +81,11 @@ import { mcpManager } from "../utils/mcp_manager";
import z from "zod";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
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>;
@@ -407,6 +412,9 @@ ${componentSnippet}
role: "assistant",
content: "", // Start with empty content
requestId: dyadRequestId,
sourceCommitHash: await getCurrentCommitHash({
path: getDyadAppPath(chat.app.path),
}),
})
.returning();
@@ -523,12 +531,20 @@ ${componentSnippet}
const messageHistory = updatedChat.messages.map((message) => ({
role: message.role as "user" | "assistant" | "system",
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
// We add 1 because the current prompt counts as a turn.
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
let limitedMessageHistory = messageHistory;
@@ -713,6 +729,11 @@ This conversation includes one or more image attachments. When the user uploads
settings.selectedChatMode === "ask"
? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content),
providerOptions: {
"dyad-engine": {
sourceCommitHash: msg.sourceCommitHash,
},
},
}));
let chatMessages: ModelMessage[] = [
@@ -776,12 +797,22 @@ This conversation includes one or more image attachments. When the user uploads
} else {
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
const providerOptions: Record<string, any> = {
"dyad-engine": {
dyadAppId: updatedChat.app.id,
dyadRequestId,
dyadDisableFiles,
dyadFiles: files,
dyadFiles: versionedFiles ? undefined : files,
dyadVersionedFiles: versionedFiles,
dyadMentionedApps: mentionedAppsCodebases.map(
({ files, appName }) => ({
appName,
@@ -979,21 +1010,48 @@ This conversation includes one or more image attachments. When the user uploads
settings.selectedChatMode !== "ask" &&
isTurboEditsV2Enabled(settings)
) {
const issues = await dryRunSearchReplace({
let issues = await dryRunSearchReplace({
fullResponse,
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(
`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
.map(({ filePath, error }) => {
return `File path: ${filePath}\nError: ${error}`;
})
.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>`;
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 } =
await simpleStreamText({
@@ -1001,16 +1059,13 @@ This conversation includes one or more image attachments. When the user uploads
chatMessages: [
...chatMessages,
{ role: "assistant", content: originalFullResponse },
{
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.
${formattedSearchReplaceIssues}`,
},
...previousAttempts,
userPrompt,
],
modelClient,
files: files,
});
previousAttempts.push(userPrompt);
const result = await processStreamChunks({
fullStream: fixSearchReplaceStream,
fullResponse,
@@ -1019,6 +1074,16 @@ ${formattedSearchReplaceIssues}`,
processResponseChunkUpdate,
});
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 result = applySearchReplace(original, tag.content);
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(
`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 { promisify } from "node:util";
import { readSettings } from "../../main/settings";
import log from "electron-log";
const logger = log.scope("git_utils");
const execAsync = promisify(exec);
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({
path,
message,
@@ -166,3 +179,45 @@ export async function gitAddAll({ path }: { path: string }): Promise<void> {
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;
enableSmartFilesContext?: boolean;
enableWebSearch?: boolean;
smartContextMode?: "balanced" | "conservative";
smartContextMode?: "balanced" | "conservative" | "deep";
};
settings: UserSettings;
}
@@ -125,6 +125,10 @@ export function createDyadEngine(
options.settings,
),
};
const dyadVersionedFiles = parsedBody.dyadVersionedFiles;
if ("dyadVersionedFiles" in parsedBody) {
delete parsedBody.dyadVersionedFiles;
}
const dyadFiles = parsedBody.dyadFiles;
if ("dyadFiles" in parsedBody) {
delete parsedBody.dyadFiles;
@@ -133,6 +137,10 @@ export function createDyadEngine(
if ("dyadRequestId" in parsedBody) {
delete parsedBody.dyadRequestId;
}
const dyadAppId = parsedBody.dyadAppId;
if ("dyadAppId" in parsedBody) {
delete parsedBody.dyadAppId;
}
const dyadDisableFiles = parsedBody.dyadDisableFiles;
if ("dyadDisableFiles" in parsedBody) {
delete parsedBody.dyadDisableFiles;
@@ -151,14 +159,16 @@ export function createDyadEngine(
}
// Add files to the request if they exist
if (dyadFiles?.length && !dyadDisableFiles) {
if (!dyadDisableFiles) {
parsedBody.dyad_options = {
files: dyadFiles,
versioned_files: dyadVersionedFiles,
enable_lazy_edits: options.dyadOptions.enableLazyEdits,
enable_smart_files_context:
options.dyadOptions.enableSmartFilesContext,
smart_context_mode: options.dyadOptions.smartContextMode,
enable_web_search: options.dyadOptions.enableWebSearch,
app_id: dyadAppId,
};
if (dyadMentionedApps?.length) {
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(),
enableProSmartFilesContextMode: z.boolean().optional(),
enableProWebSearch: z.boolean().optional(),
proSmartContextOption: z.enum(["balanced", "conservative"]).optional(),
proSmartContextOption: z
.enum(["balanced", "conservative", "deep"])
.optional(),
selectedTemplateId: z.string(),
enableSupabaseWriteSqlMigration: z.boolean().optional(),
selectedChatMode: ChatModeSchema.optional(),

View File

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

View File

@@ -411,12 +411,19 @@ ${content}
}
}
export type CodebaseFile = {
export interface BaseFile {
path: string;
content: string;
focused?: 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