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.

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