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 | /** |
| 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 | } |