1/**
2 * Pull-request-specific annotations: conversations already on the host and comments queued for
3 * the review being written. New comment composition uses the shared diff annotation.
4 */
5import type {
6 EnvironmentId,
7 PullRequestDiffSide,
8 PullRequestRef,
9 PullRequestReviewPosition,
10 PullRequestReviewThread,
11 PullRequestThreadCommentsResult,
12 PullRequestThreadComment,
13} from "@t3tools/contracts";
14import { HammerIcon, MessageSquareIcon, Trash2Icon } from "lucide-react";
15import { Circle, CircleCheck } from "lucide";
16import { useRef, useState } from "react";
17
18import { formatRelativeTimeLabel } from "~/timestampFormat";
19import { cn } from "~/lib/utils";
20
21import { Button } from "../ui/button";
22import { MorphIcon } from "~/components/MorphIcon";
23import { PullRequestEditButton } from "./PullRequestEditButton";
24import { Textarea } from "../ui/textarea";
25import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut";
26import {
27 editPullRequestThreadComment,
28 mergePullRequestThreadComments,
29} from "./pullRequestDetail.logic";
30import { PullRequestActorLabel } from "./pullRequestPresentation";
31import { PullRequestMarkdown } from "./PullRequestMarkdown";
32import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
33import { PullRequestReactionBar } from "./PullRequestReactions";
34import type { PendingReviewComment } from "./pullRequestReviewStore";
35
36const CARD_CLASS =
37 "mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 text-sm shadow-sm";
38
39/** Sends a reply on ⌘/Ctrl+Enter and abandons it on Escape. */
40function submitKeys(input: {
41 readonly value: string;
42 readonly pending: boolean;
43 readonly onSubmit: () => void;
44 readonly onCancel?: (() => void) | undefined;
45}) {
46 return (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
47 if (event.key === "Escape" && input.onCancel) {
48 event.preventDefault();
49 input.onCancel();
50 }
51 if (isCommentSubmitShortcut(event, input.value, input.pending)) {
52 event.preventDefault();
53 input.onSubmit();
54 }
55 };
56}
57
58/** The contract's sides named the way the diff viewer names them, and back again. */
59export function toViewerSide(side: PullRequestDiffSide) {
60 return side === "left" ? ("deletions" as const) : ("additions" as const);
61}
62
63export function getReviewPositionAnchor(position: PullRequestReviewPosition): {
64 line: number;
65 side: PullRequestDiffSide;
66} {
67 switch (position.kind) {
68 case "added":
69 return { line: position.newLine, side: "right" };
70 case "deleted":
71 return { line: position.oldLine, side: "left" };
72 case "context":
73 return {
74 line: position.side === "left" ? position.oldLine : position.newLine,
75 side: position.side,
76 };
77 }
78}
79
80/** A comment waiting to be sent with the rest of the review. */
81export function PendingReviewCommentCard({
82 comment,
83 onRemove,
84}: {
85 comment: PendingReviewComment;
86 onRemove: () => void;
87}) {
88 return (
89 <div
90 className={cn(CARD_CLASS, "border-dashed")}
91 contentEditable={false}
92 onPointerDown={(event) => event.stopPropagation()}
93 >
94 <div className="flex items-center gap-2 text-xs text-muted-foreground">
95 <MessageSquareIcon className="size-3.5" />
96 <span>Pending — sent when you submit the review</span>
97 <Button
98 size="icon-xs"
99 variant="ghost"
100 className="ml-auto"
101 aria-label="Discard this comment"
102 onClick={onRemove}
103 >
104 <Trash2Icon className="size-3.5" />
105 </Button>
106 </div>
107 <p className="mt-2 whitespace-pre-wrap leading-relaxed">{comment.body}</p>
108 </div>
109 );
110}
111
112/** A conversation already on the host, with whatever this host lets the reader do to it. */
113export function ReviewThreadCard({
114 thread,
115 workspaceRoot,
116 canReply,
117 canResolve,
118 canReact,
119 environmentId,
120 reference,
121 pending,
122 fixPending,
123 fixLabel = "Fix in a thread",
124 onFix,
125 onReply,
126 onLoadMore,
127 canEditComment,
128 onEditComment,
129 onToggleResolved,
130 onReacted,
131}: {
132 thread: PullRequestReviewThread;
133 workspaceRoot: string;
134 canReply: boolean;
135 canResolve: boolean;
136 canReact: boolean;
137 environmentId: EnvironmentId;
138 reference: PullRequestRef;
139 pending: boolean;
140 /** True while this thread's own hand-off is preparing, so only its button says so. */
141 fixPending?: boolean;
142 fixLabel?: string;
143 /** Absent where a thread is shown outside the pull request page's reach. */
144 onFix?: () => void;
145 /** Resolves to whether the host took it, so a reply that failed keeps the words it was given. */
146 onReply: (body: string) => Promise<boolean>;
147 /** Reads one more page only after the reader asks for it. */
148 onLoadMore: (cursor: string) => Promise<PullRequestThreadCommentsResult | null>;
149 /** Whether this reader wrote this remark, which is what rewriting one takes. */
150 canEditComment: (comment: PullRequestThreadComment) => boolean;
151 /** Resolves to whether the host took it, like `onReply`. */
152 onEditComment: (commentId: string, body: string) => Promise<boolean>;
153 onToggleResolved: () => void;
154 onReacted: () => void;
155}) {
156 // A resolved thread is finished work, so it opens collapsed and stays one line until asked for.
157 const [expanded, setExpanded] = useState(!thread.isResolved);
158 const [replying, setReplying] = useState(false);
159 const [reply, setReply] = useState("");
160 const [editingId, setEditingId] = useState<string | null>(null);
161 const [savingEdit, setSavingEdit] = useState(false);
162 const sendingRef = useRef(false);
163 const [loadedPage, setLoadedPage] = useState<
164 (PullRequestThreadCommentsResult & { readonly threadId: string }) | null
165 >(null);
166 const [loadingMore, setLoadingMore] = useState(false);
167 const currentPage = loadedPage?.threadId === thread.id ? loadedPage : null;
168 const comments = mergePullRequestThreadComments(thread.comments, currentPage?.comments ?? []);
169 const nextCommentsCursor =
170 currentPage === null ? (thread.nextCommentsCursor ?? null) : currentPage.nextCursor;
171 const commentCount = thread.commentCount ?? comments.length;
172
173 const saveEdit = async (commentId: string, body: string) => {
174 if (savingEdit) return;
175 setSavingEdit(true);
176 const saved = await onEditComment(commentId, body);
177 setSavingEdit(false);
178 if (saved) {
179 setLoadedPage((previous) =>
180 previous?.threadId === thread.id
181 ? {
182 ...previous,
183 comments: editPullRequestThreadComment(previous.comments, commentId, body),
184 }
185 : previous,
186 );
187 setEditingId(null);
188 }
189 };
190
191 const send = async () => {
192 const trimmed = reply.trim();
193 if (trimmed.length === 0 || pending || sendingRef.current) return;
194 sendingRef.current = true;
195 // Cleared only once the host has it. Otherwise a failed reply leaves an error toast and an
196 // empty box, and the words have to be written again.
197 try {
198 if (await onReply(trimmed)) {
199 // The mutation returns no comment. Keep what the reader loaded and reopen its cursor so
200 // the new reply remains reachable without spending requests until they ask to load it.
201 setLoadedPage((previous) =>
202 previous?.threadId === thread.id
203 ? {
204 ...previous,
205 nextCursor: previous.nextCursor ?? thread.nextCommentsCursor ?? null,
206 }
207 : previous,
208 );
209 setReply("");
210 setReplying(false);
211 }
212 } finally {
213 sendingRef.current = false;
214 }
215 };
216 const loadMore = async () => {
217 if (nextCommentsCursor === null || loadingMore) return;
218 setLoadingMore(true);
219 try {
220 const page = await onLoadMore(nextCommentsCursor);
221 if (page === null) return;
222 setLoadedPage((previous) => ({
223 threadId: thread.id,
224 comments: mergePullRequestThreadComments(
225 previous?.threadId === thread.id ? previous.comments : [],
226 page.comments,
227 ),
228 nextCursor: page.nextCursor,
229 }));
230 } finally {
231 setLoadingMore(false);
232 }
233 };
234
235 return (
236 <div
237 className={CARD_CLASS}
238 contentEditable={false}
239 onPointerDown={(event) => event.stopPropagation()}
240 >
241 <div className="flex items-center gap-2 text-xs text-muted-foreground">
242 <MorphIcon
243 className={cn("size-3.5", thread.isResolved && "text-success-foreground")}
244 icon={thread.isResolved ? CircleCheck : Circle}
245 />
246 <button
247 type="button"
248 className="hover:text-foreground"
249 aria-expanded={expanded}
250 onClick={() => setExpanded((current) => !current)}
251 >
252 {thread.isResolved ? "Resolved" : "Open"} · {commentCount}{" "}
253 {commentCount === 1 ? "comment" : "comments"}
254 </button>
255 {thread.isOutdated ? <span>outdated</span> : null}
256 {onFix ? (
257 <Button
258 size="xs"
259 variant="ghost"
260 className="ml-auto"
261 disabled={pending || fixPending}
262 onClick={onFix}
263 >
264 <HammerIcon className="size-3" />
265 {fixPending ? "Preparing..." : fixLabel}
266 </Button>
267 ) : null}
268 {canResolve ? (
269 <Button
270 size="xs"
271 variant="ghost"
272 className={onFix ? undefined : "ml-auto"}
273 disabled={pending}
274 onClick={onToggleResolved}
275 >
276 {thread.isResolved ? "Unresolve" : "Resolve"}
277 </Button>
278 ) : null}
279 </div>
280
281 {expanded ? (
282 <>
283 <div className="mt-2 space-y-3">
284 {comments.map((comment) => (
285 <article key={comment.id} className="group min-w-0">
286 <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
287 <PullRequestActorLabel actor={comment.author} />
288 <span>{formatRelativeTimeLabel(comment.createdAt)}</span>
289 <PullRequestReactionBar
290 className="ml-auto justify-end"
291 reactions={comment.reactions ?? []}
292 canReact={canReact}
293 subjectId={comment.id}
294 environmentId={environmentId}
295 reference={reference}
296 onRefresh={onReacted}
297 />
298 </div>
299 {editingId === comment.id ? (
300 <PullRequestMarkdownEditor
301 className="mt-1"
302 value={comment.body}
303 cwd={workspaceRoot}
304 environmentId={environmentId}
305 label="Edit comment"
306 saving={savingEdit}
307 onSave={(body) => void saveEdit(comment.id, body)}
308 onCancel={() => setEditingId(null)}
309 />
310 ) : (
311 <div className="mt-1 flex items-start gap-1">
312 <PullRequestMarkdown
313 className="min-w-0 flex-1 text-sm"
314 text={comment.body}
315 cwd={workspaceRoot}
316 environmentId={environmentId}
317 />
318 {canEditComment(comment) ? (
319 <PullRequestEditButton
320 aria-label="Edit comment"
321 onClick={() => setEditingId(comment.id)}
322 />
323 ) : null}
324 </div>
325 )}
326 </article>
327 ))}
328 </div>
329 {nextCommentsCursor !== null ? (
330 <div className="mt-2">
331 <Button
332 size="xs"
333 variant="ghost"
334 disabled={loadingMore}
335 onClick={() => void loadMore()}
336 >
337 {loadingMore ? "Loading..." : "Load more comments"}
338 </Button>
339 </div>
340 ) : null}
341
342 {canReply ? (
343 replying ? (
344 <div className="mt-2">
345 <Textarea
346 autoFocus
347 size="sm"
348 value={reply}
349 placeholder="Reply"
350 aria-label="Reply to this conversation"
351 onChange={(event) => setReply(event.target.value)}
352 onKeyDown={submitKeys({
353 value: reply,
354 pending,
355 onSubmit: () => void send(),
356 onCancel: () => setReplying(false),
357 })}
358 />
359 <div className="mt-2 flex justify-end gap-2">
360 <Button size="xs" variant="ghost" onClick={() => setReplying(false)}>
361 Cancel
362 </Button>
363 <Button
364 size="xs"
365 disabled={pending || reply.trim().length === 0}
366 onClick={() => void send()}
367 >
368 Reply
369 </Button>
370 </div>
371 </div>
372 ) : (
373 <Button size="xs" variant="ghost" className="mt-2" onClick={() => setReplying(true)}>
374 Reply
375 </Button>
376 )
377 ) : null}
378 </>
379 ) : null}
380 </div>
381 );
382}