authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-10 02:22:04-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 02:22:04-03:00
log1f093d119b91263deb83f0d18e1504b791fdbbd4
treec73d06a6be3b19a5f60c48f0607697b5bf08f306
parentc2a301b12af6e18148eb1d46123c4187f4bdb78e
signature Signed by PGP key B5690EEEBB952194

feat(web): pr panel actions confirm in place (#17710)


2 files changed, 174 insertions(+), 132 deletions(-)

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+119-132
......@@ -88,15 +88,6 @@ import { vcsEnvironment } from "~/state/vcs";
8888import { formatRelativeTimeLabel } from "~/timestampFormat";
8989import { useUiStateStore } from "~/uiStateStore";
9090
91import {
92 AlertDialog,
93 AlertDialogClose,
94 AlertDialogDescription,
95 AlertDialogFooter,
96 AlertDialogHeader,
97 AlertDialogPopup,
98 AlertDialogTitle,
99} from "../ui/alert-dialog";
10091import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
10192import { Badge } from "../ui/badge";
10293import { Button } from "../ui/button";
......@@ -183,25 +174,10 @@ import {
183174 summarizePullRequestChecks,
184175} from "./pullRequestPresentation";
185176import { PullRequestGlyph } from "./pullRequestIcons";
177import { useInlineActionConfirm } from "./useInlineActionConfirm";
186178
187179type DetailTab = "summary" | "timeline" | "code";
188180
189const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {
190 merge: "Pull request merged",
191 ready: "Marked ready for review",
192 draft: "Converted to draft",
193 close: "Pull request closed",
194 reopen: "Pull request reopened",
195 "update-branch": "Branch updated with the base branch",
196 // True whichever it did: a pull request that was already mergeable merges the moment this is
197 // armed, and the client has no way to tell that apart from one still waiting on something.
198 "enable-auto-merge":
199 "Auto-merge turned on — merges as soon as this is ready, sooner if it already is",
200 "disable-auto-merge": "Auto-merge turned off",
201 revert: "Revert pull request opened",
202 "approve-workflows": "Workflows approved",
203};
204
205181/** Said as the thing that did not happen, rather than as the operation that returned an error. */
206182const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {
207183 merge: "Could not merge this pull request",
......@@ -578,11 +554,7 @@ export function PullRequestDetailPanel({
578554 const setMergeMethod = (method: PullRequestMergeMethod) => {
579555 setMergeMethodSelection({ pullRequestKey, method });
580556 };
581 const [confirmation, setConfirmation] = useState<{
582 readonly open: boolean;
583 readonly action: "merge" | "close" | "enable-auto-merge" | "revert" | "approve-workflows";
584 }>({ open: false, action: "merge" });
585 const confirmAction = confirmation.action;
557 const inlineConfirm = useInlineActionConfirm(pullRequestKey);
586558 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself
587559 // alone. One at a time whatever the key: they all check the same pull request out.
588560 const [handoff, setHandoff] = useState<string | null>(null);
......@@ -986,7 +958,10 @@ export function PullRequestDetailPanel({
986958 onActed?.(action, "failed");
987959 return false;
988960 }
989 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
961 // Reverting opens another PR; the current panel has no new state to show.
962 if (action === "revert") {
963 toastManager.add({ type: "success", title: "Revert pull request opened" });
964 }
990965 // A branch update moves the head commit, which leaves the diff atom pointed at a comparison
991966 // that no longer exists — the same staleness the manual refresh button fixes, so it goes
992967 // through that path rather than a second one. Every other action here only changes metadata;
......@@ -1918,27 +1893,44 @@ export function PullRequestDetailPanel({
19181893 size="xs"
19191894 variant="default"
19201895 disabled={actionPending}
1921 onClick={() =>
1922 setConfirmation({ open: true, action: "enable-auto-merge" })
1923 }
1896 {...inlineConfirm.props(
1897 "enable-auto-merge",
1898 () => void perform("enable-auto-merge", selectedMergeMethod),
1899 )}
19241900 aria-label={
19251901 pendingAction === "enable-auto-merge"
19261902 ? "Enabling..."
1927 : pendingAutoMergeLabel
1903 : inlineConfirm.isArmed("enable-auto-merge")
1904 ? "Confirm auto-merge?"
1905 : pendingAutoMergeLabel
19281906 }
19291907 >
19301908 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
1931 <span className="@max-[30rem]/pr-header:hidden">
1932 {pendingAction === "enable-auto-merge"
1933 ? "Enabling..."
1934 : pendingAutoMergeLabel}
1909 <span className="inline-grid @max-[30rem]/pr-header:hidden">
1910 <span aria-hidden className="invisible col-start-1 row-start-1">
1911 {pendingAutoMergeLabel}
1912 </span>
1913 <span aria-hidden className="invisible col-start-1 row-start-1">
1914 Confirm auto-merge?
1915 </span>
1916 <span className="col-start-1 row-start-1">
1917 {pendingAction === "enable-auto-merge"
1918 ? "Enabling..."
1919 : inlineConfirm.isArmed("enable-auto-merge")
1920 ? "Confirm auto-merge?"
1921 : pendingAutoMergeLabel}
1922 </span>
19351923 </span>
19361924 </Button>
19371925 </span>
19381926 }
19391927 />
19401928 <TooltipPopup side="top">
1941 {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel}
1929 {pendingAction === "enable-auto-merge"
1930 ? "Enabling..."
1931 : inlineConfirm.isArmed("enable-auto-merge")
1932 ? "Confirm auto-merge?"
1933 : pendingAutoMergeLabel}
19421934 </TooltipPopup>
19431935 </Tooltip>
19441936 ) : primaryAction === "auto-merge-armed" ? (
......@@ -1970,21 +1962,44 @@ export function PullRequestDetailPanel({
19701962 size="xs"
19711963 variant="default"
19721964 disabled={actionPending}
1973 onClick={() => setConfirmation({ open: true, action: "merge" })}
1965 {...inlineConfirm.props(
1966 "merge",
1967 () => void perform("merge", selectedMergeMethod),
1968 )}
19741969 aria-label={
1975 pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel
1970 pendingAction === "merge"
1971 ? "Merging..."
1972 : inlineConfirm.isArmed("merge")
1973 ? "Confirm merge?"
1974 : selectedMergeMethodLabel
19761975 }
19771976 >
19781977 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
1979 <span className="@max-[30rem]/pr-header:hidden">
1980 {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}
1978 <span className="inline-grid @max-[30rem]/pr-header:hidden">
1979 <span aria-hidden className="invisible col-start-1 row-start-1">
1980 {selectedMergeMethodLabel}
1981 </span>
1982 <span aria-hidden className="invisible col-start-1 row-start-1">
1983 Confirm merge?
1984 </span>
1985 <span className="col-start-1 row-start-1">
1986 {pendingAction === "merge"
1987 ? "Merging..."
1988 : inlineConfirm.isArmed("merge")
1989 ? "Confirm merge?"
1990 : selectedMergeMethodLabel}
1991 </span>
19811992 </span>
19821993 </Button>
19831994 </span>
19841995 }
19851996 />
19861997 <TooltipPopup side="top">
1987 {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}
1998 {pendingAction === "merge"
1999 ? "Merging..."
2000 : inlineConfirm.isArmed("merge")
2001 ? "Confirm merge?"
2002 : selectedMergeMethodLabel}
19882003 </TooltipPopup>
19892004 </Tooltip>
19902005 ) : (primaryAction === "merged" || primaryAction === "closed") &&
......@@ -2101,11 +2116,18 @@ export function PullRequestDetailPanel({
21012116 ) : null}
21022117 {showsMergeNow ? (
21032118 <MenuItem
2119 closeOnClick={inlineConfirm.isArmed("merge")}
2120 aria-label={
2121 inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"
2122 }
21042123 disabled={actionPending}
2105 onClick={() => setConfirmation({ open: true, action: "merge" })}
2124 {...inlineConfirm.props(
2125 "merge",
2126 () => void perform("merge", selectedMergeMethod),
2127 )}
21062128 >
21072129 <PullRequestGlyph.merged className="size-3.5" />
2108 Merge now
2130 {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"}
21092131 </MenuItem>
21102132 ) : null}
21112133 {/* The same merge, left with the host to carry out once its requirements
......@@ -2121,13 +2143,22 @@ export function PullRequestDetailPanel({
21212143 </MenuItem>
21222144 ) : showsAutoMerge ? (
21232145 <MenuItem
2124 disabled={actionPending}
2125 onClick={() =>
2126 setConfirmation({ open: true, action: "enable-auto-merge" })
2146 closeOnClick={inlineConfirm.isArmed("enable-auto-merge")}
2147 aria-label={
2148 inlineConfirm.isArmed("enable-auto-merge")
2149 ? "Confirm auto-merge?"
2150 : "Enable auto-merge"
21272151 }
2152 disabled={actionPending}
2153 {...inlineConfirm.props(
2154 "enable-auto-merge",
2155 () => void perform("enable-auto-merge", selectedMergeMethod),
2156 )}
21282157 >
21292158 <PullRequestGlyph.merged className="size-3.5" />
2130 Enable auto-merge
2159 {inlineConfirm.isArmed("enable-auto-merge")
2160 ? "Confirm auto-merge?"
2161 : "Enable auto-merge"}
21312162 </MenuItem>
21322163 ) : null}
21332164 {/* A preference for the merge action rather than a second action, so it
......@@ -2200,12 +2231,16 @@ export function PullRequestDetailPanel({
22002231 <>
22012232 <MenuSeparator />
22022233 <MenuItem
2234 closeOnClick={inlineConfirm.isArmed("close")}
2235 aria-label={
2236 inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"
2237 }
22032238 variant="destructive"
22042239 disabled={actionPending}
2205 onClick={() => setConfirmation({ open: true, action: "close" })}
2240 {...inlineConfirm.props("close", () => void perform("close"))}
22062241 >
22072242 <PullRequestGlyph.closed className="size-3.5" />
2208 Close pull request
2243 {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"}
22092244 </MenuItem>
22102245 </>
22112246 ) : detail.state === "closed" && can("reopen") ? (
......@@ -2220,11 +2255,16 @@ export function PullRequestDetailPanel({
22202255 <>
22212256 <MenuSeparator />
22222257 <MenuItem
2258 closeOnClick={inlineConfirm.isArmed("revert")}
2259 aria-label={
2260 inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"
2261 }
2262 variant={inlineConfirm.isArmed("revert") ? "destructive" : "default"}
22232263 disabled={actionPending}
2224 onClick={() => setConfirmation({ open: true, action: "revert" })}
2264 {...inlineConfirm.props("revert", () => void perform("revert"))}
22252265 >
22262266 <RotateCcwIcon className="size-3.5" />
2227 Revert changes
2267 {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"}
22282268 </MenuItem>
22292269 </>
22302270 ) : null}
......@@ -2553,20 +2593,33 @@ export function PullRequestDetailPanel({
25532593 size="xs"
25542594 variant="warning-outline"
25552595 disabled={actionPending}
2556 onClick={() =>
2557 setConfirmation({ open: true, action: "approve-workflows" })
2558 }
2596 {...inlineConfirm.props(
2597 "approve-workflows",
2598 () => void perform("approve-workflows"),
2599 )}
25592600 aria-label={
25602601 pendingAction === "approve-workflows"
25612602 ? "Approving..."
2562 : "Approve workflows to run"
2603 : inlineConfirm.isArmed("approve-workflows")
2604 ? "Confirm approve?"
2605 : "Approve workflows to run"
25632606 }
25642607 >
25652608 <PlayIcon aria-hidden className="size-3.5" />
2566 <span>
2567 {pendingAction === "approve-workflows"
2568 ? "Approving..."
2569 : "Approve workflows to run"}
2609 <span className="inline-grid">
2610 <span aria-hidden className="invisible col-start-1 row-start-1">
2611 Approve workflows to run
2612 </span>
2613 <span aria-hidden className="invisible col-start-1 row-start-1">
2614 Confirm approve?
2615 </span>
2616 <span className="col-start-1 row-start-1">
2617 {pendingAction === "approve-workflows"
2618 ? "Approving..."
2619 : inlineConfirm.isArmed("approve-workflows")
2620 ? "Confirm approve?"
2621 : "Approve workflows to run"}
2622 </span>
25702623 </span>
25712624 </Button>
25722625 </span>
......@@ -2575,7 +2628,9 @@ export function PullRequestDetailPanel({
25752628 <TooltipPopup side="top">
25762629 {pendingAction === "approve-workflows"
25772630 ? "Approving..."
2578 : "Approve workflows to run"}
2631 : inlineConfirm.isArmed("approve-workflows")
2632 ? "Confirm approve?"
2633 : "Approve workflows to run"}
25792634 </TooltipPopup>
25802635 </Tooltip>
25812636 ) : (
......@@ -2802,74 +2857,6 @@ export function PullRequestDetailPanel({
28022857 />
28032858 </div>
28042859 ) : null}
2805
2806 <AlertDialog
2807 open={confirmation.open}
2808 onOpenChange={(open) => setConfirmation((current) => ({ ...current, open }))}
2809 onOpenChangeComplete={(open) => {
2810 if (!open) setConfirmation({ open: false, action: "merge" });
2811 }}
2812 >
2813 <AlertDialogPopup>
2814 <AlertDialogHeader>
2815 <AlertDialogTitle>
2816 {confirmAction === "merge"
2817 ? "Merge pull request?"
2818 : confirmAction === "enable-auto-merge"
2819 ? "Enable auto-merge?"
2820 : confirmAction === "revert"
2821 ? "Revert these changes?"
2822 : confirmAction === "approve-workflows"
2823 ? "Approve workflows to run?"
2824 : "Close pull request?"}
2825 </AlertDialogTitle>
2826 <AlertDialogDescription>
2827 {confirmAction === "merge"
2828 ? `This merges #${reference.number} using ${selectedMergeMethod}.`
2829 : confirmAction === "enable-auto-merge"
2830 ? // The host merges this as soon as it considers the pull request ready, which
2831 // may be immediately — there is no telling from here whether anything is
2832 // still outstanding.
2833 `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.`
2834 : confirmAction === "revert"
2835 ? `This opens a new pull request that reverses the changes merged by #${reference.number}.`
2836 : confirmAction === "approve-workflows"
2837 ? `This allows ${workflowApprovalsRequired} ${workflowApprovalsRequired === 1 ? "workflow" : "workflows"} from #${reference.number} to run. Review the code and workflow changes first.`
2838 : `This closes #${reference.number} without merging it.`}
2839 </AlertDialogDescription>
2840 </AlertDialogHeader>
2841 <AlertDialogFooter>
2842 <AlertDialogClose render={<Button variant="outline" size="sm" />}>
2843 Cancel
2844 </AlertDialogClose>
2845 <Button
2846 size="sm"
2847 variant={confirmAction === "close" ? "destructive" : "default"}
2848 disabled={actionPending}
2849 onClick={() => {
2850 const action = confirmAction;
2851 setConfirmation((current) => ({ ...current, open: false }));
2852 if (action === "merge") void perform("merge", selectedMergeMethod);
2853 if (action === "enable-auto-merge")
2854 void perform("enable-auto-merge", selectedMergeMethod);
2855 if (action === "revert") void perform("revert");
2856 if (action === "approve-workflows") void perform("approve-workflows");
2857 if (action === "close") void perform("close");
2858 }}
2859 >
2860 {confirmAction === "merge"
2861 ? selectedMergeMethodLabel
2862 : confirmAction === "enable-auto-merge"
2863 ? "Enable auto-merge"
2864 : confirmAction === "revert"
2865 ? "Create revert PR"
2866 : confirmAction === "approve-workflows"
2867 ? "Approve and run"
2868 : "Close"}
2869 </Button>
2870 </AlertDialogFooter>
2871 </AlertDialogPopup>
2872 </AlertDialog>
28732860 </div>
28742861 );
28752862}
apps/web/src/components/pullRequest/useInlineActionConfirm.ts created+55
......@@ -0,0 +1,55 @@
1import { useEffect, useState, type MouseEvent } from "react";
2
3/** Arms one control at a time, including controls rendered in a menu portal. */
4export function useInlineActionConfirm(scopeKey: string) {
5 const [armed, setArmed] = useState<{
6 scopeKey: string;
7 action: string;
8 element: HTMLElement;
9 } | null>(null);
10 const active = armed?.scopeKey === scopeKey ? armed : null;
11
12 useEffect(() => {
13 if (!armed) return;
14 const disarm = () => setArmed(null);
15 if (armed.scopeKey !== scopeKey) {
16 disarm();
17 return;
18 }
19 const onPointerDown = (event: PointerEvent) => {
20 if (event.target instanceof Node && !armed.element.contains(event.target)) disarm();
21 };
22 const onKeyDown = (event: KeyboardEvent) => {
23 if (event.key === "Escape") {
24 event.preventDefault();
25 event.stopPropagation();
26 disarm();
27 }
28 };
29 const timeout = window.setTimeout(disarm, 3000);
30 document.addEventListener("pointerdown", onPointerDown, true);
31 window.addEventListener("keydown", onKeyDown, true);
32 window.addEventListener("blur", disarm);
33 return () => {
34 window.clearTimeout(timeout);
35 document.removeEventListener("pointerdown", onPointerDown, true);
36 window.removeEventListener("keydown", onKeyDown, true);
37 window.removeEventListener("blur", disarm);
38 };
39 }, [armed, scopeKey]);
40
41 return {
42 isArmed: (action: string) => active?.action === action,
43 props: (action: string, onConfirm: () => void) => ({
44 onClick: (event: MouseEvent<HTMLElement>) => {
45 if (active?.action === action && active.element === event.currentTarget) {
46 setArmed(null);
47 onConfirm();
48 } else {
49 setArmed({ scopeKey, action, element: event.currentTarget });
50 }
51 },
52 onBlur: () => setArmed(null),
53 }),
54 };
55}