1import { useAtomCommand } from "~/state/use-atom-command";
2import type {
3 EnvironmentId,
4 PullRequestActor,
5 PullRequestComment,
6 PullRequestDetailView,
7 PullRequestRef,
8 ScopedThreadRef,
9} from "@t3tools/contracts";
10import {
11 ChevronDownIcon,
12 ExternalLinkIcon,
13 FileCode2Icon,
14 GitCommitHorizontalIcon,
15 MessageSquareIcon,
16} from "lucide-react";
17import { useState, type ReactNode } from "react";
18
19import { cn } from "~/lib/utils";
20import { readLocalApi } from "~/localApi";
21import { pullRequestEnvironment } from "~/state/pullRequests";
22import { formatRelativeTimeLabel } from "~/timestampFormat";
23
24import { Button } from "../ui/button";
25import { PullRequestEditButton } from "./PullRequestEditButton";
26import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
27import { toastManager } from "../ui/toast";
28import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
29import {
30 buildPullRequestTimeline,
31 groupPullRequestTimelineConversations,
32 isPullRequestVerdictStale,
33 newestPullRequestCommitAt,
34 pullRequestReviewOutcome,
35 type PullRequestReviewOutcome,
36 type PullRequestTimelineEvent,
37} from "./pullRequestDetail.logic";
38import { canEditPullRequestComment } from "./pullRequestEditing.logic";
39import { PullRequestMarkdown } from "./PullRequestMarkdown";
40import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
41import { PullRequestReactionBar } from "./PullRequestReactions";
42import {
43 PullRequestActorAvatar,
44 PullRequestDiffStat,
45 PullRequestMetaLine,
46 PullRequestReviewOutcomeIcon,
47 pullRequestReviewOutcomeLabel,
48 pullRequestReviewOutcomeStaleLabel,
49 pullRequestReviewOutcomeToneClassName,
50} from "./pullRequestPresentation";
51import { PullRequestGlyph } from "./pullRequestIcons";
52
53/** What every comment on the timeline needs to react; only the subject differs between them. */
54interface ReactionSurface {
55 readonly canReact: boolean;
56 readonly environmentId: EnvironmentId;
57 /** Thread the timeline is shown beside, so body links can open in its in-app browser. */
58 readonly threadRef: ScopedThreadRef | null;
59 readonly reference: PullRequestRef;
60 readonly onRefresh: () => void;
61}
62
63function TimelineBody({
64 body,
65 markdown,
66 cwd,
67 environmentId,
68 threadRef,
69}: {
70 body: string;
71 markdown: boolean;
72 cwd: string;
73 environmentId: EnvironmentId;
74 threadRef: ScopedThreadRef | null;
75}) {
76 return (
77 <div className="mt-3">
78 {markdown ? (
79 <PullRequestMarkdown
80 text={body}
81 cwd={cwd}
82 environmentId={environmentId}
83 threadRef={threadRef}
84 />
85 ) : (
86 <p className="whitespace-pre-wrap text-xs text-muted-foreground">{body}</p>
87 )}
88 </div>
89 );
90}
91
92function ActorName({ actor }: { actor: PullRequestActor | null }) {
93 return <span className="font-semibold text-foreground">{actor?.login ?? "ghost"}</span>;
94}
95
96function TimelineMarker({
97 children,
98 className,
99}: {
100 children: ReactNode;
101 className?: string | undefined;
102}) {
103 return (
104 <span
105 className={cn(
106 "absolute left-0 top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center bg-background",
107 className,
108 )}
109 >
110 {children}
111 </span>
112 );
113}
114
115function IconMarker({ icon, className }: { icon: ReactNode; className?: string | undefined }) {
116 return (
117 <TimelineMarker className={className}>
118 <span className="flex size-7 items-center justify-center bg-background text-muted-foreground">
119 {icon}
120 </span>
121 </TimelineMarker>
122 );
123}
124
125function ActorTimelineMarker({
126 actors,
127 className,
128 fallback,
129 muted = false,
130}: {
131 actors: ReadonlyArray<PullRequestActor>;
132 className?: string | undefined;
133 fallback: ReactNode;
134 muted?: boolean;
135}) {
136 const actor = actors[0];
137 return actor === undefined ? (
138 <IconMarker className={className} icon={fallback} />
139 ) : (
140 <TimelineMarker className={className}>
141 <PullRequestActorAvatar
142 actor={actor}
143 className={cn(
144 "size-7 bg-muted text-3xs transition-opacity",
145 muted && "opacity-45 grayscale",
146 )}
147 />
148 </TimelineMarker>
149 );
150}
151
152function friendlyReviewState(value: string): string {
153 const words = value.toLowerCase().replaceAll("_", " ").replaceAll("-", " ");
154 return words.replace(/^\w/u, (letter) => letter.toUpperCase());
155}
156
157function ReviewStateBadge({ state }: { state: string }) {
158 return (
159 <span className="text-3xs font-medium text-muted-foreground">{friendlyReviewState(state)}</span>
160 );
161}
162
163function OpenOnHostButton({ url, onOpen }: { url: string | null; onOpen: (url: string) => void }) {
164 return url === null ? null : (
165 <Button
166 size="icon-xs"
167 variant="ghost-muted"
168 className="-mr-1 -mt-1 shrink-0"
169 aria-label="Open activity on host"
170 onClick={() => onOpen(url)}
171 >
172 <ExternalLinkIcon className="size-3" />
173 </Button>
174 );
175}
176
177function ConversationCard({
178 event,
179 editable,
180 cwd,
181 onOpen,
182 reactions,
183}: {
184 event: PullRequestTimelineEvent;
185 /** The remark behind this entry, only where this reader may rewrite it. */
186 editable: PullRequestComment | null;
187 cwd: string;
188 onOpen: (url: string) => void;
189 reactions: ReactionSurface;
190}) {
191 const [editing, setEditing] = useState(false);
192 const [saving, setSaving] = useState(false);
193 const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, {
194 reportFailure: false,
195 });
196
197 const save = async (body: string) => {
198 // A review's own summary is not a kind any host rewrites, which is why `editable` is never
199 // one; the check is here because the comment's own type still allows it.
200 if (editable === null || saving || editable.kind === "review") return;
201 setSaving(true);
202 const result = await updateComment({
203 environmentId: reactions.environmentId,
204 input: { ...reactions.reference, commentId: editable.id, kind: editable.kind, body },
205 });
206 setSaving(false);
207 if (result._tag === "Failure") {
208 toastManager.add({ type: "error", title: "Could not save the comment" });
209 return;
210 }
211 setEditing(false);
212 reactions.onRefresh();
213 };
214
215 return (
216 <article className="group py-2">
217 <div className="px-2">
218 <div className="flex min-w-0 flex-wrap items-start gap-2">
219 <div className="min-w-0 flex-1">
220 <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs">
221 <ActorName actor={event.actor} />
222 <span className="text-muted-foreground">{event.title}</span>
223 {event.reviewState ? <ReviewStateBadge state={event.reviewState} /> : null}
224 </div>
225 <PullRequestMetaLine className="mt-1 flex-wrap text-2xs text-muted-foreground">
226 <span>{formatRelativeTimeLabel(event.at)}</span>
227 {event.path ? (
228 <span className="inline-flex min-w-0 items-center gap-1">
229 <FileCode2Icon aria-hidden className="size-3 shrink-0" />
230 <span className="truncate">{event.path}</span>
231 </span>
232 ) : null}
233 </PullRequestMetaLine>
234 </div>
235 {editable !== null && !editing ? (
236 <PullRequestEditButton
237 className="-mt-1"
238 aria-label="Edit comment"
239 onClick={() => setEditing(true)}
240 />
241 ) : null}
242 {reactions.canReact || event.reactions.length > 0 ? (
243 <PullRequestReactionBar
244 className="ml-auto justify-end"
245 reactions={event.reactions}
246 canReact={reactions.canReact}
247 subjectId={event.id}
248 environmentId={reactions.environmentId}
249 reference={reactions.reference}
250 onRefresh={reactions.onRefresh}
251 />
252 ) : null}
253 <OpenOnHostButton url={event.url} onOpen={onOpen} />
254 </div>
255 </div>
256 {editing && editable !== null ? (
257 <div className="px-2 pb-2 pt-3">
258 <PullRequestMarkdownEditor
259 value={editable.body}
260 cwd={cwd}
261 environmentId={reactions.environmentId}
262 threadRef={reactions.threadRef}
263 label="Edit comment"
264 saving={saving}
265 onSave={(body) => void save(body)}
266 onCancel={() => setEditing(false)}
267 />
268 </div>
269 ) : event.body ? (
270 <div className="px-2 pb-2">
271 <TimelineBody
272 body={event.body}
273 markdown={event.markdown}
274 cwd={cwd}
275 environmentId={reactions.environmentId}
276 threadRef={reactions.threadRef}
277 />
278 </div>
279 ) : null}
280 </article>
281 );
282}
283
284function uniqueConversationActors(events: ReadonlyArray<PullRequestTimelineEvent>) {
285 const actors = new Map<string, PullRequestActor>();
286 for (const event of events) {
287 const actor = event.actor;
288 if (actor !== null && !actors.has(actor.login)) actors.set(actor.login, actor);
289 }
290 return [...actors.values()];
291}
292
293function ConversationGroup({
294 events,
295 editable,
296 cwd,
297 onOpen,
298 reactions,
299}: {
300 events: ReadonlyArray<PullRequestTimelineEvent>;
301 editable: ReadonlyMap<string, PullRequestComment>;
302 cwd: string;
303 onOpen: (url: string) => void;
304 reactions: ReactionSurface;
305}) {
306 const [open, setOpen] = useState(false);
307 const actors = uniqueConversationActors(events);
308 const first = events[0];
309 if (first === undefined) return null;
310
311 return (
312 <div className="relative mb-5 pl-12 [contain-intrinsic-block-size:48px] [content-visibility:auto]">
313 <ActorTimelineMarker
314 actors={actors}
315 className="top-6"
316 fallback={<MessageSquareIcon className="size-3.5" />}
317 muted={!open}
318 />
319 <Collapsible open={open} onOpenChange={setOpen}>
320 <div>
321 <CollapsibleTrigger
322 className={cn(
323 "flex w-full min-w-0 items-center gap-3 py-2 text-left transition-opacity hover:opacity-100",
324 open ? "text-foreground opacity-100" : "text-muted-foreground opacity-55",
325 )}
326 >
327 <span className="min-w-0 flex-1">
328 <span className="block text-xs font-semibold">
329 {events.length.toLocaleString()} {events.length === 1 ? "comment" : "comments"}
330 </span>
331 <span className="block truncate text-3xs text-muted-foreground">
332 {actors.length.toLocaleString()} {actors.length === 1 ? "author" : "authors"} ·{" "}
333 {formatRelativeTimeLabel(first.at)}
334 </span>
335 </span>
336 <ChevronDownIcon
337 aria-hidden
338 className={cn(
339 "size-3.5 shrink-0 text-muted-foreground transition-transform",
340 open && "rotate-180",
341 )}
342 />
343 </CollapsibleTrigger>
344 <CollapsiblePanel>
345 {open ? (
346 <div className="mt-1 space-y-1">
347 {events.map((event) => (
348 <ConversationCard
349 // Named with the pull request too: a remark's id is the host's own, and two
350 // pull requests can hand out the same one — which would leave one card's open
351 // editor standing over the other's remark.
352 key={`${reactions.reference.projectId}#${reactions.reference.number}:${event.id}`}
353 event={event}
354 editable={editable.get(event.id) ?? null}
355 cwd={cwd}
356 onOpen={onOpen}
357 reactions={reactions}
358 />
359 ))}
360 </div>
361 ) : null}
362 </CollapsiblePanel>
363 </div>
364 </Collapsible>
365 </div>
366 );
367}
368
369function CommitEvent({
370 event,
371 onOpen,
372}: {
373 event: PullRequestTimelineEvent;
374 onOpen: (oid: string) => void;
375}) {
376 return (
377 <button
378 type="button"
379 className="group relative mb-5 block w-full cursor-pointer rounded-sm pl-12 text-left outline-none [contain-intrinsic-block-size:48px] [content-visibility:auto] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
380 aria-label={`View commit ${event.id}`}
381 onClick={() => onOpen(event.id)}
382 >
383 <ActorTimelineMarker
384 actors={event.commitAuthors}
385 fallback={<GitCommitHorizontalIcon className="size-3.5" />}
386 />
387 <div className="flex min-w-0 items-center gap-2.5 py-1.5">
388 <div className="min-w-0 flex-1">
389 <div className="truncate text-xs font-semibold text-foreground transition-colors group-hover:text-primary">
390 {event.body ?? "Untitled commit"}
391 </div>
392 <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-3xs text-muted-foreground">
393 <code className="font-mono">{event.id.slice(0, 7)}</code>
394 <span>{formatRelativeTimeLabel(event.at)}</span>
395 </div>
396 </div>
397 {event.additions !== null && event.deletions !== null ? (
398 <PullRequestDiffStat
399 additions={event.additions}
400 deletions={event.deletions}
401 className="ml-auto shrink-0 font-mono text-3xs"
402 />
403 ) : null}
404 </div>
405 </button>
406 );
407}
408
409function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) {
410 const presentation =
411 event.kind === "opened"
412 ? {
413 icon: <PullRequestGlyph.pullRequest className="size-3.5" />,
414 label: "Pull request opened",
415 }
416 : event.kind === "merged"
417 ? {
418 icon: <PullRequestGlyph.merged className="size-3.5" />,
419 label: "Pull request merged",
420 }
421 : {
422 icon: <PullRequestGlyph.closed className="size-3.5" />,
423 label: "Pull request closed",
424 };
425
426 return (
427 <div className="relative mb-5 pl-12 [contain-intrinsic-block-size:48px] [content-visibility:auto]">
428 <IconMarker icon={presentation.icon} />
429 <div className="py-1.5 text-xs">
430 <div className="flex flex-wrap items-center gap-1.5">
431 {event.actor ? <ActorName actor={event.actor} /> : null}
432 <span className="font-semibold text-foreground">{presentation.label}</span>
433 </div>
434 <div className="mt-0.5 text-2xs text-muted-foreground">
435 {formatRelativeTimeLabel(event.at)}
436 </div>
437 </div>
438 </div>
439 );
440}
441
442/**
443 * A verdict, as its own row rather than a line inside a collapsed conversation. It wears the
444 * reviewer's face on the rail and the verdict's own icon beside their name, so "approved" reads
445 * at a glance from the same place a merge or a commit does.
446 */
447function ReviewVerdictEvent({
448 event,
449 outcome,
450 stale,
451 cwd,
452 onOpen,
453 reactions,
454}: {
455 event: PullRequestTimelineEvent;
456 outcome: PullRequestReviewOutcome;
457 /** Commits landed after this verdict, so it speaks for code the branch no longer has. */
458 stale: boolean;
459 cwd: string;
460 onOpen: (url: string) => void;
461 reactions: ReactionSurface;
462}) {
463 return (
464 <div className="group relative mb-5 pl-12 [contain-intrinsic-block-size:48px] [content-visibility:auto]">
465 {/* Pinned rather than centred: this row grows with a body, and a
466 centred avatar drifts down beside it instead of sitting by the name. */}
467 <ActorTimelineMarker
468 actors={event.actor ? [event.actor] : []}
469 className="top-6"
470 fallback={<PullRequestReviewOutcomeIcon outcome={outcome} />}
471 />
472 <div className="flex min-w-0 flex-wrap items-start gap-2 py-1.5">
473 <div className="min-w-0 flex-1">
474 <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs">
475 <ActorName actor={event.actor} />
476 {/* The word alone, in the verdict's own colour — green for an approval, red for a
477 request for changes. A verdict overtaken by later commits keeps its word and
478 loses that colour: it still happened, and it no longer speaks for what is on the
479 branch. Lowercased in the styling rather than the string, so what a screen reader
480 announces stays the label every other surface uses. */}
481 <Tooltip disabled={!stale}>
482 <TooltipTrigger
483 render={
484 <span
485 className={cn(
486 "font-medium lowercase",
487 stale
488 ? "text-muted-foreground opacity-70"
489 : pullRequestReviewOutcomeToneClassName(outcome),
490 )}
491 />
492 }
493 >
494 {pullRequestReviewOutcomeLabel(outcome)}
495 {stale ? <span className="sr-only">, before the latest commits</span> : null}
496 </TooltipTrigger>
497 <TooltipPopup>{pullRequestReviewOutcomeStaleLabel(outcome)}</TooltipPopup>
498 </Tooltip>
499 </div>
500 <div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
501 <PullRequestMetaLine className="flex-wrap text-2xs text-muted-foreground">
502 <span>{formatRelativeTimeLabel(event.at)}</span>
503 {event.path ? (
504 <span className="inline-flex min-w-0 items-center gap-1">
505 <FileCode2Icon aria-hidden className="size-3 shrink-0" />
506 <span className="truncate">{event.path}</span>
507 </span>
508 ) : null}
509 </PullRequestMetaLine>
510 </div>
511 </div>
512 {reactions.canReact || event.reactions.length > 0 ? (
513 <PullRequestReactionBar
514 className="ml-auto justify-end"
515 reactions={event.reactions}
516 canReact={reactions.canReact}
517 subjectId={event.id}
518 environmentId={reactions.environmentId}
519 reference={reactions.reference}
520 onRefresh={reactions.onRefresh}
521 />
522 ) : null}
523 <OpenOnHostButton url={event.url} onOpen={onOpen} />
524 </div>
525 {/* An approval usually carries no words. When it does they are the review, so they stay
526 visible rather than being folded away with the ordinary conversation. */}
527 {event.body ? (
528 <TimelineBody
529 body={event.body}
530 markdown={event.markdown}
531 cwd={cwd}
532 environmentId={reactions.environmentId}
533 threadRef={reactions.threadRef}
534 />
535 ) : null}
536 </div>
537 );
538}
539
540export function PullRequestTimelineTab({
541 scrollerRef,
542 detail,
543 environmentId,
544 threadRef = null,
545 reference,
546 order,
547 onOpenCommit,
548 onRefresh,
549}: {
550 scrollerRef: (node: HTMLDivElement | null) => void;
551 detail: PullRequestDetailView;
552 environmentId: EnvironmentId;
553 threadRef?: ScopedThreadRef | null;
554 reference: PullRequestRef;
555 order: "newest" | "oldest";
556 onOpenCommit: (oid: string) => void;
557 onRefresh: () => void;
558}) {
559 const events = buildPullRequestTimeline(detail);
560 const newestCommitAt = newestPullRequestCommitAt(detail.commits);
561 const reactions: ReactionSurface = {
562 canReact: detail.capabilities.reactions === true,
563 environmentId,
564 threadRef,
565 reference,
566 onRefresh,
567 };
568 // A timeline entry keeps only what it draws, so the remarks this reader may rewrite are looked
569 // up here by the id the entry carries.
570 const editable = new Map(
571 detail.comments
572 .filter((comment) => canEditPullRequestComment(detail, comment))
573 .map((comment) => [comment.id, comment] as const),
574 );
575 const orderedEvents = order === "newest" ? events : events.toReversed();
576 const rows = groupPullRequestTimelineConversations(orderedEvents);
577 const openOnHost = (url: string) => {
578 void readLocalApi()?.shell.openExternal(url);
579 };
580
581 return (
582 <div ref={scrollerRef} className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5">
583 <div className="mx-auto max-w-3xl">
584 <div className="relative">
585 <span aria-hidden className="absolute bottom-5 left-[15px] top-1 w-px bg-border/45" />
586 {rows.map((row) => {
587 if (row.kind === "comments") {
588 return (
589 <ConversationGroup
590 key={`comments:${row.events[0]?.id ?? "empty"}`}
591 events={row.events}
592 editable={editable}
593 cwd={detail.workspaceRoot}
594 onOpen={openOnHost}
595 reactions={reactions}
596 />
597 );
598 }
599 const event = row.event;
600 if (event.kind === "commit") {
601 return <CommitEvent key={event.id} event={event} onOpen={onOpenCommit} />;
602 }
603 const outcome = pullRequestReviewOutcome(event.reviewState);
604 if (outcome !== null) {
605 return (
606 <ReviewVerdictEvent
607 key={event.id}
608 event={event}
609 outcome={outcome}
610 stale={isPullRequestVerdictStale(event.at, newestCommitAt)}
611 cwd={detail.workspaceRoot}
612 onOpen={openOnHost}
613 reactions={reactions}
614 />
615 );
616 }
617 return <LifecycleEvent key={event.id} event={event} />;
618 })}
619 </div>
620
621 {events.length === 0 ? (
622 <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
623 <PullRequestGlyph.pullRequest className="mb-2 size-5" />
624 <p className="text-xs">No activity yet.</p>
625 </div>
626 ) : null}
627 </div>
628 </div>
629 );
630}