Merge pull request #158 from flagon-io/home-signal
Home leads with the daily signal; the workspace overview threads agent spend to the work
87 files+4360−7239 files not counted0/87 viewed
| 7 | 7 | "name": "g1t", | |
| 8 | 8 | "source": "./plugins/claude-code", | |
| 9 | 9 | "description": "Work on g1t work items from Claude Code: records your sessions on g1t, loads a work item's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 10 | − | "version": "0.3.0", | |
| 10 | + | "version": "0.3.1", | |
| 11 | 11 | "category": "development" | |
| 12 | 12 | } | |
| 13 | 13 | ] |
| 1 | + | # Generated, vendored or built: never formatted. | |
| 2 | + | node_modules/ | |
| 3 | + | target/ | |
| 4 | + | dist/ | |
| 5 | + | build/ | |
| 6 | + | .wrangler/ | |
| 7 | + | .react-router/ | |
| 8 | + | *.lock | |
| 9 | + | package-lock.json |
| 1 | + | { | |
| 2 | + | "printWidth": 160 | |
| 3 | + | } |
| 76 | 76 | - `cargo test` in the crate or service you changed. | |
| 77 | 77 | - `npx tsc -b --force` in `apps/web` (the incremental build misses changes | |
| 78 | 78 | in `packages/contracts`). | |
| 79 | + | - `npm run format -- <files>` on the TypeScript files you added | |
| 80 | + | (`.prettierrc.json`). Older files are not formatted yet, so format a file | |
| 81 | + | you only edited in a change of its own, not in the middle of a feature. | |
| 79 | 82 | - Look at what you changed in a browser. Screenshots catch what type | |
| 80 | 83 | checks do not. | |
| 81 | 84 |
| 1396 | 1396 | ||
| 1397 | 1397 | [[package]] | |
| 1398 | 1398 | name = "g1t" | |
| 1399 | − | version = "0.4.0" | |
| 1399 | + | version = "0.4.1" | |
| 1400 | 1400 | dependencies = [ | |
| 1401 | 1401 | "anstyle-query", | |
| 1402 | 1402 | "base64 0.22.1", | |
| ⋯ | |||
| 1466 | 1466 | "futures-util", | |
| 1467 | 1467 | "g1t-contracts", | |
| 1468 | 1468 | "g1t-kit", | |
| 1469 | + | "g1t-secrets", | |
| 1469 | 1470 | "getrandom 0.2.17", | |
| 1470 | 1471 | "hex", | |
| 1471 | 1472 | "hmac 0.12.1", | |
| 1473 | + | "rusqlite", | |
| 1472 | 1474 | "serde", | |
| 1473 | 1475 | "serde_json", | |
| 1474 | 1476 | "sha2 0.10.9", | |
| 134 | 134 | pub fn description(self) -> &'static str { | |
| 135 | 135 | match self { | |
| 136 | 136 | CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the work item, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `item` and `pull` attach it to a work item of the repository's workspace and a pull request of the repository; a pull request for a work item attaches that work item too. A subagent is a session of its own: give `parent_id` (one of your sessions here), its `agent_type`, the `task` and `prompt` its parent gave it, and as `harness_session_id` the parent's harness id, `/agent/` and the subagent's id; it starts on the parent's work item, pull request and branch unless it says otherwise, sees and is seen by its siblings in get_work and list_sessions, and once ended stays ended when started again. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open work items.", | |
| 137 | − | CoordinationOp::UpdateSession => "Update one of your sessions: attach it to a work item or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. Returns the session; one ended with end_session is refused with `409`.", | |
| 137 | + | CoordinationOp::UpdateSession => "Update one of your sessions: attach it to a work item or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. `usage` reports the session's token usage so far, per model, replacing what was reported before: g1t estimates `cost_usd` from it at Anthropic's list price (none when no model in it has a known price), with `cost_partial` true when some of it was for a model it has no price for. Returns the session; one ended with end_session is refused with `409`.", | |
| 138 | 138 | CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the work item off, or let them expire. Anyone with write access to the repository may also end someone else's session, one left behind by a harness that was killed or never ended it: it ends as `ended by hand by @you`, without their `reason` or `result`.", | |
| 139 | 139 | CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and work items it and they touched; `links`, the work items and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. `updates` contains up to 100 oldest pending coordination events that concern this session (id, kind, data, at): those about the work items and pull requests it is on or tied to, or every one while it is on nothing yet; never its own start, end or claims. Reading never acknowledges them; use record_session_entries with acknowledge IDs after considering them. To check for updates without the transcript, give `after` 4294967295. Anyone who can read the repository may. With `workspace` instead of `repo`, the session is found in whichever repository of the workspace it runs in, and `repo` says which.", | |
| 140 | 140 | CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended; `item` (a work item's number) or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may. With `workspace` instead of `repo`, every repository of the workspace you may read: see list_workspace_sessions.", | |
| ⋯ | |||
| 206 | 206 | ), | |
| 207 | 207 | &["repo", "harness"], | |
| 208 | 208 | ), | |
| 209 | − | CoordinationOp::UpdateSession => (with(fields(json!({ "id": id, "result": result })), subagent.clone()), &["repo", "id"]), | |
| 209 | + | CoordinationOp::UpdateSession => ( | |
| 210 | + | with( | |
| 211 | + | fields(json!({ | |
| 212 | + | "id": id, | |
| 213 | + | "result": result, | |
| 214 | + | "usage": { | |
| 215 | + | "type": "array", | |
| 216 | + | "description": "The session's token usage so far, one entry per model: `model`, `tier` (`long` for a claude-haiku-5-5 prompt over 100,000 tokens), `input_tokens`, `output_tokens`, `cache_write_5m_tokens`, `cache_write_1h_tokens` and `cache_read_tokens`, each a whole number. Cumulative: it replaces the usage given before. Up to 50 models. g1t estimates the session's `cost_usd` from it at Anthropic's list price.", | |
| 217 | + | "items": { | |
| 218 | + | "type": "object", | |
| 219 | + | "properties": { | |
| 220 | + | "model": { "type": "string" }, | |
| 221 | + | "tier": { "type": "string" }, | |
| 222 | + | "input_tokens": { "type": "integer" }, | |
| 223 | + | "output_tokens": { "type": "integer" }, | |
| 224 | + | "cache_write_5m_tokens": { "type": "integer" }, | |
| 225 | + | "cache_write_1h_tokens": { "type": "integer" }, | |
| 226 | + | "cache_read_tokens": { "type": "integer" }, | |
| 227 | + | }, | |
| 228 | + | "required": ["model"], | |
| 229 | + | }, | |
| 230 | + | }, | |
| 231 | + | })), | |
| 232 | + | subagent.clone(), | |
| 233 | + | ), | |
| 234 | + | &["repo", "id"], | |
| 235 | + | ), | |
| 210 | 236 | CoordinationOp::EndSession => ( | |
| 211 | 237 | json!({ | |
| 212 | 238 | "repo": repo, | |
| ⋯ | |||
| 471 | 497 | g1t_kit::call(work, "start_session", &args).await | |
| 472 | 498 | } | |
| 473 | 499 | CoordinationOp::UpdateSession => { | |
| 500 | + | let usage = match &input["usage"] { | |
| 501 | + | Value::Null => None, | |
| 502 | + | given => match g1t_contracts::model_prices::parse_usage(given) { | |
| 503 | + | Ok(usage) => Some(usage), | |
| 504 | + | Err(message) => return invalid(message), | |
| 505 | + | }, | |
| 506 | + | }; | |
| 474 | 507 | let args = UpdateSessionArgs { | |
| 475 | 508 | actor: actor(), | |
| 476 | 509 | repo, | |
| ⋯ | |||
| 485 | 518 | prompt: text(input, "prompt"), | |
| 486 | 519 | model: text(input, "model"), | |
| 487 | 520 | result: text(input, "result"), | |
| 521 | + | usage, | |
| 488 | 522 | }; | |
| 489 | 523 | g1t_kit::call(work, "update_session", &args).await | |
| 490 | 524 | } | |
Binary or large file; its contents are not shown.
| 104 | 104 | | Claude writes a file, makes a branch or a commit, or claims a work item | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--item`) | What it touched: the path from the repository's root, the branch, the commit, the work item. A file is one Claude edited or wrote, or one a shell command changed while it ran (the uncommitted files before and after it), never what was uncommitted already, so sessions sharing a working copy do not claim each other's work. g1t ties the session to the pull requests and work items these lead to, and tells two sessions that touch the same file. | | |
| 105 | 105 | | A `git push` reaches the remote | `g1t api update_session -f id=ID -f head_commit=SHA` and `g1t session touch --commit SHA --branch BRANCH` | The commit you pushed and its branch, so the session's entries line up with the code and it is tied to the branch's pull request. | | |
| 106 | 106 | | Claude starts a subagent | `g1t session start --parent SESSION --agent-type TYPE --task TASK --prompt PROMPT` | A session of its own for the subagent, under this one, with its kind and the task it was given (its prompt's first 4,000 characters). See [Subagents](#subagents). | | |
| 107 | − | | A subagent finishes | `g1t session end --result MESSAGE` | Its final message, up to 8,000 characters: what it reported to Claude. | | |
| 108 | − | | Claude finishes a turn | `g1t session heartbeat` | That the session is still active, which also renews its claims. | | |
| 109 | − | | The session ends | `g1t session end --reason REASON` | That it ended, and why: you exited, cleared it or logged out. | | |
| 107 | + | | A subagent finishes | `g1t session heartbeat --usage-json USAGE`, then `g1t session end --result MESSAGE` | The tokens it used, from its own transcript (see [What a session cost](#what-a-session-cost)), then its final message, up to 8,000 characters: what it reported to Claude. | | |
| 108 | + | | Claude finishes a turn | `g1t session heartbeat --usage-json USAGE` | That the session is still active, which also renews its claims, and the tokens it used so far. | | |
| 109 | + | | The session ends | `g1t session heartbeat --usage-json USAGE`, then `g1t session end --reason REASON` | The tokens it used, then that it ended, and why: you exited, cleared it or logged out. | | |
| 110 | 110 | ||
| 111 | 111 | It never records: | |
| 112 | 112 | ||
| ⋯ | |||
| 115 | 115 | for its subagent's final message and id), nor the turns Claude Code | |
| 116 | 116 | starts itself to hand Claude a background task's output; | |
| 117 | 117 | - Claude's replies or reasoning; | |
| 118 | − | - your files, environment or transcript. | |
| 118 | + | - your files, environment or transcript: of the transcript, only the token | |
| 119 | + | counts are sent. | |
| 119 | 120 | ||
| 120 | 121 | A session that stops sending heartbeats reads as idle after 15 minutes and | |
| 121 | 122 | ended after 30 minutes, so a closed laptop does not leave work looking | |
| ⋯ | |||
| 135 | 136 | works in the same working copy. A command run before the session's id | |
| 136 | 137 | arrives, in its first second, uses the working copy's session. | |
| 137 | 138 | ||
| 139 | + | ## What a session cost | |
| 140 | + | ||
| 141 | + | When Claude finishes a turn, when a subagent finishes and when the session | |
| 142 | + | ends, the plugin counts the tokens the session used so far from its | |
| 143 | + | transcript, the JSONL file Claude Code keeps of it (`transcript_path`, and | |
| 144 | + | for a subagent its own, `agent_transcript_path`), and sends the counts with | |
| 145 | + | the heartbeat. g1t estimates what the session cost from them at | |
| 146 | + | Anthropic's list price and shows it on the session's page; see | |
| 147 | + | [What a session cost](/guides/sessions/#what-a-session-cost). | |
| 148 | + | ||
| 149 | + | - **What is counted**: each model's input tokens, output tokens, cache | |
| 150 | + | reads, and cache writes for 5 minutes and for 1 hour, from the `usage` | |
| 151 | + | of each response Claude Code recorded (`message.usage` of the assistant | |
| 152 | + | lines). Claude Code writes several lines for one response, each with the | |
| 153 | + | same usage, so each response counts once, by `message.id` and | |
| 154 | + | `requestId`. | |
| 155 | + | - **Only the counts leave your machine**: per model, the five numbers. | |
| 156 | + | Nothing Claude or you wrote in the transcript is sent. | |
| 157 | + | - **A subagent counts on its own session.** Its parent's transcript leaves | |
| 158 | + | out what the subagent used, and the subagent reports its own, so nothing | |
| 159 | + | is counted twice. | |
| 160 | + | - The plugin reads the transcript from where it stopped last time, in the | |
| 161 | + | background, so a long session does not slow a hook down. If the | |
| 162 | + | transcript cannot be read, the heartbeat goes without usage. | |
| 163 | + | ||
| 164 | + | The usage is sent to a g1t CLI of 0.4.1 or later; with an older one, | |
| 165 | + | everything else is still recorded. | |
| 166 | + | ||
| 138 | 167 | ## The context Claude starts with | |
| 139 | 168 | ||
| 140 | 169 | When a session starts, the plugin looks for the work item it is working | |
| ⋯ | |||
| 274 | 303 | ||
| 275 | 304 | If `g1t` is not installed, not signed in, or older than 0.4.0 (the oldest | |
| 276 | 305 | that takes every command the hooks run), the hooks record nothing and | |
| 277 | − | Claude Code says so once. The plugin never installs anything itself. | |
| 306 | + | Claude Code says so once. A CLI older than 0.4.1 records everything but | |
| 307 | + | what the session cost. The plugin never installs anything itself. | |
| 278 | 308 | Install the CLI or run `g1t auth login`, and the next session is recorded. | |
| 279 | 309 | ||
| 280 | 310 | To update the CLI, run `g1t upgrade` (0.3.4 and later). For an older CLI, | |
| 1 | 1 | --- | |
| 2 | 2 | title: Home | |
| 3 | − | description: Your workspace's front page. Ask g1t what to do, where you're needed now, what happened since you were last here in plain English, what is running, and the one item to start with. How every number is worked out. | |
| 3 | + | description: Your workspace's front page. The past week in four figures, the daily signal (what your work says today, with the evidence), where you're needed now, what happened since you were last here in plain English, what is running, and the one item to start with. How every number is worked out. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | **Home** is the first page of your workspace, at `/<workspace>/-/home`. | |
| 7 | − | It opens with **What are we building today?** and a box that | |
| 8 | − | [asks g1t](#ask-from-home). Under it, the main column is read top to | |
| 7 | + | Across the top is [the past week](#the-past-week) in four figures, each | |
| 8 | + | against the week before. Under it, the main column is read top to | |
| 9 | 9 | bottom: | |
| 10 | 10 | ||
| 11 | − | 1. **Where you're needed**: everything waiting on you, however long ago | |
| 11 | + | 1. **[The daily signal](#the-daily-signal)**: what the workspace's work | |
| 12 | + | says today. Its headline is the finding that matters most, and the | |
| 13 | + | others follow, each a sentence with its evidence and where to see it. | |
| 14 | + | Under them is a box that [asks g1t](#ask-from-home) about any of it. | |
| 15 | + | 2. **Where you're needed**: everything waiting on you, however long ago | |
| 12 | 16 | it started, with the action beside each item. Its first row is | |
| 13 | 17 | [Start here](#start-here), the one item to do first, and why. | |
| 14 | − | 2. **Since you were last here**: one card for the span. It shows what people | |
| 18 | + | 3. **Since you were last here**: one card for the span. It shows what people | |
| 15 | 19 | did, what changed in plain English when you ask for it, and what landed, | |
| 16 | 20 | each change with whether it is live yet. | |
| 17 | 21 | ||
| ⋯ | |||
| 21 | 25 | - **[Spent](#spent)**: what the span cost, and the month so far. | |
| 22 | 26 | - **[Settled for you](#settled-for-you)**: what finished by itself. | |
| 23 | 27 | ||
| 24 | − | On a phone it is one column: Where you're needed, Running now, the span, | |
| 25 | − | then Spent and Settled for you. | |
| 28 | + | On a phone it is one column: the past week, the daily signal, Where | |
| 29 | + | you're needed, Running now, the span, then Spent and Settled for you. | |
| 26 | 30 | ||
| 27 | 31 | For a workspace's owners, Home also opens | |
| 28 | 32 | [Getting started](/guides/workspaces/#getting-started), the setup guide, | |
| ⋯ | |||
| 30 | 34 | of Home opens it again at any time, as `?setup=1` does. | |
| 31 | 35 | ||
| 32 | 36 | Every number on the page comes from what g1t has recorded. Nothing is | |
| 33 | − | estimated. When a part of g1t does not answer, the section it feeds says | |
| 37 | + | estimated, and nothing compares your workspace or its teams with anyone | |
| 38 | + | else's: each figure is set against your own last weeks. When a part of g1t does not answer, the section it feeds says | |
| 34 | 39 | so, and the rest of the page still shows. Only what you ask g1t is written | |
| 35 | 40 | by a model: [What changed](#what-changed) and the answers in Ask. | |
| 36 | 41 | ||
| 42 | + | ## The past week | |
| 43 | + | ||
| 44 | + | Four figures for the last seven UTC days. Each shows how it moved since the | |
| 45 | + | seven days before (green when that is good news, red when it is not) and a | |
| 46 | + | line over the last ten weeks. Point at the line, or focus it and use the | |
| 47 | + | arrow keys, to read any week. Each figure opens the page behind it. | |
| 48 | + | ||
| 49 | + | | Figure | What it counts | | |
| 50 | + | | --- | --- | | |
| 51 | + | | **Agent-authored merges** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents). | | |
| 52 | + | | **Spend per agent merge** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent pull requests that merged in it: an average, not what any one cost. A dash when usage can't be read or nothing an agent made merged. | | |
| 53 | + | | **Stalled changes** | Pull requests open at the end of each week (now, for this one), opened three days or more before, with nothing in the three days before: no push, comment, review, timeline event, check or session. Drafts count. | | |
| 54 | + | | **Agent rework rate** | Of the agent pull requests that merged in the week, the share a person asked for changes on before they merged. | | |
| 55 | + | ||
| 56 | + | ### Whose work is an agent's | |
| 57 | + | ||
| 58 | + | A pull request is an agent's when g1t's agent opened it, it was opened as | |
| 59 | + | an agent's, or a [session](/guides/sessions/) is tied to it: linked to it, | |
| 60 | + | started for it, or pushing its branch. Its owner is the person who asked | |
| 61 | + | for it, else its author, so an agent's work counts toward the person it | |
| 62 | + | was for. | |
| 63 | + | ||
| 64 | + | ## The daily signal | |
| 65 | + | ||
| 66 | + | First the day, then one sentence for the finding that matters most today, | |
| 67 | + | then the others. Each finding names its evidence in a line of figures, | |
| 68 | + | links to where you can see it, and has **Ask g1t**, which asks | |
| 69 | + | [Ask](/guides/ask/) to look into it. A finding with a trend has its line | |
| 70 | + | beside it. When nothing stands out, the headline says what the week did. | |
| 71 | + | ||
| 72 | + | | Finding | When it shows | | |
| 73 | + | | --- | --- | | |
| 74 | + | | **Work items waiting on one unreviewed change** | An open pull request no one has reviewed is for a work item that other open work items are blocked by. The one holding up the most leads. | | |
| 75 | + | | **Agent drafts waiting for review** | This week's first reviews waited at least four times as long as agents took to open the pull requests (from their session starting), and at least four hours. Without that gap: first reviews took half as long again as the weeks before. | | |
| 76 | + | | **A milestone shipped** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. | | |
| 77 | + | | **Where agent spend is going** | More than a third of the quarter's agent spend by pull request went into one milestone (else one work item, else one project). Spend is g1t's usage on each pull request and its share of its sessions' cost. | | |
| 78 | + | | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. | | |
| 79 | + | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 80 | + | | **A practice catching on** | Two or more people started using a harness (Claude Code, Codex, Cursor and so on) this month, at least two weeks after the first person did. | | |
| 81 | + | | **Who works where** | Someone started merging to a project one person has carried (three quarters of its last 90 days' merges), or merged to a project for the first time this week after work elsewhere. | | |
| 82 | + | ||
| 83 | + | Findings are ordered by how much they hold up: blocked work first, then | |
| 84 | + | waits, then what shipped and where spend went. Up to five show, and the | |
| 85 | + | rest are one click away. What the work says is read again at most every | |
| 86 | + | five minutes for each person, so a figure can be up to five minutes | |
| 87 | + | behind. The same reads, at more length, are on the | |
| 88 | + | [workspace overview](/guides/workspaces/#the-workspace-overview). | |
| 89 | + | ||
| 37 | 90 | ## Ask from Home | |
| 38 | 91 | ||
| 39 | − | The box at the top asks g1t anything about your work. Type a question and | |
| 92 | + | The box under the daily signal asks g1t anything about your work. Type a question and | |
| 40 | 93 | press **Enter** (**Shift+Enter** starts a new line): the top bar's | |
| 41 | 94 | [Ask](/guides/ask/) panel opens and sends it, and the answer streams in | |
| 42 | 95 | there. Under the box are questions to start with: | |
| ⋯ | |||
| 341 | 394 | A member whose membership does not include Code sees Home without anything | |
| 342 | 395 | from Code: | |
| 343 | 396 | ||
| 397 | + | - The past week and the daily signal are not shown. The page opens with | |
| 398 | + | **What are we building today?** and the box that asks g1t. | |
| 344 | 399 | - People shows what was said and the artifacts edited; nothing from | |
| 345 | 400 | repositories. | |
| 346 | 401 | - Where you're needed has no rows from Code, and no notifications about a | |
| ⋯ | |||
| 362 | 417 | deployments or sessions can't be read, Landed shows its rows without | |
| 363 | 418 | their live labels or agent markers. | |
| 364 | 419 | - Settled for you says when your notifications can't be read. | |
| 420 | + | - The daily signal says when the work couldn't be read, and the past week | |
| 421 | + | is left out. When some projects didn't answer, it says its findings | |
| 422 | + | leave them out. | |
| 365 | 423 | - What changed says when Ask can't be reached or isn't set up, or when | |
| 366 | 424 | the question could not be sent. | |
| 367 | 425 | - If your last visit can't be read, the span is the last 24 hours and the | |
| ⋯ | |||
| 409 | 467 | `until`) returns `messages`, `channels` (conversations with at least one) | |
| 410 | 468 | and `authors` (`key`, `messages`; most first), over text messages and | |
| 411 | 469 | thread replies not deleted in the conversations the viewer can read. | |
| 470 | + | ||
| 471 | + | **`workspace_signals`** on the work service takes `workspace`, | |
| 472 | + | `repo_ids` (at most 100), `viewer`, `until` (a UTC day), `weeks` (1 to | |
| 473 | + | 13), `days` (1 to 28) and `since` (a UTC day, at most 400 days before | |
| 474 | + | `until`). For the repositories the viewer can read, it returns: | |
| 475 | + | ||
| 476 | + | - `weeks`: per repository and week of seven days ending with `until`, | |
| 477 | + | `merged`, `agent_merged`, `agent_reworked`, `agent_drafted` and | |
| 478 | + | `agent_draft_seconds`, `reviewed` and `review_seconds`, and `stalled`. | |
| 479 | + | - `days`: per repository and day, `opened`, `merged`, `agent_merged`, | |
| 480 | + | `reworked`, `reviews` and `sessions`. | |
| 481 | + | - `waiting`: open pull requests with no first review, oldest first, at | |
| 482 | + | most 50, each with its work item and the open work items it `blocks`. | |
| 483 | + | - `pulls`: those opened or merged since `since`, at most 3,000 newest | |
| 484 | + | first, each with its owner, work item and milestone; `pulls_capped` | |
| 485 | + | when there were more. | |
| 486 | + | - `authors`: per repository and owner, their merges over its history and | |
| 487 | + | in the last 90 days. | |
| 488 | + | - The `items` and `milestones` those name. | |
| 489 | + | ||
| 490 | + | Rows are per repository, so a caller asking in batches adds them up. | |
| 1 | 1 | --- | |
| 2 | 2 | title: Recording sessions | |
| 3 | − | description: How a harness records its sessions on g1t, with the API, MCP or the CLI. Start, heartbeat, record and end a session, record subagents, work before there is a work item, and have g1t start sessions on your Anthropic account. | |
| 3 | + | description: How a harness records its sessions on g1t, with the API, MCP or the CLI. Start, heartbeat, record and end a session, record subagents, report what a session cost, work before there is a work item, and have g1t start sessions on your Anthropic account. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | A [session](/work/sessions/) is one run of your harness on a repository: | |
| ⋯ | |||
| 125 | 125 | [Claude Code plugin](/guides/claude-code-plugin/#subagents) does all of | |
| 126 | 126 | this for Claude Code's subagents. | |
| 127 | 127 | ||
| 128 | + | ## What a session cost | |
| 129 | + | ||
| 130 | + | A harness can say how many tokens a session used, and g1t estimates what | |
| 131 | + | that cost at Anthropic's list price. The session's page and the | |
| 132 | + | workspace's **Sessions** list show it as "≈ $4.12 at list price". It is | |
| 133 | + | an estimate, not a bill: it is what the tokens cost at Anthropic's | |
| 134 | + | published per-token prices, whatever your plan or discounts. | |
| 135 | + | ||
| 136 | + | 1. **Report** the session's usage so far with `session` `update`, | |
| 137 | + | `g1t session heartbeat --usage-json '…'`, or | |
| 138 | + | [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/) | |
| 139 | + | with `usage`: one entry per model, with its `input_tokens`, | |
| 140 | + | `output_tokens`, `cache_write_5m_tokens`, `cache_write_1h_tokens` and | |
| 141 | + | `cache_read_tokens`. Usage is cumulative: each report replaces the | |
| 142 | + | last, so send the whole session's each time. | |
| 143 | + | ||
| 144 | + | ```json | |
| 145 | + | { | |
| 146 | + | "usage": [ | |
| 147 | + | { | |
| 148 | + | "model": "claude-opus-5-5", | |
| 149 | + | "input_tokens": 1840, | |
| 150 | + | "output_tokens": 52310, | |
| 151 | + | "cache_write_5m_tokens": 210400, | |
| 152 | + | "cache_write_1h_tokens": 0, | |
| 153 | + | "cache_read_tokens": 4102300 | |
| 154 | + | } | |
| 155 | + | ] | |
| 156 | + | } | |
| 157 | + | ``` | |
| 158 | + | ||
| 159 | + | 2. **g1t prices it** as it records it: each model's tokens at its list | |
| 160 | + | price, cache writes at 1.25 times the input price for 5 minutes and | |
| 161 | + | twice it for 1 hour. A model id is matched without its date | |
| 162 | + | (`-20251001`), a `[1m]` suffix or a provider prefix | |
| 163 | + | (`us.anthropic.`). For claude-haiku-5-5, a prompt over 100,000 tokens | |
| 164 | + | is priced at its long-prompt rate: give those tokens in an entry of | |
| 165 | + | their own with `tier` `long`. | |
| 166 | + | 3. **The session shows `cost_usd`**, the sum over the models g1t has a | |
| 167 | + | price for. A model it has no price for, another provider's included, | |
| 168 | + | is left out, and `cost_partial` says some usage was priced as unknown | |
| 169 | + | ("some usage priced as unknown" on the page). With no model priced, | |
| 170 | + | `cost_usd` is null. | |
| 171 | + | ||
| 172 | + | A subagent reports its own usage, to its own session: its parent's | |
| 173 | + | transcript leaves out what the subagent used, so nothing is counted twice. | |
| 174 | + | Only the workspace's members see what a session cost, as with the rest of | |
| 175 | + | what is inside it ([who sees what](/work/sessions/#who-sees-what)). Up to | |
| 176 | + | 50 models a session, each count a whole number of tokens. | |
| 177 | + | ||
| 178 | + | The [Claude Code plugin](/guides/claude-code-plugin/#what-a-session-cost) | |
| 179 | + | counts it from the session's transcript and sends only the counts. The | |
| 180 | + | Codex plugin does not report usage yet: it records sessions through g1t's | |
| 181 | + | MCP tools, with no hooks to read Codex's session files from. | |
| 182 | + | ||
| 128 | 183 | ## Start a session on your Anthropic account | |
| 129 | 184 | ||
| 130 | 185 | g1t runs no model. Work on a work item happens in your own harness, with | |
| 1004 | 1004 | and what covered it, for your own books. Invoices from Stripe remain the | |
| 1005 | 1005 | record for what was charged to your card. | |
| 1006 | 1006 | ||
| 1007 | + | ## Provider cost reports | |
| 1008 | + | ||
| 1009 | + | g1t counts what its own runs cost. Your team's other agents (CI scripts, | |
| 1010 | + | other tools, people's own sessions) are on your AI provider's bill too, | |
| 1011 | + | and the provider's own cost report is the record of all of it. Connect | |
| 1012 | + | the provider and g1t reads that report, day by day, at | |
| 1013 | + | `g1t.sh/<workspace>/-/provider-costs` (**Provider costs**, under Money in | |
| 1014 | + | the workspace's settings). | |
| 1015 | + | ||
| 1016 | + | **What it reads.** The provider's cost report for the whole organization | |
| 1017 | + | the key belongs to, in US dollars, one line per day and line item: | |
| 1018 | + | ||
| 1019 | + | | Provider | Report | Line items | | |
| 1020 | + | | --- | --- | --- | | |
| 1021 | + | | Anthropic | The Usage and Cost Admin API's cost report (`/v1/organizations/cost_report`) | Each description, such as *Claude Opus 5.5 Usage - Input Tokens*, for each of the organization's Console workspaces | | |
| 1022 | + | | OpenAI | The Costs API (`/v1/organization/costs`) | Each line item, for each of the organization's projects | | |
| 1023 | + | ||
| 1024 | + | **Which key.** An admin key, which only an organization's admins can make: | |
| 1025 | + | ||
| 1026 | + | - **Anthropic**: an Admin API key (it starts `sk-ant-admin`), made in the | |
| 1027 | + | Claude Console under **Settings**, **Admin keys**. An ordinary API key | |
| 1028 | + | or a workspace's key can't read the cost report. Individual accounts have | |
| 1029 | + | no Admin API: set up an organization first. | |
| 1030 | + | - **OpenAI**: an admin key (it starts `sk-admin-`), made in the OpenAI | |
| 1031 | + | platform under **Organization settings**, **Admin keys**. A project or | |
| 1032 | + | user API key can't read the Costs API. | |
| 1033 | + | ||
| 1034 | + | Only an owner of the workspace can connect a provider, one key per | |
| 1035 | + | provider. g1t checks the key with one small report request and says why | |
| 1036 | + | when it doesn't work (the wrong kind of key, no permission), then reads | |
| 1037 | + | the last 90 days. | |
| 1038 | + | ||
| 1039 | + | **What g1t keeps.** The key, sealed when it is saved; it is never shown | |
| 1040 | + | again, never sent back to anyone and never written to a log. The page shows | |
| 1041 | + | its last four characters, who connected it and when, when it was last read | |
| 1042 | + | and what went wrong. And the costs: day, provider, line item, the provider's | |
| 1043 | + | own workspace or project, and the amount. g1t uses the key for nothing but | |
| 1044 | + | reading the report: never to run a model. | |
| 1045 | + | ||
| 1046 | + | **How often.** Once a day, around 04:17 UTC. Each read asks for the last | |
| 1047 | + | seven days again, since providers revise recent days (and further back, | |
| 1048 | + | up to 90 days, after reads have failed), and replaces what it had for | |
| 1049 | + | those days. A provider that fails keeps its error on the page until a | |
| 1050 | + | read works; the others are read as usual. The day in progress is | |
| 1051 | + | incomplete at the provider too. | |
| 1052 | + | ||
| 1053 | + | **Not added to g1t's own figures.** Runs g1t made on the workspace's own | |
| 1054 | + | key, such as Ask and sessions on [your AI providers](/guides/ai/), are on | |
| 1055 | + | the provider's report as well. The provider's figure already includes | |
| 1056 | + | them, so it is shown beside g1t's, never added to it. | |
| 1057 | + | ||
| 1058 | + | **Disconnecting.** **Disconnect** on the page deletes the key and every cost | |
| 1059 | + | read with it. Replacing the key does the same and reads the last 90 days | |
| 1060 | + | again, since a new key may belong to another organization. To stop the key | |
| 1061 | + | working anywhere, also delete it at the provider. | |
| 1062 | + | ||
| 1063 | + | Members can see which providers are connected and what they reported; | |
| 1064 | + | only owners connect, replace and disconnect them. Services read the figures | |
| 1065 | + | with the billing service's `provider_costs` RPC. | |
| 1066 | + | ||
| 1007 | 1067 | ## For services: entitlements, reserve, settle | |
| 1008 | 1068 | ||
| 1009 | 1069 | Every g1t service that starts something that costs money asks billing |
| 671 | 671 | says so and shows a dash in place of numbers it cannot vouch for. **Explore | |
| 672 | 672 | costs** opens [Usage](/guides/usage-and-billing/#the-usage-page) with the same period and project. | |
| 673 | 673 | ||
| 674 | + | ### The workspace overview | |
| 675 | + | ||
| 676 | + | The workspace's **Overview**, `g1t.sh/<workspace>/-/workspace`, opens with | |
| 677 | + | what its work says, for members who can see Code. It is worked out the | |
| 678 | + | same way as [Home's daily signal](/guides/home/#the-daily-signal), and | |
| 679 | + | each person sees only the projects they can read. | |
| 680 | + | ||
| 681 | + | 1. **[The past week](/guides/home/#the-past-week)**: the same four | |
| 682 | + | figures as Home. | |
| 683 | + | 2. **Spend, threaded to the work**: the quarter's work in four columns: | |
| 684 | + | each milestone, its work items, each work item's change set in each | |
| 685 | + | project, and that change set's pull requests. Each box is sized by | |
| 686 | + | what agents cost on its pull requests (g1t's metered usage, and each | |
| 687 | + | pull request's share of its sessions' cost estimated at list price), | |
| 688 | + | and shaded by how many were an agent's (**Agent use**, less to more). Choose a box to | |
| 689 | + | open what is under it. Work with no milestone or work item has its own | |
| 690 | + | box. When usage can't be read, boxes are sized by pull requests. | |
| 691 | + | Above it, with a [provider's cost report](/guides/usage-and-billing/) | |
| 692 | + | connected, is what Anthropic or OpenAI reported for the quarter and how | |
| 693 | + | much of it is threaded to the work. Their figure holds everything the | |
| 694 | + | organization ran, so it is never added to g1t's. | |
| 695 | + | 3. **What's moving and what's waiting**: each team's last two weeks, day | |
| 696 | + | by day, by the projects the team works on (each project, when the | |
| 697 | + | workspace has no teams). Above the line are pull requests opened and | |
| 698 | + | merged, and agent sessions started; below it, reviews and comments and | |
| 699 | + | requests for changes. Turn each layer on or off above the rows, point | |
| 700 | + | at a day for its numbers, or open **The numbers, as a table**. Each | |
| 701 | + | row is drawn to its own scale. Beside it, **Waiting on a first | |
| 702 | + | review** lists the oldest open pull requests no one has reviewed. | |
| 703 | + | 4. **Quiet changes**: places to look, not verdicts. | |
| 704 | + | - **Knowledge silos**: projects where one person merged three quarters | |
| 705 | + | or more of the last 90 days' changes (six or more), and whoever has | |
| 706 | + | started there this month. | |
| 707 | + | - **New ground**: people who merged to a project for the first time in | |
| 708 | + | the last two weeks, after three or more merges elsewhere. | |
| 709 | + | - **Spreading**: a harness two or more people in a team started using | |
| 710 | + | this month, at least two weeks after the first person did (across | |
| 711 | + | the workspace, when it has no teams). | |
| 712 | + | - **New habits**: people whose first agent-made change merged in the | |
| 713 | + | last two weeks, after three or more of their own. | |
| 714 | + | ||
| 715 | + | Under it are the members and projects, the month's usage against the | |
| 716 | + | plan, and the workspace's policies. | |
| 717 | + | ||
| 674 | 718 | ## The rail, the sidebar and the page | |
| 675 | 719 | ||
| 676 | 720 | Signed in, g1t is always about one workspace: the one you are in. On a | |
| ⋯ | |||
| 820 | 864 | ||
| 821 | 865 | | Group | Page | Who | | | |
| 822 | 866 | | --- | --- | --- | --- | | |
| 823 | − | | | **Overview** | Members | The workspace at a glance: members, plan and spend. `g1t.sh/<workspace>/-/workspace` | | |
| 867 | + | | | **Overview** | Members | What the work says (the past week, spend threaded to the work, what is moving and waiting, quiet changes), then members, plan and spend. `g1t.sh/<workspace>/-/workspace` | | |
| 824 | 868 | | **General** | **General** | Owners | The icon, the display name, a one-line description, the address (the slug), [requiring two-factor authentication](#require-two-factor-authentication), [data residency](#data-residency), and deleting the workspace. `-/settings` | | |
| 825 | 869 | | **Access** | **Members** | Members | Who is in the workspace and their roles, with search and filters; owners [invite people](#add-people), change roles, and see **Outside collaborators**. `-/members` | | |
| 826 | 870 | | | **Invitations** | Owners | Every invitation to join, filtered by where it stands; send one again, revoke it, copy its link. `-/members/invitations` | | |
| 939 | 939 | g1t session heartbeat [OPTIONS] | |
| 940 | 940 | ``` | |
| 941 | 941 | ||
| 942 | + | | Argument or flag | | | |
| 943 | + | | --- | --- | | |
| 944 | + | | `--usage-json <JSON>` | The session's token usage so far, per model, as JSON; - reads it from stdin. | | |
| 945 | + | ||
| 946 | + | ```text | |
| 947 | + | --usage-json reports the session's token usage so far, which g1t prices at Anthropic's list price: | |
| 948 | + | a JSON list with one entry per model, each {model, tier?, input_tokens, output_tokens, | |
| 949 | + | cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens}. It is cumulative, so it replaces | |
| 950 | + | what the session reported before. - reads it from stdin. | |
| 951 | + | ``` | |
| 952 | + | ||
| 942 | 953 | Calls [`update_session`](/reference/api/sessions/update-session/). | |
| 943 | 954 | ||
| 944 | 955 | ### g1t session log |
| 430 | 430 | | Action | What it does | Required | Scope | | |
| 431 | 431 | | --- | --- | --- | --- | | |
| 432 | 432 | | [`start`](/reference/api/sessions/start-session/) | Start a session: `harness` (`claude_code`, `claude_managed_agents`, `codex`, `cursor`, `copilot` or `other`), and `harness_session_id`, which makes starting again return the same session. `item` (a work item of the repository's workspace), `pull`, `branch`, `title` and `head_commit` as you know them. A subagent gives `parent_id`, `agent_type`, `task` and `prompt`, and starts on its parent's work item and branch. | `repo`, `harness` | `pull_requests:write` | | |
| 433 | − | | [`update`](/reference/api/sessions/update-session/) | A heartbeat, and renews the session's claims; with fields, attaches an `item` or `pull`, or sets `branch`, `head_commit`, `title`, or a subagent's `task`, `prompt`, `model` or `result`. | `repo`, `id` | `pull_requests:write` | | |
| 433 | + | | [`update`](/reference/api/sessions/update-session/) | A heartbeat, and renews the session's claims; with fields, attaches an `item` or `pull`, or sets `branch`, `head_commit`, `title`, or a subagent's `task`, `prompt`, `model` or `result`; `usage` reports its token usage so far, per model, from which g1t estimates `cost_usd`. | `repo`, `id` | `pull_requests:write` | | |
| 434 | 434 | | [`record`](/reference/api/sessions/record-session-entries/) | Record up to 200 `entries`, each with `kind` and `text` (`skill` for a skill used, by name), `tool` for tools, and a `key` that makes recording it again a no-op; and `touched`, what it touched (`file`, `commit`, `branch`, `pull`, `item`), which ties it to pull requests and work items and tells sessions on the same files of each other. | `repo`, `id` | `pull_requests:write` | | |
| 435 | 435 | | [`end`](/reference/api/sessions/end-session/) | End it, with an optional `reason` and `result` (what it reported). Its subagents still going end with it. | `repo`, `id` | `pull_requests:write` | | |
| 436 | 436 | | [`get`](/reference/api/sessions/get-session/) | A session and its entries (`after` pages through them), its subagents (`children`), what they touched, the work items and pull requests they worked on (`links`) and the skills used. With [`workspace`](/reference/api/sessions/get-workspace-session/) and `id` instead of `repo`, a session of any of the workspace's repositories, with its `repo`. | `repo` or `workspace`, `id` | `pull_requests:read` | |
| 223 | 223 | | Who | Sees | | |
| 224 | 224 | | --- | --- | | |
| 225 | 225 | | Whoever ran it | All of it: its prompts, tool calls, what it touched and reported, its pending updates. | | |
| 226 | − | | A member of the repository's workspace | What it did: its title, tool calls, messages, notes, skills, what it touched, its subagents' tasks, prompts and reports, its pending updates. Not the prompts someone else typed, nor a tool's output (`tool_result`): those entries show as kept back. A title shows without swearing. | | |
| 227 | − | | Anyone else who may read the repository | That it runs: who runs it, in which harness, since when, its state and the work items and pull requests it is tied to. Nothing it recorded, touched, was asked or reported, nor its title, branch or model. On a pull request's session, only g1t's own notes. | | |
| 226 | + | | A member of the repository's workspace | What it did: its title, tool calls, messages, notes, skills, what it touched, its subagents' tasks, prompts and reports, what it cost ([estimated at list price](/guides/sessions/#what-a-session-cost)), its pending updates. Not the prompts someone else typed, nor a tool's output (`tool_result`): those entries show as kept back. A title shows without swearing. | | |
| 227 | + | | Anyone else who may read the repository | That it runs: who runs it, in which harness, since when, its state and the work items and pull requests it is tied to. Nothing it recorded, touched, was asked or reported, nor its title, branch, model or cost. On a pull request's session, only g1t's own notes. | | |
| 228 | 228 | ||
| 229 | 229 | g1t's own agent's sessions are listed only for members. | |
| 230 | 230 |
Binary or large file; its contents are not shown.
| 1 | + | /** | |
| 2 | + | * The workspace overview's ground truth (lib/ground.ts): the past week, | |
| 3 | + | * the quarter's spend threaded to the work beside what providers reported, | |
| 4 | + | * each team's last two weeks with what waits on a first review, and the | |
| 5 | + | * quiet changes in who works where. | |
| 6 | + | */ | |
| 7 | + | import { ArrowRight, Bot } from "lucide-react"; | |
| 8 | + | import type { ReactNode } from "react"; | |
| 9 | + | import { Link } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { COST_PROVIDERS, type CostProvider, isCostProvider } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | import type { Ground } from "../lib/ground"; | |
| 14 | + | import { dollars } from "../lib/signals"; | |
| 15 | + | import { Boundary, Unavailable } from "./boundary"; | |
| 16 | + | import { Rhythm } from "./rhythm"; | |
| 17 | + | import { PulseSkeleton, PulseStrip } from "./signals"; | |
| 18 | + | import { SpendFlow } from "./spend-flow"; | |
| 19 | + | import { Skeleton } from "./ui/skeleton"; | |
| 20 | + | ||
| 21 | + | export function GroundSkeleton() { | |
| 22 | + | return ( | |
| 23 | + | <div className="space-y-6"> | |
| 24 | + | <PulseSkeleton /> | |
| 25 | + | <Skeleton className="h-112 w-full rounded-2xl" /> | |
| 26 | + | </div> | |
| 27 | + | ); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** A part of the ground truth: a mono eyebrow, a sentence for a heading, and what it shows. */ | |
| 31 | + | function Section({ id, eyebrow, title, hint, children }: { id: string; eyebrow: string; title: string; hint?: ReactNode; children: ReactNode }) { | |
| 32 | + | return ( | |
| 33 | + | <section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-6 rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-7"> | |
| 34 | + | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">{eyebrow}</p> | |
| 35 | + | <h2 id={`${id}-title`} className="mt-2 font-display text-2xl leading-tight tracking-tight text-fg"> | |
| 36 | + | {title} | |
| 37 | + | </h2> | |
| 38 | + | {hint && <p className="mt-1.5 max-w-3xl text-sm text-muted">{hint}</p>} | |
| 39 | + | <div className="mt-6">{children}</div> | |
| 40 | + | </section> | |
| 41 | + | ); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type QuietRow = Ground["silos"][number]; | |
| 45 | + | ||
| 46 | + | function QuietList({ title, hint, rows, empty }: { title: string; hint: string; rows: QuietRow[]; empty: string }) { | |
| 47 | + | return ( | |
| 48 | + | <div className="min-w-0"> | |
| 49 | + | <h3 className="text-sm font-semibold text-fg">{title}</h3> | |
| 50 | + | <p className="mt-0.5 text-xs text-muted">{hint}</p> | |
| 51 | + | {rows.length === 0 ? ( | |
| 52 | + | <p className="mt-3 text-sm text-faint">{empty}</p> | |
| 53 | + | ) : ( | |
| 54 | + | <ul className="mt-3 space-y-3"> | |
| 55 | + | {rows.map((row) => ( | |
| 56 | + | <li key={row.key} className="text-sm"> | |
| 57 | + | {row.to ? ( | |
| 58 | + | <Link to={row.to} className="font-medium text-fg hover:text-accent"> | |
| 59 | + | {row.where} | |
| 60 | + | </Link> | |
| 61 | + | ) : ( | |
| 62 | + | <span className="font-medium text-fg">{row.where}</span> | |
| 63 | + | )} | |
| 64 | + | <p className="text-fg-soft">{row.line}</p> | |
| 65 | + | <p className="font-mono text-[0.6875rem] text-faint">{row.more}</p> | |
| 66 | + | </li> | |
| 67 | + | ))} | |
| 68 | + | </ul> | |
| 69 | + | )} | |
| 70 | + | </div> | |
| 71 | + | ); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | export function GroundTruth({ slug, ground }: { slug: string; ground: Ground }) { | |
| 75 | + | return ( | |
| 76 | + | <div className="space-y-6"> | |
| 77 | + | <Boundary what="The past week"> | |
| 78 | + | <PulseStrip tiles={ground.tiles} /> | |
| 79 | + | </Boundary> | |
| 80 | + | {!ground.complete && <p className="text-xs text-faint">Some projects didn't answer, so what follows leaves them out.</p>} | |
| 81 | + | <Section | |
| 82 | + | id="spend" | |
| 83 | + | eyebrow="Spend, threaded to the work" | |
| 84 | + | title={ | |
| 85 | + | ground.quarterSpend | |
| 86 | + | ? `${dollars(ground.quarterSpend)} of agent spend this quarter, and what it went into` | |
| 87 | + | : "This quarter's work, and how much of it agents did" | |
| 88 | + | } | |
| 89 | + | hint={ | |
| 90 | + | ground.sized === "spend" | |
| 91 | + | ? `Each milestone, its work items, their change set in each project and its pull requests, sized by what agents cost on them${ground.sessionSpend > 0 ? ` (g1t's metered usage, and ${dollars(ground.sessionSpend)} of harness sessions estimated at list price)` : ""} and shaded by how much was an agent's. Choose a box to open what is under it.` | |
| 92 | + | : "Usage isn't known for this quarter, so each box is sized by its pull requests and shaded by how many were an agent's. Choose a box to open what is under it." | |
| 93 | + | } | |
| 94 | + | > | |
| 95 | + | <ProvidersLine slug={slug} providers={ground.providers} threaded={ground.quarterSpend} /> | |
| 96 | + | <Boundary what="Spend, threaded to the work"> | |
| 97 | + | <SpendFlow flow={ground.flow} sized={ground.sized} /> | |
| 98 | + | </Boundary> | |
| 99 | + | {ground.capped && <p className="mt-3 text-xs text-faint">There were more pull requests this quarter than this reads, so the newest are shown.</p>} | |
| 100 | + | </Section> | |
| 101 | + | <Section | |
| 102 | + | id="rhythm" | |
| 103 | + | eyebrow="What's moving and what's waiting" | |
| 104 | + | title={ground.rhythm?.by === "project" ? "Each project's last two weeks" : "Each team's last two weeks"} | |
| 105 | + | hint="Pull requests and agent sessions above the line; reviews, comments and requests for changes below it. Each row is drawn to its own scale: point at a day for its numbers." | |
| 106 | + | > | |
| 107 | + | <div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_18rem]"> | |
| 108 | + | <Boundary what="What's moving"> | |
| 109 | + | {ground.rhythm ? <Rhythm rows={ground.rhythm.rows} by={ground.rhythm.by} /> : <Unavailable what="The teams" />} | |
| 110 | + | </Boundary> | |
| 111 | + | <div className="min-w-0"> | |
| 112 | + | <h3 className="text-sm font-semibold text-fg">Waiting on a first review</h3> | |
| 113 | + | <p className="mt-0.5 text-xs text-muted">Open, and no one has reviewed them yet: the oldest first.</p> | |
| 114 | + | {ground.waiting.length === 0 ? ( | |
| 115 | + | <p className="mt-3 text-sm text-faint">Nothing is waiting for a first review.</p> | |
| 116 | + | ) : ( | |
| 117 | + | <ul className="mt-3 divide-y divide-line"> | |
| 118 | + | {ground.waiting.map((pull) => ( | |
| 119 | + | <li key={`${pull.repoId}#${pull.number}`} className="py-2.5 text-sm"> | |
| 120 | + | <Link to={pull.to} className="flex items-start gap-2 hover:text-accent"> | |
| 121 | + | <span className="shrink-0 pt-px font-mono text-xs text-muted">#{pull.number}</span> | |
| 122 | + | <span className="min-w-0 grow"> | |
| 123 | + | <span className="line-clamp-2 font-medium text-fg">{pull.title}</span> | |
| 124 | + | <span className="font-mono text-[0.6875rem] text-faint"> | |
| 125 | + | {pull.repo.name} · open {pull.age} | |
| 126 | + | {pull.blocks.length > 0 && ` · holds up ${pull.blocks.length}`} | |
| 127 | + | </span> | |
| 128 | + | </span> | |
| 129 | + | {pull.agent && <Bot size={13} className="mt-0.5 shrink-0 text-accent" aria-label="An agent's" />} | |
| 130 | + | </Link> | |
| 131 | + | </li> | |
| 132 | + | ))} | |
| 133 | + | </ul> | |
| 134 | + | )} | |
| 135 | + | <Link to={`/${slug}/-/overview?tab=needs#work`} className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 136 | + | The review queue <ArrowRight size={11} /> | |
| 137 | + | </Link> | |
| 138 | + | </div> | |
| 139 | + | </div> | |
| 140 | + | </Section> | |
| 141 | + | <Section | |
| 142 | + | id="quiet" | |
| 143 | + | eyebrow="Quiet changes" | |
| 144 | + | title="Who works where, and what is spreading" | |
| 145 | + | hint="What no roll-up shows: knowledge resting with one person, people moving into new ground, and new ways of working taking hold. Each is a place to look, not a verdict." | |
| 146 | + | > | |
| 147 | + | <div className="grid gap-8 md:grid-cols-2 xl:grid-cols-4"> | |
| 148 | + | <QuietList | |
| 149 | + | title="Knowledge silos" | |
| 150 | + | hint="Projects where one person has done most of the last 90 days' work" | |
| 151 | + | rows={ground.silos} | |
| 152 | + | empty="No project rests on one person right now." | |
| 153 | + | /> | |
| 154 | + | <QuietList | |
| 155 | + | title="New ground" | |
| 156 | + | hint="People who merged to a project for the first time in the last two weeks" | |
| 157 | + | rows={ground.newGround} | |
| 158 | + | empty="No one has moved into a new project in the last two weeks." | |
| 159 | + | /> | |
| 160 | + | <QuietList | |
| 161 | + | title="Spreading" | |
| 162 | + | hint="A harness more people took up this month, after someone started with it" | |
| 163 | + | rows={ground.spreading} | |
| 164 | + | empty="No harness has caught on in a team this month." | |
| 165 | + | /> | |
| 166 | + | <QuietList | |
| 167 | + | title="New habits" | |
| 168 | + | hint="People whose first agent-made change merged in the last two weeks" | |
| 169 | + | rows={ground.habits} | |
| 170 | + | empty="No one new started merging agents' work in the last two weeks." | |
| 171 | + | /> | |
| 172 | + | </div> | |
| 173 | + | </Section> | |
| 174 | + | </div> | |
| 175 | + | ); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | const providerName = (provider: string) => (isCostProvider(provider) ? COST_PROVIDERS[provider as CostProvider].label : provider); | |
| 179 | + | ||
| 180 | + | /** | |
| 181 | + | * What the connected providers reported for the quarter, beside what g1t | |
| 182 | + | * threads to the work: their bill holds everything the organization ran, | |
| 183 | + | * so it is the larger figure, never added to the other. | |
| 184 | + | */ | |
| 185 | + | function ProvidersLine({ slug, providers, threaded }: { slug: string; providers: Ground["providers"]; threaded: number | null }) { | |
| 186 | + | if (!providers) { | |
| 187 | + | return ( | |
| 188 | + | <p className="-mt-2 mb-5 text-sm text-muted"> | |
| 189 | + | <Link to={`/${slug}/-/provider-costs`} className="text-accent hover:underline"> | |
| 190 | + | Connect a provider's cost report | |
| 191 | + | </Link>{" "} | |
| 192 | + | to see everything your organization spent with Anthropic or OpenAI, not only what g1t can tie to the work. | |
| 193 | + | </p> | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | return ( | |
| 197 | + | <div className="-mt-2 mb-5 flex flex-wrap items-baseline gap-x-5 gap-y-1 rounded-xl border border-line bg-bg px-4 py-3 text-sm"> | |
| 198 | + | <span className="text-fg"> | |
| 199 | + | <span className="font-semibold">{dollars(providers.total)}</span> reported by your providers this quarter | |
| 200 | + | </span> | |
| 201 | + | <span className="font-mono text-xs text-muted"> | |
| 202 | + | {providers.providers.map((row) => `${providerName(row.provider)} ${dollars(row.micros)}`).join(" · ")} | |
| 203 | + | </span> | |
| 204 | + | {threaded != null && threaded > 0 && providers.total > 0 && ( | |
| 205 | + | <span className="text-xs text-muted">{Math.min(100, Math.round((threaded / providers.total) * 100))}% of it is threaded to the work below</span> | |
| 206 | + | )} | |
| 207 | + | {!providers.complete && <span className="text-xs text-faint">Some of the quarter isn't read yet</span>} | |
| 208 | + | <Link to={`/${slug}/-/provider-costs`} className="ml-auto text-xs text-muted hover:text-fg"> | |
| 209 | + | Provider costs | |
| 210 | + | </Link> | |
| 211 | + | </div> | |
| 212 | + | ); | |
| 213 | + | } |
| 33 | 33 | return span.key === "last" && span.note == null ? "since I was last here" : `${span.words.charAt(0).toLowerCase()}${span.words.slice(1)}`; | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | − | /** The top of Home: what are we building today, a box that asks g1t, and a few things to ask first. */ | |
| 37 | − | export function HomeAsk({ span }: { span: AskSpan }) { | |
| 36 | + | /** | |
| 37 | + | * Home's box that asks g1t, with a few things to ask first: under the | |
| 38 | + | * daily signal, `compact`, it says to ask about any of it; on its own, it | |
| 39 | + | * leads the page with what are we building today. | |
| 40 | + | */ | |
| 41 | + | export function HomeAsk({ span, compact = false }: { span: AskSpan; compact?: boolean }) { | |
| 38 | 42 | const [draft, setDraft] = useState(""); | |
| 39 | 43 | const send = () => { | |
| 40 | 44 | if (!draft.trim()) return; | |
| ⋯ | |||
| 52 | 56 | } | |
| 53 | 57 | }; | |
| 54 | 58 | return ( | |
| 55 | − | <section aria-labelledby="home-ask" className="mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8"> | |
| 56 | − | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 57 | − | What are we building today? | |
| 58 | − | </h1> | |
| 59 | + | <section aria-labelledby="home-ask" className={compact ? "flex w-full flex-col gap-3" : "mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8"}> | |
| 60 | + | {compact ? ( | |
| 61 | + | <h2 id="home-ask" className="text-sm font-semibold text-fg"> | |
| 62 | + | Ask about any of this | |
| 63 | + | </h2> | |
| 64 | + | ) : ( | |
| 65 | + | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 66 | + | What are we building today? | |
| 67 | + | </h1> | |
| 68 | + | )} | |
| 59 | 69 | <form onSubmit={submit} className="w-full"> | |
| 60 | 70 | <div className="flex items-end gap-2 rounded-2xl border border-line bg-surface p-2 pl-4 shadow-sm transition-shadow focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25"> | |
| 61 | 71 | <MarkGlyph className="mb-2.5 h-4 shrink-0 max-sm:hidden" /> | |
| ⋯ | |||
| 79 | 89 | </Hint> | |
| 80 | 90 | </div> | |
| 81 | 91 | </form> | |
| 82 | − | <Suggestions className="justify-center"> | |
| 92 | + | <Suggestions className={compact ? "" : "justify-center"}> | |
| 83 | 93 | <Suggestion | |
| 84 | 94 | suggestion={`Summarize what changed ${spanLabel(span)}`} | |
| 85 | 95 | onPick={() => window.dispatchEvent(new CustomEvent(SUMMARIZE_EVENT))} | |
| 1 | + | /** | |
| 2 | + | * What is moving and what is waiting (lib/signals.ts rhythm): each team's | |
| 3 | + | * last two weeks, day by day. Above the line, pull requests opened and | |
| 4 | + | * merged (bars) and agent sessions started (a wash); below it, reviews and | |
| 5 | + | * comments (a wash) and requests for changes (bars). Each row is drawn to | |
| 6 | + | * its own scale, so a small team's week is as legible as a big one's; the | |
| 7 | + | * numbers are in the readout and the table. Layers turn on and off above | |
| 8 | + | * the rows, and pointing at a day says what happened on it. | |
| 9 | + | */ | |
| 10 | + | import { useState } from "react"; | |
| 11 | + | import { Link } from "react-router"; | |
| 12 | + | ||
| 13 | + | import { cn } from "../lib/cn"; | |
| 14 | + | import { type RhythmDay, type RhythmRow, rhythmLine, rhythmSum } from "../lib/signals"; | |
| 15 | + | ||
| 16 | + | type Layer = "prs" | "rework" | "agent" | "reviews"; | |
| 17 | + | ||
| 18 | + | const LAYERS: { key: Layer; label: string; swatch: string }[] = [ | |
| 19 | + | { key: "prs", label: "PR activity", swatch: "bg-fg-soft" }, | |
| 20 | + | { key: "rework", label: "Rework", swatch: "bg-faint" }, | |
| 21 | + | { key: "agent", label: "Agent activity", swatch: "bg-series-1" }, | |
| 22 | + | { key: "reviews", label: "Reviews & comments", swatch: "bg-series-4" }, | |
| 23 | + | ]; | |
| 24 | + | ||
| 25 | + | const WIDTH = 700; | |
| 26 | + | const HEIGHT = 112; | |
| 27 | + | const MID = HEIGHT / 2; | |
| 28 | + | const BAR = 5; | |
| 29 | + | ||
| 30 | + | /** A smooth path through points, as a closed wash from the baseline. */ | |
| 31 | + | function wash(points: [number, number][]): string { | |
| 32 | + | if (points.length === 0) return ""; | |
| 33 | + | let d = `M${points[0]![0]},${MID} L${points[0]![0]},${points[0]![1]}`; | |
| 34 | + | for (let i = 1; i < points.length; i++) { | |
| 35 | + | const [x0, y0] = points[i - 1]!; | |
| 36 | + | const [x1, y1] = points[i]!; | |
| 37 | + | const cx = (x0 + x1) / 2; | |
| 38 | + | d += ` C${cx},${y0} ${cx},${y1} ${x1},${y1}`; | |
| 39 | + | } | |
| 40 | + | return `${d} L${points[points.length - 1]![0]},${MID} Z`; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | function dayWords(day: string): string { | |
| 44 | + | return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | function Readout({ row, day }: { row: RhythmRow; day: RhythmDay | null }) { | |
| 48 | + | const sum = day ?? rhythmSum(row.days.slice(Math.floor(row.days.length / 2))); | |
| 49 | + | return ( | |
| 50 | + | <div className="min-w-48 rounded-xl border border-line bg-surface px-3.5 py-3 text-xs shadow-lg"> | |
| 51 | + | <p className="font-mono text-[0.625rem] tracking-[0.14em] text-muted uppercase"> | |
| 52 | + | {row.label} · {day ? dayWords(day.day) : "this week"} | |
| 53 | + | </p> | |
| 54 | + | <ul className="mt-2 space-y-1 text-fg"> | |
| 55 | + | <li className="flex items-center gap-2"> | |
| 56 | + | <span className="size-2 rounded-full bg-fg-soft" /> | |
| 57 | + | {sum.opened} opened · {sum.merged} merged | |
| 58 | + | </li> | |
| 59 | + | <li className="flex items-center gap-2"> | |
| 60 | + | <span className="size-2 rounded-full bg-faint" /> | |
| 61 | + | {sum.reworked} {sum.reworked === 1 ? "request" : "requests"} for changes | |
| 62 | + | </li> | |
| 63 | + | <li className="flex items-center gap-2"> | |
| 64 | + | <span className="size-2 rounded-full bg-series-4" /> | |
| 65 | + | {sum.reviews} reviews & comments | |
| 66 | + | </li> | |
| 67 | + | <li className="flex items-center gap-2"> | |
| 68 | + | <span className="size-2 rounded-full bg-series-1" /> | |
| 69 | + | {sum.sessions} agent {sum.sessions === 1 ? "session" : "sessions"} | |
| 70 | + | </li> | |
| 71 | + | </ul> | |
| 72 | + | </div> | |
| 73 | + | ); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | function Row({ | |
| 77 | + | row, | |
| 78 | + | on, | |
| 79 | + | active, | |
| 80 | + | faded, | |
| 81 | + | onDay, | |
| 82 | + | }: { | |
| 83 | + | row: RhythmRow; | |
| 84 | + | on: Set<Layer>; | |
| 85 | + | active: number | null; | |
| 86 | + | faded: boolean; | |
| 87 | + | onDay: (day: number | null) => void; | |
| 88 | + | }) { | |
| 89 | + | const n = row.days.length; | |
| 90 | + | const slot = WIDTH / n; | |
| 91 | + | const x = (i: number) => slot * i + slot / 2; | |
| 92 | + | const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged : 0, on.has("agent") ? day.sessions : 0))); | |
| 93 | + | const down = Math.max(1, ...row.days.map((day) => Math.max(on.has("rework") ? day.reworked : 0, on.has("reviews") ? day.reviews : 0))); | |
| 94 | + | const above = (value: number) => MID - (value / up) * (MID - 6); | |
| 95 | + | const below = (value: number) => MID + (value / down) * (MID - 6); | |
| 96 | + | const day = active != null ? row.days[active]! : null; | |
| 97 | + | return ( | |
| 98 | + | <li className={cn("grid gap-3 py-5 transition-opacity md:grid-cols-[12rem_minmax(0,1fr)] md:gap-6", faded && "opacity-45")}> | |
| 99 | + | <div className="min-w-0"> | |
| 100 | + | <Link to={row.to} className="text-base font-semibold text-fg hover:text-accent"> | |
| 101 | + | {row.label} | |
| 102 | + | </Link> | |
| 103 | + | {row.note && <p className="text-xs text-muted">{row.note}</p>} | |
| 104 | + | <p className="mt-1.5 text-xs text-fg-soft">{rhythmLine(row)}</p> | |
| 105 | + | </div> | |
| 106 | + | <div className="relative"> | |
| 107 | + | <svg | |
| 108 | + | viewBox={`0 0 ${WIDTH} ${HEIGHT}`} | |
| 109 | + | preserveAspectRatio="none" | |
| 110 | + | className="block h-28 w-full overflow-visible" | |
| 111 | + | role="img" | |
| 112 | + | aria-label={`${row.label}: the last ${n} days`} | |
| 113 | + | onMouseLeave={() => onDay(null)} | |
| 114 | + | > | |
| 115 | + | {active != null && <rect x={slot * active} y={0} width={slot} height={HEIGHT} className="fill-accent/8" />} | |
| 116 | + | <line x1={WIDTH / 2} x2={WIDTH / 2} y1={0} y2={HEIGHT} className="stroke-line" strokeWidth={1} vectorEffect="non-scaling-stroke" /> | |
| 117 | + | {on.has("agent") && ( | |
| 118 | + | <path | |
| 119 | + | d={wash(row.days.map((d, i) => [x(i), above(d.sessions)]))} | |
| 120 | + | className="fill-series-1/15 stroke-series-1" | |
| 121 | + | strokeWidth={1.5} | |
| 122 | + | vectorEffect="non-scaling-stroke" | |
| 123 | + | /> | |
| 124 | + | )} | |
| 125 | + | {on.has("reviews") && ( | |
| 126 | + | <path | |
| 127 | + | d={wash(row.days.map((d, i) => [x(i), below(d.reviews)]))} | |
| 128 | + | className="fill-series-4/18 stroke-series-4" | |
| 129 | + | strokeWidth={1.5} | |
| 130 | + | vectorEffect="non-scaling-stroke" | |
| 131 | + | /> | |
| 132 | + | )} | |
| 133 | + | <line x1={0} x2={WIDTH} y1={MID} y2={MID} className="stroke-line-strong" strokeWidth={1} vectorEffect="non-scaling-stroke" /> | |
| 134 | + | {on.has("prs") && | |
| 135 | + | row.days.map((d, i) => | |
| 136 | + | d.opened + d.merged > 0 ? ( | |
| 137 | + | <rect | |
| 138 | + | key={`p${i}`} | |
| 139 | + | x={x(i) - BAR / 2} | |
| 140 | + | y={above(d.opened + d.merged)} | |
| 141 | + | width={BAR} | |
| 142 | + | height={MID - above(d.opened + d.merged)} | |
| 143 | + | rx={1.5} | |
| 144 | + | className="fill-fg-soft" | |
| 145 | + | /> | |
| 146 | + | ) : null, | |
| 147 | + | )} | |
| 148 | + | {on.has("rework") && | |
| 149 | + | row.days.map((d, i) => | |
| 150 | + | d.reworked > 0 ? ( | |
| 151 | + | <rect key={`r${i}`} x={x(i) - BAR / 2} y={MID} width={BAR} height={below(d.reworked) - MID} rx={1.5} className="fill-faint" /> | |
| 152 | + | ) : null, | |
| 153 | + | )} | |
| 154 | + | {row.days.map((_, i) => ( | |
| 155 | + | <rect key={`h${i}`} x={slot * i} y={0} width={slot} height={HEIGHT} fill="transparent" onMouseEnter={() => onDay(i)} /> | |
| 156 | + | ))} | |
| 157 | + | </svg> | |
| 158 | + | {day && ( | |
| 159 | + | <div | |
| 160 | + | className="pointer-events-none absolute top-0 z-10 -translate-y-1/3" | |
| 161 | + | style={active! < n / 2 ? { left: `${((active! + 1) / n) * 100}%` } : { right: `${((n - active!) / n) * 100}%` }} | |
| 162 | + | > | |
| 163 | + | <Readout row={row} day={day} /> | |
| 164 | + | </div> | |
| 165 | + | )} | |
| 166 | + | </div> | |
| 167 | + | </li> | |
| 168 | + | ); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | export function Rhythm({ rows, by }: { rows: RhythmRow[]; by: "team" | "project" }) { | |
| 172 | + | const [on, setOn] = useState<Set<Layer>>(new Set(["prs", "rework", "agent", "reviews"])); | |
| 173 | + | const [hover, setHover] = useState<{ row: string; day: number } | null>(null); | |
| 174 | + | const toggle = (layer: Layer) => { | |
| 175 | + | const next = new Set(on); | |
| 176 | + | if (next.has(layer)) next.delete(layer); | |
| 177 | + | else next.add(layer); | |
| 178 | + | setOn(next); | |
| 179 | + | }; | |
| 180 | + | if (rows.length === 0) return <p className="text-sm text-muted">Nothing has moved in the last two weeks.</p>; | |
| 181 | + | return ( | |
| 182 | + | <div> | |
| 183 | + | <div className="flex flex-wrap gap-2" role="group" aria-label="What the rows show"> | |
| 184 | + | {LAYERS.map((layer) => ( | |
| 185 | + | <button | |
| 186 | + | key={layer.key} | |
| 187 | + | type="button" | |
| 188 | + | aria-pressed={on.has(layer.key)} | |
| 189 | + | onClick={() => toggle(layer.key)} | |
| 190 | + | className={cn( | |
| 191 | + | "inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs transition-colors", | |
| 192 | + | on.has(layer.key) ? "border-line-strong bg-surface text-fg" : "border-line bg-transparent text-faint", | |
| 193 | + | )} | |
| 194 | + | > | |
| 195 | + | <span className={cn("h-2.5 w-2.5 rounded-sm", on.has(layer.key) ? layer.swatch : "bg-line")} /> | |
| 196 | + | {layer.label} | |
| 197 | + | </button> | |
| 198 | + | ))} | |
| 199 | + | </div> | |
| 200 | + | <div className="mt-5 hidden grid-cols-[12rem_minmax(0,1fr)] gap-6 md:grid"> | |
| 201 | + | <span /> | |
| 202 | + | <div className="grid grid-cols-2 font-mono text-[0.625rem] tracking-[0.14em] text-muted uppercase"> | |
| 203 | + | <span>Last week</span> | |
| 204 | + | <span className="pl-2">This week</span> | |
| 205 | + | </div> | |
| 206 | + | </div> | |
| 207 | + | <ul className="divide-y divide-line"> | |
| 208 | + | {rows.map((row) => ( | |
| 209 | + | <Row | |
| 210 | + | key={row.key} | |
| 211 | + | row={row} | |
| 212 | + | on={on} | |
| 213 | + | active={hover?.row === row.key ? hover.day : null} | |
| 214 | + | faded={hover != null && hover.row !== row.key} | |
| 215 | + | onDay={(day) => setHover(day == null ? null : { row: row.key, day })} | |
| 216 | + | /> | |
| 217 | + | ))} | |
| 218 | + | </ul> | |
| 219 | + | <details className="mt-2 text-xs text-muted"> | |
| 220 | + | <summary className="cursor-pointer hover:text-fg">The numbers, as a table</summary> | |
| 221 | + | <table className="mt-3 w-full text-left tabular-nums"> | |
| 222 | + | <thead className="text-faint"> | |
| 223 | + | <tr> | |
| 224 | + | <th className="py-1 font-normal">{by === "team" ? "Team" : "Project"}</th> | |
| 225 | + | <th className="py-1 font-normal">Opened</th> | |
| 226 | + | <th className="py-1 font-normal">Merged</th> | |
| 227 | + | <th className="py-1 font-normal">Rework</th> | |
| 228 | + | <th className="py-1 font-normal">Reviews & comments</th> | |
| 229 | + | <th className="py-1 font-normal">Agent sessions</th> | |
| 230 | + | </tr> | |
| 231 | + | </thead> | |
| 232 | + | <tbody> | |
| 233 | + | {rows.map((row) => { | |
| 234 | + | const half = Math.floor(row.days.length / 2); | |
| 235 | + | const last = rhythmSum(row.days.slice(0, half)); | |
| 236 | + | const now = rhythmSum(row.days.slice(half)); | |
| 237 | + | const cell = (a: number, b: number) => `${b} (${a} last week)`; | |
| 238 | + | return ( | |
| 239 | + | <tr key={row.key} className="border-t border-line text-fg-soft"> | |
| 240 | + | <td className="py-1.5 text-fg">{row.label}</td> | |
| 241 | + | <td>{cell(last.opened, now.opened)}</td> | |
| 242 | + | <td>{cell(last.merged, now.merged)}</td> | |
| 243 | + | <td>{cell(last.reworked, now.reworked)}</td> | |
| 244 | + | <td>{cell(last.reviews, now.reviews)}</td> | |
| 245 | + | <td>{cell(last.sessions, now.sessions)}</td> | |
| 246 | + | </tr> | |
| 247 | + | ); | |
| 248 | + | })} | |
| 249 | + | </tbody> | |
| 250 | + | </table> | |
| 251 | + | </details> | |
| 252 | + | </div> | |
| 253 | + | ); | |
| 254 | + | } |
| 24 | 24 | import { ItemIcon, PullIcon } from "./work-icons"; | |
| 25 | 25 | import { cn } from "../lib/cn"; | |
| 26 | 26 | import { HARNESS_NAMES, SESSION_STATES } from "../lib/coordination"; | |
| 27 | − | import { KIND_NAMES, type SessionsFilters, narrowed, repoName, runsIn, sessionHref, sessionsQuery, workspaceSessionTitle } from "../lib/sessions"; | |
| 27 | + | import { KIND_NAMES, type SessionsFilters, narrowed, repoName, runsIn, sessionCost, sessionHref, sessionsQuery, workspaceSessionTitle } from "../lib/sessions"; | |
| 28 | 28 | import { itemPath, sessionsPath } from "../lib/work-items"; | |
| 29 | 29 | ||
| 30 | 30 | const BRANDS: Partial<Record<Harness, { path: string; title: string }>> = { | |
| ⋯ | |||
| 121 | 121 | ); | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | /** What a harness session cost, quietly, when it reported usage. */ | |
| 125 | + | function Cost({ session }: { session: WorkspaceSession }) { | |
| 126 | + | const cost = sessionCost(session.session); | |
| 127 | + | if (!cost) return null; | |
| 128 | + | return ( | |
| 129 | + | <Hint label="Estimated from the tokens its transcript recorded, at Anthropic's list price. Its subagents count on their own sessions."> | |
| 130 | + | <span className="text-xs text-faint tabular-nums">{cost}</span> | |
| 131 | + | </Hint> | |
| 132 | + | ); | |
| 133 | + | } | |
| 134 | + | ||
| 124 | 135 | /** | |
| 125 | 136 | * A live session: its state, what it is called, who runs it and in what, | |
| 126 | 137 | * where, the work item and pull request it is on, its subagents and when | |
| ⋯ | |||
| 153 | 164 | <Where session={session} workspace={workspace} /> | |
| 154 | 165 | </div> | |
| 155 | 166 | {session.step && <p className="line-clamp-1 text-xs text-muted">{session.step}</p>} | |
| 167 | + | <Cost session={session} /> | |
| 156 | 168 | {(session.item != null || session.pull != null || session.subagents > 0) && ( | |
| 157 | 169 | <div className="flex flex-wrap items-center gap-2"> | |
| 158 | 170 | {session.item != null && <ItemChip workspace={workspace} number={session.item} title={session.itemTitle} />} | |
| ⋯ | |||
| 194 | 206 | </span> | |
| 195 | 207 | <Where session={session} workspace={workspace} /> | |
| 196 | 208 | {session.subagents > 0 && <span>{session.subagents} subagents</span>} | |
| 209 | + | <Cost session={session} /> | |
| 197 | 210 | </div> | |
| 198 | 211 | </div> | |
| 199 | 212 | <span className="shrink-0 text-xs text-faint"> | |
| 1 | − | import { Database, Activity, BarChart3, Bot, Cpu, Columns3, Radio, ScrollText, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTodo, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Database, Activity, BarChart3, Bot, Cpu, Columns3, Radio, ScrollText, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTodo, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, ReceiptText, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 1329 | 1329 | "personal-access-tokens": <UserRoundKey size={15} />, | |
| 1330 | 1330 | usage: <BarChart3 size={15} />, | |
| 1331 | 1331 | billing: <CreditCard size={15} />, | |
| 1332 | + | "provider-costs": <ReceiptText size={15} />, | |
| 1332 | 1333 | runners: <ServerCog size={15} />, | |
| 1333 | 1334 | repositories: <BookMarked size={15} />, | |
| 1334 | 1335 | actions: <PlayCircle size={15} />, | |
| ⋯ | |||
| 1609 | 1610 | "personal-access-tokens": "Personal access tokens", | |
| 1610 | 1611 | usage: "Usage", | |
| 1611 | 1612 | billing: "Billing and plans", | |
| 1613 | + | "provider-costs": "Provider costs", | |
| 1612 | 1614 | integrations: "Integrations", | |
| 1613 | 1615 | ai: "AI", | |
| 1614 | 1616 | webhooks: "Webhooks", | |
| 1 | + | /** | |
| 2 | + | * The daily signal on Home (lib/signals.ts): the past week in four | |
| 3 | + | * figures, each against the week before and drawn over ten, and the | |
| 4 | + | * findings worth a look today, each a sentence with its evidence, where | |
| 5 | + | * to see it, and a way to ask g1t about it. | |
| 6 | + | */ | |
| 7 | + | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useId, useState } from "react"; | |
| 9 | + | import { Link } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { askG1t } from "../lib/ask"; | |
| 12 | + | import { cn } from "../lib/cn"; | |
| 13 | + | import type { DailySignal as DailySignalView, PulseTile, Signal } from "../lib/signals"; | |
| 14 | + | import { Skeleton } from "./ui/skeleton"; | |
| 15 | + | ||
| 16 | + | // --- A trend line ------------------------------------------------------------------- | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * A measure over the weeks as a thin line, the latest point marked. Weeks | |
| 20 | + | * with nothing to measure break the line. Pointing at a week (or moving | |
| 21 | + | * along with the arrow keys) says its value through `onPoint`; every | |
| 22 | + | * value is also in a list for screen readers. | |
| 23 | + | */ | |
| 24 | + | export function TrendLine({ | |
| 25 | + | values, | |
| 26 | + | points, | |
| 27 | + | label, | |
| 28 | + | width = 120, | |
| 29 | + | height = 28, | |
| 30 | + | onPoint, | |
| 31 | + | }: { | |
| 32 | + | values: (number | null)[]; | |
| 33 | + | points: string[]; | |
| 34 | + | label: string; | |
| 35 | + | width?: number; | |
| 36 | + | height?: number; | |
| 37 | + | onPoint?: (index: number | null) => void; | |
| 38 | + | }) { | |
| 39 | + | const [active, setActive] = useState<number | null>(null); | |
| 40 | + | const list = useId(); | |
| 41 | + | const known = values.filter((value): value is number => value != null); | |
| 42 | + | if (known.length === 0) return <div style={{ width, height }} aria-hidden />; | |
| 43 | + | const pad = 4; | |
| 44 | + | const min = Math.min(...known); | |
| 45 | + | const max = Math.max(...known); | |
| 46 | + | const step = values.length > 1 ? (width - 2 * pad) / (values.length - 1) : 0; | |
| 47 | + | const x = (i: number) => pad + i * step; | |
| 48 | + | const y = (value: number) => (max === min ? height / 2 : pad + (1 - (value - min) / (max - min)) * (height - 2 * pad)); | |
| 49 | + | // One path, lifted where a week has nothing. | |
| 50 | + | let d = ""; | |
| 51 | + | values.forEach((value, i) => { | |
| 52 | + | if (value == null) return; | |
| 53 | + | d += `${i > 0 && values[i - 1] != null ? "L" : "M"}${x(i).toFixed(1)},${y(value).toFixed(1)}`; | |
| 54 | + | }); | |
| 55 | + | const last = values.length - 1 - [...values].reverse().findIndex((value) => value != null); | |
| 56 | + | const point = (index: number | null) => { | |
| 57 | + | setActive(index); | |
| 58 | + | onPoint?.(index); | |
| 59 | + | }; | |
| 60 | + | const shown = active ?? last; | |
| 61 | + | return ( | |
| 62 | + | <> | |
| 63 | + | <svg | |
| 64 | + | width={width} | |
| 65 | + | height={height} | |
| 66 | + | viewBox={`0 0 ${width} ${height}`} | |
| 67 | + | className="block overflow-visible outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-accent/40" | |
| 68 | + | role="img" | |
| 69 | + | aria-label={label} | |
| 70 | + | aria-describedby={list} | |
| 71 | + | tabIndex={0} | |
| 72 | + | onMouseLeave={() => point(null)} | |
| 73 | + | onBlur={() => point(null)} | |
| 74 | + | onKeyDown={(event) => { | |
| 75 | + | if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; | |
| 76 | + | event.preventDefault(); | |
| 77 | + | const from = active ?? last; | |
| 78 | + | point(Math.max(0, Math.min(values.length - 1, from + (event.key === "ArrowLeft" ? -1 : 1)))); | |
| 79 | + | }} | |
| 80 | + | > | |
| 81 | + | {active != null && <line x1={x(active)} x2={x(active)} y1={0} y2={height} className="stroke-line-strong" strokeWidth={1} />} | |
| 82 | + | <path d={d} fill="none" className="stroke-muted" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" /> | |
| 83 | + | {values[shown] != null && <circle cx={x(shown)} cy={y(values[shown]!)} r={3.5} className="fill-accent" stroke="var(--color-surface)" strokeWidth={2} />} | |
| 84 | + | {values.map((_, i) => ( | |
| 85 | + | <rect key={i} x={x(i) - step / 2} y={-4} width={Math.max(step, 8)} height={height + 8} fill="transparent" onMouseEnter={() => point(i)} /> | |
| 86 | + | ))} | |
| 87 | + | </svg> | |
| 88 | + | <ul id={list} className="sr-only"> | |
| 89 | + | {points.map((text, i) => ( | |
| 90 | + | <li key={i}>{text}</li> | |
| 91 | + | ))} | |
| 92 | + | </ul> | |
| 93 | + | </> | |
| 94 | + | ); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // --- The pulse ---------------------------------------------------------------------- | |
| 98 | + | ||
| 99 | + | const TONE: Record<"good" | "bad" | "flat", string> = { | |
| 100 | + | good: "bg-success/12 text-success", | |
| 101 | + | bad: "bg-danger/10 text-danger", | |
| 102 | + | flat: "bg-line text-muted", | |
| 103 | + | }; | |
| 104 | + | ||
| 105 | + | const TONE_WORDS: Record<"good" | "bad" | "flat", string> = { good: "better than last week", bad: "worse than last week", flat: "the same as last week" }; | |
| 106 | + | ||
| 107 | + | function Tile({ tile }: { tile: PulseTile }) { | |
| 108 | + | const [point, setPoint] = useState<number | null>(null); | |
| 109 | + | return ( | |
| 110 | + | <div className="group relative flex min-w-0 flex-col gap-2 bg-surface px-4 py-3.5 transition-colors hover:bg-raised"> | |
| 111 | + | <Link to={tile.to} className="truncate text-xs text-muted after:absolute after:inset-0 group-hover:text-fg"> | |
| 112 | + | {tile.label} | |
| 113 | + | </Link> | |
| 114 | + | <div className="flex items-center gap-2"> | |
| 115 | + | <span className="text-2xl leading-none font-semibold tracking-tight text-fg">{tile.value}</span> | |
| 116 | + | {tile.delta && ( | |
| 117 | + | <span className={cn("rounded-full px-1.5 py-0.5 text-[0.6875rem] font-medium tabular-nums", TONE[tile.delta.tone])}> | |
| 118 | + | {tile.delta.text} | |
| 119 | + | <span className="sr-only">, {TONE_WORDS[tile.delta.tone]}</span> | |
| 120 | + | </span> | |
| 121 | + | )} | |
| 122 | + | </div> | |
| 123 | + | {/* Above the link's cover, so pointing at a week says it. */} | |
| 124 | + | <div className="relative"> | |
| 125 | + | <TrendLine values={tile.series} points={tile.points} label={`${tile.label} over the last ${tile.series.length} weeks`} width={132} onPoint={setPoint} /> | |
| 126 | + | </div> | |
| 127 | + | <p className="truncate text-[0.6875rem] text-faint">{point != null ? tile.points[point] : tile.hint}</p> | |
| 128 | + | </div> | |
| 129 | + | ); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /** The past week in four figures, across the top of Home. */ | |
| 133 | + | export function PulseStrip({ tiles }: { tiles: PulseTile[] }) { | |
| 134 | + | if (tiles.length === 0) return null; | |
| 135 | + | return ( | |
| 136 | + | <section | |
| 137 | + | aria-label="The past week" | |
| 138 | + | className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-line bg-line lg:grid-cols-[6.5rem_repeat(4,minmax(0,1fr))]" | |
| 139 | + | > | |
| 140 | + | <div className="col-span-2 flex items-center bg-surface px-4 py-2 lg:col-span-1 lg:py-0"> | |
| 141 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">Past week</p> | |
| 142 | + | </div> | |
| 143 | + | {tiles.map((tile) => ( | |
| 144 | + | <Tile key={tile.key} tile={tile} /> | |
| 145 | + | ))} | |
| 146 | + | </section> | |
| 147 | + | ); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | export function PulseSkeleton() { | |
| 151 | + | return ( | |
| 152 | + | <div className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-line bg-line lg:grid-cols-[6.5rem_repeat(4,minmax(0,1fr))]"> | |
| 153 | + | <div className="col-span-2 bg-surface px-4 py-3 lg:col-span-1" /> | |
| 154 | + | {[0, 1, 2, 3].map((i) => ( | |
| 155 | + | <div key={i} className="space-y-2 bg-surface px-4 py-3.5"> | |
| 156 | + | <Skeleton className="h-3 w-28" /> | |
| 157 | + | <Skeleton className="h-6 w-16" /> | |
| 158 | + | <Skeleton className="h-5 w-32" /> | |
| 159 | + | </div> | |
| 160 | + | ))} | |
| 161 | + | </div> | |
| 162 | + | ); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // --- The findings ------------------------------------------------------------------- | |
| 166 | + | ||
| 167 | + | function Facts({ facts }: { facts: string[] }) { | |
| 168 | + | return ( | |
| 169 | + | <p className="font-mono text-[0.75rem] leading-relaxed text-muted"> | |
| 170 | + | {facts.map((fact, i) => ( | |
| 171 | + | <span key={i}> | |
| 172 | + | {i > 0 && <span className="px-1.5 text-faint">·</span>} | |
| 173 | + | {fact} | |
| 174 | + | </span> | |
| 175 | + | ))} | |
| 176 | + | </p> | |
| 177 | + | ); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | function Actions({ signal }: { signal: Signal }) { | |
| 181 | + | return ( | |
| 182 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> | |
| 183 | + | {signal.link && ( | |
| 184 | + | <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-sm font-medium text-accent hover:underline"> | |
| 185 | + | {signal.link.label} | |
| 186 | + | <ChevronRight size={14} /> | |
| 187 | + | </Link> | |
| 188 | + | )} | |
| 189 | + | <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 190 | + | <MessageSquare size={12} /> | |
| 191 | + | Ask g1t | |
| 192 | + | </button> | |
| 193 | + | </div> | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | function Trend({ trend }: { trend: NonNullable<Signal["trend"]> }) { | |
| 198 | + | const [point, setPoint] = useState<number | null>(null); | |
| 199 | + | return ( | |
| 200 | + | <figure className="space-y-1.5"> | |
| 201 | + | <TrendLine values={trend.values} points={trend.points} label={`${trend.caption}, ${trend.range}`} width={196} height={40} onPoint={setPoint} /> | |
| 202 | + | <figcaption> | |
| 203 | + | <p className="text-xs text-fg-soft">{trend.caption}</p> | |
| 204 | + | <p className="font-mono text-[0.6875rem] text-faint">{point != null ? trend.points[point] : trend.range}</p> | |
| 205 | + | </figcaption> | |
| 206 | + | </figure> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | function Finding({ signal }: { signal: Signal }) { | |
| 211 | + | return ( | |
| 212 | + | <li className={cn("grid gap-4 py-5 first:pt-0 last:pb-0", signal.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> | |
| 213 | + | <div className="min-w-0 space-y-2"> | |
| 214 | + | <h3 className="font-display text-lg leading-snug text-fg md:text-xl">{signal.headline}</h3> | |
| 215 | + | <Facts facts={signal.facts} /> | |
| 216 | + | <Actions signal={signal} /> | |
| 217 | + | </div> | |
| 218 | + | {signal.trend && ( | |
| 219 | + | <div className="md:pt-1"> | |
| 220 | + | <Trend trend={signal.trend} /> | |
| 221 | + | </div> | |
| 222 | + | )} | |
| 223 | + | </li> | |
| 224 | + | ); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /** Findings under the lead, before "and N more". */ | |
| 228 | + | const SHOWN = 4; | |
| 229 | + | ||
| 230 | + | /** | |
| 231 | + | * The daily signal: the day, the finding that matters most as the page's | |
| 232 | + | * headline, and the others under it. A quiet week still gets a sentence. | |
| 233 | + | * Null `view`: the work service did not answer. | |
| 234 | + | */ | |
| 235 | + | export function DailySignal({ date, view, line, footer }: { date: string; view: DailySignalView | null; line?: ReactNode; footer?: ReactNode }) { | |
| 236 | + | const [more, setMore] = useState(false); | |
| 237 | + | const lead = view?.findings[0] ?? null; | |
| 238 | + | const rest = view?.findings.slice(1) ?? []; | |
| 239 | + | const shown = more ? rest : rest.slice(0, SHOWN); | |
| 240 | + | return ( | |
| 241 | + | <section aria-labelledby="daily-signal" className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> | |
| 242 | + | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> | |
| 243 | + | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> | |
| 244 | + | {view == null ? ( | |
| 245 | + | <> | |
| 246 | + | <h1 id="daily-signal" className="mt-4 font-display text-3xl leading-tight tracking-tight text-fg md:text-[2.5rem]"> | |
| 247 | + | Here's your day. | |
| 248 | + | </h1> | |
| 249 | + | <p className="mt-3 flex items-center gap-1.5 text-sm text-muted"> | |
| 250 | + | <TriangleAlert size={13} className="shrink-0 text-warn" /> | |
| 251 | + | What the work says couldn't be read just now, so today's findings aren't here. | |
| 252 | + | </p> | |
| 253 | + | </> | |
| 254 | + | ) : ( | |
| 255 | + | <> | |
| 256 | + | <h1 | |
| 257 | + | id="daily-signal" | |
| 258 | + | className="mt-4 max-w-3xl font-display text-3xl leading-[1.12] tracking-tight text-balance text-fg md:text-[2.25rem] xl:text-[2.5rem]" | |
| 259 | + | > | |
| 260 | + | {lead?.headline ?? view.quiet} | |
| 261 | + | </h1> | |
| 262 | + | {lead && ( | |
| 263 | + | <div className={cn("mt-4 grid gap-4", lead.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> | |
| 264 | + | <div className="space-y-2"> | |
| 265 | + | <Facts facts={lead.facts} /> | |
| 266 | + | <Actions signal={lead} /> | |
| 267 | + | </div> | |
| 268 | + | {lead.trend && <Trend trend={lead.trend} />} | |
| 269 | + | </div> | |
| 270 | + | )} | |
| 271 | + | {!view.complete && ( | |
| 272 | + | <p className="mt-3 flex items-center gap-1.5 text-xs text-faint"> | |
| 273 | + | <TriangleAlert size={12} className="shrink-0" /> | |
| 274 | + | Some projects didn't answer, so these findings leave them out. | |
| 275 | + | </p> | |
| 276 | + | )} | |
| 277 | + | </> | |
| 278 | + | )} | |
| 279 | + | {line && <p className="mt-4 text-sm text-muted">{line}</p>} | |
| 280 | + | {shown.length > 0 && ( | |
| 281 | + | <> | |
| 282 | + | <hr className="my-7 border-line" /> | |
| 283 | + | <ul className="divide-y divide-line"> | |
| 284 | + | {shown.map((signal) => ( | |
| 285 | + | <Finding key={signal.key} signal={signal} /> | |
| 286 | + | ))} | |
| 287 | + | </ul> | |
| 288 | + | {rest.length > SHOWN && ( | |
| 289 | + | <button type="button" onClick={() => setMore(!more)} className="mt-5 text-xs text-muted hover:text-fg"> | |
| 290 | + | {more ? "Show fewer" : `${rest.length - SHOWN} more ${rest.length - SHOWN === 1 ? "finding" : "findings"}`} | |
| 291 | + | </button> | |
| 292 | + | )} | |
| 293 | + | </> | |
| 294 | + | )} | |
| 295 | + | {footer && <div className="mt-8 border-t border-line pt-6">{footer}</div>} | |
| 296 | + | </section> | |
| 297 | + | ); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | export function DailySignalSkeleton({ date }: { date: string }) { | |
| 301 | + | return ( | |
| 302 | + | <section className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> | |
| 303 | + | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> | |
| 304 | + | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> | |
| 305 | + | <Skeleton className="mt-5 h-9 w-full max-w-2xl" /> | |
| 306 | + | <Skeleton className="mt-2 h-9 w-2/3 max-w-xl" /> | |
| 307 | + | <Skeleton className="mt-5 h-3 w-64" /> | |
| 308 | + | </section> | |
| 309 | + | ); | |
| 310 | + | } |
| 1 | + | /** | |
| 2 | + | * Spend threaded to the work it produced (lib/signals.ts spendFlow): the | |
| 3 | + | * quarter's milestones, the chosen one's work items, the chosen work | |
| 4 | + | * item's change set in each project, and that change set's pull requests. | |
| 5 | + | * Each box is sized by what the agents' usage on it came to (by its pull | |
| 6 | + | * requests when usage is not known) and shaded by how much of it was an | |
| 7 | + | * agent's. Choosing a box opens what is under it; on a phone the columns | |
| 8 | + | * stack. | |
| 9 | + | */ | |
| 10 | + | import { Bot } from "lucide-react"; | |
| 11 | + | import { type CSSProperties, useState } from "react"; | |
| 12 | + | import { Link } from "react-router"; | |
| 13 | + | ||
| 14 | + | import { cn } from "../lib/cn"; | |
| 15 | + | import { type FlowNode, type FlowPull, agentStep, dollars, percent } from "../lib/signals"; | |
| 16 | + | ||
| 17 | + | /** The five steps of agent use: the accent, from a wash to full. */ | |
| 18 | + | const STEP_FILL = [ | |
| 19 | + | "color-mix(in oklab, var(--color-accent) 10%, var(--color-surface))", | |
| 20 | + | "color-mix(in oklab, var(--color-accent) 24%, var(--color-surface))", | |
| 21 | + | "color-mix(in oklab, var(--color-accent) 42%, var(--color-surface))", | |
| 22 | + | "color-mix(in oklab, var(--color-accent) 68%, var(--color-surface))", | |
| 23 | + | "var(--color-accent)", | |
| 24 | + | ]; | |
| 25 | + | /** Steps deep enough that their text turns: white on the light theme's accent, near black on the dark theme's pale one. */ | |
| 26 | + | const DARK_STEP = 3; | |
| 27 | + | ||
| 28 | + | const HEIGHT = 480; | |
| 29 | + | const GAP = 12; | |
| 30 | + | const MIN_BOX = 76; | |
| 31 | + | /** Columns' left edges and width, in percent of the canvas: the pull requests' column is wider, for their titles. */ | |
| 32 | + | const COLUMN = 19; | |
| 33 | + | const left = (column: number) => column * 23.5; | |
| 34 | + | ||
| 35 | + | type Box = { top: number; height: number }; | |
| 36 | + | ||
| 37 | + | /** Boxes down a column, each at least MIN_BOX tall and the rest shared by weight. */ | |
| 38 | + | function stack(weights: number[]): Box[] { | |
| 39 | + | const n = weights.length; | |
| 40 | + | if (n === 0) return []; | |
| 41 | + | const free = HEIGHT - GAP * (n - 1) - MIN_BOX * n; | |
| 42 | + | const total = weights.reduce((sum, w) => sum + w, 0); | |
| 43 | + | let top = 0; | |
| 44 | + | return weights.map((w) => { | |
| 45 | + | const height = MIN_BOX + (total > 0 ? (w / total) * free : free / n); | |
| 46 | + | const box = { top, height }; | |
| 47 | + | top += height + GAP; | |
| 48 | + | return box; | |
| 49 | + | }); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | const weightOf = (sized: "spend" | "pulls") => (node: { micros: number; pulls: number }) => (sized === "spend" ? node.micros : node.pulls); | |
| 53 | + | ||
| 54 | + | /** A band from a slice of `from` (in column `a`) to all of `to` (in column `a + 1`), as an SVG path in percent across. */ | |
| 55 | + | function band(a: number, from: Box, to: Box): string { | |
| 56 | + | const x0 = left(a) + COLUMN; | |
| 57 | + | const x1 = left(a + 1); | |
| 58 | + | const mid = (x0 + x1) / 2; | |
| 59 | + | return `M${x0},${from.top} C${mid},${from.top} ${mid},${to.top} ${x1},${to.top} L${x1},${to.top + to.height} C${mid},${to.top + to.height} ${mid},${from.top + from.height} ${x0},${from.top + from.height} Z`; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** Slices of a box, one per child, by weight. */ | |
| 63 | + | function slices(box: Box, weights: number[]): Box[] { | |
| 64 | + | const total = weights.reduce((sum, w) => sum + w, 0); | |
| 65 | + | let top = box.top; | |
| 66 | + | return weights.map((w) => { | |
| 67 | + | const height = total > 0 ? (w / total) * box.height : box.height / weights.length; | |
| 68 | + | const slice = { top, height }; | |
| 69 | + | top += height; | |
| 70 | + | return slice; | |
| 71 | + | }); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | function Legend() { | |
| 75 | + | return ( | |
| 76 | + | <div className="flex items-center gap-2 text-xs text-muted"> | |
| 77 | + | <span className="font-medium text-fg">Agent use</span> | |
| 78 | + | <span>less</span> | |
| 79 | + | <span className="flex gap-0.5" aria-hidden> | |
| 80 | + | {STEP_FILL.map((fill, i) => ( | |
| 81 | + | <span key={i} className="h-2.5 w-5 rounded-sm" style={{ background: fill }} /> | |
| 82 | + | ))} | |
| 83 | + | </span> | |
| 84 | + | <span>more</span> | |
| 85 | + | </div> | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | function figures(node: FlowNode, sized: "spend" | "pulls"): string { | |
| 90 | + | const share = node.pulls > 0 ? node.agent / node.pulls : 0; | |
| 91 | + | return `${node.pulls} ${node.pulls === 1 ? "PR" : "PRs"} · ${percent(share)} agent${sized === "spend" && node.micros > 0 ? ` · ${dollars(node.micros)}` : ""}`; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | function NodeBox({ | |
| 95 | + | node, | |
| 96 | + | sized, | |
| 97 | + | chosen, | |
| 98 | + | onChoose, | |
| 99 | + | style, | |
| 100 | + | }: { | |
| 101 | + | node: FlowNode; | |
| 102 | + | sized: "spend" | "pulls"; | |
| 103 | + | chosen: boolean; | |
| 104 | + | onChoose?: () => void; | |
| 105 | + | style?: CSSProperties; | |
| 106 | + | }) { | |
| 107 | + | const step = agentStep(node.pulls > 0 ? node.agent / node.pulls : 0); | |
| 108 | + | const dark = step >= DARK_STEP; | |
| 109 | + | return ( | |
| 110 | + | <button | |
| 111 | + | type="button" | |
| 112 | + | onClick={onChoose} | |
| 113 | + | aria-pressed={chosen} | |
| 114 | + | className={cn( | |
| 115 | + | "flex flex-col items-start overflow-hidden rounded-xl px-3.5 py-3 text-left transition-shadow outline-none focus-visible:ring-2 focus-visible:ring-accent/50", | |
| 116 | + | chosen ? "ring-[1.5px] ring-fg/80" : "hover:ring-1 hover:ring-line-strong", | |
| 117 | + | dark ? "text-[light-dark(#ffffff,#141018)]" : "text-fg", | |
| 118 | + | )} | |
| 119 | + | style={{ background: STEP_FILL[step], ...style }} | |
| 120 | + | > | |
| 121 | + | <span className="line-clamp-2 text-sm leading-snug font-semibold">{node.label}</span> | |
| 122 | + | <span className={cn("mt-1 font-mono text-[0.6875rem]", dark ? "text-[light-dark(rgb(255_255_255/0.82),rgb(20_16_24/0.75))]" : "text-fg-soft")}> | |
| 123 | + | {figures(node, sized)} | |
| 124 | + | </span> | |
| 125 | + | </button> | |
| 126 | + | ); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | function PullRow({ pull, style }: { pull: FlowPull; style?: CSSProperties }) { | |
| 130 | + | return ( | |
| 131 | + | <Link to={pull.to} className="flex items-center gap-3 border-b border-line px-3 text-sm last:border-b-0 hover:bg-raised" style={style}> | |
| 132 | + | <span className="shrink-0 font-mono text-xs text-muted">#{pull.number}</span> | |
| 133 | + | <span className="min-w-0 grow truncate font-medium text-fg">{pull.title}</span> | |
| 134 | + | {pull.agent && <Bot size={13} className="shrink-0 text-accent" aria-label="An agent's" />} | |
| 135 | + | </Link> | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | const HEADS = ["Milestone", "Work item", "Change set", "Pull requests"]; | |
| 140 | + | ||
| 141 | + | export function SpendFlow({ flow, sized }: { flow: FlowNode[]; sized: "spend" | "pulls" }) { | |
| 142 | + | const [path, setPath] = useState<string[]>([]); | |
| 143 | + | const weight = weightOf(sized); | |
| 144 | + | // The chosen node at each level: the one asked for, else the biggest. | |
| 145 | + | const columns: FlowNode[][] = [flow]; | |
| 146 | + | const chosen: FlowNode[] = []; | |
| 147 | + | for (let level = 0; level < 3; level++) { | |
| 148 | + | const nodes = columns[level] ?? []; | |
| 149 | + | const pick = nodes.find((node) => node.key === path[level]) ?? nodes[0]; | |
| 150 | + | if (!pick) break; | |
| 151 | + | chosen.push(pick); | |
| 152 | + | if (level < 2) columns.push(pick.children); | |
| 153 | + | } | |
| 154 | + | const pulls = chosen[2]?.list ?? []; | |
| 155 | + | const choose = (level: number, key: string) => setPath([...chosen.slice(0, level).map((node) => node.key), key]); | |
| 156 | + | const boxes = columns.map((nodes) => stack(nodes.map(weight))); | |
| 157 | + | const rowHeight = pulls.length > 0 ? HEIGHT / Math.max(pulls.length, 5) : 0; | |
| 158 | + | const rows: Box[] = pulls.map((_, i) => ({ top: i * rowHeight, height: rowHeight })); | |
| 159 | + | ||
| 160 | + | if (flow.length === 0) return <p className="text-sm text-muted">No pull requests were opened or merged this quarter yet.</p>; | |
| 161 | + | ||
| 162 | + | return ( | |
| 163 | + | <div className="space-y-4"> | |
| 164 | + | <Legend /> | |
| 165 | + | {/* Wide: the four columns, joined. */} | |
| 166 | + | <div className="hidden md:block"> | |
| 167 | + | <div className="relative h-7"> | |
| 168 | + | {HEADS.map((head, column) => ( | |
| 169 | + | <p key={head} className="absolute top-0 font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase" style={{ left: `${left(column)}%` }}> | |
| 170 | + | {head} | |
| 171 | + | </p> | |
| 172 | + | ))} | |
| 173 | + | </div> | |
| 174 | + | <div className="relative" style={{ height: HEIGHT }}> | |
| 175 | + | <svg className="pointer-events-none absolute inset-0 h-full w-full" viewBox={`0 0 100 ${HEIGHT}`} preserveAspectRatio="none" aria-hidden> | |
| 176 | + | {chosen.map((node, level) => { | |
| 177 | + | const index = columns[level]!.indexOf(node); | |
| 178 | + | const from = boxes[level]![index]!; | |
| 179 | + | if (level < 2) { | |
| 180 | + | const children = columns[level + 1]!; | |
| 181 | + | return slices(from, children.map(weight)).map((slice, i) => ( | |
| 182 | + | <path key={`${level}-${i}`} d={band(level, slice, boxes[level + 1]![i]!)} className="fill-line-strong" opacity={0.45} /> | |
| 183 | + | )); | |
| 184 | + | } | |
| 185 | + | return slices( | |
| 186 | + | from, | |
| 187 | + | rows.map(() => 1), | |
| 188 | + | ).map((slice, i) => ( | |
| 189 | + | <path | |
| 190 | + | key={`p-${i}`} | |
| 191 | + | d={band(level, slice, { top: rows[i]!.top + 6, height: rows[i]!.height - 12 })} | |
| 192 | + | className="fill-line-strong" | |
| 193 | + | opacity={0.35} | |
| 194 | + | /> | |
| 195 | + | )); | |
| 196 | + | })} | |
| 197 | + | </svg> | |
| 198 | + | {columns.map((nodes, level) => | |
| 199 | + | nodes.map((node, i) => ( | |
| 200 | + | <NodeBox | |
| 201 | + | key={node.key} | |
| 202 | + | node={node} | |
| 203 | + | sized={sized} | |
| 204 | + | chosen={chosen[level]?.key === node.key} | |
| 205 | + | onChoose={() => choose(level, node.key)} | |
| 206 | + | style={{ position: "absolute", left: `${left(level)}%`, width: `${COLUMN}%`, top: boxes[level]![i]!.top, height: boxes[level]![i]!.height }} | |
| 207 | + | /> | |
| 208 | + | )), | |
| 209 | + | )} | |
| 210 | + | <div | |
| 211 | + | className="absolute rounded-xl border border-line bg-surface" | |
| 212 | + | style={{ left: `${left(3)}%`, width: `${100 - left(3)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }} | |
| 213 | + | > | |
| 214 | + | {pulls.length === 0 ? ( | |
| 215 | + | <p className="px-3 py-4 text-sm text-muted">No pull requests here.</p> | |
| 216 | + | ) : ( | |
| 217 | + | pulls.map((pull, i) => <PullRow key={pull.key} pull={pull} style={{ height: rows[i]!.height }} />) | |
| 218 | + | )} | |
| 219 | + | </div> | |
| 220 | + | </div> | |
| 221 | + | </div> | |
| 222 | + | {/* A phone: each level as a list, the chosen one open. */} | |
| 223 | + | <div className="space-y-5 md:hidden"> | |
| 224 | + | {columns.map((nodes, level) => ( | |
| 225 | + | <div key={level} className="space-y-2"> | |
| 226 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[level]}</p> | |
| 227 | + | <div className="grid gap-2"> | |
| 228 | + | {nodes.map((node) => ( | |
| 229 | + | <NodeBox key={node.key} node={node} sized={sized} chosen={chosen[level]?.key === node.key} onChoose={() => choose(level, node.key)} /> | |
| 230 | + | ))} | |
| 231 | + | </div> | |
| 232 | + | </div> | |
| 233 | + | ))} | |
| 234 | + | <div className="space-y-2"> | |
| 235 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[3]}</p> | |
| 236 | + | <div className="rounded-xl border border-line bg-surface"> | |
| 237 | + | {pulls.map((pull) => ( | |
| 238 | + | <PullRow key={pull.key} pull={pull} style={{ height: 48 }} /> | |
| 239 | + | ))} | |
| 240 | + | </div> | |
| 241 | + | </div> | |
| 242 | + | </div> | |
| 243 | + | </div> | |
| 244 | + | ); | |
| 245 | + | } |
| 35 | 35 | "compare", "confirm-email", "context", "deployments", "dm", "docs", "emails", "emoji", "entries", "explore", "files", "forgot", "gateway", | |
| 36 | 36 | "github", "guardrails", "home", "inbox", "apps", "insights", "integrations", "invitations", "invite", "invites", "issues", | |
| 37 | 37 | "jobs", "keys", "labels", "login", "members", "merge-queue", "milestones", "new", "notifications", "overview", | |
| 38 | − | "packages", "patterns", "people", "personal-access-tokens", "pins", "policies", "profile", "projects", "pull", | |
| 38 | + | "packages", "patterns", "people", "personal-access-tokens", "pins", "policies", "profile", "projects", "provider-costs", "pull", | |
| 39 | 39 | "pulls", "queue", "releases", "repositories", "reset", "rules", "runners", "runs", "search", "secrets", "security", | |
| 40 | 40 | "security-log", "settings", "soon", "spend", "tags", "teams", "today", "tokens", "tree", "two-factor", "u", "usage", | |
| 41 | 41 | "verify", "webhooks", "work", "workspace", "workspaces", |
| 1 | + | /** | |
| 2 | + | * The workspace overview's ground truth, shaped from one reading of the | |
| 3 | + | * work (lib/signals.ts) for components/ground-truth.tsx: the pulse, spend | |
| 4 | + | * threaded to the work, each team's last two weeks, what waits on a first | |
| 5 | + | * review, and the quiet changes, each list in sentences. Imports only types | |
| 6 | + | * and pure functions, so the page's parts can be drawn and tested alone. | |
| 7 | + | */ | |
| 8 | + | import type { SignalData } from "./signals.server"; | |
| 9 | + | import { | |
| 10 | + | type Group, | |
| 11 | + | dailySignal, | |
| 12 | + | dayList, | |
| 13 | + | harnessWords, | |
| 14 | + | namesWords, | |
| 15 | + | newGround, | |
| 16 | + | newHabits, | |
| 17 | + | providerQuarter, | |
| 18 | + | quarterSpend, | |
| 19 | + | rhythm, | |
| 20 | + | silos, | |
| 21 | + | spendFlow, | |
| 22 | + | spoken, | |
| 23 | + | spreading, | |
| 24 | + | waitingRows, | |
| 25 | + | } from "./signals"; | |
| 26 | + | ||
| 27 | + | /** Entries each quiet change lists. */ | |
| 28 | + | export const QUIET_ROWS = 4; | |
| 29 | + | ||
| 30 | + | /** What the rhythm's rows are: teams by their projects, else the projects. Null when identity did not answer. */ | |
| 31 | + | export type Groups = { groups: Group[]; by: "team" | "project" } | null; | |
| 32 | + | ||
| 33 | + | export function shapeGround(found: SignalData, groups: Groups, now: number) { | |
| 34 | + | const name = (id: string) => found.repos[id]?.name ?? "a project"; | |
| 35 | + | const path = (id: string) => (found.repos[id] ? `/${found.repos[id]!.namespace}/${found.repos[id]!.name}` : null); | |
| 36 | + | const quarter = quarterSpend({ repos: found.repos, signals: found.signals, quarterUsage: found.quarterUsage, quarter: found.range.quarter }); | |
| 37 | + | // Practices catching on in each team (else across the workspace), the newest first. | |
| 38 | + | const scopes = | |
| 39 | + | groups?.by === "team" | |
| 40 | + | ? groups.groups.map((group) => ({ key: group.key, label: group.label, only: new Set(group.repoIds) as ReadonlySet<string> | undefined })) | |
| 41 | + | : [{ key: "all", label: "the workspace", only: undefined }]; | |
| 42 | + | const spreads = scopes.flatMap((scope) => spreading(found.signals.harnesses, now, { only: scope.only }).map((spread) => ({ scope, spread }))); | |
| 43 | + | return { | |
| 44 | + | tiles: dailySignal(found).tiles, | |
| 45 | + | complete: found.complete, | |
| 46 | + | capped: found.signals.pullsCapped, | |
| 47 | + | flow: spendFlow({ slug: found.slug, repos: found.repos, signals: found.signals, quarterUsage: found.quarterUsage, quarter: found.range.quarter }), | |
| 48 | + | sized: (quarter?.total ?? 0) > 0 ? ("spend" as const) : ("pulls" as const), | |
| 49 | + | quarterSpend: quarter?.total ?? null, | |
| 50 | + | sessionSpend: quarter?.sessions ?? 0, | |
| 51 | + | rhythm: groups ? { by: groups.by, rows: rhythm(found.signals.days, dayList(found.range.until, found.range.days), groups.groups) } : null, | |
| 52 | + | waiting: waitingRows(found.signals.waiting, found.repos).map((pull) => ({ ...pull, age: spoken(Math.max(0, (now - Date.parse(pull.createdAt)) / 1000)) })), | |
| 53 | + | silos: silos(found.signals.authors, now) | |
| 54 | + | .slice(0, QUIET_ROWS) | |
| 55 | + | .map((silo) => ({ | |
| 56 | + | key: silo.repoId, | |
| 57 | + | to: path(silo.repoId), | |
| 58 | + | where: name(silo.repoId), | |
| 59 | + | line: `${silo.name} merged ${silo.theirs} of its ${silo.recent} changes in 90 days`, | |
| 60 | + | more: silo.joining.length > 0 ? `${silo.joining.map((who) => who.name).join(", ")} started this month` : "No one else has started there", | |
| 61 | + | })), | |
| 62 | + | newGround: newGround(found.signals.authors, now) | |
| 63 | + | .slice(0, QUIET_ROWS) | |
| 64 | + | .map((row) => ({ | |
| 65 | + | key: `${row.ownerId}:${row.repoId}`, | |
| 66 | + | to: path(row.repoId), | |
| 67 | + | where: name(row.repoId), | |
| 68 | + | line: `${row.name} merged a first change here`, | |
| 69 | + | more: `${row.elsewhere} merges elsewhere first`, | |
| 70 | + | })), | |
| 71 | + | spreading: spreads.slice(0, QUIET_ROWS).map(({ scope, spread }) => ({ | |
| 72 | + | key: `${scope.key}:${spread.harness}`, | |
| 73 | + | to: null, | |
| 74 | + | where: `${harnessWords(spread.harness)} in ${scope.label}`, | |
| 75 | + | line: `${namesWords(spread.recent.map((person) => person.name))} started using it this month, after ${spread.first.name}`, | |
| 76 | + | more: `${spread.people} people use it now`, | |
| 77 | + | })), | |
| 78 | + | habits: newHabits(found.signals.pulls, now) | |
| 79 | + | .slice(0, QUIET_ROWS) | |
| 80 | + | .map((row) => ({ | |
| 81 | + | key: row.ownerId, | |
| 82 | + | to: path(row.repoId), | |
| 83 | + | where: name(row.repoId), | |
| 84 | + | line: `${row.name} merged a first agent-made change`, | |
| 85 | + | more: `after ${row.byHand} by hand`, | |
| 86 | + | })), | |
| 87 | + | }; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | export type Ground = ReturnType<typeof shapeGround> & { providers: ReturnType<typeof providerQuarter> }; |
| 3 | 3 | ||
| 4 | 4 | import type { WorkSession, WorkspaceSession } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { narrowed, peopleIn, repoName, runsIn, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 6 | + | import { narrowed, peopleIn, repoName, runsIn, sessionCost, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 7 | 7 | ||
| 8 | 8 | function listed(id: string, more: Partial<WorkspaceSession> = {}): WorkspaceSession { | |
| 9 | 9 | return { | |
| ⋯ | |||
| 86 | 86 | assert.equal(repoName("acme/web", "Acme"), "web"); | |
| 87 | 87 | assert.equal(repoName("other/web", "acme"), "other/web"); | |
| 88 | 88 | }); | |
| 89 | + | ||
| 90 | + | test("a session's cost reads as an estimate at list price, and says when some usage is priced as unknown", () => { | |
| 91 | + | assert.equal(sessionCost({ costUsd: 4.1234 }), "≈ $4.12 at list price"); | |
| 92 | + | assert.equal(sessionCost({ costUsd: 1234.5, costPartial: true }), "≈ $1,234.50 at list price, some usage priced as unknown"); | |
| 93 | + | assert.equal(sessionCost({ costUsd: 0.004 }), "≈ under $0.01 at list price"); | |
| 94 | + | assert.equal(sessionCost({ costUsd: 0 }), "≈ $0.00 at list price"); | |
| 95 | + | assert.equal(sessionCost({ costUsd: null, costPartial: true }), "Usage priced as unknown"); | |
| 96 | + | assert.equal(sessionCost({ costUsd: null }), null); | |
| 97 | + | assert.equal(sessionCost({}), null); | |
| 98 | + | assert.equal(sessionCost(null), null); | |
| 99 | + | }); | |
| 3 | 3 | * and where it opens, the filters Sessions keeps in its address, and live | |
| 4 | 4 | * ones apart from the rest. Pure, so it is tested under Node. | |
| 5 | 5 | */ | |
| 6 | − | import type { Harness, SessionKind, WorkspaceSession } from "@g1t/contracts"; | |
| 6 | + | import type { Harness, SessionKind, WorkSession, WorkspaceSession } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | import { HARNESS_NAMES, sessionTitle } from "./coordination.ts"; | |
| 9 | 9 | import { sessionPath } from "./work-items.ts"; | |
| ⋯ | |||
| 106 | 106 | return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name); | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | + | /** | |
| 110 | + | * What a session cost, in a few words: "≈ $4.12 at list price", with | |
| 111 | + | * "some usage priced as unknown" when some of it was for models g1t has no | |
| 112 | + | * price for. Null when it reported no usage. | |
| 113 | + | */ | |
| 114 | + | export function sessionCost(session: Pick<WorkSession, "costUsd" | "costPartial"> | null | undefined): string | null { | |
| 115 | + | if (!session) return null; | |
| 116 | + | const cost = session.costUsd; | |
| 117 | + | if (cost == null || !Number.isFinite(cost)) return session.costPartial ? "Usage priced as unknown" : null; | |
| 118 | + | const amount = cost > 0 && cost < 0.01 ? "under $0.01" : `$${cost.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; | |
| 119 | + | return `≈ ${amount} at list price${session.costPartial ? ", some usage priced as unknown" : ""}`; | |
| 120 | + | } | |
| 121 | + | ||
| 109 | 122 | /** `acme/web` as the workspace `acme` names it: `web`. */ | |
| 110 | 123 | export function repoName(path: string, workspace: string): string { | |
| 111 | 124 | const [owner, name] = path.split("/"); | |
| 1 | + | /** | |
| 2 | + | * The daily signal's reads: the work service's `workspaceSignals` for the | |
| 3 | + | * workspace's repositories, a hundred at a time; the agents' usage for the | |
| 4 | + | * weeks and for the quarter; and, for the Workspace overview, its teams. | |
| 5 | + | * Each comes back on its own, so one that fails leaves only what needs it | |
| 6 | + | * saying so. The shaping is in ./signals.ts. | |
| 7 | + | */ | |
| 8 | + | import type { Repo, RepoPath, Usage, User, WorkspaceSignals } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | import { billing, identity, work } from "./services.server"; | |
| 11 | + | import { type Group, type WeekTotals, mergeSignals, signalsRange, weekStarts, weekTotals, weeklySpend } from "./signals"; | |
| 12 | + | ||
| 13 | + | /** Repositories asked of the work service at once: its limit. */ | |
| 14 | + | const BATCH = 100; | |
| 15 | + | /** Teams the rhythm reads the projects of. */ | |
| 16 | + | const MAX_TEAMS = 8; | |
| 17 | + | ||
| 18 | + | export type SignalData = { | |
| 19 | + | slug: string; | |
| 20 | + | now: number; | |
| 21 | + | range: ReturnType<typeof signalsRange>; | |
| 22 | + | starts: string[]; | |
| 23 | + | repos: Record<string, RepoPath>; | |
| 24 | + | signals: WorkspaceSignals; | |
| 25 | + | /** Every batch answered: otherwise the figures leave some projects out. */ | |
| 26 | + | complete: boolean; | |
| 27 | + | weeks: WeekTotals[]; | |
| 28 | + | /** Usage per week; null when billing did not answer. */ | |
| 29 | + | spend: number[] | null; | |
| 30 | + | quarterUsage: Usage | null; | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | async function usageSince(viewer: User, slug: string, day: string): Promise<Usage | null> { | |
| 34 | + | const result = await billing.usage(slug, viewer, `${day}T00:00:00.000Z`).catch(() => null); | |
| 35 | + | return result?.ok ? result.value : null; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** How long one reading of the work is used again: Home refreshes itself often, and the weeks barely move in minutes. */ | |
| 39 | + | const KEEP_MS = 5 * 60_000; | |
| 40 | + | /** Readings kept in the isolate at once. */ | |
| 41 | + | const KEEP_MAX = 50; | |
| 42 | + | /** Where readings are kept in the data centre's cache: a name nothing outside can ask for. */ | |
| 43 | + | const KEEP_ORIGIN = "https://signals.g1t.internal/"; | |
| 44 | + | ||
| 45 | + | const kept = new Map<string, { at: number; data: SignalData }>(); | |
| 46 | + | ||
| 47 | + | /** | |
| 48 | + | * What the workspace's work says, for a page drawn at `now`, read again | |
| 49 | + | * at most every five minutes per person (what each person can read | |
| 50 | + | * differs, so a reading is never shared between people). Kept in the | |
| 51 | + | * isolate and in the data centre's cache; only a reading every batch of | |
| 52 | + | * which answered is kept. Null when the work service answered none of | |
| 53 | + | * the batches: the page then says so. | |
| 54 | + | */ | |
| 55 | + | export async function loadSignalData(viewer: User, slug: string, now: number, owned: Repo[] | null): Promise<SignalData | null> { | |
| 56 | + | if (!owned) return null; | |
| 57 | + | const key = `${viewer.id}/${slug}/${await digest( | |
| 58 | + | owned | |
| 59 | + | .map((repo) => repo.id) | |
| 60 | + | .sort() | |
| 61 | + | .join(","), | |
| 62 | + | )}`; | |
| 63 | + | const memory = kept.get(key); | |
| 64 | + | if (memory && now - memory.at < KEEP_MS) return memory.data; | |
| 65 | + | const shared = sharedCache(); | |
| 66 | + | const url = `${KEEP_ORIGIN}${encodeURIComponent(key)}`; | |
| 67 | + | const found = shared ? await shared.match(url).catch(() => undefined) : undefined; | |
| 68 | + | if (found) { | |
| 69 | + | const data = (await found.json().catch(() => null)) as SignalData | null; | |
| 70 | + | if (data && now - data.now < KEEP_MS) { | |
| 71 | + | remember(key, data); | |
| 72 | + | return data; | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | const data = await readSignalData(viewer, slug, now, owned); | |
| 76 | + | if (data?.complete) { | |
| 77 | + | remember(key, data); | |
| 78 | + | const response = new Response(JSON.stringify(data), { | |
| 79 | + | headers: { "content-type": "application/json", "cache-control": `private, max-age=${KEEP_MS / 1000}` }, | |
| 80 | + | }); | |
| 81 | + | // Kept for the next page; the answer never waits for it. | |
| 82 | + | void shared?.put(url, response).catch(() => undefined); | |
| 83 | + | } | |
| 84 | + | return data; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function remember(key: string, data: SignalData) { | |
| 88 | + | kept.delete(key); | |
| 89 | + | kept.set(key, { at: data.now, data }); | |
| 90 | + | while (kept.size > KEEP_MAX) kept.delete(kept.keys().next().value!); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /** The data centre's cache, or null where there is none (tests, local development). */ | |
| 94 | + | function sharedCache(): Cache | null { | |
| 95 | + | try { | |
| 96 | + | return (globalThis as { caches?: { default?: Cache } }).caches?.default ?? null; | |
| 97 | + | } catch { | |
| 98 | + | return null; | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | async function digest(text: string): Promise<string> { | |
| 103 | + | const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); | |
| 104 | + | return [...new Uint8Array(bytes).slice(0, 12)].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | async function readSignalData(viewer: User, slug: string, now: number, owned: Repo[]): Promise<SignalData | null> { | |
| 108 | + | const range = signalsRange(now); | |
| 109 | + | const starts = weekStarts(range.until, range.weeks); | |
| 110 | + | const ids = owned.map((repo) => repo.id); | |
| 111 | + | const repos: Record<string, RepoPath> = {}; | |
| 112 | + | for (const repo of owned) repos[repo.id] = { namespace: repo.namespace, name: repo.name }; | |
| 113 | + | const [batches, weekly, quarterUsage] = await Promise.all([ | |
| 114 | + | Promise.allSettled( | |
| 115 | + | Array.from({ length: Math.ceil(ids.length / BATCH) }, (_, i) => | |
| 116 | + | work.workspaceSignals( | |
| 117 | + | { workspace: slug, repoIds: ids.slice(i * BATCH, (i + 1) * BATCH), until: range.until, weeks: range.weeks, days: range.days, since: range.since }, | |
| 118 | + | viewer, | |
| 119 | + | ), | |
| 120 | + | ), | |
| 121 | + | ), | |
| 122 | + | usageSince(viewer, slug, starts[0]!), | |
| 123 | + | usageSince(viewer, slug, range.quarter), | |
| 124 | + | ]); | |
| 125 | + | const answered = batches.flatMap((batch) => (batch.status === "fulfilled" ? [batch.value] : [])); | |
| 126 | + | for (const batch of batches) if (batch.status === "rejected") console.warn("g1t: workspace signals could not be read:", batch.reason); | |
| 127 | + | if (ids.length > 0 && answered.length === 0) return null; | |
| 128 | + | const signals = mergeSignals(answered); | |
| 129 | + | const weeks = weekTotals(signals.weeks, starts); | |
| 130 | + | return { | |
| 131 | + | slug, | |
| 132 | + | now, | |
| 133 | + | range, | |
| 134 | + | starts, | |
| 135 | + | repos, | |
| 136 | + | signals, | |
| 137 | + | complete: answered.length === batches.length, | |
| 138 | + | weeks, | |
| 139 | + | // g1t's metered usage each week, and what the harness sessions started in it cost. | |
| 140 | + | spend: weeklySpend(weekly, starts)?.map((micros, i) => micros + Math.round((weeks[i]?.sessionCostUsd ?? 0) * 1_000_000)) ?? null, | |
| 141 | + | quarterUsage, | |
| 142 | + | }; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /** | |
| 146 | + | * What the rhythm's rows are: the workspace's teams by the projects they | |
| 147 | + | * work on, with how many people each has; else, with no teams, the | |
| 148 | + | * projects themselves. Null when identity did not answer. | |
| 149 | + | */ | |
| 150 | + | export async function loadGroups(viewer: User, slug: string, owned: Repo[]): Promise<{ groups: Group[]; by: "team" | "project" } | null> { | |
| 151 | + | const projects = (): { groups: Group[]; by: "project" } => ({ | |
| 152 | + | by: "project", | |
| 153 | + | groups: owned.map((repo) => ({ key: repo.id, label: repo.name, note: repo.namespace, to: `/${repo.namespace}/${repo.name}`, repoIds: [repo.id] })), | |
| 154 | + | }); | |
| 155 | + | const listed = await identity.listTeams(viewer, slug).catch(() => null); | |
| 156 | + | if (!listed) return null; | |
| 157 | + | if (!listed.ok || listed.value.length === 0) return projects(); | |
| 158 | + | const teams = listed.value.slice(0, MAX_TEAMS); | |
| 159 | + | const [repos, memberships] = await Promise.all([ | |
| 160 | + | Promise.all(teams.map((team) => identity.teamRepos(viewer, slug, team.slug).catch(() => null))), | |
| 161 | + | identity.teamMemberships(viewer, slug).catch(() => null), | |
| 162 | + | ]); | |
| 163 | + | const people = new Map<string, number>(); | |
| 164 | + | if (memberships?.ok) for (const member of memberships.value) for (const team of member.teams) people.set(team.slug, (people.get(team.slug) ?? 0) + 1); | |
| 165 | + | const groups = teams.flatMap((team, i) => { | |
| 166 | + | const found = repos[i]; | |
| 167 | + | if (!found?.ok || found.value.length === 0) return []; | |
| 168 | + | const count = people.get(team.slug); | |
| 169 | + | return [ | |
| 170 | + | { | |
| 171 | + | key: team.slug, | |
| 172 | + | label: team.name, | |
| 173 | + | note: count == null ? "" : `${count} ${count === 1 ? "person" : "people"}`, | |
| 174 | + | to: `/${slug}/-/teams/${team.slug}`, | |
| 175 | + | repoIds: found.value.map((repo) => repo.repo_id), | |
| 176 | + | }, | |
| 177 | + | ]; | |
| 178 | + | }); | |
| 179 | + | return groups.length > 0 ? { by: "team", groups } : projects(); | |
| 180 | + | } |
Loading the diff…
A large diff (1,076 lines) is not shown until you ask for it.
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.