Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

834 linesCodeBlameRaw
1import { Bot, CircleCheck, CircleSlash, Pencil, Trash2 } from "lucide-react";
2import { type CSSProperties, Fragment, type ReactNode } from "react";
3import { Form, Link } from "react-router";
4
5import type { AgentRef, Comment, Issue, Pull, State } from "@g1t/contracts";
6
7import { mayChangeComment } from "../lib/comments";
8import { chipStyle } from "../lib/labels";
9import { repoAt } from "../lib/markdown-plugins";
10import { Markdown } from "./markdown";
11import { Reactions } from "./reactions";
12import { IssueIcon, PullIcon } from "./work-icons";
13import { MentionTextarea } from "./mention-textarea";
14import { ErrorText, SubmitButton, TimeAgo } from "./ui";
15import { Badge } from "./ui/badge";
16import {
17 AlertDialog,
18 AlertDialogAction,
19 AlertDialogCancel,
20 AlertDialogContent,
21 AlertDialogDescription,
22 AlertDialogFooter,
23 AlertDialogHeader,
24 AlertDialogTitle,
25 AlertDialogTrigger,
26} from "./ui/alert-dialog";
27import { Avatar } from "./ui/avatar";
28import { Button } from "./ui/button";
29import { Card } from "./ui/card";
30import { CheckboxOption } from "./ui/checkbox";
31import { Hint } from "./ui/hint";
32import { UserCard } from "./user-card";
33
34export { IssueIcon, PullIcon };
35
36/** Hues for the labels every repository starts with. */
37const LABEL_HUES: Record<string, number> = {
38 bug: 25,
39 feature: 150,
40 docs: 240,
41 chore: 80,
42 question: 310,
43};
44
45/** A stable hue for any other label, from its name. */
46function hueFor(name: string): number {
47 let hash = 0;
48 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
49 return hash;
50}
51
52/**
53 * A label's chip: in its own color when the page knows it (the
54 * repository's labels, by name), else in a hue chosen from its name.
55 */
56export function Label({ name, color }: { name: string; color?: string | null }) {
57 const hue = LABEL_HUES[name] ?? hueFor(name);
58 const own = chipStyle(color);
59 const style: CSSProperties = Object.keys(own).length > 0 ? own : {
60 // Light text on a dark page, deep text on a light one (lib/theme.ts).
61 color: `light-dark(oklch(0.46 0.13 ${hue}), oklch(0.84 0.11 ${hue}))`,
62 borderColor: `light-dark(oklch(0.55 0.13 ${hue} / 0.35), oklch(0.84 0.11 ${hue} / 0.35))`,
63 backgroundColor: `light-dark(oklch(0.55 0.13 ${hue} / 0.08), oklch(0.84 0.11 ${hue} / 0.1))`,
64 };
65 return (
66 <span
67 style={style}
68 className="inline-flex shrink-0 items-center rounded-full border px-2 py-px text-xs font-medium"
69 >
70 {name}
71 </span>
72 );
73}
74
75/** The people an issue is assigned to, as overlapping avatars. */
76export function AssigneeStack({ people }: { people: string[] }) {
77 if (people.length === 0) return null;
78 const shown = people.slice(0, 3);
79 return (
80 <Hint label={`Assigned to ${people.join(", ")}`}>
81 <span className="flex shrink-0 items-center">
82 <span className="sr-only">Assigned to {people.join(", ")}</span>
83 {shown.map((name, index) => (
84 <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`} aria-hidden>
85 <Avatar name={name} size={18} />
86 </span>
87 ))}
88 {people.length > shown.length && (
89 <span className="ml-1 text-xs text-faint" aria-hidden>
90 +{people.length - shown.length}
91 </span>
92 )}
93 </span>
94 </Hint>
95 );
96}
97
98/** The agent working on an issue now. */
99export function Assignee({ agent }: { agent: string }) {
100 return (
101 <Hint label={`Assigned to ${agent}`}>
102 <Badge tone="accent" className="text-xs">
103 <Bot size={12} />
104 <span className="sr-only">Assigned to </span>
105 {agent}
106 </Badge>
107 </Hint>
108 );
109}
110
111const PILL = "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium";
112
113/** The large state badge at the top of an issue. */
114export function IssueState({ issue }: { issue: Pick<Issue, "state" | "reason"> }) {
115 const style =
116 issue.state === "open"
117 ? "border-success/30 bg-success/10 text-success"
118 : issue.reason === "not_planned"
119 ? "border-line bg-surface text-muted"
120 : "border-merged/30 bg-merged/10 text-merged";
121 return (
122 <span className={`${PILL} ${style}`}>
123 <IssueIcon issue={issue} size={14} />
124 {issue.state === "open"
125 ? "Open"
126 : issue.reason === "not_planned"
127 ? "Closed as not planned"
128 : "Closed"}
129 </span>
130 );
131}
132
133const PULL_STYLES: Record<Pull["status"], string> = {
134 draft: "border-line bg-surface text-muted",
135 open: "border-success/30 bg-success/10 text-success",
136 merged: "border-merged/30 bg-merged/10 text-merged",
137 closed: "border-danger/30 bg-danger/10 text-danger",
138};
139
140const PULL_NAMES: Record<Pull["status"], string> = {
141 draft: "Draft",
142 open: "Open",
143 merged: "Merged",
144 closed: "Closed",
145};
146
147export function PullState({ status }: { status: Pull["status"] }) {
148 return (
149 <span className={`${PILL} ${PULL_STYLES[status]}`}>
150 <PullIcon status={status} size={14} />
151 {PULL_NAMES[status]}
152 </span>
153 );
154}
155
156/** The open and closed switch above a list. `to` is the list's own path. */
157export function StateTabs({
158 to,
159 state,
160 query = "",
161 action,
162}: {
163 to: string;
164 state: State;
165 /** Other query parameters to keep, e.g. `label=bug`. */
166 query?: string;
167 action?: ReactNode;
168}) {
169 const link = (value: State) =>
170 `${to}?${[value === "open" ? "" : "state=closed", query].filter(Boolean).join("&")}`;
171 const tab = (value: State, label: string) => (
172 <Link
173 to={link(value)}
174 aria-current={state === value ? "page" : undefined}
175 className={
176 "inline-flex h-full items-center rounded-[5px] px-2.5 text-sm transition-colors " +
177 (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")
178 }
179 >
180 {label}
181 </Link>
182 );
183 return (
184 <div className="flex flex-wrap items-center justify-between gap-3">
185 {/* A filter, the height of the filters beside it: the section's views above it outweigh it. */}
186 <Card tone="plain" radius="lg" className="flex h-8 gap-0.5 rounded-md p-0.5">
187
188 {tab("open", "Open")}
189 {tab("closed", "Closed")}
190 </Card>
191 {action}
192 </div>
193 );
194}
195
196const VERDICTS = {
197 approve: { label: "approved these changes", style: "text-success" },
198 request_changes: { label: "requested changes", style: "text-danger" },
199} as const;
200
201/**
202 * A person's name (or `children`) linking to their profile at `/u/<name>`.
203 * g1t, and names that are not usernames, stay plain text.
204 */
205export function PersonLink({
206 name,
207 className,
208 label,
209 children,
210}: {
211 name: string;
212 className?: string;
213 label?: string;
214 children?: ReactNode;
215}) {
216 // g1t itself has no profile; its name carries a small label instead, and
217 // its card says what it is.
218 if (name === "g1t" && !children) {
219 return (
220 <UserCard username="g1t">
221 <span tabIndex={0} className="inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
222 <span className={className}>g1t</span>
223 <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
224 </span>
225 </UserCard>
226 );
227 }
228 if (name === "g1t") {
229 return (
230 <UserCard username="g1t">
231 <span className={className}>{children}</span>
232 </UserCard>
233 );
234 }
235 // Ghost, a deleted account, has no profile and no card.
236 if (name === "ghost" || !/^[a-z0-9-]{1,39}$/i.test(name)) {
237 return <span className={className}>{children ?? name}</span>;
238 }
239 return (
240 <UserCard username={name}>
241 <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}>
242 {children ?? name}
243 </Link>
244 </UserCard>
245 );
246}
247
248/**
249 * Comments in order, then the box to add one. On a pull request, `review`
250 * says where its changes are shown and whether the viewer may give a
251 * verdict.
252 */
253/**
254 * One entry in a conversation: who, what they did and when, then what they
255 * wrote. The author's avatar sits beside it, as on any forge.
256 */
257export function TimelineItem({
258 id,
259 author,
260 bot = false,
261 agent,
262 action,
263 at,
264 edited,
265 aside,
266 children,
267}: {
268 /** Its anchor, so a link can point at it (`#cmt_…`). */
269 id?: string;
270 author: string;
271 /** g1t, or an agent in someone's harness: drawn as an agent, with no profile to link to. */
272 bot?: boolean;
273 /**
274 * Set when one of the workspace's former agents wrote it, as itself:
275 * shown by its name with an Agent badge, and whom it acted for, in place
276 * of `author`.
277 */
278 agent?: { ref: AgentRef; actingFor?: string | null };
279 /** What they did, after their name: "commented", "opened this". */
280 action: ReactNode;
281 at?: string;
282 /** When what they wrote was last edited, if it was. */
283 edited?: string | null;
284 /** Shown at the right of the header. */
285 aside?: ReactNode;
286 children?: ReactNode;
287}) {
288 const face = (size: number) =>
289 agent ? (
290 <Avatar name={agent.ref.displayName} agent size={size} />
291 ) : (
292 <Avatar name={author} size={size} agent={bot} />
293 );
294 return (
295 <div id={id} className="flex scroll-mt-20 gap-3">
296 <span className="mt-1 hidden shrink-0 sm:block">
297 {agent || bot ? (
298 face(32)
299 ) : (
300 <PersonLink name={author} label={`${author}'s profile`}>
301 {face(32)}
302 </PersonLink>
303 )}
304 </span>
305 <Card asChild className="min-w-0 grow overflow-hidden">
306 <article>
307 <header className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-line bg-raised/40 px-4 py-2 text-sm text-muted">
308 <span className="sm:hidden">{face(18)}</span>
309 {agent ? (
310 <AgentName agent={agent.ref} />
311 ) : bot ? (
312 <span className="font-medium text-fg">{author}</span>
313 ) : (
314 <PersonLink name={author} className="font-medium text-fg hover:underline" />
315 )}
316 {action}
317 {at && <TimeAgo at={at} />}
318 {agent?.actingFor && (
319 <span className="text-xs text-faint">
320 on behalf of{" "}
321 <PersonLink name={agent.actingFor} className="hover:text-fg hover:underline">
322 @{agent.actingFor}
323 </PersonLink>
324 </span>
325 )}
326 {edited && (
327 <Hint
328 label={
329 <>
330 Edited <TimeAgo at={edited} />
331 </>
332 }
333 >
334 <span tabIndex={0} className="text-xs text-faint">
335 edited
336 </span>
337 </Hint>
338 )}
339 {aside && <span className="ml-auto min-w-0">{aside}</span>}
340 </header>
341 {children && <div className="px-4 py-3">{children}</div>}
342 </article>
343 </Card>
344 </div>
345 );
346}
347
348/** A former agent's name, with an Agent badge. */
349function AgentName({ agent }: { agent: AgentRef }) {
350 return (
351 <span className="inline-flex items-center gap-1.5">
352 <span className="font-medium text-fg">{agent.displayName}</span>
353 <Badge tone="accent" className="px-1.5 text-[0.625rem] leading-[1.35]">
354 Agent
355 </Badge>
356 </span>
357 );
358}
359
360/** That an agent's review is advisory, and why it counts toward nothing. */
361function AdvisoryChip() {
362 return (
363 <Hint label="Agent reviews don't count toward required approvals">
364 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
365 <Badge tone="neutral">Advisory</Badge>
366 </span>
367 </Hint>
368 );
369}
370
371type Review = { changesUrl: string; canJudge: boolean };
372
373/**
374 * Text with each `#12` linked to the issue or pull request of that number,
375 * and each file named in backticks shown as code, linked to its diff when
376 * `filesUrl` says where the changes are.
377 */
378function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) {
379 if (!base) return <>{text}</>;
380 return (
381 <>
382 {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) =>
383 /^`[^`]+`$/.test(part) ? (
384 filesUrl ? (
385 <Link
386 key={index}
387 to={`${filesUrl}#file-${part.slice(1, -1)}`}
388 className="font-mono text-xs text-fg hover:underline"
389 >
390 {part.slice(1, -1)}
391 </Link>
392 ) : (
393 <code key={index} className="font-mono text-xs text-fg">
394 {part.slice(1, -1)}
395 </code>
396 )
397 ) : /^#\d+$/.test(part) ? (
398 // Issues and pull requests share numbers; the issue page forwards.
399 <Link
400 key={index}
401 to={`${base}/issues/${part.slice(1)}`}
402 className="font-medium text-fg hover:underline"
403 >
404 {part}
405 </Link>
406 ) : (
407 part
408 ),
409 )}
410 </>
411 );
412}
413
414/**
415 * One line on the conversation's rail: a mark on the rail (a dot unless
416 * `mark` says otherwise), who, what and when.
417 */
418function TimelineRow({ comment, mark, children }: { comment: Comment; mark?: ReactNode; children: ReactNode }) {
419 return (
420 <div className="flex items-center gap-3 text-sm text-muted">
421 <span className="hidden w-8 shrink-0 justify-center sm:flex">
422 {mark ?? <span className="size-2 rounded-full border border-line-strong bg-bg" />}
423 </span>
424 <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
425 {comment.agent ? (
426 <>
427 <Avatar name={comment.agent.displayName} agent size={16} />
428 <AgentName agent={comment.agent} />
429 </>
430 ) : (
431 <>
432 <Avatar name={comment.author.username} size={16} />
433 <PersonLink name={comment.author.username} className="font-medium text-fg hover:underline" />
434 </>
435 )}
436 {children}
437 {comment.actingFor && (
438 <span className="text-xs text-faint">
439 on behalf of{" "}
440 <PersonLink name={comment.actingFor.username} className="hover:text-fg hover:underline">
441 @{comment.actingFor.username}
442 </PersonLink>
443 </span>
444 )}
445 <span className="text-faint">
446 · <TimeAgo at={comment.createdAt} />
447 </span>
448 </span>
449 </div>
450 );
451}
452
453/** Something that happened, as one line on the conversation's rail. */
454function TimelineEvent({ comment, base, filesUrl }: { comment: Comment; base?: string; filesUrl?: string }) {
455 return (
456 <TimelineRow comment={comment}>
457 <span>
458 <WithReferences text={comment.body} base={base} filesUrl={filesUrl} />
459 </span>
460 </TimelineRow>
461 );
462}
463
464/** A review's verdict, approved or changes requested, in its own colour. */
465function Verdict({ verdict }: { verdict: keyof typeof VERDICTS }) {
466 const { label, style } = VERDICTS[verdict];
467 return (
468 <span className={`flex items-center gap-1 font-medium ${style}`}>
469 {verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />}
470 {label}
471 </span>
472 );
473}
474
475/**
476 * A review that gave a verdict and said nothing else: one line on the rail,
477 * not a comment with an empty body. There is nothing to edit, and a
478 * verdict is not deleted, so it has no actions.
479 */
480function TimelineVerdict({ comment, verdict }: { comment: Comment; verdict: keyof typeof VERDICTS }) {
481 return (
482 <TimelineRow
483 comment={comment}
484 mark={
485 verdict === "approve" ? (
486 <CircleCheck size={16} className="text-success" />
487 ) : (
488 <CircleSlash size={16} className="text-danger" />
489 )
490 }
491 >
492 <span className={VERDICTS[verdict].style}>{VERDICTS[verdict].label}</span>
493 {comment.advisory && <AdvisoryChip />}
494 </TimelineRow>
495 );
496}
497
498/** Edit and Delete, under a comment the viewer may change. */
499function CommentActions({
500 comment,
501 can,
502 error,
503}: {
504 comment: Comment;
505 can: { edit: boolean; delete: boolean };
506 error?: string | null;
507}) {
508 return (
509 <div className="mt-3 border-t border-line pt-2 text-sm">
510 <div className="flex flex-wrap items-center gap-1">
511 {can.edit && (
512 <details className="open:order-last open:w-full">
513 <summary className="inline-flex cursor-pointer list-none items-center gap-1 rounded-md px-1.5 py-1 text-xs text-faint hover:text-fg">
514 <Pencil size={12} />
515 Edit
516 </summary>
517 <Form method="post" className="mt-2 space-y-2">
518 <input type="hidden" name="action" value="edit-comment" />
519 <input type="hidden" name="comment" value={comment.id} />
520 <MentionTextarea name="body" rows={4} defaultValue={comment.body} />
521 <SubmitButton match={{ action: "edit-comment", comment: comment.id }} pending="Saving…">
522 Save
523 </SubmitButton>
524 </Form>
525 </details>
526 )}
527 {can.delete && (
528 <AlertDialog>
529 <AlertDialogTrigger asChild>
530 <Button
531 type="button"
532 variant="ghost"
533 size="inline"
534 className="gap-1 px-1.5 py-1 text-xs text-faint hover:text-danger font-normal"
535 >
536 <Trash2 size={12} />
537 Delete
538 </Button>
539 </AlertDialogTrigger>
540 <AlertDialogContent>
541 <Form method="post" className="grid gap-4">
542 <input type="hidden" name="action" value="delete-comment" />
543 <input type="hidden" name="comment" value={comment.id} />
544 <AlertDialogHeader>
545 <AlertDialogTitle>Delete this comment?</AlertDialogTitle>
546 <AlertDialogDescription>It is removed from the conversation for everyone. This cannot be undone.</AlertDialogDescription>
547 </AlertDialogHeader>
548 <AlertDialogFooter>
549 <AlertDialogCancel>Cancel</AlertDialogCancel>
550 <AlertDialogAction asChild>
551 <button type="submit">
552 <Trash2 size={14} />
553 Delete comment
554 </button>
555 </AlertDialogAction>
556 </AlertDialogFooter>
557 </Form>
558 </AlertDialogContent>
559 </AlertDialog>
560 )}
561 </div>
562 {error && <ErrorText>{error}</ErrorText>}
563 </div>
564 );
565}
566
567/** Something else in a conversation, put among its comments by when it happened: a session, say. */
568export type TimelineExtra = { id: string; at: string; node: ReactNode };
569
570/**
571 * The conversation of an issue or a pull request, oldest first: what
572 * people and agents wrote, and between those, what happened, with
573 * `extra` (its sessions) where each happened.
574 */
575export function CommentList({
576 comments,
577 review,
578 base,
579 viewerId,
580 viewerName = null,
581 canModerate = false,
582 failed,
583 extra = [],
584}: {
585 comments: Comment[];
586 review?: Review;
587 /** The repository's path, so that `#12` in an event can be linked. */
588 base?: string;
589 /** The signed-in viewer, who may edit and delete their own comments. */
590 viewerId?: string | null;
591 /** Maintain and up: may edit and delete anyone's comments. */
592 canModerate?: boolean;
593 /** Why editing or deleting a comment was refused, under that comment. */
594 failed?: { comment: string; error?: string | null } | null;
595 /** The signed-in viewer's username, who may react; none to only show reactions. */
596 viewerName?: string | null;
597 extra?: TimelineExtra[];
598}) {
599 // Each extra goes before the first comment written after it.
600 const later = [...extra].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
601 const before = (at: string) => {
602 const due: TimelineExtra[] = [];
603 while (later.length > 0 && Date.parse(later[0].at) <= Date.parse(at)) due.push(later.shift()!);
604 return due.map((one) => <Fragment key={one.id}>{one.node}</Fragment>);
605 };
606 return (
607 <>
608 {comments.map((comment) => (
609 <Fragment key={comment.id}>
610 {before(comment.createdAt)}
611 {entry(comment)}
612 </Fragment>
613 ))}
614 {later.map((one) => (
615 <Fragment key={one.id}>{one.node}</Fragment>
616 ))}
617 </>
618 );
619
620 function entry(comment: Comment) {
621 if (comment.kind === "event") {
622 return <TimelineEvent key={comment.id} comment={comment} base={base} filesUrl={review?.changesUrl} />;
623 }
624 const verdict = comment.verdict;
625 // A verdict with nothing said and no line it is about is one line, not a card.
626 if (verdict && !comment.body.trim() && !comment.path) {
627 return <TimelineVerdict key={comment.id} comment={comment} verdict={verdict} />;
628 }
629 const can = mayChangeComment(comment, viewerId, canModerate);
630 return (
631 <TimelineItem
632 key={comment.id}
633 id={comment.id}
634 author={comment.author.username}
635 agent={
636 comment.agent && {
637 ref: comment.agent,
638 actingFor: comment.actingFor?.username,
639 }
640 }
641 at={comment.createdAt}
642 action={
643 <>
644 {verdict ? (
645 <Verdict verdict={verdict} />
646 ) : (
647 <span>{comment.advisory ? "reviewed" : "commented"}</span>
648 )}
649 {comment.advisory && <AdvisoryChip />}
650 </>
651 }
652 edited={comment.editedAt}
653 aside={
654 comment.path &&
655 review && (
656 <Link
657 to={`${review.changesUrl}#file-${comment.path}`}
658 className="block truncate font-mono text-xs text-faint hover:text-fg"
659 >
660 {comment.path}
661 {comment.line != null && `:${comment.line}`}
662 </Link>
663 )
664 }
665 >
666 {comment.body && <Markdown source={comment.body} repo={repoAt(base)} />}
667 <Reactions reactions={comment.reactions} commentId={comment.id} viewer={viewerName} className="mt-3" />
668 {(can.edit || can.delete) && (
669 <CommentActions comment={comment} can={can} error={failed?.comment === comment.id ? failed.error : null} />
670 )}
671 </TimelineItem>
672 );
673 }
674}
675
676/** Where a signed-in person writes a comment, or a review. */
677export function CommentForm({
678 author,
679 resetKey,
680 review,
681 handles,
682}: {
683 /** The viewer's username, or null if they are signed out. */
684 author: string | null;
685 /** Changes when a comment is added, which clears the box. */
686 resetKey: number;
687 review?: Review;
688 /** Teams the box offers to mention, as `@workspace/team`. */
689 handles?: readonly string[];
690}) {
691 if (!author) {
692 return (
693 <p className="text-sm text-muted">
694 <Link to="/login" className="text-fg underline underline-offset-4">
695 Sign in
696 </Link>{" "}
697 to comment.
698 </p>
699 );
700 }
701 return (
702 <div className="flex gap-3">
703 <span className="mt-1 hidden shrink-0 sm:block">
704 <Avatar name={author} size={32} />
705 </span>
706 <Form method="post" className="min-w-0 grow space-y-2" key={resetKey}>
707 <input type="hidden" name="action" value="comment" />
708 <MentionTextarea
709 name="body"
710 handles={handles}
711 rows={3}
712 placeholder={
713 review?.canJudge
714 ? "Leave a comment, or a review. Markdown works."
715 : "Leave a comment. Markdown works."
716 }
717 />
718 <div className="flex flex-wrap gap-2">
719 {/* A plain comment's verdict is none the action knows, so each button tells its own post apart. */}
720 <SubmitButton name="verdict" value="comment" match={{ action: "comment" }} pending="Commenting…">
721 Comment
722 </SubmitButton>
723 {review?.canJudge && (
724 <>
725 <SubmitButton variant="outline" name="verdict" value="approve" match={{ action: "comment" }} pending="Approving…">
726 <CircleCheck size={14} className="text-success" />
727 Approve
728 </SubmitButton>
729 <SubmitButton
730 variant="outline"
731 name="verdict"
732 value="request_changes"
733 match={{ action: "comment" }}
734 pending="Requesting changes…"
735 >
736 <CircleSlash size={14} className="text-danger" />
737 Request changes
738 </SubmitButton>
739 </>
740 )}
741 </div>
742 </Form>
743 </div>
744 );
745}
746
747/**
748 * Checkboxes for choosing people: the workspace's members, anyone already
749 * chosen, and a box for other usernames. Posts `name` for each ticked and
750 * `others` for the rest.
751 */
752export function PeoplePicker({
753 name,
754 members,
755 chosen,
756 placeholder = "Other usernames, comma separated",
757}: {
758 name: string;
759 members: string[];
760 chosen: string[];
761 placeholder?: string;
762}) {
763 const people = [...new Set([...members, ...chosen])];
764 return (
765 <>
766 {people.length > 0 && (
767 <div className="space-y-1.5">
768 {people.map((person) => (
769 <CheckboxOption
770 key={person}
771 name={name}
772 value={person}
773 defaultChecked={chosen.includes(person)}
774 className="items-center"
775 labelClassName="flex items-center gap-2"
776 label={
777 <>
778 <Avatar name={person} size={18} />
779 <span className="font-mono text-xs">{person}</span>
780 </>
781 }
782 />
783 ))}
784 </div>
785 )}
786 <input
787 name="others"
788 placeholder={placeholder}
789 autoComplete="off"
790 data-1p-ignore
791 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
792 />
793 </>
794 );
795}
796
797/**
798 * Where each reviewer stands: their most recent verdict. An agent's review
799 * is advisory and is not one of them.
800 */
801export function verdicts(comments: Comment[]): { reviewer: string; verdict: NonNullable<Comment["verdict"]> }[] {
802 const latest = new Map<string, NonNullable<Comment["verdict"]>>();
803 for (const comment of comments) {
804 if (comment.verdict && !comment.agent && !comment.advisory) latest.set(comment.author.username, comment.verdict);
805 }
806 return [...latest].map(([reviewer, verdict]) => ({ reviewer, verdict }));
807}
808
809/** `2 files +12 −3`: the size of a pull request's change. */
810export function ChangeSize({ files }: { files: Pull["files"] }) {
811 if (files.length === 0) return null;
812 const additions = files.reduce((sum, file) => sum + file.additions, 0);
813 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
814 return (
815 <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-faint">
816 <span>
817 {files.length} {files.length === 1 ? "file" : "files"}
818 </span>
819 <span className="text-success">+{additions}</span>
820 <span className="text-danger">−{deletions}</span>
821 </span>
822 );
823}
824
825/** The words of a Markdown text, without its markup, for a one-line preview. */
826export function plainText(markdown: string): string {
827 return markdown
828 .replace(/```[\s\S]*?```/g, " ")
829 .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, "")
830 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
831 .replace(/[*_`~]/g, "")
832 .replace(/\s+/g, " ")
833 .trim();
834}