From fed25d5d2919f3ae320d2d9684a1fb16dcd22f8f Mon Sep 17 00:00:00 2001 From: Chirag Arora <76108151+ChiragArora31@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:35:29 +0530 Subject: [PATCH] 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 --- .changeset/web-merge-conflict-actions.md | 5 + packages/web/src/app/globals.css | 41 +++++++ packages/web/src/components/SessionDetail.tsx | 70 ++++++++++-- ...essionDetail.mergeConflictActions.test.tsx | 107 ++++++++++++++++++ .../src/lib/__tests__/github-links.test.ts | 37 ++++++ packages/web/src/lib/github-links.ts | 15 +++ 6 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 .changeset/web-merge-conflict-actions.md create mode 100644 packages/web/src/components/__tests__/SessionDetail.mergeConflictActions.test.tsx create mode 100644 packages/web/src/lib/__tests__/github-links.test.ts create mode 100644 packages/web/src/lib/github-links.ts diff --git a/.changeset/web-merge-conflict-actions.md b/.changeset/web-merge-conflict-actions.md new file mode 100644 index 000000000..364715347 --- /dev/null +++ b/.changeset/web-merge-conflict-actions.md @@ -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. diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 39bd0a0c6..2eb7b9b15 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -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; diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 5ef47213b..0e4a4fbd3 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -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>(new Set()); const [sentComments, setSentComments] = useState>(new Set()); const [errorComments, setErrorComments] = useState>(new Set()); - const timersRef = useRef>>(new Map()); + const [branchCopied, setBranchCopied] = useState(false); + const timersRef = useRef>(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 (
{/* Row 1: Title + diff stats */} @@ -823,6 +853,31 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses )}
+ {showConflictActions ? ( +
+ + Compare with base branch + + +
+ ) : null} + {/* Row 2: Blocker chips + CI chips inline */}
{allGreen ? ( @@ -995,7 +1050,8 @@ function buildBlockerChips(pr: DashboardPR, metadata: Record): 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; diff --git a/packages/web/src/components/__tests__/SessionDetail.mergeConflictActions.test.tsx b/packages/web/src/components/__tests__/SessionDetail.mergeConflictActions.test.tsx new file mode 100644 index 000000000..fdcad5d46 --- /dev/null +++ b/packages/web/src/components/__tests__/SessionDetail.mergeConflictActions.test.tsx @@ -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 }) => ( +
{sessionId}
+ ), +})); + +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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/packages/web/src/lib/__tests__/github-links.test.ts b/packages/web/src/lib/__tests__/github-links.test.ts new file mode 100644 index 000000000..b2b473a62 --- /dev/null +++ b/packages/web/src/lib/__tests__/github-links.test.ts @@ -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"); + }); +}); diff --git a/packages/web/src/lib/github-links.ts b/packages/web/src/lib/github-links.ts new file mode 100644 index 000000000..70a4915b8 --- /dev/null +++ b/packages/web/src/lib/github-links.ts @@ -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, +): 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}`; +}