Skip to content
535 linesCodeBlameRaw
1/**
2 * The pieces mission control and a project's overview are made of: panels
3 * with a "View all", what needs the viewer, the activity feed with its
4 * filters, the week's pulse with its sparklines, a project's pipeline and
5 * its deploy history.
6 */
7import {
8 ArrowRight,
9 Brain,
10 CircleCheck,
11 CircleDot,
12 CircleSlash,
13 CreditCard,
14 Eye,
15 GitCommitHorizontal,
16 GitMerge,
17 GitPullRequest,
18 Hand,
19 MessageCircleQuestion,
20 MessageSquare,
21 Play,
22 Rocket,
23 Swords,
24 Terminal,
25 TimerOff,
26 TriangleAlert,
27 UserPlus,
28} from "lucide-react";
29import { type ReactNode, useMemo, useState } from "react";
30import { Link } from "react-router";
31
32import type { DeployStatus } from "@g1t/contracts";
33
34import { cn } from "../lib/cn";
35import { type ActivityGroup, type Need, type NeedKind, type Verb, isAgent, sparkPoints } from "../lib/mission";
36import { TimeAgo } from "./ui";
37import { Avatar } from "./ui/avatar";
38import { Button } from "./ui/button";
39import { Card } from "./ui/card";
40import { Hint } from "./ui/hint";
41import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
42
43// --- Panels -------------------------------------------------------------------
44
45/** A titled section, with a count and a link to everything it summarises. */
46export function Panel({
47 id,
48 title,
49 icon,
50 count,
51 all,
52 extra,
53 children,
54 className,
55}: {
56 id?: string;
57 title: ReactNode;
58 icon?: ReactNode;
59 count?: number | null;
60 all?: { to: string; label?: string } | null;
61 extra?: ReactNode;
62 children: ReactNode;
63 className?: string;
64}) {
65 return (
66 <section id={id} className={cn("scroll-mt-20", className)}>
67 <div className="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-2">
68 <h2 className="flex items-center gap-2 text-sm font-semibold tracking-tight">
69 {icon && <span className="text-faint">{icon}</span>}
70 {title}
71 {count != null && count > 0 && (
72 <span className="rounded-full bg-line px-1.5 text-[0.6875rem] font-medium tabular-nums text-muted">{count}</span>
73 )}
74 </h2>
75 <span className="grow" />
76 {extra}
77 {all && (
78 <Link to={all.to} prefetch="intent" className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
79 {all.label ?? "View all"}
80 <ArrowRight size={12} />
81 </Link>
82 )}
83 </div>
84 <div className="mt-3">{children}</div>
85 </section>
86 );
87}
88
89/** Says a section could not be loaded, without taking the page down. */
90export function Unavailable({ what }: { what: string }) {
91 return (
92 <Card asChild tone="plain" className="flex items-center gap-2 border-dashed px-4 py-5 text-sm text-muted">
93 <p>
94 <TriangleAlert size={14} className="text-warn" />
95 {what} could not be loaded just now. It will be back on the next refresh.
96 </p>
97 </Card>
98 );
99}
100
101/** A quiet, dashed empty state with an optional call to action. */
102export function Quiet({ children, action }: { children: ReactNode; action?: ReactNode }) {
103 return (
104 <Card tone="plain" className="flex flex-wrap items-center gap-3 border-dashed px-4 py-5 text-sm text-muted">
105 <span className="min-w-0 grow">{children}</span>
106 {action}
107 </Card>
108 );
109}
110
111// --- Needs you ----------------------------------------------------------------
112
113const NEED: Record<NeedKind, { icon: ReactNode; tone: string; label: string }> = {
114 limit: { icon: <CreditCard size={15} />, tone: "text-danger", label: "Usage" },
115 deploy: { icon: <Rocket size={15} />, tone: "text-danger", label: "Production" },
116 invitation: { icon: <UserPlus size={15} />, tone: "text-accent", label: "Invitation" },
117 conflict: { icon: <Swords size={15} />, tone: "text-warn", label: "Conflict" },
118 stalled: { icon: <Hand size={15} />, tone: "text-warn", label: "Stopped" },
119 stuck: { icon: <TimerOff size={15} />, tone: "text-warn", label: "Quiet agent" },
120 runner: { icon: <TimerOff size={15} />, tone: "text-warn", label: "No runner" },
121 review: { icon: <Eye size={15} />, tone: "text-info", label: "Review" },
122 checks: { icon: <Terminal size={15} />, tone: "text-danger", label: "Checks" },
123 ready: { icon: <GitMerge size={15} />, tone: "text-success", label: "Ready" },
124};
125
126/** What is waiting on the viewer, most urgent first, each with its next step. */
127export function NeedsList({ needs, limit = 8 }: { needs: Need[]; limit?: number }) {
128 const [all, setAll] = useState(false);
129 const shown = all ? needs : needs.slice(0, limit);
130 return (
131 <>
132 <Card asChild divided className="overflow-hidden">
133 <ul>
134 {shown.map((need) => {
135 const look = NEED[need.kind];
136 return (
137 <li key={need.key} className="flex items-start gap-3 px-4 py-3">
138 <Hint label={look.label}>
139 <span className={cn("mt-0.5 shrink-0", look.tone)}>
140 {look.icon}
141 <span className="sr-only">{look.label}</span>
142 </span>
143 </Hint>
144 <span className="min-w-0 grow">
145 <Link to={need.to} prefetch="intent" className="block truncate text-sm font-medium hover:text-accent">
146 {need.title}
147 </Link>
148 <span className="mt-0.5 block text-xs leading-5 text-muted">
149 {need.where && <span className="font-mono text-faint">{need.where} · </span>}
150 {need.detail}
151 </span>
152 </span>
153 <span className="hidden shrink-0 pt-0.5 text-xs text-faint sm:block">
154 <TimeAgo at={need.at} />
155 </span>
156 <Link
157 to={need.to}
158 prefetch="intent"
159 className="shrink-0 rounded-md border border-line px-2.5 py-1 text-xs font-medium text-fg/85 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"
160 >
161 {need.action}
162 </Link>
163 </li>
164 );
165 })}
166 </ul>
167 </Card>
168 {needs.length > limit && (
169 <Button type="button" onClick={() => setAll(!all)} variant="link" size="inline" className="mt-2 text-xs text-muted hover:text-fg font-normal">
170 {all ? "Show fewer" : `Show ${needs.length - limit} more`}
171 </Button>
172 )}
173 </>
174 );
175}
176
177// --- Activity -----------------------------------------------------------------
178
179const VERB: Record<Verb, { icon: ReactNode; tone: string; text: (n: string) => string }> = {
180 landed: { icon: <GitMerge size={14} />, tone: "text-merged", text: (n) => `landed ${n}` },
181 opened_issue: { icon: <CircleDot size={14} />, tone: "text-success", text: (n) => `opened ${n}` },
182 closed_issue: { icon: <CircleSlash size={14} />, tone: "text-faint", text: (n) => `closed ${n}` },
183 started: { icon: <Play size={14} />, tone: "text-accent", text: (n) => `started on ${n}` },
184 ready: { icon: <GitPullRequest size={14} />, tone: "text-info", text: (n) => `marked ${n} ready for review` },
185 checks_passed: { icon: <Terminal size={14} />, tone: "text-success", text: (n) => `checks passed on ${n}` },
186 checks_failed: { icon: <Terminal size={14} />, tone: "text-danger", text: (n) => `checks failed on ${n}` },
187 approved: { icon: <CircleCheck size={14} />, tone: "text-success", text: (n) => `approved ${n}` },
188 changes_requested: { icon: <CircleSlash size={14} />, tone: "text-warn", text: (n) => `asked for changes on ${n}` },
189 commented: { icon: <MessageSquare size={14} />, tone: "text-muted", text: (n) => `commented on ${n}` },
190 asked: { icon: <MessageCircleQuestion size={14} />, tone: "text-accent", text: (n) => `asked the agent on ${n}` },
191 deployed: { icon: <Rocket size={14} />, tone: "text-success", text: () => "deployed production" },
192 deploy_failed: { icon: <Rocket size={14} />, tone: "text-danger", text: () => "production build failed" },
193 pushed: { icon: <GitCommitHorizontal size={14} />, tone: "text-muted", text: () => "pushed to the default branch" },
194 learned: { icon: <Brain size={14} />, tone: "text-accent", text: () => "learned" },
195};
196
197function Refs({ numbers, base }: { numbers: number[]; base: string }) {
198 const shown = numbers.slice(0, 4);
199 return (
200 <>
201 {shown.map((number, index) => (
202 <span key={number}>
203 {index > 0 && (index === shown.length - 1 && numbers.length <= 4 ? " and " : ", ")}
204 <Link to={`${base}/issues/${number}`} prefetch="intent" className="font-medium text-fg hover:underline">
205 #{number}
206 </Link>
207 </span>
208 ))}
209 {numbers.length > 4 && ` and ${numbers.length - 4} more`}
210 </>
211 );
212}
213
214/** One group as a sentence: "landed #4 and #3; started on #5". */
215function GroupLine({ group }: { group: ActivityGroup }) {
216 const base = `/${group.repo.namespace}/${group.repo.name}`;
217 return (
218 <>
219 {group.parts.map((part, index) => {
220 const look = VERB[part.verb];
221 const [before, after] = look.text("\u0000").split("\u0000");
222 return (
223 <span key={part.verb}>
224 {index > 0 && "; "}
225 {part.verb === "learned" ? (
226 <>
227 learned{" "}
228 <Link to={part.to ?? `${base}/memory`} className="text-fg-soft hover:text-fg">
229 “{part.texts[0]}”
230 </Link>
231 {part.texts.length > 1 && ` and ${part.texts.length - 1} more`}
232 </>
233 ) : part.numbers.length > 0 ? (
234 <>
235 {before}
236 <Refs numbers={part.numbers} base={base} />
237 {after}
238 </>
239 ) : part.to ? (
240 <Link to={part.to} className="hover:text-fg">
241 {look.text("")}
242 </Link>
243 ) : (
244 look.text("")
245 )}
246 </span>
247 );
248 })}
249 </>
250 );
251}
252
253type Who = "all" | "agents" | "people";
254
255/**
256 * What moved across projects, newest first, a burst of one actor's work
257 * as one line, filterable by project and by who did it.
258 */
259export function ActivityFeed({
260 groups,
261 showRepo = true,
262 limit = 14,
263 empty,
264}: {
265 groups: ActivityGroup[];
266 showRepo?: boolean;
267 limit?: number;
268 empty: ReactNode;
269}) {
270 const [project, setProject] = useState("all");
271 const [who, setWho] = useState<Who>("all");
272 const [person, setPerson] = useState("all");
273 const [more, setMore] = useState(false);
274 const projects = useMemo(
275 () => [...new Set(groups.map((g) => `${g.repo.namespace}/${g.repo.name}`))].sort(),
276 [groups],
277 );
278 const people = useMemo(
279 () => [...new Set(groups.map((g) => g.actor).filter((a): a is string => a != null))].sort(),
280 [groups],
281 );
282 const filtered = groups.filter(
283 (g) =>
284 (project === "all" || `${g.repo.namespace}/${g.repo.name}` === project) &&
285 (person === "all" || g.actor === person) &&
286 (who === "all" || (who === "agents" ? g.actor == null || isAgent(g.actor) : g.actor != null && !isAgent(g.actor))),
287 );
288 const shown = more ? filtered : filtered.slice(0, limit);
289 return (
290 <div>
291 {groups.length > 0 && (
292 <div className="mb-3 flex flex-wrap items-center gap-2">
293 <Card tone="bg" radius="lg" className="inline-flex p-0.5 text-xs">
294 {(["all", "agents", "people"] as const).map((value) => (
295 <button
296 key={value}
297 type="button"
298 onClick={() => setWho(value)}
299 className={cn(
300 "rounded-md px-2.5 py-1 font-medium capitalize transition-colors",
301 who === value ? "bg-raised text-fg" : "text-muted hover:text-fg",
302 )}
303 >
304 {value === "all" ? "Everyone" : value}
305 </button>
306 ))}
307 </Card>
308 {showRepo && projects.length > 1 && (
309 <Select value={project} onValueChange={setProject}>
310 <SelectTrigger size="sm" className="w-auto max-w-56" aria-label="Project">
311 <SelectValue />
312 </SelectTrigger>
313 <SelectContent>
314 <SelectItem value="all">All projects</SelectItem>
315 {projects.map((name) => (
316 <SelectItem key={name} value={name}>
317 {name.split("/")[1]}
318 </SelectItem>
319 ))}
320 </SelectContent>
321 </Select>
322 )}
323 {people.length > 1 && (
324 <Select value={person} onValueChange={setPerson}>
325 <SelectTrigger size="sm" className="w-auto max-w-48" aria-label="Who">
326 <SelectValue />
327 </SelectTrigger>
328 <SelectContent>
329 <SelectItem value="all">Anyone</SelectItem>
330 {people.map((name) => (
331 <SelectItem key={name} value={name} icon={<Avatar name={name} size={16} />}>
332 {name}
333 </SelectItem>
334 ))}
335 </SelectContent>
336 </Select>
337 )}
338 </div>
339 )}
340 {shown.length === 0 ? (
341 <Quiet>{groups.length === 0 ? empty : "Nothing matches these filters."}</Quiet>
342 ) : (
343 <ol className="relative space-y-0.5 before:absolute before:top-3 before:bottom-3 before:left-[0.9375rem] before:w-px before:bg-line">
344 {shown.map((group) => {
345 const look = VERB[group.parts[0].verb];
346 const agent = group.actor == null || isAgent(group.actor);
347 return (
348 <li key={group.id} className="relative flex items-start gap-3 rounded-lg px-1 py-1.5 text-sm">
349 <span
350 className={cn(
351 "relative z-10 mt-0.5 flex size-[1.375rem] shrink-0 items-center justify-center rounded-full bg-bg ring-1 ring-line",
352 look.tone,
353 )}
354 >
355 {look.icon}
356 </span>
357 <span className="min-w-0 grow leading-6 text-muted">
358 {group.actor ? (
359 <span className={cn("mr-1.5 inline-flex items-center gap-1.5 align-middle font-medium", agent ? "text-accent" : "text-fg")}>
360 <Avatar name={group.actor} size={16} />
361 {group.actor}
362 </span>
363 ) : (
364 <span className="mr-1 text-fg-soft">g1t:</span>
365 )}
366 <GroupLine group={group} />
367 {showRepo && (
368 <Link
369 to={`/${group.repo.namespace}/${group.repo.name}`}
370 className="ml-1.5 font-mono text-xs text-faint hover:text-muted"
371 >
372 {group.repo.name}
373 </Link>
374 )}
375 {group.count > 2 && <span className="ml-1.5 text-xs text-faint">· {group.count} events</span>}
376 </span>
377 <span className="shrink-0 pt-0.5 text-xs text-faint">
378 <TimeAgo at={group.at} />
379 </span>
380 </li>
381 );
382 })}
383 </ol>
384 )}
385 {filtered.length > limit && (
386 <Button type="button" onClick={() => setMore(!more)} variant="link" size="inline" className="mt-2 text-xs text-muted hover:text-fg font-normal">
387 {more ? "Show fewer" : `Show ${filtered.length - limit} more`}
388 </Button>
389 )}
390 </div>
391 );
392}
393
394// --- Pulse --------------------------------------------------------------------
395
396const DAY_LABEL = (offset: number) => {
397 const date = new Date(Date.now() - offset * 86_400_000);
398 return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
399};
400
401/**
402 * A week of one measure as a small line, the last day marked. Each day
403 * has a hover target that says its value.
404 */
405export function Sparkline({
406 values,
407 format,
408 width = 96,
409 height = 28,
410}: {
411 values: number[];
412 format: (value: number) => string;
413 width?: number;
414 height?: number;
415}) {
416 const points = sparkPoints(values, width, height, 3);
417 if (points.length === 0) return null;
418 const line = points.map(([x, y], i) => `${i ? "L" : "M"}${x},${y}`).join(" ");
419 const area = `${line} L${points[points.length - 1][0]},${height} L${points[0][0]},${height} Z`;
420 const [lastX, lastY] = points[points.length - 1];
421 const slot = width / values.length;
422 return (
423 <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible text-accent" role="img" aria-label={values.map(format).join(", ")}>
424 <path d={area} fill="currentColor" opacity={0.12} />
425 <path d={line} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
426 <circle cx={lastX} cy={lastY} r={3} fill="currentColor" stroke="var(--color-surface)" strokeWidth={2} />
427 {values.map((value, index) => (
428 <rect key={index} x={index * slot} y={0} width={slot} height={height} fill="transparent" className="hover:fill-fg/5">
429 <title>{`${DAY_LABEL(values.length - 1 - index)}: ${format(value)}`}</title>
430 </rect>
431 ))}
432 </svg>
433 );
434}
435
436/** One measure of the week: a headline number, what it means, and its line. */
437export function PulseTile({
438 label,
439 value,
440 hint,
441 values,
442 format,
443 to,
444}: {
445 label: string;
446 value: string;
447 hint?: string;
448 values?: number[];
449 format?: (value: number) => string;
450 to?: string;
451}) {
452 const body = (
453 <>
454 <p className="truncate text-xs text-muted">{label}</p>
455 <div className="mt-1 flex items-end justify-between gap-2">
456 <p className="text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
457 {values && format && values.some((v) => v > 0) && (
458 <span className="mb-1">
459 <Sparkline values={values} format={format} width={56} height={22} />
460 </span>
461 )}
462 </div>
463 <Hint label={hint}>
464 <p className="mt-1.5 truncate text-[0.6875rem] text-faint">{hint}</p>
465 </Hint>
466 </>
467 );
468 const box = "block rounded-xl border border-line bg-surface p-4 transition-colors";
469 return to ? (
470 <Link to={to} className={`${box} hover:border-line-strong`}>
471 {body}
472 </Link>
473 ) : (
474 <div className={box}>{body}</div>
475 );
476}
477
478// --- Deploys ------------------------------------------------------------------
479
480const STRIP_TONE: Record<DeployStatus, string> = {
481 ready: "bg-success",
482 // Served once, until a newer build or a take-down: it worked.
483 replaced: "bg-success/45",
484 down: "bg-line-strong",
485 failed: "bg-danger",
486 building: "bg-warn animate-pulse",
487 queued: "bg-line-strong animate-pulse",
488 skipped: "bg-line",
489};
490
491/** The last builds as a row of bars, oldest on the left; each links to its build. */
492export function DeployStrip({
493 builds,
494 base,
495 slots = 20,
496}: {
497 builds: { id: string; status: DeployStatus; kind: string; commit: string; createdAt: string }[];
498 base: string;
499 slots?: number;
500}) {
501 const shown = builds.slice(0, slots).reverse();
502 return (
503 <div className="flex h-8 items-end gap-[3px]" aria-label="Recent builds, oldest first">
504 {Array.from({ length: slots - shown.length }, (_, i) => (
505 <span key={`empty-${i}`} className="h-2 flex-1 rounded-sm bg-line/60" />
506 ))}
507 {shown.map((build) => {
508 const about = `${build.kind === "production" ? "Production" : "Preview"} · ${build.commit.slice(0, 7)} · ${build.status} · ${new Date(build.createdAt).toLocaleString("en-US")}`;
509 return (
510 <Hint key={build.id} label={about}>
511 <Link
512 to={`${base}/deployments/${build.id}`}
513 aria-label={about}
514 className={cn("flex-1 rounded-sm transition-opacity hover:opacity-80", STRIP_TONE[build.status], build.kind === "production" ? "h-8" : "h-5")}
515 />
516 </Hint>
517 );
518 })}
519 </div>
520 );
521}
522
523/** A small meter for a share, 0 to 1. */
524export function Meter({ value, tone = "bg-success" }: { value: number | null; tone?: string }) {
525 return (
526 <span className="block h-1.5 w-full overflow-hidden rounded-full bg-line">
527 {value != null && <span className={cn("block h-full rounded-full", tone)} style={{ width: `${Math.round(value * 100)}%` }} />}
528 </span>
529 );
530}
531
532export function percent(value: number | null): string {
533 return value == null ? "—" : `${Math.round(value * 100)}%`;
534}
535