Skip to content

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

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