From e123b482c9c5d3f2cecdf99c071342f6a3417667 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 4 Nov 2026 12:55:14 -0800 Subject: [PATCH 1/2] fix(web): clarify auto-merge action and pending state --- .../pullRequest/PullRequestActionsMenu.tsx | 7 ++++--- .../pullRequest/PullRequestDetailActions.tsx | 19 +++++++++++++------ .../pullRequest/PullRequestDetailPanel.tsx | 6 ++---- .../useInlineActionConfirm.browser.test.tsx | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx index df402185ce350..627dda77a3b5c 100644 --- a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx @@ -17,6 +17,7 @@ import type { import { ArrowUpRightIcon, BookOpenIcon, + ClockIcon, CopyIcon, HammerIcon, LinkIcon, @@ -348,7 +349,7 @@ export function PullRequestActionsMenu({ closeOnClick={inlineConfirm.isArmed("enable-auto-merge")} aria-label={ inlineConfirm.isArmed("enable-auto-merge") - ? "Confirm auto-merge?" + ? "Confirm enable auto-merge?" : "Enable auto-merge" } disabled={actionPending} @@ -357,9 +358,9 @@ export function PullRequestActionsMenu({ () => void perform("enable-auto-merge", selectedMergeMethod), )} > - + {inlineConfirm.isArmed("enable-auto-merge") - ? "Confirm auto-merge?" + ? "Confirm enable auto-merge?" : "Enable auto-merge"} ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx index f5a5b4ee77bd9..555e135198324 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx @@ -4,7 +4,14 @@ import type { PullRequestMergeMethod, PullRequestUpdateMethod, } from "@t3tools/contracts"; -import { CheckIcon, ChevronDownIcon, FolderGit2Icon, GitBranchIcon, PlayIcon } from "lucide-react"; +import { + CheckIcon, + ChevronDownIcon, + ClockIcon, + FolderGit2Icon, + GitBranchIcon, + PlayIcon, +} from "lucide-react"; import type { ReactNode } from "react"; import { AnimatedWidth } from "~/components/AnimatedWidth"; @@ -277,14 +284,14 @@ export function PullRequestPrimaryAction({ pendingAction === "enable-auto-merge" ? "Enabling..." : inlineConfirm.isArmed("enable-auto-merge") - ? "Confirm auto-merge?" + ? "Confirm enable auto-merge?" : pendingAutoMergeLabel } > {inlineConfirm.isArmed("enable-auto-merge") ? ( ) : ( - + )} @@ -295,7 +302,7 @@ export function PullRequestPrimaryAction({ {pendingAction === "enable-auto-merge" ? "Enabling..." : inlineConfirm.isArmed("enable-auto-merge") - ? "Confirm auto-merge?" + ? "Confirm enable auto-merge?" : pendingAutoMergeLabel} @@ -309,8 +316,8 @@ export function PullRequestPrimaryAction({ (pendingAction === "enable-auto-merge" ? "Enabling..." : inlineConfirm.isArmed("enable-auto-merge") - ? "Confirm auto-merge?" - : pendingAutoMergeLabel)} + ? "Confirm enable auto-merge?" + : `${pendingAutoMergeLabel}: waits for requirements instead of merging right away`)} ) : primaryAction === "auto-merge-armed" ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4c8e1e2acbcd6..11cfea2e71e93 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1117,13 +1117,11 @@ export function PullRequestDetailPanel({ lastSelectedMergeMethod, ); const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; - const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`; + const pendingAutoMergeLabel = `Enable auto-merge (${selectedMergeMethod})`; const conflicting = isPullRequestConflicting(detail); const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true; const armedMergeMethod = detail?.autoMergeMethod; - const armedAutoMergeLabel = armedMergeMethod - ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` - : "Auto-merge"; + const armedAutoMergeLabel = armedMergeMethod ? `Auto-merge (${armedMergeMethod})` : "Auto-merge"; const workflowApprovalsRequired = detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0; const freshness = detail === null ? null : resolveBaseFreshness(detail); diff --git a/apps/web/src/components/pullRequest/useInlineActionConfirm.browser.test.tsx b/apps/web/src/components/pullRequest/useInlineActionConfirm.browser.test.tsx index c94403abde4d3..202edd6766394 100644 --- a/apps/web/src/components/pullRequest/useInlineActionConfirm.browser.test.tsx +++ b/apps/web/src/components/pullRequest/useInlineActionConfirm.browser.test.tsx @@ -89,7 +89,7 @@ it.each(["merge", "enable-auto-merge"] as const)( mergeBlockedReason={mergeBlockedReason} autoMergeArmed={false} armedAutoMergeLabel="Auto-merge enabled" - pendingAutoMergeLabel="Auto-merge (squash)" + pendingAutoMergeLabel="Enable auto-merge (squash)" selectedMergeMethod="squash" selectedMergeMethodLabel="Squash and merge" statePresentation={null} From 4ffda7b3bc9178b0475980da9e3584a32f52c92c Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 4 Nov 2026 13:17:55 -0800 Subject: [PATCH 2/2] fix(web): clarify auto-merge action and pending state --- .../chat/ThreadDetailsPrRow.browser.test.tsx | 75 ++++++++++++++++++- .../components/chat/ThreadDetailsPrRow.tsx | 51 +++++++++---- .../pullRequestDetail.logic.test.ts | 27 +++++++ .../pullRequest/pullRequestDetail.logic.ts | 26 +++++-- 4 files changed, 155 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx index 7d1c3199b8ec8..0d32b19cf1b95 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx @@ -6,6 +6,8 @@ import { expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ status: "success" as PullRequestCheck["status"], extraStatus: null as PullRequestCheck["status"] | null, + autoMergeEnabled: false as boolean | undefined, + actions: ["merge"] as Array<"merge" | "enable-auto-merge">, mergeMethods: ["merge"] as Array<"merge" | "squash" | "rebase">, perform: vi.fn(), newThread: vi.fn(), @@ -46,8 +48,9 @@ const queryData = () => ({ description: null, url: null, })), - capabilities: { actions: ["merge"], mergeMethods: state.mergeMethods }, - viewerPermissions: { actions: ["merge"] }, + autoMergeEnabled: state.autoMergeEnabled, + capabilities: { actions: state.actions, mergeMethods: state.mergeMethods }, + viewerPermissions: { actions: state.actions }, mergeCapabilities: { merge: state.mergeMethods.includes("merge"), squash: state.mergeMethods.includes("squash"), @@ -177,6 +180,72 @@ it("requires a new merge click after passing checks become pending and pass agai expect(state.perform).not.toHaveBeenCalled(); }); +it("hides Merge once auto-merge is armed", async () => { + await using screen = await setup(); + const { container } = screen; + const render = () => ( + + ); + await act(async () => await screen.rerender(render())); + expect( + [...container.querySelectorAll("button")].some( + (button) => button.textContent?.trim() === "Merge", + ), + ).toBe(true); + await act(async () => { + state.autoMergeEnabled = true; + await screen.rerender(render()); + }); + expect( + [...container.querySelectorAll("button")].some( + (button) => button.textContent?.trim() === "Merge", + ), + ).toBe(false); +}); + +it("offers enable auto-merge while checks are pending", async () => { + await using screen = await setup(); + const { container } = screen; + state.status = "pending"; + state.actions = ["merge", "enable-auto-merge"]; + useUiStateStore.getState().setPullRequestMergeMethod("squash"); + state.mergeMethods = ["merge", "squash"]; + await act(async () => { + await screen.rerender( + , + ); + }); + const clickEnable = async (scope: ParentNode) => + await act(async () => { + [...scope.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === "Enable auto-merge")! + .click(); + }); + await clickEnable(container); + const dialog = document.querySelector('[role="alertdialog"]')!; + expect(dialog.textContent).toContain("not right away"); + await clickEnable(dialog); + expect(state.perform).toHaveBeenCalledWith("enable-auto-merge", "squash"); +}); + async function setup() { state.perform.mockReset(); state.checkoutError = null; @@ -190,6 +259,8 @@ async function setup() { state.status = "success"; state.extraStatus = null; + state.autoMergeEnabled = false; + state.actions = ["merge"]; state.mergeMethods = ["merge"]; useUiStateStore.getState().setPullRequestMergeMethod("merge"); return renderUi(null); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 8c3f814427991..18418210907a3 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -174,11 +174,11 @@ export function ThreadDetailsPrRow({ environmentId, (project?.id as ProjectId | undefined) ?? null, ); - const [confirmingMerge, setConfirmingMerge] = useState(false); + const [confirming, setConfirming] = useState<"merge" | "enable-auto-merge" | null>(null); const rowAction = resolveThreadPanelPullRequestAction(detail); - if (confirmingMerge && rowAction !== "merge") { - setConfirmingMerge(false); + if (confirming !== null && rowAction !== confirming) { + setConfirming(null); } const conflicting = isPullRequestConflicting(detail); const checksState = detail === null ? "none" : classifyPullRequestChecks(detail.checks); @@ -348,17 +348,27 @@ export function ThreadDetailsPrRow({ tooltip: "Fix the failing checks in a new thread", onClick: startFixChecks, } - : rowAction === "merge" + : rowAction === "enable-auto-merge" ? { - label: "Merge", - pendingLabel: "Merging...", + label: "Enable auto-merge", + pendingLabel: "Enabling...", pending: actionPending, destructive: false, suffix: null, - tooltip: `Merge this ${terminology.singular} (${selectedMergeMethod})`, - onClick: () => setConfirmingMerge(true), + tooltip: `Enable auto-merge (${selectedMergeMethod}): waits for requirements instead of merging right away`, + onClick: () => setConfirming("enable-auto-merge"), } - : null; + : rowAction === "merge" + ? { + label: "Merge", + pendingLabel: "Merging...", + pending: actionPending, + destructive: false, + suffix: null, + tooltip: `Merge this ${terminology.singular} (${selectedMergeMethod})`, + onClick: () => setConfirming("merge"), + } + : null; const watching = onStopWatching !== undefined && (detail?.state ?? pr?.state ?? "open") === "open"; @@ -482,13 +492,22 @@ export function ThreadDetailsPrRow({ ) : ( openRow(label, "row") )} - {rowAction === "merge" ? ( - setConfirmingMerge(open)}> + {rowAction === "merge" || rowAction === "enable-auto-merge" ? ( + setConfirming(open ? rowAction : null)} + > - Merge {terminology.singular}? + + {rowAction === "enable-auto-merge" + ? "Enable auto-merge?" + : `Merge ${terminology.singular}?`} + - This merges #{number} using {selectedMergeMethod}. + {rowAction === "enable-auto-merge" + ? `The host will merge #${number} using ${selectedMergeMethod} once its requirements are met, not right away.` + : `This merges #${number} using ${selectedMergeMethod}.`} @@ -499,11 +518,11 @@ export function ThreadDetailsPrRow({ size="sm" disabled={actionPending} onClick={() => { - setConfirmingMerge(false); - void perform("merge", selectedMergeMethod); + setConfirming(null); + void perform(rowAction, selectedMergeMethod); }} > - Merge + {rowAction === "enable-auto-merge" ? "Enable auto-merge" : "Merge"} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 7c1cc47883f6f..c1aeb8bb3c38e 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -1232,6 +1232,33 @@ describe("the compact row's single action slot", () => { ).toBeNull(); }); + it("offers enable-auto-merge while checks run when that is available instead of Merge", () => { + expect( + resolveThreadPanelPullRequestAction( + openDetail({ + autoMergeEnabled: false, + checks: [check("pending")], + capabilities: { + actions: ["merge", "enable-auto-merge"], + mergeMethods: ["merge", "squash"], + } as unknown as PullRequestDetailView["capabilities"], + viewerPermissions: { + actions: ["merge", "enable-auto-merge"], + } as unknown as PullRequestDetailView["viewerPermissions"], + }), + ), + ).toBe("enable-auto-merge"); + }); + + it("hides Merge once auto-merge is armed, but still offers Fix for failing checks", () => { + expect(resolveThreadPanelPullRequestAction(openDetail({ autoMergeEnabled: true }))).toBeNull(); + expect( + resolveThreadPanelPullRequestAction( + openDetail({ autoMergeEnabled: true, checks: [check("failure")] }), + ), + ).toBe("fix"); + }); + it("ranks conflicts above everything, then draft, then failing checks", () => { expect( resolveThreadPanelPullRequestAction( diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 2a7dc7151b92d..6017591ca7458 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -276,7 +276,13 @@ export function isStackedPullRequestBase( type PullRequestActionableDetail = Pick< PullRequestDetail, - "state" | "isDraft" | "mergeability" | "capabilities" | "viewerPermissions" | "mergeCapabilities" + | "state" + | "isDraft" + | "mergeability" + | "capabilities" + | "viewerPermissions" + | "mergeCapabilities" + | "autoMergeEnabled" >; export function allowedPullRequestMergeMethods( @@ -349,7 +355,7 @@ export function groupPullRequestChecks(checks: ReadonlyArray) }; } -type ThreadPanelPullRequestAction = "resolve" | "ready" | "fix" | "merge"; +type ThreadPanelPullRequestAction = "resolve" | "ready" | "fix" | "merge" | "enable-auto-merge"; export function resolveThreadPanelPullRequestAction( detail: (PullRequestActionableDetail & Pick) | null, @@ -361,11 +367,19 @@ export function resolveThreadPanelPullRequestAction( } const checks = classifyPullRequestChecks(detail.checks); if (checks === "failing") return "fix"; + // Auto-merge already waits on the host; do not keep a Merge control beside Commit. + if (detail.autoMergeEnabled === true) return null; + const hasMergeMethod = allowedPullRequestMergeMethods(detail).length > 0; + if ( + checks === "pending" && + detail.autoMergeEnabled === false && + canPerformPullRequestAction(detail, "enable-auto-merge") && + hasMergeMethod + ) { + return "enable-auto-merge"; + } if (checks === "pending") return null; - return canPerformPullRequestAction(detail, "merge") && - allowedPullRequestMergeMethods(detail).length > 0 - ? "merge" - : null; + return canPerformPullRequestAction(detail, "merge") && hasMergeMethod ? "merge" : null; } export function orderPullRequestComments(