Skip to content
337 linesCodeBlameRaw

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 all1/**
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings2 * 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 all7 */
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings8import type {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r99 DigestCadence,
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings10 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 settings24const 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 bar29 "team_mention",
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings30 "ci_activity",
31 "security_alert",
32 "state_change",
33 "author",
34 "comment",
35 "manual",
36 "subscribed",
37];
38const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"];
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all39
40export type InboxTab = "all" | "needs" | "error" | "success" | "info";
41
42/** The tabs, in order, and the severity each shows. */
43export 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 all46 { 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. */
53export const SEVERITY_LABEL: Record<InboxSeverity, string> = {
54 error: "Error",
55 warning: "Needs you",
56 success: "Success",
57 info: "Info",
58};
59
60export function inboxTab(value: string | null | undefined): InboxTab {
61 return INBOX_TABS.find((entry) => entry.tab === value)?.tab ?? "all";
62}
63
64export function severityOf(tab: InboxTab): InboxSeverity | null {
65 return INBOX_TABS.find((entry) => entry.tab === tab)?.severity ?? null;
66}
67
68export function inboxView(value: string | null | undefined): InboxView {
69 return value === "saved" || value === "done" ? value : "inbox";
70}
71
72/** What is unread under a tab. */
73export 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. */
80export 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. */
86export 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 all92 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 all96 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
103const MINUTE = 60_000;
104const HOUR = 60 * MINUTE;
105const DAY = 24 * HOUR;
106
107/** When, in a few characters: "just now", "15m ago", "3h ago", "Yesterday", "4d ago", "Sep 30". */
108export 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. */
120export 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
126export 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`. */
132const 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 */
147export 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 all165 * and how many there are in all.
166 */
167export 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 settings171 .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 all172 return { items: needs.slice(0, max), total: needs.length };
173}
174
175/** Whether an item is still unread. */
176export function isUnread(item: Pick<InboxItem, "readAt">): boolean {
177 return item.readAt == null;
178}
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings179
180/** Why someone was told, in a few quiet words on the card. */
181export 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 bar186 team_mention: "team mentioned",
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings187 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. */
197export 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. */
202export 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 settings205];
206
207/** How much has happened on a thread, when more than one thing has: "3 updates". */
208export 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. */
213export 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. */
221export 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. */
229export 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 */
247export 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. */
257export 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 bar273 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 settings275 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. */
281export 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. */
297export 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 bar300 { 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 settings301 { 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-7nm8r9303 {
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 settings308 { 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. */
316export 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-7nm8r9320
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 */
326export 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. */
334export 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.