flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/components/work.tsx

541 lines16,686 bytesCodeBlame
1import {
2 Bot,
3 CircleCheck,
4 CircleDot,
5 CircleSlash,
6 GitMerge,
7 GitPullRequest,
8 GitPullRequestClosed,
9 GitPullRequestDraft,
10} from "lucide-react";
11import type { CSSProperties, ReactNode } from "react";
12import { Form, Link } from "react-router";
13
14import type { Comment, Issue, Pull, State } from "@g1t/contracts";
15
16import { repoAt } from "../lib/markdown-plugins";
17import { Markdown } from "./markdown";
18import { MentionTextarea } from "./mention-textarea";
19import { Avatar, Button, TimeAgo } from "./ui";
20import { CheckboxOption } from "./ui/checkbox";
21
22/** Hues for the labels every repository starts with. */
23const LABEL_HUES: Record<string, number> = {
24 bug: 25,
25 feature: 150,
26 docs: 240,
27 chore: 80,
28 question: 310,
29};
30
31/** A stable hue for any other label, from its name. */
32function hueFor(name: string): number {
33 let hash = 0;
34 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
35 return hash;
36}
37
38export function Label({ name }: { name: string }) {
39 const hue = LABEL_HUES[name] ?? hueFor(name);
40 const style: CSSProperties = {
41 color: `oklch(0.84 0.11 ${hue})`,
42 borderColor: `oklch(0.84 0.11 ${hue} / 0.35)`,
43 backgroundColor: `oklch(0.84 0.11 ${hue} / 0.1)`,
44 };
45 return (
46 <span
47 style={style}
48 className="inline-flex shrink-0 items-center rounded-full border px-2 py-px text-xs font-medium"
49 >
50 {name}
51 </span>
52 );
53}
54
55/** The people an issue is assigned to, as overlapping avatars. */
56export function AssigneeStack({ people }: { people: string[] }) {
57 if (people.length === 0) return null;
58 const shown = people.slice(0, 3);
59 return (
60 <span
61 className="flex shrink-0 items-center"
62 title={`Assigned to ${people.join(", ")}`}
63 >
64 {shown.map((name, index) => (
65 <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`}>
66 <Avatar name={name} size={18} />
67 </span>
68 ))}
69 {people.length > shown.length && (
70 <span className="ml-1 text-xs text-faint">+{people.length - shown.length}</span>
71 )}
72 </span>
73 );
74}
75
76/** The agent working on an issue now. */
77export function Assignee({ agent }: { agent: string }) {
78 return (
79 <span
80 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"
81 title={`Assigned to ${agent}`}
82 >
83 <Bot size={12} />
84 {agent}
85 </span>
86 );
87}
88
89export function IssueIcon({
90 issue,
91 size = 16,
92}: {
93 issue: Pick<Issue, "state" | "reason">;
94 size?: number;
95}) {
96 if (issue.state === "open") return <CircleDot size={size} className="shrink-0 text-accent" />;
97 if (issue.reason === "not_planned") {
98 return <CircleSlash size={size} className="shrink-0 text-faint" />;
99 }
100 return <CircleCheck size={size} className="shrink-0 text-merged" />;
101}
102
103export function PullIcon({ status, size = 16 }: { status: Pull["status"]; size?: number }) {
104 if (status === "merged") return <GitMerge size={size} className="shrink-0 text-merged" />;
105 if (status === "closed") {
106 return <GitPullRequestClosed size={size} className="shrink-0 text-danger" />;
107 }
108 if (status === "draft") {
109 return <GitPullRequestDraft size={size} className="shrink-0 text-faint" />;
110 }
111 return <GitPullRequest size={size} className="shrink-0 text-accent" />;
112}
113
114const PILL = "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium";
115
116/** The large state badge at the top of an issue. */
117export function IssueState({ issue }: { issue: Pick<Issue, "state" | "reason"> }) {
118 const style =
119 issue.state === "open"
120 ? "border-accent/30 bg-accent/10 text-accent"
121 : issue.reason === "not_planned"
122 ? "border-line bg-surface text-muted"
123 : "border-merged/30 bg-merged/10 text-merged";
124 return (
125 <span className={`${PILL} ${style}`}>
126 <IssueIcon issue={issue} size={14} />
127 {issue.state === "open"
128 ? "Open"
129 : issue.reason === "not_planned"
130 ? "Closed as not planned"
131 : "Closed"}
132 </span>
133 );
134}
135
136const PULL_STYLES: Record<Pull["status"], string> = {
137 draft: "border-line bg-surface text-muted",
138 open: "border-accent/30 bg-accent/10 text-accent",
139 merged: "border-merged/30 bg-merged/10 text-merged",
140 closed: "border-danger/30 bg-danger/10 text-danger",
141};
142
143const PULL_NAMES: Record<Pull["status"], string> = {
144 draft: "Draft",
145 open: "Open",
146 merged: "Merged",
147 closed: "Closed",
148};
149
150export function PullState({ status }: { status: Pull["status"] }) {
151 return (
152 <span className={`${PILL} ${PULL_STYLES[status]}`}>
153 <PullIcon status={status} size={14} />
154 {PULL_NAMES[status]}
155 </span>
156 );
157}
158
159/** The open and closed switch above a list. `to` is the list's own path. */
160export function StateTabs({
161 to,
162 state,
163 query = "",
164 action,
165}: {
166 to: string;
167 state: State;
168 /** Other query parameters to keep, e.g. `label=bug`. */
169 query?: string;
170 action?: ReactNode;
171}) {
172 const link = (value: State) =>
173 `${to}?${[value === "open" ? "" : "state=closed", query].filter(Boolean).join("&")}`;
174 const tab = (value: State, label: string) => (
175 <Link
176 to={link(value)}
177 className={
178 "rounded-md px-3 py-1.5 text-sm transition-colors " +
179 (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")
180 }
181 >
182 {label}
183 </Link>
184 );
185 return (
186 <div className="flex flex-wrap items-center justify-between gap-3">
187 <div className="flex gap-1 rounded-lg border border-line p-1">
188 {tab("open", "Open")}
189 {tab("closed", "Closed")}
190 </div>
191 {action}
192 </div>
193 );
194}
195
196const VERDICTS = {
197 approve: { label: "approved these changes", style: "text-accent" },
198 request_changes: { label: "requested changes", style: "text-danger" },
199} as const;
200
201/** How g1t's own agents are named; they have no profile to link to. */
202const AGENTS = new Set(["g1t-agent", "g1t agent", "g1t"]);
203
204/**
205 * A person's name (or `children`) linking to their profile at `/u/<name>`.
206 * g1t's agents, and names that are not usernames, stay plain text.
207 */
208export function PersonLink({
209 name,
210 className,
211 label,
212 children,
213}: {
214 name: string;
215 className?: string;
216 label?: string;
217 children?: ReactNode;
218}) {
219 if (AGENTS.has(name) || !/^[a-z0-9-]{1,39}$/i.test(name)) {
220 return <span className={className}>{children ?? name}</span>;
221 }
222 return (
223 <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}>
224 {children ?? name}
225 </Link>
226 );
227}
228
229/**
230 * Comments in order, then the box to add one. On a pull request, `review`
231 * says where its changes are shown and whether the viewer may give a
232 * verdict.
233 */
234/**
235 * One entry in a conversation: who, what they did and when, then what they
236 * wrote. The author's avatar sits beside it, as on any forge.
237 */
238export function TimelineItem({
239 author,
240 action,
241 at,
242 aside,
243 children,
244}: {
245 author: string;
246 /** What they did, after their name: "commented", "opened this". */
247 action: ReactNode;
248 at?: string;
249 /** Shown at the right of the header. */
250 aside?: ReactNode;
251 children?: ReactNode;
252}) {
253 return (
254 <div className="flex gap-3">
255 <span className="mt-1 hidden shrink-0 sm:block">
256 <PersonLink name={author} label={`${author}'s profile`}>
257 <Avatar name={author} size={32} />
258 </PersonLink>
259 </span>
260 <article className="min-w-0 grow overflow-hidden rounded-xl border border-line bg-surface">
261 <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">
262 <span className="sm:hidden">
263 <Avatar name={author} size={18} />
264 </span>
265 <PersonLink name={author} className="font-medium text-fg hover:underline" />
266 {action}
267 {at && <TimeAgo at={at} />}
268 {aside && <span className="ml-auto min-w-0">{aside}</span>}
269 </header>
270 {children && <div className="px-4 py-3">{children}</div>}
271 </article>
272 </div>
273 );
274}
275
276type Review = { changesUrl: string; canJudge: boolean };
277
278/**
279 * Text with each `#12` linked to the issue or pull request of that number,
280 * and each file named in backticks shown as code, linked to its diff when
281 * `filesUrl` says where the changes are.
282 */
283function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) {
284 if (!base) return <>{text}</>;
285 return (
286 <>
287 {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) =>
288 /^`[^`]+`$/.test(part) ? (
289 filesUrl ? (
290 <Link
291 key={index}
292 to={`${filesUrl}#file-${part.slice(1, -1)}`}
293 className="font-mono text-xs text-fg hover:underline"
294 >
295 {part.slice(1, -1)}
296 </Link>
297 ) : (
298 <code key={index} className="font-mono text-xs text-fg">
299 {part.slice(1, -1)}
300 </code>
301 )
302 ) : /^#\d+$/.test(part) ? (
303 // Issues and pull requests share numbers; the issue page forwards.
304 <Link
305 key={index}
306 to={`${base}/issues/${part.slice(1)}`}
307 className="font-medium text-fg hover:underline"
308 >
309 {part}
310 </Link>
311 ) : (
312 part
313 ),
314 )}
315 </>
316 );
317}
318
319/** Something that happened, as one line on the conversation's rail. */
320function TimelineEvent({ comment, base, filesUrl }: { comment: Comment; base?: string; filesUrl?: string }) {
321 return (
322 <div className="flex items-center gap-3 text-sm text-muted">
323 <span className="hidden w-8 shrink-0 justify-center sm:flex">
324 <span className="size-2 rounded-full border border-line-strong bg-bg" />
325 </span>
326 <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
327 <Avatar name={comment.author.username} size={16} />
328 <PersonLink name={comment.author.username} className="font-medium text-fg hover:underline" />
329 <span>
330 <WithReferences text={comment.body} base={base} filesUrl={filesUrl} />
331 </span>
332 <span className="text-faint">
333 · <TimeAgo at={comment.createdAt} />
334 </span>
335 </span>
336 </div>
337 );
338}
339
340/**
341 * The conversation of an issue or a pull request, oldest first: what
342 * people and agents wrote, and between those, what happened.
343 */
344export function CommentList({
345 comments,
346 review,
347 base,
348}: {
349 comments: Comment[];
350 review?: Review;
351 /** The repository's path, so that `#12` in an event can be linked. */
352 base?: string;
353}) {
354 return (
355 <>
356 {comments.map((comment) => {
357 if (comment.kind === "event") {
358 return <TimelineEvent key={comment.id} comment={comment} base={base} filesUrl={review?.changesUrl} />;
359 }
360 const verdict = comment.verdict && VERDICTS[comment.verdict];
361 return (
362 <TimelineItem
363 key={comment.id}
364 author={comment.author.username}
365 at={comment.createdAt}
366 action={
367 verdict ? (
368 <span className={`flex items-center gap-1 font-medium ${verdict.style}`}>
369 {comment.verdict === "approve" ? (
370 <CircleCheck size={14} />
371 ) : (
372 <CircleSlash size={14} />
373 )}
374 {verdict.label}
375 </span>
376 ) : (
377 <span>commented</span>
378 )
379 }
380 aside={
381 comment.path &&
382 review && (
383 <Link
384 to={`${review.changesUrl}#file-${comment.path}`}
385 className="block truncate font-mono text-xs text-faint hover:text-fg"
386 >
387 {comment.path}
388 {comment.line != null && `:${comment.line}`}
389 </Link>
390 )
391 }
392 >
393 {comment.body && <Markdown source={comment.body} repo={repoAt(base)} />}
394 </TimelineItem>
395 );
396 })}
397 </>
398 );
399}
400
401/** Where a signed-in person writes a comment, or a review. */
402export function CommentForm({
403 author,
404 resetKey,
405 review,
406}: {
407 /** The viewer's username, or null if they are signed out. */
408 author: string | null;
409 /** Changes when a comment is added, which clears the box. */
410 resetKey: number;
411 review?: Review;
412}) {
413 if (!author) {
414 return (
415 <p className="text-sm text-muted">
416 <Link to="/login" className="text-fg underline underline-offset-4">
417 Sign in
418 </Link>{" "}
419 to comment.
420 </p>
421 );
422 }
423 return (
424 <div className="flex gap-3">
425 <span className="mt-1 hidden shrink-0 sm:block">
426 <Avatar name={author} size={32} />
427 </span>
428 <Form method="post" className="min-w-0 grow space-y-2" key={resetKey}>
429 <input type="hidden" name="action" value="comment" />
430 <MentionTextarea
431 name="body"
432 rows={3}
433 placeholder={
434 review?.canJudge
435 ? "Leave a comment, or a review. Markdown works."
436 : "Leave a comment. Markdown works."
437 }
438 />
439 <div className="flex flex-wrap gap-2">
440 <Button type="submit">Comment</Button>
441 {review?.canJudge && (
442 <>
443 <Button variant="quiet" type="submit" name="verdict" value="approve">
444 <CircleCheck size={14} className="text-accent" />
445 Approve
446 </Button>
447 <Button variant="quiet" type="submit" name="verdict" value="request_changes">
448 <CircleSlash size={14} className="text-danger" />
449 Request changes
450 </Button>
451 </>
452 )}
453 </div>
454 </Form>
455 </div>
456 );
457}
458
459/**
460 * Checkboxes for choosing people: the workspace's members, anyone already
461 * chosen, and a box for other usernames. Posts `name` for each ticked and
462 * `others` for the rest.
463 */
464export function PeoplePicker({
465 name,
466 members,
467 chosen,
468}: {
469 name: string;
470 members: string[];
471 chosen: string[];
472}) {
473 const people = [...new Set([...members, ...chosen])];
474 return (
475 <>
476 {people.length > 0 && (
477 <div className="space-y-1.5">
478 {people.map((person) => (
479 <CheckboxOption
480 key={person}
481 name={name}
482 value={person}
483 defaultChecked={chosen.includes(person)}
484 className="items-center"
485 labelClassName="flex items-center gap-2"
486 label={
487 <>
488 <Avatar name={person} size={18} />
489 <span className="font-mono text-xs">{person}</span>
490 </>
491 }
492 />
493 ))}
494 </div>
495 )}
496 <input
497 name="others"
498 placeholder="Other usernames, comma separated"
499 autoComplete="off"
500 data-1p-ignore
501 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"
502 />
503 </>
504 );
505}
506
507/** Where each reviewer stands: their most recent verdict. */
508export function verdicts(comments: Comment[]): { reviewer: string; verdict: NonNullable<Comment["verdict"]> }[] {
509 const latest = new Map<string, NonNullable<Comment["verdict"]>>();
510 for (const comment of comments) {
511 if (comment.verdict) latest.set(comment.author.username, comment.verdict);
512 }
513 return [...latest].map(([reviewer, verdict]) => ({ reviewer, verdict }));
514}
515
516/** `2 files +12 −3`: the size of a pull request's change. */
517export function ChangeSize({ files }: { files: Pull["files"] }) {
518 if (files.length === 0) return null;
519 const additions = files.reduce((sum, file) => sum + file.additions, 0);
520 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
521 return (
522 <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-faint">
523 <span>
524 {files.length} {files.length === 1 ? "file" : "files"}
525 </span>
526 <span className="text-accent">+{additions}</span>
527 <span className="text-danger">−{deletions}</span>
528 </span>
529 );
530}
531
532/** The words of a Markdown text, without its markup, for a one-line preview. */
533export function plainText(markdown: string): string {
534 return markdown
535 .replace(/```[\s\S]*?```/g, " ")
536 .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, "")
537 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
538 .replace(/[*_`~]/g, "")
539 .replace(/\s+/g, " ")
540 .trim();
541}