| author | |
| committer | |
| log | d7c71f91d61e68ae35fcc0032847cb42324a8b51 |
| tree | 8ff17e1d3c2a8fa312369f847923366c7709a503 |
| parent | 21d53ca2eecbf9f1e481454e8084459b422de714 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>4 files changed, 200 insertions(+), 145 deletions(-)
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+14-4| ... | ... | @@ -961,7 +961,14 @@ function PullRequestCodeTab({ |
| 961 | 961 | review.verdicts.length === 0 ? null : ( |
| 962 | 962 | <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10"> |
| 963 | 963 | {reviewOpen ? ( |
| 964 | <div className="surface-glass pointer-events-auto absolute inset-x-3 bottom-3 rounded-xl border border-border/60 shadow-lg"> | |
| 964 | <div | |
| 965 | className={cn( | |
| 966 | "surface-glass pointer-events-auto absolute inset-x-3 rounded-xl border border-border/60 shadow-lg", | |
| 967 | detail.capabilities.comment && detail.viewerPermissions.comment | |
| 968 | ? "bottom-16" | |
| 969 | : "bottom-3", | |
| 970 | )} | |
| 971 | > | |
| 965 | 972 | <Button |
| 966 | 973 | type="button" |
| 967 | 974 | size="icon-sm" |
| ... | ... | @@ -984,10 +991,13 @@ function PullRequestCodeTab({ |
| 984 | 991 | /> |
| 985 | 992 | </div> |
| 986 | 993 | ) : ( |
| 987 | // Bottom-right, clear of the vertical scrollbar the diff view keeps to its own right | |
| 988 | // edge. | |
| 989 | 994 | <Button |
| 990 | className="pointer-events-auto absolute right-4 bottom-3 rounded-full shadow-lg" | |
| 995 | className={cn( | |
| 996 | "pointer-events-auto absolute bottom-3 rounded-full shadow-lg", | |
| 997 | detail.capabilities.comment && detail.viewerPermissions.comment | |
| 998 | ? "right-16" | |
| 999 | : "right-4", | |
| 1000 | )} | |
| 991 | 1001 | onClick={() => setReviewOpen(true)} |
| 992 | 1002 | size="compact" |
| 993 | 1003 | variant="glass" |
apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx created+163| ... | ... | @@ -0,0 +1,163 @@ |
| 1 | import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | GitPullRequestClosedIcon, | |
| 4 | MessageSquareIcon, | |
| 5 | RotateCcwIcon, | |
| 6 | SendIcon, | |
| 7 | XIcon, | |
| 8 | } from "lucide-react"; | |
| 9 | import { useRef, useState } from "react"; | |
| 10 | ||
| 11 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 13 | ||
| 14 | import { Button } from "../ui/button"; | |
| 15 | import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; | |
| 16 | import { Textarea } from "../ui/textarea"; | |
| 17 | import { toastManager } from "../ui/toast"; | |
| 18 | ||
| 19 | export function PullRequestCommentComposer({ | |
| 20 | environmentId, | |
| 21 | reference, | |
| 22 | detail, | |
| 23 | actionPending, | |
| 24 | onCommentAction, | |
| 25 | onCommented, | |
| 26 | }: { | |
| 27 | environmentId: EnvironmentId; | |
| 28 | reference: PullRequestRef; | |
| 29 | detail: PullRequestDetailView; | |
| 30 | actionPending: boolean; | |
| 31 | onCommentAction: ( | |
| 32 | body: string, | |
| 33 | action: "close" | "reopen", | |
| 34 | ) => Promise<{ readonly commentPosted: boolean }>; | |
| 35 | onCommented: () => void; | |
| 36 | }) { | |
| 37 | const [open, setOpen] = useState(false); | |
| 38 | const [body, setBody] = useState(""); | |
| 39 | const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); | |
| 40 | const textareaRef = useRef<HTMLTextAreaElement>(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 | setOpen(false); | |
| 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 | setOpen(false); | |
| 81 | onCommented(); | |
| 82 | }; | |
| 83 | ||
| 84 | return ( | |
| 85 | <Popover open={open} onOpenChange={setOpen}> | |
| 86 | <PopoverTrigger | |
| 87 | render={ | |
| 88 | <Button | |
| 89 | size="icon" | |
| 90 | variant="glass" | |
| 91 | className="rounded-full shadow-lg [--glass-opacity:50%]" | |
| 92 | /> | |
| 93 | } | |
| 94 | aria-label="Comment on pull request" | |
| 95 | > | |
| 96 | <MessageSquareIcon className="size-4" /> | |
| 97 | </PopoverTrigger> | |
| 98 | <PopoverPopup | |
| 99 | side="top" | |
| 100 | align="end" | |
| 101 | sideOffset={8} | |
| 102 | className="w-96 max-w-[calc(100vw-2rem)]" | |
| 103 | initialFocus={textareaRef} | |
| 104 | > | |
| 105 | <div className="mb-3 flex items-center justify-between gap-2"> | |
| 106 | <PopoverTitle className="text-sm">Comment on pull request</PopoverTitle> | |
| 107 | <PopoverClose | |
| 108 | render={<Button size="icon-xs" variant="ghost" />} | |
| 109 | aria-label="Close comment composer" | |
| 110 | > | |
| 111 | <XIcon className="size-3.5" /> | |
| 112 | </PopoverClose> | |
| 113 | </div> | |
| 114 | <div className="space-y-2"> | |
| 115 | <Textarea | |
| 116 | ref={textareaRef} | |
| 117 | className="[&_textarea]:max-h-64" | |
| 118 | // Locked while posting: the body is cleared on success, which would otherwise throw | |
| 119 | // away a new draft typed while the request was still in flight. | |
| 120 | disabled={submitting !== null || actionPending} | |
| 121 | value={body} | |
| 122 | rows={3} | |
| 123 | placeholder="Leave a comment" | |
| 124 | aria-label="Comment on this pull request" | |
| 125 | onChange={(event) => setBody(event.target.value)} | |
| 126 | /> | |
| 127 | <div className="flex flex-wrap justify-end gap-2"> | |
| 128 | {followUpAction === null ? null : ( | |
| 129 | <Button | |
| 130 | size="xs" | |
| 131 | variant={followUpAction === "close" ? "destructive-outline" : "outline"} | |
| 132 | disabled={body.trim().length === 0 || submitting !== null || actionPending} | |
| 133 | onClick={() => void submit(followUpAction)} | |
| 134 | > | |
| 135 | {followUpAction === "close" ? ( | |
| 136 | <GitPullRequestClosedIcon className="size-3.5" /> | |
| 137 | ) : ( | |
| 138 | <RotateCcwIcon className="size-3.5" /> | |
| 139 | )} | |
| 140 | {submitting === followUpAction | |
| 141 | ? followUpAction === "close" | |
| 142 | ? "Closing..." | |
| 143 | : "Reopening..." | |
| 144 | : followUpAction === "close" | |
| 145 | ? "Close with comment" | |
| 146 | : "Reopen with comment"} | |
| 147 | </Button> | |
| 148 | )} | |
| 149 | <Button | |
| 150 | size="xs" | |
| 151 | variant="outline" | |
| 152 | disabled={body.trim().length === 0 || submitting !== null || actionPending} | |
| 153 | onClick={() => void submit("comment")} | |
| 154 | > | |
| 155 | <SendIcon className="size-3.5" /> | |
| 156 | {submitting === "comment" ? "Posting..." : "Comment"} | |
| 157 | </Button> | |
| 158 | </div> | |
| 159 | </div> | |
| 160 | </PopoverPopup> | |
| 161 | </Popover> | |
| 162 | ); | |
| 163 | } |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+23-3| ... | ... | @@ -116,6 +116,7 @@ import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; |
| 116 | 116 | import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; |
| 117 | 117 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; |
| 118 | 118 | import { PullRequestMarkdownContext } from "./PullRequestMarkdown"; |
| 119 | import { PullRequestCommentComposer } from "./PullRequestCommentComposer"; | |
| 119 | 120 | import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; |
| 120 | 121 | import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; |
| 121 | 122 | import { |
| ... | ... | @@ -1480,7 +1481,7 @@ export function PullRequestDetailPanel({ |
| 1480 | 1481 | } |
| 1481 | 1482 | |
| 1482 | 1483 | return ( |
| 1483 | <div className="flex h-full min-h-0 w-full flex-col bg-background"> | |
| 1484 | <div className="relative flex h-full min-h-0 w-full flex-col bg-background"> | |
| 1484 | 1485 | {threadPickerOpen && detail ? ( |
| 1485 | 1486 | <PullRequestThreadLinks |
| 1486 | 1487 | key={`${environmentId}:${detail.url}`} |
| ... | ... | @@ -2612,8 +2613,6 @@ export function PullRequestDetailPanel({ |
| 2612 | 2613 | fixFindingLabel={handoffLabels.fixFinding} |
| 2613 | 2614 | fixCheckLabel={handoffLabels.fixCheck} |
| 2614 | 2615 | onFixFinding={startFixFinding} |
| 2615 | actionPending={actionPending} | |
| 2616 | onCommentAction={performCommentAction} | |
| 2617 | 2616 | onRefresh={refreshDetail} |
| 2618 | 2617 | /> |
| 2619 | 2618 | </div> |
| ... | ... | @@ -2663,6 +2662,27 @@ export function PullRequestDetailPanel({ |
| 2663 | 2662 | ) : null} |
| 2664 | 2663 | </div> |
| 2665 | 2664 | |
| 2665 | {/* Float over the content; do not reserve a footer or padding in the PR tabs. */} | |
| 2666 | {detail?.capabilities.comment && detail.viewerPermissions.comment ? ( | |
| 2667 | <div className="absolute right-4 bottom-3 z-20"> | |
| 2668 | <PullRequestCommentComposer | |
| 2669 | key={JSON.stringify([ | |
| 2670 | environmentId, | |
| 2671 | reference.projectId, | |
| 2672 | reference.host, | |
| 2673 | reference.repository, | |
| 2674 | reference.number, | |
| 2675 | ])} | |
| 2676 | environmentId={environmentId} | |
| 2677 | reference={reference} | |
| 2678 | detail={detail} | |
| 2679 | actionPending={actionPending} | |
| 2680 | onCommentAction={performCommentAction} | |
| 2681 | onCommented={refreshDetail} | |
| 2682 | /> | |
| 2683 | </div> | |
| 2684 | ) : null} | |
| 2685 | ||
| 2666 | 2686 | <AlertDialog |
| 2667 | 2687 | open={confirmation.open} |
| 2668 | 2688 | onOpenChange={(open) => setConfirmation((current) => ({ ...current, open }))} |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx-138| ... | ... | @@ -10,11 +10,8 @@ import { |
| 10 | 10 | ArrowDownUpIcon, |
| 11 | 11 | ChevronDownIcon, |
| 12 | 12 | ChevronRightIcon, |
| 13 | GitPullRequestClosedIcon, | |
| 14 | 13 | HammerIcon, |
| 15 | 14 | PencilIcon, |
| 16 | RotateCcwIcon, | |
| 17 | SendIcon, | |
| 18 | 15 | TagIcon, |
| 19 | 16 | UsersIcon, |
| 20 | 17 | } from "lucide-react"; |
| ... | ... | @@ -28,7 +25,6 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 28 | 25 | |
| 29 | 26 | import { Button } from "../ui/button"; |
| 30 | 27 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 31 | import { Textarea } from "../ui/textarea"; | |
| 32 | 28 | import { toastManager } from "../ui/toast"; |
| 33 | 29 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 34 | 30 | import { |
| ... | ... | @@ -303,113 +299,6 @@ function Section({ |
| 303 | 299 | ); |
| 304 | 300 | } |
| 305 | 301 | |
| 306 | function CommentComposer({ | |
| 307 | environmentId, | |
| 308 | reference, | |
| 309 | detail, | |
| 310 | actionPending, | |
| 311 | onCommentAction, | |
| 312 | onCommented, | |
| 313 | }: { | |
| 314 | environmentId: EnvironmentId; | |
| 315 | reference: PullRequestRef; | |
| 316 | detail: PullRequestDetailView; | |
| 317 | actionPending: boolean; | |
| 318 | onCommentAction: ( | |
| 319 | body: string, | |
| 320 | action: "close" | "reopen", | |
| 321 | ) => Promise<{ readonly commentPosted: boolean }>; | |
| 322 | onCommented: () => void; | |
| 323 | }) { | |
| 324 | const [body, setBody] = useState(""); | |
| 325 | const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); | |
| 326 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); | |
| 327 | const followUpAction = | |
| 328 | detail.state === "open" && | |
| 329 | detail.capabilities.actions.includes("close") && | |
| 330 | detail.viewerPermissions.actions.includes("close") | |
| 331 | ? ("close" as const) | |
| 332 | : detail.state === "closed" && | |
| 333 | detail.capabilities.actions.includes("reopen") && | |
| 334 | detail.viewerPermissions.actions.includes("reopen") | |
| 335 | ? ("reopen" as const) | |
| 336 | : null; | |
| 337 | ||
| 338 | const submit = async (action: "comment" | "close" | "reopen") => { | |
| 339 | const trimmed = body.trim(); | |
| 340 | if (trimmed.length === 0 || submitting !== null || actionPending) return; | |
| 341 | setSubmitting(action); | |
| 342 | if (action !== "comment") { | |
| 343 | const result = await onCommentAction(trimmed, action); | |
| 344 | if (result.commentPosted) setBody(""); | |
| 345 | setSubmitting(null); | |
| 346 | return; | |
| 347 | } | |
| 348 | const result = await postComment({ | |
| 349 | environmentId, | |
| 350 | input: { | |
| 351 | ...reference, | |
| 352 | body: trimmed, | |
| 353 | }, | |
| 354 | }); | |
| 355 | if (result._tag === "Failure") { | |
| 356 | setSubmitting(null); | |
| 357 | toastManager.add({ type: "error", title: "Could not post the comment" }); | |
| 358 | return; | |
| 359 | } | |
| 360 | setBody(""); | |
| 361 | setSubmitting(null); | |
| 362 | onCommented(); | |
| 363 | }; | |
| 364 | ||
| 365 | return ( | |
| 366 | <div className="space-y-2"> | |
| 367 | <Textarea | |
| 368 | // Locked while posting: the body is cleared on success, which would otherwise throw | |
| 369 | // away a new draft typed while the request was still in flight. | |
| 370 | disabled={submitting !== null || actionPending} | |
| 371 | value={body} | |
| 372 | rows={3} | |
| 373 | placeholder="Leave a comment" | |
| 374 | aria-label="Comment on this pull request" | |
| 375 | onChange={(event) => setBody(event.target.value)} | |
| 376 | /> | |
| 377 | <div className="flex justify-end gap-2"> | |
| 378 | {followUpAction === null ? null : ( | |
| 379 | <Button | |
| 380 | size="xs" | |
| 381 | variant={followUpAction === "close" ? "destructive-outline" : "outline"} | |
| 382 | disabled={body.trim().length === 0 || submitting !== null || actionPending} | |
| 383 | onClick={() => void submit(followUpAction)} | |
| 384 | > | |
| 385 | {followUpAction === "close" ? ( | |
| 386 | <GitPullRequestClosedIcon className="size-3.5" /> | |
| 387 | ) : ( | |
| 388 | <RotateCcwIcon className="size-3.5" /> | |
| 389 | )} | |
| 390 | {submitting === followUpAction | |
| 391 | ? followUpAction === "close" | |
| 392 | ? "Closing..." | |
| 393 | : "Reopening..." | |
| 394 | : followUpAction === "close" | |
| 395 | ? "Close with comment" | |
| 396 | : "Reopen with comment"} | |
| 397 | </Button> | |
| 398 | )} | |
| 399 | <Button | |
| 400 | size="xs" | |
| 401 | variant="outline" | |
| 402 | disabled={body.trim().length === 0 || submitting !== null || actionPending} | |
| 403 | onClick={() => void submit("comment")} | |
| 404 | > | |
| 405 | <SendIcon className="size-3.5" /> | |
| 406 | {submitting === "comment" ? "Posting..." : "Comment"} | |
| 407 | </Button> | |
| 408 | </div> | |
| 409 | </div> | |
| 410 | ); | |
| 411 | } | |
| 412 | ||
| 413 | 302 | /** |
| 414 | 303 | * What a first render of the conversation carries. A pull request with two hundred comments is |
| 415 | 304 | * two hundred markdown documents, and the ones worth arriving for are the recent ones. |
| ... | ... | @@ -427,8 +316,6 @@ export function PullRequestSummaryTab({ |
| 427 | 316 | fixFindingLabel = "Fix in a thread", |
| 428 | 317 | fixCheckLabel = "Fix", |
| 429 | 318 | onFixFinding, |
| 430 | actionPending, | |
| 431 | onCommentAction, | |
| 432 | 319 | onRefresh, |
| 433 | 320 | }: { |
| 434 | 321 | environmentId: EnvironmentId; |
| ... | ... | @@ -442,11 +329,6 @@ export function PullRequestSummaryTab({ |
| 442 | 329 | fixFindingLabel?: string; |
| 443 | 330 | fixCheckLabel?: string; |
| 444 | 331 | onFixFinding?: (finding: PullRequestFinding) => void; |
| 445 | actionPending: boolean; | |
| 446 | onCommentAction: ( | |
| 447 | body: string, | |
| 448 | action: "close" | "reopen", | |
| 449 | ) => Promise<{ readonly commentPosted: boolean }>; | |
| 450 | 332 | onRefresh: () => void; |
| 451 | 333 | }) { |
| 452 | 334 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| ... | ... | @@ -984,26 +866,6 @@ export function PullRequestSummaryTab({ |
| 984 | 866 | </> |
| 985 | 867 | )} |
| 986 | 868 | </Section> |
| 987 | <div className="px-4 pb-4"> | |
| 988 | {/* Posting stays available when the conversation is folded or its activity read failed. */} | |
| 989 | {detail.capabilities.comment && detail.viewerPermissions.comment ? ( | |
| 990 | <CommentComposer | |
| 991 | reference={reference} | |
| 992 | key={JSON.stringify([ | |
| 993 | environmentId, | |
| 994 | reference.projectId, | |
| 995 | reference.host, | |
| 996 | reference.repository, | |
| 997 | reference.number, | |
| 998 | ])} | |
| 999 | environmentId={environmentId} | |
| 1000 | detail={detail} | |
| 1001 | actionPending={actionPending} | |
| 1002 | onCommentAction={onCommentAction} | |
| 1003 | onCommented={onRefresh} | |
| 1004 | /> | |
| 1005 | ) : null} | |
| 1006 | </div> | |
| 1007 | 869 | </div> |
| 1008 | 870 | ); |
| 1009 | 871 | } |