feat(web): merge conflict compare link and copy branch on session PR card (#1318)

* feat(web): merge conflict compare link and branch copy on PR card

- Add buildGitHubCompareUrl helper with unit tests
- Show compare + copy head branch when open PR has merge conflicts
- Add SessionDetail regression test for conflict affordances

Made-with: Cursor

* fix(web): harden merge-conflict PR actions

Address review feedback by correcting the changeset package name, encoding all compare URL path segments, and making conflict actions resilient to unenriched/rate-limited PR data and clipboard/timer edge cases. Add regression tests for URL encoding and conflict-action gating.

Made-with: Cursor

* fix(web): normalize browser timer handles in session detail

Use browser timer handle types consistently in the session detail PR card copy-feedback flow so Next.js typechecking does not mix Node Timeout and DOM number timer signatures.

Made-with: Cursor
This commit is contained in:
Chirag Arora 2026-04-21 11:35:29 +05:30 committed by GitHub
parent 64badbd388
commit fed25d5d29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 268 additions and 7 deletions

View File

@ -0,0 +1,5 @@
---
"@aoagents/ao-web": patch
---
Show GitHub compare and copy-branch actions on session PR detail when the PR has merge conflicts.

View File

@ -3004,6 +3004,47 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
min-height: 0;
}
.session-detail-pr-card__merge-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 14px 10px;
border-bottom: 1px solid var(--color-border-subtle);
}
.session-detail-pr-merge-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 6px 12px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
text-decoration: none;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--color-status-attention) 35%, transparent);
background: color-mix(in srgb, var(--color-status-attention) 8%, transparent);
color: var(--color-status-attention);
transition: background-color 120ms;
}
.session-detail-pr-merge-action:hover {
background: color-mix(in srgb, var(--color-status-attention) 14%, transparent);
}
.session-detail-pr-merge-action--btn {
border-color: var(--color-border-default);
background: var(--color-bg-subtle);
color: var(--color-text-secondary);
}
.session-detail-pr-merge-action--btn:hover {
background: var(--color-bg-elevated);
}
.session-detail-pr-card__title-link {
font-size: 12.5px;
font-weight: 600;

View File

@ -9,11 +9,14 @@ import {
TERMINAL_STATUSES,
NON_RESTORABLE_STATUSES,
isPRMergeReady,
isPRRateLimited,
isPRUnenriched,
} from "@/lib/types";
import { CI_STATUS } from "@aoagents/ao-core/types";
import { cn } from "@/lib/cn";
import dynamic from "next/dynamic";
import { getSessionTitle } from "@/lib/format";
import { buildGitHubCompareUrl } from "@/lib/github-links";
import type { ProjectInfo } from "@/lib/project-name";
import { SidebarContext } from "./workspace/SidebarContext";
@ -724,11 +727,12 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
const [sendingComments, setSendingComments] = useState<Set<string>>(new Set());
const [sentComments, setSentComments] = useState<Set<string>>(new Set());
const [errorComments, setErrorComments] = useState<Set<string>>(new Set());
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const [branchCopied, setBranchCopied] = useState(false);
const timersRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
return () => {
timersRef.current.forEach((timer) => clearTimeout(timer));
timersRef.current.forEach((timer) => window.clearTimeout(timer));
timersRef.current.clear();
};
}, []);
@ -757,8 +761,8 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
});
setSentComments((prev) => new Set(prev).add(comment.url));
const existing = timersRef.current.get(comment.url);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
if (existing !== undefined) window.clearTimeout(existing);
const timer = window.setTimeout(() => {
setSentComments((prev) => {
const next = new Set(prev);
next.delete(comment.url);
@ -776,8 +780,8 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
});
setErrorComments((prev) => new Set(prev).add(comment.url));
const existing = timersRef.current.get(comment.url);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
if (existing !== undefined) window.clearTimeout(existing);
const timer = window.setTimeout(() => {
setErrorComments((prev) => {
const next = new Set(prev);
next.delete(comment.url);
@ -794,6 +798,32 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
const blockerIssues = buildBlockerChips(pr, metadata);
const fileCount = pr.changedFiles ?? 0;
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
const hasConflicts = mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
const showConflictActions = hasConflicts && pr.state === "open";
const compareUrl = showConflictActions ? buildGitHubCompareUrl(pr) : "";
const handleCopyBranch = () => {
const clipboardWrite = navigator.clipboard?.writeText(pr.branch);
if (!clipboardWrite) return;
void clipboardWrite
.then(() => {
setBranchCopied(true);
const timerKey = "__copy-branch";
const existing = timersRef.current.get(timerKey);
if (existing !== undefined) window.clearTimeout(existing);
const timer = window.setTimeout(() => {
setBranchCopied(false);
timersRef.current.delete(timerKey);
}, 2000);
timersRef.current.set(timerKey, timer);
})
.catch(() => {
/* clipboard unavailable */
});
};
return (
<div className={cn("session-detail-pr-card", allGreen && "session-detail-pr-card--green")}>
{/* Row 1: Title + diff stats */}
@ -823,6 +853,31 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
)}
</div>
{showConflictActions ? (
<div
className="session-detail-pr-card__merge-actions"
role="group"
aria-label="Resolve merge conflicts"
>
<a
href={compareUrl}
target="_blank"
rel="noopener noreferrer"
className="session-detail-pr-merge-action"
>
Compare with base branch
</a>
<button
type="button"
onClick={handleCopyBranch}
aria-label={branchCopied ? "Head branch name copied" : "Copy head branch name"}
className="session-detail-pr-merge-action session-detail-pr-merge-action--btn"
>
{branchCopied ? "Copied branch name" : "Copy head branch name"}
</button>
</div>
) : null}
{/* Row 2: Blocker chips + CI chips inline */}
<div className="session-detail-pr-card__details">
{allGreen ? (
@ -995,7 +1050,8 @@ function buildBlockerChips(pr: DashboardPR, metadata: Record<string, string>): B
const ciIsFailing = pr.ciStatus === CI_STATUS.FAILING || lifecycleStatus === "ci_failed";
const hasChangesRequested =
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
const hasConflicts = pr.state !== "merged" && !pr.mergeability.noConflicts;
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
const hasConflicts = mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
if (ciIsFailing) {
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;

View File

@ -0,0 +1,107 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { SessionDetail } from "../SessionDetail";
import { makePR, makeSession } from "../../__tests__/helpers";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("../DirectTerminal", () => ({
DirectTerminal: ({ sessionId }: { sessionId: string }) => (
<div data-testid="direct-terminal">{sessionId}</div>
),
}));
function mockDesktopViewport() {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: !query.includes("max-width: 767px"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
describe("SessionDetail merge conflict actions", () => {
beforeEach(() => {
mockDesktopViewport();
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(""),
} as Response),
);
Object.defineProperty(globalThis.navigator, "clipboard", {
value: { writeText: vi.fn(() => Promise.resolve()) },
configurable: true,
});
});
it("renders compare and copy actions when the PR has merge conflicts", () => {
render(
<SessionDetail
session={makeSession({
id: "worker-conflict",
projectId: "my-app",
pr: makePR({
number: 99,
title: "Conflict PR",
branch: "feat/has-conflict",
mergeability: {
mergeable: false,
ciPassing: true,
approved: true,
noConflicts: false,
blockers: ["Merge conflicts"],
},
}),
})}
projectOrchestratorId="orch-1"
/>,
);
fireEvent.click(screen.getByRole("link", { name: "PR #99" }));
const compare = screen.getByRole("link", { name: /Compare with base branch/i });
expect(compare).toHaveAttribute(
"href",
"https://github.com/acme/app/compare/main...feat%2Fhas-conflict",
);
expect(screen.getByRole("button", { name: /Copy head branch name/i })).toBeInTheDocument();
});
it("hides conflict actions when mergeability data is not reliable", () => {
render(
<SessionDetail
session={makeSession({
id: "worker-unenriched",
projectId: "my-app",
pr: makePR({
number: 100,
enriched: false,
mergeability: {
mergeable: false,
ciPassing: true,
approved: true,
noConflicts: false,
blockers: ["API rate limited or unavailable"],
},
}),
})}
projectOrchestratorId="orch-1"
/>,
);
fireEvent.click(screen.getByRole("link", { name: "PR #100" }));
expect(screen.queryByRole("link", { name: /Compare with base branch/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Copy head branch name/i })).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildGitHubCompareUrl } from "../github-links";
describe("buildGitHubCompareUrl", () => {
it("builds a GitHub compare URL for base and head branches", () => {
expect(
buildGitHubCompareUrl({
owner: "acme",
repo: "app",
baseBranch: "main",
branch: "feat/foo-bar",
}),
).toBe("https://github.com/acme/app/compare/main...feat%2Ffoo-bar");
});
it("encodes special characters in branch names", () => {
expect(
buildGitHubCompareUrl({
owner: "o",
repo: "r",
baseBranch: "release/1.0",
branch: "fix#123",
}),
).toBe("https://github.com/o/r/compare/release%2F1.0...fix%23123");
});
it("encodes owner and repo segments", () => {
expect(
buildGitHubCompareUrl({
owner: "../../evil",
repo: "app?tab=code",
baseBranch: "main",
branch: "feat/x",
}),
).toBe("https://github.com/..%2F..%2Fevil/app%3Ftab%3Dcode/compare/main...feat%2Fx");
});
});

View File

@ -0,0 +1,15 @@
import type { DashboardPR } from "@/lib/types";
/**
* GitHub compare URL for the PR head branch against its base branch.
* Used when resolving merge conflicts (GitHub compare view).
*/
export function buildGitHubCompareUrl(
pr: Pick<DashboardPR, "owner" | "repo" | "baseBranch" | "branch">,
): string {
const owner = encodeURIComponent(pr.owner);
const repo = encodeURIComponent(pr.repo);
const base = encodeURIComponent(pr.baseBranch);
const head = encodeURIComponent(pr.branch);
return `https://github.com/${owner}/${repo}/compare/${base}...${head}`;
}