Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
import { Group, GroupSeparator } from "~/components/ui/group";
import {
Menu,
MenuGroup,
MenuGroupLabel,
MenuItem,
MenuPopup,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ describe("humanizeConflictError", () => {
expect(result.summary).toBe("Not a git repository");
});

it("recognises when a conflict candidate has gone stale", () => {
const result = humanizeConflictError(
"PrReview failed in applyConflictResolution: Conflict candidate not found: src/auth.ts:ours",
);
expect(result.summary).toBe("Conflict candidate is out of date");
expect(result.detail).toContain("Refresh the conflict analysis");
});

it("falls back to stripping the prefix and using the first sentence", () => {
const result = humanizeConflictError(
"Git manager failed in preparePullRequestThread: Something unexpected happened. More details here.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export function buildConflictRecommendation(input: {
tone: "warning",
title: "Manual merge work is still required.",
detail:
"The workspace is prepared, but no candidate patch was safe to generate. Resolve the markers manually and keep the handoff note explicit.",
"The workspace is prepared, but no candidate patch was safe to generate. Stay in this conflict workspace, resolve the markers manually, and keep the handoff note explicit before you commit.",
};
}

Expand Down Expand Up @@ -195,6 +195,18 @@ const KNOWN_ERROR_PATTERNS: ReadonlyArray<{
detail:
"The selected project directory is not a valid git repository. Check the project path in the intake panel.",
},
{
pattern: "conflict candidate not found",
summary: "Conflict candidate is out of date",
detail:
"The selected candidate no longer matches the current workspace. Refresh the conflict analysis, then pick a new option or continue the merge manually.",
},
{
pattern: "failed to inspect merge conflicts",
summary: "Conflict analysis is temporarily unavailable",
detail:
"OK Code could not refresh the local conflict analysis, but your workspace is still open. Refresh the panel, reopen the prepared worktree, or continue the merge manually.",
},
];

const ERROR_PREFIX_RE = /^[A-Za-z ]+(?:failed|error) in [A-Za-z]+:\s*/i;
Expand Down
87 changes: 77 additions & 10 deletions apps/web/src/components/merge-conflicts/MergeConflictShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { parsePullRequestReference } from "~/pullRequestReference";
import { findProjectMatchingPullRequestReference } from "~/pullRequestProjectMatch";
import { useStore } from "~/store";
import type { Project } from "~/types";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { toastManager } from "~/components/ui/toast";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
Expand Down Expand Up @@ -139,6 +139,15 @@ async function openPathInEditor(targetPath: string) {
await openInPreferredEditor(ensureNativeApi(), targetPath);
}

function notifyRecoverableConflictError(rawMessage: string) {
const { summary, detail } = humanizeConflictError(rawMessage);
toastManager.add({
type: "error",
title: summary,
description: detail,
});
}

function ConflictCandidateButton({
candidate,
isRecommended,
Expand Down Expand Up @@ -700,10 +709,18 @@ export function MergeConflictShell({

const handlePrepareWorkspace = async (mode: "local" | "worktree") => {
if (!resolvedPullRequest || !parsedReference) return;
const result = await preparePullRequestThreadMutation.mutateAsync({
reference: parsedReference,
mode,
});
let result: Awaited<ReturnType<typeof preparePullRequestThreadMutation.mutateAsync>>;
try {
result = await preparePullRequestThreadMutation.mutateAsync({
reference: parsedReference,
mode,
});
} catch (error) {
notifyRecoverableConflictError(
error instanceof Error ? error.message : "Failed to prepare merge-conflict workspace.",
);
return;
}
const nextWorkspace: PreparedWorkspace = {
branch: result.branch,
cwd: result.worktreePath ?? project.cwd,
Expand All @@ -725,6 +742,22 @@ export function MergeConflictShell({
});
};

const handleApplyCandidate = async () => {
if (!selectedCandidate || !resolvedPullRequest) return;

try {
await applyConflictResolutionMutation.mutateAsync({
candidateId: selectedCandidate.id,
cwd: activeWorkspaceCwd,
prNumber: resolvedPullRequest.number,
});
} catch (error) {
notifyRecoverableConflictError(
error instanceof Error ? error.message : "Failed to apply conflict candidate.",
);
}
};

return (
<>
<div className="flex min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
Expand Down Expand Up @@ -1093,6 +1126,44 @@ export function MergeConflictShell({
))
) : preparedWorkspace && conflictedFiles.length > 0 ? (
<div className="space-y-3">
<Alert variant="warning" className="rounded-2xl">
<AlertTriangleIcon />
<AlertTitle>Manual resolution required</AlertTitle>
<AlertDescription>
<p>
OK Code prepared the workspace and found the conflicted files, but
it could not generate a patch that was safe to apply
automatically.
</p>
<p>
Keep working here: open a file, resolve the markers, and capture
the handoff note before you commit.
</p>
</AlertDescription>
<AlertAction>
<Button
onClick={() => {
if (!activeConflictFile) return;
void openPathInEditor(
joinPath(activeWorkspaceCwd, activeConflictFile),
);
}}
size="xs"
variant="outline"
>
<FileCode2Icon className="size-3.5" />
Open active file
</Button>
<Button
onClick={() => copyToClipboard(feedbackPreview, undefined)}
size="xs"
variant="outline"
>
<CopyIcon className="size-3.5" />
Copy handoff note
</Button>
</AlertAction>
</Alert>
<div className="rounded-2xl border border-border/70 bg-background/92 p-4">
<p className="font-medium text-sm text-foreground">
Conflicted files
Expand Down Expand Up @@ -1218,11 +1289,7 @@ export function MergeConflictShell({
<Button
disabled={applyConflictResolutionMutation.isPending}
onClick={() => {
void applyConflictResolutionMutation.mutateAsync({
candidateId: selectedCandidate.id,
cwd: activeWorkspaceCwd,
prNumber: resolvedPullRequest.number,
});
void handleApplyCandidate();
}}
size="xs"
>
Expand Down
Loading