Skip to content
708 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.

g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.1/**
2 * Today, the workspace's front page: how the day's agent work went, what is
3 * waiting on you, what the day cost, and the one place to start. Every
4 * number is worked out here from what the services return, so each
5 * definition is tested on its own (today.test.ts) and written down in the
6 * Today guide (apps/docs/.../guides/today.md). Imports only types.
7 */
8import type { AgentSession, AgentSessionKind, InboxItem, Pull, RepoPath, Statement } from "@g1t/contracts";
9
10const MINUTE = 60_000;
11const DAY = 24 * 60 * MINUTE;
12
13/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */
14export function dayIn(at: number, timeZone: string | null): string {
15 try {
16 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
17 } catch {
18 return new Date(at).toISOString().slice(0, 10);
19 }
20}
21
22/** The `YYYY-MM-DD` keys of the `count` days before `now`'s day, newest first. */
23export function daysBefore(now: number, timeZone: string | null, count: number): string[] {
24 const today = dayIn(now, timeZone);
25 const keys: string[] = [];
26 // Noon steps never skip or repeat a day across a clock change.
27 for (let back = 1; keys.length < count && back < count + 3; back++) {
28 const key = dayIn(now - back * DAY, timeZone);
29 if (key !== today && !keys.includes(key)) keys.push(key);
30 }
31 return keys;
32}
33
34/** "Oct 9", in the reader's time zone. */
35export function shortDate(now: number, timeZone: string | null): string {
36 const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
37 try {
38 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
39 } catch {
40 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
41 }
42}
43
44// --- Today's tasks ------------------------------------------------------------
45
46/**
47 * How one of today's tasks went:
48 * - `accepted`: an agent's pull request merged today without the agent being sent back to revise it.
49 * - `fixed`: an agent's pull request merged today after one or more revisions.
50 * - `finished`: an agent session that finished today. Sessions are not reviewed, so they are never `accepted`.
51 * - `open`: still open, and worked on today.
52 * - `dropped`: an agent's pull request closed today without merging, or a session that failed or was stopped today.
53 */
54export type TaskOutcome = "accepted" | "fixed" | "finished" | "open" | "dropped";
55
56/** Where a task came from. */
57export type TaskSource = "chat" | "schedule" | "colleague" | "code";
58
59export const OUTCOME_LABEL: Record<TaskOutcome, string> = {
60 accepted: "Accepted first time",
61 fixed: "Fixed after review",
62 finished: "Sessions finished",
63 open: "Still open",
64 dropped: "Didn't finish",
65};
66
67export const SOURCE_LABEL: Record<TaskSource, string> = {
68 chat: "Chat",
69 schedule: "Schedules",
70 colleague: "Another agent",
71 code: "Code",
72};
73
74/** One mark on the strip. */
75export type DayTask = {
76 key: string;
77 outcome: TaskOutcome;
78 source: TaskSource;
79 /** Null for a session in a conversation the viewer is not in. */
80 title: string | null;
81 to: string | null;
82 /** When it settled, or its latest activity while open: the strip's order. */
83 at: number;
84};
85
86/** An agent's pull request with where it is: what Today reads from Code. */
87export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & {
88 repo: RepoPath;
89 author: { username: string; kind?: string };
90};
91
92/** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */
93export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean {
94 const name = pull.author.username;
95 return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent");
96}
97
98/** `acme/web#12`, lowercase: how a pull request and its runs are matched. */
99export function pullKey(repo: RepoPath, number: number): string {
100 return `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
101}
102
103/**
104 * How an agent's pull request went on `day`, or null when it is not one of
105 * that day's tasks. `revisions` counts its `revise` runs: each time g1t
106 * sent the agent back for failed checks or a review.
107 */
108export function pullOutcome(pull: CodePull, revisions: number, day: string, timeZone: string | null): TaskOutcome | null {
109 if (pull.status === "merged") {
110 if (!pull.mergedAt || dayIn(Date.parse(pull.mergedAt), timeZone) !== day) return null;
111 return revisions > 0 ? "fixed" : "accepted";
112 }
113 if (pull.status === "closed") return dayIn(Date.parse(pull.updatedAt), timeZone) === day ? "dropped" : null;
114 // Draft or open: today's when it was worked on today.
115 return dayIn(Date.parse(pull.updatedAt), timeZone) === day ? "open" : null;
116}
117
118const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]);
119
120/**
121 * How a session went today, or null when it is not one of today's tasks.
122 * Only a session at the root of its tree counts: the subagents and
123 * colleagues it brought in are part of its task.
124 */
125export function sessionOutcome(
126 session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">,
127 day: string,
128 timeZone: string | null,
129): TaskOutcome | null {
130 if (session.parent_id) return null;
131 if (LIVE.has(session.status)) return dayIn(Date.parse(session.updated_at), timeZone) === day ? "open" : null;
132 const ended = session.finished_at ?? session.updated_at;
133 if (dayIn(Date.parse(ended), timeZone) !== day) return null;
134 return session.status === "done" ? "finished" : "dropped";
135}
136
137export function sessionSource(kind: AgentSessionKind): TaskSource {
138 return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague";
139}
140
141/** The day's agent work, and how the last seven days compare. */
142export type DayWork = {
143 tasks: DayTask[];
144 counts: Record<TaskOutcome, number>;
145 /** Tasks by where they came from, most first; only sources with any. */
146 sources: { source: TaskSource; count: number }[];
147 /**
148 * Accepted first time: of the agents' pull requests that merged or closed
149 * today, the share that merged without a revision. Null with none.
150 */
151 rate: { value: number; of: number } | null;
152 /** The same share over the seven days before today; null when there were none, or they could not all be read. */
153 lastWeek: { value: number; of: number } | null;
154 /** Whether Code was read: false for a member without Code access, or when Code did not answer. */
155 code: "read" | "no_access" | "unavailable";
156 /** Whether sessions were read. */
157 sessions: "read" | "unavailable";
158 /** True when a list was cut short before the start of the day, so some of today's tasks may be missing. */
159 partial: boolean;
160};
161
162export type CodeWork = {
163 pulls: CodePull[];
164 /** `revise` runs per pull request (`pullKey`). */
165 revisions: Record<string, number>;
166 /** Whether every list read reached back eight days. */
167 complete: boolean;
168};
169
170export type SessionWork = { sessions: AgentSession[]; complete: boolean };
171
172/** Accepted first time over a set of settled outcomes: null when nothing settled. */
173export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null {
174 const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped");
175 if (settled.length === 0) return null;
176 return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length };
177}
178
179export function dayWork(input: {
180 now: number;
181 timeZone: string | null;
182 slug: string;
183 code: CodeWork | null | "no_access";
184 sessions: SessionWork | null;
185}): DayWork {
186 const { now, timeZone, slug } = input;
187 const today = dayIn(now, timeZone);
188 const tasks: DayTask[] = [];
189 let lastWeek: DayWork["lastWeek"] = null;
190 const code = input.code;
191 if (code && code !== "no_access") {
192 const week = new Set(daysBefore(now, timeZone, 7));
193 const earlier: TaskOutcome[] = [];
194 for (const pull of code.pulls) {
195 if (!isAgentPull(pull)) continue;
196 const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0;
197 const outcome = pullOutcome(pull, revisions, today, timeZone);
198 const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`;
199 if (outcome) {
200 const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt);
201 tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at });
202 continue;
203 }
204 // The seven days before: merged or closed on one of them.
205 if (pull.status === "merged" || pull.status === "closed") {
206 const settled = pull.status === "merged" ? pull.mergedAt : pull.updatedAt;
207 if (settled && week.has(dayIn(Date.parse(settled), timeZone))) {
208 earlier.push(pull.status === "closed" ? "dropped" : revisions > 0 ? "fixed" : "accepted");
209 }
210 }
211 }
212 lastWeek = code.complete ? acceptance(earlier) : null;
213 }
214 for (const session of input.sessions?.sessions ?? []) {
215 const outcome = sessionOutcome(session, today, timeZone);
216 if (!outcome) continue;
217 tasks.push({
218 key: `session:${session.id}`,
219 outcome,
220 source: sessionSource(session.kind),
221 title: session.visible ? session.title : null,
222 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
223 at: Date.parse(outcome === "open" ? session.updated_at : (session.finished_at ?? session.updated_at)),
224 });
225 }
226 // Settled work first, in the order it settled; open work last.
227 const order: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0, open: 1 };
228 tasks.sort((a, b) => order[a.outcome] - order[b.outcome] || a.at - b.at || a.key.localeCompare(b.key));
229 const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, open: 0, dropped: 0 };
230 const bySource = new Map<TaskSource, number>();
231 for (const task of tasks) {
232 counts[task.outcome]++;
233 bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1);
234 }
235 const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"];
236 return {
237 tasks,
238 counts,
239 sources: [...bySource]
240 .map(([source, count]) => ({ source, count }))
241 .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)),
242 rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)),
243 lastWeek,
244 code: code === "no_access" ? "no_access" : code ? "read" : "unavailable",
245 sessions: input.sessions ? "read" : "unavailable",
246 partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete),
247 };
248}
249
250/** The change in percentage points from the last seven days, rounded: "+6 pts", "−3 pts", "Same". */
251export function trendLabel(rate: { value: number } | null, lastWeek: { value: number } | null): string | null {
252 if (!rate || !lastWeek) return null;
253 const points = Math.round(rate.value * 100) - Math.round(lastWeek.value * 100);
254 if (points === 0) return "Same as the last 7 days";
255 return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the last 7 days`;
256}
257
258// --- Needs attention ----------------------------------------------------------
259
260/**
261 * What a row is. Tiers, most pressing first:
262 * 0. production is down to an older build (`deploy`), or the workspace hit its usage limit (`limit`);
263 * 1. agent work that has stopped and can't go on without you;
264 * 2. finished work waiting only for you (merge, review, an invitation, an agent asking);
265 * 3. something stuck that may need a look;
266 * 4. an unread notification that is a warning or a failure;
267 * 5. a mention or a direct message.
268 */
269export type AttentionKind =
270 | "limit"
271 | "deploy"
272 | "session_cap"
273 | "agent_budget"
274 | "pull_blocked"
275 | "ready"
276 | "review"
277 | "invitation"
278 | "agent_waiting"
279 | "stuck"
280 | "runner"
281 | "notification"
282 | "chat";
283
284export const TIER: Record<AttentionKind, number> = {
285 limit: 0,
286 deploy: 0,
287 session_cap: 1,
288 agent_budget: 1,
289 pull_blocked: 1,
290 ready: 2,
291 review: 2,
292 invitation: 2,
293 agent_waiting: 2,
294 stuck: 3,
295 runner: 3,
296 notification: 4,
297 chat: 5,
298};
299
300export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
301
302/** One row of Needs attention. */
303export type AttentionRow = {
304 key: string;
305 kind: AttentionKind;
306 title: string;
307 /** One line: what it is and where. */
308 detail: string;
309 /** What is at stake, in a few words. */
310 stake: Stake | null;
311 /** The action, right there. */
312 action: { label: string; to: string };
313 /** Who is on it: the agent, or the person who asked. */
314 owner: { name: string; agent: boolean } | null;
315 /** Epoch ms: when it started waiting. */
316 at: number;
317 /** Where it came from, for a member without Code access never `code`. */
318 from: "code" | "agents" | "notifications" | "chat";
319};
320
321/**
322 * Most pressing first: by tier, then what has waited longest, then by key
323 * so the order never depends on the order rows were read. One of each key,
324 * and one of each place: a notification about a pull request already on
325 * the list is left out.
326 */
327export function rankAttention(rows: AttentionRow[]): AttentionRow[] {
328 const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
329 const keys = new Set<string>();
330 const places = new Set<string>();
331 return sorted.filter((row) => {
332 const place = row.action.to.split(/[?#]/)[0];
333 if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false;
334 keys.add(row.key);
335 places.add(place);
336 return true;
337 });
338}
339
340/** Start here: the first row by `rankAttention`, with why it was picked. */
341export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null {
342 const row = ranked[0];
343 if (!row) return null;
344 const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length;
345 const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : "";
346 return { row, why: `${WHY[row.kind]}${tail}` };
347}
348
349const WHY: Record<AttentionKind, string> = {
350 limit: "Agents start nothing new past the workspace's usage limit, so everything else waits on it.",
351 deploy: "Production is serving an older build until this is fixed, so it comes before anything else.",
352 session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.",
353 agent_budget: "An agent used up its budget and takes no new work until it is raised.",
354 pull_blocked: "An agent stopped on this change and won't go on until a person decides.",
355 ready: "The work is done and only waits for you to land it.",
356 review: "You were asked by name, so it waits for your verdict.",
357 invitation: "It is addressed to you; no one else can answer it.",
358 agent_waiting: "An agent is waiting on you to go on.",
359 stuck: "A running agent has stopped reporting and may be stuck.",
360 runner: "A job is waiting for a runner that is not there.",
361 notification: "It is the most pressing unread notification.",
362 chat: "Nothing else is waiting, and this is the oldest message for you.",
363};
364
365// --- Rows from each source ----------------------------------------------------
366
367/** Mission control's need row, as Today reads it (lib/mission-control.ts `NeedRow`). */
368export type CodeNeed = {
369 key: string;
370 reason: string;
371 repo: RepoPath | null;
372 ref: string | null;
373 title: string;
374 ask: string;
375 by: { name: string; agent: boolean } | null;
376 for: string | null;
377 at: number;
378 to: string;
379 facts: { label: string; value: string; tone: string | null }[];
380};
381
382const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null;
383
384const REASON_STAKE: Record<string, Stake> = {
385 blocking: { text: "Blocking", tone: "danger" },
386 checks_failing: { text: "Checks failing", tone: "danger" },
387 outside_guardrails: { text: "Over a guardrail", tone: "warn" },
388 low_confidence: { text: "Low confidence", tone: "warn" },
389 needs_review: { text: "Needs a verdict", tone: "warn" },
390 stalled: { text: "Stalled", tone: "warn" },
391};
392
393/** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */
394export function codeRow(need: CodeNeed): AttentionRow {
395 const prefix = need.key.split(":")[0];
396 const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null;
397 const detail = where ? `${where} · ${need.ask}` : need.ask;
398 const owner = need.by;
399 const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const };
400 switch (prefix) {
401 case "deploy":
402 return {
403 ...base,
404 kind: "deploy",
405 stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" },
406 action: { label: "See the build", to: need.to },
407 };
408 case "invitation":
409 return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } };
410 case "review": {
411 const lines = fact(need, "Lines");
412 return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } };
413 }
414 case "runner":
415 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
416 case "run": {
417 const cost = fact(need, "Cost so far");
418 return {
419 ...base,
420 kind: "stuck",
421 stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" },
422 action: { label: "Look", to: need.to },
423 };
424 }
425 default: {
426 if (need.reason === "ready_to_merge") {
427 const files = fact(need, "Files changed");
428 return {
429 ...base,
430 kind: "ready",
431 stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" },
432 action: { label: "Merge", to: need.to },
433 };
434 }
435 return {
436 ...base,
437 kind: "pull_blocked",
438 stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" },
439 action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to },
440 };
441 }
442 }
443}
444
445/** "$4.20", or "<$0.01" for a sliver. */
446export function dollars(micros: number): string {
447 const value = micros / 1_000_000;
448 if (value > 0 && value < 0.01) return "<$0.01";
449 return `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
450}
451
452/** A session stopped at its spend cap that the viewer may approve more for. */
453export function sessionCapRow(session: AgentSession, slug: string): AttentionRow {
454 const cap = session.cap_micros;
455 return {
456 key: `session:${session.id}`,
457 kind: "session_cap",
458 title: session.visible ? session.title : `A session of @${session.agent_handle}`,
459 detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`,
460 stake: { text: cap != null ? `${dollars(session.charged_micros)} of ${dollars(cap)} cap` : `${dollars(session.charged_micros)} spent`, tone: "warn" },
461 action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` },
462 owner: { name: session.agent_handle, agent: true },
463 at: Date.parse(session.updated_at),
464 from: "agents",
465 };
466}
467
468export type AgentLike = {
469 id: string;
470 handle: string;
471 display_name: string;
472 status: string;
473 spent_month_micros: number;
474 budget: { monthly_micros: number | null };
475 updated_at: string;
476};
477
478/** An agent out of budget (for those who may raise it), or waiting on someone. */
479export function agentRow(agent: AgentLike, slug: string): AttentionRow | null {
480 const base = {
481 key: `agent:${agent.id}`,
482 owner: { name: agent.handle, agent: true },
483 at: Date.parse(agent.updated_at),
484 from: "agents" as const,
485 };
486 if (agent.status === "out_of_budget") {
487 const budget = agent.budget.monthly_micros;
488 return {
489 ...base,
490 kind: "agent_budget",
491 title: `${agent.display_name} is out of budget`,
492 detail: `@${agent.handle} takes no new work this month until its budget is raised.`,
493 stake: { text: budget != null ? `${dollars(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" },
494 action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` },
495 };
496 }
497 if (agent.status === "waiting") {
498 return {
499 ...base,
500 kind: "agent_waiting",
501 title: `${agent.display_name} is waiting`,
502 detail: `@${agent.handle} is waiting before it goes on.`,
503 stake: null,
504 action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` },
505 };
506 }
507 return null;
508}
509
510const NOTIFICATION_STAKE: Record<string, string> = {
511 agent: "Agent waiting",
512 review_requested: "Review requested",
513 assign: "Assigned to you",
514 mention: "Mentioned",
515 team_mention: "Team mentioned",
516 ci_activity: "CI activity",
517 security_alert: "Security alert",
518 state_change: "State changed",
519 author: "Your work",
520 comment: "Comment",
521 manual: "Subscribed",
522 subscribed: "Watching",
523};
524
525/**
526 * An unread notification that is a warning (someone or something is
527 * waiting) or a failure, in this workspace. A member without Code access
528 * gets none about a repository.
529 */
530export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null {
531 if (item.readAt != null || item.doneAt != null) return null;
532 if (item.severity !== "warning" && item.severity !== "error") return null;
533 const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug;
534 if (!inWorkspace) return null;
535 if (!code && item.repo) return null;
536 return {
537 key: `notification:${item.id}`,
538 kind: "notification",
539 title: item.body || item.title,
540 detail: item.body ? item.title : (item.repo ?? "A notification"),
541 stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" },
542 action: { label: "Open", to: item.url },
543 owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null,
544 at: Date.parse(item.updatedAt),
545 from: "notifications",
546 };
547}
548
549export type ChatEntryLike = {
550 channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null };
551 title: string;
552 muted: boolean;
553 unread: number;
554 mentions: number;
555};
556
557/** A conversation with a mention of you, or a direct message you haven't read. */
558export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null {
559 if (entry.muted) return null;
560 if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null;
561 const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`;
562 const dm = entry.channel.kind === "dm";
563 return {
564 key: `chat:${entry.channel.id}`,
565 kind: "chat",
566 title: dm ? entry.title : `#${entry.title}`,
567 detail: dm ? "A direct message you haven't read." : "You were mentioned.",
568 stake: {
569 text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`,
570 tone: entry.mentions > 0 ? "accent" : null,
571 },
572 action: { label: "Reply", to },
573 owner: null,
574 at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0,
575 from: "chat",
576 };
577}
578
579/** What Needs attention and Start here show, and which sources did not answer. */
580export type Attention = {
581 rows: AttentionRow[];
582 start: { row: AttentionRow; why: string } | null;
583 /** Sources that did not answer, by name, for the card to say so. */
584 missing: string[];
585};
586
587export function attention(input: {
588 slug: string;
589 code: boolean;
590 codeNeeds: CodeNeed[] | null;
591 capped: AgentSession[] | null;
592 agents: AgentLike[] | null;
593 canManage: boolean;
594 notifications: InboxItem[] | null;
595 chat: ChatEntryLike[] | null;
596}): Attention {
597 const rows: AttentionRow[] = [];
598 const missing: string[] = [];
599 if (input.code) {
600 if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow));
601 else missing.push("Code");
602 }
603 if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug)));
604 if (input.agents) {
605 const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle));
606 for (const agent of input.agents) {
607 if (agent.status === "out_of_budget" && !input.canManage) continue;
608 if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue;
609 const row = agentRow(agent, input.slug);
610 if (row) rows.push(row);
611 }
612 }
613 if (input.capped == null || input.agents == null) missing.push("Agents");
614 if (input.notifications) {
615 for (const item of input.notifications) {
616 const row = notificationRow(item, input.slug, input.code);
617 if (row) rows.push(row);
618 }
619 } else missing.push("Notifications");
620 if (input.chat) {
621 for (const entry of input.chat) {
622 const row = chatRow(entry, input.slug);
623 if (row) rows.push(row);
624 }
625 } else missing.push("Chat");
626 const ranked = rankAttention(rows);
627 return { rows: ranked, start: startHere(ranked), missing };
628}
629
630// --- Spent today --------------------------------------------------------------
631
632/** Money in, which the statement lists but which is not spend. */
633const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]);
634
635export type Spend = {
636 /** The statement's day, `YYYY-MM-DD` in UTC. */
637 day: string;
638 /** Usage at price today, in micros. */
639 totalMicros: number;
640 /** A line per kind of charge, as the statement names it, most first. */
641 lines: { kind: string; micros: number; count: number }[];
642 /** Usage at price this month so far. */
643 monthMicros: number;
644};
645
646/**
647 * Today's spend from the month's statement grouped by day: each usage
648 * line at its price (what was charged, plus what the plan, a trial, a pool
649 * or a discount paid of it), leaving out money in. The statement's days are
650 * UTC days.
651 */
652export function spendToday(statement: Pick<Statement, "groups" | "totals">, now: number): Spend {
653 const day = new Date(now).toISOString().slice(0, 10);
654 const group = statement.groups.find((g) => g.key === day);
655 const lines = (group?.lines ?? [])
656 .filter((line) => !MONEY_IN.has(line.kind))
657 .map((line) => ({ kind: line.kind, micros: line.priceMicros ?? line.chargedMicros, count: line.count }))
658 .filter((line) => line.micros > 0)
659 .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind));
660 return {
661 day,
662 totalMicros: lines.reduce((sum, line) => sum + line.micros, 0),
663 lines,
664 monthMicros: statement.totals.priceMicros ?? statement.totals.chargedMicros,
665 };
666}
667
668// --- The sentence -----------------------------------------------------------
669
670const count = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
671
672/**
673 * The one sentence under the heading: "Agents finished 35 tasks. 4 needed
674 * a fix after review, and 6 are still open." Finished counts every task
675 * that settled well: accepted, fixed, and sessions that finished.
676 */
677export function daySentence(work: Pick<DayWork, "counts" | "tasks">): string {
678 if (work.tasks.length === 0) return "No agent work today yet.";
679 const { accepted, fixed, finished, open, dropped } = work.counts;
680 const done = accepted + fixed + finished;
681 const parts: string[] = [];
682 const first = done > 0 ? `Agents finished ${count(done, "task")}.` : "Agents haven't finished a task yet today.";
683 const after: string[] = [];
684 if (fixed > 0) after.push(`${fixed.toLocaleString("en-US")} needed a fix after review`);
685 if (dropped > 0) after.push(`${dropped.toLocaleString("en-US")} didn't finish`);
686 if (open > 0) after.push(`${open.toLocaleString("en-US")} ${open === 1 ? "is" : "are"} still open`);
687 parts.push(first);
688 if (after.length > 0) {
689 const last = after.pop();
690 const text = after.length > 0 ? `${after.join(", ")}, and ${last}` : String(last);
691 parts.push(`${text.charAt(0).toUpperCase()}${text.slice(1)}.`);
692 }
693 return parts.join(" ");
694}
695
696/** "8 things are waiting on you.", or that nothing is. */
697export function waitingSentence(total: number): string {
698 if (total === 0) return "Nothing is waiting on you.";
699 return `${count(total, "thing")} ${total === 1 ? "is" : "are"} waiting on you.`;
700}
701
702/** How long something has waited: "45 min", "17 h", "2 d". */
703export function waited(at: number, now: number): string {
704 const minutes = Math.max(0, Math.floor((now - at) / MINUTE));
705 if (minutes < 60) return `${minutes} min`;
706 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`;
707 return `${Math.floor(minutes / (24 * 60))} d`;
708}