Skip to content

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

384 lines16,440 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all1import { 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, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, 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.createdAt]);
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 items-center gap-2 text-xs text-faint">
100 <time dateTime={item.createdAt} suppressHydrationWarning>
101 {whenShort(item.createdAt, now)}
102 </time>
103 <Badge tone={TONE[item.severity]}>{SEVERITY_LABEL[item.severity]}</Badge>
104 {item.saved && (
105 <span className="inline-flex items-center gap-1 text-faint">
106 <Bookmark size={12} aria-hidden="true" />
107 Saved
108 </span>
109 )}
110 </div>
111 {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>}
112 </div>
113 {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" />}
114 {/* Above the card's link, so they act instead of opening it. On hover or focus, and always on a touch screen. */}
115 <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">
116 {item.doneAt ? (
117 <fetcher.Form method="post" action={ACTION}>
118 <Fields intent="undone" id={item.id} />
119 <SubmitButton fetcher={fetcher} icon aria-label="Move back to the inbox" title="Move back to the inbox" className={ICON_BUTTON}>
120 <Undo2 size={14} />
121 </SubmitButton>
122 </fetcher.Form>
123 ) : (
124 <fetcher.Form method="post" action={ACTION}>
125 <Fields intent="done" id={item.id} />
126 <SubmitButton fetcher={fetcher} icon aria-label="Done" title="Done" className={ICON_BUTTON}>
127 <Check size={14} />
128 </SubmitButton>
129 </fetcher.Form>
130 )}
131 <DropdownMenu>
132 <DropdownMenuTrigger aria-label="More actions" className={ICON_BUTTON}>
133 <Ellipsis size={14} />
134 </DropdownMenuTrigger>
135 <DropdownMenuContent align="end">
136 <DropdownMenuItem onSelect={() => submit(unread ? "read" : "unread")}>
137 {unread ? <MailOpen /> : <Mail />}
138 {unread ? "Mark as read" : "Mark as unread"}
139 </DropdownMenuItem>
140 <DropdownMenuItem onSelect={() => submit(item.saved ? "unsave" : "save")}>
141 <Bookmark />
142 {item.saved ? "Unsave" : "Save"}
143 </DropdownMenuItem>
144 {!item.doneAt && (
145 <>
146 <DropdownMenuSeparator />
147 <DropdownMenuLabel>Snooze until</DropdownMenuLabel>
148 {SNOOZES.map((snooze) => (
149 <DropdownMenuItem key={snooze.key} onSelect={() => submit("snooze", { for: snooze.key })}>
150 <AlarmClock />
151 {snooze.label}
152 </DropdownMenuItem>
153 ))}
154 </>
155 )}
156 </DropdownMenuContent>
157 </DropdownMenu>
158 </div>
159 </li>
160 );
161}
162
163const ICON_BUTTON =
164 "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";
165
166/** What an empty list says, for its tab. */
167export function InboxEmpty({ tab, view }: { tab: InboxTab; view?: "inbox" | "saved" | "done" }) {
168 const { title, detail } = emptyFor(tab, view);
169 return (
170 <div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-line px-6 py-12 text-center">
171 <Inbox size={20} className="text-faint" aria-hidden="true" />
172 <p className="text-sm font-medium">{title}</p>
173 <p className="max-w-64 text-xs text-muted">{detail}</p>
174 </div>
175 );
176}
177
178/** The tab row, with what is unread under each. */
179export function InboxTabs({ tab, counts, onChange }: { tab: InboxTab; counts: InboxCounts | null; onChange: (tab: InboxTab) => void }) {
180 return (
181 <Tabs value={tab} onValueChange={(value) => onChange(value as InboxTab)}>
182 <TabsList className="flex w-full overflow-x-auto [scrollbar-width:none]">
183 {INBOX_TABS.map((entry) => {
184 const count = tabCount(counts, entry.tab);
185 return (
186 <TabsTrigger key={entry.tab} value={entry.tab} className="flex shrink-0 grow items-center justify-center gap-1.5">
187 {entry.label}
188 {count > 0 && (
189 <span
190 className={cn(
191 "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
192 entry.tab === "needs" ? "bg-warn/15 text-warn" : "bg-line text-muted",
193 )}
194 >
195 {count}
196 </span>
197 )}
198 </TabsTrigger>
199 );
200 })}
201 </TabsList>
202 </Tabs>
203 );
204}
205
206/** Mark all read, for the tab being looked at. */
207export function MarkAllRead({ tab, disabled }: { tab: InboxTab; disabled: boolean }) {
208 const fetcher = useFetcher();
209 return (
210 <fetcher.Form method="post" action={ACTION}>
211 <input type="hidden" name="intent" value="read_all" />
212 <input type="hidden" name="tab" value={tab} />
213 <SubmitButton
214 fetcher={fetcher}
215 disabled={disabled}
216 pending="Marking…"
217 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"
218 >
219 <CheckCheck size={14} />
220 Mark all read
221 </SubmitButton>
222 </fetcher.Form>
223 );
224}
225
226/**
227 * The bell in the top bar, and the panel it opens from the right: the
228 * inbox's tabs and newest items, to work through without leaving the page.
229 * Its count comes with the page (root.tsx), and is asked for again every
230 * minute while the page is in view, and after anything is marked.
231 */
232export function InboxBell({ counts: loaded }: { counts: InboxCounts | null }) {
233 const [open, setOpen] = useState(false);
234 const [tab, setTab] = useState<InboxTab>("all");
235 const list = useFetcher<InboxPanelData>({ key: "inbox-panel" });
236 const poll = useFetcher<InboxPanelData>({ key: "inbox-count" });
237
238 useEffect(() => {
239 if (open) list.load(`/inbox.json?tab=${tab}`);
240 // eslint-disable-next-line react-hooks/exhaustive-deps
241 }, [open, tab]);
242 useEffect(() => {
243 const ask = () => {
244 if (document.visibilityState === "visible") poll.load("/inbox.json?only=counts");
245 };
246 const timer = setInterval(ask, COUNT_EVERY_MS);
247 window.addEventListener("focus", ask);
248 return () => {
249 clearInterval(timer);
250 window.removeEventListener("focus", ask);
251 };
252 // eslint-disable-next-line react-hooks/exhaustive-deps
253 }, []);
254
255 const counts = (open ? list.data?.counts : null) ?? poll.data?.counts ?? loaded;
256 const unread = counts?.unread ?? 0;
257 const items = list.data?.tab === tab ? list.data.items : null;
258 const shown = bellCount(unread);
259
260 return (
261 <Sheet open={open} onOpenChange={setOpen}>
262 <SheetTrigger
263 aria-label={unread ? `Inbox, ${unread} unread` : "Inbox"}
264 className="relative flex size-9 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg"
265 >
266 <Bell size={17} />
267 {shown && (
268 // Amber while an agent waits on them, red for a failure, else the accent.
269 <span
270 className={cn(
271 "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",
272 (counts?.warning ?? 0) > 0 ? "bg-warn" : (counts?.error ?? 0) > 0 ? "bg-danger" : "bg-accent",
273 )}
274 >
275 {shown}
276 </span>
277 )}
278 </SheetTrigger>
279 <SheetContent
280 // Focus starts on the tab being looked at, not on Mark all read.
281 onOpenAutoFocus={(event) => {
282 event.preventDefault();
283 (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[role="tab"][data-state="active"]')?.focus();
284 }}
285 >
286 <SheetHeader className="flex-row items-center justify-between gap-3">
287 <SheetTitle>Inbox</SheetTitle>
288 <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />
289 </SheetHeader>
290 <div className="border-b border-line px-5 py-3">
291 <InboxTabs tab={tab} counts={counts} onChange={setTab} />
292 </div>
293 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
294 {items == null ? (
295 list.state === "idle" && list.data?.tab === tab ? (
296 <p className="text-sm text-muted">The inbox could not be loaded. Try again in a moment.</p>
297 ) : (
298 <ul aria-busy="true" className="space-y-2">
299 {Array.from({ length: 4 }, (_, index) => (
300 <li key={index} className="flex gap-3 rounded-lg border border-line p-3">
301 <Skeleton className="size-8 rounded-full" />
302 <div className="flex-1 space-y-2">
303 <Skeleton className="h-3.5 w-3/4" />
304 <Skeleton className="h-3 w-1/2" />
305 </div>
306 </li>
307 ))}
308 </ul>
309 )
310 ) : items.length === 0 ? (
311 <InboxEmpty tab={tab} />
312 ) : (
313 <ul className="space-y-2">
314 {items.map((item) => (
315 <InboxCard key={item.id} item={item} onOpen={() => setOpen(false)} />
316 ))}
317 </ul>
318 )}
319 </div>
320 <SheetFooter className="justify-between">
321 <SheetDescription className="text-xs">Done, saved and snoozed items are in the full inbox.</SheetDescription>
322 <SheetClose asChild>
323 <Link to={tab === "all" ? "/inbox" : `/inbox?tab=${tab}`} className="shrink-0 text-sm font-medium text-accent hover:underline">
324 Open inbox
325 </Link>
326 </SheetClose>
327 </SheetFooter>
328 </SheetContent>
329 </Sheet>
330 );
331}
332
333/**
334 * Ask AI: here to say it is coming, and not yet usable. A disabled button
335 * gets no pointer events, so the tooltip hangs on a span around it.
336 */
337export function AskAi() {
338 return (
339 <Tooltip>
340 <TooltipTrigger asChild>
341 <span tabIndex={0} className="inline-flex rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent">
342 <button
343 type="button"
344 disabled
345 aria-label="Ask AI"
346 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"
347 >
348 <Sparkles size={15} className="text-merged" />
349 <span className="hidden sm:inline">Ask AI</span>
350 </button>
351 </span>
352 </TooltipTrigger>
353 <TooltipContent>Ask AI is coming later</TooltipContent>
354 </Tooltip>
355 );
356}
357
358/**
359 * Mission control's card of what needs the person from their inbox: the
360 * newest unread items an agent is waiting on, then failures. Not shown
361 * when there are none.
362 */
363export function InboxNeedsCard({ items, total }: { items: InboxItem[]; total: number }) {
364 if (items.length === 0) return null;
365 return (
366 <section className="rounded-xl border border-line bg-surface p-5">
367 <div className="flex items-center justify-between gap-3">
368 <h2 className="flex items-center gap-2 text-sm font-semibold">
369 <Hand size={14} className="text-warn" aria-hidden="true" />
370 Needs you
371 <span className="rounded-full bg-warn/15 px-1.5 text-[0.6875rem] font-medium tabular-nums text-warn">{total}</span>
372 </h2>
373 <Link to="/inbox?tab=needs" className="text-xs font-medium text-accent hover:underline">
374 Open inbox
375 </Link>
376 </div>
377 <ul className="mt-3 space-y-2">
378 {items.map((item) => (
379 <InboxCard key={item.id} item={item} />
380 ))}
381 </ul>
382 </section>
383 );
384}