authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 03:20:17-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 03:20:17-03:00
logd7c71f91d61e68ae35fcc0032847cb42324a8b51
tree8ff17e1d3c2a8fa312369f847923366c7709a503
parent21d53ca2eecbf9f1e481454e8084459b422de714
signature Signed by PGP key B5690EEEBB952194

feat(web): float the pull request comment composer (#11531)

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,7 +961,14 @@ function PullRequestCodeTab({
961 review.verdicts.length === 0 ? null : (961 review.verdicts.length === 0 ? null : (
962 <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">962 <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
963 {reviewOpen ? (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 <Button972 <Button
966 type="button"973 type="button"
967 size="icon-sm"974 size="icon-sm"
...@@ -984,10 +991,13 @@ function PullRequestCodeTab({...@@ -984,10 +991,13 @@ function PullRequestCodeTab({
984 />991 />
985 </div>992 </div>
986 ) : (993 ) : (
987 // Bottom-right, clear of the vertical scrollbar the diff view keeps to its own right
988 // edge.
989 <Button994 <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 onClick={() => setReviewOpen(true)}1001 onClick={() => setReviewOpen(true)}
992 size="compact"1002 size="compact"
993 variant="glass"1003 variant="glass"
apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx created+163
...@@ -0,0 +1,163 @@
1import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
2import {
3 GitPullRequestClosedIcon,
4 MessageSquareIcon,
5 RotateCcwIcon,
6 SendIcon,
7 XIcon,
8} from "lucide-react";
9import { useRef, useState } from "react";
10
11import { useAtomCommand } from "~/state/use-atom-command";
12import { pullRequestEnvironment } from "~/state/pullRequests";
13
14import { Button } from "../ui/button";
15import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
16import { Textarea } from "../ui/textarea";
17import { toastManager } from "../ui/toast";
18
19export 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,6 +116,7 @@ import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
116import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";116import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";
117import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";117import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
118import { PullRequestMarkdownContext } from "./PullRequestMarkdown";118import { PullRequestMarkdownContext } from "./PullRequestMarkdown";
119import { PullRequestCommentComposer } from "./PullRequestCommentComposer";
119import { PullRequestSummaryTab } from "./PullRequestSummaryTab";120import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
120import { PullRequestTimelineTab } from "./PullRequestTimelineTab";121import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
121import {122import {
...@@ -1480,7 +1481,7 @@ export function PullRequestDetailPanel({...@@ -1480,7 +1481,7 @@ export function PullRequestDetailPanel({
1480 }1481 }
14811482
1482 return (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 {threadPickerOpen && detail ? (1485 {threadPickerOpen && detail ? (
1485 <PullRequestThreadLinks1486 <PullRequestThreadLinks
1486 key={`${environmentId}:${detail.url}`}1487 key={`${environmentId}:${detail.url}`}
...@@ -2612,8 +2613,6 @@ export function PullRequestDetailPanel({...@@ -2612,8 +2613,6 @@ export function PullRequestDetailPanel({
2612 fixFindingLabel={handoffLabels.fixFinding}2613 fixFindingLabel={handoffLabels.fixFinding}
2613 fixCheckLabel={handoffLabels.fixCheck}2614 fixCheckLabel={handoffLabels.fixCheck}
2614 onFixFinding={startFixFinding}2615 onFixFinding={startFixFinding}
2615 actionPending={actionPending}
2616 onCommentAction={performCommentAction}
2617 onRefresh={refreshDetail}2616 onRefresh={refreshDetail}
2618 />2617 />
2619 </div>2618 </div>
...@@ -2663,6 +2662,27 @@ export function PullRequestDetailPanel({...@@ -2663,6 +2662,27 @@ export function PullRequestDetailPanel({
2663 ) : null}2662 ) : null}
2664 </div>2663 </div>
26652664
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 <AlertDialog2686 <AlertDialog
2667 open={confirmation.open}2687 open={confirmation.open}
2668 onOpenChange={(open) => setConfirmation((current) => ({ ...current, open }))}2688 onOpenChange={(open) => setConfirmation((current) => ({ ...current, open }))}
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx-138
...@@ -10,11 +10,8 @@ import {...@@ -10,11 +10,8 @@ import {
10 ArrowDownUpIcon,10 ArrowDownUpIcon,
11 ChevronDownIcon,11 ChevronDownIcon,
12 ChevronRightIcon,12 ChevronRightIcon,
13 GitPullRequestClosedIcon,
14 HammerIcon,13 HammerIcon,
15 PencilIcon,14 PencilIcon,
16 RotateCcwIcon,
17 SendIcon,
18 TagIcon,15 TagIcon,
19 UsersIcon,16 UsersIcon,
20} from "lucide-react";17} from "lucide-react";
...@@ -28,7 +25,6 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";...@@ -28,7 +25,6 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";
2825
29import { Button } from "../ui/button";26import { Button } from "../ui/button";
30import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";27import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
31import { Textarea } from "../ui/textarea";
32import { toastManager } from "../ui/toast";28import { toastManager } from "../ui/toast";
33import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";29import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
34import {30import {
...@@ -303,113 +299,6 @@ function Section({...@@ -303,113 +299,6 @@ function Section({
303 );299 );
304}300}
305301
306function 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 * What a first render of the conversation carries. A pull request with two hundred comments is303 * What a first render of the conversation carries. A pull request with two hundred comments is
415 * two hundred markdown documents, and the ones worth arriving for are the recent ones.304 * two hundred markdown documents, and the ones worth arriving for are the recent ones.
...@@ -427,8 +316,6 @@ export function PullRequestSummaryTab({...@@ -427,8 +316,6 @@ export function PullRequestSummaryTab({
427 fixFindingLabel = "Fix in a thread",316 fixFindingLabel = "Fix in a thread",
428 fixCheckLabel = "Fix",317 fixCheckLabel = "Fix",
429 onFixFinding,318 onFixFinding,
430 actionPending,
431 onCommentAction,
432 onRefresh,319 onRefresh,
433}: {320}: {
434 environmentId: EnvironmentId;321 environmentId: EnvironmentId;
...@@ -442,11 +329,6 @@ export function PullRequestSummaryTab({...@@ -442,11 +329,6 @@ export function PullRequestSummaryTab({
442 fixFindingLabel?: string;329 fixFindingLabel?: string;
443 fixCheckLabel?: string;330 fixCheckLabel?: string;
444 onFixFinding?: (finding: PullRequestFinding) => void;331 onFixFinding?: (finding: PullRequestFinding) => void;
445 actionPending: boolean;
446 onCommentAction: (
447 body: string,
448 action: "close" | "reopen",
449 ) => Promise<{ readonly commentPosted: boolean }>;
450 onRefresh: () => void;332 onRefresh: () => void;
451}) {333}) {
452 // Keyed by the pull request, so opening another one starts at the end of its conversation334 // Keyed by the pull request, so opening another one starts at the end of its conversation
...@@ -984,26 +866,6 @@ export function PullRequestSummaryTab({...@@ -984,26 +866,6 @@ export function PullRequestSummaryTab({
984 </>866 </>
985 )}867 )}
986 </Section>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 </div>869 </div>
1008 );870 );
1009}871}