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