Skip to content
1,085 linesCodeBlameRaw
1/**
2 * Home, the workspace's front page: where you're needed now, what happened
3 * since you were last here, and what is running. It never depends on the
4 * calendar: what needs you has no time window, and what happened is
5 * counted over a span you choose, since your last visit by default. Every
6 * number is worked out here from what the services return, so each
7 * definition is tested on its own (home.test.ts) and written down in the
8 * Home guide (apps/docs/.../guides/home.md). Imports only types.
9 */
10import type {
11 AgentSession,
12 AgentSessionKind,
13 Deployment,
14 InboxItem,
15 InstallRequest,
16 Memory,
17 ProjectDeploys,
18 Pull,
19 RepoPath,
20 Statement,
21} from "@g1t/contracts";
22
23import { money } from "./money.ts";
24
25const MINUTE = 60_000;
26const HOUR = 60 * MINUTE;
27const DAY = 24 * HOUR;
28
29/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */
30export function dayIn(at: number, timeZone: string | null): string {
31 try {
32 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
33 } catch {
34 return new Date(at).toISOString().slice(0, 10);
35 }
36}
37
38/** The hour of the day (0–23) in a time zone; UTC when it is unknown. */
39export function hourIn(at: number, timeZone: string | null): number {
40 const read = (zone: string) => Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: zone }).format(at)) % 24;
41 try {
42 return read(timeZone || "UTC");
43 } catch {
44 return read("UTC");
45 }
46}
47
48function formatIn(at: number, timeZone: string | null, options: Intl.DateTimeFormatOptions): string {
49 try {
50 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(at);
51 } catch {
52 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(at);
53 }
54}
55
56/** "Oct 9", in the reader's time zone. */
57export function shortDate(at: number, timeZone: string | null): string {
58 return formatIn(at, timeZone, { month: "short", day: "numeric" });
59}
60
61// --- The span -----------------------------------------------------------------
62
63/** Which span "what happened" covers: since your last visit, or a fixed one. */
64export type WindowKey = "last" | "24h" | "7d";
65
66export const WINDOWS: { key: WindowKey; label: string }[] = [
67 { key: "last", label: "Since last visit" },
68 { key: "24h", label: "Last 24 hours" },
69 { key: "7d", label: "Last 7 days" },
70];
71
72/** The furthest back "since your last visit" looks. */
73export const MAX_AWAY_DAYS = 14;
74
75/** A `?window=` value, or the default: since your last visit. */
76export function parseWindow(value: string | null | undefined): WindowKey {
77 return value === "24h" || value === "7d" ? value : "last";
78}
79
80export type Span = {
81 key: WindowKey;
82 /** Epoch ms: the start of the span. */
83 from: number;
84 now: number;
85 /** "Since Tuesday evening", "In the last 24 hours". */
86 words: string;
87 /** How long it covers: "3 days", "45 minutes". */
88 length: string;
89 /**
90 * Why "since your last visit" covers something else: `first` when there
91 * is no visit on record (it shows the last 24 hours), `capped` when the
92 * last one was longer ago than MAX_AWAY_DAYS, `unknown` when it could
93 * not be read (it shows the last 24 hours).
94 */
95 note: "first" | "capped" | "unknown" | null;
96};
97
98const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
99
100/** How long a stretch of time is, roughly: "a moment", "40 minutes", "3 hours", "3 days", "2 weeks". */
101export function duration(ms: number): string {
102 const minutes = Math.floor(Math.max(0, ms) / MINUTE);
103 if (minutes < 1) return "a moment";
104 if (minutes < 60) return plural(minutes, "minute");
105 const hours = Math.floor(minutes / 60);
106 if (hours < 24) return plural(hours, "hour");
107 const days = Math.floor(hours / 24);
108 if (days < 14) return plural(days, "day");
109 return plural(Math.floor(days / 7), "week");
110}
111
112type Part = "night" | "morning" | "afternoon" | "evening";
113
114/**
115 * When `from` was, in words, from `now`: "a moment ago", "40 minutes ago",
116 * "2 hours ago", "this morning", "last night", "yesterday evening",
117 * "Tuesday evening", "Oct 2". The small hours belong to the night before:
118 * 2 a.m. on Wednesday is Tuesday night.
119 */
120export function sinceWords(from: number, now: number, timeZone: string | null): string {
121 const ago = now - from;
122 if (ago < MINUTE) return "a moment ago";
123 if (ago < HOUR) return `${plural(Math.floor(ago / MINUTE), "minute")} ago`;
124 if (ago < 3 * HOUR) return Math.floor(ago / HOUR) === 1 ? "an hour ago" : `${Math.floor(ago / HOUR)} hours ago`;
125 const hour = hourIn(from, timeZone);
126 const part: Part = hour < 5 ? "night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening";
127 // The night is the evening's day's.
128 const day = dayIn(part === "night" ? from - 6 * HOUR : from, timeZone);
129 const today = dayIn(now, timeZone);
130 const yesterday = dayIn(now - DAY, timeZone);
131 const twoDaysAgo = dayIn(now - 2 * DAY, timeZone);
132 if (day === today) return `this ${part === "night" ? "evening" : part}`;
133 if (day === yesterday) return part === "night" ? "last night" : `yesterday ${part}`;
134 if (part === "night" && day === twoDaysAgo) return "the night before last";
135 if (ago < 6 * DAY) return `${formatIn(part === "night" ? from - 6 * HOUR : from, timeZone, { weekday: "long" })} ${part}`;
136 return shortDate(from, timeZone);
137}
138
139/**
140 * The span for `key`: since `lastSeen` (your last visit, kept by notify),
141 * or the last 24 hours or 7 days. Since your last visit falls back to the
142 * last 24 hours when there is none (`null`) or it could not be read
143 * (`undefined`), and to MAX_AWAY_DAYS when it was longer ago.
144 */
145export function spanFor(key: WindowKey, now: number, lastSeen: number | null | undefined, timeZone: string | null): Span {
146 if (key === "7d") return { key, from: now - 7 * DAY, now, words: "In the last 7 days", length: "7 days", note: null };
147 if (key === "24h" || lastSeen == null || lastSeen > now) {
148 const note = key !== "last" ? null : lastSeen === undefined ? "unknown" : "first";
149 return { key, from: now - DAY, now, words: "In the last 24 hours", length: "24 hours", note };
150 }
151 const furthest = now - MAX_AWAY_DAYS * DAY;
152 if (lastSeen < furthest) return { key, from: furthest, now, words: `In the last ${MAX_AWAY_DAYS} days`, length: `${MAX_AWAY_DAYS} days`, note: "capped" };
153 return { key, from: lastSeen, now, words: `Since ${sinceWords(lastSeen, now, timeZone)}`, length: duration(now - lastSeen), note: null };
154}
155
156/** Whether `at` (epoch ms or RFC 3339) falls in the span. */
157export function within(at: number | string | null | undefined, span: Pick<Span, "from" | "now">): boolean {
158 if (at == null) return false;
159 const ms = typeof at === "number" ? at : Date.parse(at);
160 return Number.isFinite(ms) && ms >= span.from && ms <= span.now;
161}
162
163// --- Agent work in the span ---------------------------------------------------
164
165/**
166 * How one piece of agent work that settled in the span went:
167 * - `accepted`: an agent's pull request merged without the agent being sent back to revise it.
168 * - `fixed`: an agent's pull request merged after one or more revisions.
169 * - `finished`: an agent session that finished. Sessions are not reviewed, so they are never `accepted`.
170 * - `dropped`: an agent's pull request closed without merging, or a session that failed or was stopped.
171 * Work still going is not here: it is under Running now.
172 */
173export type TaskOutcome = "accepted" | "fixed" | "finished" | "dropped";
174
175/** Where a task came from. */
176export type TaskSource = "chat" | "schedule" | "colleague" | "code";
177
178export const OUTCOME_LABEL: Record<TaskOutcome, string> = {
179 accepted: "Accepted first time",
180 fixed: "Fixed after review",
181 finished: "Sessions finished",
182 dropped: "Didn't finish",
183};
184
185export const SOURCE_LABEL: Record<TaskSource, string> = {
186 chat: "Chat",
187 schedule: "Schedules",
188 colleague: "Another agent",
189 code: "Code",
190};
191
192/** One mark on the strip. */
193export type WorkTask = {
194 key: string;
195 outcome: TaskOutcome;
196 source: TaskSource;
197 /** Null for a session in a conversation the viewer is not in. */
198 title: string | null;
199 to: string | null;
200 /** When it settled: the strip's order. */
201 at: number;
202};
203
204/** A pull request with where it is: what Home reads from Code. */
205export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & {
206 repo: RepoPath;
207 author: { username: string; kind?: string };
208 /** Who merged it, when it merged. */
209 mergedBy?: string | null;
210};
211
212/** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */
213export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean {
214 const name = pull.author.username;
215 return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent");
216}
217
218/** `acme/web#12`, lowercase: how a pull request and its runs are matched. */
219export function pullKey(repo: RepoPath, number: number): string {
220 return `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
221}
222
223/**
224 * How an agent's pull request settled in the span, or null when it did not
225 * settle in it. `revisions` counts its `revise` runs: each time g1t sent
226 * the agent back for failed checks or a review.
227 */
228export function pullOutcome(pull: CodePull, revisions: number, span: Pick<Span, "from" | "now">): TaskOutcome | null {
229 if (pull.status === "merged") return within(pull.mergedAt, span) ? (revisions > 0 ? "fixed" : "accepted") : null;
230 if (pull.status === "closed") return within(pull.updatedAt, span) ? "dropped" : null;
231 return null;
232}
233
234const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]);
235
236/**
237 * How a session settled in the span, or null. Only a session at the root
238 * of its tree counts: the subagents and colleagues it brought in are part
239 * of its task.
240 */
241export function sessionOutcome(
242 session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">,
243 span: Pick<Span, "from" | "now">,
244): TaskOutcome | null {
245 if (session.parent_id || LIVE.has(session.status)) return null;
246 if (!within(session.finished_at ?? session.updated_at, span)) return null;
247 return session.status === "done" ? "finished" : "dropped";
248}
249
250export function sessionSource(kind: AgentSessionKind): TaskSource {
251 return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague";
252}
253
254/** The agents' work that settled in the span, and how the 7 days before it compare. */
255export type SpanWork = {
256 tasks: WorkTask[];
257 counts: Record<TaskOutcome, number>;
258 /** Tasks by where they came from, most first; only sources with any. */
259 sources: { source: TaskSource; count: number }[];
260 /**
261 * Accepted first time: of the agents' pull requests that merged or closed
262 * in the span, the share that merged without a revision. Null with none.
263 */
264 rate: { value: number; of: number } | null;
265 /** The same share over the 7 days before the span; null when there were none, or they could not all be read. */
266 before: { value: number; of: number } | null;
267 /** Whether Code was read: false for a member without Code access, or when Code did not answer. */
268 code: "read" | "no_access" | "unavailable";
269 /** Whether sessions were read. */
270 sessions: "read" | "unavailable";
271 /** True when a list was cut short before the start of the span, so some work may be missing. */
272 partial: boolean;
273};
274
275export type CodeWork = {
276 pulls: CodePull[];
277 /** `revise` runs per pull request (`pullKey`). */
278 revisions: Record<string, number>;
279 /** Whether every list read reached back to 7 days before the span. */
280 complete: boolean;
281};
282
283export type SessionWork = { sessions: AgentSession[]; complete: boolean };
284
285/** Accepted first time over a set of settled outcomes: null when no pull request settled. */
286export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null {
287 const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped");
288 if (settled.length === 0) return null;
289 return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length };
290}
291
292export function spanWork(input: {
293 span: Pick<Span, "from" | "now">;
294 slug: string;
295 code: CodeWork | null | "no_access";
296 sessions: SessionWork | null;
297}): SpanWork {
298 const { span, slug } = input;
299 const tasks: WorkTask[] = [];
300 let before: SpanWork["before"] = null;
301 const code = input.code;
302 if (code && code !== "no_access") {
303 const prior = { from: span.from - 7 * DAY, now: span.from - 1 };
304 const earlier: TaskOutcome[] = [];
305 for (const pull of code.pulls) {
306 if (!isAgentPull(pull)) continue;
307 const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0;
308 const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`;
309 const outcome = pullOutcome(pull, revisions, span);
310 if (outcome) {
311 const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt);
312 tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at });
313 continue;
314 }
315 const then = pullOutcome(pull, revisions, prior);
316 if (then) earlier.push(then);
317 }
318 before = code.complete ? acceptance(earlier) : null;
319 }
320 for (const session of input.sessions?.sessions ?? []) {
321 const outcome = sessionOutcome(session, span);
322 if (!outcome) continue;
323 tasks.push({
324 key: `session:${session.id}`,
325 outcome,
326 source: sessionSource(session.kind),
327 title: session.visible ? session.title : null,
328 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
329 at: Date.parse(session.finished_at ?? session.updated_at),
330 });
331 }
332 // In the order they settled.
333 tasks.sort((a, b) => a.at - b.at || a.key.localeCompare(b.key));
334 const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0 };
335 const bySource = new Map<TaskSource, number>();
336 for (const task of tasks) {
337 counts[task.outcome]++;
338 bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1);
339 }
340 const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"];
341 return {
342 tasks,
343 counts,
344 sources: [...bySource]
345 .map(([source, count]) => ({ source, count }))
346 .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)),
347 rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)),
348 before,
349 code: code === "no_access" ? "no_access" : code ? "read" : "unavailable",
350 sessions: input.sessions ? "read" : "unavailable",
351 partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete),
352 };
353}
354
355/** The change in percentage points from the 7 days before, rounded: "+6 pts vs the 7 days before", "Same as the 7 days before". */
356export function trendLabel(rate: { value: number } | null, before: { value: number } | null): string | null {
357 if (!rate || !before) return null;
358 const points = Math.round(rate.value * 100) - Math.round(before.value * 100);
359 if (points === 0) return "Same as the 7 days before";
360 return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`;
361}
362
363// --- What else happened in the span -------------------------------------------
364
365/** A change that landed: a pull request merged, by a person or an agent. */
366export type LandedChange = {
367 key: string;
368 title: string;
369 repo: RepoPath;
370 number: number;
371 to: string;
372 at: number;
373 /** An agent's change, or a person's. */
374 agent: boolean;
375 author: string;
376 mergedBy: string | null;
377};
378
379/** Every pull request that merged in the span, newest first. */
380export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
381 return pulls
382 .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))
383 .map((pull) => ({
384 key: `landed:${pullKey(pull.repo, pull.number)}`,
385 title: pull.title,
386 repo: pull.repo,
387 number: pull.number,
388 to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
389 at: Date.parse(pull.mergedAt!),
390 agent: isAgentPull(pull),
391 author: pull.author.username,
392 mergedBy: pull.mergedBy ?? null,
393 }))
394 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
395}
396
397/** A build that finished in the span. */
398export type DeployRow = {
399 key: string;
400 project: string;
401 kind: Deployment["kind"];
402 /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */
403 outcome: "live" | "failed";
404 commit: string;
405 branch: string | null;
406 at: number;
407 to: string;
408 by: string;
409};
410
411/**
412 * Builds that finished in the span, newest first: production builds each,
413 * and previews too. A build that went live and was replaced since still
414 * went live. Skipped builds are left out.
415 */
416export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">): DeployRow[] {
417 const rows: DeployRow[] = [];
418 for (const project of projects) {
419 for (const deploy of project.deployments) {
420 const ended = deploy.finishedAt ?? null;
421 if (!within(ended, span)) continue;
422 const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null;
423 if (!outcome) continue;
424 rows.push({
425 key: `deploy:${project.slug}:${deploy.id}`,
426 project: project.slug,
427 kind: deploy.kind,
428 outcome,
429 commit: deploy.commit.slice(0, 7),
430 branch: deploy.branch,
431 at: Date.parse(ended!),
432 to: `/${workspace}/${project.slug}/deployments/${deploy.id}`,
433 by: deploy.createdBy,
434 });
435 }
436 }
437 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
438}
439
440/** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */
441export type DecisionRow = {
442 key: string;
443 text: string;
444 by: string | null;
445 at: number;
446 to: string;
447 kind: "memory" | "request";
448};
449
450export function decisionsIn(
451 input: { memories: Memory[] | null; requests: InstallRequest[] | null },
452 slug: string,
453 span: Pick<Span, "from" | "now">,
454): DecisionRow[] {
455 const rows: DecisionRow[] = [];
456 for (const memory of input.memories ?? []) {
457 if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue;
458 rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" });
459 }
460 for (const request of input.requests ?? []) {
461 if (request.status === "open" || !within(request.resolved_at, span)) continue;
462 rows.push({
463 key: `request:${request.id}`,
464 text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`,
465 by: request.resolved_by,
466 at: Date.parse(request.resolved_at!),
467 to: `/${slug}/-/marketplace/requests`,
468 kind: "request",
469 });
470 }
471 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
472}
473
474// --- Running now ----------------------------------------------------------------
475
476/** Something going right now: a session, an agent's change, a build. */
477export type RunningRow = {
478 key: string;
479 kind: "session" | "change" | "deploy";
480 title: string;
481 /** One line: who and where. */
482 detail: string;
483 /** "Working", "Queued", "Building". */
484 status: string;
485 /** Epoch ms: when it started. */
486 at: number;
487 to: string;
488};
489
490const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" };
491
492/** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */
493export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] {
494 return sessions
495 .filter((session) => !session.parent_id && SESSION_STATUS[session.status])
496 .map((session) => ({
497 key: `session:${session.id}`,
498 kind: "session" as const,
499 title: session.visible ? session.title : "A session in a conversation you're not in",
500 detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`,
501 status: SESSION_STATUS[session.status]!,
502 at: Date.parse(session.created_at),
503 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
504 }));
505}
506
507/** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */
508export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string };
509
510export function runningChanges(changes: AgentChange[]): RunningRow[] {
511 return changes.map((change) => ({
512 key: `change:${change.key}`,
513 kind: "change" as const,
514 title: change.title,
515 detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`,
516 status: change.chip,
517 at: change.at,
518 to: change.to,
519 }));
520}
521
522/** Builds queued or going now, one per project (its newest). */
523export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] {
524 return overview.flatMap((project) => {
525 const latest = project.latest;
526 if (!latest || (latest.status !== "queued" && latest.status !== "building")) return [];
527 return [
528 {
529 key: `deploy:${project.slug}:${latest.id}`,
530 kind: "deploy" as const,
531 title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`,
532 detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`,
533 status: latest.status === "queued" ? "Queued" : "Building",
534 at: Date.parse(latest.createdAt),
535 to: `/${slug}/${project.slug}/deployments/${latest.id}`,
536 },
537 ];
538 });
539}
540
541/** Everything running, longest-going first within each kind: sessions, then changes, then builds. */
542export function running(parts: RunningRow[][]): RunningRow[] {
543 const order = { session: 0, change: 1, deploy: 2 } as const;
544 return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
545}
546
547// --- Needs you ------------------------------------------------------------------
548
549/**
550 * What a row is. Tiers, most pressing first:
551 * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`);
552 * 1. agent work that has stopped and can't go on without you;
553 * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something);
554 * 3. something stuck that may need a look;
555 * 4. an unread notification that is a warning or a failure;
556 * 5. a mention or a direct message.
557 */
558export type AttentionKind =
559 | "limit"
560 | "deploy"
561 | "session_cap"
562 | "agent_budget"
563 | "pull_blocked"
564 | "ready"
565 | "review"
566 | "invitation"
567 | "install_request"
568 | "agent_waiting"
569 | "stuck"
570 | "runner"
571 | "notification"
572 | "chat";
573
574export const TIER: Record<AttentionKind, number> = {
575 limit: 0,
576 deploy: 0,
577 session_cap: 1,
578 agent_budget: 1,
579 pull_blocked: 1,
580 ready: 2,
581 review: 2,
582 invitation: 2,
583 install_request: 2,
584 agent_waiting: 2,
585 stuck: 3,
586 runner: 3,
587 notification: 4,
588 chat: 5,
589};
590
591export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
592
593/** One row of Needs you. */
594export type AttentionRow = {
595 key: string;
596 kind: AttentionKind;
597 title: string;
598 /** One line: what it is and where. */
599 detail: string;
600 /** What is at stake, in a few words. */
601 stake: Stake | null;
602 /** The action, right there. */
603 action: { label: string; to: string };
604 /** Who is on it: the agent, or the person who asked. */
605 owner: { name: string; agent: boolean } | null;
606 /** Epoch ms: when it started waiting. */
607 at: number;
608 /** Where it came from, for a member without Code access never `code`. */
609 from: "code" | "agents" | "notifications" | "chat" | "marketplace";
610};
611
612/**
613 * Most pressing first: by tier, then what has waited longest, then by key
614 * so the order never depends on the order rows were read. One of each key,
615 * and one of each place: a notification about a pull request already on
616 * the list is left out.
617 */
618export function rankAttention(rows: AttentionRow[]): AttentionRow[] {
619 const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
620 const keys = new Set<string>();
621 const places = new Set<string>();
622 return sorted.filter((row) => {
623 const place = row.action.to.split(/[?#]/)[0];
624 if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false;
625 keys.add(row.key);
626 places.add(place);
627 return true;
628 });
629}
630
631/** Start here: the first row by `rankAttention`, with why it was picked. */
632export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null {
633 const row = ranked[0];
634 if (!row) return null;
635 const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length;
636 const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : "";
637 return { row, why: `${WHY[row.kind]}${tail}` };
638}
639
640const WHY: Record<AttentionKind, string> = {
641 limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.",
642 install_request: "A member asked for it, and only an owner can add it or turn it down.",
643 deploy: "Production is serving an older build until this is fixed, so it comes before anything else.",
644 session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.",
645 agent_budget: "An agent used up its budget and takes no new work until it is raised.",
646 pull_blocked: "An agent stopped on this change and won't go on until a person decides.",
647 ready: "The work is done and only waits for you to land it.",
648 review: "You were asked by name, so it waits for your verdict.",
649 invitation: "It is addressed to you; no one else can answer it.",
650 agent_waiting: "An agent is waiting on you to go on.",
651 stuck: "A running agent has stopped reporting and may be stuck.",
652 runner: "A job is waiting for a runner that is not there.",
653 notification: "It is the most pressing unread notification.",
654 chat: "Nothing else is waiting, and this is the oldest message for you.",
655};
656
657// --- Rows from each source ----------------------------------------------------
658
659/** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */
660export type CodeNeed = {
661 key: string;
662 reason: string;
663 repo: RepoPath | null;
664 ref: string | null;
665 title: string;
666 ask: string;
667 by: { name: string; agent: boolean } | null;
668 for: string | null;
669 at: number;
670 to: string;
671 facts: { label: string; value: string; tone: string | null }[];
672};
673
674const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null;
675
676const REASON_STAKE: Record<string, Stake> = {
677 blocking: { text: "Blocking", tone: "danger" },
678 checks_failing: { text: "Checks failing", tone: "danger" },
679 outside_guardrails: { text: "Over a guardrail", tone: "warn" },
680 low_confidence: { text: "Low confidence", tone: "warn" },
681 needs_review: { text: "Needs a verdict", tone: "warn" },
682 stalled: { text: "Stalled", tone: "warn" },
683};
684
685/** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */
686export function codeRow(need: CodeNeed): AttentionRow {
687 const prefix = need.key.split(":")[0];
688 const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null;
689 const detail = where ? `${where} · ${need.ask}` : need.ask;
690 const owner = need.by;
691 const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const };
692 switch (prefix) {
693 case "deploy":
694 return {
695 ...base,
696 kind: "deploy",
697 stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" },
698 action: { label: "See the build", to: need.to },
699 };
700 case "invitation":
701 return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } };
702 case "review": {
703 const lines = fact(need, "Lines");
704 return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } };
705 }
706 case "runner":
707 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
708 case "run": {
709 const cost = fact(need, "Cost so far");
710 return {
711 ...base,
712 kind: "stuck",
713 stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" },
714 action: { label: "Look", to: need.to },
715 };
716 }
717 default: {
718 if (need.reason === "ready_to_merge") {
719 const files = fact(need, "Files changed");
720 return {
721 ...base,
722 kind: "ready",
723 stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" },
724 action: { label: "Merge", to: need.to },
725 };
726 }
727 return {
728 ...base,
729 kind: "pull_blocked",
730 stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" },
731 action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to },
732 };
733 }
734 }
735}
736
737/** A session stopped at its spend cap that the viewer may approve more for. */
738export function sessionCapRow(session: AgentSession, slug: string): AttentionRow {
739 const cap = session.cap_micros;
740 return {
741 key: `session:${session.id}`,
742 kind: "session_cap",
743 title: session.visible ? session.title : `A session of @${session.agent_handle}`,
744 detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`,
745 stake: { text: cap != null ? `${money(session.charged_micros)} of ${money(cap)} cap` : `${money(session.charged_micros)} spent`, tone: "warn" },
746 action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` },
747 owner: { name: session.agent_handle, agent: true },
748 at: Date.parse(session.updated_at),
749 from: "agents",
750 };
751}
752
753export type AgentLike = {
754 id: string;
755 handle: string;
756 display_name: string;
757 status: string;
758 spent_month_micros: number;
759 budget: { monthly_micros: number | null };
760 updated_at: string;
761};
762
763/** An agent out of budget (for those who may raise it), or waiting on someone. */
764export function agentRow(agent: AgentLike, slug: string): AttentionRow | null {
765 const base = {
766 key: `agent:${agent.id}`,
767 owner: { name: agent.handle, agent: true },
768 at: Date.parse(agent.updated_at),
769 from: "agents" as const,
770 };
771 if (agent.status === "out_of_budget") {
772 const budget = agent.budget.monthly_micros;
773 return {
774 ...base,
775 kind: "agent_budget",
776 title: `${agent.display_name} is out of budget`,
777 detail: `@${agent.handle} takes no new work this month until its budget is raised.`,
778 stake: { text: budget != null ? `${money(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" },
779 action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` },
780 };
781 }
782 if (agent.status === "waiting") {
783 return {
784 ...base,
785 kind: "agent_waiting",
786 title: `${agent.display_name} is waiting`,
787 detail: `@${agent.handle} is waiting before it goes on.`,
788 stake: null,
789 action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` },
790 };
791 }
792 return null;
793}
794
795const NOTIFICATION_STAKE: Record<string, string> = {
796 agent: "Agent waiting",
797 review_requested: "Review requested",
798 assign: "Assigned to you",
799 mention: "Mentioned",
800 team_mention: "Team mentioned",
801 ci_activity: "CI activity",
802 security_alert: "Security alert",
803 state_change: "State changed",
804 author: "Your work",
805 comment: "Comment",
806 manual: "Subscribed",
807 subscribed: "Watching",
808};
809
810/**
811 * An unread notification that is a warning (someone or something is
812 * waiting) or a failure, in this workspace. A member without Code access
813 * gets none about a repository.
814 */
815export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null {
816 if (item.readAt != null || item.doneAt != null) return null;
817 if (item.severity !== "warning" && item.severity !== "error") return null;
818 const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug;
819 if (!inWorkspace) return null;
820 if (!code && item.repo) return null;
821 return {
822 key: `notification:${item.id}`,
823 kind: "notification",
824 title: item.body || item.title,
825 detail: item.body ? item.title : (item.repo ?? "A notification"),
826 stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" },
827 action: { label: "Open", to: item.url },
828 owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null,
829 at: Date.parse(item.updatedAt),
830 from: "notifications",
831 };
832}
833
834export type ChatEntryLike = {
835 channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null };
836 title: string;
837 muted: boolean;
838 unread: number;
839 mentions: number;
840};
841
842/** A conversation with a mention of you, or a direct message you haven't read. */
843export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null {
844 if (entry.muted) return null;
845 if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null;
846 const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`;
847 const dm = entry.channel.kind === "dm";
848 return {
849 key: `chat:${entry.channel.id}`,
850 kind: "chat",
851 title: dm ? entry.title : `#${entry.title}`,
852 detail: dm ? "A direct message you haven't read." : "You were mentioned.",
853 stake: {
854 text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`,
855 tone: entry.mentions > 0 ? "accent" : null,
856 },
857 action: { label: "Reply", to },
858 owner: null,
859 at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0,
860 from: "chat",
861 };
862}
863
864/** A member's open request to add something from the Marketplace, for an owner. */
865export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null {
866 if (request.status !== "open") return null;
867 return {
868 key: `request:${request.id}`,
869 kind: "install_request",
870 title: `${request.requested_by} asked to add ${request.name}`,
871 detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.",
872 stake: { text: "Only owners can add it", tone: null },
873 action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` },
874 owner: { name: request.requested_by, agent: false },
875 at: Date.parse(request.requested_at),
876 from: "marketplace",
877 };
878}
879
880/**
881 * The workspace's agents at 100% of its monthly agent budget, for those who
882 * may raise it. `since` is when it was reached as far as is known: the
883 * newest session that stopped at its cap, else the start of the month.
884 */
885export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null {
886 if (input.alert == null || input.alert < 100) return null;
887 return {
888 key: "limit:agents",
889 kind: "limit",
890 title: "Agents used up the workspace's budget",
891 detail: `${money(input.spentMicros)} spent this month. Agents start nothing new until the budget is raised or the month turns.`,
892 stake: { text: "No new agent work", tone: "danger" },
893 action: { label: "Raise the budget", to: `/${slug}/-/spend` },
894 owner: null,
895 at: input.since,
896 from: "agents",
897 };
898}
899
900/** What Needs you and Start here show, and which sources did not answer. */
901export type Attention = {
902 rows: AttentionRow[];
903 start: { row: AttentionRow; why: string } | null;
904 /** Sources that did not answer, by name, for the card to say so. */
905 missing: string[];
906};
907
908export function attention(input: {
909 slug: string;
910 code: boolean;
911 codeNeeds: CodeNeed[] | null;
912 capped: AgentSession[] | null;
913 agents: AgentLike[] | null;
914 canManage: boolean;
915 /** The workspace's agent budget, for those who may manage agents; null for anyone else. */
916 limit?: { alert: number | null; spentMicros: number; since: number } | null;
917 /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */
918 requests?: InstallRequest[] | null;
919 notifications: InboxItem[] | null;
920 chat: ChatEntryLike[] | null;
921}): Attention {
922 const rows: AttentionRow[] = [];
923 const missing: string[] = [];
924 if (input.code) {
925 if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow));
926 else missing.push("Code");
927 }
928 if (input.limit && input.canManage) {
929 const row = limitRow(input.limit, input.slug);
930 if (row) rows.push(row);
931 }
932 for (const request of input.requests ?? []) {
933 const row = installRequestRow(request, input.slug);
934 if (row) rows.push(row);
935 }
936 if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug)));
937 if (input.agents) {
938 const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle));
939 for (const agent of input.agents) {
940 if (agent.status === "out_of_budget" && !input.canManage) continue;
941 if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue;
942 const row = agentRow(agent, input.slug);
943 if (row) rows.push(row);
944 }
945 }
946 if (input.capped == null || input.agents == null) missing.push("Agents");
947 if (input.notifications) {
948 for (const item of input.notifications) {
949 const row = notificationRow(item, input.slug, input.code);
950 if (row) rows.push(row);
951 }
952 } else missing.push("Notifications");
953 if (input.chat) {
954 for (const entry of input.chat) {
955 const row = chatRow(entry, input.slug);
956 if (row) rows.push(row);
957 }
958 } else missing.push("Chat");
959 const ranked = rankAttention(rows);
960 return { rows: ranked, start: startHere(ranked), missing };
961}
962
963// --- Spend in the span ----------------------------------------------------------
964
965/** Money in, which the statement lists but which is not spend. */
966const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]);
967
968export type Spend = {
969 /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */
970 from: string;
971 to: string;
972 /** Usage at price over those days, in micros. */
973 totalMicros: number;
974 /** A line per kind of charge, as the statement names it, most first. */
975 lines: { kind: string; micros: number; count: number }[];
976 /** Usage at price this month so far, the top bar's and Usage's figure; null when it could not be read. */
977 monthMicros: number | null;
978};
979
980/** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */
981export function spanMonths(span: Pick<Span, "from" | "now">): string[] {
982 const months: string[] = [];
983 const first = new Date(span.from);
984 let year = first.getUTCFullYear();
985 let month = first.getUTCMonth();
986 const last = new Date(span.now).toISOString().slice(0, 7);
987 for (let i = 0; i < 3; i++) {
988 const key = `${year}-${String(month + 1).padStart(2, "0")}`;
989 months.push(key);
990 if (key >= last) break;
991 month++;
992 if (month === 12) {
993 month = 0;
994 year++;
995 }
996 }
997 return months;
998}
999
1000/**
1001 * The span's spend from the statements grouped by day: each usage line at
1002 * its price (what was charged, plus what the plan, a trial, a pool or a
1003 * discount paid of it), leaving out money in. The statement keeps whole
1004 * UTC days, so the span is counted from the start of the UTC day it began.
1005 * `statements` is the months of `spanMonths`, the current one last.
1006 * `monthMicros` is this month so far as billing's usage report counts it,
1007 * the month's usage not yet closed included: the figure the top bar's
1008 * pill, Spend and Usage show, so Home shows no other.
1009 */
1010export function spendIn(statements: Pick<Statement, "groups" | "totals">[], span: Pick<Span, "from" | "now">, monthMicros: number | null): Spend {
1011 const from = new Date(span.from).toISOString().slice(0, 10);
1012 const to = new Date(span.now).toISOString().slice(0, 10);
1013 const byKind = new Map<string, { micros: number; count: number }>();
1014 for (const statement of statements) {
1015 for (const group of statement.groups) {
1016 if (group.key < from || group.key > to) continue;
1017 for (const line of group.lines) {
1018 if (MONEY_IN.has(line.kind)) continue;
1019 const micros = line.priceMicros ?? line.chargedMicros;
1020 const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 };
1021 byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count });
1022 }
1023 }
1024 }
1025 const lines = [...byKind]
1026 .map(([kind, value]) => ({ kind, ...value }))
1027 .filter((line) => line.micros > 0)
1028 .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind));
1029 return {
1030 from,
1031 to,
1032 totalMicros: lines.reduce((sum, line) => sum + line.micros, 0),
1033 lines,
1034 monthMicros,
1035 };
1036}
1037
1038// --- The sentence -----------------------------------------------------------
1039
1040/**
1041 * The sentence under the heading: "Since Tuesday evening, agents finished
1042 * 35 tasks and 12 changes landed. 4 needed a fix after review." Finished
1043 * counts every task that settled well: accepted, fixed, and sessions that
1044 * finished.
1045 */
1046export function spanSentence(span: Pick<Span, "words">, work: Pick<SpanWork, "counts" | "tasks"> | null, landed: number | null): string {
1047 const lead = span.words;
1048 if (!work) return `${lead}, agent work couldn't be read.`;
1049 const { accepted, fixed, finished, dropped } = work.counts;
1050 const done = accepted + fixed + finished;
1051 const changes = landed && landed > 0 ? `${plural(landed, "change")} landed` : null;
1052 if (work.tasks.length === 0 && !changes) return `${lead}, nothing new has settled.`;
1053 const first =
1054 done > 0
1055 ? `${lead}, agents finished ${plural(done, "task")}${changes ? ` and ${changes}` : ""}.`
1056 : changes
1057 ? `${lead}, ${changes}${work.tasks.length > 0 ? "; agents finished none of their tasks" : ""}.`
1058 : `${lead}, agents finished no tasks.`;
1059 const after: string[] = [];
1060 if (fixed > 0) after.push(`${fixed.toLocaleString("en-US")} needed a fix after review`);
1061 if (dropped > 0) after.push(`${dropped.toLocaleString("en-US")} didn't finish`);
1062 if (after.length === 0) return first;
1063 const text = after.length > 1 ? `${after[0]}, and ${after[1]}` : after[0]!;
1064 return `${first} ${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
1065}
1066
1067/** "8 things need you.", or that nothing does. */
1068export function waitingSentence(total: number): string {
1069 if (total === 0) return "Nothing needs you right now.";
1070 return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`;
1071}
1072
1073/** How long something has waited: "45 min", "17 h", "2 d". */
1074export function waited(at: number, now: number): string {
1075 const minutes = Math.max(0, Math.floor((now - at) / MINUTE));
1076 if (minutes < 60) return `${minutes} min`;
1077 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`;
1078 return `${Math.floor(minutes / (24 * 60))} d`;
1079}
1080
1081/** "3:40 PM" when it is the same day as `now`, else "Oct 7". */
1082export function whenShort(at: number, now: number, timeZone: string | null): string {
1083 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
1084 return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone);
1085}