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.

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