Skip to content
787 linesCodeBlameRaw
1import { Bot, CircleCheck, CircleSlash, Pencil, Trash2 } from "lucide-react";
2import type { CSSProperties, 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 { IssueIcon, PullIcon } from "./work-icons";
12import { MentionTextarea } from "./mention-textarea";
13import { AgentAvatar } from "./agent-avatar";
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 className={
175 "rounded-md px-3 py-1.5 text-sm transition-colors " +
176 (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")
177 }
178 >
179 {label}
180 </Link>
181 );
182 return (
183 <div className="flex flex-wrap items-center justify-between gap-3">
184 <Card tone="plain" radius="lg" className="flex gap-1 p-1">
185 {tab("open", "Open")}
186 {tab("closed", "Closed")}
187 </Card>
188 {action}
189 </div>
190 );
191}
192
193const VERDICTS = {
194 approve: { label: "approved these changes", style: "text-success" },
195 request_changes: { label: "requested changes", style: "text-danger" },
196} as const;
197
198/**
199 * A person's name (or `children`) linking to their profile at `/u/<name>`.
200 * g1t, and names that are not usernames, stay plain text.
201 */
202export function PersonLink({
203 name,
204 className,
205 label,
206 children,
207}: {
208 name: string;
209 className?: string;
210 label?: string;
211 children?: ReactNode;
212}) {
213 // g1t itself has no profile; its name carries a small label instead, and
214 // its card says what it is.
215 if (name === "g1t" && !children) {
216 return (
217 <UserCard username="g1t">
218 <span tabIndex={0} className="inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
219 <span className={className}>g1t</span>
220 <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
221 </span>
222 </UserCard>
223 );
224 }
225 if (name === "g1t") {
226 return (
227 <UserCard username="g1t">
228 <span className={className}>{children}</span>
229 </UserCard>
230 );
231 }
232 // Ghost, a deleted account, has no profile and no card.
233 if (name === "ghost" || !/^[a-z0-9-]{1,39}$/i.test(name)) {
234 return <span className={className}>{children ?? name}</span>;
235 }
236 return (
237 <UserCard username={name}>
238 <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}>
239 {children ?? name}
240 </Link>
241 </UserCard>
242 );
243}
244
245/**
246 * Comments in order, then the box to add one. On a pull request, `review`
247 * says where its changes are shown and whether the viewer may give a
248 * verdict.
249 */
250/**
251 * One entry in a conversation: who, what they did and when, then what they
252 * wrote. The author's avatar sits beside it, as on any forge.
253 */
254export function TimelineItem({
255 author,
256 agent,
257 action,
258 at,
259 edited,
260 aside,
261 children,
262}: {
263 author: string;
264 /**
265 * Set when one of the workspace's agents wrote it, as itself: shown by
266 * its face and name with an Agent badge, linked to its page, and whom it
267 * acted for, in place of `author`.
268 */
269 agent?: { ref: AgentRef; href: string | null; actingFor?: string | null };
270 /** What they did, after their name: "commented", "opened this". */
271 action: ReactNode;
272 at?: string;
273 /** When what they wrote was last edited, if it was. */
274 edited?: string | null;
275 /** Shown at the right of the header. */
276 aside?: ReactNode;
277 children?: ReactNode;
278}) {
279 const face = (size: number) =>
280 agent ? (
281 <AgentAvatar agent={{ id: agent.ref.id, handle: agent.ref.handle, avatar_seed: agent.ref.avatarSeed, look: agent.ref.look }} size={size} />
282 ) : (
283 <Avatar name={author} size={size} />
284 );
285 return (
286 <div className="flex gap-3">
287 <span className="mt-1 hidden shrink-0 sm:block">
288 {agent ? (
289 agent.href ? (
290 <Link to={agent.href} aria-label={`${agent.ref.displayName}, an agent`}>
291 {face(32)}
292 </Link>
293 ) : (
294 face(32)
295 )
296 ) : (
297 <PersonLink name={author} label={`${author}'s profile`}>
298 {face(32)}
299 </PersonLink>
300 )}
301 </span>
302 <Card asChild className="min-w-0 grow overflow-hidden">
303 <article>
304 <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">
305 <span className="sm:hidden">{face(18)}</span>
306 {agent ? (
307 <AgentName agent={agent.ref} href={agent.href} />
308 ) : (
309 <PersonLink name={author} className="font-medium text-fg hover:underline" />
310 )}
311 {action}
312 {at && <TimeAgo at={at} />}
313 {agent?.actingFor && (
314 <span className="text-xs text-faint">
315 on behalf of{" "}
316 <PersonLink name={agent.actingFor} className="hover:text-fg hover:underline">
317 @{agent.actingFor}
318 </PersonLink>
319 </span>
320 )}
321 {edited && (
322 <Hint
323 label={
324 <>
325 Edited <TimeAgo at={edited} />
326 </>
327 }
328 >
329 <span tabIndex={0} className="text-xs text-faint">
330 edited
331 </span>
332 </Hint>
333 )}
334 {aside && <span className="ml-auto min-w-0">{aside}</span>}
335 </header>
336 {children && <div className="px-4 py-3">{children}</div>}
337 </article>
338 </Card>
339 </div>
340 );
341}
342
343/** An agent's name, linked to its page when there is one, with an Agent badge. */
344function AgentName({ agent, href }: { agent: AgentRef; href: string | null }) {
345 const name = <span className="font-medium text-fg">{agent.displayName}</span>;
346 return (
347 <span className="inline-flex items-center gap-1.5">
348 {href ? (
349 <Link to={href} className="hover:underline">
350 {name}
351 </Link>
352 ) : (
353 name
354 )}
355 <Badge tone="accent" className="px-1.5 text-[0.625rem] leading-[1.35]">
356 Agent
357 </Badge>
358 </span>
359 );
360}
361
362/**
363 * An agent's page, `/<workspace>/-/agents/<handle>`, from the repository's
364 * path (`/acme/web`): its agents are its workspace's.
365 */
366export function agentPage(base: string | undefined, handle: string): string | null {
367 const owner = base?.split("/").filter(Boolean)[0];
368 return owner ? `/${owner}/-/agents/${handle}` : null;
369}
370
371/** That an agent's review is advisory, and why it counts toward nothing. */
372function AdvisoryChip() {
373 return (
374 <Hint label="Agent reviews don't count toward required approvals">
375 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
376 <Badge tone="neutral">Advisory</Badge>
377 </span>
378 </Hint>
379 );
380}
381
382type Review = { changesUrl: string; canJudge: boolean };
383
384/**
385 * Text with each `#12` linked to the issue or pull request of that number,
386 * and each file named in backticks shown as code, linked to its diff when
387 * `filesUrl` says where the changes are.
388 */
389function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) {
390 if (!base) return <>{text}</>;
391 return (
392 <>
393 {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) =>
394 /^`[^`]+`$/.test(part) ? (
395 filesUrl ? (
396 <Link
397 key={index}
398 to={`${filesUrl}#file-${part.slice(1, -1)}`}
399 className="font-mono text-xs text-fg hover:underline"
400 >
401 {part.slice(1, -1)}
402 </Link>
403 ) : (
404 <code key={index} className="font-mono text-xs text-fg">
405 {part.slice(1, -1)}
406 </code>
407 )
408 ) : /^#\d+$/.test(part) ? (
409 // Issues and pull requests share numbers; the issue page forwards.
410 <Link
411 key={index}
412 to={`${base}/issues/${part.slice(1)}`}
413 className="font-medium text-fg hover:underline"
414 >
415 {part}
416 </Link>
417 ) : (
418 part
419 ),
420 )}
421 </>
422 );
423}
424
425/** Something that happened, as one line on the conversation's rail. */
426function TimelineEvent({ comment, base, filesUrl }: { comment: Comment; base?: string; filesUrl?: string }) {
427 return (
428 <div className="flex items-center gap-3 text-sm text-muted">
429 <span className="hidden w-8 shrink-0 justify-center sm:flex">
430 <span className="size-2 rounded-full border border-line-strong bg-bg" />
431 </span>
432 <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
433 <Avatar name={comment.author.username} size={16} />
434 <PersonLink name={comment.author.username} className="font-medium text-fg hover:underline" />
435 <span>
436 <WithReferences text={comment.body} base={base} filesUrl={filesUrl} />
437 </span>
438 <span className="text-faint">
439 · <TimeAgo at={comment.createdAt} />
440 </span>
441 </span>
442 </div>
443 );
444}
445
446/** Edit and Delete, under a comment the viewer may change. */
447function CommentActions({
448 comment,
449 can,
450 error,
451}: {
452 comment: Comment;
453 can: { edit: boolean; delete: boolean };
454 error?: string | null;
455}) {
456 return (
457 <div className="mt-3 border-t border-line pt-2 text-sm">
458 <div className="flex flex-wrap items-center gap-1">
459 {can.edit && (
460 <details className="open:order-last open:w-full">
461 <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">
462 <Pencil size={12} />
463 Edit
464 </summary>
465 <Form method="post" className="mt-2 space-y-2">
466 <input type="hidden" name="action" value="edit-comment" />
467 <input type="hidden" name="comment" value={comment.id} />
468 <MentionTextarea name="body" rows={4} defaultValue={comment.body} />
469 <SubmitButton match={{ action: "edit-comment", comment: comment.id }} pending="Saving…">
470 Save
471 </SubmitButton>
472 </Form>
473 </details>
474 )}
475 {can.delete && (
476 <AlertDialog>
477 <AlertDialogTrigger asChild>
478 <Button
479 type="button"
480 variant="ghost"
481 size="inline"
482 className="gap-1 px-1.5 py-1 text-xs text-faint hover:text-danger font-normal"
483 >
484 <Trash2 size={12} />
485 Delete
486 </Button>
487 </AlertDialogTrigger>
488 <AlertDialogContent>
489 <Form method="post" className="grid gap-4">
490 <input type="hidden" name="action" value="delete-comment" />
491 <input type="hidden" name="comment" value={comment.id} />
492 <AlertDialogHeader>
493 <AlertDialogTitle>Delete this comment?</AlertDialogTitle>
494 <AlertDialogDescription>It is removed from the conversation for everyone. This cannot be undone.</AlertDialogDescription>
495 </AlertDialogHeader>
496 <AlertDialogFooter>
497 <AlertDialogCancel>Cancel</AlertDialogCancel>
498 <AlertDialogAction asChild>
499 <button type="submit">
500 <Trash2 size={14} />
501 Delete comment
502 </button>
503 </AlertDialogAction>
504 </AlertDialogFooter>
505 </Form>
506 </AlertDialogContent>
507 </AlertDialog>
508 )}
509 </div>
510 {error && <ErrorText>{error}</ErrorText>}
511 </div>
512 );
513}
514
515/**
516 * The conversation of an issue or a pull request, oldest first: what
517 * people and agents wrote, and between those, what happened.
518 */
519export function CommentList({
520 comments,
521 review,
522 base,
523 viewerId,
524 canModerate = false,
525 failed,
526}: {
527 comments: Comment[];
528 review?: Review;
529 /** The repository's path, so that `#12` in an event can be linked. */
530 base?: string;
531 /** The signed-in viewer, who may edit and delete their own comments. */
532 viewerId?: string | null;
533 /** Maintain and up: may edit and delete anyone's comments. */
534 canModerate?: boolean;
535 /** Why editing or deleting a comment was refused, under that comment. */
536 failed?: { comment: string; error?: string | null } | null;
537}) {
538 return (
539 <>
540 {comments.map((comment) => {
541 if (comment.kind === "event") {
542 return <TimelineEvent key={comment.id} comment={comment} base={base} filesUrl={review?.changesUrl} />;
543 }
544 const verdict = comment.verdict && VERDICTS[comment.verdict];
545 const can = mayChangeComment(comment, viewerId, canModerate);
546 return (
547 <TimelineItem
548 key={comment.id}
549 author={comment.author.username}
550 agent={
551 comment.agent && {
552 ref: comment.agent,
553 href: agentPage(base, comment.agent.handle),
554 actingFor: comment.actingFor?.username,
555 }
556 }
557 at={comment.createdAt}
558 action={
559 <>
560 {verdict ? (
561 <span className={`flex items-center gap-1 font-medium ${verdict.style}`}>
562 {comment.verdict === "approve" ? (
563 <CircleCheck size={14} />
564 ) : (
565 <CircleSlash size={14} />
566 )}
567 {verdict.label}
568 </span>
569 ) : (
570 <span>{comment.advisory ? "reviewed" : "commented"}</span>
571 )}
572 {comment.advisory && <AdvisoryChip />}
573 </>
574 }
575 edited={comment.editedAt}
576 aside={
577 comment.path &&
578 review && (
579 <Link
580 to={`${review.changesUrl}#file-${comment.path}`}
581 className="block truncate font-mono text-xs text-faint hover:text-fg"
582 >
583 {comment.path}
584 {comment.line != null && `:${comment.line}`}
585 </Link>
586 )
587 }
588 >
589 {comment.body && <Markdown source={comment.body} repo={repoAt(base)} />}
590 {(can.edit || can.delete) && (
591 <CommentActions comment={comment} can={can} error={failed?.comment === comment.id ? failed.error : null} />
592 )}
593 </TimelineItem>
594 );
595 })}
596 </>
597 );
598}
599
600/** Where a signed-in person writes a comment, or a review. */
601export function CommentForm({
602 author,
603 resetKey,
604 review,
605 handles,
606}: {
607 /** The viewer's username, or null if they are signed out. */
608 author: string | null;
609 /** Changes when a comment is added, which clears the box. */
610 resetKey: number;
611 review?: Review;
612 /** Teams the box offers to mention, as `@workspace/team`. */
613 handles?: readonly string[];
614}) {
615 if (!author) {
616 return (
617 <p className="text-sm text-muted">
618 <Link to="/login" className="text-fg underline underline-offset-4">
619 Sign in
620 </Link>{" "}
621 to comment.
622 </p>
623 );
624 }
625 return (
626 <div className="flex gap-3">
627 <span className="mt-1 hidden shrink-0 sm:block">
628 <Avatar name={author} size={32} />
629 </span>
630 <Form method="post" className="min-w-0 grow space-y-2" key={resetKey}>
631 <input type="hidden" name="action" value="comment" />
632 <MentionTextarea
633 name="body"
634 handles={handles}
635 rows={3}
636 placeholder={
637 review?.canJudge
638 ? "Leave a comment, or a review. Markdown works."
639 : "Leave a comment. Markdown works."
640 }
641 />
642 <div className="flex flex-wrap gap-2">
643 {/* A plain comment's verdict is none the action knows, so each button tells its own post apart. */}
644 <SubmitButton name="verdict" value="comment" match={{ action: "comment" }} pending="Commenting…">
645 Comment
646 </SubmitButton>
647 {review?.canJudge && (
648 <>
649 <SubmitButton variant="outline" name="verdict" value="approve" match={{ action: "comment" }} pending="Approving…">
650 <CircleCheck size={14} className="text-success" />
651 Approve
652 </SubmitButton>
653 <SubmitButton
654 variant="outline"
655 name="verdict"
656 value="request_changes"
657 match={{ action: "comment" }}
658 pending="Requesting changes…"
659 >
660 <CircleSlash size={14} className="text-danger" />
661 Request changes
662 </SubmitButton>
663 </>
664 )}
665 </div>
666 </Form>
667 </div>
668 );
669}
670
671/**
672 * Checkboxes for choosing people: the workspace's members, anyone already
673 * chosen, and a box for other usernames. Posts `name` for each ticked and
674 * `others` for the rest.
675 */
676export function PeoplePicker({
677 name,
678 members,
679 chosen,
680 placeholder = "Other usernames, comma separated",
681}: {
682 name: string;
683 members: string[];
684 chosen: string[];
685 placeholder?: string;
686}) {
687 const people = [...new Set([...members, ...chosen])];
688 return (
689 <>
690 {people.length > 0 && (
691 <div className="space-y-1.5">
692 {people.map((person) => (
693 <CheckboxOption
694 key={person}
695 name={name}
696 value={person}
697 defaultChecked={chosen.includes(person)}
698 className="items-center"
699 labelClassName="flex items-center gap-2"
700 label={
701 <>
702 <Avatar name={person} size={18} />
703 <span className="font-mono text-xs">{person}</span>
704 </>
705 }
706 />
707 ))}
708 </div>
709 )}
710 <input
711 name="others"
712 placeholder={placeholder}
713 autoComplete="off"
714 data-1p-ignore
715 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"
716 />
717 </>
718 );
719}
720
721/**
722 * Where each reviewer stands: their most recent verdict. An agent's review
723 * is advisory and is not one of them.
724 */
725export function verdicts(comments: Comment[]): { reviewer: string; verdict: NonNullable<Comment["verdict"]> }[] {
726 const latest = new Map<string, NonNullable<Comment["verdict"]>>();
727 for (const comment of comments) {
728 if (comment.verdict && !comment.agent && !comment.advisory) latest.set(comment.author.username, comment.verdict);
729 }
730 return [...latest].map(([reviewer, verdict]) => ({ reviewer, verdict }));
731}
732
733/**
734 * Each agent's latest review, advisory: shown beside people's verdicts but
735 * never one of them. `verdict` is null for a review that only commented.
736 */
737export function agentReviews(comments: Comment[]): { agent: AgentRef; verdict: Comment["verdict"] }[] {
738 const latest = new Map<string, { agent: AgentRef; verdict: Comment["verdict"] }>();
739 for (const comment of comments) {
740 if (comment.agent && comment.advisory) {
741 latest.delete(comment.agent.id);
742 latest.set(comment.agent.id, { agent: comment.agent, verdict: comment.verdict });
743 }
744 }
745 return [...latest.values()];
746}
747
748/** An agent's advisory review, in a pull request's summary of reviews. */
749export function AgentReviewLine({ agent, verdict, base }: { agent: AgentRef; verdict: Comment["verdict"]; base?: string }) {
750 const href = agentPage(base, agent.handle);
751 const said = verdict === "approve" ? "approved" : verdict === "request_changes" ? "requested changes" : "reviewed";
752 return (
753 <span className="flex items-center gap-1.5 text-muted">
754 <AgentAvatar agent={{ id: agent.id, handle: agent.handle, avatar_seed: agent.avatarSeed, look: agent.look }} size={16} />
755 <AgentName agent={agent} href={href} />
756 <span>{said}</span>
757 <AdvisoryChip />
758 </span>
759 );
760}
761
762/** `2 files +12 −3`: the size of a pull request's change. */
763export function ChangeSize({ files }: { files: Pull["files"] }) {
764 if (files.length === 0) return null;
765 const additions = files.reduce((sum, file) => sum + file.additions, 0);
766 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
767 return (
768 <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-faint">
769 <span>
770 {files.length} {files.length === 1 ? "file" : "files"}
771 </span>
772 <span className="text-success">+{additions}</span>
773 <span className="text-danger">−{deletions}</span>
774 </span>
775 );
776}
777
778/** The words of a Markdown text, without its markup, for a one-line preview. */
779export function plainText(markdown: string): string {
780 return markdown
781 .replace(/```[\s\S]*?```/g, " ")
782 .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, "")
783 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
784 .replace(/[*_`~]/g, "")
785 .replace(/\s+/g, " ")
786 .trim();
787}