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

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