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.

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