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.

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