Unverified 提交 d70c8269 authored 作者: Adekunle James Adeniji's avatar Adekunle James Adeniji 提交者: GitHub

AI Conflict Resolver (#2240)

Solves part of #2207 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces an in-app merge conflict workflow with both AI-assisted and manual resolution, integrated into Git sync and branch merges. > > - New `GithubConflictResolver` dialog: previews conflicting sections, "Auto-Resolve with AI" (with optional auto-approve) and "Accept Current Changes", and progresses through multi-file conflicts > - Integrated into `GitHubConnector` and `GithubBranchManager` to surface conflicts after failed push/merge, guide resolution, and then finalize via `ipc.github.completeMerge` (rebase-continue or merge commit) > - New IPC contracts/handlers: `github:get-conflicts`, `github:resolve-conflict` (writes/stages ours), `github:complete-merge`; plus improved conflict detection by checking conflicts directly on errors > - Enhanced error/rebase-state messaging and abort/continue flows; avoids relying on IPC-serialized error names > - E2E tests for AI and manual conflict resolution; fake LLM updated to emit conflict-fixing `dyad-write` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 98b8aa07ef175a2806bbc0609b433279fbde78c8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds inline merge-conflict resolution with a chat-based flow and a cancel option. Conflict detection, streaming, and cancel behavior are more reliable; merges remain blocked until the repo is clean. - **New Features** - Inline conflict banner listing files, with “Resolve merge conflicts with AI” (opens a new chat, auto-starts streaming) and “Cancel sync”; buttons disable while resolving or cancelling. - New useResolveMergeConflictsWithAI hook that validates appId, guards rapid clicks, creates the chat, navigates, starts the stream, and refreshes chats/app on completion; integrated into GitHubConnector and GithubBranchManager and clears conflicts only after chat creation. - E2E tests for AI resolution and cancel flow; fake LLM supports new and legacy prompts; Electron IPC docs clarify stream start returns void and how to avoid duplicate streams. - **Bug Fixes** - Always fetch conflicts after failed sync/merge, not relying on serialized error names; clearer messages for rebase/merge states and when conflict checks fail. - Cancel sync only shows success when an abort actually occurs; clears conflicts and refreshes branches; buttons disable during cancellation. - Resets streaming state if stream setup or navigation fails; onError now refreshes chat list and app; e2e git commands quote filenames. <sup>Written for commit 8ec0dd605bdadbec9b64b64643c0010129c9735b. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2240"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com> Co-authored-by: 's avatarWill Chen <willchen90@gmail.com>
上级 b1c4aa28
import { expect } from "@playwright/test";
import { test, Timeout } from "./helpers/test_helper";
import type { PageObject } from "./helpers/test_helper";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
async function createGitConflict(po: PageObject) {
await po.setUp({ disableNativeGit: false, autoApprove: true });
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
const repoName = "test-git-conflict-" + Date.now();
await po.githubConnector.fillCreateRepoName(repoName);
await po.githubConnector.clickCreateRepoButton();
await expect(po.page.getByTestId("github-connected-repo")).toBeVisible({
timeout: Timeout.MEDIUM,
});
const appPath = await po.getCurrentAppPath();
if (!appPath) throw new Error("App path not found");
// Setup conflict
const conflictFile = "conflict.txt";
const conflictFilePath = path.join(appPath, conflictFile);
// Configure git user for commits
execSync("git config user.email 'test@example.com'", { cwd: appPath });
execSync("git config user.name 'Test User'", { cwd: appPath });
execSync("git config commit.gpgsign false", { cwd: appPath });
// 1. Create file on main
fs.writeFileSync(conflictFilePath, "Line 1\nLine 2\nLine 3");
execSync(`git add "${conflictFile}" && git commit -m "Add conflict file"`, {
cwd: appPath,
});
// 2. Create feature branch
const featureBranch = "feature-conflict";
execSync(`git checkout -b ${featureBranch}`, { cwd: appPath });
fs.writeFileSync(conflictFilePath, "Line 1\nLine 2 Modified Feature\nLine 3");
execSync(`git add "${conflictFile}" && git commit -m "Modify on feature"`, {
cwd: appPath,
});
// 3. Switch back to main and modify
execSync(`git checkout main`, { cwd: appPath });
fs.writeFileSync(conflictFilePath, "Line 1\nLine 2 Modified Main\nLine 3");
execSync(`git add "${conflictFile}" && git commit -m "Modify on main"`, {
cwd: appPath,
});
// 4. Try to merge feature into main via UI
await po.goToChatTab();
await po.getTitleBarAppNameButton().click(); // Open Publish Panel
// Open branches accordion
const branchesCard = po.page.getByTestId("branches-header");
await branchesCard.click();
await po.page.getByTestId(`branch-actions-${featureBranch}`).click();
await po.page.getByTestId("merge-branch-menu-item").click();
await po.page.getByTestId("merge-branch-submit-button").click();
return { conflictFile, appPath };
}
test.describe("Git Collaboration", () => {
//create git conflict helper function
test("should create, switch, rename, merge, and delete branches", async ({
......@@ -307,4 +369,43 @@ test.describe("Git Collaboration", () => {
timeout: 5000,
});
});
test("should resolve merge conflicts with AI", async ({ po }) => {
const { conflictFile, appPath } = await createGitConflict(po);
// Verify inline resolve buttons appear (no modal)
const resolveButton = po.page.getByRole("button", {
name: "Resolve merge conflicts with AI",
});
await expect(resolveButton).toBeVisible({ timeout: Timeout.MEDIUM });
// Click the button to start AI resolution - this navigates to a new chat
await resolveButton.click();
// Wait for the chat to load and AI to respond (auto-approve is enabled)
const conflictFilePath = path.join(appPath, conflictFile);
await expect
.poll(() => fs.readFileSync(conflictFilePath, "utf-8"), {
timeout: Timeout.LONG,
})
.not.toMatch(/<<<<<<<|=======|>>>>>>>/);
await expect
.poll(() => fs.existsSync(path.join(appPath, ".git", "MERGE_HEAD")), {
timeout: Timeout.MEDIUM,
})
.toBe(false);
});
test("should cancel sync when merge conflicts occur", async ({ po }) => {
await createGitConflict(po);
// Verify inline resolve buttons appear
await expect(
po.page.getByRole("button", { name: "Resolve merge conflicts with AI" }),
).toBeVisible({ timeout: Timeout.MEDIUM });
// Click Cancel sync to abort the merge
await po.page.getByRole("button", { name: "Cancel sync" }).click();
// Conflict buttons should be gone (merge/rebase aborted, no toast shown)
await expect(
po.page.getByRole("button", { name: "Resolve merge conflicts with AI" }),
).not.toBeVisible({ timeout: Timeout.MEDIUM });
});
});
......@@ -46,6 +46,11 @@ const unsub = ipc.events.agent.onTodosUpdate((payload) => { ... });
ipc.chatStream.start(params, { onChunk, onEnd, onError });
```
## Stream client notes
- `createStreamClient(...).start()` returns `void`, not a cleanup/unsubscribe function. You cannot capture a handle to abort or clean up an active stream from the caller side.
- To guard against duplicate streams, use a module-level `Set` (like `pendingStreamChatIds` in `useStreamChat.ts`) or a React state/ref-based lock, not the return value.
## Handler expectations
- Handlers should `throw new Error("...")` on failure instead of returning `{ success: false }` style payloads.
......
......@@ -30,6 +30,8 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GithubBranchManager } from "@/components/GithubBranchManager";
import { useResolveMergeConflictsWithAI } from "@/hooks/useResolveMergeConflictsWithAI";
import { showSuccess, showError } from "@/lib/toast";
type SyncResult =
| { error: Error; handled?: boolean }
......@@ -91,8 +93,45 @@ function ConnectedGitHubConnector({
"abort" | "continue" | "safe-push" | null
>(null);
const [rebaseInProgress, setRebaseInProgress] = useState(false);
const [isCancellingSync, setIsCancellingSync] = useState(false);
const lastAutoSyncedAppIdRef = useRef<number | null>(null);
const { resolveWithAI, isResolving } = useResolveMergeConflictsWithAI({
appId,
conflicts,
onStartResolving: () => {
// Clear conflicts state when starting AI resolution since user will be navigated to chat
setConflicts([]);
setSyncError(null);
},
});
const handleCancelSync = async () => {
setIsCancellingSync(true);
try {
const state = await ipc.github.getGitState({ appId });
let aborted = false;
if (state.rebaseInProgress) {
await ipc.github.rebaseAbort({ appId });
setRebaseInProgress(false);
setRebaseStatusMessage("Rebase aborted.");
aborted = true;
} else if (state.mergeInProgress) {
await ipc.github.mergeAbort({ appId });
aborted = true;
}
setConflicts([]);
setSyncError(null);
if (aborted) {
showSuccess("Sync cancelled");
}
} catch (error: any) {
showError(error?.message || "Failed to cancel sync");
} finally {
setIsCancellingSync(false);
}
};
const handleDisconnectRepo = async () => {
setIsDisconnecting(true);
setDisconnectError(null);
......@@ -132,22 +171,41 @@ function ConnectedGitHubConnector({
setRebaseStatusMessage(null);
return {};
} catch (err: any) {
if (err?.name === "GitConflictError") {
try {
const mergeConflicts = await ipc.github.getConflicts({ appId });
if (mergeConflicts.length > 0) {
setConflicts(mergeConflicts);
setSyncError(
"Merge conflicts detected. Please resolve them in the editor.",
);
(err as Error & { handled?: boolean }).handled = true;
return { error: err, handled: true };
}
} catch {
// If getGithubMergeConflicts fails, fall through to handle the original GitConflictError
// The error from getGithubMergeConflicts is intentionally not handled here
// so the original GitConflictError can be displayed to the user
}
// Always check for conflicts when sync fails, regardless of error type
// IPC serialization may not preserve error.name, so we check conflicts directly
// This is important because gitPull can throw GitConflictError which might not
// be properly serialized through IPC
let conflictsDetected: string[] = [];
let conflictCheckError: unknown = null;
try {
conflictsDetected = await ipc.github.getConflicts({ appId });
} catch (error) {
// If conflict check fails, keep the error to surface it with the sync failure.
conflictCheckError = error;
}
if (conflictsDetected.length > 0) {
// Conflicts were detected - show resolution buttons below
setConflicts(conflictsDetected);
setSyncError(
"Merge conflicts detected. Use the buttons below to resolve them.",
);
(err as Error & { handled?: boolean }).handled = true;
return { error: err, handled: true };
}
// Check if it's a known conflict error for user messaging
// (even if conflicts check failed or returned empty)
const errorName = err?.name || "";
const isConflict = errorName === "GitConflictError";
if (isConflict) {
// Conflict error detected but no conflicts found - this shouldn't happen
// but we'll show an error message
setSyncError(
"Merge conflict detected, but no conflicting files were returned. Please check git status and try again.",
);
return { error: err };
}
// Check for structured error codes instead of parsing error messages
......@@ -177,8 +235,15 @@ function ConnectedGitHubConnector({
inferredRebaseInProgress ||
messageIndicatesRebase;
const errorMessage = err.message || "Failed to sync to GitHub.";
setSyncError(errorMessage);
const baseErrorMessage = err.message || "Failed to sync to GitHub.";
const conflictCheckMessage =
conflictCheckError instanceof Error
? ` Conflict check failed: ${conflictCheckError.message}`
: conflictCheckError
? " Conflict check failed."
: "";
const finalErrorMessage = `${baseErrorMessage}${conflictCheckMessage}`;
setSyncError(finalErrorMessage);
setRebaseInProgress(rebaseInProgressState);
setRebaseStatusMessage(null);
return { error: err };
......@@ -460,13 +525,29 @@ function ConnectedGitHubConnector({
)}
</div>
)}
{/* Conflict Resolver */}
{/* Conflict Resolution Buttons */}
{conflicts.length > 0 && (
//show a message that there are conflicts and to resolve them in Editor
<p className="text-sm text-red-600">
There are conflicts in the repository. Please resolve them in the
editor.
</p>
<div className="mt-3 p-3 rounded-md border border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-900/20">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-3">
{conflicts.length} file{conflicts.length > 1 ? "s" : ""} with merge
conflicts: {conflicts.join(", ")}
</p>
<div className="flex gap-2">
<Button
onClick={resolveWithAI}
disabled={isCancellingSync || isResolving}
>
{isResolving ? "Resolving..." : "Resolve merge conflicts with AI"}
</Button>
<Button
variant="outline"
onClick={handleCancelSync}
disabled={isCancellingSync || isResolving}
>
{isCancellingSync ? "Cancelling..." : "Cancel sync"}
</Button>
</div>
</div>
)}
{rebaseStatusMessage && (
<p className="text-sm text-gray-700 dark:text-gray-300 mt-2">
......
......@@ -68,6 +68,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useResolveMergeConflictsWithAI } from "@/hooks/useResolveMergeConflictsWithAI";
interface BranchManagerProps {
appId: number;
......@@ -107,6 +108,40 @@ export function GithubBranchManager({
operationType: "merge" | "rebase";
hasConflicts: boolean;
} | null>(null);
const [isCancellingSync, setIsCancellingSync] = useState(false);
const { resolveWithAI, isResolving } = useResolveMergeConflictsWithAI({
appId,
conflicts,
onStartResolving: () => {
// Clear conflicts state when starting AI resolution
setConflicts([]);
},
});
const handleCancelSync = async () => {
setIsCancellingSync(true);
try {
const state = await ipc.github.getGitState({ appId });
let aborted = false;
if (state.rebaseInProgress) {
await ipc.github.rebaseAbort({ appId });
aborted = true;
} else if (state.mergeInProgress) {
await ipc.github.mergeAbort({ appId });
aborted = true;
}
setConflicts([]);
if (aborted) {
showSuccess("Sync cancelled");
await loadBranches();
}
} catch (error: any) {
showError(error?.message || "Failed to cancel sync");
} finally {
setIsCancellingSync(false);
}
};
const loadBranches = useCallback(async () => {
setIsLoading(true);
......@@ -332,34 +367,33 @@ export function GithubBranchManager({
setBranchToMerge(null);
await loadBranches(); // Refresh to see any status changes if we implement them
} catch (error: any) {
// Check if it's a merge conflict error (handler converts GitConflictError to MergeConflictError)
// Always check for conflicts when merge fails, regardless of error type
// IPC serialization may not preserve error.name, so we check conflicts directly
let conflictsDetected: string[] = [];
try {
conflictsDetected = await ipc.github.getConflicts({ appId });
} catch {
// If conflict check fails, continue with original error handling below
}
if (conflictsDetected.length > 0) {
// Conflicts were detected - show the resolver
setConflicts(conflictsDetected);
setBranchToMerge(null);
showInfo("Merge conflict detected. Please resolve them in the dialog.");
return;
}
// No conflicts found - show the original error
// Check if it's a merge conflict error for user messaging
const errorName = error?.name || "";
const isConflict =
error?.name === "MergeConflictError" ||
error?.name === "GitConflictError";
errorName === "MergeConflictError" || errorName === "GitConflictError";
if (isConflict) {
showInfo("Merge conflict detected. Please resolve them in the editor.");
// Show conflicts dialog
try {
const conflicts = await ipc.github.getConflicts({ appId });
if (conflicts.length > 0) {
setConflicts(conflicts);
// Close the merge modal since user has been notified
setBranchToMerge(null);
return;
}
setConflicts([]);
showError(
"Merge conflict detected, but no conflicting files were returned. Please check git status and try again.",
);
} catch (fetchError: any) {
setConflicts([]);
showError(
fetchError.message ||
"Merge conflict detected, but failed to fetch conflicting files. Please try again.",
);
}
showError(
"Merge conflict detected, but no conflicting files were returned. Please check git status and try again.",
);
} else {
showError(error.message || "Failed to merge branch");
}
......@@ -703,12 +737,29 @@ export function GithubBranchManager({
</AlertDialogContent>
</AlertDialog>
{/* Conflict Resolver */}
{/* Conflict Resolution Buttons */}
{conflicts.length > 0 && (
<p className="text-sm text-red-600">
There are conflicts in the repository. Please resolve them in the
editor.
</p>
<div className="mt-3 p-3 rounded-md border border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-900/20">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-3">
{conflicts.length} file{conflicts.length > 1 ? "s" : ""} with merge
conflicts: {conflicts.join(", ")}
</p>
<div className="flex gap-2">
<Button
onClick={resolveWithAI}
disabled={isCancellingSync || isResolving}
>
{isResolving ? "Resolving..." : "Resolve merge conflicts with AI"}
</Button>
<Button
variant="outline"
onClick={handleCancelSync}
disabled={isCancellingSync || isResolving}
>
{isCancellingSync ? "Cancelling..." : "Cancel sync"}
</Button>
</div>
</div>
)}
<Card className="transition-all duration-200">
......
import { useCallback, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { useNavigate } from "@tanstack/react-router";
import { ipc } from "@/ipc/types";
import {
selectedChatIdAtom,
chatMessagesByIdAtom,
isStreamingByIdAtom,
chatStreamCountByIdAtom,
} from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { showError } from "@/lib/toast";
import { useChats } from "@/hooks/useChats";
import { useLoadApp } from "@/hooks/useLoadApp";
interface UseResolveMergeConflictsWithAIProps {
appId: number;
conflicts: string[];
onStartResolving?: () => void;
}
/**
* Hook to resolve merge conflicts with AI by creating a new chat,
* navigating to it, and automatically starting the conflict resolution stream.
*/
export function useResolveMergeConflictsWithAI({
appId,
conflicts,
onStartResolving,
}: UseResolveMergeConflictsWithAIProps) {
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const setIsStreamingById = useSetAtom(isStreamingByIdAtom);
const setStreamCountById = useSetAtom(chatStreamCountByIdAtom);
const navigate = useNavigate();
const [isResolving, setIsResolving] = useState(false);
const isResolvingRef = useRef(false);
const { invalidateChats } = useChats(appId);
const { refreshApp } = useLoadApp(appId);
const resolveWithAI = useCallback(async () => {
if (!appId) {
showError("App ID is required");
return;
}
if (conflicts.length === 0) {
showError("No conflicts to resolve");
return;
}
if (isResolvingRef.current) {
return;
}
isResolvingRef.current = true;
setIsResolving(true);
let chatId: number | null = null;
try {
// Create a new chat for conflict resolution
const newChatId = await ipc.chat.createChat(appId);
chatId = newChatId;
// Clear conflicts state after successful chat creation
onStartResolving?.();
// Build the prompt for resolving all conflicts
const fileList = conflicts.map((f) => `- ${f}`).join("\n");
const prompt = `Please resolve the Git merge conflicts in the following file${conflicts.length > 1 ? "s" : ""}:
${fileList}
For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choose the best resolution that preserves the intended functionality from both sides. Remove all conflict markers and provide the complete resolved file content.`;
// Set up the chat state and navigate
setSelectedChatId(newChatId);
setSelectedAppId(appId);
// Mark as streaming
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(newChatId, true);
return next;
});
// Navigate to the chat page
navigate({
to: "/chat",
search: { id: newChatId },
});
// Start the stream
let hasIncrementedStreamCount = false;
ipc.chatStream.start(
{
chatId: newChatId,
prompt,
},
{
onChunk: ({ messages }) => {
if (!hasIncrementedStreamCount) {
setStreamCountById((prev) => {
const next = new Map(prev);
next.set(newChatId, (prev.get(newChatId) ?? 0) + 1);
return next;
});
hasIncrementedStreamCount = true;
}
setMessagesById((prev) => {
const next = new Map(prev);
next.set(newChatId, messages);
return next;
});
},
onEnd: () => {
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(newChatId, false);
return next;
});
isResolvingRef.current = false;
setIsResolving(false);
invalidateChats();
refreshApp();
},
onError: ({ error }) => {
showError(error || "Failed to resolve conflicts");
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(newChatId, false);
return next;
});
isResolvingRef.current = false;
setIsResolving(false);
invalidateChats();
refreshApp();
},
},
);
} catch (error: any) {
showError(error?.message || "Failed to start conflict resolution");
if (chatId !== null) {
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(chatId!, false);
return next;
});
}
isResolvingRef.current = false;
setIsResolving(false);
}
}, [
appId,
conflicts,
onStartResolving,
setSelectedChatId,
setSelectedAppId,
setMessagesById,
setIsStreamingById,
setStreamCountById,
navigate,
invalidateChats,
refreshApp,
]);
return { resolveWithAI, isResolving };
}
......@@ -90,6 +90,40 @@ DYAD_ATTACHMENT_0
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
// Handle merge conflict resolution prompts (both old and new formats)
if (
lastMessage &&
typeof lastMessage.content === "string" &&
(lastMessage.content.includes("Resolve the Git conflict(s) in ") ||
lastMessage.content.includes(
"Please resolve the Git merge conflicts in the following file",
))
) {
// Extract conflict file path from different prompt formats
let conflictPath = "conflict.txt";
if (lastMessage.content.includes("Resolve the Git conflict(s) in ")) {
conflictPath =
lastMessage.content
.split("Resolve the Git conflict(s) in ")[1]
?.split("\n")[0]
?.replace(/\.$/, "")
.trim() || "conflict.txt";
} else {
// New format: "Please resolve the Git merge conflicts in the following file(s):\n\n- conflict.txt"
const fileListMatch = lastMessage.content.match(/^- (.+)$/m);
if (fileListMatch) {
conflictPath = fileListMatch[1].trim();
}
}
messageContent = `Resolved conflicts in ${conflictPath}.
<dyad-write path="${conflictPath}" description="Resolve merge conflicts.">
Line 1
Line 2 Modified Feature
Line 3
</dyad-write>
`;
}
// TS auto-fix prefixes
if (
lastMessage &&
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论