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 | /** |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 2 | * The inbox's tabs, reasons, times and actions, shared by the panel in the |
| 3 | * top bar (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx), | |
| 4 | * and how people subscribe to threads and watch repositories | |
| 5 | * (components/notifications.tsx). The events service keeps the items and | |
| 6 | * decides who is told of what. | |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 7 | */ |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 8 | import type { |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 9 | DigestCadence, |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 10 | InboxCounts, |
| 11 | InboxItem, | |
| 12 | InboxMarkArgs, | |
| 13 | InboxReason, | |
| 14 | InboxSeverity, | |
| 15 | InboxView, | |
| 16 | ThreadSubscription, | |
| 17 | WatchEvent, | |
| 18 | WatchLevel, | |
| 19 | } from "@g1t/contracts"; | |
| 20 | ||
| 21 | // The contracts' lists, as types only, so this file runs under `node --test`. | |
| 22 | // Typed by the contract: a reason or kind added there must be added here. | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 23 | // `agent` (an agent waiting on you) is no longer sent, but old items carry it. |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 24 | const INBOX_REASONS: InboxReason[] = [ |
| 25 | "agent", | |
| 26 | "review_requested", | |
| 27 | "assign", | |
| 28 | "mention", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 29 | "team_mention", |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 30 | "ci_activity", |
| 31 | "security_alert", | |
| 32 | "state_change", | |
| 33 | "author", | |
| 34 | "comment", | |
| 35 | "manual", | |
| 36 | "subscribed", | |
| 37 | ]; | |
| 38 | const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"]; | |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 39 | |
| 40 | export type InboxTab = "all" | "needs" | "error" | "success" | "info"; | |
| 41 | ||
| 42 | /** The tabs, in order, and the severity each shows. */ | |
| 43 | export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [ | |
| 44 | { tab: "all", label: "All", severity: null }, | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 45 | // Warnings are what is waiting on a person: a review asked of them, say. |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 46 | { tab: "needs", label: "Needs you", severity: "warning" }, |
| 47 | { tab: "error", label: "Errors", severity: "error" }, | |
| 48 | { tab: "success", label: "Success", severity: "success" }, | |
| 49 | { tab: "info", label: "Info", severity: "info" }, | |
| 50 | ]; | |
| 51 | ||
| 52 | /** The words on an item's badge. */ | |
| 53 | export const SEVERITY_LABEL: Record<InboxSeverity, string> = { | |
| 54 | error: "Error", | |
| 55 | warning: "Needs you", | |
| 56 | success: "Success", | |
| 57 | info: "Info", | |
| 58 | }; | |
| 59 | ||
| 60 | export function inboxTab(value: string | null | undefined): InboxTab { | |
| 61 | return INBOX_TABS.find((entry) => entry.tab === value)?.tab ?? "all"; | |
| 62 | } | |
| 63 | ||
| 64 | export function severityOf(tab: InboxTab): InboxSeverity | null { | |
| 65 | return INBOX_TABS.find((entry) => entry.tab === tab)?.severity ?? null; | |
| 66 | } | |
| 67 | ||
| 68 | export function inboxView(value: string | null | undefined): InboxView { | |
| 69 | return value === "saved" || value === "done" ? value : "inbox"; | |
| 70 | } | |
| 71 | ||
| 72 | /** What is unread under a tab. */ | |
| 73 | export function tabCount(counts: InboxCounts | null | undefined, tab: InboxTab): number { | |
| 74 | if (!counts) return 0; | |
| 75 | const severity = severityOf(tab); | |
| 76 | return severity ? counts[severity] : counts.unread; | |
| 77 | } | |
| 78 | ||
| 79 | /** The bell's number: up to 99, then "99+". Empty when nothing is unread. */ | |
| 80 | export function bellCount(unread: number | null | undefined): string { | |
| 81 | if (!unread || unread < 1) return ""; | |
| 82 | return unread > 99 ? "99+" : String(unread); | |
| 83 | } | |
| 84 | ||
| 85 | /** What an empty tab says. */ | |
| 86 | export function emptyFor(tab: InboxTab, view: InboxView = "inbox"): { title: string; detail: string } { | |
| 87 | if (view === "saved") return { title: "Nothing saved", detail: "Save an item to keep it here after it is done." }; | |
| 88 | if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." }; | |
| 89 | switch (tab) { | |
| 90 | case "needs": | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 91 | return { title: "Nothing needs you", detail: "When someone asks for your review, or something waits on you, it shows up here first." }; |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 92 | case "error": |
| 93 | return { title: "No failures", detail: "Failed checks and workflows on your work show up here." }; | |
| 94 | case "success": | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 95 | return { title: "Nothing new landed", detail: "Merges and approvals show up here." }; |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 96 | case "info": |
| 97 | return { title: "No mentions or comments", detail: "Mentions of you and comments on your work show up here." }; | |
| 98 | default: | |
| 99 | return { title: "You're all caught up", detail: "What needs you, or what you follow, shows up here as it happens." }; | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | const MINUTE = 60_000; | |
| 104 | const HOUR = 60 * MINUTE; | |
| 105 | const DAY = 24 * HOUR; | |
| 106 | ||
| 107 | /** When, in a few characters: "just now", "15m ago", "3h ago", "Yesterday", "4d ago", "Sep 30". */ | |
| 108 | export function whenShort(at: string, now: number): string { | |
| 109 | const time = Date.parse(at); | |
| 110 | const elapsed = Math.max(0, now - time); | |
| 111 | if (elapsed < MINUTE) return "just now"; | |
| 112 | if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`; | |
| 113 | if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`; | |
| 114 | if (elapsed < 2 * DAY) return "Yesterday"; | |
| 115 | if (elapsed < 7 * DAY) return `${Math.floor(elapsed / DAY)}d ago`; | |
| 116 | return new Date(time).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); | |
| 117 | } | |
| 118 | ||
| 119 | /** How long an item can be snoozed for. */ | |
| 120 | export const SNOOZES = [ | |
| 121 | { key: "3h", label: "3 hours", ms: 3 * HOUR }, | |
| 122 | { key: "tomorrow", label: "Tomorrow", ms: DAY }, | |
| 123 | { key: "week", label: "Next week", ms: 7 * DAY }, | |
| 124 | ] as const; | |
| 125 | ||
| 126 | export function snoozeUntil(key: string | null | undefined, now: number): string | null { | |
| 127 | const snooze = SNOOZES.find((entry) => entry.key === key); | |
| 128 | return snooze ? new Date(now + snooze.ms).toISOString() : null; | |
| 129 | } | |
| 130 | ||
| 131 | /** What the inbox forms post, by `intent`. */ | |
| 132 | const MARKS: Record<string, InboxMarkArgs["mark"]> = { | |
| 133 | read: "read", | |
| 134 | unread: "unread", | |
| 135 | done: "done", | |
| 136 | undone: "undone", | |
| 137 | save: "save", | |
| 138 | unsave: "unsave", | |
| 139 | snooze: "snooze", | |
| 140 | }; | |
| 141 | ||
| 142 | /** | |
| 143 | * A posted inbox form as the mark it asks for: one item (`id`), or every | |
| 144 | * item (`intent` `read_all`, optionally for one tab). Null when it asks for | |
| 145 | * nothing the inbox does. | |
| 146 | */ | |
| 147 | export function markFromForm(form: FormData, now: number): InboxMarkArgs | null { | |
| 148 | const intent = String(form.get("intent") ?? ""); | |
| 149 | if (intent === "read_all") { | |
| 150 | return { mark: "read", all: true, severity: severityOf(inboxTab(String(form.get("tab") ?? ""))) }; | |
| 151 | } | |
| 152 | const mark = MARKS[intent]; | |
| 153 | const id = String(form.get("id") ?? "").trim(); | |
| 154 | if (!mark || !id) return null; | |
| 155 | if (mark === "snooze") { | |
| 156 | const until = snoozeUntil(String(form.get("for") ?? ""), now); | |
| 157 | return until ? { mark, ids: [id], until } : null; | |
| 158 | } | |
| 159 | return { mark, ids: [id] }; | |
| 160 | } | |
| 161 | ||
| 162 | /** | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 163 | * Mission control's Needs you card, from unread items: what is waiting on |
| 164 | * the person first, then failures, newest first within each; `max` of them, | |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 165 | * and how many there are in all. |
| 166 | */ | |
| 167 | export function needsYou(items: InboxItem[], max: number): { items: InboxItem[]; total: number } { | |
| 168 | const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1); | |
| 169 | const needs = items | |
| 170 | .filter((item) => isUnread(item) && (item.severity === "warning" || item.severity === "error")) | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 171 | .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt)); |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 172 | return { items: needs.slice(0, max), total: needs.length }; |
| 173 | } | |
| 174 | ||
| 175 | /** Whether an item is still unread. */ | |
| 176 | export function isUnread(item: Pick<InboxItem, "readAt">): boolean { | |
| 177 | return item.readAt == null; | |
| 178 | } | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 179 | |
| 180 | /** Why someone was told, in a few quiet words on the card. */ | |
| 181 | export const REASON_LABEL: Record<InboxReason, string> = { | |
| 182 | agent: "agent waiting", | |
| 183 | review_requested: "review requested", | |
| 184 | assign: "assigned", | |
| 185 | mention: "mentioned", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 186 | team_mention: "team mentioned", |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 187 | ci_activity: "CI activity", |
| 188 | security_alert: "security alert", | |
| 189 | state_change: "state changed", | |
| 190 | author: "your work", | |
| 191 | comment: "commented", | |
| 192 | manual: "subscribed", | |
| 193 | subscribed: "watching", | |
| 194 | }; | |
| 195 | ||
| 196 | /** The reason filter on /inbox, from the address: null for any reason. */ | |
| 197 | export function inboxReason(value: string | null | undefined): InboxReason | null { | |
| 198 | return INBOX_REASONS.find((reason) => reason === value) ?? null; | |
| 199 | } | |
| 200 | ||
| 201 | /** The reason filter's choices, in the order reasons rank. */ | |
| 202 | export const REASON_FILTERS: { reason: InboxReason | null; label: string }[] = [ | |
| 203 | { reason: null, label: "Any reason" }, | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 204 | ...INBOX_REASONS.filter((reason) => reason !== "agent").map((reason) => ({ reason, label: REASON_LABEL[reason].replace(/^./, (c) => c.toUpperCase()) })), |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 205 | ]; |
| 206 | ||
| 207 | /** How much has happened on a thread, when more than one thing has: "3 updates". */ | |
| 208 | export function updatesLabel(count: number | null | undefined): string { | |
| 209 | return count && count > 1 ? `${count} updates` : ""; | |
| 210 | } | |
| 211 | ||
| 212 | /** The ways to watch a repository, in the order the menu shows them. */ | |
| 213 | export const WATCH_CHOICES: { level: WatchLevel; label: string; detail: string }[] = [ | |
| 214 | { level: "participating", label: "Participating and @mentions", detail: "Only what you take part in, or are mentioned in." }, | |
| 215 | { level: "all", label: "All activity", detail: "Every issue and pull request, and every deployment." }, | |
| 216 | { level: "ignore", label: "Ignore", detail: "Nothing at all, not even a mention." }, | |
| 217 | { level: "custom", label: "Custom", detail: "What you take part in, and the kinds you choose." }, | |
| 218 | ]; | |
| 219 | ||
| 220 | /** The kinds a custom watch can follow, with their names for people. */ | |
| 221 | export const WATCH_EVENT_LABEL: Record<WatchEvent, string> = { | |
| 222 | issues: "Issues", | |
| 223 | pulls: "Pull requests", | |
| 224 | deployments: "Deployments", | |
| 225 | security: "Security alerts", | |
| 226 | }; | |
| 227 | ||
| 228 | /** The Watch button's words for how someone watches. */ | |
| 229 | export function watchLabel(level: WatchLevel | null | undefined): string { | |
| 230 | switch (level) { | |
| 231 | case "all": | |
| 232 | return "Watching"; | |
| 233 | case "custom": | |
| 234 | return "Watching some"; | |
| 235 | case "ignore": | |
| 236 | return "Ignoring"; | |
| 237 | default: | |
| 238 | return "Watch"; | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * A posted watch form as the level and kinds it asks for: `level`, and for | |
| 244 | * `custom` the `event` fields checked. A custom watch with nothing checked | |
| 245 | * is participating. Null when the level is not one. | |
| 246 | */ | |
| 247 | export function watchFromForm(form: FormData): { level: WatchLevel; events: WatchEvent[] } | null { | |
| 248 | const level = String(form.get("level") ?? ""); | |
| 249 | if (!["participating", "all", "ignore", "custom"].includes(level)) return null; | |
| 250 | if (level !== "custom") return { level: level as WatchLevel, events: [] }; | |
| 251 | const checked = form.getAll("event").map(String); | |
| 252 | const events = WATCH_EVENTS.filter((event) => checked.includes(event)); | |
| 253 | return events.length > 0 ? { level: "custom", events } : { level: "participating", events: [] }; | |
| 254 | } | |
| 255 | ||
| 256 | /** The line under the subscribe button: whether, and why. */ | |
| 257 | export function subscriptionLine(subscription: ThreadSubscription | null | undefined, kind: "issue" | "pull"): string { | |
| 258 | const thing = kind === "pull" ? "pull request" : "issue"; | |
| 259 | if (!subscription) return `Subscribe to hear of what happens on this ${thing}.`; | |
| 260 | if (subscription.ignored) return `You ignore this ${thing}: you hear of nothing on it, not even a mention.`; | |
| 261 | if (!subscription.subscribed) return "You're not subscribed. You'll still hear if you're mentioned or asked to review."; | |
| 262 | switch (subscription.reason) { | |
| 263 | case "author": | |
| 264 | return `You're subscribed because you opened this ${thing}, or asked g1t for it.`; | |
| 265 | case "assign": | |
| 266 | return "You're subscribed because you were assigned."; | |
| 267 | case "review_requested": | |
| 268 | return "You're subscribed because you were asked to review."; | |
| 269 | case "comment": | |
| 270 | return "You're subscribed because you commented."; | |
| 271 | case "mention": | |
| 272 | return "You're subscribed because you were mentioned."; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 273 | case "team_mention": |
| 274 | return "You're subscribed because a team you're in was mentioned."; | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 275 | default: |
| 276 | return `You're subscribed to this ${thing}.`; | |
| 277 | } | |
| 278 | } | |
| 279 | ||
| 280 | /** What a posted subscription form asks for: subscribe, unsubscribe, ignore, or the default. */ | |
| 281 | export function subscriptionFromForm(form: FormData): { subscribed: boolean | null; ignored: boolean } | null { | |
| 282 | switch (String(form.get("intent") ?? "")) { | |
| 283 | case "subscribe": | |
| 284 | return { subscribed: true, ignored: false }; | |
| 285 | case "unsubscribe": | |
| 286 | return { subscribed: false, ignored: false }; | |
| 287 | case "ignore": | |
| 288 | return { subscribed: false, ignored: true }; | |
| 289 | case "default": | |
| 290 | return { subscribed: null, ignored: false }; | |
| 291 | default: | |
| 292 | return null; | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | /** The reasons someone can be emailed for, in the settings' order, with what each is. */ | |
| 297 | export const EMAIL_REASONS: { reason: InboxReason; label: string; detail: string }[] = [ | |
| 298 | { reason: "review_requested", label: "You're asked to review", detail: "Someone asked for your review of a pull request." }, | |
| 299 | { reason: "mention", label: "You're mentioned", detail: "Someone wrote your @username in a comment." }, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 300 | { reason: "team_mention", label: "Your team is mentioned", detail: "Someone wrote @workspace/team for a team you're in." }, |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 301 | { reason: "assign", label: "You're assigned", detail: "Someone assigned you an issue or a pull request." }, |
| 302 | { reason: "ci_activity", label: "Your work's checks and deployments", detail: "Checks, a workflow or a deployment failed on your work." }, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 303 | { |
| 304 | reason: "security_alert", | |
| 305 | label: "Security alerts", | |
| 306 | detail: "Critical and high alerts, a push of yours that push protection refused, a vulnerability report, and your security digest.", | |
| 307 | }, | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 308 | { reason: "state_change", label: "What you follow closes or merges", detail: "An issue or pull request you're subscribed to was closed, reopened or merged." }, |
| 309 | { reason: "author", label: "News on your work", detail: "An approval, changes asked for, or g1t finishing what you asked for." }, | |
| 310 | { reason: "comment", label: "Conversations you're in", detail: "Comments on issues and pull requests you commented on." }, | |
| 311 | { reason: "manual", label: "Threads you subscribed to", detail: "Activity on issues and pull requests you subscribed to by hand." }, | |
| 312 | { reason: "subscribed", label: "Repositories you watch", detail: "Activity in repositories you watch." }, | |
| 313 | ]; | |
| 314 | ||
| 315 | /** The reasons checked on the settings form, each once, in rank order. */ | |
| 316 | export function emailReasonsFromForm(form: FormData): InboxReason[] { | |
| 317 | const checked = form.getAll("email").map(String); | |
| 318 | return INBOX_REASONS.filter((reason) => checked.includes(reason)); | |
| 319 | } | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 320 | |
| 321 | /** | |
| 322 | * When security alerts that are not urgent (medium and low) reach a person, | |
| 323 | * in the settings' order. Critical and high alerts, a refused push and a | |
| 324 | * vulnerability report come at once whatever is chosen here. | |
| 325 | */ | |
| 326 | export const SECURITY_DIGEST_CHOICES: { cadence: DigestCadence; label: string; detail: string }[] = [ | |
| 327 | { cadence: "daily", label: "In a daily digest", detail: "One item and email a day per workspace, in the morning (UTC), with what was found." }, | |
| 328 | { cadence: "weekly", label: "In a weekly digest", detail: "The same, on Monday mornings." }, | |
| 329 | { cadence: "at_once", label: "At once", detail: "Each scan that finds something is an item of its own, as the urgent ones are." }, | |
| 330 | { cadence: "off", label: "Not at all", detail: "Only critical and high alerts reach you. The findings are still on each repository's Security page." }, | |
| 331 | ]; | |
| 332 | ||
| 333 | /** The cadence a settings form posts, or null for anything else. */ | |
| 334 | export function securityDigestFromForm(form: FormData): DigestCadence | null { | |
| 335 | const given = String(form.get("security_digest") ?? ""); | |
| 336 | return SECURITY_DIGEST_CHOICES.find((choice) => choice.cadence === given)?.cadence ?? null; | |
| 337 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.