authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-22 05:51:35+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 23:51:35-03:00
log5423ba0fd08447240282c08362ee8e382bf16903
treee103240fa0ffc063151e985b25e0f212acdc45cc
parent9a609a4e444ba739d6fcd607769d68b679b0bcc5
signature Signed by PGP key B5690EEEBB952194

feat(web): merge the comment and review buttons into one composer (#12945)

Co-authored-by: maria-rcks <maria@kuuro.net>

8 files changed, 534 insertions(+), 422 deletions(-)

apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+6-84
...@@ -18,13 +18,11 @@ import {...@@ -18,13 +18,11 @@ import {
18 Columns2Icon,18 Columns2Icon,
19 FolderTreeIcon,19 FolderTreeIcon,
20 InfoIcon,20 InfoIcon,
21 MessageSquareIcon,
22 MessageSquareOffIcon,21 MessageSquareOffIcon,
23 PilcrowIcon,22 PilcrowIcon,
24 Rows3Icon,23 Rows3Icon,
25 TextWrapIcon,24 TextWrapIcon,
26 TriangleAlertIcon,25 TriangleAlertIcon,
27 XIcon,
28} from "lucide-react";26} from "lucide-react";
29import { useAtomRefresh } from "@effect/atom-react";27import { useAtomRefresh } from "@effect/atom-react";
30import * as Schema from "effect/Schema";28import * as Schema from "effect/Schema";
...@@ -78,7 +76,6 @@ import { toastManager } from "../ui/toast";...@@ -78,7 +76,6 @@ import { toastManager } from "../ui/toast";
78import { Toggle, ToggleGroup } from "../ui/toggle-group";76import { Toggle, ToggleGroup } from "../ui/toggle-group";
79import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";77import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
80import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation";78import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation";
81import { PullRequestReviewBar } from "./PullRequestReviewBar";
82import {79import {
83 isFileDiffCollapsed,80 isFileDiffCollapsed,
84 isLineInFileDiff,81 isLineInFileDiff,
...@@ -247,9 +244,6 @@ function PullRequestCodeTab({...@@ -247,9 +244,6 @@ function PullRequestCodeTab({
247 const [draft, setDraft] = useState<DraftAnchor | null>(null);244 const [draft, setDraft] = useState<DraftAnchor | null>(null);
248 const [threadPending, setThreadPending] = useState(false);245 const [threadPending, setThreadPending] = useState(false);
249 const [orphansOpen, setOrphansOpen] = useState(false);246 const [orphansOpen, setOrphansOpen] = useState(false);
250 // Closed by default so the review form does not permanently eat vertical space below the
251 // diff; opened on demand as a floating overlay instead.
252 const [reviewOpen, setReviewOpen] = useState(false);
253 // Which pull request the slices belong to travels with them, so a render taken before the247 // Which pull request the slices belong to travels with them, so a render taken before the
254 // reset below cannot read the previous one's slices — or send its cursor to the host.248 // reset below cannot read the previous one's slices — or send its cursor to the host.
255 const [sliceState, setSliceState] = useState<{249 const [sliceState, setSliceState] = useState<{
...@@ -378,7 +372,6 @@ function PullRequestCodeTab({...@@ -378,7 +372,6 @@ function PullRequestCodeTab({
378 inlineComment: hostReview.inlineComment && viewer.comment,372 inlineComment: hostReview.inlineComment && viewer.comment,
379 reply: hostReview.reply && viewer.comment,373 reply: hostReview.reply && viewer.comment,
380 resolve: hostReview.resolve && viewer.resolve,374 resolve: hostReview.resolve && viewer.resolve,
381 verdicts: hostReview.verdicts.filter((verdict) => viewer.verdicts.includes(verdict)),
382 };375 };
383 }, [detail.capabilities.review, detail.viewerPermissions]);376 }, [detail.capabilities.review, detail.viewerPermissions]);
384 // A comment is posted against the pull request's head diff, so a line number taken from one377 // A comment is posted against the pull request's head diff, so a line number taken from one
...@@ -1054,70 +1047,6 @@ function PullRequestCodeTab({...@@ -1054,70 +1047,6 @@ function PullRequestCodeTab({
1054 ],1047 ],
1055 );1048 );
10561049
1057 /**
1058 * The review overlay belongs to the pull request, not to the patch: a change whose diff
1059 * cannot be structured — or read at all — is still one a reviewer can approve or reject, so
1060 * it survives every branch below. It floats over the scroll area rather than sitting in the
1061 * layout flow, so the diff keeps the full height instead of permanently losing a strip to a
1062 * footer most reviews never touch. Hidden entirely where the host offers no verdicts, same as
1063 * the bar it wraps did.
1064 */
1065 const reviewOverlay =
1066 review.verdicts.length === 0 ? null : (
1067 <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
1068 {reviewOpen ? (
1069 <div
1070 className={cn(
1071 "surface-glass pointer-events-auto absolute inset-x-3 rounded-xl border border-border/60 shadow-lg",
1072 detail.capabilities.comment && detail.viewerPermissions.comment
1073 ? "bottom-16"
1074 : "bottom-3",
1075 )}
1076 >
1077 <Button
1078 type="button"
1079 size="icon-sm"
1080 variant="ghost"
1081 aria-label="Close review"
1082 className="absolute right-2 top-2"
1083 onClick={() => setReviewOpen(false)}
1084 >
1085 <XIcon className="size-3.5" />
1086 </Button>
1087 <PullRequestReviewBar
1088 environmentId={environmentId}
1089 reference={reference}
1090 verdicts={review.verdicts}
1091 requestChangesSummaryRequired={detail.provider === "forgejo"}
1092 onSubmitted={() => {
1093 onRefresh();
1094 setReviewOpen(false);
1095 }}
1096 />
1097 </div>
1098 ) : (
1099 <Button
1100 className={cn(
1101 "pointer-events-auto absolute bottom-3 rounded-full shadow-lg",
1102 detail.capabilities.comment && detail.viewerPermissions.comment
1103 ? "right-16"
1104 : "right-4",
1105 )}
1106 onClick={() => setReviewOpen(true)}
1107 size="compact"
1108 variant="glass"
1109 >
1110 <MessageSquareIcon className="size-3.5" />
1111 Review
1112 {pendingComments.length > 0 ? (
1113 <span className="flex size-4 items-center justify-center rounded-full bg-accent text-[10px] tabular-nums text-accent-foreground">
1114 {pendingComments.length}
1115 </span>
1116 ) : null}
1117 </Button>
1118 )}
1119 </div>
1120 );
1121 // A rebase or a force-push can take the scoped commit out of the change. Its diff may still1050 // A rebase or a force-push can take the scoped commit out of the change. Its diff may still
1122 // be reachable on the host, but it is no longer part of what is being reviewed, so the scope1051 // be reachable on the host, but it is no longer part of what is being reviewed, so the scope
1123 // goes back to the whole change rather than sitting under a name nothing matches.1052 // goes back to the whole change rather than sitting under a name nothing matches.
...@@ -1392,29 +1321,23 @@ function PullRequestCodeTab({...@@ -1392,29 +1321,23 @@ function PullRequestCodeTab({
1392 );1321 );
1393 // The toolbar rides above every branch below, not just the one with a patch in it: a commit1322 // The toolbar rides above every branch below, not just the one with a patch in it: a commit
1394 // whose diff is empty or unreadable still needs the scope dropdown that got the reader there.1323 // whose diff is empty or unreadable still needs the scope dropdown that got the reader there.
1395 const withReviewBar = (body: ReactNode) => (1324 const withToolbar = (body: ReactNode) => (
1396 <div className="flex h-full min-h-0 flex-col">1325 <div className="flex h-full min-h-0 flex-col">
1397 {toolbar}1326 {toolbar}
1398 {/* The overlay is anchored to this wrapper, not the scroller: absolute positioning1327 <div className="min-h-0 flex-1 overflow-auto">{body}</div>
1399 inside an overflowing element tracks the content's bottom edge, which would carry
1400 the trigger away with the first scroll. */}
1401 <div className="relative min-h-0 flex-1">
1402 <div className="h-full overflow-auto">{body}</div>
1403 {reviewOverlay}
1404 </div>
1405 </div>1328 </div>
1406 );1329 );
14071330
1408 // Under the toolbar rather than in place of it, so choosing a commit does not take the1331 // Under the toolbar rather than in place of it, so choosing a commit does not take the
1409 // dropdown that was just used off the screen while its diff loads.1332 // dropdown that was just used off the screen while its diff loads.
1410 if (diffQuery.isPending && loadedSlices.length === 0) {1333 if (diffQuery.isPending && loadedSlices.length === 0) {
1411 return withReviewBar(<DiffPanelLoadingState label="Loading pull request diff..." />);1334 return withToolbar(<DiffPanelLoadingState label="Loading pull request diff..." />);
1412 }1335 }
14131336
1414 // A slice that fails once there are files on screen is reported at the end of them instead:1337 // A slice that fails once there are files on screen is reported at the end of them instead:
1415 // the diff already read is worth more than the error that stopped it growing.1338 // the diff already read is worth more than the error that stopped it growing.
1416 if (diffQuery.error && loadedSlices.length === 0) {1339 if (diffQuery.error && loadedSlices.length === 0) {
1417 return withReviewBar(1340 return withToolbar(
1418 <p className="px-4 py-5 text-sm text-muted-foreground">{diffQuery.error}</p>,1341 <p className="px-4 py-5 text-sm text-muted-foreground">{diffQuery.error}</p>,
1419 );1342 );
1420 }1343 }
...@@ -1428,7 +1351,7 @@ function PullRequestCodeTab({...@@ -1428,7 +1351,7 @@ function PullRequestCodeTab({
1428 ? parsedSlices.flatMap((parsed) => (parsed?.kind === "raw" ? [parsed] : []))1351 ? parsedSlices.flatMap((parsed) => (parsed?.kind === "raw" ? [parsed] : []))
1429 : [];1352 : [];
1430 if (files.length === 0 && rawSlices.length > 0) {1353 if (files.length === 0 && rawSlices.length > 0) {
1431 return withReviewBar(1354 return withToolbar(
1432 <div className="space-y-4 px-4 py-5">1355 <div className="space-y-4 px-4 py-5">
1433 {rawSlices.map((slice) => (1356 {rawSlices.map((slice) => (
1434 <div key={`${slice.reason}:${slice.text.slice(0, 64)}`} className="space-y-2">1357 <div key={`${slice.reason}:${slice.text.slice(0, 64)}`} className="space-y-2">
...@@ -1441,7 +1364,7 @@ function PullRequestCodeTab({...@@ -1441,7 +1364,7 @@ function PullRequestCodeTab({
1441 }1364 }
14421365
1443 if (items.length === 0 && nextCursor === null) {1366 if (items.length === 0 && nextCursor === null) {
1444 return withReviewBar(1367 return withToolbar(
1445 <p className="px-4 py-5 text-sm text-muted-foreground">1368 <p className="px-4 py-5 text-sm text-muted-foreground">
1446 {commit === null1369 {commit === null
1447 ? "This pull request has no file changes."1370 ? "This pull request has no file changes."
...@@ -1595,7 +1518,6 @@ function PullRequestCodeTab({...@@ -1595,7 +1518,6 @@ function PullRequestCodeTab({
1595 renderAnnotation={renderAnnotation}1518 renderAnnotation={renderAnnotation}
1596 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}1519 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
1597 />1520 />
1598 {reviewOverlay}
1599 </div>1521 </div>
1600 {fileTreeOpen ? (1522 {fileTreeOpen ? (
1601 <aside className="flex w-[min(20rem,40%)] min-w-48 shrink-0 border-l border-border/60">1523 <aside className="flex w-[min(20rem,40%)] min-w-48 shrink-0 border-l border-border/60">
apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx deleted-171
...@@ -1,171 +0,0 @@
1import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
2import { MessageSquareIcon, SendIcon, XIcon } from "lucide-react";
3import { useRef, useState } from "react";
4
5import { useAtomCommand } from "~/state/use-atom-command";
6import { pullRequestEnvironment } from "~/state/pullRequests";
7
8import { Button } from "../ui/button";
9import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
10import { Textarea } from "../ui/textarea";
11import { toastManager } from "../ui/toast";
12import { PullRequestGlyph } from "./pullRequestIcons";
13
14export function PullRequestCommentComposer({
15 environmentId,
16 reference,
17 detail,
18 actionPending,
19 onCommentAction,
20 onCommented,
21}: {
22 environmentId: EnvironmentId;
23 reference: PullRequestRef;
24 detail: PullRequestDetailView;
25 actionPending: boolean;
26 onCommentAction: (
27 body: string,
28 action: "close" | "reopen",
29 ) => Promise<{ readonly commentPosted: boolean }>;
30 onCommented: () => void;
31}) {
32 const [open, setOpen] = useState(false);
33 const [body, setBody] = useState("");
34 const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null);
35 const textareaRef = useRef<HTMLTextAreaElement>(null);
36 const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });
37 const followUpAction =
38 detail.state === "open" &&
39 detail.capabilities.actions.includes("close") &&
40 detail.viewerPermissions.actions.includes("close")
41 ? ("close" as const)
42 : detail.state === "closed" &&
43 detail.capabilities.actions.includes("reopen") &&
44 detail.viewerPermissions.actions.includes("reopen")
45 ? ("reopen" as const)
46 : null;
47
48 const submit = async (action: "comment" | "close" | "reopen") => {
49 const trimmed = body.trim();
50 if (trimmed.length === 0 || submitting !== null || actionPending) return;
51 setSubmitting(action);
52 if (action !== "comment") {
53 const result = await onCommentAction(trimmed, action);
54 if (result.commentPosted) {
55 setBody("");
56 setOpen(false);
57 }
58 setSubmitting(null);
59 return;
60 }
61 const result = await postComment({
62 environmentId,
63 input: {
64 ...reference,
65 body: trimmed,
66 },
67 });
68 if (result._tag === "Failure") {
69 setSubmitting(null);
70 toastManager.add({ type: "error", title: "Could not post the comment" });
71 return;
72 }
73 setBody("");
74 setSubmitting(null);
75 setOpen(false);
76 onCommented();
77 };
78
79 return (
80 <Popover open={open} onOpenChange={setOpen}>
81 <PopoverTrigger
82 render={
83 <Button
84 size="icon"
85 variant="glass"
86 className="rounded-full shadow-lg [--glass-opacity:50%]"
87 />
88 }
89 aria-label="Comment on pull request"
90 >
91 <MessageSquareIcon className="size-4" />
92 </PopoverTrigger>
93 <PopoverPopup
94 side="top"
95 align="end"
96 sideOffset={8}
97 className="w-96 max-w-[calc(100vw-2rem)]"
98 initialFocus={textareaRef}
99 >
100 <div className="mb-3 flex items-center justify-between gap-2">
101 <PopoverTitle className="text-sm">Comment on pull request</PopoverTitle>
102 <PopoverClose
103 render={<Button size="icon-xs" variant="ghost" />}
104 aria-label="Close comment composer"
105 >
106 <XIcon className="size-3.5" />
107 </PopoverClose>
108 </div>
109 <div className="space-y-2">
110 <Textarea
111 ref={textareaRef}
112 className="[&_textarea]:max-h-64"
113 // Locked while posting: the body is cleared on success, which would otherwise throw
114 // away a new draft typed while the request was still in flight.
115 disabled={submitting !== null || actionPending}
116 value={body}
117 rows={3}
118 placeholder="Leave a comment"
119 aria-label="Comment on this pull request"
120 onChange={(event) => setBody(event.target.value)}
121 onKeyDown={(event) => {
122 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
123 if (
124 event.key === "Enter" &&
125 (event.metaKey || event.ctrlKey) &&
126 !event.shiftKey &&
127 !event.altKey
128 ) {
129 event.preventDefault();
130 event.stopPropagation();
131 if (!event.repeat) void submit("comment");
132 }
133 }}
134 />
135 <div className="flex flex-wrap justify-end gap-2">
136 {followUpAction === null ? null : (
137 <Button
138 size="xs"
139 variant={followUpAction === "close" ? "destructive-outline" : "outline"}
140 disabled={body.trim().length === 0 || submitting !== null || actionPending}
141 onClick={() => void submit(followUpAction)}
142 >
143 {followUpAction === "close" ? (
144 <PullRequestGlyph.closed className="size-3.5" />
145 ) : (
146 <PullRequestGlyph.reopen className="size-3.5" />
147 )}
148 {submitting === followUpAction
149 ? followUpAction === "close"
150 ? "Closing..."
151 : "Reopening..."
152 : followUpAction === "close"
153 ? "Close with comment"
154 : "Reopen with comment"}
155 </Button>
156 )}
157 <Button
158 size="xs"
159 variant="outline"
160 disabled={body.trim().length === 0 || submitting !== null || actionPending}
161 onClick={() => void submit("comment")}
162 >
163 <SendIcon className="size-3.5" />
164 {submitting === "comment" ? "Posting..." : "Comment"}
165 </Button>
166 </div>
167 </div>
168 </PopoverPopup>
169 </Popover>
170 );
171}
apps/web/src/components/pullRequest/PullRequestCommentForm.tsx created+145
...@@ -0,0 +1,145 @@
1/**
2 * The comment half of the floating composer: a remark on the pull request itself, optionally
3 * the one that closes or reopens it. The popover around it belongs to PullRequestComposer.
4 */
5import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
6import { SendIcon } from "lucide-react";
7import { useState, type RefObject } from "react";
8
9import { useAtomCommand } from "~/state/use-atom-command";
10import { pullRequestEnvironment } from "~/state/pullRequests";
11
12import { Button } from "../ui/button";
13import { Textarea } from "../ui/textarea";
14import { toastManager } from "../ui/toast";
15import { PullRequestGlyph } from "./pullRequestIcons";
16
17export function PullRequestCommentForm({
18 environmentId,
19 reference,
20 detail,
21 actionPending,
22 textareaRef,
23 onCommentAction,
24 onCommented,
25 onClose,
26}: {
27 environmentId: EnvironmentId;
28 reference: PullRequestRef;
29 detail: PullRequestDetailView;
30 actionPending: boolean;
31 textareaRef: RefObject<HTMLTextAreaElement | null>;
32 onCommentAction: (
33 body: string,
34 action: "close" | "reopen",
35 ) => Promise<{ readonly commentPosted: boolean }>;
36 onCommented: () => void;
37 onClose: () => void;
38}) {
39 const [body, setBody] = useState("");
40 const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null);
41 const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });
42 const followUpAction =
43 detail.state === "open" &&
44 detail.capabilities.actions.includes("close") &&
45 detail.viewerPermissions.actions.includes("close")
46 ? ("close" as const)
47 : detail.state === "closed" &&
48 detail.capabilities.actions.includes("reopen") &&
49 detail.viewerPermissions.actions.includes("reopen")
50 ? ("reopen" as const)
51 : null;
52
53 const submit = async (action: "comment" | "close" | "reopen") => {
54 const trimmed = body.trim();
55 if (trimmed.length === 0 || submitting !== null || actionPending) return;
56 setSubmitting(action);
57 if (action !== "comment") {
58 const result = await onCommentAction(trimmed, action);
59 if (result.commentPosted) {
60 setBody("");
61 onClose();
62 }
63 setSubmitting(null);
64 return;
65 }
66 const result = await postComment({
67 environmentId,
68 input: {
69 ...reference,
70 body: trimmed,
71 },
72 });
73 if (result._tag === "Failure") {
74 setSubmitting(null);
75 toastManager.add({ type: "error", title: "Could not post the comment" });
76 return;
77 }
78 setBody("");
79 setSubmitting(null);
80 onClose();
81 onCommented();
82 };
83
84 return (
85 <div className="space-y-2">
86 <Textarea
87 ref={textareaRef}
88 className="[&_textarea]:max-h-64"
89 // Locked while posting: the body is cleared on success, which would otherwise throw
90 // away a new draft typed while the request was still in flight.
91 disabled={submitting !== null || actionPending}
92 value={body}
93 rows={3}
94 placeholder="Leave a comment"
95 aria-label="Comment on this pull request"
96 onChange={(event) => setBody(event.target.value)}
97 onKeyDown={(event) => {
98 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
99 if (
100 event.key === "Enter" &&
101 (event.metaKey || event.ctrlKey) &&
102 !event.shiftKey &&
103 !event.altKey
104 ) {
105 event.preventDefault();
106 event.stopPropagation();
107 if (!event.repeat) void submit("comment");
108 }
109 }}
110 />
111 <div className="flex flex-wrap justify-end gap-2">
112 {followUpAction === null ? null : (
113 <Button
114 size="xs"
115 variant={followUpAction === "close" ? "destructive-outline" : "outline"}
116 disabled={body.trim().length === 0 || submitting !== null || actionPending}
117 onClick={() => void submit(followUpAction)}
118 >
119 {followUpAction === "close" ? (
120 <PullRequestGlyph.closed className="size-3.5" />
121 ) : (
122 <PullRequestGlyph.reopen className="size-3.5" />
123 )}
124 {submitting === followUpAction
125 ? followUpAction === "close"
126 ? "Closing..."
127 : "Reopening..."
128 : followUpAction === "close"
129 ? "Close with comment"
130 : "Reopen with comment"}
131 </Button>
132 )}
133 <Button
134 size="xs"
135 variant="outline"
136 disabled={body.trim().length === 0 || submitting !== null || actionPending}
137 onClick={() => void submit("comment")}
138 >
139 <SendIcon className="size-3.5" />
140 {submitting === "comment" ? "Posting..." : "Comment"}
141 </Button>
142 </div>
143 </div>
144 );
145}
apps/web/src/components/pullRequest/PullRequestComposer.tsx created+195
...@@ -0,0 +1,195 @@
1/**
2 * The single floating control over a pull request. Commenting on the change and submitting the
3 * review that carries the Code tab's line comments used to float as two buttons that crowded
4 * each other and read as the same offer twice; they are two modes of one composer now.
5 *
6 * Opening picks the mode with work waiting in it, so a reader who has collected line comments
7 * lands on the review and everyone else lands on the comment box. Either mode's draft survives
8 * the toggle: they are separate texts going to separate places, and merging them would send a
9 * summary as a comment or the reverse.
10 */
11import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
12import { MessageSquareIcon, Trash2Icon, XIcon } from "lucide-react";
13import { useRef, useState } from "react";
14
15import { Button } from "../ui/button";
16import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
17import { Toggle, ToggleGroup } from "../ui/toggle-group";
18import { PullRequestCommentForm } from "./PullRequestCommentForm";
19import { PullRequestReviewForm } from "./PullRequestReviewForm";
20import {
21 pullRequestReviewKey,
22 usePendingReviewComments,
23 usePullRequestReviewStore,
24} from "./pullRequestReviewStore";
25
26export function PullRequestComposer({
27 environmentId,
28 reference,
29 detail,
30 actionPending,
31 onCommentAction,
32 onCommented,
33 onReviewSubmitted,
34}: {
35 environmentId: EnvironmentId;
36 reference: PullRequestRef;
37 detail: PullRequestDetailView;
38 actionPending: boolean;
39 onCommentAction: (
40 body: string,
41 action: "close" | "reopen",
42 ) => Promise<{ readonly commentPosted: boolean }>;
43 onCommented: () => void;
44 onReviewSubmitted: () => void;
45}) {
46 const [open, setOpen] = useState(false);
47 const [reviewPending, setReviewPending] = useState(false);
48 const [requestedMode, setRequestedMode] = useState<"comment" | "review">("comment");
49 const commentRef = useRef<HTMLTextAreaElement>(null);
50 const reviewRef = useRef<HTMLTextAreaElement>(null);
51 const pendingComments = usePendingReviewComments(reference);
52 const clearComments = usePullRequestReviewStore((store) => store.clear);
53 // A summary typed but not sent is review work too, and it outlives the popover. Selected as a
54 // boolean rather than the text, so typing one does not re-render the composer per keystroke.
55 const reviewKey = pullRequestReviewKey(reference);
56 const summaryStarted = usePullRequestReviewStore(
57 (store) => (store.summaries[reviewKey] ?? "").trim().length > 0,
58 );
59 const reviewStarted = pendingComments.length > 0 || summaryStarted;
60
61 // What is offered is the intersection of two different questions: what this host can do at
62 // all, and what this account may do on this repository. Either one saying no means a control
63 // that would only ever end in a refusal.
64 const canComment = detail.capabilities.comment && detail.viewerPermissions.comment;
65 const verdicts = detail.capabilities.review.verdicts.filter((verdict) =>
66 detail.viewerPermissions.verdicts.includes(verdict),
67 );
68 if (!canComment && verdicts.length === 0) return null;
69
70 const mode = canComment ? (verdicts.length === 0 ? "comment" : requestedMode) : "review";
71
72 return (
73 <Popover
74 open={open}
75 onOpenChange={(next) => {
76 if (next) setRequestedMode(reviewStarted ? "review" : "comment");
77 setOpen(next);
78 }}
79 >
80 <PopoverTrigger
81 render={
82 <Button
83 size="icon"
84 variant="glass"
85 className="rounded-full shadow-lg [--glass-opacity:50%]"
86 />
87 }
88 // The only label this control has, so the pending count belongs in it: the badge beside
89 // the icon is decorative and a reader who cannot see it still needs the number.
90 aria-label={
91 pendingComments.length > 0
92 ? `Review pull request, ${pendingComments.length} ${pendingComments.length === 1 ? "comment" : "comments"} pending`
93 : reviewStarted || !canComment
94 ? "Review pull request"
95 : "Comment on pull request"
96 }
97 >
98 <MessageSquareIcon className="size-4" />
99 {pendingComments.length > 0 ? (
100 <span
101 aria-hidden
102 className="absolute -top-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-[9px] font-semibold tabular-nums text-white"
103 >
104 {pendingComments.length}
105 </span>
106 ) : null}
107 </PopoverTrigger>
108 <PopoverPopup
109 keepMounted
110 side="top"
111 align="end"
112 sideOffset={8}
113 className="w-96 max-w-[calc(100vw-2rem)]"
114 initialFocus={mode === "review" ? reviewRef : commentRef}
115 aria-label="Pull request composer"
116 >
117 <div className="mb-3 flex items-center justify-between gap-2">
118 {canComment && verdicts.length > 0 ? (
119 <ToggleGroup
120 aria-label="Composer mode"
121 variant="segmented"
122 value={[mode]}
123 onValueChange={(next) => {
124 const value = next[0];
125 if (value === "comment" || value === "review") setRequestedMode(value);
126 }}
127 >
128 <Toggle value="comment">Comment</Toggle>
129 <Toggle value="review">
130 {pendingComments.length > 0 ? `Review (${pendingComments.length})` : "Review"}
131 </Toggle>
132 </ToggleGroup>
133 ) : (
134 <PopoverTitle className="text-sm">
135 {mode === "review" ? "Review pull request" : "Comment on pull request"}
136 </PopoverTitle>
137 )}
138 <div className="flex items-center gap-1">
139 {mode === "review" && pendingComments.length > 0 ? (
140 <Button
141 size="icon-xs"
142 variant="ghost"
143 aria-label="Discard pending line comments"
144 title="Discard pending line comments"
145 disabled={reviewPending}
146 onClick={() => clearComments(reviewKey)}
147 >
148 <Trash2Icon className="size-3.5" />
149 </Button>
150 ) : null}
151 <PopoverClose
152 render={<Button size="icon-xs" variant="ghost" />}
153 aria-label="Close composer"
154 >
155 <XIcon className="size-3.5" />
156 </PopoverClose>
157 </div>
158 </div>
159 {/* Keep both forms mounted across toggles and dismissal so drafts and in-flight
160 submit guards survive closing and reopening the composer. */}
161 {verdicts.length > 0 ? (
162 <div hidden={mode !== "review"}>
163 <PullRequestReviewForm
164 environmentId={environmentId}
165 reference={reference}
166 verdicts={verdicts}
167 requestChangesSummaryRequired={detail.provider === "forgejo"}
168 textareaRef={reviewRef}
169 pending={reviewPending}
170 onPendingChange={setReviewPending}
171 onSubmitted={() => {
172 setOpen(false);
173 onReviewSubmitted();
174 }}
175 />
176 </div>
177 ) : null}
178 {canComment ? (
179 <div hidden={mode !== "comment"}>
180 <PullRequestCommentForm
181 environmentId={environmentId}
182 reference={reference}
183 detail={detail}
184 actionPending={actionPending}
185 textareaRef={commentRef}
186 onCommentAction={onCommentAction}
187 onCommented={onCommented}
188 onClose={() => setOpen(false)}
189 />
190 </div>
191 ) : null}
192 </PopoverPopup>
193 </Popover>
194 );
195}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+4-3
...@@ -130,7 +130,7 @@ import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";...@@ -130,7 +130,7 @@ import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
130import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";130import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";
131import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";131import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
132import { PullRequestMarkdownContext } from "./PullRequestMarkdown";132import { PullRequestMarkdownContext } from "./PullRequestMarkdown";
133import { PullRequestCommentComposer } from "./PullRequestCommentComposer";133import { PullRequestComposer } from "./PullRequestComposer";
134import { PullRequestSummaryTab } from "./PullRequestSummaryTab";134import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
135import { PullRequestTimelineTab } from "./PullRequestTimelineTab";135import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
136import {136import {
...@@ -2793,9 +2793,9 @@ export function PullRequestDetailPanel({...@@ -2793,9 +2793,9 @@ export function PullRequestDetailPanel({
2793 </div>2793 </div>
27942794
2795 {/* Float over the content; do not reserve a footer or padding in the PR tabs. */}2795 {/* Float over the content; do not reserve a footer or padding in the PR tabs. */}
2796 {detail?.capabilities.comment && detail.viewerPermissions.comment ? (2796 {detail ? (
2797 <div className="absolute right-4 bottom-3 z-20">2797 <div className="absolute right-4 bottom-3 z-20">
2798 <PullRequestCommentComposer2798 <PullRequestComposer
2799 key={JSON.stringify([2799 key={JSON.stringify([
2800 environmentId,2800 environmentId,
2801 reference.projectId,2801 reference.projectId,
...@@ -2809,6 +2809,7 @@ export function PullRequestDetailPanel({...@@ -2809,6 +2809,7 @@ export function PullRequestDetailPanel({
2809 actionPending={actionPending}2809 actionPending={actionPending}
2810 onCommentAction={performCommentAction}2810 onCommentAction={performCommentAction}
2811 onCommented={refreshDetail}2811 onCommented={refreshDetail}
2812 onReviewSubmitted={refreshDetail}
2812 />2813 />
2813 </div>2814 </div>
2814 ) : null}2815 ) : null}
apps/web/src/components/pullRequest/PullRequestReviewBar.tsx deleted-161
...@@ -1,161 +0,0 @@
1/**
2 * The review form floated over the Code tab: how many comments the review is holding, its
3 * summary, and the verdict that sends the lot. Hidden entirely on a host that cannot take a
4 * review. The glass card frame belongs to the caller (PullRequestCodeTab), which is why this
5 * only contributes its own padding.
6 */
7import type { EnvironmentId, PullRequestRef, PullRequestReviewVerdict } from "@t3tools/contracts";
8import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react";
9import { useState, type ReactNode } from "react";
10
11import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useAtomCommand } from "~/state/use-atom-command";
13
14import { Button } from "../ui/button";
15import { Textarea } from "../ui/textarea";
16import { toastManager } from "../ui/toast";
17import {
18 pullRequestReviewKey,
19 usePendingReviewComments,
20 usePullRequestReviewStore,
21} from "./pullRequestReviewStore";
22
23const VERDICTS: ReadonlyArray<{
24 readonly value: PullRequestReviewVerdict;
25 readonly label: string;
26 readonly sent: string;
27 readonly icon: ReactNode;
28}> = [
29 {
30 value: "comment",
31 label: "Comment",
32 sent: "Review submitted",
33 icon: <MessageSquareIcon className="size-3" />,
34 },
35 {
36 value: "approve",
37 label: "Approve",
38 sent: "Pull request approved",
39 icon: <CheckIcon className="size-3" />,
40 },
41 {
42 value: "request-changes",
43 label: "Request changes",
44 sent: "Changes requested",
45 icon: <XCircleIcon className="size-3" />,
46 },
47];
48
49export function PullRequestReviewBar({
50 environmentId,
51 reference,
52 verdicts,
53 requestChangesSummaryRequired,
54 onSubmitted,
55}: {
56 environmentId: EnvironmentId;
57 reference: PullRequestRef;
58 verdicts: ReadonlyArray<PullRequestReviewVerdict>;
59 requestChangesSummaryRequired: boolean;
60 onSubmitted: () => void;
61}) {
62 const [pending, setPending] = useState(false);
63 const comments = usePendingReviewComments(reference);
64 const reviewKey = pullRequestReviewKey(reference);
65 // The panel stays mounted while the selected pull request changes. Keeping summaries beside
66 // the keyed line-comment drafts makes the selected pull request's body correct on the first
67 // render, before an effect could reset state left behind by the previous one.
68 const body = usePullRequestReviewStore((store) => store.summaries[reviewKey] ?? "");
69 const clear = usePullRequestReviewStore((store) => store.clear);
70 const removeComments = usePullRequestReviewStore((store) => store.removeComments);
71 const setSummary = usePullRequestReviewStore((store) => store.setSummary);
72 const clearSummary = usePullRequestReviewStore((store) => store.clearSummary);
73 const submitReview = useAtomCommand(pullRequestEnvironment.submitReview, {
74 reportFailure: false,
75 });
76
77 const offered = VERDICTS.filter((verdict) => verdicts.includes(verdict.value));
78 if (offered.length === 0) return null;
79
80 const submit = async (verdict: (typeof VERDICTS)[number]) => {
81 if (pending) return;
82 const submittedBody = body;
83 const submittedComments = comments;
84 setPending(true);
85 const result = await submitReview({
86 environmentId,
87 input: {
88 ...reference,
89 verdict: verdict.value,
90 body: submittedBody,
91 comments: submittedComments,
92 },
93 });
94 setPending(false);
95 if (result._tag === "Failure") {
96 // The draft is kept: whatever went wrong, retyping the review is not the answer.
97 toastManager.add({ type: "error", title: "The review could not be submitted" });
98 return;
99 }
100 // More remarks may have been added while the host was accepting this snapshot. Leave those,
101 // and any summary revised in the meantime, ready for the next review.
102 removeComments(
103 reviewKey,
104 submittedComments.map((comment) => comment.id),
105 );
106 clearSummary(reviewKey, submittedBody);
107 toastManager.add({ type: "success", title: verdict.sent });
108 onSubmitted();
109 };
110
111 // Forgejo requires a summary when requesting changes, even with inline comments.
112 const canSubmit = (verdict: PullRequestReviewVerdict) =>
113 verdict === "request-changes" && requestChangesSummaryRequired
114 ? body.trim().length > 0
115 : verdict === "approve" || body.trim().length > 0 || comments.length > 0;
116
117 return (
118 <div className="px-4 py-3">
119 <div className="flex items-center gap-2 text-xs text-muted-foreground">
120 <span>
121 {comments.length === 0
122 ? "No line comments yet"
123 : `${comments.length} ${comments.length === 1 ? "comment" : "comments"} pending`}
124 </span>
125 {comments.length > 0 ? (
126 <Button size="xs" variant="ghost" disabled={pending} onClick={() => clear(reviewKey)}>
127 Discard
128 </Button>
129 ) : null}
130 </div>
131 <Textarea
132 size="sm"
133 className="mt-2"
134 value={body}
135 placeholder={
136 requestChangesSummaryRequired && verdicts.includes("request-changes")
137 ? "Summarize your review (required to request changes)"
138 : "Summarize your review (optional)"
139 }
140 aria-label="Review summary"
141 onChange={(event) => setSummary(reviewKey, event.target.value)}
142 />
143 <div className="mt-2 flex flex-wrap justify-end gap-2">
144 {offered.map((verdict) => (
145 <Button
146 key={verdict.value}
147 size="xs"
148 variant={verdict.value === "comment" ? "outline" : "default"}
149 disabled={pending || !canSubmit(verdict.value)}
150 onClick={() => void submit(verdict)}
151 >
152 <span className="flex items-center gap-1.5">
153 {verdict.icon}
154 {verdict.label}
155 </span>
156 </Button>
157 ))}
158 </div>
159 </div>
160 );
161}
apps/web/src/components/pullRequest/PullRequestReviewForm.tsx created+176
...@@ -0,0 +1,176 @@
1/**
2 * The review half of the floating composer: the summary and the verdict that sends it, together
3 * with whatever line comments the review is holding. The count of those lives on the composer's
4 * trigger and mode toggle, and each pending card can be dropped from the diff, so neither is
5 * repeated here. The popover around it belongs to PullRequestComposer.
6 */
7import type { EnvironmentId, PullRequestRef, PullRequestReviewVerdict } from "@t3tools/contracts";
8import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react";
9import { useState, type ReactNode, type RefObject } from "react";
10
11import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useAtomCommand } from "~/state/use-atom-command";
13
14import { Button } from "../ui/button";
15import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select";
16import { Textarea } from "../ui/textarea";
17import { toastManager } from "../ui/toast";
18import {
19 pullRequestReviewKey,
20 usePendingReviewComments,
21 usePullRequestReviewStore,
22} from "./pullRequestReviewStore";
23
24const VERDICTS: ReadonlyArray<{
25 readonly value: PullRequestReviewVerdict;
26 readonly label: string;
27 readonly sent: string;
28 readonly icon: ReactNode;
29}> = [
30 {
31 value: "comment",
32 label: "Comment",
33 sent: "Review submitted",
34 icon: <MessageSquareIcon className="size-3" />,
35 },
36 {
37 value: "approve",
38 label: "Approve",
39 sent: "Pull request approved",
40 icon: <CheckIcon className="size-3" />,
41 },
42 {
43 value: "request-changes",
44 label: "Request changes",
45 sent: "Changes requested",
46 icon: <XCircleIcon className="size-3" />,
47 },
48];
49
50export function PullRequestReviewForm({
51 environmentId,
52 reference,
53 verdicts,
54 requestChangesSummaryRequired,
55 textareaRef,
56 pending,
57 onPendingChange,
58 onSubmitted,
59}: {
60 environmentId: EnvironmentId;
61 reference: PullRequestRef;
62 verdicts: ReadonlyArray<PullRequestReviewVerdict>;
63 requestChangesSummaryRequired: boolean;
64 textareaRef: RefObject<HTMLTextAreaElement | null>;
65 pending: boolean;
66 onPendingChange: (pending: boolean) => void;
67 onSubmitted: () => void;
68}) {
69 const [requestedVerdict, setRequestedVerdict] = useState<PullRequestReviewVerdict>("comment");
70 const comments = usePendingReviewComments(reference);
71 const reviewKey = pullRequestReviewKey(reference);
72 // The panel stays mounted while the selected pull request changes. Keeping summaries beside
73 // the keyed line-comment drafts makes the selected pull request's body correct on the first
74 // render, before an effect could reset state left behind by the previous one.
75 const body = usePullRequestReviewStore((store) => store.summaries[reviewKey] ?? "");
76 const removeComments = usePullRequestReviewStore((store) => store.removeComments);
77 const setSummary = usePullRequestReviewStore((store) => store.setSummary);
78 const clearSummary = usePullRequestReviewStore((store) => store.clearSummary);
79 const submitReview = useAtomCommand(pullRequestEnvironment.submitReview, {
80 reportFailure: false,
81 });
82
83 const offered = VERDICTS.filter((verdict) => verdicts.includes(verdict.value));
84 const selectedVerdict =
85 offered.find((verdict) => verdict.value === requestedVerdict) ?? offered[0];
86
87 const submit = async (verdict: (typeof VERDICTS)[number]) => {
88 if (pending) return;
89 const submittedBody = body;
90 const submittedComments = comments;
91 onPendingChange(true);
92 const result = await submitReview({
93 environmentId,
94 input: {
95 ...reference,
96 verdict: verdict.value,
97 body: submittedBody,
98 comments: submittedComments,
99 },
100 });
101 onPendingChange(false);
102 if (result._tag === "Failure") {
103 // The draft is kept: whatever went wrong, retyping the review is not the answer.
104 toastManager.add({ type: "error", title: "The review could not be submitted" });
105 return;
106 }
107 // More remarks may have been added while the host was accepting this snapshot. Leave those,
108 // and any summary revised in the meantime, ready for the next review.
109 removeComments(
110 reviewKey,
111 submittedComments.map((comment) => comment.id),
112 );
113 clearSummary(reviewKey, submittedBody);
114 toastManager.add({ type: "success", title: verdict.sent });
115 onSubmitted();
116 };
117
118 // Forgejo requires a summary when requesting changes, even with inline comments.
119 const canSubmit = (verdict: PullRequestReviewVerdict) =>
120 verdict === "request-changes" && requestChangesSummaryRequired
121 ? body.trim().length > 0
122 : verdict === "approve" || body.trim().length > 0 || comments.length > 0;
123
124 return (
125 <>
126 <Textarea
127 ref={textareaRef}
128 rows={3}
129 className="[&_textarea]:max-h-64"
130 value={body}
131 placeholder={
132 requestChangesSummaryRequired && verdicts.includes("request-changes")
133 ? "Summarize your review (required to request changes)"
134 : "Summarize your review (optional)"
135 }
136 aria-label="Review summary"
137 onChange={(event) => setSummary(reviewKey, event.target.value)}
138 />
139 <div className="mt-2 flex justify-between gap-2">
140 <Select
141 value={selectedVerdict?.value ?? null}
142 disabled={pending}
143 onValueChange={(value) => {
144 if (value !== null) setRequestedVerdict(value);
145 }}
146 >
147 <SelectTrigger size="xs" className="w-auto min-w-0" aria-label="Review verdict">
148 <span className="flex items-center gap-1.5">
149 {selectedVerdict?.icon}
150 {selectedVerdict?.label}
151 </span>
152 </SelectTrigger>
153 <SelectPopup side="top" alignItemWithTrigger={false}>
154 {offered.map((verdict) => (
155 <SelectItem key={verdict.value} value={verdict.value}>
156 <span className="flex items-center gap-1.5">
157 {verdict.icon}
158 {verdict.label}
159 </span>
160 </SelectItem>
161 ))}
162 </SelectPopup>
163 </Select>
164 <Button
165 size="xs"
166 disabled={pending || selectedVerdict === undefined || !canSubmit(selectedVerdict.value)}
167 onClick={() => {
168 if (selectedVerdict !== undefined) void submit(selectedVerdict);
169 }}
170 >
171 {pending ? "Submitting..." : "Submit review"}
172 </Button>
173 </div>
174 </>
175 );
176}
apps/web/src/components/ui/popover.tsx+8-3
...@@ -25,6 +25,7 @@ function PopoverPopup({...@@ -25,6 +25,7 @@ function PopoverPopup({
25 sideOffset = 4,25 sideOffset = 4,
26 alignOffset = 0,26 alignOffset = 0,
27 tooltipStyle = false,27 tooltipStyle = false,
28 keepMounted = false,
28 anchor,29 anchor,
29 ...props30 ...props
30}: PopoverPrimitive.Popup.Props & {31}: PopoverPrimitive.Popup.Props & {
...@@ -34,10 +35,14 @@ function PopoverPopup({...@@ -34,10 +35,14 @@ function PopoverPopup({
34 sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"];35 sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"];
35 alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"];36 alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"];
36 tooltipStyle?: boolean;37 tooltipStyle?: boolean;
38 keepMounted?: PopoverPrimitive.Portal.Props["keepMounted"];
37 anchor?: PopoverPrimitive.Positioner.Props["anchor"];39 anchor?: PopoverPrimitive.Positioner.Props["anchor"];
38}) {40}) {
41 // Viewport rekeys its children when the active trigger clears on close. Persistent
42 // single-trigger forms need a stable container to retain drafts and submit guards.
43 const Viewport = keepMounted ? "div" : PopoverPrimitive.Viewport;
39 return (44 return (
40 <PopoverPrimitive.Portal>45 <PopoverPrimitive.Portal keepMounted={keepMounted}>
41 <PopoverPrimitive.Positioner46 <PopoverPrimitive.Positioner
42 align={align}47 align={align}
43 alignOffset={alignOffset}48 alignOffset={alignOffset}
...@@ -59,7 +64,7 @@ function PopoverPopup({...@@ -59,7 +64,7 @@ function PopoverPopup({
59 data-slot="popover-popup"64 data-slot="popover-popup"
60 {...props}65 {...props}
61 >66 >
62 <PopoverPrimitive.Viewport67 <Viewport
63 className={cn(68 className={cn(
64 "relative size-full max-h-(--available-height) overflow-clip px-(--viewport-inline-padding) py-4 [--viewport-inline-padding:--spacing(4)] has-data-[slot=calendar]:p-2 data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity",69 "relative size-full max-h-(--available-height) overflow-clip px-(--viewport-inline-padding) py-4 [--viewport-inline-padding:--spacing(4)] has-data-[slot=calendar]:p-2 data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity",
65 tooltipStyle70 tooltipStyle
...@@ -70,7 +75,7 @@ function PopoverPopup({...@@ -70,7 +75,7 @@ function PopoverPopup({
70 data-slot="popover-viewport"75 data-slot="popover-viewport"
71 >76 >
72 {children}77 {children}
73 </PopoverPrimitive.Viewport>78 </Viewport>
74 </PopoverPrimitive.Popup>79 </PopoverPrimitive.Popup>
75 </PopoverPrimitive.Positioner>80 </PopoverPrimitive.Positioner>
76 </PopoverPrimitive.Portal>81 </PopoverPrimitive.Portal>