authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-15 05:48:54+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 22:48:54-04:00
log8c628f14993cb159d467e7a0f8c52578dde77005
tree06b6d2cf8135718539bc99ff4054fe810b3e0baa
parent48ddb3d469d245363dba723f774ba449e4d950c5
signature Signed by PGP key B5690EEEBB952194

fix(web): align pull request action menu rows (#6534)

Co-authored-by: Nickolas Kyryliuk <nickolaskyryliuk@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

5 files changed, 74 insertions(+), 9 deletions(-)

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+34-8
......@@ -103,6 +103,7 @@ import {
103103 handoffPrompt,
104104 handoffReviewComments,
105105 pullRequestActionNeedsHostRefresh,
106 pullRequestActionMenuHasGroup,
106107 pullRequestFindingKey,
107108 pullRequestHandoffLabels,
108109 readableFailure,
......@@ -1033,6 +1034,26 @@ export function PullRequestDetailPanel({
10331034 : allowedMergeMethods.length > 0
10341035 ? "merge"
10351036 : null;
1037 // What the menu's action group holds. Named once so the separators around it are drawn from
1038 // the same answer as its contents, rather than on the assumption that it has any.
1039 const showsDraftToggle =
1040 detail?.state === "open" &&
1041 can(detail.isDraft ? "ready" : "draft") &&
1042 !(detail.isDraft && primaryAction === "ready");
1043 const showsAutoMerge =
1044 detail?.state === "open" &&
1045 ((autoMergeArmed && can("disable-auto-merge")) ||
1046 (!autoMergeArmed &&
1047 !detail.isDraft &&
1048 !conflicting &&
1049 can("enable-auto-merge") &&
1050 allowedMergeMethods.length > 0));
1051 const showsMergeMethods =
1052 detail?.state === "open" &&
1053 can("merge") &&
1054 !detail.isDraft &&
1055 !conflicting &&
1056 allowedMergeMethods.length > 1;
10361057 // The pull request number carries this state in the overview and the right-panel tab mirrors
10371058 // it. Conflicts keep their own row below: an open pull request remains green there.
10381059 const statePresentation = detail
......@@ -1191,8 +1212,7 @@ export function PullRequestDetailPanel({
11911212 {/* Only where the button row could not take it: "Ready for review" on a
11921213 draft is the primary header button, so offering it here as well would
11931214 show the same action twice. */}
1194 {can(detail.isDraft ? "ready" : "draft") &&
1195 !(detail.isDraft && primaryAction === "ready") ? (
1215 {showsDraftToggle ? (
11961216 <MenuItem
11971217 disabled={actionPending}
11981218 onClick={() => void perform(detail.isDraft ? "ready" : "draft")}
......@@ -1236,12 +1256,12 @@ export function PullRequestDetailPanel({
12361256 Hidden while conflicting: every method would fail. */}
12371257 {/* Only where merging is on offer at all: a strategy to merge with is not
12381258 a choice for someone who may not merge. */}
1239 {can("merge") &&
1240 !detail.isDraft &&
1241 !conflicting &&
1242 allowedMergeMethods.length > 1 ? (
1259 {showsMergeMethods ? (
12431260 <>
1244 <MenuSeparator />
1261 {/* Only below the draft control. A host with no draft of its own, or
1262 a draft whose control is already the header button, would leave
1263 this against the separator that opened the group. */}
1264 {showsDraftToggle ? <MenuSeparator /> : null}
12451265 <MenuRadioGroup
12461266 value={selectedMergeMethod}
12471267 onValueChange={(method) =>
......@@ -1261,7 +1281,13 @@ export function PullRequestDetailPanel({
12611281 </MenuRadioGroup>
12621282 </>
12631283 ) : null}
1264 <MenuSeparator />
1284 {pullRequestActionMenuHasGroup(
1285 showsDraftToggle,
1286 showsAutoMerge,
1287 showsMergeMethods,
1288 ) ? (
1289 <MenuSeparator />
1290 ) : null}
12651291 </>
12661292 ) : null}
12671293 <MenuItem onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}>
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+7
......@@ -19,6 +19,7 @@ import {
1919 isThreadOwnPullRequest,
2020 orderPullRequestComments,
2121 pullRequestActionNeedsHostRefresh,
22 pullRequestActionMenuHasGroup,
2223 pullRequestFindingKey,
2324 pullRequestHandoffLabels,
2425 readableFailure,
......@@ -53,6 +54,12 @@ const TIMELINE_SOURCE: Pick<
5354 closedAt: null,
5455};
5556
57describe("pull request action menu", () => {
58 it("keeps the group divider when auto-merge is the only action", () => {
59 expect(pullRequestActionMenuHasGroup(false, true, false)).toBe(true);
60 });
61});
62
5663describe("pull request state description", () => {
5764 it("keeps draft and conflicts orthogonal to the terminal states", () => {
5865 expect(describePullRequestState("open", true)).toBe("Draft");
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+9
......@@ -57,6 +57,15 @@ export function pullRequestHandoffLabels(inThisThread: boolean) {
5757 };
5858}
5959
60/** Whether the open pull-request action group contains at least one action. */
61export function pullRequestActionMenuHasGroup(
62 showsDraftToggle: boolean,
63 showsAutoMerge: boolean,
64 showsMergeMethods: boolean,
65): boolean {
66 return showsDraftToggle || showsAutoMerge || showsMergeMethods;
67}
68
6069/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */
6170export function describePullRequestState(state: PullRequestState, isDraft: boolean): string {
6271 if (state === "merged") return "Merged";
apps/web/src/components/ui/menu.test.tsx created+23
......@@ -0,0 +1,23 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { Menu, MenuRadioGroup, MenuRadioItem } from "./menu";
5
6describe("menu radio item geometry", () => {
7 it("keeps radio-item icons on the same text grid as menu items", () => {
8 const html = renderToStaticMarkup(
9 <Menu>
10 <MenuRadioGroup value="merge">
11 <MenuRadioItem value="merge">
12 <span className="flex items-center gap-2">
13 <svg aria-hidden className="size-3.5" />
14 <span>Merge</span>
15 </span>
16 </MenuRadioItem>
17 </MenuRadioGroup>
18 </Menu>,
19 );
20
21 expect(html).toContain("-mx-0.5");
22 });
23});
apps/web/src/components/ui/menu.tsx+1-1
......@@ -166,7 +166,7 @@ function MenuRadioItem({
166166 return (
167167 <MenuPrimitive.RadioItem
168168 className={cn(
169 "flex min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center rounded-sm px-2 py-1 text-base text-foreground outline-none data-checked:bg-foreground/[0.08] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
169 "[&_svg]:-mx-0.5 flex min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center rounded-sm px-2 py-1 text-base text-foreground outline-none data-checked:bg-foreground/[0.08] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
170170 className,
171171 )}
172172 data-slot="menu-radio-item"