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.
17 files+1058−100/17 viewed
| 40 | 40 | --animate-fade-out: fade-out 0.12s ease-in forwards; | |
| 41 | 41 | --animate-dialog-in: dialog-in 0.2s cubic-bezier(0.16, 1, 0.3, 1); | |
| 42 | 42 | --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 | + | } | |
| 43 | 72 | } | |
| 44 | 73 | ||
| 45 | 74 | @keyframes pop-in { |
| 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 | + | } |
| 29 | 29 | import { cn } from "../lib/cn"; | |
| 30 | 30 | import { AgentComposer, type ComposerResult } from "./agent-composer"; | |
| 31 | 31 | import { AgentSetup } from "./agent-setup"; | |
| 32 | + | import { InboxNeedsCard } from "./inbox"; | |
| 32 | 33 | import type { ShellData } from "./shell"; | |
| 33 | 34 | import { useLiveRefresh } from "./agents"; | |
| 34 | 35 | import { Unavailable } from "./mission"; | |
| 1090 | 1091 | </section> | |
| 1091 | 1092 | ||
| 1092 | 1093 | <aside className="min-w-0 space-y-6"> | |
| 1094 | + | <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} /> | |
| 1093 | 1095 | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 1094 | 1096 | <div className="flex items-center justify-between"> | |
| 1095 | 1097 | <h2 className="text-base font-semibold tracking-tight">This week</h2> |
| 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"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { Abilities, Membership, Spike, User } from "@g1t/contracts"; | |
| 5 | + | import type { Abilities, InboxCounts, Membership, Spike, User } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette"; | |
| 8 | + | import { AskAi, InboxBell } from "./inbox"; | |
| 8 | 9 | import { StatusDot, useSiteStatus } from "./footer"; | |
| 9 | 10 | import { Logo, Mark } from "./logo"; | |
| 10 | 11 | import { Avatar, notACredential } from "./ui"; | |
| 62 | 63 | monthUsageMicros: number | null; | |
| 63 | 64 | /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */ | |
| 64 | 65 | 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; | |
| 65 | 68 | }; | |
| 66 | 69 | ||
| 67 | 70 | function SidebarLink({ | |
| 1085 | 1088 | const active = shell.repo; | |
| 1086 | 1089 | // In a repository, or on the way into one, its own list. | |
| 1087 | 1090 | 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"]); | |
| 1089 | 1092 | // An invitation to a repository is answered before its menu means anything. | |
| 1090 | 1093 | const inRepo = | |
| 1091 | 1094 | repoPath != null && | |
| 1248 | 1251 | return <span className="text-sm font-medium">Not found</span>; | |
| 1249 | 1252 | } | |
| 1250 | 1253 | 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"]; | |
| 1252 | 1255 | if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>; | |
| 1253 | 1256 | // Your settings: Settings / Emails. | |
| 1254 | 1257 | if (parts[0] === "settings") { | |
| 1285 | 1288 | support: "Support", | |
| 1286 | 1289 | status: "Status", | |
| 1287 | 1290 | invite: "Invite", | |
| 1291 | + | inbox: "Inbox", | |
| 1288 | 1292 | }; | |
| 1289 | 1293 | return <span className="text-sm font-medium">{words[parts[0]!]}</span>; | |
| 1290 | 1294 | } | |
| 1346 | 1350 | : []), | |
| 1347 | 1351 | { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> }, | |
| 1348 | 1352 | { 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} /> }, | |
| 1349 | 1354 | { label: "New project", to: "/new", icon: <Plus size={15} /> }, | |
| 1350 | 1355 | { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> }, | |
| 1351 | 1356 | { label: "Your settings", to: FIRST_SETTINGS_PAGE, icon: <Settings size={15} /> }, | |
| 1567 | 1572 | > | |
| 1568 | 1573 | Docs | |
| 1569 | 1574 | </a> | |
| 1575 | + | {user && ( | |
| 1576 | + | <> | |
| 1577 | + | <AskAi /> | |
| 1578 | + | <InboxBell counts={shell.inbox ?? null} /> | |
| 1579 | + | </> | |
| 1580 | + | )} | |
| 1570 | 1581 | {!user ? ( | |
| 1571 | 1582 | // The sidebar has these too, but on a phone it is folded away. | |
| 1572 | 1583 | <Link |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 13 | 13 | eventsClient, | |
| 14 | 14 | guardrailsClient, | |
| 15 | 15 | identityClient, | |
| 16 | + | inboxClient, | |
| 16 | 17 | integrationsClient, | |
| 17 | 18 | reposClient, | |
| 18 | 19 | searchClient, | |
| 49 | 50 | export const work = workClient(WORK); | |
| 50 | 51 | export const billing = billingClient(BILLING); | |
| 51 | 52 | export const events = eventsClient(EVENTS); | |
| 53 | + | /** Each person's inbox, which the events service keeps. */ | |
| 54 | + | export const inbox = inboxClient(EVENTS); | |
| 52 | 55 | export const integrations = integrationsClient(INTEGRATIONS); | |
| 53 | 56 | export const webhooks = webhooksClient(WEBHOOKS); | |
| 54 | 57 | export const actions = actionsClient(ACTIONS); |
| 54 | 54 | import { NotFound } from "./components/not-found"; | |
| 55 | 55 | import { usesAppShell } from "./lib/chrome"; | |
| 56 | 56 | 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"; | |
| 58 | 58 | import { countsFor, readableRepos } from "./lib/access.server"; | |
| 59 | 59 | import { shortCache } from "./lib/cache.server"; | |
| 60 | 60 | import { getViewer, viewerMiddleware } from "./lib/session.server"; | |
| 140 | 140 | // something (lib/cache.server.ts). | |
| 141 | 141 | const kept = <T,>(what: string, load: () => Promise<T>) => | |
| 142 | 142 | 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([ | |
| 144 | 144 | kept("projects", () => projects.list(workspace!.slug, user)), | |
| 145 | 145 | path ? countsFor(context, params) : Promise.resolve(null), | |
| 146 | 146 | // Whether billing is on, without reading the account: that asks the | |
| 150 | 150 | kept("limit", () => billing.limit(workspace!.slug, user)).catch(() => null), | |
| 151 | 151 | kept("entitlements", () => billing.entitlements(workspace!.slug)).catch(() => null), | |
| 152 | 152 | 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), | |
| 153 | 155 | ]); | |
| 154 | 156 | return { | |
| 155 | 157 | workspace, | |
| 189 | 191 | ? { paused: entitlements.paused, spike: entitlements.spike ?? null, owner: workspace.role === "owner" } | |
| 190 | 192 | : null, | |
| 191 | 193 | shared, | |
| 194 | + | inbox: unread, | |
| 192 | 195 | // While g1t is free every charge is zero, so usage is shown at cost. | |
| 193 | 196 | monthUsageMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : usage.value.spentMicros) : null, | |
| 194 | 197 | }; |
| 41 | 41 | route("explore", "routes/explore.tsx", { id: "explore" }), | |
| 42 | 42 | route("pricing", "routes/pricing.tsx"), | |
| 43 | 43 | 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"), | |
| 44 | 47 | // What the command palette shows as someone types. | |
| 45 | 48 | route("search.json", "routes/search-json.ts"), | |
| 46 | 49 | route("workspaces/new", "routes/workspace/new.tsx"), |
| 57 | 57 | withConfidence, | |
| 58 | 58 | } from "../lib/mission-control"; | |
| 59 | 59 | import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate"; | |
| 60 | + | import { needsYou } from "../lib/inbox"; | |
| 60 | 61 | import { Landing } from "../components/landing"; | |
| 61 | 62 | import { Skeleton, SkeletonRows } from "../components/ui/skeleton"; | |
| 62 | 63 | import { | |
| 65 | 66 | deployments, | |
| 66 | 67 | events as eventLog, | |
| 67 | 68 | identity, | |
| 69 | + | inbox, | |
| 68 | 70 | projects as projectsApi, | |
| 69 | 71 | repos as reposApi, | |
| 70 | 72 | work, | |
| 80 | 82 | const EVENTS_PER_PROJECT = 80; | |
| 81 | 83 | /** How many pull requests work lists at once (`LIST_PAGE` in services/work). */ | |
| 82 | 84 | 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; | |
| 83 | 88 | ||
| 84 | 89 | export function meta(args: Route.MetaArgs) { | |
| 85 | 90 | return page(args, { | |
| 100 | 105 | ||
| 101 | 106 | /** | |
| 102 | 107 | * 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. | |
| 105 | 112 | */ | |
| 106 | − | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { | |
| 113 | + | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { | |
| 107 | 114 | if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false; | |
| 115 | + | if (formAction === "/inbox") return false; | |
| 108 | 116 | return defaultShouldRevalidate; | |
| 109 | 117 | } | |
| 110 | 118 | ||
| 236 | 244 | ); | |
| 237 | 245 | }); | |
| 238 | 246 | ||
| 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([ | |
| 240 | 248 | reposP, | |
| 241 | 249 | soft("projects", perRepoP), | |
| 242 | 250 | soft("pulls", work.listActivePulls(viewer)), | |
| 252 | 260 | // Model tokens over the last six weeks: the workspace's, and yours. | |
| 253 | 261 | slug ? soft("tokens", billing.tokenUsage(slug, viewer)) : null, | |
| 254 | 262 | 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 })), | |
| 255 | 265 | ]); | |
| 256 | 266 | ||
| 257 | 267 | const repoList = repos ?? []; | |
| 664 | 674 | tokens: { workspace: okOr(tokens), mine: okOr(myTokens) }, | |
| 665 | 675 | groups, | |
| 666 | 676 | titles: shownTitles, | |
| 677 | + | inboxNeeds: needsYou(unread?.items ?? [], INBOX_SHOWN), | |
| 667 | 678 | // A run that is going makes the page worth refreshing on its own. | |
| 668 | 679 | changing: | |
| 669 | 680 | liveRuns.length > 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 | + | } |
| 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 | + | } |
| 45 | 45 | "waitlist", | |
| 46 | 46 | // g1t.sh/v2/: the container registry; `-` paths hold the others. | |
| 47 | 47 | "v2", | |
| 48 | + | // g1t.sh/inbox, and the name it might also go by. | |
| 49 | + | "inbox", | |
| 50 | + | "notifications", | |
| 48 | 51 | ]; | |
| 49 | 52 | ||
| 50 | 53 | /// Whether `value`, whatever its case, is a name nobody can register or |
| 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 | + | } |
| 13 | 13 | export * from "./github"; | |
| 14 | 14 | export * from "./guardrails"; | |
| 15 | 15 | export * from "./identity"; | |
| 16 | + | export * from "./inbox"; | |
| 16 | 17 | export * from "./instances"; | |
| 17 | 18 | export * from "./ids"; | |
| 18 | 19 | export * from "./integrations"; |
| 16 | 16 | "invite", "invites", "waitlist", | |
| 17 | 17 | // The container registry, at g1t.sh/v2/. | |
| 18 | 18 | "v2", | |
| 19 | + | // g1t.sh/inbox, and the name it might also go by. | |
| 20 | + | "inbox", "notifications", | |
| 19 | 21 | ]); | |
| 20 | 22 | ||
| 21 | 23 | /** Whether `value`, whatever its case, is a name nobody can register: a route, or g1t's own. */ |