Skip to content

Commit e4779a5

Browse files
authored
Add merge conflict workflow and navigation (#58)
- add a dedicated merge-conflicts subpage with guidance, workspace prep, and conflict note preview - surface merge-conflict entry points in the sidebar and home empty state - extract shared conflict recommendation logic with tests
1 parent 74d5e1d commit e4779a5

12 files changed

Lines changed: 1697 additions & 130 deletions

apps/web/src/components/ChatHomeEmptyState.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
44
import {
55
FolderOpenIcon,
66
FolderIcon,
7+
GitMergeIcon,
78
GitPullRequestIcon,
89
SettingsIcon,
910
SquarePenIcon,
@@ -185,6 +186,14 @@ export function ChatHomeEmptyState() {
185186
<GitPullRequestIcon className="size-4" />
186187
Review pull requests
187188
</Button>
189+
<Button
190+
variant="outline"
191+
className="justify-start gap-2"
192+
onClick={() => void navigate({ to: "/merge-conflicts" })}
193+
>
194+
<GitMergeIcon className="size-4" />
195+
Resolve merge conflicts
196+
</Button>
188197
<Button
189198
variant="ghost"
190199
className="justify-start gap-2"

apps/web/src/components/Sidebar.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ArrowUpDownIcon,
44
ChevronRightIcon,
55
FolderIcon,
6+
GitMergeIcon,
67
GitPullRequestIcon,
78
PlusIcon,
89
RocketIcon,
@@ -373,7 +374,8 @@ export default function Sidebar() {
373374
);
374375
const navigate = useNavigate();
375376
const pathname = useLocation({ select: (loc) => loc.pathname });
376-
const isOnSubPage = pathname === "/settings" || pathname === "/pr-review";
377+
const isOnSubPage =
378+
pathname === "/settings" || pathname === "/pr-review" || pathname === "/merge-conflicts";
377379
const { settings: appSettings, updateSettings } = useAppSettings();
378380
const { resolvedTheme } = useTheme();
379381
const { handleNewThread } = useHandleNewThread();
@@ -2050,6 +2052,16 @@ export default function Sidebar() {
20502052
<span className="text-xs">PR Review</span>
20512053
</SidebarMenuButton>
20522054
</SidebarMenuItem>
2055+
<SidebarMenuItem>
2056+
<SidebarMenuButton
2057+
size="sm"
2058+
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2059+
onClick={() => void navigate({ to: "/merge-conflicts" })}
2060+
>
2061+
<GitMergeIcon className="size-3.5" />
2062+
<span className="text-xs">Merge Conflicts</span>
2063+
</SidebarMenuButton>
2064+
</SidebarMenuItem>
20532065
<SidebarMenuItem>
20542066
<SidebarMenuButton
20552067
size="sm"
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
buildConflictFeedbackPreview,
5+
buildConflictRecommendation,
6+
groupConflictCandidatesByFile,
7+
pickRecommendedConflictCandidate,
8+
} from "./MergeConflictShell.logic";
9+
10+
describe("pickRecommendedConflictCandidate", () => {
11+
it("prefers deterministic candidates", () => {
12+
expect(
13+
pickRecommendedConflictCandidate({
14+
candidates: [
15+
{
16+
id: "src/demo.ts:review",
17+
path: "src/demo.ts",
18+
title: "Prefer current side",
19+
description: "Review candidate",
20+
confidence: "review",
21+
previewPatch: "const review = true;\n",
22+
},
23+
{
24+
id: "src/demo.ts:safe",
25+
path: "src/demo.ts",
26+
title: "Take theirs",
27+
description: "Safe candidate",
28+
confidence: "safe",
29+
previewPatch: "const safe = true;\n",
30+
},
31+
],
32+
})?.id,
33+
).toBe("src/demo.ts:safe");
34+
});
35+
});
36+
37+
describe("groupConflictCandidatesByFile", () => {
38+
it("keeps deterministic candidates first within a file group", () => {
39+
const groups = groupConflictCandidatesByFile([
40+
{
41+
id: "src/b.ts:review",
42+
path: "src/b.ts",
43+
title: "Prefer incoming side",
44+
description: "Review candidate",
45+
confidence: "review",
46+
previewPatch: "incoming\n",
47+
},
48+
{
49+
id: "src/b.ts:safe",
50+
path: "src/b.ts",
51+
title: "Take ours",
52+
description: "Safe candidate",
53+
confidence: "safe",
54+
previewPatch: "ours\n",
55+
},
56+
{
57+
id: "src/a.ts:review",
58+
path: "src/a.ts",
59+
title: "Prefer current side",
60+
description: "Review candidate",
61+
confidence: "review",
62+
previewPatch: "current\n",
63+
},
64+
]);
65+
66+
expect(groups.map((group) => group.path)).toEqual(["src/a.ts", "src/b.ts"]);
67+
expect(groups[1]?.candidates.map((candidate) => candidate.id)).toEqual([
68+
"src/b.ts:safe",
69+
"src/b.ts:review",
70+
]);
71+
});
72+
});
73+
74+
describe("buildConflictRecommendation", () => {
75+
it("guides the user to prepare a local workspace when GitHub is conflicting but no candidates exist", () => {
76+
expect(
77+
buildConflictRecommendation({
78+
analysis: {
79+
status: "conflicted",
80+
mergeableState: "CONFLICTING",
81+
summary: "GitHub reports merge conflicts.",
82+
candidates: [],
83+
},
84+
hasPreparedWorkspace: false,
85+
}),
86+
).toMatchObject({
87+
tone: "warning",
88+
title: "Prepare a local workspace to continue.",
89+
});
90+
});
91+
});
92+
93+
describe("buildConflictFeedbackPreview", () => {
94+
it("builds a human-readable brief", () => {
95+
expect(
96+
buildConflictFeedbackPreview({
97+
disposition: "review",
98+
note: "Keep the API signature from the incoming branch.",
99+
pullRequest: {
100+
number: 42,
101+
title: "Unify auth boundary",
102+
url: "https://github.com/acme/app/pull/42",
103+
baseBranch: "main",
104+
headBranch: "feature/auth",
105+
state: "open",
106+
},
107+
selectedCandidate: {
108+
id: "src/auth.ts:theirs",
109+
path: "src/auth.ts",
110+
title: "Prefer incoming side",
111+
description: "Review-required candidate using the incoming side.",
112+
confidence: "review",
113+
previewPatch: "export const auth = true;\n",
114+
},
115+
workspaceLabel: "Dedicated worktree",
116+
}),
117+
).toContain("Operator note: Keep the API signature from the incoming branch.");
118+
});
119+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type {
2+
GitResolvedPullRequest,
3+
PrConflictAnalysis,
4+
PrConflictCandidateResolution,
5+
} from "@okcode/contracts";
6+
7+
export type MergeConflictFeedbackDisposition = "accept" | "review" | "escalate" | "blocked";
8+
9+
export interface MergeConflictCandidateGroup {
10+
path: string;
11+
candidates: PrConflictCandidateResolution[];
12+
recommendedCandidate: PrConflictCandidateResolution | null;
13+
}
14+
15+
export interface MergeConflictRecommendation {
16+
candidateId: string | null;
17+
tone: "neutral" | "success" | "warning";
18+
title: string;
19+
detail: string;
20+
}
21+
22+
function candidatePriority(candidate: PrConflictCandidateResolution): number {
23+
return candidate.confidence === "safe" ? 0 : 1;
24+
}
25+
26+
export function sortConflictCandidates(
27+
candidates: readonly PrConflictCandidateResolution[],
28+
): PrConflictCandidateResolution[] {
29+
return [...candidates].toSorted((left, right) => {
30+
const priorityDiff = candidatePriority(left) - candidatePriority(right);
31+
if (priorityDiff !== 0) return priorityDiff;
32+
return left.title.localeCompare(right.title);
33+
});
34+
}
35+
36+
export function pickRecommendedConflictCandidate(
37+
analysis: Pick<PrConflictAnalysis, "candidates"> | null | undefined,
38+
): PrConflictCandidateResolution | null {
39+
const sorted = sortConflictCandidates(analysis?.candidates ?? []);
40+
return sorted[0] ?? null;
41+
}
42+
43+
export function groupConflictCandidatesByFile(
44+
candidates: readonly PrConflictCandidateResolution[],
45+
): MergeConflictCandidateGroup[] {
46+
const groups = new Map<string, PrConflictCandidateResolution[]>();
47+
for (const candidate of sortConflictCandidates(candidates)) {
48+
const nextGroup = groups.get(candidate.path) ?? [];
49+
nextGroup.push(candidate);
50+
groups.set(candidate.path, nextGroup);
51+
}
52+
53+
return [...groups.entries()]
54+
.toSorted(([leftPath], [rightPath]) => leftPath.localeCompare(rightPath))
55+
.map(([path, groupedCandidates]) => ({
56+
path,
57+
candidates: groupedCandidates,
58+
recommendedCandidate: groupedCandidates[0] ?? null,
59+
}));
60+
}
61+
62+
export function buildConflictRecommendation(input: {
63+
analysis: PrConflictAnalysis | undefined;
64+
hasPreparedWorkspace: boolean;
65+
}): MergeConflictRecommendation {
66+
if (!input.analysis || input.analysis.status === "unavailable") {
67+
return {
68+
candidateId: null,
69+
tone: "neutral",
70+
title: "Resolve a pull request link to start.",
71+
detail:
72+
"Paste a GitHub pull request URL to inspect mergeability, pull candidate resolutions, and stage a human-readable handoff note.",
73+
};
74+
}
75+
76+
if (input.analysis.status === "clean") {
77+
return {
78+
candidateId: null,
79+
tone: "success",
80+
title: "No merge conflicts are active.",
81+
detail: input.analysis.summary,
82+
};
83+
}
84+
85+
const recommendedCandidate = pickRecommendedConflictCandidate(input.analysis);
86+
if (recommendedCandidate?.confidence === "safe") {
87+
return {
88+
candidateId: recommendedCandidate.id,
89+
tone: "success",
90+
title: "Recommended resolution is ready.",
91+
detail:
92+
"OK Code found a deterministic candidate. Review the patch, capture the operator note, and then apply the recommendation.",
93+
};
94+
}
95+
96+
if (recommendedCandidate) {
97+
return {
98+
candidateId: recommendedCandidate.id,
99+
tone: "warning",
100+
title: "Review-required options are available.",
101+
detail:
102+
"OK Code found possible resolutions, but none are deterministic. Compare both sides, leave a readable decision note, and only then apply one.",
103+
};
104+
}
105+
106+
if (input.hasPreparedWorkspace) {
107+
return {
108+
candidateId: null,
109+
tone: "warning",
110+
title: "Manual merge work is still required.",
111+
detail:
112+
"The workspace is prepared, but no candidate patch was safe to generate. Resolve the markers manually and keep the handoff note explicit.",
113+
};
114+
}
115+
116+
return {
117+
candidateId: null,
118+
tone: "warning",
119+
title: "Prepare a local workspace to continue.",
120+
detail:
121+
"GitHub reports merge conflicts, but file-level candidates need a checked-out pull request branch or worktree before OK Code can inspect markers locally.",
122+
};
123+
}
124+
125+
function feedbackDispositionSentence(disposition: MergeConflictFeedbackDisposition): string {
126+
switch (disposition) {
127+
case "accept":
128+
return "Accept the proposed resolution after reviewing the resulting diff.";
129+
case "review":
130+
return "Keep this in review until a human confirms the chosen side.";
131+
case "escalate":
132+
return "Escalate this conflict to the PR author or code owner for direction.";
133+
case "blocked":
134+
return "Treat this conflict as blocked until the workspace or intent is clarified.";
135+
}
136+
}
137+
138+
export function buildConflictFeedbackPreview(input: {
139+
disposition: MergeConflictFeedbackDisposition;
140+
note: string;
141+
pullRequest: GitResolvedPullRequest | null;
142+
selectedCandidate: PrConflictCandidateResolution | null;
143+
workspaceLabel: string;
144+
}): string {
145+
const lines = [
146+
input.pullRequest
147+
? `Merge conflict brief for PR #${input.pullRequest.number}: ${input.pullRequest.title}`
148+
: "Merge conflict brief",
149+
`Workspace: ${input.workspaceLabel}`,
150+
input.selectedCandidate
151+
? `Candidate: ${input.selectedCandidate.title} on ${input.selectedCandidate.path} (${input.selectedCandidate.confidence} confidence).`
152+
: "Candidate: No deterministic candidate is selected yet.",
153+
input.selectedCandidate
154+
? `Rationale: ${input.selectedCandidate.description}`
155+
: "Rationale: Keep the branch prepared and inspect the conflict manually.",
156+
`Disposition: ${feedbackDispositionSentence(input.disposition)}`,
157+
];
158+
159+
const trimmedNote = input.note.trim();
160+
if (trimmedNote.length > 0) {
161+
lines.push(`Operator note: ${trimmedNote}`);
162+
}
163+
164+
return lines.join("\n");
165+
}

0 commit comments

Comments
 (0)