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.
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 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 | */ | |
| 10 | import type { | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 11 | ActivityDigest, |
| 12 | ActorCount, | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 13 | AgentSession, |
| 14 | AgentSessionKind, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 15 | ChatActivity, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 16 | Deployment, |
| 17 | InboxItem, | |
| 18 | InstallRequest, | |
| 19 | Memory, | |
| 20 | ProjectDeploys, | |
| 21 | Pull, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 22 | RepoDigest, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 23 | RepoPath, |
| 24 | Statement, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 25 | WorkflowRun, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 26 | } from "@g1t/contracts"; |
| 27 | ||
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 28 | import { money } from "./money.ts"; |
| 29 | ||
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 30 | const MINUTE = 60_000; |
| 31 | const HOUR = 60 * MINUTE; | |
| 32 | const DAY = 24 * HOUR; | |
| 33 | ||
| 34 | /** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */ | |
| 35 | export function dayIn(at: number, timeZone: string | null): string { | |
| 36 | try { | |
| 37 | return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at); | |
| 38 | } catch { | |
| 39 | return new Date(at).toISOString().slice(0, 10); | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | /** The hour of the day (0–23) in a time zone; UTC when it is unknown. */ | |
| 44 | export function hourIn(at: number, timeZone: string | null): number { | |
| 45 | const read = (zone: string) => Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: zone }).format(at)) % 24; | |
| 46 | try { | |
| 47 | return read(timeZone || "UTC"); | |
| 48 | } catch { | |
| 49 | return read("UTC"); | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | function formatIn(at: number, timeZone: string | null, options: Intl.DateTimeFormatOptions): string { | |
| 54 | try { | |
| 55 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(at); | |
| 56 | } catch { | |
| 57 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(at); | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | /** "Oct 9", in the reader's time zone. */ | |
| 62 | export function shortDate(at: number, timeZone: string | null): string { | |
| 63 | return formatIn(at, timeZone, { month: "short", day: "numeric" }); | |
| 64 | } | |
| 65 | ||
| 66 | // --- The span ----------------------------------------------------------------- | |
| 67 | ||
| 68 | /** Which span "what happened" covers: since your last visit, or a fixed one. */ | |
| 69 | export type WindowKey = "last" | "24h" | "7d"; | |
| 70 | ||
| 71 | export const WINDOWS: { key: WindowKey; label: string }[] = [ | |
| 72 | { key: "last", label: "Since last visit" }, | |
| 73 | { key: "24h", label: "Last 24 hours" }, | |
| 74 | { key: "7d", label: "Last 7 days" }, | |
| 75 | ]; | |
| 76 | ||
| 77 | /** The furthest back "since your last visit" looks. */ | |
| 78 | export const MAX_AWAY_DAYS = 14; | |
| 79 | ||
| 80 | /** A `?window=` value, or the default: since your last visit. */ | |
| 81 | export function parseWindow(value: string | null | undefined): WindowKey { | |
| 82 | return value === "24h" || value === "7d" ? value : "last"; | |
| 83 | } | |
| 84 | ||
| 85 | export type Span = { | |
| 86 | key: WindowKey; | |
| 87 | /** Epoch ms: the start of the span. */ | |
| 88 | from: number; | |
| 89 | now: number; | |
| 90 | /** "Since Tuesday evening", "In the last 24 hours". */ | |
| 91 | words: string; | |
| 92 | /** How long it covers: "3 days", "45 minutes". */ | |
| 93 | length: string; | |
| 94 | /** | |
| 95 | * Why "since your last visit" covers something else: `first` when there | |
| 96 | * is no visit on record (it shows the last 24 hours), `capped` when the | |
| 97 | * last one was longer ago than MAX_AWAY_DAYS, `unknown` when it could | |
| 98 | * not be read (it shows the last 24 hours). | |
| 99 | */ | |
| 100 | note: "first" | "capped" | "unknown" | null; | |
| 101 | }; | |
| 102 | ||
| 103 | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 104 | ||
| 105 | /** How long a stretch of time is, roughly: "a moment", "40 minutes", "3 hours", "3 days", "2 weeks". */ | |
| 106 | export function duration(ms: number): string { | |
| 107 | const minutes = Math.floor(Math.max(0, ms) / MINUTE); | |
| 108 | if (minutes < 1) return "a moment"; | |
| 109 | if (minutes < 60) return plural(minutes, "minute"); | |
| 110 | const hours = Math.floor(minutes / 60); | |
| 111 | if (hours < 24) return plural(hours, "hour"); | |
| 112 | const days = Math.floor(hours / 24); | |
| 113 | if (days < 14) return plural(days, "day"); | |
| 114 | return plural(Math.floor(days / 7), "week"); | |
| 115 | } | |
| 116 | ||
| 117 | type Part = "night" | "morning" | "afternoon" | "evening"; | |
| 118 | ||
| 119 | /** | |
| 120 | * When `from` was, in words, from `now`: "a moment ago", "40 minutes ago", | |
| 121 | * "2 hours ago", "this morning", "last night", "yesterday evening", | |
| 122 | * "Tuesday evening", "Oct 2". The small hours belong to the night before: | |
| 123 | * 2 a.m. on Wednesday is Tuesday night. | |
| 124 | */ | |
| 125 | export function sinceWords(from: number, now: number, timeZone: string | null): string { | |
| 126 | const ago = now - from; | |
| 127 | if (ago < MINUTE) return "a moment ago"; | |
| 128 | if (ago < HOUR) return `${plural(Math.floor(ago / MINUTE), "minute")} ago`; | |
| 129 | if (ago < 3 * HOUR) return Math.floor(ago / HOUR) === 1 ? "an hour ago" : `${Math.floor(ago / HOUR)} hours ago`; | |
| 130 | const hour = hourIn(from, timeZone); | |
| 131 | const part: Part = hour < 5 ? "night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"; | |
| 132 | // The night is the evening's day's. | |
| 133 | const day = dayIn(part === "night" ? from - 6 * HOUR : from, timeZone); | |
| 134 | const today = dayIn(now, timeZone); | |
| 135 | const yesterday = dayIn(now - DAY, timeZone); | |
| 136 | const twoDaysAgo = dayIn(now - 2 * DAY, timeZone); | |
| 137 | if (day === today) return `this ${part === "night" ? "evening" : part}`; | |
| 138 | if (day === yesterday) return part === "night" ? "last night" : `yesterday ${part}`; | |
| 139 | if (part === "night" && day === twoDaysAgo) return "the night before last"; | |
| 140 | if (ago < 6 * DAY) return `${formatIn(part === "night" ? from - 6 * HOUR : from, timeZone, { weekday: "long" })} ${part}`; | |
| 141 | return shortDate(from, timeZone); | |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * The span for `key`: since `lastSeen` (your last visit, kept by notify), | |
| 146 | * or the last 24 hours or 7 days. Since your last visit falls back to the | |
| 147 | * last 24 hours when there is none (`null`) or it could not be read | |
| 148 | * (`undefined`), and to MAX_AWAY_DAYS when it was longer ago. | |
| 149 | */ | |
| 150 | export function spanFor(key: WindowKey, now: number, lastSeen: number | null | undefined, timeZone: string | null): Span { | |
| 151 | if (key === "7d") return { key, from: now - 7 * DAY, now, words: "In the last 7 days", length: "7 days", note: null }; | |
| 152 | if (key === "24h" || lastSeen == null || lastSeen > now) { | |
| 153 | const note = key !== "last" ? null : lastSeen === undefined ? "unknown" : "first"; | |
| 154 | return { key, from: now - DAY, now, words: "In the last 24 hours", length: "24 hours", note }; | |
| 155 | } | |
| 156 | const furthest = now - MAX_AWAY_DAYS * DAY; | |
| 157 | if (lastSeen < furthest) return { key, from: furthest, now, words: `In the last ${MAX_AWAY_DAYS} days`, length: `${MAX_AWAY_DAYS} days`, note: "capped" }; | |
| 158 | return { key, from: lastSeen, now, words: `Since ${sinceWords(lastSeen, now, timeZone)}`, length: duration(now - lastSeen), note: null }; | |
| 159 | } | |
| 160 | ||
| 161 | /** Whether `at` (epoch ms or RFC 3339) falls in the span. */ | |
| 162 | export function within(at: number | string | null | undefined, span: Pick<Span, "from" | "now">): boolean { | |
| 163 | if (at == null) return false; | |
| 164 | const ms = typeof at === "number" ? at : Date.parse(at); | |
| 165 | return Number.isFinite(ms) && ms >= span.from && ms <= span.now; | |
| 166 | } | |
| 167 | ||
| 168 | // --- Agent work in the span --------------------------------------------------- | |
| 169 | ||
| 170 | /** | |
| 171 | * How one piece of agent work that settled in the span went: | |
| 172 | * - `accepted`: an agent's pull request merged without the agent being sent back to revise it. | |
| 173 | * - `fixed`: an agent's pull request merged after one or more revisions. | |
| 174 | * - `finished`: an agent session that finished. Sessions are not reviewed, so they are never `accepted`. | |
| 175 | * - `dropped`: an agent's pull request closed without merging, or a session that failed or was stopped. | |
| 176 | * Work still going is not here: it is under Running now. | |
| 177 | */ | |
| 178 | export type TaskOutcome = "accepted" | "fixed" | "finished" | "dropped"; | |
| 179 | ||
| 180 | /** Where a task came from. */ | |
| 181 | export type TaskSource = "chat" | "schedule" | "colleague" | "code"; | |
| 182 | ||
| 183 | export const OUTCOME_LABEL: Record<TaskOutcome, string> = { | |
| 184 | accepted: "Accepted first time", | |
| 185 | fixed: "Fixed after review", | |
| 186 | finished: "Sessions finished", | |
| 187 | dropped: "Didn't finish", | |
| 188 | }; | |
| 189 | ||
| 190 | export const SOURCE_LABEL: Record<TaskSource, string> = { | |
| 191 | chat: "Chat", | |
| 192 | schedule: "Schedules", | |
| 193 | colleague: "Another agent", | |
| 194 | code: "Code", | |
| 195 | }; | |
| 196 | ||
| 197 | /** One mark on the strip. */ | |
| 198 | export type WorkTask = { | |
| 199 | key: string; | |
| 200 | outcome: TaskOutcome; | |
| 201 | source: TaskSource; | |
| 202 | /** Null for a session in a conversation the viewer is not in. */ | |
| 203 | title: string | null; | |
| 204 | to: string | null; | |
| 205 | /** When it settled: the strip's order. */ | |
| 206 | at: number; | |
| 207 | }; | |
| 208 | ||
| 209 | /** A pull request with where it is: what Home reads from Code. */ | |
| 210 | export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & { | |
| 211 | repo: RepoPath; | |
| 212 | author: { username: string; kind?: string }; | |
| 213 | /** Who merged it, when it merged. */ | |
| 214 | mergedBy?: string | null; | |
| 215 | }; | |
| 216 | ||
| 217 | /** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */ | |
| 218 | export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean { | |
| 219 | const name = pull.author.username; | |
| 220 | return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent"); | |
| 221 | } | |
| 222 | ||
| 223 | /** `acme/web#12`, lowercase: how a pull request and its runs are matched. */ | |
| 224 | export function pullKey(repo: RepoPath, number: number): string { | |
| 225 | return `${repo.namespace}/${repo.name}#${number}`.toLowerCase(); | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * How an agent's pull request settled in the span, or null when it did not | |
| 230 | * settle in it. `revisions` counts its `revise` runs: each time g1t sent | |
| 231 | * the agent back for failed checks or a review. | |
| 232 | */ | |
| 233 | export function pullOutcome(pull: CodePull, revisions: number, span: Pick<Span, "from" | "now">): TaskOutcome | null { | |
| 234 | if (pull.status === "merged") return within(pull.mergedAt, span) ? (revisions > 0 ? "fixed" : "accepted") : null; | |
| 235 | if (pull.status === "closed") return within(pull.updatedAt, span) ? "dropped" : null; | |
| 236 | return null; | |
| 237 | } | |
| 238 | ||
| 239 | const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]); | |
| 240 | ||
| 241 | /** | |
| 242 | * How a session settled in the span, or null. Only a session at the root | |
| 243 | * of its tree counts: the subagents and colleagues it brought in are part | |
| 244 | * of its task. | |
| 245 | */ | |
| 246 | export function sessionOutcome( | |
| 247 | session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">, | |
| 248 | span: Pick<Span, "from" | "now">, | |
| 249 | ): TaskOutcome | null { | |
| 250 | if (session.parent_id || LIVE.has(session.status)) return null; | |
| 251 | if (!within(session.finished_at ?? session.updated_at, span)) return null; | |
| 252 | return session.status === "done" ? "finished" : "dropped"; | |
| 253 | } | |
| 254 | ||
| 255 | export function sessionSource(kind: AgentSessionKind): TaskSource { | |
| 256 | return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague"; | |
| 257 | } | |
| 258 | ||
| 259 | /** The agents' work that settled in the span, and how the 7 days before it compare. */ | |
| 260 | export type SpanWork = { | |
| 261 | tasks: WorkTask[]; | |
| 262 | counts: Record<TaskOutcome, number>; | |
| 263 | /** Tasks by where they came from, most first; only sources with any. */ | |
| 264 | sources: { source: TaskSource; count: number }[]; | |
| 265 | /** | |
| 266 | * Accepted first time: of the agents' pull requests that merged or closed | |
| 267 | * in the span, the share that merged without a revision. Null with none. | |
| 268 | */ | |
| 269 | rate: { value: number; of: number } | null; | |
| 270 | /** The same share over the 7 days before the span; null when there were none, or they could not all be read. */ | |
| 271 | before: { value: number; of: number } | null; | |
| 272 | /** Whether Code was read: false for a member without Code access, or when Code did not answer. */ | |
| 273 | code: "read" | "no_access" | "unavailable"; | |
| 274 | /** Whether sessions were read. */ | |
| 275 | sessions: "read" | "unavailable"; | |
| 276 | /** True when a list was cut short before the start of the span, so some work may be missing. */ | |
| 277 | partial: boolean; | |
| 278 | }; | |
| 279 | ||
| 280 | export type CodeWork = { | |
| 281 | pulls: CodePull[]; | |
| 282 | /** `revise` runs per pull request (`pullKey`). */ | |
| 283 | revisions: Record<string, number>; | |
| 284 | /** Whether every list read reached back to 7 days before the span. */ | |
| 285 | complete: boolean; | |
| 286 | }; | |
| 287 | ||
| 288 | export type SessionWork = { sessions: AgentSession[]; complete: boolean }; | |
| 289 | ||
| 290 | /** Accepted first time over a set of settled outcomes: null when no pull request settled. */ | |
| 291 | export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null { | |
| 292 | const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped"); | |
| 293 | if (settled.length === 0) return null; | |
| 294 | return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length }; | |
| 295 | } | |
| 296 | ||
| 297 | export function spanWork(input: { | |
| 298 | span: Pick<Span, "from" | "now">; | |
| 299 | slug: string; | |
| 300 | code: CodeWork | null | "no_access"; | |
| 301 | sessions: SessionWork | null; | |
| 302 | }): SpanWork { | |
| 303 | const { span, slug } = input; | |
| 304 | const tasks: WorkTask[] = []; | |
| 305 | let before: SpanWork["before"] = null; | |
| 306 | const code = input.code; | |
| 307 | if (code && code !== "no_access") { | |
| 308 | const prior = { from: span.from - 7 * DAY, now: span.from - 1 }; | |
| 309 | const earlier: TaskOutcome[] = []; | |
| 310 | for (const pull of code.pulls) { | |
| 311 | if (!isAgentPull(pull)) continue; | |
| 312 | const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0; | |
| 313 | const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`; | |
| 314 | const outcome = pullOutcome(pull, revisions, span); | |
| 315 | if (outcome) { | |
| 316 | const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt); | |
| 317 | tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at }); | |
| 318 | continue; | |
| 319 | } | |
| 320 | const then = pullOutcome(pull, revisions, prior); | |
| 321 | if (then) earlier.push(then); | |
| 322 | } | |
| 323 | before = code.complete ? acceptance(earlier) : null; | |
| 324 | } | |
| 325 | for (const session of input.sessions?.sessions ?? []) { | |
| 326 | const outcome = sessionOutcome(session, span); | |
| 327 | if (!outcome) continue; | |
| 328 | tasks.push({ | |
| 329 | key: `session:${session.id}`, | |
| 330 | outcome, | |
| 331 | source: sessionSource(session.kind), | |
| 332 | title: session.visible ? session.title : null, | |
| 333 | to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`, | |
| 334 | at: Date.parse(session.finished_at ?? session.updated_at), | |
| 335 | }); | |
| 336 | } | |
| 337 | // In the order they settled. | |
| 338 | tasks.sort((a, b) => a.at - b.at || a.key.localeCompare(b.key)); | |
| 339 | const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0 }; | |
| 340 | const bySource = new Map<TaskSource, number>(); | |
| 341 | for (const task of tasks) { | |
| 342 | counts[task.outcome]++; | |
| 343 | bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1); | |
| 344 | } | |
| 345 | const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"]; | |
| 346 | return { | |
| 347 | tasks, | |
| 348 | counts, | |
| 349 | sources: [...bySource] | |
| 350 | .map(([source, count]) => ({ source, count })) | |
| 351 | .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)), | |
| 352 | rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)), | |
| 353 | before, | |
| 354 | code: code === "no_access" ? "no_access" : code ? "read" : "unavailable", | |
| 355 | sessions: input.sessions ? "read" : "unavailable", | |
| 356 | partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete), | |
| 357 | }; | |
| 358 | } | |
| 359 | ||
| 360 | /** The change in percentage points from the 7 days before, rounded: "+6 pts vs the 7 days before", "Same as the 7 days before". */ | |
| 361 | export function trendLabel(rate: { value: number } | null, before: { value: number } | null): string | null { | |
| 362 | if (!rate || !before) return null; | |
| 363 | const points = Math.round(rate.value * 100) - Math.round(before.value * 100); | |
| 364 | if (points === 0) return "Same as the 7 days before"; | |
| 365 | return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`; | |
| 366 | } | |
| 367 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 368 | // --- People and agents in the span ----------------------------------------------- |
| 369 | ||
| 370 | /** | |
| 371 | * Who an actor is. Actors are member keys, as the events and chat services | |
| 372 | * return them: `user:<id>` for an account, `agent:<id>` for one of the | |
| 373 | * workspace's agents acting as itself, `""` for nobody (a push a mirror | |
| 374 | * copied in). An agent is one of the workspace's agents, or g1t itself; | |
| 375 | * everyone else is a person. `agents` names more keys that are agents' | |
| 376 | * (accounts of kind `agent`), when known. | |
| 377 | */ | |
| 378 | export type ActorKind = "person" | "agent"; | |
| 379 | ||
| 380 | /** g1t's own account, as events name it (identity's `AGENT_ID`). */ | |
| 381 | export const G1T_ACTOR = "user:usr_g1t_agent"; | |
| 382 | ||
| 383 | export function actorKind(key: string, agents?: ReadonlySet<string>): ActorKind { | |
| Four small things seen today. An agent can attach a file it made to any doc the person who asked can edit, whatever the space's agent mode: attaching changes nothing in the doc, so the suggest mode that stopped every PDF and spreadsheet no longer does; the ability is its own, attach, beside read, suggest and edit. Home no longer counts robots among the people: events that name an agent by its own id, or g1t's upkeep as the workspace or as g1t, read as the agent and as g1t, in the digest and on the page, so the faces say who instead of someone. Tooltips are the inverted bubble, the page's foreground behind the page's background, with no border. The Spend page's slice tabs keep their ring inside the row instead of losing its top to the scroll edge, and the Apps launcher keeps its search box and footer in place while it reads which apps there are, with skeleton tiles between. | 384 | return key.startsWith("agent:") || key === G1T_ACTOR || isRobotKey(key) || agents?.has(key) ? "agent" : "person"; |
| 385 | } | |
| 386 | ||
| 387 | /** | |
| 388 | * A member key that names a robot under `user:`: an agent by its own id, | |
| 389 | * or g1t's upkeep as the workspace or as `g1t`. Events normalise these | |
| 390 | * (services/events digest), so this is the safety net for older ones. | |
| 391 | */ | |
| 392 | export function isRobotKey(key: string): boolean { | |
| 393 | return key.startsWith("user:agt_") || key.startsWith("user:wsp_") || key === "user:g1t"; | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 394 | } |
| 395 | ||
| 396 | /** Whether a username is an agent's: g1t, or an agent account. */ | |
| 397 | export function isAgentName(name: string | null | undefined): boolean { | |
| 398 | return name === "g1t" || (name != null && name.endsWith("-agent")); | |
| 399 | } | |
| 400 | ||
| 401 | /** A repository as the digest's links and lines need it. */ | |
| 402 | export type RepoRef = RepoPath & { defaultBranch: string }; | |
| 403 | ||
| 404 | /** Someone who did something in the span: for the faces beside a column. */ | |
| 405 | export type DigestActor = { | |
| 406 | key: string; | |
| 407 | kind: ActorKind; | |
| 408 | /** Their username or handle; `someone` when it could not be read. */ | |
| 409 | name: string; | |
| 410 | /** How many things they did, over every line: the order of the faces. */ | |
| 411 | count: number; | |
| 412 | }; | |
| 413 | ||
| 414 | /** One line of a column: what was done, with its count, and where that is listed. */ | |
| 415 | export type DigestLine = { | |
| 416 | key: string; | |
| 417 | text: string; | |
| 418 | to: string | null; | |
| 419 | }; | |
| 420 | ||
| 421 | export type DigestColumn = { | |
| 422 | kind: ActorKind; | |
| 423 | lines: DigestLine[]; | |
| 424 | /** Most active first. */ | |
| 425 | actors: DigestActor[]; | |
| 426 | }; | |
| 427 | ||
| 428 | /** What the sentence under the heading needs. */ | |
| 429 | export type DigestSummary = { | |
| 430 | /** Distinct people who did anything. */ | |
| 431 | people: number; | |
| 432 | /** Commits people pushed. */ | |
| 433 | commits: number; | |
| 434 | /** Pull requests people merged. */ | |
| 435 | merged: number; | |
| 436 | /** Agent tasks that settled well, and that did not. */ | |
| 437 | finished: number; | |
| 438 | dropped: number; | |
| 439 | /** Messages people sent. */ | |
| 440 | said: number; | |
| 441 | /** Whether anything at all was counted, in either column. */ | |
| 442 | anything: boolean; | |
| 443 | /** Whether what people did could be read at all (events and chat both silent: no). */ | |
| 444 | peopleRead: boolean; | |
| 445 | }; | |
| 446 | ||
| 447 | export type Digest = { | |
| 448 | people: DigestColumn; | |
| 449 | agents: DigestColumn; | |
| 450 | summary: DigestSummary; | |
| 451 | /** Sources that did not answer, by name, for the card to say so: Code, Chat, Agents. */ | |
| 452 | missing: string[]; | |
| 453 | /** True when a source was cut short, so some counts are low. */ | |
| 454 | partial: boolean; | |
| 455 | }; | |
| 456 | ||
| 457 | export type DigestInput = { | |
| 458 | slug: string; | |
| 459 | /** The workspace's repositories by id: for links, and to count projects. */ | |
| 460 | repos: Record<string, RepoRef>; | |
| 461 | /** Events' digest; null when events did not answer. Without Code access it has no repositories. */ | |
| 462 | activity: ActivityDigest | null; | |
| 463 | /** Chat's; null when chat did not answer. */ | |
| 464 | chat: ChatActivity | null; | |
| 465 | /** Names for member keys: a username, or an agent's handle. */ | |
| 466 | names: Record<string, string>; | |
| 467 | /** Keys of accounts that are agents, beyond g1t and the workspace's agents. */ | |
| 468 | agentKeys?: ReadonlySet<string>; | |
| 469 | /** The agents' tasks, for their column; null when neither Code nor Agents answered. */ | |
| 470 | work: SpanWork | null; | |
| 471 | code: boolean; | |
| 472 | }; | |
| 473 | ||
| 474 | const people = (n: number) => plural(n, "person", "people"); | |
| 475 | ||
| 476 | /** `a, b and c`. */ | |
| 477 | export const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`); | |
| 478 | ||
| 479 | /** What one kind did, from a tally: how many, and by whom (non-empty keys). */ | |
| 480 | function part(counts: ActorCount[], kind: ActorKind, agents?: ReadonlySet<string>): { count: number; actors: Map<string, number> } { | |
| 481 | const actors = new Map<string, number>(); | |
| 482 | let count = 0; | |
| 483 | for (const entry of counts) { | |
| 484 | if (actorKind(entry.actor, agents) !== kind) continue; | |
| 485 | count += entry.count; | |
| 486 | if (entry.actor) actors.set(entry.actor, (actors.get(entry.actor) ?? 0) + entry.count); | |
| 487 | } | |
| 488 | return { count, actors }; | |
| 489 | } | |
| 490 | ||
| 491 | /** Where a line points: the one project's page, or the workspace's projects when several. */ | |
| 492 | function placeFor(slug: string, repos: Record<string, RepoRef>, repoIds: Iterable<string>, page: string): string { | |
| 493 | const ids = [...new Set(repoIds)]; | |
| 494 | const only = ids.length === 1 ? repos[ids[0]!] : null; | |
| 495 | return only ? `/${only.namespace}/${only.name}/${page}` : `/${slug}/-/repositories`; | |
| 496 | } | |
| 497 | ||
| 498 | /** | |
| 499 | * One column: what people, or agents, did in the span, as lines with | |
| 500 | * counts and links, and who did it. Pure: everything comes from the | |
| 501 | * services' counts and the names looked up for them. | |
| 502 | */ | |
| 503 | export function digestColumn(kind: ActorKind, input: DigestInput): DigestColumn { | |
| 504 | const { slug, repos, activity, chat, names, agentKeys, work } = input; | |
| 505 | const lines: DigestLine[] = []; | |
| 506 | const did = new Map<string, number>(); | |
| 507 | const credit = (actors: Map<string, number>) => { | |
| 508 | for (const [key, n] of actors) did.set(key, (did.get(key) ?? 0) + n); | |
| 509 | }; | |
| 510 | ||
| 511 | // The agents' tasks come first in their column: what Home has always counted. | |
| 512 | if (kind === "agent" && work) { | |
| 513 | const { accepted, fixed, finished, dropped } = work.counts; | |
| 514 | const done = accepted + fixed + finished; | |
| 515 | if (done > 0 || dropped > 0) { | |
| 516 | const text = done > 0 ? `finished ${plural(done, "task")}${dropped > 0 ? `, ${dropped.toLocaleString("en-US")} didn't finish` : ""}` : `finished none of ${plural(dropped, "task")}`; | |
| 517 | lines.push({ key: "tasks", text, to: `/${slug}/-/agents` }); | |
| 518 | } | |
| 519 | const sessions = work.tasks.filter((task) => task.source !== "code").length; | |
| 520 | if (sessions > 0) lines.push({ key: "sessions", text: `ran ${plural(sessions, "session")}`, to: `/${slug}/-/agents` }); | |
| 521 | } | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 522 | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 523 | if (activity) { |
| 524 | // Pushes: commits, projects, and who. | |
| 525 | const pushers = new Map<string, number>(); | |
| 526 | const pushed = new Set<string>(); | |
| 527 | let commits = 0; | |
| 528 | let pushes = 0; | |
| 529 | for (const repo of activity.repos) { | |
| 530 | let any = false; | |
| 531 | for (const by of repo.pushes.by) { | |
| 532 | if (actorKind(by.actor, agentKeys) !== kind) continue; | |
| 533 | any = true; | |
| 534 | commits += by.commits; | |
| 535 | pushes += by.count; | |
| 536 | if (by.actor) pushers.set(by.actor, (pushers.get(by.actor) ?? 0) + by.count); | |
| 537 | } | |
| 538 | if (any) pushed.add(repo.repo_id); | |
| 539 | } | |
| 540 | if (pushes > 0) { | |
| 541 | const where = `to ${plural(pushed.size, "project")}`; | |
| 542 | const text = | |
| 543 | kind === "person" | |
| 544 | ? pushers.size > 0 | |
| 545 | ? `${people(pushers.size)} pushed ${plural(commits, "commit")} ${where}` | |
| 546 | : `${plural(commits, "commit")} pushed ${where}` | |
| 547 | : `pushed ${plural(commits, "commit")} ${where}`; | |
| 548 | lines.push({ key: "pushes", text, to: placeFor(slug, repos, pushed, "activity") }); | |
| 549 | credit(pushers); | |
| 550 | } | |
| 551 | ||
| 552 | // Pull requests, reviews, issues, comments, deploys, releases, packages: summed over projects. | |
| 553 | const sum = (pick: (repo: RepoDigest) => ActorCount[]) => { | |
| 554 | const total = { count: 0, actors: new Map<string, number>(), repoIds: [] as string[] }; | |
| 555 | for (const repo of activity.repos) { | |
| 556 | const { count, actors } = part(pick(repo), kind, agentKeys); | |
| 557 | if (count === 0) continue; | |
| 558 | total.count += count; | |
| 559 | total.repoIds.push(repo.repo_id); | |
| 560 | for (const [key, n] of actors) total.actors.set(key, (total.actors.get(key) ?? 0) + n); | |
| 561 | } | |
| 562 | return total; | |
| 563 | }; | |
| 564 | const opened = sum((repo) => repo.pulls.opened); | |
| 565 | const merged = sum((repo) => repo.pulls.merged); | |
| 566 | const closed = sum((repo) => repo.pulls.closed); | |
| 567 | if (opened.count + merged.count + closed.count > 0) { | |
| 568 | const parts: string[] = []; | |
| 569 | if (opened.count > 0) parts.push(`opened ${plural(opened.count, "pull request")}`); | |
| 570 | if (merged.count > 0) parts.push(`merged ${opened.count > 0 ? merged.count.toLocaleString("en-US") : plural(merged.count, "pull request")}`); | |
| 571 | if (closed.count > 0) parts.push(`closed ${closed.count.toLocaleString("en-US")} without merging`); | |
| 572 | lines.push({ key: "pulls", text: parts.join(", "), to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds], "pulls") }); | |
| 573 | credit(opened.actors); | |
| 574 | credit(merged.actors); | |
| 575 | credit(closed.actors); | |
| 576 | } | |
| 577 | const reviews = sum((repo) => repo.reviews); | |
| 578 | if (reviews.count > 0) { | |
| 579 | lines.push({ key: "reviews", text: `reviewed ${plural(reviews.count, "pull request")}`, to: placeFor(slug, repos, reviews.repoIds, "pulls") }); | |
| 580 | credit(reviews.actors); | |
| 581 | } | |
| 582 | const issuesOpened = sum((repo) => repo.issues.opened); | |
| 583 | const issuesClosed = sum((repo) => repo.issues.closed); | |
| 584 | if (issuesOpened.count + issuesClosed.count > 0) { | |
| 585 | const parts: string[] = []; | |
| 586 | if (issuesOpened.count > 0) parts.push(`opened ${plural(issuesOpened.count, "issue")}`); | |
| 587 | if (issuesClosed.count > 0) parts.push(`closed ${issuesOpened.count > 0 ? issuesClosed.count.toLocaleString("en-US") : plural(issuesClosed.count, "issue")}`); | |
| 588 | lines.push({ key: "issues", text: parts.join(", "), to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds], "issues") }); | |
| 589 | credit(issuesOpened.actors); | |
| 590 | credit(issuesClosed.actors); | |
| 591 | } | |
| 592 | const comments = sum((repo) => repo.comments); | |
| 593 | if (comments.count > 0) { | |
| 594 | lines.push({ key: "comments", text: `left ${plural(comments.count, "comment")}`, to: placeFor(slug, repos, comments.repoIds, "issues") }); | |
| 595 | credit(comments.actors); | |
| 596 | } | |
| 597 | const succeeded = sum((repo) => repo.deployments.succeeded); | |
| 598 | const failed = sum((repo) => repo.deployments.failed); | |
| 599 | if (succeeded.count + failed.count > 0) { | |
| 600 | const text = succeeded.count > 0 ? `${plural(succeeded.count, "deploy")} went out${failed.count > 0 ? `, ${failed.count.toLocaleString("en-US")} failed` : ""}` : `${plural(failed.count, "deploy")} failed`; | |
| 601 | lines.push({ key: "deploys", text, to: placeFor(slug, repos, [...succeeded.repoIds, ...failed.repoIds], "deployments") }); | |
| 602 | credit(succeeded.actors); | |
| 603 | credit(failed.actors); | |
| 604 | } | |
| 605 | const releases = sum((repo) => tallyOf(repo.releases.map((release) => release.actor))); | |
| 606 | if (releases.count > 0) { | |
| 607 | lines.push({ key: "releases", text: `published ${plural(releases.count, "release")}`, to: placeFor(slug, repos, releases.repoIds, "releases") }); | |
| 608 | credit(releases.actors); | |
| 609 | } | |
| 610 | const packages = sum((repo) => tallyOf(repo.packages.map((pkg) => pkg.actor))); | |
| 611 | if (packages.count > 0) { | |
| 612 | lines.push({ key: "packages", text: `published ${plural(packages.count, "package version")}`, to: `/${slug}/-/packages` }); | |
| 613 | credit(packages.actors); | |
| 614 | } | |
| 615 | ||
| 616 | // Artifacts: made, and whose content changed. | |
| 617 | if (activity.folios) { | |
| 618 | const made = part(activity.folios.created, kind, agentKeys); | |
| 619 | const edited = new Set<string>(); | |
| 620 | const editors = new Map<string, number>(); | |
| 621 | let allDocs = true; | |
| 622 | for (const folio of activity.folios.edited) { | |
| 623 | const mine = folio.authors.filter((author) => actorKind(author, agentKeys) === kind); | |
| 624 | if (mine.length === 0) continue; | |
| 625 | edited.add(folio.folio_id); | |
| 626 | if (folio.kind !== "doc") allDocs = false; | |
| 627 | for (const author of mine) editors.set(author, (editors.get(author) ?? 0) + 1); | |
| 628 | } | |
| 629 | const what = (n: number) => plural(n, allDocs ? "doc" : "artifact"); | |
| 630 | if (made.count + edited.size > 0) { | |
| 631 | const parts: string[] = []; | |
| 632 | if (made.count > 0) parts.push(`created ${what(made.count)}`); | |
| 633 | if (edited.size > 0) parts.push(`edited ${what(edited.size)}`); | |
| 634 | lines.push({ key: "docs", text: parts.join(", "), to: `/${slug}/-/artifacts` }); | |
| 635 | credit(made.actors); | |
| 636 | credit(editors); | |
| 637 | } | |
| 638 | } | |
| 639 | } | |
| 640 | ||
| 641 | // Chat: what each kind said. | |
| 642 | if (chat) { | |
| 643 | const said = part( | |
| 644 | chat.authors.map((author) => ({ actor: author.key, count: author.messages })), | |
| 645 | kind, | |
| 646 | agentKeys, | |
| 647 | ); | |
| 648 | if (said.count > 0) { | |
| 649 | const text = kind === "person" ? `sent ${plural(said.count, "message")} in ${plural(chat.channels, "conversation")}` : `replied ${plural(said.count, "time")} in chat`; | |
| 650 | lines.push({ key: "chat", text, to: `/${slug}/-/chat` }); | |
| 651 | credit(said.actors); | |
| 652 | } | |
| 653 | } | |
| 654 | ||
| 655 | const actors: DigestActor[] = [...did] | |
| 656 | .map(([key, count]) => ({ key, kind, name: names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"), count })) | |
| 657 | .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); | |
| 658 | return { kind, lines, actors }; | |
| 659 | } | |
| 660 | ||
| 661 | /** A tally with one of each actor, for lists that name an actor per item. */ | |
| 662 | function tallyOf(actors: string[]): ActorCount[] { | |
| 663 | const counts = new Map<string, number>(); | |
| 664 | for (const actor of actors) counts.set(actor, (counts.get(actor) ?? 0) + 1); | |
| 665 | return [...counts].map(([actor, count]) => ({ actor, count })); | |
| 666 | } | |
| 667 | ||
| 668 | /** Both columns, what the sentence needs, and which sources were silent. */ | |
| 669 | export function digestOf(input: DigestInput): Digest { | |
| 670 | const people = digestColumn("person", input); | |
| 671 | const agents = digestColumn("agent", input); | |
| 672 | const missing: string[] = []; | |
| 673 | if (input.code && !input.activity) missing.push("Code"); | |
| 674 | if (!input.chat) missing.push("Chat"); | |
| 675 | if (!input.work) missing.push("Agents"); | |
| 676 | let commits = 0; | |
| 677 | let merged = 0; | |
| 678 | for (const repo of input.activity?.repos ?? []) { | |
| 679 | for (const by of repo.pushes.by) if (actorKind(by.actor, input.agentKeys) === "person") commits += by.commits; | |
| 680 | merged += part(repo.pulls.merged, "person", input.agentKeys).count; | |
| 681 | } | |
| 682 | const said = part( | |
| 683 | (input.chat?.authors ?? []).map((author) => ({ actor: author.key, count: author.messages })), | |
| 684 | "person", | |
| 685 | input.agentKeys, | |
| 686 | ).count; | |
| 687 | const counts = input.work?.counts; | |
| 688 | return { | |
| 689 | people, | |
| 690 | agents, | |
| 691 | summary: { | |
| 692 | people: people.actors.length, | |
| 693 | commits, | |
| 694 | merged, | |
| 695 | finished: counts ? counts.accepted + counts.fixed + counts.finished : 0, | |
| 696 | dropped: counts?.dropped ?? 0, | |
| 697 | said, | |
| 698 | anything: people.lines.length > 0 || agents.lines.length > 0, | |
| 699 | peopleRead: input.activity != null || input.chat != null, | |
| 700 | }, | |
| 701 | missing, | |
| 702 | partial: (input.activity != null && !input.activity.complete) || (input.work?.partial ?? false), | |
| 703 | }; | |
| 704 | } | |
| 705 | ||
| 706 | // --- What landed in the span ----------------------------------------------------- | |
| 707 | ||
| 708 | /** | |
| 709 | * A change that landed: a pull request merged; commits pushed straight to | |
| 710 | * a default branch; a production build that went live; a release or a | |
| 711 | * package version published. By a person or an agent. | |
| 712 | */ | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 713 | export type LandedChange = { |
| 714 | key: string; | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 715 | kind: "merge" | "push" | "deploy" | "release" | "package"; |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 716 | title: string; |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 717 | /** One line under it: where, and by whom. */ |
| 718 | detail: string; | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 719 | to: string; |
| 720 | at: number; | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 721 | /** An agent's doing, or a person's. */ |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 722 | agent: boolean; |
| 723 | }; | |
| 724 | ||
| 725 | /** Every pull request that merged in the span, newest first. */ | |
| 726 | export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] { | |
| 727 | return pulls | |
| 728 | .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span)) | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 729 | .map((pull) => { |
| 730 | const agent = isAgentPull(pull); | |
| 731 | const mergedBy = pull.mergedBy && pull.mergedBy !== pull.author.username ? `, merged by ${pull.mergedBy}` : ""; | |
| 732 | return { | |
| 733 | key: `landed:${pullKey(pull.repo, pull.number)}`, | |
| 734 | kind: "merge" as const, | |
| 735 | title: pull.title, | |
| 736 | detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`, | |
| 737 | to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`, | |
| 738 | at: Date.parse(pull.mergedAt!), | |
| 739 | agent, | |
| 740 | }; | |
| 741 | }) | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 742 | .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); |
| 743 | } | |
| 744 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 745 | /** The names behind member keys, for a line: `ana and bo`, `@margo`, `a mirror` for nobody. */ |
| 746 | function namesOf(keys: string[], names: Record<string, string>, agentKeys?: ReadonlySet<string>): string { | |
| 747 | const shown = keys.map((key) => { | |
| 748 | if (!key) return "a mirror"; | |
| 749 | const name = names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"); | |
| 750 | return actorKind(key, agentKeys) === "agent" ? `@${name}` : name; | |
| 751 | }); | |
| 752 | return listOf([...new Set(shown)]); | |
| 753 | } | |
| 754 | ||
| 755 | /** | |
| 756 | * Commits pushed straight to a default branch, one row per project with | |
| 757 | * any: what landed without a pull request. Releases and package versions | |
| 758 | * published, one row each. Pushes within the span only: the digest was | |
| 759 | * asked for the span. | |
| 760 | */ | |
| 761 | export function landedFromActivity(activity: ActivityDigest | null, repos: Record<string, RepoRef>, names: Record<string, string>, slug: string, agentKeys?: ReadonlySet<string>): LandedChange[] { | |
| 762 | const rows: LandedChange[] = []; | |
| 763 | for (const repo of activity?.repos ?? []) { | |
| 764 | const path = repos[repo.repo_id]; | |
| 765 | if (!path) continue; | |
| 766 | const direct = repo.pushes.default_branch; | |
| 767 | if (direct.count > 0 && direct.last_at) { | |
| 768 | const by = direct.by.map((entry) => entry.actor); | |
| 769 | rows.push({ | |
| 770 | key: `push:${repo.repo_id}`, | |
| 771 | kind: "push", | |
| 772 | title: `${plural(direct.commits, "commit")} pushed to ${path.defaultBranch}`, | |
| 773 | detail: `${path.name} · ${plural(direct.count, "push", "pushes")} by ${namesOf(by, names, agentKeys)}`, | |
| 774 | to: `/${path.namespace}/${path.name}/activity`, | |
| 775 | at: Date.parse(direct.last_at), | |
| 776 | agent: by.length > 0 && by.every((key) => key && actorKind(key, agentKeys) === "agent"), | |
| 777 | }); | |
| 778 | } | |
| 779 | for (const release of repo.releases) { | |
| 780 | rows.push({ | |
| 781 | key: `release:${repo.repo_id}:${release.tag}`, | |
| 782 | kind: "release", | |
| 783 | title: release.name ? `${release.tag} · ${release.name}` : release.tag, | |
| 784 | detail: `${path.name} · released by ${namesOf([release.actor], names, agentKeys)}`, | |
| 785 | to: `/${path.namespace}/${path.name}/releases/tag/${encodeURIComponent(release.tag)}`, | |
| 786 | at: Date.parse(release.at), | |
| 787 | agent: actorKind(release.actor, agentKeys) === "agent", | |
| 788 | }); | |
| 789 | } | |
| 790 | for (const pkg of repo.packages) { | |
| 791 | rows.push({ | |
| 792 | key: `package:${repo.repo_id}:${pkg.ecosystem}:${pkg.name}:${pkg.version}`, | |
| 793 | kind: "package", | |
| 794 | title: `${pkg.name} ${pkg.version}`, | |
| 795 | detail: `${pkg.ecosystem} package · published by ${namesOf([pkg.actor], names, agentKeys)}`, | |
| 796 | to: `/${slug}/-/packages`, | |
| 797 | at: Date.parse(pkg.at), | |
| 798 | agent: actorKind(pkg.actor, agentKeys) === "agent", | |
| 799 | }); | |
| 800 | } | |
| 801 | } | |
| 802 | return rows; | |
| 803 | } | |
| 804 | ||
| 805 | /** Production builds that went live in the span, as landed changes; previews stay in Deploys. */ | |
| 806 | export function landedDeploys(deploys: DeployRow[]): LandedChange[] { | |
| 807 | return deploys | |
| 808 | .filter((row) => row.kind === "production" && row.outcome === "live") | |
| 809 | .map((row) => ({ | |
| 810 | key: `landed:${row.key}`, | |
| 811 | kind: "deploy" as const, | |
| 812 | title: `Production of ${row.project} went live`, | |
| Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface. | 813 | detail: `${row.commit} · by ${row.by ?? "someone"}`, |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 814 | to: row.to, |
| 815 | at: row.at, | |
| Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface. | 816 | agent: row.agent, |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 817 | })); |
| 818 | } | |
| 819 | ||
| 820 | /** Everything that landed, newest first. */ | |
| 821 | export function landed(parts: LandedChange[][]): LandedChange[] { | |
| 822 | return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 823 | } | |
| 824 | ||
| Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface. | 825 | /** A deployment's maker as a member key: an id becomes `user:<id>`, a key stays as it is. */ |
| 826 | export function deployActorKey(createdBy: string | null | undefined): string | null { | |
| 827 | const by = (createdBy ?? "").trim(); | |
| 828 | if (!by) return null; | |
| 829 | return /^(user|agent):/.test(by) ? by : `user:${by}`; | |
| 830 | } | |
| 831 | ||
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 832 | /** A build that finished in the span. */ |
| 833 | export type DeployRow = { | |
| 834 | key: string; | |
| 835 | project: string; | |
| 836 | kind: Deployment["kind"]; | |
| 837 | /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */ | |
| 838 | outcome: "live" | "failed"; | |
| 839 | commit: string; | |
| 840 | branch: string | null; | |
| 841 | at: number; | |
| 842 | to: string; | |
| Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface. | 843 | /** Who made it, as a name (`@handle` for an agent); null when the deployment does not say. */ |
| 844 | by: string | null; | |
| 845 | /** Whether an agent made it. */ | |
| 846 | agent: boolean; | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 847 | }; |
| 848 | ||
| 849 | /** | |
| 850 | * Builds that finished in the span, newest first: production builds each, | |
| 851 | * and previews too. A build that went live and was replaced since still | |
| 852 | * went live. Skipped builds are left out. | |
| 853 | */ | |
| Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface. | 854 | export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">, names: Record<string, string> = {}, agentKeys?: ReadonlySet<string>): DeployRow[] { |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 855 | const rows: DeployRow[] = []; |
| 856 | for (const project of projects) { | |
| 857 | for (const deploy of project.deployments) { | |
| Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface. | 858 | // Who made it: the deployment names them by id, so it reads as a name, or as someone when identity doesn't know it. |
| 859 | const key = deployActorKey(deploy.createdBy); | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 860 | const ended = deploy.finishedAt ?? null; |
| 861 | if (!within(ended, span)) continue; | |
| 862 | const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null; | |
| 863 | if (!outcome) continue; | |
| 864 | rows.push({ | |
| 865 | key: `deploy:${project.slug}:${deploy.id}`, | |
| 866 | project: project.slug, | |
| 867 | kind: deploy.kind, | |
| 868 | outcome, | |
| 869 | commit: deploy.commit.slice(0, 7), | |
| 870 | branch: deploy.branch, | |
| 871 | at: Date.parse(ended!), | |
| 872 | to: `/${workspace}/${project.slug}/deployments/${deploy.id}`, | |
| Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface. | 873 | by: key ? namesOf([key], names, agentKeys) : null, |
| 874 | agent: !!key && actorKind(key, agentKeys) === "agent", | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 875 | }); |
| 876 | } | |
| 877 | } | |
| 878 | return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 879 | } | |
| 880 | ||
| 881 | /** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */ | |
| 882 | export type DecisionRow = { | |
| 883 | key: string; | |
| 884 | text: string; | |
| 885 | by: string | null; | |
| 886 | at: number; | |
| 887 | to: string; | |
| 888 | kind: "memory" | "request"; | |
| 889 | }; | |
| 890 | ||
| 891 | export function decisionsIn( | |
| 892 | input: { memories: Memory[] | null; requests: InstallRequest[] | null }, | |
| 893 | slug: string, | |
| 894 | span: Pick<Span, "from" | "now">, | |
| 895 | ): DecisionRow[] { | |
| 896 | const rows: DecisionRow[] = []; | |
| 897 | for (const memory of input.memories ?? []) { | |
| 898 | if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue; | |
| 899 | rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" }); | |
| 900 | } | |
| 901 | for (const request of input.requests ?? []) { | |
| 902 | if (request.status === "open" || !within(request.resolved_at, span)) continue; | |
| 903 | rows.push({ | |
| 904 | key: `request:${request.id}`, | |
| 905 | text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`, | |
| 906 | by: request.resolved_by, | |
| 907 | at: Date.parse(request.resolved_at!), | |
| 908 | to: `/${slug}/-/marketplace/requests`, | |
| 909 | kind: "request", | |
| 910 | }); | |
| 911 | } | |
| 912 | return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 913 | } | |
| 914 | ||
| 915 | // --- Running now ---------------------------------------------------------------- | |
| 916 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 917 | /** Something going right now: a session, an agent's change, a workflow run, a build. */ |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 918 | export type RunningRow = { |
| 919 | key: string; | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 920 | kind: "session" | "change" | "workflow" | "deploy"; |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 921 | title: string; |
| 922 | /** One line: who and where. */ | |
| 923 | detail: string; | |
| 924 | /** "Working", "Queued", "Building". */ | |
| 925 | status: string; | |
| 926 | /** Epoch ms: when it started. */ | |
| 927 | at: number; | |
| 928 | to: string; | |
| 929 | }; | |
| 930 | ||
| 931 | const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" }; | |
| 932 | ||
| 933 | /** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */ | |
| 934 | export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] { | |
| 935 | return sessions | |
| 936 | .filter((session) => !session.parent_id && SESSION_STATUS[session.status]) | |
| 937 | .map((session) => ({ | |
| 938 | key: `session:${session.id}`, | |
| 939 | kind: "session" as const, | |
| 940 | title: session.visible ? session.title : "A session in a conversation you're not in", | |
| 941 | detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`, | |
| 942 | status: SESSION_STATUS[session.status]!, | |
| 943 | at: Date.parse(session.created_at), | |
| 944 | to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`, | |
| 945 | })); | |
| 946 | } | |
| 947 | ||
| 948 | /** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */ | |
| 949 | export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string }; | |
| 950 | ||
| 951 | export function runningChanges(changes: AgentChange[]): RunningRow[] { | |
| 952 | return changes.map((change) => ({ | |
| 953 | key: `change:${change.key}`, | |
| 954 | kind: "change" as const, | |
| 955 | title: change.title, | |
| 956 | detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`, | |
| 957 | status: change.chip, | |
| 958 | at: change.at, | |
| 959 | to: change.to, | |
| 960 | })); | |
| 961 | } | |
| 962 | ||
| 963 | /** Builds queued or going now, one per project (its newest). */ | |
| 964 | export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] { | |
| 965 | return overview.flatMap((project) => { | |
| 966 | const latest = project.latest; | |
| 967 | if (!latest || (latest.status !== "queued" && latest.status !== "building")) return []; | |
| 968 | return [ | |
| 969 | { | |
| 970 | key: `deploy:${project.slug}:${latest.id}`, | |
| 971 | kind: "deploy" as const, | |
| 972 | title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`, | |
| 973 | detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`, | |
| 974 | status: latest.status === "queued" ? "Queued" : "Building", | |
| 975 | at: Date.parse(latest.createdAt), | |
| 976 | to: `/${slug}/${project.slug}/deployments/${latest.id}`, | |
| 977 | }, | |
| 978 | ]; | |
| 979 | }); | |
| 980 | } | |
| 981 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 982 | const RUN_STATUS: Partial<Record<WorkflowRun["status"], string>> = { |
| 983 | pending: "Queued", | |
| 984 | queued: "Queued", | |
| 985 | action_required: "Needs approval", | |
| 986 | in_progress: "Running", | |
| 987 | waiting: "Waiting", | |
| 988 | }; | |
| 989 | ||
| 990 | /** Workflow runs not finished, in the projects read: queued, waiting, running, or waiting for approval. */ | |
| 991 | export function runningWorkflows(projects: { repo: RepoPath; runs: WorkflowRun[] }[]): RunningRow[] { | |
| 992 | return projects.flatMap(({ repo, runs }) => | |
| 993 | runs.flatMap((run) => { | |
| 994 | const status = RUN_STATUS[run.status]; | |
| 995 | if (!status) return []; | |
| 996 | return [ | |
| 997 | { | |
| 998 | key: `workflow:${repo.namespace}/${repo.name}:${run.id}`.toLowerCase(), | |
| 999 | kind: "workflow" as const, | |
| 1000 | title: run.title ? `${run.name}: ${run.title}` : run.name, | |
| 1001 | detail: `${repo.namespace}/${repo.name} ${run.ref.replace(/^refs\/heads\//, "")}${run.actor ? ` · by ${run.actor}` : ""}`, | |
| 1002 | status, | |
| 1003 | at: Date.parse(run.startedAt ?? run.createdAt), | |
| 1004 | to: `/${repo.namespace}/${repo.name}/actions/runs/${run.id}`, | |
| 1005 | }, | |
| 1006 | ]; | |
| 1007 | }), | |
| 1008 | ); | |
| 1009 | } | |
| 1010 | ||
| 1011 | /** Everything running, longest-going first within each kind: sessions, then changes, then workflow runs, then builds. */ | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1012 | export function running(parts: RunningRow[][]): RunningRow[] { |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1013 | const order = { session: 0, change: 1, workflow: 2, deploy: 3 } as const; |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1014 | return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key)); |
| 1015 | } | |
| 1016 | ||
| 1017 | // --- Needs you ------------------------------------------------------------------ | |
| 1018 | ||
| 1019 | /** | |
| 1020 | * What a row is. Tiers, most pressing first: | |
| 1021 | * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`); | |
| 1022 | * 1. agent work that has stopped and can't go on without you; | |
| 1023 | * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something); | |
| 1024 | * 3. something stuck that may need a look; | |
| 1025 | * 4. an unread notification that is a warning or a failure; | |
| 1026 | * 5. a mention or a direct message. | |
| 1027 | */ | |
| 1028 | export type AttentionKind = | |
| 1029 | | "limit" | |
| 1030 | | "deploy" | |
| 1031 | | "session_cap" | |
| 1032 | | "agent_budget" | |
| 1033 | | "pull_blocked" | |
| 1034 | | "ready" | |
| 1035 | | "review" | |
| 1036 | | "invitation" | |
| 1037 | | "install_request" | |
| 1038 | | "agent_waiting" | |
| 1039 | | "stuck" | |
| 1040 | | "runner" | |
| 1041 | | "notification" | |
| 1042 | | "chat"; | |
| 1043 | ||
| 1044 | export const TIER: Record<AttentionKind, number> = { | |
| 1045 | limit: 0, | |
| 1046 | deploy: 0, | |
| 1047 | session_cap: 1, | |
| 1048 | agent_budget: 1, | |
| 1049 | pull_blocked: 1, | |
| 1050 | ready: 2, | |
| 1051 | review: 2, | |
| 1052 | invitation: 2, | |
| 1053 | install_request: 2, | |
| 1054 | agent_waiting: 2, | |
| 1055 | stuck: 3, | |
| 1056 | runner: 3, | |
| 1057 | notification: 4, | |
| 1058 | chat: 5, | |
| 1059 | }; | |
| 1060 | ||
| 1061 | export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null }; | |
| 1062 | ||
| 1063 | /** One row of Needs you. */ | |
| 1064 | export type AttentionRow = { | |
| 1065 | key: string; | |
| 1066 | kind: AttentionKind; | |
| 1067 | title: string; | |
| 1068 | /** One line: what it is and where. */ | |
| 1069 | detail: string; | |
| 1070 | /** What is at stake, in a few words. */ | |
| 1071 | stake: Stake | null; | |
| 1072 | /** The action, right there. */ | |
| 1073 | action: { label: string; to: string }; | |
| 1074 | /** Who is on it: the agent, or the person who asked. */ | |
| 1075 | owner: { name: string; agent: boolean } | null; | |
| 1076 | /** Epoch ms: when it started waiting. */ | |
| 1077 | at: number; | |
| 1078 | /** Where it came from, for a member without Code access never `code`. */ | |
| 1079 | from: "code" | "agents" | "notifications" | "chat" | "marketplace"; | |
| 1080 | }; | |
| 1081 | ||
| 1082 | /** | |
| 1083 | * Most pressing first: by tier, then what has waited longest, then by key | |
| 1084 | * so the order never depends on the order rows were read. One of each key, | |
| 1085 | * and one of each place: a notification about a pull request already on | |
| 1086 | * the list is left out. | |
| 1087 | */ | |
| 1088 | export function rankAttention(rows: AttentionRow[]): AttentionRow[] { | |
| 1089 | const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key)); | |
| 1090 | const keys = new Set<string>(); | |
| 1091 | const places = new Set<string>(); | |
| 1092 | return sorted.filter((row) => { | |
| 1093 | const place = row.action.to.split(/[?#]/)[0]; | |
| 1094 | if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false; | |
| 1095 | keys.add(row.key); | |
| 1096 | places.add(place); | |
| 1097 | return true; | |
| 1098 | }); | |
| 1099 | } | |
| 1100 | ||
| 1101 | /** Start here: the first row by `rankAttention`, with why it was picked. */ | |
| 1102 | export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null { | |
| 1103 | const row = ranked[0]; | |
| 1104 | if (!row) return null; | |
| 1105 | const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length; | |
| 1106 | const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : ""; | |
| 1107 | return { row, why: `${WHY[row.kind]}${tail}` }; | |
| 1108 | } | |
| 1109 | ||
| 1110 | const WHY: Record<AttentionKind, string> = { | |
| 1111 | limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.", | |
| 1112 | install_request: "A member asked for it, and only an owner can add it or turn it down.", | |
| 1113 | deploy: "Production is serving an older build until this is fixed, so it comes before anything else.", | |
| 1114 | session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.", | |
| 1115 | agent_budget: "An agent used up its budget and takes no new work until it is raised.", | |
| 1116 | pull_blocked: "An agent stopped on this change and won't go on until a person decides.", | |
| 1117 | ready: "The work is done and only waits for you to land it.", | |
| 1118 | review: "You were asked by name, so it waits for your verdict.", | |
| 1119 | invitation: "It is addressed to you; no one else can answer it.", | |
| 1120 | agent_waiting: "An agent is waiting on you to go on.", | |
| 1121 | stuck: "A running agent has stopped reporting and may be stuck.", | |
| 1122 | runner: "A job is waiting for a runner that is not there.", | |
| 1123 | notification: "It is the most pressing unread notification.", | |
| 1124 | chat: "Nothing else is waiting, and this is the oldest message for you.", | |
| 1125 | }; | |
| 1126 | ||
| 1127 | // --- Rows from each source ---------------------------------------------------- | |
| 1128 | ||
| 1129 | /** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */ | |
| 1130 | export type CodeNeed = { | |
| 1131 | key: string; | |
| 1132 | reason: string; | |
| 1133 | repo: RepoPath | null; | |
| 1134 | ref: string | null; | |
| 1135 | title: string; | |
| 1136 | ask: string; | |
| 1137 | by: { name: string; agent: boolean } | null; | |
| 1138 | for: string | null; | |
| 1139 | at: number; | |
| 1140 | to: string; | |
| 1141 | facts: { label: string; value: string; tone: string | null }[]; | |
| 1142 | }; | |
| 1143 | ||
| 1144 | const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null; | |
| 1145 | ||
| 1146 | const REASON_STAKE: Record<string, Stake> = { | |
| 1147 | blocking: { text: "Blocking", tone: "danger" }, | |
| 1148 | checks_failing: { text: "Checks failing", tone: "danger" }, | |
| 1149 | outside_guardrails: { text: "Over a guardrail", tone: "warn" }, | |
| 1150 | low_confidence: { text: "Low confidence", tone: "warn" }, | |
| 1151 | needs_review: { text: "Needs a verdict", tone: "warn" }, | |
| 1152 | stalled: { text: "Stalled", tone: "warn" }, | |
| 1153 | }; | |
| 1154 | ||
| 1155 | /** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */ | |
| 1156 | export function codeRow(need: CodeNeed): AttentionRow { | |
| 1157 | const prefix = need.key.split(":")[0]; | |
| 1158 | const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null; | |
| 1159 | const detail = where ? `${where} · ${need.ask}` : need.ask; | |
| 1160 | const owner = need.by; | |
| 1161 | const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const }; | |
| 1162 | switch (prefix) { | |
| 1163 | case "deploy": | |
| 1164 | return { | |
| 1165 | ...base, | |
| 1166 | kind: "deploy", | |
| 1167 | stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" }, | |
| 1168 | action: { label: "See the build", to: need.to }, | |
| 1169 | }; | |
| 1170 | case "invitation": | |
| 1171 | return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } }; | |
| 1172 | case "review": { | |
| 1173 | const lines = fact(need, "Lines"); | |
| 1174 | return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } }; | |
| 1175 | } | |
| 1176 | case "runner": | |
| 1177 | return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } }; | |
| 1178 | case "run": { | |
| 1179 | const cost = fact(need, "Cost so far"); | |
| 1180 | return { | |
| 1181 | ...base, | |
| 1182 | kind: "stuck", | |
| 1183 | stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" }, | |
| 1184 | action: { label: "Look", to: need.to }, | |
| 1185 | }; | |
| 1186 | } | |
| 1187 | default: { | |
| 1188 | if (need.reason === "ready_to_merge") { | |
| 1189 | const files = fact(need, "Files changed"); | |
| 1190 | return { | |
| 1191 | ...base, | |
| 1192 | kind: "ready", | |
| 1193 | stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" }, | |
| 1194 | action: { label: "Merge", to: need.to }, | |
| 1195 | }; | |
| 1196 | } | |
| 1197 | return { | |
| 1198 | ...base, | |
| 1199 | kind: "pull_blocked", | |
| 1200 | stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" }, | |
| 1201 | action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to }, | |
| 1202 | }; | |
| 1203 | } | |
| 1204 | } | |
| 1205 | } | |
| 1206 | ||
| 1207 | /** A session stopped at its spend cap that the viewer may approve more for. */ | |
| 1208 | export function sessionCapRow(session: AgentSession, slug: string): AttentionRow { | |
| 1209 | const cap = session.cap_micros; | |
| 1210 | return { | |
| 1211 | key: `session:${session.id}`, | |
| 1212 | kind: "session_cap", | |
| 1213 | title: session.visible ? session.title : `A session of @${session.agent_handle}`, | |
| 1214 | detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`, | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1215 | stake: { text: cap != null ? `${money(session.charged_micros)} of ${money(cap)} cap` : `${money(session.charged_micros)} spent`, tone: "warn" }, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1216 | action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` }, |
| 1217 | owner: { name: session.agent_handle, agent: true }, | |
| 1218 | at: Date.parse(session.updated_at), | |
| 1219 | from: "agents", | |
| 1220 | }; | |
| 1221 | } | |
| 1222 | ||
| 1223 | export type AgentLike = { | |
| 1224 | id: string; | |
| 1225 | handle: string; | |
| 1226 | display_name: string; | |
| 1227 | status: string; | |
| 1228 | spent_month_micros: number; | |
| 1229 | budget: { monthly_micros: number | null }; | |
| 1230 | updated_at: string; | |
| 1231 | }; | |
| 1232 | ||
| 1233 | /** An agent out of budget (for those who may raise it), or waiting on someone. */ | |
| 1234 | export function agentRow(agent: AgentLike, slug: string): AttentionRow | null { | |
| 1235 | const base = { | |
| 1236 | key: `agent:${agent.id}`, | |
| 1237 | owner: { name: agent.handle, agent: true }, | |
| 1238 | at: Date.parse(agent.updated_at), | |
| 1239 | from: "agents" as const, | |
| 1240 | }; | |
| 1241 | if (agent.status === "out_of_budget") { | |
| 1242 | const budget = agent.budget.monthly_micros; | |
| 1243 | return { | |
| 1244 | ...base, | |
| 1245 | kind: "agent_budget", | |
| 1246 | title: `${agent.display_name} is out of budget`, | |
| 1247 | detail: `@${agent.handle} takes no new work this month until its budget is raised.`, | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1248 | stake: { text: budget != null ? `${money(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" }, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1249 | action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` }, |
| 1250 | }; | |
| 1251 | } | |
| 1252 | if (agent.status === "waiting") { | |
| 1253 | return { | |
| 1254 | ...base, | |
| 1255 | kind: "agent_waiting", | |
| 1256 | title: `${agent.display_name} is waiting`, | |
| 1257 | detail: `@${agent.handle} is waiting before it goes on.`, | |
| 1258 | stake: null, | |
| 1259 | action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` }, | |
| 1260 | }; | |
| 1261 | } | |
| 1262 | return null; | |
| 1263 | } | |
| 1264 | ||
| 1265 | const NOTIFICATION_STAKE: Record<string, string> = { | |
| 1266 | agent: "Agent waiting", | |
| 1267 | review_requested: "Review requested", | |
| 1268 | assign: "Assigned to you", | |
| 1269 | mention: "Mentioned", | |
| 1270 | team_mention: "Team mentioned", | |
| 1271 | ci_activity: "CI activity", | |
| 1272 | security_alert: "Security alert", | |
| 1273 | state_change: "State changed", | |
| 1274 | author: "Your work", | |
| 1275 | comment: "Comment", | |
| 1276 | manual: "Subscribed", | |
| 1277 | subscribed: "Watching", | |
| 1278 | }; | |
| 1279 | ||
| 1280 | /** | |
| 1281 | * An unread notification that is a warning (someone or something is | |
| 1282 | * waiting) or a failure, in this workspace. A member without Code access | |
| 1283 | * gets none about a repository. | |
| 1284 | */ | |
| 1285 | export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null { | |
| 1286 | if (item.readAt != null || item.doneAt != null) return null; | |
| 1287 | if (item.severity !== "warning" && item.severity !== "error") return null; | |
| 1288 | const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug; | |
| 1289 | if (!inWorkspace) return null; | |
| 1290 | if (!code && item.repo) return null; | |
| 1291 | return { | |
| 1292 | key: `notification:${item.id}`, | |
| 1293 | kind: "notification", | |
| 1294 | title: item.body || item.title, | |
| 1295 | detail: item.body ? item.title : (item.repo ?? "A notification"), | |
| 1296 | stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" }, | |
| 1297 | action: { label: "Open", to: item.url }, | |
| 1298 | owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null, | |
| 1299 | at: Date.parse(item.updatedAt), | |
| 1300 | from: "notifications", | |
| 1301 | }; | |
| 1302 | } | |
| 1303 | ||
| 1304 | export type ChatEntryLike = { | |
| 1305 | channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null }; | |
| 1306 | title: string; | |
| 1307 | muted: boolean; | |
| 1308 | unread: number; | |
| 1309 | mentions: number; | |
| 1310 | }; | |
| 1311 | ||
| 1312 | /** A conversation with a mention of you, or a direct message you haven't read. */ | |
| 1313 | export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null { | |
| 1314 | if (entry.muted) return null; | |
| 1315 | if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null; | |
| 1316 | const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`; | |
| 1317 | const dm = entry.channel.kind === "dm"; | |
| 1318 | return { | |
| 1319 | key: `chat:${entry.channel.id}`, | |
| 1320 | kind: "chat", | |
| 1321 | title: dm ? entry.title : `#${entry.title}`, | |
| 1322 | detail: dm ? "A direct message you haven't read." : "You were mentioned.", | |
| 1323 | stake: { | |
| 1324 | text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`, | |
| 1325 | tone: entry.mentions > 0 ? "accent" : null, | |
| 1326 | }, | |
| 1327 | action: { label: "Reply", to }, | |
| 1328 | owner: null, | |
| 1329 | at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0, | |
| 1330 | from: "chat", | |
| 1331 | }; | |
| 1332 | } | |
| 1333 | ||
| 1334 | /** A member's open request to add something from the Marketplace, for an owner. */ | |
| 1335 | export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null { | |
| 1336 | if (request.status !== "open") return null; | |
| 1337 | return { | |
| 1338 | key: `request:${request.id}`, | |
| 1339 | kind: "install_request", | |
| 1340 | title: `${request.requested_by} asked to add ${request.name}`, | |
| 1341 | detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.", | |
| 1342 | stake: { text: "Only owners can add it", tone: null }, | |
| 1343 | action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` }, | |
| 1344 | owner: { name: request.requested_by, agent: false }, | |
| 1345 | at: Date.parse(request.requested_at), | |
| 1346 | from: "marketplace", | |
| 1347 | }; | |
| 1348 | } | |
| 1349 | ||
| 1350 | /** | |
| 1351 | * The workspace's agents at 100% of its monthly agent budget, for those who | |
| 1352 | * may raise it. `since` is when it was reached as far as is known: the | |
| 1353 | * newest session that stopped at its cap, else the start of the month. | |
| 1354 | */ | |
| 1355 | export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null { | |
| 1356 | if (input.alert == null || input.alert < 100) return null; | |
| 1357 | return { | |
| 1358 | key: "limit:agents", | |
| 1359 | kind: "limit", | |
| 1360 | title: "Agents used up the workspace's budget", | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1361 | detail: `${money(input.spentMicros)} spent this month. Agents start nothing new until the budget is raised or the month turns.`, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1362 | stake: { text: "No new agent work", tone: "danger" }, |
| 1363 | action: { label: "Raise the budget", to: `/${slug}/-/spend` }, | |
| 1364 | owner: null, | |
| 1365 | at: input.since, | |
| 1366 | from: "agents", | |
| 1367 | }; | |
| 1368 | } | |
| 1369 | ||
| 1370 | /** What Needs you and Start here show, and which sources did not answer. */ | |
| 1371 | export type Attention = { | |
| 1372 | rows: AttentionRow[]; | |
| 1373 | start: { row: AttentionRow; why: string } | null; | |
| 1374 | /** Sources that did not answer, by name, for the card to say so. */ | |
| 1375 | missing: string[]; | |
| 1376 | }; | |
| 1377 | ||
| 1378 | export function attention(input: { | |
| 1379 | slug: string; | |
| 1380 | code: boolean; | |
| 1381 | codeNeeds: CodeNeed[] | null; | |
| 1382 | capped: AgentSession[] | null; | |
| 1383 | agents: AgentLike[] | null; | |
| 1384 | canManage: boolean; | |
| 1385 | /** The workspace's agent budget, for those who may manage agents; null for anyone else. */ | |
| 1386 | limit?: { alert: number | null; spentMicros: number; since: number } | null; | |
| 1387 | /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */ | |
| 1388 | requests?: InstallRequest[] | null; | |
| 1389 | notifications: InboxItem[] | null; | |
| 1390 | chat: ChatEntryLike[] | null; | |
| 1391 | }): Attention { | |
| 1392 | const rows: AttentionRow[] = []; | |
| 1393 | const missing: string[] = []; | |
| 1394 | if (input.code) { | |
| 1395 | if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow)); | |
| 1396 | else missing.push("Code"); | |
| 1397 | } | |
| 1398 | if (input.limit && input.canManage) { | |
| 1399 | const row = limitRow(input.limit, input.slug); | |
| 1400 | if (row) rows.push(row); | |
| 1401 | } | |
| 1402 | for (const request of input.requests ?? []) { | |
| 1403 | const row = installRequestRow(request, input.slug); | |
| 1404 | if (row) rows.push(row); | |
| 1405 | } | |
| 1406 | if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug))); | |
| 1407 | if (input.agents) { | |
| 1408 | const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle)); | |
| 1409 | for (const agent of input.agents) { | |
| 1410 | if (agent.status === "out_of_budget" && !input.canManage) continue; | |
| 1411 | if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue; | |
| 1412 | const row = agentRow(agent, input.slug); | |
| 1413 | if (row) rows.push(row); | |
| 1414 | } | |
| 1415 | } | |
| 1416 | if (input.capped == null || input.agents == null) missing.push("Agents"); | |
| 1417 | if (input.notifications) { | |
| 1418 | for (const item of input.notifications) { | |
| 1419 | const row = notificationRow(item, input.slug, input.code); | |
| 1420 | if (row) rows.push(row); | |
| 1421 | } | |
| 1422 | } else missing.push("Notifications"); | |
| 1423 | if (input.chat) { | |
| 1424 | for (const entry of input.chat) { | |
| 1425 | const row = chatRow(entry, input.slug); | |
| 1426 | if (row) rows.push(row); | |
| 1427 | } | |
| 1428 | } else missing.push("Chat"); | |
| 1429 | const ranked = rankAttention(rows); | |
| 1430 | return { rows: ranked, start: startHere(ranked), missing }; | |
| 1431 | } | |
| 1432 | ||
| 1433 | // --- Spend in the span ---------------------------------------------------------- | |
| 1434 | ||
| 1435 | /** Money in, which the statement lists but which is not spend. */ | |
| 1436 | const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]); | |
| 1437 | ||
| 1438 | export type Spend = { | |
| 1439 | /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */ | |
| 1440 | from: string; | |
| 1441 | to: string; | |
| 1442 | /** Usage at price over those days, in micros. */ | |
| 1443 | totalMicros: number; | |
| 1444 | /** A line per kind of charge, as the statement names it, most first. */ | |
| 1445 | lines: { kind: string; micros: number; count: number }[]; | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1446 | /** Usage at price this month so far, the top bar's and Usage's figure; null when it could not be read. */ |
| 1447 | monthMicros: number | null; | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1448 | }; |
| 1449 | ||
| 1450 | /** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */ | |
| 1451 | export function spanMonths(span: Pick<Span, "from" | "now">): string[] { | |
| 1452 | const months: string[] = []; | |
| 1453 | const first = new Date(span.from); | |
| 1454 | let year = first.getUTCFullYear(); | |
| 1455 | let month = first.getUTCMonth(); | |
| 1456 | const last = new Date(span.now).toISOString().slice(0, 7); | |
| 1457 | for (let i = 0; i < 3; i++) { | |
| 1458 | const key = `${year}-${String(month + 1).padStart(2, "0")}`; | |
| 1459 | months.push(key); | |
| 1460 | if (key >= last) break; | |
| 1461 | month++; | |
| 1462 | if (month === 12) { | |
| 1463 | month = 0; | |
| 1464 | year++; | |
| 1465 | } | |
| 1466 | } | |
| 1467 | return months; | |
| 1468 | } | |
| 1469 | ||
| 1470 | /** | |
| 1471 | * The span's spend from the statements grouped by day: each usage line at | |
| 1472 | * its price (what was charged, plus what the plan, a trial, a pool or a | |
| 1473 | * discount paid of it), leaving out money in. The statement keeps whole | |
| 1474 | * UTC days, so the span is counted from the start of the UTC day it began. | |
| 1475 | * `statements` is the months of `spanMonths`, the current one last. | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1476 | * `monthMicros` is this month so far as billing's usage report counts it, |
| 1477 | * the month's usage not yet closed included: the figure the top bar's | |
| 1478 | * pill, Spend and Usage show, so Home shows no other. | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1479 | */ |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1480 | export function spendIn(statements: Pick<Statement, "groups" | "totals">[], span: Pick<Span, "from" | "now">, monthMicros: number | null): Spend { |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1481 | const from = new Date(span.from).toISOString().slice(0, 10); |
| 1482 | const to = new Date(span.now).toISOString().slice(0, 10); | |
| 1483 | const byKind = new Map<string, { micros: number; count: number }>(); | |
| 1484 | for (const statement of statements) { | |
| 1485 | for (const group of statement.groups) { | |
| 1486 | if (group.key < from || group.key > to) continue; | |
| 1487 | for (const line of group.lines) { | |
| 1488 | if (MONEY_IN.has(line.kind)) continue; | |
| 1489 | const micros = line.priceMicros ?? line.chargedMicros; | |
| 1490 | const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 }; | |
| 1491 | byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count }); | |
| 1492 | } | |
| 1493 | } | |
| 1494 | } | |
| 1495 | const lines = [...byKind] | |
| 1496 | .map(([kind, value]) => ({ kind, ...value })) | |
| 1497 | .filter((line) => line.micros > 0) | |
| 1498 | .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind)); | |
| 1499 | return { | |
| 1500 | from, | |
| 1501 | to, | |
| 1502 | totalMicros: lines.reduce((sum, line) => sum + line.micros, 0), | |
| 1503 | lines, | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1504 | monthMicros, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1505 | }; |
| 1506 | } | |
| 1507 | ||
| 1508 | // --- The sentence ----------------------------------------------------------- | |
| 1509 | ||
| 1510 | /** | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1511 | * The sentence under the heading, honest about what happened: "Since |
| 1512 | * Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents | |
| 1513 | * finished 7 tasks, and 12 changes landed." What people did comes first | |
| 1514 | * (commits and merges; else what they said), then the agents' tasks that | |
| 1515 | * settled well (accepted, fixed, sessions finished), then everything that | |
| 1516 | * landed. When nothing at all was counted: "Quiet since Tuesday evening: | |
| 1517 | * nothing pushed, merged, deployed or said." When what people did could | |
| 1518 | * not be read, it says so. | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1519 | */ |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1520 | export function spanSentence(span: Pick<Span, "words">, digest: Pick<Digest, "summary"> | null, landed: number | null): string { |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1521 | const lead = span.words; |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1522 | if (!digest) return `${lead}, what happened couldn't be read.`; |
| 1523 | const { people: count, commits, merged, finished, dropped, said, anything, peopleRead } = digest.summary; | |
| 1524 | const landedCount = landed ?? 0; | |
| 1525 | if (!anything && landedCount === 0) { | |
| 1526 | if (!peopleRead) return `${lead}, what people did couldn't be read, and no agent task settled.`; | |
| 1527 | return `Quiet ${lead.charAt(0).toLowerCase()}${lead.slice(1)}: nothing pushed, merged, deployed or said.`; | |
| 1528 | } | |
| 1529 | const parts: string[] = []; | |
| 1530 | if (count > 0) { | |
| 1531 | if (commits > 0 || merged > 0) { | |
| 1532 | const did: string[] = []; | |
| 1533 | if (commits > 0) did.push(`pushed ${plural(commits, "commit")}`); | |
| 1534 | if (merged > 0) did.push(`merged ${plural(merged, "change")}`); | |
| 1535 | parts.push(`${people(count)} ${did.join(" and ")}`); | |
| 1536 | } else if (said > 0) parts.push(`${people(count)} sent ${plural(said, "message")}`); | |
| 1537 | else parts.push(`${people(count)} ${count === 1 ? "was" : "were"} active`); | |
| 1538 | } | |
| 1539 | if (finished > 0) parts.push(`agents finished ${plural(finished, "task")}`); | |
| 1540 | else if (dropped > 0) parts.push(`agents finished none of ${plural(dropped, "task")}`); | |
| 1541 | if (landedCount > 0) parts.push(`${plural(landedCount, "change")} landed`); | |
| 1542 | const tail = peopleRead ? "" : "; what people did couldn't be read"; | |
| 1543 | if (parts.length === 0) return `${lead}, work went on, but nothing landed and no agent task finished${tail}.`; | |
| 1544 | const joined = parts.length > 2 ? `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}` : parts.join(" and "); | |
| 1545 | return `${lead}, ${joined}${tail}.`; | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1546 | } |
| 1547 | ||
| 1548 | /** "8 things need you.", or that nothing does. */ | |
| 1549 | export function waitingSentence(total: number): string { | |
| 1550 | if (total === 0) return "Nothing needs you right now."; | |
| 1551 | return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`; | |
| 1552 | } | |
| 1553 | ||
| 1554 | /** How long something has waited: "45 min", "17 h", "2 d". */ | |
| 1555 | export function waited(at: number, now: number): string { | |
| 1556 | const minutes = Math.max(0, Math.floor((now - at) / MINUTE)); | |
| 1557 | if (minutes < 60) return `${minutes} min`; | |
| 1558 | if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`; | |
| 1559 | return `${Math.floor(minutes / (24 * 60))} d`; | |
| 1560 | } | |
| 1561 | ||
| 1562 | /** "3:40 PM" when it is the same day as `now`, else "Oct 7". */ | |
| 1563 | export function whenShort(at: number, now: number, timeZone: string | null): string { | |
| 1564 | if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" }); | |
| 1565 | return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone); | |
| 1566 | } |