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 | */ | |
| 8 | import type { AgentSession, AgentSessionKind, InboxItem, Pull, RepoPath, Statement } from "@g1t/contracts"; | |
| 9 | ||
| 10 | const MINUTE = 60_000; | |
| 11 | const 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. */ | |
| 14 | export 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. */ | |
| 23 | export 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. */ | |
| 35 | export 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 | */ | |
| 54 | export type TaskOutcome = "accepted" | "fixed" | "finished" | "open" | "dropped"; | |
| 55 | ||
| 56 | /** Where a task came from. */ | |
| 57 | export type TaskSource = "chat" | "schedule" | "colleague" | "code"; | |
| 58 | ||
| 59 | export 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 | ||
| 67 | export 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. */ | |
| 75 | export 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. */ | |
| 87 | export 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. */ | |
| 93 | export 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. */ | |
| 99 | export 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 | */ | |
| 108 | export 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 | ||
| 118 | const 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 | */ | |
| 125 | export 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 | ||
| 137 | export 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. */ | |
| 142 | export 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 | ||
| 162 | export 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 | ||
| 170 | export type SessionWork = { sessions: AgentSession[]; complete: boolean }; | |
| 171 | ||
| 172 | /** Accepted first time over a set of settled outcomes: null when nothing settled. */ | |
| 173 | export 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 | ||
| 179 | export 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". */ | |
| 251 | export 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 | */ | |
| 269 | export 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 | ||
| 284 | export 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 | ||
| 300 | export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null }; | |
| 301 | ||
| 302 | /** One row of Needs attention. */ | |
| 303 | export 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 | */ | |
| 327 | export 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. */ | |
| 341 | export 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 | ||
| 349 | const 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`). */ | |
| 368 | export 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 | ||
| 382 | const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null; | |
| 383 | ||
| 384 | const 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. */ | |
| 394 | export 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. */ | |
| 446 | export 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. */ | |
| 453 | export 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 | ||
| 468 | export 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. */ | |
| 479 | export 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 | ||
| 510 | const 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 | */ | |
| 530 | export 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 | ||
| 549 | export 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. */ | |
| 558 | export 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. */ | |
| 580 | export 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 | ||
| 587 | export 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. */ | |
| 633 | const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]); | |
| 634 | ||
| 635 | export 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 | */ | |
| 652 | export 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 | ||
| 670 | const 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 | */ | |
| 677 | export 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. */ | |
| 697 | export 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". */ | |
| 703 | export 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 | } |