Skip to content

Commit

Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all

The bell shows what is unread and opens a sheet from the right with tabs (All, Needs you, Errors, Success, Info), each item opening what it is about and marking it read, with Done, Save and Snooze at hand. /inbox lists the same with Saved and Done. Mission control shows what needs you from it. Ask AI sits beside the bell, disabled, until it comes. inbox and notifications are reserved names.

syntaqxcommitted Parent73e6416Browse files
17 files+1058−100/17 viewed
+29−0
4040 --animate-fade-out: fade-out 0.12s ease-in forwards;
4141 --animate-dialog-in: dialog-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
4242 --animate-dialog-out: dialog-out 0.12s ease-in forwards;
43+ /* Sheets (components/ui/sheet.tsx) slide in from their edge. */
44+ --animate-sheet-in-right: sheet-in-right 0.24s cubic-bezier(0.16, 1, 0.3, 1);
45+ --animate-sheet-out-right: sheet-out-right 0.16s ease-in forwards;
46+ --animate-sheet-in-left: sheet-in-left 0.24s cubic-bezier(0.16, 1, 0.3, 1);
47+ --animate-sheet-out-left: sheet-out-left 0.16s ease-in forwards;
48+}
49+
50+@keyframes sheet-in-right {
51+ from {
52+ transform: translateX(100%);
53+ }
54+}
55+
56+@keyframes sheet-out-right {
57+ to {
58+ transform: translateX(100%);
59+ }
60+}
61+
62+@keyframes sheet-in-left {
63+ from {
64+ transform: translateX(-100%);
65+ }
66+}
67+
68+@keyframes sheet-out-left {
69+ to {
70+ transform: translateX(-100%);
71+ }
4372 }
4473
4574 @keyframes pop-in {
+384−0
1+import { AlarmClock, Bell, Bookmark, Check, CheckCheck, CircleCheck, CircleX, Ellipsis, Hand, Inbox, Info, Mail, MailOpen, Sparkles, Undo2 } from "lucide-react";
2+import { type ReactNode, useEffect, useState } from "react";
3+import { Link, useFetcher } from "react-router";
4+
5+import type { InboxCounts, InboxItem, InboxSeverity } from "@g1t/contracts";
6+
7+import { SubmitButton } from "./ui";
8+import { Badge, type BadgeTone } from "./ui/badge";
9+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
10+import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
11+import { Skeleton } from "./ui/skeleton";
12+import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
13+import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
14+import { cn } from "../lib/cn";
15+import { isPending } from "../lib/pending";
16+import { INBOX_TABS, type InboxTab, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, whenShort } from "../lib/inbox";
17+import type { InboxPanelData } from "../routes/inbox-json";
18+
19+/** Where every inbox form posts (routes/inbox.tsx). */
20+const ACTION = "/inbox";
21+/** How often the bell asks for its count while the page is in view. */
22+const COUNT_EVERY_MS = 60_000;
23+
24+const TONE: Record<InboxSeverity, BadgeTone> = {
25+ error: "danger",
26+ warning: "warn",
27+ success: "accent",
28+ info: "info",
29+};
30+
31+const 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. */
39+function 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. */
49+function 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+ */
67+export 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+
163+const 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. */
167+export 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. */
179+export 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. */
207+export 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+ */
232+export 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+ */
337+export 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+ */
363+export 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+}
+2−0
2929 import { cn } from "../lib/cn";
3030 import { AgentComposer, type ComposerResult } from "./agent-composer";
3131 import { AgentSetup } from "./agent-setup";
32+import { InboxNeedsCard } from "./inbox";
3233 import type { ShellData } from "./shell";
3334 import { useLiveRefresh } from "./agents";
3435 import { Unavailable } from "./mission";
10901091 </section>
10911092
10921093 <aside className="min-w-0 space-y-6">
1094+ <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} />
10931095 <section className="rounded-xl border border-line bg-surface p-5">
10941096 <div className="flex items-center justify-between">
10951097 <h2 className="text-base font-semibold tracking-tight">This week</h2>
+15−4
1−import { Activity, BarChart3, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
5−import type { Abilities, Membership, Spike, User } from "@g1t/contracts";
5+import type { Abilities, InboxCounts, Membership, Spike, User } from "@g1t/contracts";
66
77 import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette";
8+import { AskAi, InboxBell } from "./inbox";
89 import { StatusDot, useSiteStatus } from "./footer";
910 import { Logo, Mark } from "./logo";
1011 import { Avatar, notACredential } from "./ui";
6263 monthUsageMicros: number | null;
6364 /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */
6465 compute?: { paused: string | null; spike: Spike | null; owner: boolean } | null;
66+ /** What is unread in their inbox, for the bell. Absent for a visitor. */
67+ inbox?: InboxCounts | null;
6568 };
6669
6770 function SidebarLink({
10851088 const active = shell.repo;
10861089 // In a repository, or on the way into one, its own list.
10871090 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
1088− const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth", "policies", "security", "support", "status", "invite", ".well-known"]);
1091+ const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth", "policies", "security", "support", "status", "invite", "inbox", ".well-known"]);
10891092 // An invitation to a repository is answered before its menu means anything.
10901093 const inRepo =
10911094 repoPath != null &&
12481251 return <span className="text-sm font-medium">Not found</span>;
12491252 }
12501253 const parts = pathname.split("/").filter(Boolean);
1251− const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite"];
1254+ const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite", "inbox"];
12521255 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
12531256 // Your settings: Settings / Emails.
12541257 if (parts[0] === "settings") {
12851288 support: "Support",
12861289 status: "Status",
12871290 invite: "Invite",
1291+ inbox: "Inbox",
12881292 };
12891293 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
12901294 }
13461350 : []),
13471351 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
13481352 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1353+ { label: "Inbox", hint: "What needs you", to: "/inbox", icon: <Bell size={15} /> },
13491354 { label: "New project", to: "/new", icon: <Plus size={15} /> },
13501355 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
13511356 { label: "Your settings", to: FIRST_SETTINGS_PAGE, icon: <Settings size={15} /> },
15671572 >
15681573 Docs
15691574 </a>
1575+ {user && (
1576+ <>
1577+ <AskAi />
1578+ <InboxBell counts={shell.inbox ?? null} />
1579+ </>
1580+ )}
15701581 {!user ? (
15711582 // The sidebar has these too, but on a phone it is folded away.
15721583 <Link
+70−0
1+import { X } from "lucide-react";
2+import { Dialog as Primitive } from "radix-ui";
3+import type { ComponentProps } from "react";
4+
5+import { cn } from "../../lib/cn";
6+import { DialogOverlay } from "./dialog";
7+
8+// shadcn/ui's sheet, styled with g1t's tokens: a dialog that slides in
9+// from the edge of the screen, for a panel to work in beside the page.
10+
11+export const Sheet = Primitive.Root;
12+export const SheetTrigger = Primitive.Trigger;
13+export const SheetClose = Primitive.Close;
14+
15+type Side = "right" | "left";
16+
17+const SIDES: Record<Side, string> = {
18+ // Full width on a phone, a column beside the page from there up.
19+ right:
20+ "inset-y-0 right-0 border-l data-[state=open]:animate-sheet-in-right data-[state=closed]:animate-sheet-out-right sm:max-w-md",
21+ left: "inset-y-0 left-0 border-r data-[state=open]:animate-sheet-in-left data-[state=closed]:animate-sheet-out-left sm:max-w-md",
22+};
23+
24+export function SheetContent({
25+ side = "right",
26+ className,
27+ children,
28+ showClose = true,
29+ ...props
30+}: ComponentProps<typeof Primitive.Content> & { side?: Side; showClose?: boolean }) {
31+ return (
32+ <Primitive.Portal>
33+ <DialogOverlay />
34+ <Primitive.Content
35+ className={cn(
36+ "fixed z-50 flex h-dvh w-full flex-col border-line-strong bg-surface text-fg shadow-2xl shadow-black/60 outline-none motion-reduce:animate-none",
37+ SIDES[side],
38+ className,
39+ )}
40+ {...props}
41+ >
42+ {children}
43+ {showClose && (
44+ <Primitive.Close
45+ aria-label="Close"
46+ className="absolute top-4 right-4 rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
47+ >
48+ <X size={16} />
49+ </Primitive.Close>
50+ )}
51+ </Primitive.Content>
52+ </Primitive.Portal>
53+ );
54+}
55+
56+export function SheetHeader({ className, ...props }: ComponentProps<"div">) {
57+ return <div className={cn("flex flex-col gap-1.5 border-b border-line px-5 py-4 pr-12", className)} {...props} />;
58+}
59+
60+export function SheetFooter({ className, ...props }: ComponentProps<"div">) {
61+ return <div className={cn("mt-auto flex items-center gap-2 border-t border-line px-5 py-3", className)} {...props} />;
62+}
63+
64+export function SheetTitle({ className, ...props }: ComponentProps<typeof Primitive.Title>) {
65+ return <Primitive.Title className={cn("text-base font-semibold tracking-tight", className)} {...props} />;
66+}
67+
68+export function SheetDescription({ className, ...props }: ComponentProps<typeof Primitive.Description>) {
69+ return <Primitive.Description className={cn("text-sm text-muted", className)} {...props} />;
70+}
+109−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { InboxItem } from "@g1t/contracts";
5+
6+import { bellCount, emptyFor, inboxTab, inboxView, markFromForm, needsYou, severityOf, snoozeUntil, tabCount, whenShort } from "./inbox.ts";
7+
8+const NOW = Date.parse("2026-10-07T12:00:00.000Z");
9+
10+test("tabs read from the address, and each shows one severity", () => {
11+ assert.equal(inboxTab("needs"), "needs");
12+ assert.equal(inboxTab("nonsense"), "all");
13+ assert.equal(inboxTab(null), "all");
14+ assert.equal(severityOf("needs"), "warning");
15+ assert.equal(severityOf("all"), null);
16+ assert.equal(inboxView("done"), "done");
17+ assert.equal(inboxView("archive"), "inbox");
18+});
19+
20+test("counts are what is unread under each tab", () => {
21+ const counts = { unread: 15, error: 3, warning: 0, success: 8, info: 4 };
22+ assert.equal(tabCount(counts, "all"), 15);
23+ assert.equal(tabCount(counts, "error"), 3);
24+ assert.equal(tabCount(counts, "needs"), 0);
25+ assert.equal(tabCount(null, "all"), 0);
26+});
27+
28+test("the bell shows a number up to 99", () => {
29+ assert.equal(bellCount(0), "");
30+ assert.equal(bellCount(null), "");
31+ assert.equal(bellCount(7), "7");
32+ assert.equal(bellCount(140), "99+");
33+});
34+
35+test("times are said in a few characters", () => {
36+ assert.equal(whenShort("2026-10-07T11:59:30.000Z", NOW), "just now");
37+ assert.equal(whenShort("2026-10-07T11:45:00.000Z", NOW), "15m ago");
38+ assert.equal(whenShort("2026-10-07T09:00:00.000Z", NOW), "3h ago");
39+ assert.equal(whenShort("2026-10-06T09:00:00.000Z", NOW), "Yesterday");
40+ assert.equal(whenShort("2026-10-03T12:00:00.000Z", NOW), "4d ago");
41+ assert.equal(whenShort("2026-09-20T12:00:00.000Z", NOW), "Sep 20");
42+});
43+
44+test("a snooze is for later, by one of a few choices", () => {
45+ assert.equal(snoozeUntil("3h", NOW), "2026-10-07T15:00:00.000Z");
46+ assert.equal(snoozeUntil("week", NOW), "2026-10-14T12:00:00.000Z");
47+ assert.equal(snoozeUntil("forever", NOW), null);
48+});
49+
50+test("forms ask for one mark on one item, or read for every item", () => {
51+ const form = (fields: Record<string, string>) => {
52+ const data = new FormData();
53+ for (const [name, value] of Object.entries(fields)) data.set(name, value);
54+ return data;
55+ };
56+ assert.deepEqual(markFromForm(form({ intent: "done", id: "ntf_1" }), NOW), { mark: "done", ids: ["ntf_1"] });
57+ assert.deepEqual(markFromForm(form({ intent: "snooze", id: "ntf_1", for: "tomorrow" }), NOW), {
58+ mark: "snooze",
59+ ids: ["ntf_1"],
60+ until: "2026-10-08T12:00:00.000Z",
61+ });
62+ assert.deepEqual(markFromForm(form({ intent: "read_all", tab: "error" }), NOW), { mark: "read", all: true, severity: "error" });
63+ assert.deepEqual(markFromForm(form({ intent: "read_all" }), NOW), { mark: "read", all: true, severity: null });
64+ assert.equal(markFromForm(form({ intent: "snooze", id: "ntf_1", for: "never" }), NOW), null);
65+ assert.equal(markFromForm(form({ intent: "delete", id: "ntf_1" }), NOW), null);
66+ assert.equal(markFromForm(form({ intent: "done" }), NOW), null);
67+});
68+
69+test("what needs you puts a waiting agent before a failure, and leaves out the rest", () => {
70+ const item = (id: string, severity: InboxItem["severity"], createdAt: string, readAt: string | null = null): InboxItem => ({
71+ id,
72+ reason: "x",
73+ severity,
74+ title: id,
75+ body: "",
76+ repo: "acme/rocket",
77+ workspace: "acme",
78+ subject: "pull",
79+ number: 1,
80+ url: "/acme/rocket/pull/1",
81+ actor: null,
82+ createdAt,
83+ readAt,
84+ doneAt: null,
85+ saved: false,
86+ snoozedUntil: null,
87+ });
88+ const needs = needsYou(
89+ [
90+ item("failed-new", "error", "2026-10-07T11:00:00.000Z"),
91+ item("waiting-old", "warning", "2026-10-06T11:00:00.000Z"),
92+ item("waiting-new", "warning", "2026-10-07T10:00:00.000Z"),
93+ item("merged", "success", "2026-10-07T11:30:00.000Z"),
94+ item("failed-read", "error", "2026-10-07T11:40:00.000Z", "2026-10-07T11:50:00.000Z"),
95+ ],
96+ 2,
97+ );
98+ assert.deepEqual(
99+ needs.items.map((entry) => entry.id),
100+ ["waiting-new", "waiting-old"],
101+ );
102+ assert.equal(needs.total, 3);
103+});
104+
105+test("every tab says something when it is empty", () => {
106+ assert.equal(emptyFor("all").title, "You're all caught up");
107+ assert.equal(emptyFor("needs").title, "Nothing needs you");
108+ assert.equal(emptyFor("all", "saved").title, "Nothing saved");
109+});
+146−0
1+/**
2+ * The inbox's tabs, times and actions, shared by the panel in the top bar
3+ * (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx). The
4+ * events service keeps the items and decides who is told of what.
5+ */
6+import type { InboxCounts, InboxItem, InboxMarkArgs, InboxSeverity, InboxView } from "@g1t/contracts";
7+
8+export type InboxTab = "all" | "needs" | "error" | "success" | "info";
9+
10+/** The tabs, in order, and the severity each shows. */
11+export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [
12+ { tab: "all", label: "All", severity: null },
13+ // Warnings are what an agent is waiting on a person for.
14+ { tab: "needs", label: "Needs you", severity: "warning" },
15+ { tab: "error", label: "Errors", severity: "error" },
16+ { tab: "success", label: "Success", severity: "success" },
17+ { tab: "info", label: "Info", severity: "info" },
18+];
19+
20+/** The words on an item's badge. */
21+export const SEVERITY_LABEL: Record<InboxSeverity, string> = {
22+ error: "Error",
23+ warning: "Needs you",
24+ success: "Success",
25+ info: "Info",
26+};
27+
28+export function inboxTab(value: string | null | undefined): InboxTab {
29+ return INBOX_TABS.find((entry) => entry.tab === value)?.tab ?? "all";
30+}
31+
32+export function severityOf(tab: InboxTab): InboxSeverity | null {
33+ return INBOX_TABS.find((entry) => entry.tab === tab)?.severity ?? null;
34+}
35+
36+export function inboxView(value: string | null | undefined): InboxView {
37+ return value === "saved" || value === "done" ? value : "inbox";
38+}
39+
40+/** What is unread under a tab. */
41+export function tabCount(counts: InboxCounts | null | undefined, tab: InboxTab): number {
42+ if (!counts) return 0;
43+ const severity = severityOf(tab);
44+ return severity ? counts[severity] : counts.unread;
45+}
46+
47+/** The bell's number: up to 99, then "99+". Empty when nothing is unread. */
48+export function bellCount(unread: number | null | undefined): string {
49+ if (!unread || unread < 1) return "";
50+ return unread > 99 ? "99+" : String(unread);
51+}
52+
53+/** What an empty tab says. */
54+export function emptyFor(tab: InboxTab, view: InboxView = "inbox"): { title: string; detail: string } {
55+ if (view === "saved") return { title: "Nothing saved", detail: "Save an item to keep it here after it is done." };
56+ if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." };
57+ switch (tab) {
58+ case "needs":
59+ return { title: "Nothing needs you", detail: "When an agent is waiting on you, it shows up here first." };
60+ case "error":
61+ return { title: "No failures", detail: "Failed checks and workflows on your work show up here." };
62+ case "success":
63+ return { title: "Nothing new landed", detail: "Merges, approvals and finished agent work show up here." };
64+ case "info":
65+ return { title: "No mentions or comments", detail: "Mentions of you and comments on your work show up here." };
66+ default:
67+ return { title: "You're all caught up", detail: "What needs you, or what you follow, shows up here as it happens." };
68+ }
69+}
70+
71+const MINUTE = 60_000;
72+const HOUR = 60 * MINUTE;
73+const DAY = 24 * HOUR;
74+
75+/** When, in a few characters: "just now", "15m ago", "3h ago", "Yesterday", "4d ago", "Sep 30". */
76+export function whenShort(at: string, now: number): string {
77+ const time = Date.parse(at);
78+ const elapsed = Math.max(0, now - time);
79+ if (elapsed < MINUTE) return "just now";
80+ if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
81+ if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
82+ if (elapsed < 2 * DAY) return "Yesterday";
83+ if (elapsed < 7 * DAY) return `${Math.floor(elapsed / DAY)}d ago`;
84+ return new Date(time).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
85+}
86+
87+/** How long an item can be snoozed for. */
88+export const SNOOZES = [
89+ { key: "3h", label: "3 hours", ms: 3 * HOUR },
90+ { key: "tomorrow", label: "Tomorrow", ms: DAY },
91+ { key: "week", label: "Next week", ms: 7 * DAY },
92+] as const;
93+
94+export function snoozeUntil(key: string | null | undefined, now: number): string | null {
95+ const snooze = SNOOZES.find((entry) => entry.key === key);
96+ return snooze ? new Date(now + snooze.ms).toISOString() : null;
97+}
98+
99+/** What the inbox forms post, by `intent`. */
100+const MARKS: Record<string, InboxMarkArgs["mark"]> = {
101+ read: "read",
102+ unread: "unread",
103+ done: "done",
104+ undone: "undone",
105+ save: "save",
106+ unsave: "unsave",
107+ snooze: "snooze",
108+};
109+
110+/**
111+ * A posted inbox form as the mark it asks for: one item (`id`), or every
112+ * item (`intent` `read_all`, optionally for one tab). Null when it asks for
113+ * nothing the inbox does.
114+ */
115+export function markFromForm(form: FormData, now: number): InboxMarkArgs | null {
116+ const intent = String(form.get("intent") ?? "");
117+ if (intent === "read_all") {
118+ return { mark: "read", all: true, severity: severityOf(inboxTab(String(form.get("tab") ?? ""))) };
119+ }
120+ const mark = MARKS[intent];
121+ const id = String(form.get("id") ?? "").trim();
122+ if (!mark || !id) return null;
123+ if (mark === "snooze") {
124+ const until = snoozeUntil(String(form.get("for") ?? ""), now);
125+ return until ? { mark, ids: [id], until } : null;
126+ }
127+ return { mark, ids: [id] };
128+}
129+
130+/**
131+ * Mission control's Needs you card, from unread items: what an agent is
132+ * waiting on first, then failures, newest first within each; `max` of them,
133+ * and how many there are in all.
134+ */
135+export function needsYou(items: InboxItem[], max: number): { items: InboxItem[]; total: number } {
136+ const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1);
137+ const needs = items
138+ .filter((item) => isUnread(item) && (item.severity === "warning" || item.severity === "error"))
139+ .sort((a, b) => rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt));
140+ return { items: needs.slice(0, max), total: needs.length };
141+}
142+
143+/** Whether an item is still unread. */
144+export function isUnread(item: Pick<InboxItem, "readAt">): boolean {
145+ return item.readAt == null;
146+}
+3−0
1313 eventsClient,
1414 guardrailsClient,
1515 identityClient,
16+ inboxClient,
1617 integrationsClient,
1718 reposClient,
1819 searchClient,
4950 export const work = workClient(WORK);
5051 export const billing = billingClient(BILLING);
5152 export const events = eventsClient(EVENTS);
53+/** Each person's inbox, which the events service keeps. */
54+export const inbox = inboxClient(EVENTS);
5255 export const integrations = integrationsClient(INTEGRATIONS);
5356 export const webhooks = webhooksClient(WEBHOOKS);
5457 export const actions = actionsClient(ACTIONS);
+5−2
5454 import { NotFound } from "./components/not-found";
5555 import { usesAppShell } from "./lib/chrome";
5656 import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./components/command-palette";
57−import { billing, projects } from "./lib/services.server";
57+import { billing, inbox, projects } from "./lib/services.server";
5858 import { countsFor, readableRepos } from "./lib/access.server";
5959 import { shortCache } from "./lib/cache.server";
6060 import { getViewer, viewerMiddleware } from "./lib/session.server";
140140 // something (lib/cache.server.ts).
141141 const kept = <T,>(what: string, load: () => Promise<T>) =>
142142 workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null);
143− const [listed, counts, status, usage, limit, entitlements, shared] = await Promise.all([
143+ const [listed, counts, status, usage, limit, entitlements, shared, unread] = await Promise.all([
144144 kept("projects", () => projects.list(workspace!.slug, user)),
145145 path ? countsFor(context, params) : Promise.resolve(null),
146146 // Whether billing is on, without reading the account: that asks the
150150 kept("limit", () => billing.limit(workspace!.slug, user)).catch(() => null),
151151 kept("entitlements", () => billing.entitlements(workspace!.slug)).catch(() => null),
152152 sharedRepos(user),
153+ // The bell's count: one query, never kept, so marking an item shows at once.
154+ inbox.counts(user.username).catch(() => null),
153155 ]);
154156 return {
155157 workspace,
189191 ? { paused: entitlements.paused, spike: entitlements.spike ?? null, owner: workspace.role === "owner" }
190192 : null,
191193 shared,
194+ inbox: unread,
192195 // While g1t is free every charge is zero, so usage is shown at cost.
193196 monthUsageMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : usage.value.spentMicros) : null,
194197 };
+3−0
4141 route("explore", "routes/explore.tsx", { id: "explore" }),
4242 route("pricing", "routes/pricing.tsx"),
4343 route("search", "routes/search.tsx"),
44+ // Each person's inbox, and what its panel in the top bar fetches.
45+ route("inbox", "routes/inbox.tsx"),
46+ route("inbox.json", "routes/inbox-json.ts"),
4447 // What the command palette shows as someone types.
4548 route("search.json", "routes/search-json.ts"),
4649 route("workspaces/new", "routes/workspace/new.tsx"),
+15−4
5757 withConfidence,
5858 } from "../lib/mission-control";
5959 import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate";
60+import { needsYou } from "../lib/inbox";
6061 import { Landing } from "../components/landing";
6162 import { Skeleton, SkeletonRows } from "../components/ui/skeleton";
6263 import {
6566 deployments,
6667 events as eventLog,
6768 identity,
69+ inbox,
6870 projects as projectsApi,
6971 repos as reposApi,
7072 work,
8082 const EVENTS_PER_PROJECT = 80;
8183 /** How many pull requests work lists at once (`LIST_PAGE` in services/work). */
8284 const PULL_PAGE = 100;
85+/** Unread inbox items read for the Needs you card, and how many it shows. */
86+const INBOX_READ = 50;
87+const INBOX_SHOWN = 4;
8388
8489 export function meta(args: Route.MetaArgs) {
8590 return page(args, {
100105
101106 /**
102107 * Switching tabs, the sort or the activity list changes only the address:
103− * everything is already loaded. A refresh, a form, or anything else loads
104− * again as usual.
108+ * everything is already loaded. Marking an inbox item read or done changes
109+ * nothing else here: the card steps out by itself, and the bell's count
110+ * comes with the shell (root.tsx). A refresh, a form, or anything else
111+ * loads again as usual.
105112 */
106−export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
113+export function shouldRevalidate({ currentUrl, nextUrl, formMethod, formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
107114 if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false;
115+ if (formAction === "/inbox") return false;
108116 return defaultShouldRevalidate;
109117 }
110118
236244 );
237245 });
238246
239− const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations, tokens, myTokens] = await Promise.all([
247+ const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations, tokens, myTokens, unread] = await Promise.all([
240248 reposP,
241249 soft("projects", perRepoP),
242250 soft("pulls", work.listActivePulls(viewer)),
252260 // Model tokens over the last six weeks: the workspace's, and yours.
253261 slug ? soft("tokens", billing.tokenUsage(slug, viewer)) : null,
254262 slug ? soft("my_tokens", billing.tokenUsage(slug, viewer, { person: username })) : null,
263+ // What in their inbox is unread: an agent waiting on them, or a failure, goes on the Needs you card.
264+ soft("inbox", inbox.list(viewer, { unread: true, limit: INBOX_READ })),
255265 ]);
256266
257267 const repoList = repos ?? [];
664674 tokens: { workspace: okOr(tokens), mine: okOr(myTokens) },
665675 groups,
666676 titles: shownTitles,
677+ inboxNeeds: needsYou(unread?.items ?? [], INBOX_SHOWN),
667678 // A run that is going makes the page worth refreshing on its own.
668679 changing:
669680 liveRuns.length > 0 ||
+39−0
1+/**
2+ * What the inbox panel in the top bar shows (components/inbox.tsx), fetched
3+ * when it opens and for each tab: the newest items under the tab, and the
4+ * unread counts. `?only=counts` is the bell's count alone, asked for every
5+ * minute while a page is in view.
6+ */
7+import type { InboxCounts, InboxItem } from "@g1t/contracts";
8+
9+import type { Route } from "./+types/inbox-json";
10+import { type InboxTab, inboxTab, severityOf } from "../lib/inbox";
11+import { inbox } from "../lib/services.server";
12+import { getViewer } from "../lib/session.server";
13+
14+/** As many items as the panel shows; the rest are on /inbox. */
15+const PANEL_ITEMS = 30;
16+
17+export type InboxPanelData = {
18+ tab: InboxTab;
19+ /** Null when only the counts were asked for, or the list could not be read. */
20+ items: InboxItem[] | null;
21+ counts: InboxCounts | null;
22+};
23+
24+export async function loader({ request, context }: Route.LoaderArgs) {
25+ // Never kept: it changes as people work through it.
26+ const headers = { "cache-control": "private, no-store" };
27+ const user = getViewer(context);
28+ if (!user) return Response.json(null, { status: 401, headers });
29+ const url = new URL(request.url);
30+ const tab = inboxTab(url.searchParams.get("tab"));
31+ const [page, counts] = await Promise.all([
32+ url.searchParams.get("only") === "counts"
33+ ? null
34+ : inbox.list(user, { severity: severityOf(tab), limit: PANEL_ITEMS }).catch(() => null),
35+ inbox.counts(user.username).catch(() => null),
36+ ]);
37+ const data: InboxPanelData = { tab, items: page?.items ?? null, counts };
38+ return Response.json(data, { headers });
39+}
+129−0
1+/**
2+ * The inbox, in full: the same tabs as the panel in the top bar, with what
3+ * was saved and what is done, a page at a time. Every inbox form posts
4+ * here, the panel's included.
5+ */
6+import { Bookmark, Check, Inbox as InboxIcon } from "lucide-react";
7+import { Link, data, useNavigate } from "react-router";
8+
9+import type { Route } from "./+types/inbox";
10+import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox";
11+import { cn } from "../lib/cn";
12+import { inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox";
13+import { page } from "../lib/meta";
14+import { inbox } from "../lib/services.server";
15+import { assertSameOrigin, requireUser } from "../lib/session.server";
16+
17+const PAGE_ITEMS = 50;
18+
19+export function meta(args: Route.MetaArgs) {
20+ return page(args, { title: "Inbox · g1t" });
21+}
22+
23+export async function loader({ request, context }: Route.LoaderArgs) {
24+ const user = requireUser(context, request);
25+ const url = new URL(request.url);
26+ const tab = inboxTab(url.searchParams.get("tab"));
27+ const view = inboxView(url.searchParams.get("view"));
28+ const before = url.searchParams.get("before");
29+ const [list, counts] = await Promise.all([
30+ inbox.list(user, { view, severity: severityOf(tab), before, limit: PAGE_ITEMS }).catch(() => null),
31+ inbox.counts(user.username).catch(() => null),
32+ ]);
33+ return { tab, view, before, items: list?.items ?? null, next: list?.next ?? null, counts };
34+}
35+
36+export async function action({ request, context }: Route.ActionArgs) {
37+ assertSameOrigin(request);
38+ const user = requireUser(context, request);
39+ const mark = markFromForm(await request.formData(), Date.now());
40+ if (!mark) return data({ error: "Nothing to do." }, { status: 400 });
41+ try {
42+ await inbox.mark(user.username, mark);
43+ return { error: null };
44+ } catch {
45+ return data({ error: "That did not save. Try again in a moment." }, { status: 503 });
46+ }
47+}
48+
49+const VIEWS = [
50+ { view: "inbox", label: "Inbox", icon: <InboxIcon size={14} /> },
51+ { view: "saved", label: "Saved", icon: <Bookmark size={14} /> },
52+ { view: "done", label: "Done", icon: <Check size={14} /> },
53+] as const;
54+
55+export default function InboxPage({ loaderData }: Route.ComponentProps) {
56+ const { tab, view, before, items, next, counts } = loaderData;
57+ const navigate = useNavigate();
58+ const address = (changes: Record<string, string | null>) => {
59+ const params = new URLSearchParams();
60+ const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, ...changes };
61+ for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value);
62+ const query = params.toString();
63+ return query ? `/inbox?${query}` : "/inbox";
64+ };
65+
66+ return (
67+ <main className="mx-auto max-w-3xl px-4 py-8 sm:px-6">
68+ <div className="flex flex-wrap items-center justify-between gap-3">
69+ <div>
70+ <h1 className="text-2xl font-semibold tracking-tight">Inbox</h1>
71+ <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What an agent is waiting on comes first.</p>
72+ </div>
73+ {view === "inbox" && <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />}
74+ </div>
75+
76+ <nav aria-label="Inbox views" className="mt-6 flex gap-1 border-b border-line">
77+ {VIEWS.map((entry) => (
78+ <Link
79+ key={entry.view}
80+ to={address({ view: entry.view === "inbox" ? null : entry.view, before: null })}
81+ aria-current={view === entry.view ? "page" : undefined}
82+ className={cn(
83+ "-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm transition-colors",
84+ view === entry.view ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
85+ )}
86+ >
87+ {entry.icon}
88+ {entry.label}
89+ </Link>
90+ ))}
91+ </nav>
92+
93+ <div className="mt-4">
94+ <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} />
95+ </div>
96+
97+ <div className="mt-4">
98+ {items == null ? (
99+ <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">The inbox could not be loaded. Try again in a moment.</p>
100+ ) : items.length === 0 ? (
101+ <InboxEmpty tab={tab} view={view} />
102+ ) : (
103+ <ul className="space-y-2">
104+ {items.map((item) => (
105+ <InboxCard key={item.id} item={item} />
106+ ))}
107+ </ul>
108+ )}
109+ </div>
110+
111+ {(before || next) && (
112+ <div className="mt-6 flex justify-between text-sm">
113+ {before ? (
114+ <Link to={address({ before: null })} className="text-muted hover:text-fg">
115+ Newest
116+ </Link>
117+ ) : (
118+ <span />
119+ )}
120+ {next && (
121+ <Link to={address({ before: next })} className="font-medium text-accent hover:underline">
122+ Older
123+ </Link>
124+ )}
125+ </div>
126+ )}
127+ </main>
128+ );
129+}
+3−0
4545 "waitlist",
4646 // g1t.sh/v2/: the container registry; `-` paths hold the others.
4747 "v2",
48+ // g1t.sh/inbox, and the name it might also go by.
49+ "inbox",
50+ "notifications",
4851 ];
4952
5053 /// Whether `value`, whatever its case, is a name nobody can register or
+103−0
1+/**
2+ * The inbox: what needs a person, or what they follow, as it happens. The
3+ * events service keeps it and writes items as events arrive. Mirrors
4+ * `crates/contracts/src/inbox.rs`, which says who is told of what.
5+ */
6+import type { ServiceBinding } from "./clients";
7+import type { User } from "./identity";
8+
9+/**
10+ * How much an item matters: a failure, something a person must answer (an
11+ * agent waiting on them), something that went well, or something to know.
12+ */
13+export type InboxSeverity = "error" | "warning" | "success" | "info";
14+
15+export const INBOX_SEVERITIES: InboxSeverity[] = ["error", "warning", "success", "info"];
16+
17+export type InboxSubjectKind = "issue" | "pull" | "run";
18+
19+export type InboxItem = {
20+ id: string;
21+ /** Why they were told, such as `checks_failed`, `agent_asked` or `mentioned`. */
22+ reason: string;
23+ severity: InboxSeverity;
24+ /** One line: what happened, and where. */
25+ title: string;
26+ /** One line: what it happened to, such as the pull request's title. */
27+ body: string;
28+ /** `owner/name`. */
29+ repo: string | null;
30+ workspace: string | null;
31+ subject: InboxSubjectKind | null;
32+ number: number | null;
33+ /** A path on g1t.sh, such as `/acme/rocket/pull/12`. */
34+ url: string;
35+ /** A username, or `g1t`. */
36+ actor: string | null;
37+ /** RFC 3339. */
38+ createdAt: string;
39+ readAt: string | null;
40+ doneAt: string | null;
41+ saved: boolean;
42+ snoozedUntil: string | null;
43+};
44+
45+/** The inbox itself (not done, not snoozed), what was saved, or what is done. */
46+export type InboxView = "inbox" | "saved" | "done";
47+
48+export type InboxQuery = {
49+ view?: InboxView;
50+ severity?: InboxSeverity | null;
51+ unread?: boolean;
52+ /** The `next` of the page before. */
53+ before?: string | null;
54+ /** At most 100. */
55+ limit?: number;
56+};
57+
58+export type InboxPage = { items: InboxItem[]; next: string | null };
59+
60+/** Unread items in the inbox, by severity. */
61+export type InboxCounts = { unread: number } & Record<InboxSeverity, number>;
62+
63+export type InboxMark = "read" | "unread" | "done" | "undone" | "save" | "unsave" | "snooze";
64+
65+export type InboxMarkArgs = {
66+ mark: InboxMark;
67+ /** At most 100. */
68+ ids?: string[];
69+ /** Every item in the inbox, when `ids` is empty: Mark all read. */
70+ all?: boolean;
71+ severity?: InboxSeverity | null;
72+ /** For `snooze`: RFC 3339, later than now. */
73+ until?: string | null;
74+};
75+
76+export interface InboxApi {
77+ /**
78+ * Newest first; in the inbox unfiltered, unread warnings first. Items about
79+ * a repository the viewer can no longer read are dropped.
80+ */
81+ list(viewer: User, query?: InboxQuery): Promise<InboxPage>;
82+ counts(username: string): Promise<InboxCounts>;
83+ /** Changes the person's own items. Returns how many changed. */
84+ mark(username: string, args: InboxMarkArgs): Promise<number>;
85+}
86+
87+/** The inbox, which the events service keeps. */
88+export function inboxClient(events: ServiceBinding): InboxApi {
89+ const call = async <T>(method: string, args: object): Promise<T> => {
90+ const response = await events.fetch(`https://service/rpc/${method}`, {
91+ method: "POST",
92+ headers: { "content-type": "application/json" },
93+ body: JSON.stringify(args),
94+ });
95+ if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
96+ return (await response.json()) as T;
97+ };
98+ return {
99+ list: (viewer, query = {}) => call("inbox_list", { viewer, ...query }),
100+ counts: (username) => call("inbox_counts", { username }),
101+ mark: (username, args) => call("inbox_mark", { username, ...args }),
102+ };
103+}
+1−0
1313 export * from "./github";
1414 export * from "./guardrails";
1515 export * from "./identity";
16+export * from "./inbox";
1617 export * from "./instances";
1718 export * from "./ids";
1819 export * from "./integrations";
+2−0
1616 "invite", "invites", "waitlist",
1717 // The container registry, at g1t.sh/v2/.
1818 "v2",
19+ // g1t.sh/inbox, and the name it might also go by.
20+ "inbox", "notifications",
1921 ]);
2022
2123 /** Whether `value`, whatever its case, is a name nobody can register: a route, or g1t's own. */