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