feat(FN-4966): complete Step 5 — add PR conflict diagnostics panel UI

Fusion-Task-Id: FN-4966
Fusion-Task-Lineage: 8f5ea300-feaa-469c-aeed-f7304a5a1b6d
This commit is contained in:
gsxdsm
2026-05-18 08:26:39 -07:00
parent 40cdf23cfd
commit 35ba6cb7b2
3 changed files with 175 additions and 2 deletions

View File

@@ -186,6 +186,36 @@
gap: var(--space-sm);
}
.pr-conflict-section {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 35%, transparent);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-sm);
padding: var(--space-sm);
}
.pr-conflict-section__header {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
justify-content: space-between;
}
.pr-conflict-files {
margin: 0;
padding-inline-start: var(--space-lg);
}
.pr-conflict-commands {
background: var(--surface);
border-radius: var(--radius-sm);
margin: 0;
overflow-x: auto;
padding: var(--space-sm);
}
.pr-merge-error {
align-items: center;
background: color-mix(in srgb, var(--color-error) 10%, transparent);
@@ -240,4 +270,9 @@
.pr-panel-check-chip {
justify-self: start;
}
.pr-conflict-section__header {
align-items: flex-start;
flex-direction: column;
}
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge, ChevronDown, ChevronUp } from "lucide-react";
import { getErrorMessage, type DirectMergeCommitStrategy, type StructuredGhError } from "@fusion/core";
import { fetchPrReviews, mergePr, reclaimPrConflict, refreshPrStatus, setAutoMergeOnGreen, type PrCheckStatus, type PrInfo, type PrRefreshResponse, type PrReviewsResponse } from "../api";
import { usePrChecksStream } from "../hooks/usePrChecksStream";
@@ -62,6 +62,8 @@ export function PrPanel({
const [isMerging, setIsMerging] = useState(false);
const [lastGhError, setLastGhError] = useState<(StructuredGhError & { operation: "refresh" }) | null>(null);
const [isReclaimingConflict, setIsReclaimingConflict] = useState(false);
const [conflictsExpanded, setConflictsExpanded] = useState(false);
const [copiedConflicts, setCopiedConflicts] = useState(false);
const [mergeStrategy, setMergeStrategy] = useState<"merge" | "squash" | "rebase">(
directMergeCommitStrategy === "always-rebase"
? "rebase"
@@ -217,6 +219,11 @@ export function PrPanel({
const showMergeControls = prInfo.status === "open" && (prInfo.draft ?? prInfo.isDraft) !== true;
const hasConflictBlockingReason = blockingReasons.some((reason) => reason.toLowerCase().includes("conflict"));
const showConflictHint = prInfo.mergeable === "conflicting" || hasConflictBlockingReason;
const conflictDiagnostics = refreshState?.conflictDiagnostics ?? prInfo.conflictDiagnostics;
useEffect(() => {
setConflictsExpanded((conflictDiagnostics?.conflictingFiles.length ?? 0) > 0);
}, [conflictDiagnostics?.capturedAt, conflictDiagnostics?.conflictingFiles.length]);
return (
<div className="pr-section">
@@ -361,6 +368,43 @@ export function PrPanel({
</div>
) : null}
{conflictDiagnostics && (prInfo.mergeable === "conflicting" || hasConflictBlockingReason) ? (
<div className="pr-conflict-section">
<div className="pr-conflict-section__header">
<button type="button" className="btn btn-sm" onClick={() => setConflictsExpanded((value) => !value)}>
{conflictsExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />} Conflicts
</button>
<button type="button" className="btn btn-sm" onClick={() => void handleRefresh()} disabled={isRefreshing}>Re-check conflicts</button>
</div>
{conflictsExpanded ? (
<>
{conflictDiagnostics.conflictingFiles.length > 0 ? (
<ul className="pr-conflict-files">
{conflictDiagnostics.conflictingFiles.map((file) => <li key={file}>{linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}</li>)}
</ul>
) : (
<div className="pr-panel-tone-muted">File list unavailable run the suggested commands locally.</div>
)}
<pre className="pr-conflict-commands"><code>{conflictDiagnostics.suggestedCommands.join("\n")}</code></pre>
<div className="pr-conflict-section__header">
<button
type="button"
className="btn btn-sm"
onClick={async () => {
await navigator.clipboard.writeText(conflictDiagnostics.suggestedCommands.join("\n"));
setCopiedConflicts(true);
setTimeout(() => setCopiedConflicts(false), 1200);
}}
>
{copiedConflicts ? "Copied" : "Copy"}
</button>
<span className="pr-panel-tone-muted">Captured: {new Date(conflictDiagnostics.capturedAt).toLocaleString()}</span>
</div>
</>
) : null}
</div>
) : null}
{(prInfo.draft ?? prInfo.isDraft) === true && prInfo.status === "open" ? (
<div className="pr-hint pr-hint--warning">Ready for review required before merging.</div>
) : null}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { PrPanel } from "../PrPanel";
@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
import { refreshPrStatus, fetchPrChecks, fetchPrReviews, mergePr, reclaimPrConflict, setAutoMergeOnGreen } from "../../api";
const originalClipboard = navigator.clipboard;
const mockAddToast = vi.fn();
const mockOnPrUpdated = vi.fn();
const mockOnRequestCreatePr = vi.fn();
@@ -46,6 +47,17 @@ describe("PrPanel", () => {
(fetchPrReviews as ReturnType<typeof vi.fn>).mockResolvedValue({ snapshot: { decision: null, items: [] }, comments: [] });
(mergePr as ReturnType<typeof vi.fn>).mockResolvedValue({ prInfo: { ...mockPrInfo, status: "merged" } });
(setAutoMergeOnGreen as ReturnType<typeof vi.fn>).mockResolvedValue({ prInfo: { ...mockPrInfo, autoMergeOnGreen: true } });
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: originalClipboard,
});
});
it("renders create button and calls onRequestCreatePr", () => {
@@ -258,6 +270,88 @@ describe("PrPanel", () => {
expect(screen.getByText(/Please split this function/i)).toBeInTheDocument();
});
it("renders conflict diagnostics subsection with files and commands", () => {
render(
<PrPanel
taskId="FN-001"
prInfo={{
...mockPrInfo,
mergeable: "conflicting",
conflictDiagnostics: {
conflictingFiles: ["packages/dashboard/src/github.ts", "packages/core/src/types.ts"],
suggestedCommands: ["git fetch origin", "git checkout fusion/fn-001", "git rebase origin/main", "# Resolve conflicts then: git add <files> && git rebase --continue"],
capturedAt: "2026-05-18T00:00:00.000Z",
},
}}
prAuthAvailable={true}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>,
);
expect(screen.getByText("packages/dashboard/src/github.ts")).toBeInTheDocument();
expect(screen.getByText("packages/core/src/types.ts")).toBeInTheDocument();
expect(screen.getByText(/git checkout fusion\/fn-001/)).toBeInTheDocument();
});
it("hides conflict diagnostics subsection when not conflicting and no diagnostics", () => {
render(<PrPanel taskId="FN-001" prInfo={{ ...mockPrInfo, mergeable: "clean" }} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
expect(screen.queryByText("Conflicts")).toBeNull();
expect(screen.queryByRole("button", { name: "Re-check conflicts" })).toBeNull();
});
it("copies suggested commands from diagnostics", async () => {
render(
<PrPanel
taskId="FN-001"
prInfo={{
...mockPrInfo,
mergeable: "conflicting",
conflictDiagnostics: {
conflictingFiles: ["packages/dashboard/src/github.ts"],
suggestedCommands: ["git fetch origin", "git checkout fusion/fn-001"],
capturedAt: "2026-05-18T00:00:00.000Z",
},
}}
prAuthAvailable={true}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("git fetch origin\ngit checkout fusion/fn-001");
});
});
it("re-check conflicts triggers refreshPrStatus", async () => {
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ prInfo: mockPrInfo, checks: [], reviewDecision: null, blockingReasons: [] });
render(
<PrPanel
taskId="FN-001"
projectId="project-1"
prInfo={{
...mockPrInfo,
mergeable: "conflicting",
conflictDiagnostics: {
conflictingFiles: ["packages/dashboard/src/github.ts"],
suggestedCommands: ["git fetch origin"],
capturedAt: "2026-05-18T00:00:00.000Z",
},
}}
prAuthAvailable={true}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Re-check conflicts" }));
await waitFor(() => {
expect(refreshPrStatus).toHaveBeenCalledWith("FN-001", "project-1");
});
});
it("shows conflict hint from blocking reasons after refresh", async () => {
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
prInfo: mockPrInfo,