Skip to content

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

401 linesCodeBlameRaw
1import { AlarmClock, Bell, Bookmark, Check, CheckCheck, CircleCheck, CircleX, Ellipsis, Hand, Info, Mail, MailOpen, Undo2 } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";
3import { Link, useFetcher } from "react-router";
4
5import type { InboxCounts, InboxItem, InboxSeverity } from "@g1t/contracts";
6
7import { WaitingCards } from "./notifications/card-actions";
8import { SubmitButton } from "./ui";
9import { Badge, type BadgeTone } from "./ui/badge";
10import { Button } from "./ui/button";
11import { Card } from "./ui/card";
12import { Hint } from "./ui/hint";
13import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
14import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
15import { Skeleton } from "./ui/skeleton";
16import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
17import { cn } from "../lib/cn";
18import { isPending } from "../lib/pending";
19import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, settledLine, tabCount, updatesLabel, whenShort } from "../lib/inbox";
20import { useOwnPresence } from "../lib/notify-client";
21import { dndOn } from "../lib/presence";
22import type { InboxPanelData } from "../routes/inbox-json";
23
24/** Where every notifications form posts (routes/notifications.tsx). */
25const ACTION = "/notifications";
26/** How often the bell asks for its count while the page is in view. */
27const COUNT_EVERY_MS = 60_000;
28
29const TONE: Record<InboxSeverity, BadgeTone> = {
30 error: "danger",
31 warning: "warn",
32 success: "accent",
33 info: "info",
34};
35
36const ICON: Record<InboxSeverity, { icon: ReactNode; ring: string }> = {
37 error: { icon: <CircleX size={15} />, ring: "bg-danger/10 text-danger ring-danger/30" },
38 warning: { icon: <Hand size={15} />, ring: "bg-warn/10 text-warn ring-warn/30" },
39 success: { icon: <CircleCheck size={15} />, ring: "bg-success/10 text-success ring-success/30" },
40 info: { icon: <Info size={15} />, ring: "bg-info/10 text-info ring-info/30" },
41};
42
43/** The round mark that says what kind of item it is. */
44function SeverityMark({ severity }: { severity: InboxSeverity }) {
45 const { icon, ring } = ICON[severity];
46 return (
47 <span aria-hidden="true" className={cn("mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full ring-1", ring)}>
48 {icon}
49 </span>
50 );
51}
52
53/** Hidden fields naming one item's action. */
54function Fields({ intent, id, extra }: { intent: string; id: string; extra?: Record<string, string> }) {
55 return (
56 <>
57 <input type="hidden" name="intent" value={intent} />
58 <input type="hidden" name="id" value={id} />
59 {Object.entries(extra ?? {}).map(([name, value]) => (
60 <input key={name} type="hidden" name={name} value={value} />
61 ))}
62 </>
63 );
64}
65
66/**
67 * One item: what happened, to what, when, and how much it matters. The
68 * whole card opens what it is about and marks it read; Done is at hand,
69 * and the menu has the rest. Read items are dimmed. While Done or Snooze
70 * is on its way the card steps out of the list.
71 */
72export function InboxCard({ item, onOpen }: { item: InboxItem; onOpen?: () => void }) {
73 const fetcher = useFetcher<{ error?: string }>();
74 const unread = isUnread(item);
75 const leaving = isPending(fetcher, { id: item.id }) && ["done", "snooze", "undone"].includes(String(fetcher.formData?.get("intent")));
76 const [now, setNow] = useState(() => Date.now());
77 useEffect(() => setNow(Date.now()), [item.updatedAt]);
78 if (leaving) return null;
79
80 const submit = (intent: string, extra?: Record<string, string>) =>
81 fetcher.submit({ intent, id: item.id, ...extra }, { method: "post", action: ACTION });
82 const open = () => {
83 if (unread) submit("read");
84 onOpen?.();
85 };
86
87 return (
88 <li
89 className={cn(
90 "group relative flex gap-3 rounded-lg border border-line bg-bg/40 p-3 transition-colors hover:border-line-strong hover:bg-raised/50",
91 !unread && "opacity-60 hover:opacity-100",
92 )}
93 >
94 <SeverityMark severity={item.severity} />
95 <div className="min-w-0 flex-1">
96 <Link
97 to={item.url}
98 onClick={open}
99 className="block truncate pr-14 text-sm font-semibold text-fg outline-none [@media(hover:none)]:pr-24 after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-accent"
100 >
101 {item.title}
102 </Link>
103 {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>}
104 {/* Done by itself: what ended it, and who. */}
105 {settledLine(item) && (
106 <p className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-fg-soft">
107 <CheckCheck size={12} aria-hidden="true" className="shrink-0 text-success" />
108 <span className="truncate">{settledLine(item)}</span>
109 </p>
110 )}
111 <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint [@media(hover:none)]:pr-24">
112 <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap">
113 {whenShort(item.updatedAt, now)}
114 </time>
115 <Badge tone={TONE[item.severity]} className="shrink-0">{SEVERITY_LABEL[item.severity]}</Badge>
116 {/* Why they were told, and how much has happened, said quietly. */}
117 <span className="truncate">{REASON_LABEL[item.reason] ?? item.reason}</span>
118 {updatesLabel(item.count) && (
119 <>
120 <span aria-hidden="true">·</span>
121 <span className="shrink-0 tabular-nums">{updatesLabel(item.count)}</span>
122 </>
123 )}
124 {item.saved && (
125 <span className="inline-flex items-center gap-1 text-faint">
126 <Bookmark size={12} aria-hidden="true" />
127 Saved
128 </span>
129 )}
130 </div>
131 {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>}
132 </div>
133 {unread && <span aria-label="Unread" className="absolute top-3.5 right-3 size-2 rounded-full bg-accent group-focus-within:hidden group-hover:hidden [@media(hover:none)]:hidden" />}
134 {/* Above the card's link, so they act instead of opening it. On hover or focus, and always on a touch screen. */}
135 <div className="absolute top-2 right-2 z-10 hidden items-center gap-0.5 group-focus-within:flex group-hover:flex [@media(hover:none)]:flex">
136 {item.doneAt ? (
137 <fetcher.Form method="post" action={ACTION}>
138 <Fields intent="undone" id={item.id} />
139 <Hint label="Move back to Notifications">
140 <SubmitButton fetcher={fetcher} icon aria-label="Move back to Notifications" variant="outline" size="icon-xs" className={ICON_BUTTON}>
141 <Undo2 size={14} />
142 </SubmitButton>
143 </Hint>
144 </fetcher.Form>
145 ) : (
146 <fetcher.Form method="post" action={ACTION}>
147 <Fields intent="done" id={item.id} />
148 <Hint label="Done">
149 <SubmitButton fetcher={fetcher} icon aria-label="Done" variant="outline" size="icon-xs" className={ICON_BUTTON}>
150 <Check size={14} />
151 </SubmitButton>
152 </Hint>
153 </fetcher.Form>
154 )}
155 <DropdownMenu>
156 <DropdownMenuTrigger asChild>
157 <Button variant="outline" size="icon-xs" aria-label="More actions" className={ICON_BUTTON}>
158 <Ellipsis size={14} />
159 </Button>
160 </DropdownMenuTrigger>
161 <DropdownMenuContent align="end">
162 <DropdownMenuItem onSelect={() => submit(unread ? "read" : "unread")}>
163 {unread ? <MailOpen /> : <Mail />}
164 {unread ? "Mark as read" : "Mark as unread"}
165 </DropdownMenuItem>
166 <DropdownMenuItem onSelect={() => submit(item.saved ? "unsave" : "save")}>
167 <Bookmark />
168 {item.saved ? "Unsave" : "Save"}
169 </DropdownMenuItem>
170 {!item.doneAt && (
171 <>
172 <DropdownMenuSeparator />
173 <DropdownMenuLabel>Snooze until</DropdownMenuLabel>
174 {SNOOZES.map((snooze) => (
175 <DropdownMenuItem key={snooze.key} onSelect={() => submit("snooze", { for: snooze.key })}>
176 <AlarmClock />
177 {snooze.label}
178 </DropdownMenuItem>
179 ))}
180 </>
181 )}
182 </DropdownMenuContent>
183 </DropdownMenu>
184 </div>
185 </li>
186 );
187}
188
189/** A row's small action: an outline icon button on the surface, a finger wide on a phone. */
190const ICON_BUTTON = "bg-surface text-muted max-sm:size-10 disabled:opacity-60";
191
192/** What an empty list says, for its tab. */
193export function InboxEmpty({ tab, view }: { tab: InboxTab; view?: "inbox" | "saved" | "done" }) {
194 const { title, detail } = emptyFor(tab, view);
195 return (
196 <Card tone="plain" radius="lg" className="flex flex-col items-center gap-2 border-dashed px-6 py-12 text-center">
197 <Bell size={20} className="text-faint" aria-hidden="true" />
198 <p className="text-sm font-medium">{title}</p>
199 <p className="max-w-64 text-xs text-muted">{detail}</p>
200 </Card>
201 );
202}
203
204/** The tab row, with what is unread under each. */
205export function InboxTabs({ tab, counts, onChange }: { tab: InboxTab; counts: InboxCounts | null; onChange: (tab: InboxTab) => void }) {
206 return (
207 <Tabs value={tab} onValueChange={(value) => onChange(value as InboxTab)}>
208 <TabsList className="flex w-full overflow-x-auto scrollbar-none">
209 {INBOX_TABS.map((entry) => {
210 const count = tabCount(counts, entry.tab);
211 return (
212 <TabsTrigger key={entry.tab} value={entry.tab} className="flex shrink-0 grow items-center justify-center gap-1.5">
213 {entry.label}
214 {count > 0 && (
215 <span
216 className={cn(
217 "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
218 entry.tab === "needs" ? "bg-warn/15 text-warn" : "bg-line text-muted",
219 )}
220 >
221 {count}
222 </span>
223 )}
224 </TabsTrigger>
225 );
226 })}
227 </TabsList>
228 </Tabs>
229 );
230}
231
232/** Mark all read, for the tab being looked at. */
233export function MarkAllRead({ tab, disabled }: { tab: InboxTab; disabled: boolean }) {
234 const fetcher = useFetcher();
235 return (
236 <fetcher.Form method="post" action={ACTION}>
237 <input type="hidden" name="intent" value="read_all" />
238 <input type="hidden" name="tab" value={tab} />
239 <SubmitButton
240 fetcher={fetcher}
241 disabled={disabled}
242 pending="Marking…"
243 variant="ghost"
244 size="inline"
245 className="px-2 py-1 text-xs"
246 >
247 <CheckCheck size={14} />
248 Mark all read
249 </SubmitButton>
250 </fetcher.Form>
251 );
252}
253
254/**
255 * The bell in the top bar, and the panel it opens from the right: the
256 * notifications's tabs and newest items, to work through without leaving the page.
257 * Its count comes with the page (root.tsx), and is asked for again every
258 * minute while the page is in view, and after anything is marked.
259 */
260export function NotificationsBell({ counts: loaded }: { counts: InboxCounts | null }) {
261 const [open, setOpen] = useState(false);
262 const [tab, setTab] = useState<InboxTab>("all");
263 const list = useFetcher<InboxPanelData>({ key: "inbox-panel" });
264 const poll = useFetcher<InboxPanelData>({ key: "inbox-count" });
265
266 useEffect(() => {
267 if (open) list.load(`/inbox.json?tab=${tab}`);
268 // eslint-disable-next-line react-hooks/exhaustive-deps
269 }, [open, tab]);
270 useEffect(() => {
271 const ask = () => {
272 if (document.visibilityState === "visible") poll.load("/inbox.json?only=counts");
273 };
274 const timer = setInterval(ask, COUNT_EVERY_MS);
275 window.addEventListener("focus", ask);
276 return () => {
277 clearInterval(timer);
278 window.removeEventListener("focus", ask);
279 };
280 // eslint-disable-next-line react-hooks/exhaustive-deps
281 }, []);
282
283 const counts = (open ? list.data?.counts : null) ?? poll.data?.counts ?? loaded;
284 const unread = counts?.unread ?? 0;
285 const items = list.data?.tab === tab ? list.data.items : null;
286 const shown = bellCount(unread);
287 // Do not disturb shows on the bell: an amber dot while it holds (the feed says; nothing on the server).
288 const dnd = dndOn(useOwnPresence(), Date.now());
289
290 return (
291 <Sheet open={open} onOpenChange={setOpen}>
292 <SheetTrigger asChild>
293 <Button
294 variant="ghost"
295 size="icon-sm"
296 aria-label={`${unread ? `Notifications, ${unread} unread` : "Notifications"}${dnd ? ", do not disturb is on" : ""}`}
297 className="relative"
298 >
299 <Bell size={17} />
300 {dnd && <span aria-hidden="true" className="absolute -right-0.5 -bottom-0.5 size-2 rounded-full bg-warn ring-2 ring-bg" />}
301 {shown && (
302 // Amber while something waits on them, red for a failure, else the accent.
303 <span
304 className={cn(
305 "absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.625rem] font-semibold tabular-nums text-bg ring-2 ring-bg",
306 (counts?.warning ?? 0) > 0 ? "bg-warn" : (counts?.error ?? 0) > 0 ? "bg-danger" : "bg-accent",
307 )}
308 >
309 {shown}
310 </span>
311 )}
312 </Button>
313 </SheetTrigger>
314 <SheetContent
315 // Focus starts on the tab being looked at, not on Mark all read.
316 onOpenAutoFocus={(event) => {
317 event.preventDefault();
318 (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[role="tab"][data-state="active"]')?.focus();
319 }}
320 >
321 <SheetHeader className="flex-row items-center justify-between gap-3">
322 <SheetTitle>Notifications</SheetTitle>
323 <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />
324 </SheetHeader>
325 <div className="border-b border-line px-5 py-3">
326 <InboxTabs tab={tab} counts={counts} onChange={setTab} />
327 </div>
328 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
329 {/* Chat cards waiting on you (live notifications), to act on from here. */}
330 <WaitingCards onNavigate={() => setOpen(false)} />
331 {items == null ? (
332 list.state === "idle" && list.data?.tab === tab ? (
333 <p className="text-sm text-muted">Notifications could not be loaded. Try again in a moment.</p>
334 ) : (
335 <ul aria-busy="true" className="space-y-2">
336 {Array.from({ length: 4 }, (_, index) => (
337 <Card asChild key={index} tone="plain" radius="lg" className="flex gap-3 p-3">
338 <li>
339 <Skeleton className="size-8 rounded-full" />
340 <div className="flex-1 space-y-2">
341 <Skeleton className="h-3.5 w-3/4" />
342 <Skeleton className="h-3 w-1/2" />
343 </div>
344 </li>
345 </Card>
346 ))}
347 </ul>
348 )
349 ) : items.length === 0 ? (
350 <InboxEmpty tab={tab} />
351 ) : (
352 <ul className="space-y-2">
353 {items.map((item) => (
354 <InboxCard key={item.id} item={item} onOpen={() => setOpen(false)} />
355 ))}
356 </ul>
357 )}
358 </div>
359 <SheetFooter className="justify-between">
360 <SheetDescription className="text-xs">Done, saved and snoozed items are on the Notifications page.</SheetDescription>
361 <SheetClose asChild>
362 <Link to={tab === "all" ? "/notifications" : `/notifications?tab=${tab}`} className="shrink-0 text-sm font-medium text-accent hover:underline">
363 Open Notifications
364 </Link>
365 </SheetClose>
366 </SheetFooter>
367 </SheetContent>
368 </Sheet>
369 );
370}
371
372/**
373 * Mission control's card of what is unread in the person's notifications and
374 * worth a look: items waiting on them, then failures. Titled for
375 * notifications, not "Needs you", which on mission control is the work list's
376 * own count. Not shown when there are none.
377 */
378export function InboxNeedsCard({ items, total }: { items: InboxItem[]; total: number }) {
379 if (items.length === 0) return null;
380 return (
381 <Card asChild className="p-5">
382 <section>
383 <div className="flex items-center justify-between gap-3">
384 <h2 className="flex items-center gap-2 text-sm font-semibold">
385 <Bell size={14} className="text-warn" aria-hidden="true" />
386 From your notifications
387 <span className="rounded-full bg-warn/15 px-1.5 text-[0.6875rem] font-medium tabular-nums text-warn">{total}</span>
388 </h2>
389 <Link to="/notifications" className="text-xs font-medium text-accent hover:underline">
390 Open Notifications
391 </Link>
392 </div>
393 <ul className="mt-3 space-y-2">
394 {items.map((item) => (
395 <InboxCard key={item.id} item={item} />
396 ))}
397 </ul>
398 </section>
399 </Card>
400 );
401}