Home leads with Ask, then your move beside what's live, then the week day by day #168
Mergeds
syntaqx merged home-ask-wide into main
11 files+650−6150/11 viewed
| 1 | 1 | --- | |
| 2 | 2 | title: Home | |
| 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. | |
| 3 | + | description: Your workspace's front page. A box that asks g1t, your move (everything waiting on you, and the one item to start with), the agents, runs and builds going now, this week's work day by day with what is worth a look, and what happened since you were last here in plain English. How every number is worked out. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | **Home** is the first page of your workspace, at `/<workspace>/-/home`. | |
| 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 | − | bottom: | |
| 7 | + | It reads top to bottom, from what you can do now to what already happened: | |
| 10 | 8 | ||
| 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 | |
| 9 | + | 1. **What are we building today?**: a box that [asks g1t](#ask-from-home) | |
| 10 | + | anything about your work, with questions to start with. | |
| 11 | + | 2. **[Your move](#your-move)**: everything waiting on you, however long ago | |
| 16 | 12 | it started, with the action beside each item. Its first row is | |
| 17 | − | [Start here](#start-here), the one item to do first, and why. | |
| 18 | − | 3. **Since you were last here**: one card for the span. It shows what people | |
| 13 | + | [Start here](#start-here), the one item to do first, and why. Beside it, | |
| 14 | + | **[Live](#live)**: the agents at work, workflow runs and builds going | |
| 15 | + | now, each agent session with what it has cost so far. | |
| 16 | + | 3. **[This week](#this-week)**: the week in a sentence and four figures, | |
| 17 | + | the last two weeks day by day (what agents and people landed), and what | |
| 18 | + | is [worth a look](#worth-a-look), each finding with its evidence and | |
| 19 | + | pinned to the day it happened where it happened on one. | |
| 20 | + | 4. **Since you were last here**: one card for the span. It shows what people | |
| 19 | 21 | did, what changed in plain English when you ask for it, and what landed, | |
| 20 | − | each change with whether it is live yet. | |
| 21 | − | ||
| 22 | − | The side column stays in view as you scroll: | |
| 23 | − | ||
| 24 | − | - **[Running now](#running-now)**: workflow runs and builds still going. | |
| 25 | − | - **[Spent](#spent)**: what the span cost, and the month so far. | |
| 26 | − | - **[Settled for you](#settled-for-you)**: what finished by itself. | |
| 22 | + | each change with whether it is live yet. Beside it, kept in view as you | |
| 23 | + | scroll: **[Spent](#spent)**, what the span cost and the month so far, | |
| 24 | + | and **[Settled for you](#settled-for-you)**, what finished by itself. | |
| 27 | 25 | ||
| 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 | + | On a phone it is one column, in the same order. | |
| 30 | 27 | ||
| 31 | 28 | For a workspace's owners, Home also opens | |
| 32 | 29 | [Getting started](/guides/workspaces/#getting-started), the setup guide, | |
| ⋯ | |||
| 39 | 36 | so, and the rest of the page still shows. Only what you ask g1t is written | |
| 40 | 37 | by a model: [What changed](#what-changed) and the answers in Ask. | |
| 41 | 38 | ||
| 42 | − | ## The past week | |
| 39 | + | ## This week | |
| 43 | 40 | ||
| 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. | |
| 41 | + | A sentence for what landed in the last seven UTC days and how much of it | |
| 42 | + | was agents', then four figures. Each shows how it moved since the seven | |
| 43 | + | days before (green when that is good news, red when it is not); point at | |
| 44 | + | one for what it counts, and it opens the page behind it. | |
| 48 | 45 | ||
| 49 | − | | Figure | What it counts | | |
| 50 | − | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 51 | − | | **Agent-authored changes** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. | | |
| 52 | − | | **Spend per agent change** | 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 changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. | | |
| 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 (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. | | |
| 46 | + | Under them are the last fourteen UTC days as columns: what agents landed | |
| 47 | + | (their merged pull requests and the commits their sessions pushed | |
| 48 | + | directly) in the accent, what people merged above it, last week's days | |
| 49 | + | faded. Point at a day, or focus it, to read it in full: what landed, by | |
| 50 | + | whom, and the sessions started. A finding that happened on one day, such | |
| 51 | + | as a milestone that closed, is pinned over it with the number it has in | |
| 52 | + | [Worth a look](#worth-a-look). | |
| 53 | + | ||
| 54 | + | | Figure | What it counts | | |
| 55 | + | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 56 | + | | **Landed by agents** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. | | |
| 57 | + | | **Cost per agent change** | 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 changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. | | |
| 58 | + | | **Gone quiet** | 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. | | |
| 59 | + | | **Sent back** | Of the agent pull requests that merged in the week (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. | | |
| 55 | 60 | ||
| 56 | 61 | ### Whose work is an agent's | |
| 57 | 62 | ||
| ⋯ | |||
| 61 | 66 | for it, else its author, so an agent's work counts toward the person it | |
| 62 | 67 | was for. | |
| 63 | 68 | ||
| 64 | − | ## The daily signal | |
| 69 | + | ### Worth a look | |
| 65 | 70 | ||
| 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 | + | Under the days, what the work says is worth a look, most important first. | |
| 72 | + | Each finding is a sentence with its evidence after it, a link to where you | |
| 73 | + | can see it, and **Ask g1t**, which asks [Ask](/guides/ask/) to look into | |
| 74 | + | it. A finding with a trend has its line beside it. When nothing stands | |
| 75 | + | out against the workspace's last weeks, it says so. None of it is about | |
| 76 | + | what needs you: that is [Your move](#your-move). | |
| 71 | 77 | ||
| 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** | In a workspace where three people or more merged lately, 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. | | |
| 78 | + | | Finding | When it shows | | |
| 79 | + | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 80 | + | | **One unreviewed change holding up work** | 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. | | |
| 81 | + | | **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. | | |
| 82 | + | | **A milestone closed** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. | | |
| 83 | + | | **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. | | |
| 84 | + | | **More changes gone quiet** | At least three more stalled changes than last week, and a quarter more. | | |
| 85 | + | | **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). | | |
| 86 | + | | **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. | | |
| 87 | + | | **Who works where** | In a workspace where three people or more merged lately, 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 | 88 | ||
| 83 | 89 | 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 | |
| 90 | + | waits, then what closed and where spend went. Up to four show, and the | |
| 85 | 91 | rest are one click away. What the work says is read again at most every | |
| 86 | 92 | five minutes for each person, so a figure can be up to five minutes | |
| 87 | 93 | behind. The same reads, at more length, are on the | |
| ⋯ | |||
| 89 | 95 | ||
| 90 | 96 | ## Ask from Home | |
| 91 | 97 | ||
| 92 | − | The box under the daily signal asks g1t anything about your work. Type a question and | |
| 98 | + | The box at the top of Home asks g1t anything about your work. Type a question and | |
| 93 | 99 | press **Enter** (**Shift+Enter** starts a new line): the top bar's | |
| 94 | 100 | [Ask](/guides/ask/) panel opens and sends it, and the answer streams in | |
| 95 | 101 | there. Under the box are questions to start with: | |
| ⋯ | |||
| 109 | 115 | still answering an earlier question, the panel opens with your question | |
| 110 | 116 | in its box rather than sending it, so nothing you typed is lost. | |
| 111 | 117 | ||
| 112 | − | ## Where you're needed | |
| 118 | + | ## Your move | |
| 113 | 119 | ||
| 114 | 120 | This list has no time window: something that started waiting a week ago | |
| 115 | 121 | is here until you deal with it. Each row says what it is, what is at | |
| ⋯ | |||
| 145 | 151 | 6. Mentions and direct messages. | |
| 146 | 152 | ||
| 147 | 153 | The list shows the first eight rows; **Show more** opens the rest. The | |
| 148 | − | sentence under the page's heading starts with how many things need you. | |
| 154 | + | line under its heading says how many things need you. | |
| 149 | 155 | ||
| 150 | 156 | ## Start here | |
| 151 | 157 | ||
| 152 | − | **Start here** is the first row of Where you're needed, set apart at the | |
| 158 | + | **Start here** is the first row of Your move, set apart at the | |
| 153 | 159 | top: the most pressing tier, and within it the row that has waited | |
| 154 | 160 | longest. g1t picks it with that rule, so the same rows always give the | |
| 155 | 161 | same pick. It says why, such as "Everything it needs is met; it lands when | |
| ⋯ | |||
| 192 | 198 | - With no visit on record, Home shows the last 24 hours and says so. After | |
| 193 | 199 | more than 14 days away, it shows the last 14 days. | |
| 194 | 200 | ||
| 195 | − | The sentence under the heading sums it up, for example: | |
| 201 | + | The sentence under the card's heading sums it up, for example: | |
| 196 | 202 | ||
| 197 | − | > 8 things need you. Since Tuesday evening, 3 people pushed 41 commits and | |
| 198 | − | > merged 5 changes, and 12 changes landed. | |
| 203 | + | > Since Tuesday evening, 3 people pushed 41 commits and merged 5 | |
| 204 | + | > changes, and 12 changes landed. | |
| 199 | 205 | ||
| 200 | 206 | It says what people did first (the commits they pushed and the changes | |
| 201 | 207 | they merged; if neither, the messages they sent), then everything that | |
| ⋯ | |||
| 327 | 333 | requests, pushes and releases, deployments) didn't answer, the card says | |
| 328 | 334 | so under the list. | |
| 329 | 335 | ||
| 330 | − | ## Running now | |
| 336 | + | ## Live | |
| 331 | 337 | ||
| 332 | − | At the top of the side column, what is going right now, longest-going | |
| 333 | − | first, each with its state and how long it has gone: | |
| 338 | + | Beside Your move, what is going right now. First the agents at work: each | |
| 339 | + | live [session](/guides/sessions/) of a harness (Claude Code, Codex and so | |
| 340 | + | on) or a hosted agent, with what it is on, who runs it, how long it has | |
| 341 | + | gone, its subagents, and what it has cost so far, estimated at list price | |
| 342 | + | from its token usage. Then workflow runs and builds, longest-going first, | |
| 343 | + | each with its state and how long it has gone: | |
| 334 | 344 | ||
| 335 | 345 | | Kind | What it is | | |
| 336 | 346 | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | |
| ⋯ | |||
| 339 | 349 | ||
| 340 | 350 | ## Spent | |
| 341 | 351 | ||
| 342 | − | In the side column: what the workspace spent over the span, from its | |
| 352 | + | Beside the span: what the workspace spent over it, from its | |
| 343 | 353 | [statement](/guides/usage-and-billing/), with each line at its price: what | |
| 344 | 354 | was charged plus what your plan's included usage, a trial, a pool or a | |
| 345 | 355 | discount paid of it. The lines are the statement's kinds of charge, such | |
| ⋯ | |||
| 356 | 366 | ||
| 357 | 367 | ## Settled for you | |
| 358 | 368 | ||
| 359 | − | At the bottom of the side column: your notifications in this workspace | |
| 369 | + | Under Spent: your notifications in this workspace | |
| 360 | 370 | that [settled themselves](/guides/notifications/#threads-that-settle-themselves) | |
| 361 | 371 | in the span, newest first, each with why, such as **CI passed in run 330** | |
| 362 | 372 | or **Merged by @ana**: a later run passed, the pull request merged, the | |
| ⋯ | |||
| 368 | 378 | Home keeps up with the workspace without a reload. What lands, starts | |
| 369 | 379 | running, finishes or comes to need you while you are on the page joins its | |
| 370 | 380 | list, sliding in at its place and lit for a moment; counts and the | |
| 371 | − | sentence under the heading update with it. Runs that finish leave Running | |
| 372 | − | now. | |
| 381 | + | lines update with it. Sessions that end and runs that finish leave Live. | |
| 373 | 382 | ||
| 374 | 383 | | Home updates when | From | | |
| 375 | 384 | | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | |
| ⋯ | |||
| 394 | 403 | A member whose membership does not include Code sees Home without anything | |
| 395 | 404 | from Code: | |
| 396 | 405 | ||
| 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. | |
| 406 | + | - This week is not shown. | |
| 399 | 407 | - People shows what was said and the artifacts edited; nothing from | |
| 400 | 408 | repositories. | |
| 401 | − | - Where you're needed has no rows from Code, and no notifications about a | |
| 409 | + | - Your move has no rows from Code, and no notifications about a | |
| 402 | 410 | repository. | |
| 403 | − | - Landed and Running now are not shown. | |
| 411 | + | - Landed is not shown, and Live has no sessions, runs or builds. | |
| 404 | 412 | - Spent is the same for every member. | |
| 405 | 413 | ||
| 406 | 414 | ## When something doesn't answer | |
| ⋯ | |||
| 408 | 416 | Each section reads its own part of g1t and shows as soon as it has it. | |
| 409 | 417 | When one does not answer: | |
| 410 | 418 | ||
| 411 | − | - Where you're needed and Running now name the parts that didn't answer, | |
| 419 | + | - Your move and Live name the parts that didn't answer, | |
| 412 | 420 | so an empty list never reads as nothing waiting. | |
| 413 | 421 | - People counts what could be read, and names the part that is missing | |
| 414 | 422 | (Code or Chat). If nothing could be read, the card says so, and the | |
| 415 | − | sentence under the heading says what people did couldn't be read. | |
| 423 | + | sentence under the card's heading says what people did couldn't be read. | |
| 416 | 424 | - Landed and Spent each say their part didn't answer. When | |
| 417 | 425 | deployments or sessions can't be read, Landed shows its rows without | |
| 418 | 426 | their live labels or agent markers. | |
| 419 | 427 | - 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. | |
| 428 | + | - This week says when the work couldn't be read. When some projects | |
| 429 | + | didn't answer, it says the week leaves them out. | |
| 423 | 430 | - What changed says when Ask can't be reached or isn't set up, or when | |
| 424 | 431 | the question could not be sent. | |
| 425 | 432 | - If your last visit can't be read, the span is the last 24 hours and the | |
| 99 | 99 | A thread that leaves you nothing to do moves to Done by itself, read, the | |
| 100 | 100 | moment what it told you of is over: a later run passes, the pull request | |
| 101 | 101 | merges, the review is no longer asked of you. It leaves your unread count, | |
| 102 | − | the bell, [Home](/guides/home/#where-youre-needed)'s **Where you're | |
| 103 | − | needed** and `g1t status`, and | |
| 102 | + | the bell, [Home](/guides/home/#your-move)'s **Your | |
| 103 | + | move** and `g1t status`, and | |
| 104 | 104 | stays under **Done** with its history. New activity brings it back, as with | |
| 105 | 105 | any thread. | |
| 106 | 106 |
| 675 | 675 | ||
| 676 | 676 | The workspace's **Overview**, `g1t.sh/<workspace>/-/workspace`, opens with | |
| 677 | 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 | |
| 678 | + | same way as [Home's week](/guides/home/#this-week), and | |
| 679 | 679 | each person sees only the projects they can read. | |
| 680 | 680 | ||
| 681 | − | 1. **[The past week](/guides/home/#the-past-week)**: the same four | |
| 681 | + | 1. **[The past week](/guides/home/#this-week)**: the same four | |
| 682 | 682 | figures as Home. | |
| 683 | 683 | 2. **Spend, threaded to the work**: the quarter's work in four columns: | |
| 684 | 684 | each milestone, its work items, each work item's change set in each |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 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. | |
| 37 | + | * Home's box that asks g1t, with a few things to ask first. It leads the | |
| 38 | + | * page, above everything it can be asked about, with what are we building | |
| 39 | + | * today. | |
| 40 | 40 | */ | |
| 41 | − | export function HomeAsk({ span, compact = false }: { span: AskSpan; compact?: boolean }) { | |
| 41 | + | export function HomeAsk({ span }: { span: AskSpan }) { | |
| 42 | 42 | const [draft, setDraft] = useState(""); | |
| 43 | 43 | const send = () => { | |
| 44 | 44 | if (!draft.trim()) return; | |
| ⋯ | |||
| 56 | 56 | } | |
| 57 | 57 | }; | |
| 58 | 58 | return ( | |
| 59 | − | <section | |
| 60 | − | aria-labelledby="home-ask" | |
| 61 | − | 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"} | |
| 62 | − | > | |
| 63 | − | {compact ? ( | |
| 64 | − | <h2 id="home-ask" className="text-sm font-semibold text-fg"> | |
| 65 | − | Ask about any of this | |
| 66 | − | </h2> | |
| 67 | − | ) : ( | |
| 68 | − | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 69 | − | What are we building today? | |
| 70 | − | </h1> | |
| 71 | − | )} | |
| 59 | + | <section aria-labelledby="home-ask" className="mx-auto flex w-full max-w-5xl flex-col items-center gap-5 pt-4 pb-3 md:pt-8 md:pb-5"> | |
| 60 | + | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 61 | + | What are we building today? | |
| 62 | + | </h1> | |
| 72 | 63 | <form onSubmit={submit} className="w-full"> | |
| 73 | 64 | <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"> | |
| 74 | 65 | <MarkGlyph className="mb-2.5 h-4 shrink-0 max-sm:hidden" /> | |
| ⋯ | |||
| 92 | 83 | </Hint> | |
| 93 | 84 | </div> | |
| 94 | 85 | </form> | |
| 95 | − | <Suggestions className={compact ? "" : "justify-center"}> | |
| 86 | + | <Suggestions className="justify-center"> | |
| 96 | 87 | <Suggestion | |
| 97 | 88 | suggestion={`Summarize what changed ${spanLabel(span)}`} | |
| 98 | 89 | onPick={() => window.dispatchEvent(new CustomEvent(SUMMARIZE_EVENT))} | |
| 1 | + | /** | |
| 2 | + | * Home's week (lib/signals.ts): the week in a sentence and four figures, | |
| 3 | + | * the last two weeks day by day (what agents and people landed), and what | |
| 4 | + | * is worth a look, each pinned to the day it happened where it happened | |
| 5 | + | * on one. The page leads with what needs you; this is the work's own | |
| 6 | + | * account, so it never says whether anything needs anyone. | |
| 7 | + | */ | |
| 8 | + | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; | |
| 9 | + | import { useState } from "react"; | |
| 10 | + | import { Link } from "react-router"; | |
| 11 | + | ||
| 12 | + | import { askG1t } from "../lib/ask"; | |
| 13 | + | import { cn } from "../lib/cn"; | |
| 14 | + | import type { DailySignal, DayBar, PulseTile, Signal } from "../lib/signals"; | |
| 15 | + | import { TrendLine } from "./signals"; | |
| 16 | + | import { Hint } from "./ui/hint"; | |
| 17 | + | import { Skeleton } from "./ui/skeleton"; | |
| 18 | + | ||
| 19 | + | const DELTA_TONE: Record<"good" | "bad" | "flat", string> = { good: "text-success", bad: "text-danger", flat: "text-faint" }; | |
| 20 | + | ||
| 21 | + | /** One figure: what it counts, this week's value, and the change on last week. */ | |
| 22 | + | function Figure({ tile }: { tile: PulseTile }) { | |
| 23 | + | return ( | |
| 24 | + | <Hint label={tile.hint}> | |
| 25 | + | <Link to={tile.to} className="group min-w-0 rounded-lg px-3 py-2 transition-colors hover:bg-raised"> | |
| 26 | + | <p className="truncate text-xs text-muted group-hover:text-fg">{tile.label}</p> | |
| 27 | + | <p className="mt-0.5 flex items-baseline gap-2"> | |
| 28 | + | <span className="text-xl font-semibold tracking-tight text-fg tabular-nums">{tile.value}</span> | |
| 29 | + | {tile.delta && ( | |
| 30 | + | <span className={cn("text-xs tabular-nums", DELTA_TONE[tile.delta.tone])}> | |
| 31 | + | {tile.delta.text} | |
| 32 | + | <span className="text-faint"> on last week</span> | |
| 33 | + | </span> | |
| 34 | + | )} | |
| 35 | + | </p> | |
| 36 | + | </Link> | |
| 37 | + | </Hint> | |
| 38 | + | ); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | function dayName(day: string, style: "short" | "long"): string { | |
| 42 | + | const date = new Date(`${day}T00:00:00Z`); | |
| 43 | + | return style === "short" | |
| 44 | + | ? date.toLocaleDateString("en-US", { weekday: "narrow", timeZone: "UTC" }) | |
| 45 | + | : date.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric", timeZone: "UTC" }); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | function dayWords(bar: DayBar): string { | |
| 49 | + | const landed = bar.agent + bar.people; | |
| 50 | + | if (landed === 0 && bar.sessions === 0) return `${dayName(bar.day, "long")}: nothing landed`; | |
| 51 | + | const parts = [`${landed} landed`, `${bar.agent} by agents`, `${bar.people} by people`]; | |
| 52 | + | if (bar.sessions > 0) parts.push(`${bar.sessions} ${bar.sessions === 1 ? "session" : "sessions"} started`); | |
| 53 | + | return `${dayName(bar.day, "long")}: ${parts.join(", ")}`; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** | |
| 57 | + | * Two weeks of days as columns: agents' changes in the accent, people's | |
| 58 | + | * above them, last week's faded. A finding that happened on a day is | |
| 59 | + | * pinned over it with its number in the list below. | |
| 60 | + | */ | |
| 61 | + | function Days({ days, pins }: { days: DayBar[]; pins: Map<string, number[]> }) { | |
| 62 | + | const [active, setActive] = useState<number | null>(null); | |
| 63 | + | const top = Math.max(1, ...days.map((bar) => bar.agent + bar.people)); | |
| 64 | + | const thisWeek = days.length - 7; | |
| 65 | + | const shown = active ?? days.length - 1; | |
| 66 | + | return ( | |
| 67 | + | <figure className="px-5"> | |
| 68 | + | <div | |
| 69 | + | className="grid h-36 items-end gap-1.5 sm:gap-2" | |
| 70 | + | style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }} | |
| 71 | + | onPointerLeave={() => setActive(null)} | |
| 72 | + | > | |
| 73 | + | {days.map((bar, i) => { | |
| 74 | + | const landed = bar.agent + bar.people; | |
| 75 | + | const pinned = pins.get(bar.day); | |
| 76 | + | return ( | |
| 77 | + | <button | |
| 78 | + | key={bar.day} | |
| 79 | + | type="button" | |
| 80 | + | aria-label={dayWords(bar)} | |
| 81 | + | onPointerEnter={() => setActive(i)} | |
| 82 | + | onFocus={() => setActive(i)} | |
| 83 | + | onBlur={() => setActive(null)} | |
| 84 | + | className={cn( | |
| 85 | + | "group relative flex h-full flex-col items-stretch justify-end rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent/40", | |
| 86 | + | i < thisWeek && "opacity-45", | |
| 87 | + | active === i && "opacity-100", | |
| 88 | + | )} | |
| 89 | + | > | |
| 90 | + | {pinned && ( | |
| 91 | + | <span className="absolute inset-x-0 -top-1 flex justify-center gap-0.5"> | |
| 92 | + | {pinned.map((n) => ( | |
| 93 | + | <span key={n} className="flex size-4 items-center justify-center rounded-full bg-warn text-[0.625rem] font-semibold text-bg"> | |
| 94 | + | {n} | |
| 95 | + | </span> | |
| 96 | + | ))} | |
| 97 | + | </span> | |
| 98 | + | )} | |
| 99 | + | <span | |
| 100 | + | className="mx-auto flex w-full max-w-11 flex-col justify-end overflow-hidden rounded-[5px]" | |
| 101 | + | style={{ height: `${Math.max(landed > 0 ? 6 : 2, (landed / top) * 80)}%` }} | |
| 102 | + | > | |
| 103 | + | {landed === 0 ? ( | |
| 104 | + | <span className="h-full bg-line" /> | |
| 105 | + | ) : ( | |
| 106 | + | <> | |
| 107 | + | <span className="bg-line-strong transition-colors group-hover:bg-fg-soft/50" style={{ flexGrow: bar.people }} /> | |
| 108 | + | <span className="bg-accent transition-colors group-hover:bg-accent-hover" style={{ flexGrow: bar.agent }} /> | |
| 109 | + | </> | |
| 110 | + | )} | |
| 111 | + | </span> | |
| 112 | + | </button> | |
| 113 | + | ); | |
| 114 | + | })} | |
| 115 | + | </div> | |
| 116 | + | <div className="mt-1.5 grid gap-1.5 sm:gap-2" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }} aria-hidden> | |
| 117 | + | {days.map((bar, i) => ( | |
| 118 | + | <span key={bar.day} className={cn("text-center font-mono text-[0.625rem] text-faint", i === days.length - 1 && "text-fg-soft")}> | |
| 119 | + | {dayName(bar.day, "short")} | |
| 120 | + | </span> | |
| 121 | + | ))} | |
| 122 | + | </div> | |
| 123 | + | <figcaption className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"> | |
| 124 | + | <span className="text-fg-soft" aria-live="polite"> | |
| 125 | + | {days[shown] ? dayWords(days[shown]) : ""} | |
| 126 | + | </span> | |
| 127 | + | <span className="ml-auto flex items-center gap-3"> | |
| 128 | + | <span className="flex items-center gap-1.5"> | |
| 129 | + | <span className="size-2 rounded-sm bg-accent" /> | |
| 130 | + | Agents | |
| 131 | + | </span> | |
| 132 | + | <span className="flex items-center gap-1.5"> | |
| 133 | + | <span className="size-2 rounded-sm bg-line-strong" /> | |
| 134 | + | People | |
| 135 | + | </span> | |
| 136 | + | <span className="text-faint">last week faded</span> | |
| 137 | + | </span> | |
| 138 | + | </figcaption> | |
| 139 | + | </figure> | |
| 140 | + | ); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /** A finding: what the work says, the evidence after it, where to see it, and Ask. */ | |
| 144 | + | function Finding({ signal, n }: { signal: Signal; n: number | null }) { | |
| 145 | + | return ( | |
| 146 | + | <li className={cn("grid gap-3 border-t border-line px-5 py-4", signal.trend && "md:grid-cols-[minmax(0,1fr)_9rem] md:gap-6")}> | |
| 147 | + | <div className="flex min-w-0 gap-3"> | |
| 148 | + | <span | |
| 149 | + | className={cn( | |
| 150 | + | "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-semibold", | |
| 151 | + | n != null ? "bg-warn text-bg" : "border border-line-strong text-faint", | |
| 152 | + | )} | |
| 153 | + | aria-hidden | |
| 154 | + | > | |
| 155 | + | {n ?? "·"} | |
| 156 | + | </span> | |
| 157 | + | <div className="min-w-0 space-y-1"> | |
| 158 | + | <p className="text-sm leading-snug font-medium text-fg">{signal.headline}</p> | |
| 159 | + | <p className="text-xs leading-relaxed text-muted">{signal.facts.join(" · ")}</p> | |
| 160 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-1 pt-0.5"> | |
| 161 | + | {signal.link && ( | |
| 162 | + | <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-xs font-medium text-accent hover:underline"> | |
| 163 | + | {signal.link.label} | |
| 164 | + | <ChevronRight size={12} /> | |
| 165 | + | </Link> | |
| 166 | + | )} | |
| 167 | + | <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 168 | + | <MessageSquare size={11} /> | |
| 169 | + | Ask g1t | |
| 170 | + | </button> | |
| 171 | + | </div> | |
| 172 | + | </div> | |
| 173 | + | </div> | |
| 174 | + | {signal.trend && ( | |
| 175 | + | <div className="hidden md:block"> | |
| 176 | + | <TrendLine | |
| 177 | + | values={signal.trend.values} | |
| 178 | + | points={signal.trend.points} | |
| 179 | + | label={`${signal.trend.caption}, ${signal.trend.range}`} | |
| 180 | + | width={144} | |
| 181 | + | height={32} | |
| 182 | + | /> | |
| 183 | + | <p className="mt-1 font-mono text-[0.625rem] text-faint">{signal.trend.range}</p> | |
| 184 | + | </div> | |
| 185 | + | )} | |
| 186 | + | </li> | |
| 187 | + | ); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** Findings shown before "N more". */ | |
| 191 | + | const SHOWN = 4; | |
| 192 | + | ||
| 193 | + | /** Home's week: its figures, its days with findings pinned, and what is worth a look. */ | |
| 194 | + | export function HomeWeek({ view }: { view: DailySignal | null }) { | |
| 195 | + | const [more, setMore] = useState(false); | |
| 196 | + | if (view == null) { | |
| 197 | + | return ( | |
| 198 | + | <section aria-labelledby="week" className="rounded-2xl border border-line bg-surface px-5 py-4"> | |
| 199 | + | <h2 id="week" className="text-base font-semibold tracking-tight text-fg"> | |
| 200 | + | This week | |
| 201 | + | </h2> | |
| 202 | + | <p className="mt-2 flex items-center gap-1.5 text-sm text-muted"> | |
| 203 | + | <TriangleAlert size={13} className="shrink-0 text-warn" /> | |
| 204 | + | The week's work couldn't be read just now. | |
| 205 | + | </p> | |
| 206 | + | </section> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | // A number for each finding pinned to a day in the chart, in the list's order. | |
| 210 | + | const days = new Set(view.days.map((bar) => bar.day)); | |
| 211 | + | const numbers = new Map<string, number>(); | |
| 212 | + | const pins = new Map<string, number[]>(); | |
| 213 | + | for (const signal of view.findings) { | |
| 214 | + | if (!signal.day || !days.has(signal.day)) continue; | |
| 215 | + | const n = numbers.size + 1; | |
| 216 | + | numbers.set(signal.key, n); | |
| 217 | + | pins.set(signal.day, [...(pins.get(signal.day) ?? []), n]); | |
| 218 | + | } | |
| 219 | + | const shown = more ? view.findings : view.findings.slice(0, SHOWN); | |
| 220 | + | return ( | |
| 221 | + | <section aria-labelledby="week" className="rounded-2xl border border-line bg-surface"> | |
| 222 | + | <header className="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-5 pt-4"> | |
| 223 | + | <h2 id="week" className="text-base font-semibold tracking-tight text-fg"> | |
| 224 | + | This week | |
| 225 | + | </h2> | |
| 226 | + | <p className="text-sm text-muted">{view.summary}</p> | |
| 227 | + | </header> | |
| 228 | + | {view.tiles.length > 0 && ( | |
| 229 | + | <div className="mt-2 grid grid-cols-2 gap-1 px-2 lg:grid-cols-4"> | |
| 230 | + | {view.tiles.map((tile) => ( | |
| 231 | + | <Figure key={tile.key} tile={tile} /> | |
| 232 | + | ))} | |
| 233 | + | </div> | |
| 234 | + | )} | |
| 235 | + | <div className="mt-4"> | |
| 236 | + | <Days days={view.days} pins={pins} /> | |
| 237 | + | </div> | |
| 238 | + | <div className="mt-5"> | |
| 239 | + | <h3 className="px-5 pb-2 text-xs font-medium text-muted">Worth a look</h3> | |
| 240 | + | {view.findings.length === 0 ? ( | |
| 241 | + | <p className="border-t border-line px-5 py-4 text-sm text-muted">Nothing in the work stands out against its last weeks.</p> | |
| 242 | + | ) : ( | |
| 243 | + | <ul> | |
| 244 | + | {shown.map((signal) => ( | |
| 245 | + | <Finding key={signal.key} signal={signal} n={numbers.get(signal.key) ?? null} /> | |
| 246 | + | ))} | |
| 247 | + | </ul> | |
| 248 | + | )} | |
| 249 | + | {view.findings.length > SHOWN && ( | |
| 250 | + | <button type="button" onClick={() => setMore(!more)} className="border-t border-line px-5 py-3 text-left text-xs text-muted hover:text-fg w-full"> | |
| 251 | + | {more ? "Show fewer" : `${view.findings.length - SHOWN} more worth a look`} | |
| 252 | + | </button> | |
| 253 | + | )} | |
| 254 | + | </div> | |
| 255 | + | {!view.complete && ( | |
| 256 | + | <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint"> | |
| 257 | + | <TriangleAlert size={12} className="shrink-0" /> | |
| 258 | + | Some projects didn't answer, so the week leaves them out. | |
| 259 | + | </p> | |
| 260 | + | )} | |
| 261 | + | <div className="h-2" /> | |
| 262 | + | </section> | |
| 263 | + | ); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | export function HomeWeekSkeleton() { | |
| 267 | + | return ( | |
| 268 | + | <section className="space-y-4 rounded-2xl border border-line bg-surface px-5 py-4" aria-busy="true"> | |
| 269 | + | <Skeleton className="h-5 w-72" /> | |
| 270 | + | <div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> | |
| 271 | + | {[0, 1, 2, 3].map((i) => ( | |
| 272 | + | <Skeleton key={i} className="h-10" /> | |
| 273 | + | ))} | |
| 274 | + | </div> | |
| 275 | + | <Skeleton className="h-36 w-full" /> | |
| 276 | + | </section> | |
| 277 | + | ); | |
| 278 | + | } |
| 1 | 1 | /** | |
| 2 | − | * Home's parts (routes/workspace/home.tsx): Where you're needed, Start | |
| 3 | − | * here, what happened in the span (what people did, what landed, builds, | |
| 4 | − | * spend) and Running now. Each takes what | |
| 2 | + | * Home's parts (routes/workspace/home.tsx): Your move, Start here, what | |
| 3 | + | * happened in the span (what people did, what landed, builds, spend) and | |
| 4 | + | * Live (sessions, runs and builds going now). Each takes what | |
| 5 | 5 | * lib/home.ts worked out and only draws it; a row that arrived while the | |
| 6 | 6 | * page was open comes in (`animate-arrive`, lib/use-home-live.ts). | |
| 7 | 7 | */ | |
| ⋯ | |||
| 28 | 28 | import type { ReactNode } from "react"; | |
| 29 | 29 | import { Link } from "react-router"; | |
| 30 | 30 | ||
| 31 | + | import type { WorkspaceSession } from "@g1t/contracts"; | |
| 32 | + | ||
| 31 | 33 | import { | |
| 32 | 34 | type Attention, | |
| 33 | 35 | type AttentionKind, | |
| ⋯ | |||
| 60 | 62 | import { useArrived } from "../lib/use-home-live"; | |
| 61 | 63 | import { cn } from "../lib/cn"; | |
| 62 | 64 | import { money } from "../lib/money"; | |
| 65 | + | import { repoName, runsIn, sessionHref, workspaceSessionTitle } from "../lib/sessions"; | |
| 63 | 66 | import { CommitText } from "./commit-message"; | |
| 67 | + | import { HarnessMark, StateDot } from "./sessions"; | |
| 64 | 68 | import { Button } from "./ui/button"; | |
| 65 | 69 | import { Hint } from "./ui/hint"; | |
| 66 | 70 | import { ButtonLink } from "./ui"; | |
| ⋯ | |||
| 842 | 846 | deploy: <Rocket size={15} className="text-info" />, | |
| 843 | 847 | }; | |
| 844 | 848 | ||
| 845 | − | export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) { | |
| 849 | + | export function RunningList({ rows, missing, now, sessions = 0 }: { rows: RunningRow[]; missing: string[]; now: number; sessions?: number }) { | |
| 846 | 850 | const arrived = useArrived(rows.map((row) => row.key)); | |
| 847 | 851 | return ( | |
| 848 | 852 | <> | |
| 849 | 853 | {rows.length === 0 ? ( | |
| 850 | − | <Quiet> | |
| 851 | − | {missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Workflow runs and builds show here while they go."} | |
| 852 | − | </Quiet> | |
| 854 | + | // With sessions going above it, an empty list of runs says nothing. | |
| 855 | + | sessions > 0 ? null : ( | |
| 856 | + | <Quiet> | |
| 857 | + | {missing.length > 0 | |
| 858 | + | ? "Nothing is running in what could be read." | |
| 859 | + | : "Nothing is running. Agents at work, workflow runs and builds show here while they go."} | |
| 860 | + | </Quiet> | |
| 861 | + | ) | |
| 853 | 862 | ) : ( | |
| 854 | 863 | <Folded | |
| 855 | 864 | items={rows} | |
| ⋯ | |||
| 878 | 887 | </> | |
| 879 | 888 | ); | |
| 880 | 889 | } | |
| 890 | + | ||
| 891 | + | /** | |
| 892 | + | * The agents at work now, each with what it is on, who runs it, how long | |
| 893 | + | * it has gone and what it has cost so far (list price, from its token | |
| 894 | + | * usage). Only g1t sees these: they are its sessions. | |
| 895 | + | */ | |
| 896 | + | export function LiveSessions({ sessions, slug, now }: { sessions: WorkspaceSession[] | null; slug: string; now: number }) { | |
| 897 | + | const arrived = useArrived((sessions ?? []).map((session) => session.id)); | |
| 898 | + | if (sessions == null) return <p className="px-5 pb-3 text-xs text-faint">Sessions couldn't be read just now.</p>; | |
| 899 | + | if (sessions.length === 0) return null; | |
| 900 | + | return ( | |
| 901 | + | <Folded | |
| 902 | + | items={sessions} | |
| 903 | + | render={(session) => { | |
| 904 | + | const to = sessionHref(slug, session); | |
| 905 | + | const cost = session.session?.costUsd; | |
| 906 | + | const title = workspaceSessionTitle(session); | |
| 907 | + | return ( | |
| 908 | + | <li key={session.id} className={cn(rowClass, "items-start px-4", arrived.has(session.id) && ARRIVED)}> | |
| 909 | + | <span className="mt-1"> | |
| 910 | + | <StateDot state={session.state} /> | |
| 911 | + | </span> | |
| 912 | + | <div className="min-w-0 grow"> | |
| 913 | + | {to ? ( | |
| 914 | + | <Link to={to} className="line-clamp-2 text-sm text-fg hover:underline"> | |
| 915 | + | {title} | |
| 916 | + | </Link> | |
| 917 | + | ) : ( | |
| 918 | + | <p className="line-clamp-2 text-sm text-fg">{title}</p> | |
| 919 | + | )} | |
| 920 | + | <p className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted"> | |
| 921 | + | <HarnessMark session={session} size={12} /> | |
| 922 | + | <span className="truncate">{[session.actor, runsIn(session), repoName(session.repo, slug)].filter(Boolean).join(" · ")}</span> | |
| 923 | + | </p> | |
| 924 | + | <p className="mt-1 text-xs text-fg-soft tabular-nums" suppressHydrationWarning> | |
| 925 | + | {waited(Date.parse(session.startedAt), now)} | |
| 926 | + | {cost != null && Number.isFinite(cost) && <span className="text-faint"> · {money(Math.round(cost * 1_000_000))} so far</span>} | |
| 927 | + | {session.subagents > 0 && <span className="text-faint"> · {plural(session.subagents, "subagent")}</span>} | |
| 928 | + | </p> | |
| 929 | + | </div> | |
| 930 | + | </li> | |
| 931 | + | ); | |
| 932 | + | }} | |
| 933 | + | /> | |
| 934 | + | ); | |
| 935 | + | } | |
| 1 | 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. | |
| 2 | + | * The week's figures on the Workspace overview (lib/signals.ts), each | |
| 3 | + | * against the week before and drawn over ten, and the trend line they and | |
| 4 | + | * Home's week (components/home-week.tsx) draw with. | |
| 6 | 5 | */ | |
| 7 | − | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; | |
| 8 | − | import { type ReactNode, useId, useState } from "react"; | |
| 6 | + | import { useId, useState } from "react"; | |
| 9 | 7 | import { Link } from "react-router"; | |
| 10 | 8 | ||
| 11 | − | import { askG1t } from "../lib/ask"; | |
| 12 | 9 | import { cn } from "../lib/cn"; | |
| 13 | − | import type { DailySignal as DailySignalView, PulseTile, Signal } from "../lib/signals"; | |
| 10 | + | import type { PulseTile } from "../lib/signals"; | |
| 14 | 11 | import { Skeleton } from "./ui/skeleton"; | |
| 15 | 12 | ||
| 16 | 13 | // --- A trend line ------------------------------------------------------------------- | |
| ⋯ | |||
| 157 | 154 | <Skeleton className="h-6 w-16" /> | |
| 158 | 155 | <Skeleton className="h-5 w-32" /> | |
| 159 | 156 | </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 | 157 | ))} | |
| 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 | 158 | </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 | 159 | ); | |
| 310 | 160 | } | |
| 8 | 8 | type WeekTotals, | |
| 9 | 9 | agentStep, | |
| 10 | 10 | blockedSignal, | |
| 11 | + | dayBars, | |
| 11 | 12 | dayList, | |
| 12 | 13 | emptySignals, | |
| 13 | 14 | findings, | |
| ⋯ | |||
| 18 | 19 | pulse, | |
| 19 | 20 | quarterSpend, | |
| 20 | 21 | quarterStart, | |
| 21 | − | quietHeadline, | |
| 22 | 22 | reviewSignal, | |
| 23 | 23 | reworkSignal, | |
| 24 | 24 | rhythm, | |
| ⋯ | |||
| 33 | 33 | spreading, | |
| 34 | 34 | stalledSignal, | |
| 35 | 35 | weekStarts, | |
| 36 | + | weekSummary, | |
| 36 | 37 | weekTotals, | |
| 37 | 38 | weeklySpend, | |
| 38 | 39 | } from "./signals.ts"; | |
| ⋯ | |||
| 196 | 197 | ], | |
| 197 | 198 | }; | |
| 198 | 199 | const signal = blockedSignal(ctx({ signals }))!; | |
| 199 | − | assert.equal(signal.headline, "Three work items are waiting on one unreviewed change."); | |
| 200 | + | assert.equal(signal.headline, "tax#4821 holds up three work items, and no one has reviewed it yet."); | |
| 200 | 201 | assert.deepEqual(signal.facts, ["tax#4821 Nexus threshold configuration", "open 2 days", "holds up #8, #9, #10"]); | |
| 201 | 202 | assert.equal(signal.link!.to, "/acme/tax/pull/4821"); | |
| 202 | 203 | assert.equal(blockedSignal(ctx()), null); | |
| ⋯ | |||
| 232 | 233 | ], | |
| 233 | 234 | }); | |
| 234 | 235 | const signal = spendSignal(ctx({ signals, quarterUsage }))!; | |
| 235 | − | assert.equal(signal.headline, "More than half of this quarter's agent spend is going into the Checkout replatform."); | |
| 236 | + | assert.equal(signal.headline, "The Checkout replatform took more than half of the agent spend this quarter."); | |
| 236 | 237 | assert.deepEqual(signal.facts, ["$28.3K of $52.4K", "quarter to date"]); | |
| 237 | 238 | const even = usage({ byPull: quarterUsage.byPull.map((slice) => ({ ...slice, micros: 1_000_000 })) }); | |
| 238 | 239 | assert.equal(spendSignal(ctx({ signals, quarterUsage: even })), null); | |
| ⋯ | |||
| 251 | 252 | ], | |
| 252 | 253 | }; | |
| 253 | 254 | const signal = shippedSignal(ctx({ signals }))!; | |
| 254 | − | assert.equal(signal.headline, "Single sign-on shipped on Tuesday, three weeks ahead of schedule."); | |
| 255 | + | assert.equal(signal.headline, "Single sign-on closed Tuesday, three weeks ahead of schedule."); | |
| 256 | + | assert.equal(signal.day, "2026-10-13"); | |
| 255 | 257 | assert.deepEqual(signal.facts, ["11 weeks in build", "2 changes", "2 projects"]); | |
| 256 | 258 | const old = { ...signals, milestones: [{ ...signals.milestones[0]!, closedAt: "2026-09-01T00:00:00Z" }] }; | |
| 257 | 259 | assert.equal(shippedSignal(ctx({ signals: old })), null, "only the last week's"); | |
| ⋯ | |||
| 298 | 300 | assert.equal(signal.headline, "bo is starting to work in web, which ana has carried almost alone."); | |
| 299 | 301 | }); | |
| 300 | 302 | ||
| 301 | − | test("findings come highest first, and a quiet week still says something", () => { | |
| 303 | + | test("findings come highest first, and the week is said in a sentence that claims nothing about what needs anyone", () => { | |
| 302 | 304 | const signals = { | |
| 303 | 305 | ...emptySignals(), | |
| 304 | 306 | waiting: [{ repoId: "r1", number: 2, title: "Fix", createdAt: "2026-10-16T00:00:00Z", agent: false, draftSeconds: null, item: 1, blocks: [3, 4] }], | |
| ⋯ | |||
| 308 | 310 | found.map((s) => s.key), | |
| 309 | 311 | ["blocked", "stalled"], | |
| 310 | 312 | ); | |
| 311 | − | assert.equal(quietHeadline(week("x")), "A quiet week: nothing has landed yet, and nothing is stuck."); | |
| 312 | − | assert.equal( | |
| 313 | − | quietHeadline(week("x", { merged: 4, agentMerged: 2 })), | |
| 314 | − | "Four changes landed this week, 50% of them an agent's, and nothing needs a closer look.", | |
| 315 | − | ); | |
| 313 | + | assert.equal(weekSummary(week("x")), "Nothing has landed this week yet."); | |
| 314 | + | assert.equal(weekSummary(week("x", { merged: 4, agentMerged: 2 })), "Four changes landed this week, 50% of them by agents."); | |
| 316 | 315 | // A workspace that pushes straight to main: agents' direct commits are what landed. | |
| 317 | − | assert.equal( | |
| 318 | − | quietHeadline(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })), | |
| 319 | − | "Ten changes landed this week, 90% of them an agent's, and nothing needs a closer look.", | |
| 320 | − | ); | |
| 316 | + | assert.equal(weekSummary(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })), "Ten changes landed this week, 90% of them by agents."); | |
| 321 | 317 | }); | |
| 322 | 318 | ||
| 323 | 319 | test("waits are said in words and in a few characters", () => { | |
| ⋯ | |||
| 441 | 437 | test("agents' direct commits count as what they landed, and as what spend is spread over", () => { | |
| 442 | 438 | const weeks = [week("2026-10-05", { merged: 2, agentMerged: 1, agentCommits: 3 }), week("2026-10-12", { merged: 1, agentMerged: 1, agentCommits: 9 })]; | |
| 443 | 439 | const [changes, spend] = pulse({ slug: "acme", weeks, spend: [4_000_000, 20_000_000] }); | |
| 444 | − | assert.equal(changes!.label, "Agent-authored changes"); | |
| 440 | + | assert.equal(changes!.label, "Landed by agents"); | |
| 445 | 441 | assert.equal(changes!.value, "10"); | |
| 446 | 442 | assert.deepEqual(changes!.delta, { text: "+6", tone: "good" }); | |
| 447 | 443 | assert.equal(changes!.points[1], "Week of Oct 12: 1 merged, 9 pushed directly, of 10 changes"); | |
| 448 | 444 | assert.equal(spend!.value, "$2.00", "$20 over ten agent changes"); | |
| 449 | 445 | }); | |
| 446 | + | ||
| 447 | + | test("the last two weeks day by day: every repository added up, agents' and people's, and empty days as zeros", () => { | |
| 448 | + | const bars = dayBars( | |
| 449 | + | [ | |
| 450 | + | { day: "2026-10-18", merged: 3, agentMerged: 2, agentCommits: 4, sessions: 5 }, | |
| 451 | + | { day: "2026-10-18", merged: 1, agentMerged: 0, agentCommits: 0, sessions: 1 }, | |
| 452 | + | { day: "2026-10-10", merged: 2, agentMerged: 2, agentCommits: 0, sessions: 0 }, | |
| 453 | + | // Outside the two weeks: left out. | |
| 454 | + | { day: "2026-09-01", merged: 9, agentMerged: 9, agentCommits: 9, sessions: 9 }, | |
| 455 | + | ], | |
| 456 | + | NOW, | |
| 457 | + | ); | |
| 458 | + | assert.equal(bars.length, 14); | |
| 459 | + | assert.equal(bars[0]!.day, "2026-10-05"); | |
| 460 | + | assert.deepEqual(bars.at(-1), { day: "2026-10-18", agent: 6, people: 2, sessions: 6 }); | |
| 461 | + | assert.deepEqual( | |
| 462 | + | bars.find((bar) => bar.day === "2026-10-10"), | |
| 463 | + | { day: "2026-10-10", agent: 2, people: 0, sessions: 0 }, | |
| 464 | + | ); | |
| 465 | + | assert.deepEqual( | |
| 466 | + | bars.find((bar) => bar.day === "2026-10-11"), | |
| 467 | + | { day: "2026-10-11", agent: 0, people: 0, sessions: 0 }, | |
| 468 | + | ); | |
| 469 | + | }); | |
| 315 | 315 | return [ | |
| 316 | 316 | { | |
| 317 | 317 | key: "merges", | |
| 318 | − | label: "Agent-authored changes", | |
| 318 | + | label: "Landed by agents", | |
| 319 | 319 | value: String(agentChanges(now)), | |
| 320 | 320 | delta: deltaOf(agentChanges(now), before ? agentChanges(before) : null, count, true), | |
| 321 | 321 | series: weeks.map(agentChanges), | |
| ⋯ | |||
| 325 | 325 | }, | |
| 326 | 326 | { | |
| 327 | 327 | key: "spend", | |
| 328 | − | label: "Spend per agent change", | |
| 328 | + | label: "Cost per agent change", | |
| 329 | 329 | value: dollars(perMerge[at] ?? null), | |
| 330 | 330 | delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false), | |
| 331 | 331 | series: perMerge, | |
| ⋯ | |||
| 335 | 335 | }, | |
| 336 | 336 | { | |
| 337 | 337 | key: "stalled", | |
| 338 | − | label: "Stalled changes", | |
| 338 | + | label: "Gone quiet", | |
| 339 | 339 | value: String(now.stalled), | |
| 340 | 340 | delta: deltaOf(now.stalled, before?.stalled ?? null, count, false), | |
| 341 | 341 | series: weeks.map((week) => week.stalled), | |
| ⋯ | |||
| 345 | 345 | }, | |
| 346 | 346 | { | |
| 347 | 347 | key: "rework", | |
| 348 | − | label: "Agent rework rate", | |
| 348 | + | label: "Sent back", | |
| 349 | 349 | value: percent(rework[at] ?? null), | |
| 350 | 350 | delta: deltaOf(rework[at] ?? null, before ? (rework[at - 1] ?? null) : null, (abs) => `${(abs * 100).toFixed(1).replace(/\.0$/, "")} pts`, false), | |
| 351 | 351 | series: rework, | |
| ⋯ | |||
| 369 | 369 | trend?: { values: (number | null)[]; points: string[]; caption: string; range: string }; | |
| 370 | 370 | /** How much it matters today: findings are shown highest first. */ | |
| 371 | 371 | weight: number; | |
| 372 | + | /** The UTC day it happened (`YYYY-MM-DD`), when it happened on one: Home pins it there on the week. */ | |
| 373 | + | day?: string; | |
| 372 | 374 | /** What Ask is asked, to look into it. */ | |
| 373 | 375 | ask: string; | |
| 374 | 376 | }; | |
| ⋯ | |||
| 411 | 413 | const shown = top.blocks.slice(0, 3).map((number) => `#${number}`); | |
| 412 | 414 | return { | |
| 413 | 415 | key: "blocked", | |
| 414 | − | headline: n === 1 ? "A work item is waiting on a change no one has reviewed." : `${countWord(n)} work items are waiting on one unreviewed change.`, | |
| 416 | + | headline: | |
| 417 | + | n === 1 | |
| 418 | + | ? `${repo.name}#${top.number} holds up a work item, and no one has reviewed it yet.` | |
| 419 | + | : `${repo.name}#${top.number} holds up ${countWord(n).toLowerCase()} work items, and no one has reviewed it yet.`, | |
| 415 | 420 | facts: [ | |
| 416 | 421 | `${repo.name}#${top.number} ${top.title}`, | |
| 417 | 422 | `open ${spoken(ago(ctx.now, top.createdAt))}`, | |
| ⋯ | |||
| 542 | 547 | if (!top) return null; | |
| 543 | 548 | const share = top.micros / total; | |
| 544 | 549 | if (share < 0.35 || groups.size < 2) return null; | |
| 545 | − | const amount = share > 0.5 ? "More than half" : share > 1 / 3 ? "More than a third" : `${Math.round(share * 100)}%`; | |
| 550 | + | const amount = share > 0.5 ? "more than half" : share > 1 / 3 ? "more than a third" : `${Math.round(share * 100)}%`; | |
| 546 | 551 | return { | |
| 547 | 552 | key: "spend", | |
| 548 | − | headline: `${amount} of this quarter's agent spend is going into ${top.label}.`, | |
| 553 | + | headline: `${top.label.charAt(0).toUpperCase()}${top.label.slice(1)} took ${amount} of the agent spend this quarter.`, | |
| 549 | 554 | facts: [`${dollars(top.micros)} of ${dollars(total)}`, "quarter to date"], | |
| 550 | 555 | link: { label: "See the spend", to: `/${ctx.slug}/-/workspace#spend` }, | |
| 551 | 556 | weight: 25 + share * 40, | |
| ⋯ | |||
| 580 | 585 | } | |
| 581 | 586 | return { | |
| 582 | 587 | key: "shipped", | |
| 583 | − | headline: `${recent.title} shipped on ${weekday}${schedule}.`, | |
| 588 | + | headline: `${recent.title} closed ${weekday}${schedule}.`, | |
| 589 | + | day: recent.closedAt!.slice(0, 10), | |
| 584 | 590 | facts: [ | |
| 585 | 591 | `${weeksIn} ${weeksIn === 1 ? "week" : "weeks"} in build`, | |
| 586 | 592 | `${pulls.length} ${pulls.length === 1 ? "change" : "changes"}`, | |
| ⋯ | |||
| 607 | 613 | trend: { | |
| 608 | 614 | values: ctx.weeks.map((week) => week.stalled), | |
| 609 | 615 | points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`), | |
| 610 | − | caption: "Stalled changes", | |
| 616 | + | caption: "Gone quiet", | |
| 611 | 617 | range: `${ctx.weeks[0]!.stalled} → ${now.stalled} over ${ctx.weeks.length} weeks`, | |
| 612 | 618 | }, | |
| 613 | 619 | weight: 30 + Math.min(25, more * 2), | |
| ⋯ | |||
| 815 | 821 | facts: [`${first.elsewhere} merges elsewhere before`, `first in ${repo.name}`], | |
| 816 | 822 | link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` }, | |
| 817 | 823 | weight: 20 + Math.min(10, others * 3), | |
| 824 | + | day: first.firstMergedAt.slice(0, 10), | |
| 818 | 825 | ask: `What did ${first.name} change in ${repo.namespace}/${repo.name}?`, | |
| 819 | 826 | }; | |
| 820 | 827 | } | |
| ⋯ | |||
| 835 | 842 | return found.sort((a, b) => b.weight - a.weight); | |
| 836 | 843 | } | |
| 837 | 844 | ||
| 838 | − | /** The headline when nothing stands out: still a sentence about the week. */ | |
| 839 | − | export function quietHeadline(week: WeekTotals | undefined): string { | |
| 845 | + | /** | |
| 846 | + | * The week in a sentence: what landed and how much of it was agents'. It | |
| 847 | + | * says nothing about what needs anyone; Home leads with that on its own. | |
| 848 | + | */ | |
| 849 | + | export function weekSummary(week: WeekTotals | undefined): string { | |
| 840 | 850 | const landed = week ? allChanges(week) : 0; | |
| 841 | − | if (!week || landed === 0) return "A quiet week: nothing has landed yet, and nothing is stuck."; | |
| 851 | + | if (!week || landed === 0) return "Nothing has landed this week yet."; | |
| 842 | 852 | const share = agentShare(week); | |
| 843 | − | return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`; | |
| 853 | + | return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them by agents` : ""}.`; | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | /** One day on Home's week: what landed, agents' and people's, and the sessions started. */ | |
| 857 | + | export type DayBar = { day: string; agent: number; people: number; sessions: number }; | |
| 858 | + | ||
| 859 | + | /** | |
| 860 | + | * The last `count` UTC days to `now`, oldest first, every repository's | |
| 861 | + | * rows added up; a day with nothing in it is zeros. Agents' are their | |
| 862 | + | * merged pull requests and the commits their sessions pushed directly; | |
| 863 | + | * people's, the other merged pull requests. | |
| 864 | + | */ | |
| 865 | + | export function dayBars(days: Pick<SignalDay, "day" | "merged" | "agentMerged" | "agentCommits" | "sessions">[], now: number, count = SIGNAL_DAYS): DayBar[] { | |
| 866 | + | const until = utcDay(now); | |
| 867 | + | const bars = new Map<string, DayBar>(); | |
| 868 | + | for (let i = count - 1; i >= 0; i--) { | |
| 869 | + | const day = addDays(until, -i); | |
| 870 | + | bars.set(day, { day, agent: 0, people: 0, sessions: 0 }); | |
| 871 | + | } | |
| 872 | + | for (const row of days) { | |
| 873 | + | const bar = bars.get(row.day); | |
| 874 | + | if (!bar) continue; | |
| 875 | + | bar.agent += row.agentMerged + (row.agentCommits ?? 0); | |
| 876 | + | bar.people += Math.max(0, row.merged - row.agentMerged); | |
| 877 | + | bar.sessions += row.sessions; | |
| 878 | + | } | |
| 879 | + | return [...bars.values()]; | |
| 844 | 880 | } | |
| 845 | 881 | ||
| 846 | − | /** What Home shows: the pulse, and the findings with the one that leads. */ | |
| 882 | + | /** What Home shows of the work: the week's figures, its days, and the findings, most important first. */ | |
| 847 | 883 | export type DailySignal = { | |
| 848 | 884 | tiles: PulseTile[]; | |
| 849 | 885 | findings: Signal[]; | |
| 850 | − | /** The headline when no finding stands out. */ | |
| 851 | − | quiet: string; | |
| 886 | + | /** The week in a sentence (`weekSummary`). */ | |
| 887 | + | summary: string; | |
| 888 | + | days: DayBar[]; | |
| 852 | 889 | complete: boolean; | |
| 853 | 890 | }; | |
| 854 | 891 | ||
| ⋯ | |||
| 874 | 911 | quarterUsage: data.quarterUsage, | |
| 875 | 912 | quarter: data.range.quarter, | |
| 876 | 913 | }), | |
| 877 | − | quiet: quietHeadline(data.weeks[data.weeks.length - 1]), | |
| 914 | + | summary: weekSummary(data.weeks[data.weeks.length - 1]), | |
| 915 | + | days: dayBars(data.signals.days, data.now), | |
| 878 | 916 | complete: data.complete, | |
| 879 | 917 | }; | |
| 880 | 918 | } | |
| 1 | 1 | import { Suspense, useEffect, useState } from "react"; | |
| 2 | 2 | import { Await, data } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import { hasCodeAccess } from "@g1t/contracts"; | |
| 4 | + | import { type WorkspaceSession, hasCodeAccess } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/home"; | |
| 7 | 7 | import { | |
| 8 | 8 | Card, | |
| 9 | 9 | LandedFeed, | |
| 10 | + | LiveSessions, | |
| 10 | 11 | NeedsList, | |
| 11 | 12 | PeopleSummary, | |
| 12 | 13 | RowsSkeleton, | |
| ⋯ | |||
| 18 | 19 | WindowSwitch, | |
| 19 | 20 | } from "../../components/home"; | |
| 20 | 21 | import { HomeAsk, WhatChangedCard } from "../../components/home-ask"; | |
| 21 | − | import { DailySignal, DailySignalSkeleton, PulseSkeleton, PulseStrip } from "../../components/signals"; | |
| 22 | + | import { HomeWeek, HomeWeekSkeleton } from "../../components/home-week"; | |
| 22 | 23 | import { Skeleton } from "../../components/ui/skeleton"; | |
| 23 | 24 | import { sidebarOrNull } from "../../lib/chat.server"; | |
| 24 | 25 | import { | |
| ⋯ | |||
| 66 | 67 | } from "../../lib/home.server"; | |
| 67 | 68 | import { HOME_WATCH_MAX, type HomeRepo } from "../../lib/home-live"; | |
| 68 | 69 | import { readCookie } from "../../lib/mission"; | |
| 69 | − | import { dailySignal, signalDate } from "../../lib/signals"; | |
| 70 | + | import { dailySignal } from "../../lib/signals"; | |
| 70 | 71 | import { loadSignalData } from "../../lib/signals.server"; | |
| 71 | 72 | import { useHomeLive } from "../../lib/use-home-live"; | |
| 72 | 73 | import { guideIsFor, loadOnboarding } from "../../lib/onboarding.server"; | |
| 73 | 74 | import { GettingStartedButton, OnboardingOnPage } from "../../components/onboarding"; | |
| 74 | 75 | import { loadMissionControl } from "../../lib/mission-control.server"; | |
| 75 | 76 | import { page } from "../../lib/meta"; | |
| 76 | − | import { inbox } from "../../lib/services.server"; | |
| 77 | + | import { inbox, work } from "../../lib/services.server"; | |
| 77 | 78 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 78 | 79 | import { homePagePath } from "../../lib/workspace-nav"; | |
| 79 | 80 | import { Boundary } from "../../components/boundary"; | |
| ⋯ | |||
| 82 | 83 | return page(args, { title: `Home · ${params.owner} · g1t` }); | |
| 83 | 84 | } | |
| 84 | 85 | ||
| 85 | − | /** Unread notifications read for Needs you. */ | |
| 86 | + | /** Unread notifications read for Your move. */ | |
| 86 | 87 | const NOTIFICATIONS_READ = 50; | |
| 88 | + | /** Live sessions read for Live. */ | |
| 89 | + | const LIVE_READ = 12; | |
| 87 | 90 | ||
| 88 | 91 | /** | |
| 89 | − | * Home: the workspace's front page. First the past week in four figures | |
| 90 | − | * and the daily signal: what the workspace's work says today, the finding | |
| 91 | − | * that matters most as the page's headline, each with its evidence | |
| 92 | − | * (lib/signals.ts), and under them a box that asks g1t about any of it | |
| 92 | + | * Home: the workspace's front page. First the box that asks g1t | |
| 93 | 93 | * (components/home-ask: What changed summarizes the span on the page, and | |
| 94 | − | * Explain on each change that landed asks about it); then where you're | |
| 95 | − | * needed now, with no time window; what happened since you were last here (or over the last 24 | |
| 96 | − | * hours or 7 days, `?window=`): what people did, from the events | |
| 97 | − | * service's digest of the span and chat's, then what landed; what is | |
| 98 | − | * running; what the span cost. The last visit is kept by notify, per | |
| 94 | + | * Explain on each change that landed asks about it). Then Your move: | |
| 95 | + | * everything waiting on you, with no time window, beside Live: the agents | |
| 96 | + | * at work, workflow runs and builds going now. Then the week's work | |
| 97 | + | * (components/home-week, lib/signals.ts): four figures, the last two weeks | |
| 98 | + | * day by day, and what is worth a look, each with its evidence and pinned | |
| 99 | + | * to its day where it has one. Then what happened since you were last | |
| 100 | + | * here (or over the last 24 hours or 7 days, `?window=`): what people | |
| 101 | + | * did, from the events service's digest of the span and chat's, then what | |
| 102 | + | * landed; beside it what the span cost and what settled by itself. The last visit is kept by notify, per | |
| 99 | 103 | * person and workspace, and marked by the page once it has been looked | |
| 100 | 104 | * at (routes/workspace/home-seen.ts), so a refresh never wipes it. Only | |
| 101 | 105 | * the last visit (for the span) is waited for; every section streams in | |
| ⋯ | |||
| 219 | 223 | missing: [...(code && (!workflows || !workflows.complete) ? ["Workflows"] : []), ...(code && !deploys ? ["Deployments"] : [])], | |
| 220 | 224 | })) | |
| 221 | 225 | .catch(() => ({ rows: [], missing: ["Workflows", "Deployments"] })); | |
| 226 | + | // The agents at work now: harness and hosted sessions that are live. | |
| 227 | + | const live: Promise<WorkspaceSession[] | null> = code | |
| 228 | + | ? work | |
| 229 | + | .listWorkspaceSessions(slug, viewer, { shown: "live", sessionsOnly: true, limit: LIVE_READ }) | |
| 230 | + | .then((result) => (result.ok ? result.value.sessions : null)) | |
| 231 | + | .catch(() => null) | |
| 232 | + | : Promise.resolve([]); | |
| 222 | 233 | const spend = loadSpend(viewer, slug, span).catch(() => null); | |
| 223 | 234 | // What the workspace's work says: the pulse and the findings, for members who see Code. | |
| 224 | 235 | const signal = code | |
| ⋯ | |||
| 247 | 258 | attention: waiting, | |
| 248 | 259 | away, | |
| 249 | 260 | running: runningNow, | |
| 250 | − | /** The sentence under the heading needs the digest, what landed and what waits. */ | |
| 251 | − | head: Promise.all([digest, happened, waiting]), | |
| 261 | + | live, | |
| 262 | + | /** The sentence about the span needs the digest and what landed. */ | |
| 263 | + | head: Promise.all([digest, happened]), | |
| 252 | 264 | spend, | |
| 253 | 265 | signal, | |
| 254 | − | date: signalDate(now, tz), | |
| 255 | 266 | watch, | |
| 256 | 267 | onboarding, | |
| 257 | 268 | setupAsked, | |
| ⋯ | |||
| 316 | 327 | } | |
| 317 | 328 | ||
| 318 | 329 | export default function Home({ loaderData }: Route.ComponentProps) { | |
| 319 | − | const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend, signal } = loaderData; | |
| 330 | + | const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, live, head, spend, signal } = loaderData; | |
| 320 | 331 | useMarkSeen(slug, now); | |
| 321 | 332 | useHomeLive(slug, useWatched(loaderData.watch)); | |
| 322 | 333 | return ( | |
| 323 | − | <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| 334 | + | <div className="mx-auto w-full max-w-360 space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| 324 | 335 | <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} /> | |
| 325 | 336 | {loaderData.guide && ( | |
| 326 | 337 | <div className="flex justify-end"> | |
| 327 | 338 | <GettingStartedButton slug={slug} variant="soft" /> | |
| 328 | 339 | </div> | |
| 329 | 340 | )} | |
| 330 | − | {/* 0. The past week, against the week before, for members who see Code. */} | |
| 341 | + | {/* 0. The box that asks g1t leads the page, above everything it can be asked about. */} | |
| 342 | + | <HomeAsk span={span} /> | |
| 343 | + | {/* | |
| 344 | + | What needs you and what is going on now, side by side; on a phone, | |
| 345 | + | one after the other. Then the week's work, then what happened since | |
| 346 | + | you were last here beside what it cost. | |
| 347 | + | */} | |
| 348 | + | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_24rem]"> | |
| 349 | + | {/* 1. Your move: everything waiting on you, the one to start with on top, with no time window. */} | |
| 350 | + | <div id="needed" className="scroll-mt-4"> | |
| 351 | + | <Suspense | |
| 352 | + | fallback={ | |
| 353 | + | <Card title="Your move"> | |
| 354 | + | <RowsSkeleton rows={4} /> | |
| 355 | + | </Card> | |
| 356 | + | } | |
| 357 | + | > | |
| 358 | + | {/* Each section's data says what did not answer; a section that fails to draw keeps that to itself. */} | |
| 359 | + | <Await resolve={waiting}> | |
| 360 | + | {(att) => ( | |
| 361 | + | <Card title="Your move" hint={waitingSentence(att.rows.length)} link={{ label: "Notifications", to: "/notifications" }}> | |
| 362 | + | <Boundary what="Your move"> | |
| 363 | + | <NeedsList attention={att} now={now} /> | |
| 364 | + | </Boundary> | |
| 365 | + | </Card> | |
| 366 | + | )} | |
| 367 | + | </Await> | |
| 368 | + | </Suspense> | |
| 369 | + | </div> | |
| 370 | + | ||
| 371 | + | {/* 2. Live: agents at work, then workflow runs and builds, as they go. */} | |
| 372 | + | <Card title="Live" hint="Agents, runs and builds going now"> | |
| 373 | + | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 374 | + | <Await resolve={Promise.all([live, runningNow])}> | |
| 375 | + | {([sessions, value]) => ( | |
| 376 | + | <Boundary what="Live"> | |
| 377 | + | <LiveSessions sessions={sessions} slug={slug} now={now} /> | |
| 378 | + | <RunningList rows={value.rows} missing={value.missing} now={now} sessions={sessions?.length ?? 0} /> | |
| 379 | + | </Boundary> | |
| 380 | + | )} | |
| 381 | + | </Await> | |
| 382 | + | </Suspense> | |
| 383 | + | </Card> | |
| 384 | + | </div> | |
| 385 | + | ||
| 386 | + | {/* 3. The week's work, for members who see Code: its figures, its days, and what is worth a look. */} | |
| 331 | 387 | {signal && ( | |
| 332 | − | <Suspense fallback={<PulseSkeleton />}> | |
| 388 | + | <Suspense fallback={<HomeWeekSkeleton />}> | |
| 333 | 389 | <Await resolve={signal}> | |
| 334 | − | {(value) => | |
| 335 | − | value && ( | |
| 336 | − | <Boundary what="The past week"> | |
| 337 | − | <PulseStrip tiles={value.tiles} /> | |
| 338 | − | </Boundary> | |
| 339 | − | ) | |
| 340 | − | } | |
| 390 | + | {(value) => ( | |
| 391 | + | <Boundary what="This week"> | |
| 392 | + | <HomeWeek view={value} /> | |
| 393 | + | </Boundary> | |
| 394 | + | )} | |
| 341 | 395 | </Await> | |
| 342 | 396 | </Suspense> | |
| 343 | 397 | )} | |
| 344 | − | {/* | |
| 345 | − | Two columns on wide screens: what to do and what happened on the | |
| 346 | − | left, read top to bottom; what is going on now and what it costs on | |
| 347 | − | the right, kept in view. On a phone it is one column: Where you're | |
| 348 | − | needed, Running now, what happened, then Spent and Settled (the side | |
| 349 | − | column's cards join it, `contents`, each in its place by `order`). | |
| 350 | − | */} | |
| 351 | − | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 352 | − | {/* 1. Where you're needed now, with the one to start with on top: no window. */} | |
| 353 | − | <div className="order-1 space-y-5 lg:col-start-1 lg:row-start-1"> | |
| 354 | − | {/* 1. The daily signal, then a box that asks g1t about any of it; the line under it is yours. */} | |
| 355 | − | {(() => { | |
| 356 | − | const line = ( | |
| 357 | − | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> | |
| 358 | − | <Await resolve={head}> | |
| 359 | − | {([digest, happened, att]) => ( | |
| 360 | − | <> | |
| 361 | − | <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)} | |
| 362 | − | </> | |
| 363 | − | )} | |
| 364 | − | </Await> | |
| 365 | − | </Suspense> | |
| 366 | − | ); | |
| 367 | − | const ask = <HomeAsk span={span} compact />; | |
| 368 | − | // Without Code there is no work to read: the box leads, as it always has. | |
| 369 | − | if (!signal) | |
| 370 | − | return ( | |
| 371 | − | <header className="space-y-3"> | |
| 372 | − | <HomeAsk span={span} /> | |
| 373 | − | <p className="mx-auto max-w-2xl text-center text-sm leading-relaxed text-muted">{line}</p> | |
| 374 | − | </header> | |
| 375 | − | ); | |
| 376 | − | return ( | |
| 377 | − | <Suspense fallback={<DailySignalSkeleton date={loaderData.date} />}> | |
| 378 | − | <Await resolve={signal}> | |
| 379 | − | {(value) => ( | |
| 380 | − | <Boundary what="The daily signal"> | |
| 381 | − | <DailySignal date={loaderData.date} view={value} line={line} footer={ask} /> | |
| 382 | − | </Boundary> | |
| 383 | − | )} | |
| 384 | − | </Await> | |
| 385 | − | </Suspense> | |
| 386 | − | ); | |
| 387 | − | })()} | |
| 388 | − | <div id="needed" className="scroll-mt-4"> | |
| 389 | − | <Card | |
| 390 | − | title="Where you're needed" | |
| 391 | − | hint="Everything waiting on you, however long ago it started" | |
| 392 | − | link={{ label: "Notifications", to: "/notifications" }} | |
| 393 | − | > | |
| 394 | − | <Suspense fallback={<RowsSkeleton rows={4} />}> | |
| 395 | − | {/* Each section's data says what did not answer; a section that fails to draw keeps that to itself. */} | |
| 396 | − | <Await resolve={waiting}> | |
| 397 | − | {(att) => ( | |
| 398 | − | <Boundary what="Where you're needed"> | |
| 399 | − | <NeedsList attention={att} now={now} /> | |
| 400 | − | </Boundary> | |
| 401 | − | )} | |
| 402 | − | </Await> | |
| 403 | − | </Suspense> | |
| 404 | − | </Card> | |
| 405 | − | </div> | |
| 406 | − | </div> | |
| 407 | 398 | ||
| 408 | − | {/* 2. Now: what is running, what it cost, what settled by itself. */} | |
| 409 | − | <aside aria-label="Now" className="contents lg:sticky lg:top-4 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:block lg:space-y-5"> | |
| 410 | − | <Card title="Running now" className="order-2"> | |
| 411 | − | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 412 | − | <Await resolve={runningNow}> | |
| 413 | − | {(value) => ( | |
| 414 | − | <Boundary what="Running now"> | |
| 415 | − | <RunningList rows={value.rows} missing={value.missing} now={now} /> | |
| 416 | − | </Boundary> | |
| 417 | − | )} | |
| 418 | − | </Await> | |
| 419 | − | </Suspense> | |
| 420 | − | </Card> | |
| 421 | − | <div className="order-4"> | |
| 422 | − | <Suspense | |
| 423 | − | fallback={ | |
| 424 | − | <Card title="Spent"> | |
| 425 | − | <SpendSkeleton /> | |
| 426 | − | </Card> | |
| 427 | − | } | |
| 428 | − | > | |
| 429 | − | <Await resolve={spend}> | |
| 430 | − | {(value) => ( | |
| 431 | − | <Boundary what="Spent"> | |
| 432 | − | <SpendCard spend={value} slug={slug} span={span} /> | |
| 433 | − | </Boundary> | |
| 434 | − | )} | |
| 435 | − | </Await> | |
| 436 | − | </Suspense> | |
| 437 | − | </div> | |
| 438 | − | <div className="order-5"> | |
| 439 | − | <Suspense | |
| 440 | − | fallback={ | |
| 441 | − | <Card title="Settled for you"> | |
| 442 | − | <RowsSkeleton rows={1} /> | |
| 443 | − | </Card> | |
| 444 | − | } | |
| 445 | − | > | |
| 446 | − | <Await resolve={away}> | |
| 447 | − | {(value) => ( | |
| 448 | − | <Boundary what="Settled for you"> | |
| 449 | − | <SettledCard away={value} now={now} /> | |
| 450 | − | </Boundary> | |
| 451 | − | )} | |
| 452 | − | </Await> | |
| 453 | − | </Suspense> | |
| 454 | − | </div> | |
| 455 | − | </aside> | |
| 456 | − | ||
| 457 | − | {/* 3. What happened in the span: what changed in plain English first, then who did what, then what landed. */} | |
| 458 | − | <section aria-labelledby="since" className="order-3 rounded-2xl border border-line bg-surface lg:col-start-1 lg:row-start-2"> | |
| 399 | + | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_24rem]"> | |
| 400 | + | {/* 4. What happened in the span: in a sentence, then in plain English, then who did what, then what landed. */} | |
| 401 | + | <section aria-labelledby="since" className="rounded-2xl border border-line bg-surface"> | |
| 459 | 402 | <header className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 pt-4"> | |
| 460 | 403 | <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1"> | |
| 461 | 404 | <h2 id="since" className="text-base font-semibold tracking-tight text-fg"> | |
| ⋯ | |||
| 467 | 410 | <WindowSwitch span={span} /> | |
| 468 | 411 | </div> | |
| 469 | 412 | </header> | |
| 413 | + | <p className="px-5 pt-2 text-sm text-muted"> | |
| 414 | + | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> | |
| 415 | + | <Await resolve={head}>{([digest, happened]) => spanSentence(span, digest, happened?.landed?.length ?? null)}</Await> | |
| 416 | + | </Suspense> | |
| 417 | + | </p> | |
| 470 | 418 | {/* Summarize leads: the plain-English account is what most people come for. */} | |
| 471 | 419 | <div className="mt-4"> | |
| 472 | 420 | <WhatChangedCard key={span.key} slug={slug} span={span} tz={tz} /> | |
| ⋯ | |||
| 506 | 454 | </Await> | |
| 507 | 455 | </Suspense> | |
| 508 | 456 | </section> | |
| 457 | + | ||
| 458 | + | {/* 5. What the span cost, and what settled by itself: kept in view beside it. */} | |
| 459 | + | <aside aria-label="Cost and settled" className="space-y-5 lg:sticky lg:top-4"> | |
| 460 | + | <Suspense | |
| 461 | + | fallback={ | |
| 462 | + | <Card title="Spent"> | |
| 463 | + | <SpendSkeleton /> | |
| 464 | + | </Card> | |
| 465 | + | } | |
| 466 | + | > | |
| 467 | + | <Await resolve={spend}> | |
| 468 | + | {(value) => ( | |
| 469 | + | <Boundary what="Spent"> | |
| 470 | + | <SpendCard spend={value} slug={slug} span={span} /> | |
| 471 | + | </Boundary> | |
| 472 | + | )} | |
| 473 | + | </Await> | |
| 474 | + | </Suspense> | |
| 475 | + | <Suspense | |
| 476 | + | fallback={ | |
| 477 | + | <Card title="Settled for you"> | |
| 478 | + | <RowsSkeleton rows={1} /> | |
| 479 | + | </Card> | |
| 480 | + | } | |
| 481 | + | > | |
| 482 | + | <Await resolve={away}> | |
| 483 | + | {(value) => ( | |
| 484 | + | <Boundary what="Settled for you"> | |
| 485 | + | <SettledCard away={value} now={now} /> | |
| 486 | + | </Boundary> | |
| 487 | + | )} | |
| 488 | + | </Await> | |
| 489 | + | </Suspense> | |
| 490 | + | </aside> | |
| 509 | 491 | </div> | |
| 510 | 492 | </div> | |
| 511 | 493 | ); | |
| 1 | − | # Agent spend and agent work per engineer | |
| 2 | − | ||
| 3 | − | Research, 2026-10-18. The question: how g1t should show agent spend and agent work per engineer, across every coding agent a team uses, as well as or better than the products that already do it. | |
| 4 | − | ||
| 5 | − | Sources are vendor docs, API references and changelogs, linked inline. **UNCONFIRMED** marks a claim that rests on a secondary source, on vendor source code without docs, or on nothing I could open. Vendor marketing claims are labelled as such. | |
| 6 | − | ||
| 7 | − | ## Where g1t stands | |
| 8 | − | ||
| 9 | − | These are the integration points the plan below changes. | |
| 10 | − | ||
| 11 | − | **Claude Code** is the only agent that reports spend. | |
| 12 | − | ||
| 13 | − | - `plugins/claude-code/hooks/g1t.mjs` reads `message.usage` from the transcript on `Stop`, `SubagentStop` and `SessionEnd`, and removes duplicates by message id plus `requestId`. | |
| 14 | − | - It sends the totals with `g1t session heartbeat --usage-json` (CLI 0.4.1 or later). | |
| 15 | − | - That becomes `PATCH /repos/{owner}/{name}/sessions/{id}`, then the work service's `update_session`. | |
| 16 | − | - The work service prices it with `crates/contracts/src/model_prices.rs`, which has Anthropic list prices only, and writes `sessions.usage` and `sessions.cost_usd` (`services/work/migrations/0053_session_cost.sql`). | |
| 17 | − | ||
| 18 | − | **Other agents** report no cost. | |
| 19 | − | ||
| 20 | − | - Codex (`plugins/codex`) has no hooks. | |
| 21 | − | - Other agents use `plugins/other-harnesses/AGENTS.snippet.md`, manual CLI calls without usage. | |
| 22 | − | - Even if they sent tokens, `model_prices.rs` would price a non-Anthropic model as unknown (`cost_partial`). | |
| 23 | − | ||
| 24 | − | **Provider reports**, in `services/billing/src/provider_costs.rs`: | |
| 25 | − | ||
| 26 | − | - Anthropic `cost_report` is grouped by `workspace_id` and `description`. | |
| 27 | − | - OpenAI `organization/costs` is grouped by `line_item` and `project_id`. | |
| 28 | − | - Neither is grouped by user. Both are stored in `provider_costs` for the whole organization. | |
| 29 | − | ||
| 30 | − | **How work is attributed** (`services/work/src/signals/base.sql`, `weeks.sql`, `pulls.sql`): | |
| 31 | − | ||
| 32 | − | - A pull request is an agent's when its `pulls.agent` is `agent`, its author is the g1t agent, or a session is tied to it through `session_links`, `sessions.pull`, or a branch touch. | |
| 33 | − | - A session's cost is split evenly across the pull requests it is tied to. | |
| 34 | − | - Direct pushes count only as `agent_commits`, built from the plugin's `session touch` reports. | |
| 35 | − | ||
| 36 | − | **Why a solo workspace sees almost nothing** (`apps/web/app/lib/signals.ts`): | |
| 37 | − | ||
| 38 | − | | Finding | Why it stays hidden for flagon-io | | |
| 39 | − | | ------- | ------------------------------------------------------------------------------------------------------------- | | |
| 40 | − | | spend | `quarterSpend` counts only per-pull cost, so direct-push sessions drop out. It also needs two or more groups. | | |
| 41 | − | | review | Needs a non-owner review. | | |
| 42 | − | | shipped | Needs a closed milestone. | | |
| 43 | − | | stalled | Needs open pull requests. | | |
| 44 | − | | rework | Needs 5 or more agent merges a week. | | |
| 45 | − | | silos | Needs 3 people. | | |
| 46 | − | | spread | Needs 2 people who adopted a harness later. | | |
| 47 | − | ||
| 48 | − | The pulse tiles do count direct-push spend (`signals.server.ts:140`). | |
| 49 | − | ||
| 50 | − | **Who can see what:** | |
| 51 | − | ||
| 52 | − | - There is no per-person spend view. Billing `Usage` has no actor dimension (`crates/contracts/src/billing.rs:311`). | |
| 53 | − | - Session visibility is own, member or outside (`services/work/src/coordination/shown.rs`). Members see each other's `cost_usd`, but not each other's prompts. | |
| 54 | − | ||
| 55 | − | **Open question.** The sessions this research ran, including its own subagents, came back from coordination with `cost_usd: null` after they ended. Either the coordination payload leaves cost out, or subagent usage doesn't arrive. Step 0 checks which. | |
| 56 | − | ||
| 57 | − | ## 1. What each agent exposes | |
| 58 | − | ||
| 59 | − | ### Comparison of coding agents | |
| 60 | − | ||
| 61 | − | | Agent | Telemetry (OTel) | Admin / usage API | Local session files | Hooks | Per-user? | Cost field | | |
| 62 | − | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | --------------------------------------------------- | | |
| 63 | − | | **Claude Code** | Metrics `claude_code.cost.usage` (USD) and `claude_code.token.usage`; event `claude_code.api_request` with `cost_usd`, tokens, `request_id`. Attributes `session.id`, `user.email`, `user.account_uuid`, `organization.id`, `query_source` (main/subagent). Set centrally through the `env` of managed settings. [docs](https://code.claude.com/docs/en/monitoring-usage) | `GET /v1/organizations/usage_report/claude_code`: per actor (email or API key) per day: sessions, lines, commits, PRs, tool accept/reject, `model_breakdown[].tokens` and `estimated_cost` (cents). Admin key; no individual accounts; not Bedrock or Vertex. [docs](https://platform.claude.com/docs/en/manage-claude/claude-code-analytics-api). Enterprise (claude.ai) has a separate Analytics API (`read:analytics`). `usage_report/messages` and `cost_report` **cannot group by user**. [docs](https://platform.claude.com/docs/en/manage-claude/usage-cost-api) | `~/.claude/projects/<slug>/<session>.jsonl`, `message.usage` (input, output, cache read, cache write 5m/1h). No cost. Subagents in `<session>/subagents/agent-<id>.jsonl`. | 33 events; `session_id`, `transcript_path`, `prompt_id`; `SubagentStop` adds `agent_transcript_path`. No cumulative tokens in any hook. [docs](https://code.claude.com/docs/en/hooks) | Yes (email) | Yes: OTel, Analytics API. Estimate at list price. | | |
| 64 | − | | **Codex** (CLI, IDE, app-server) | `[otel]` in `config.toml`: `exporter`/`metrics_exporter` `otlp-http\|otlp-grpc`. Events carry `conversation.id`, `user.email`, `user.account_id`, `model`; `codex.sse_event` has token counts. **Source-only:** `codex.turn_cost` event and `codex.turn.cost_microusd` metric; whether they fire in the plain CLI is UNCONFIRMED. Central defaults in `/etc/codex/managed_config.toml`. [docs](https://learn.chatgpt.com/docs/config-file/config-advanced) | API-key use: `GET /v1/organization/usage/completions` and `/v1/organization/costs` with `group_by=user_id\|api_key_id\|project_id` (no Codex-specific `api_source`). ChatGPT workspaces: Codex Enterprise Analytics API, scope `codex.enterprise.analytics.read`; paths UNCONFIRMED. [docs](https://learn.chatgpt.com/docs/enterprise/analytics-api) | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, compressed to `.jsonl.zst` when cold (2026). `event_msg` `token_count` holds `info.total_token_usage` (input, cached, cache write, output, reasoning) and `rate_limits`. No cost. | **Codex now has hooks** (on by default): `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `Stop`, `PostToolUse`, …; payload `session_id`, `transcript_path`, `model`, `turn_id`. No tokens. Legacy `notify` still exists. [docs](https://learn.chatgpt.com/docs/hooks) | Yes (email in OTel; `user_id` in Platform costs) | OTel (source-only); Platform costs API | | |
| 65 | − | | **Cursor** | **Server-side** OTel export (beta; turned on in Team Settings, nothing installed on machines). Metric `cursor.cost.usage` (USD), `cursor.token.usage`; logs `cursor.api.request`, `cursor.cloud_agent.pull_request`, `cursor.cloud_agent.commit`. Resource `cursor.user.email`; logs `cursor.conversation.id`. Plan likely Enterprise (UNCONFIRMED). [docs](https://cursor.com/docs/enterprise/opentelemetry-export/wire) | Admin API (`api.cursor.com`, Basic auth): `POST /teams/daily-usage-data` (per user per day: lines, accepts, requests); `POST /teams/filtered-usage-events` (per request: `userEmail`, `model`, `tokenUsage`, `chargedCents`, `conversationId`); `POST /teams/spend` (`spendCents` per member). AI Code Tracking API `GET /analytics/ai-code/commits` (per commit: tab, composer and non-AI lines; Enterprise alpha). [docs](https://cursor.com/docs/account/teams/admin-api) | `state.vscdb` SQLite (`cursorDiskKV`); undocumented, changes between versions. Token fields UNCONFIRMED. | `hooks.json` (MDM, team, project or user): `sessionStart`, `sessionEnd`, `stop`, `afterFileEdit`, `subagentStop`, …; payload `conversation_id`, `generation_id`, `model`, `user_email`, `transcript_path`. No tokens. [docs](https://cursor.com/docs/agent/hooks) | Yes (email) | Yes: `chargedCents`, `spendCents`, OTel | | |
| 66 | − | | **GitHub Copilot** | VS Code Copilot Chat: `github.copilot.chat.otel.*` / `COPILOT_OTEL_*`. `gen_ai.client.token.usage`, `gen_ai.conversation.id`; identity only with `captureIdentity`. No cost. [docs](https://code.visualstudio.com/docs/agents/guides/monitoring-agents) | The legacy `/copilot/metrics` API was retired 2026-04-02. New usage metrics reports: `GET /orgs/{org}/copilot/metrics/reports/users-1-day?day=` (download links). Per-user fields include `ai_credits_used`, `used_copilot_coding_agent`, `totals_by_cli.token_usage`. Billing: `GET /organizations/{org}/settings/billing/ai_credit/usage?user=` (`netAmount`). Seats: `/orgs/{org}/copilot/billing/seats`. [reports](https://docs.github.com/en/rest/copilot/copilot-usage-metrics), [billing](https://docs.github.com/en/rest/billing/usage) | Copilot CLI `~/.copilot/session-state/<id>/events.jsonl`; undocumented, token data UNCONFIRMED. | CLI and cloud agent: `.github/hooks/*.json`; `sessionStart`, `sessionEnd`, `agentStop`, …; payload `sessionId`, `cwd`, sometimes `transcriptPath`. No model, user or tokens. [docs](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-hooks-reference) | Yes (login) | AI Credits per user (since 2026-06-01, usage-based) | | |
| 67 | − | | **Windsurf, now Devin Desktop** (renamed 2026-06-02) | None documented. | Enterprise: `POST server.codeium.com/api/v1/UserPageAnalytics` (`promptCreditsUsed` per email); `/CascadeAnalytics` (`cascadeRuns[].promptsUsed` per `cascadeId`). [docs](https://docs.devin.ai/windsurf/plugins/accounts/api-reference/user-page-analytics) | `~/.windsurf/transcripts/{trajectory_id}.jsonl`, only with a transcript hook. | Cascade hooks (`post_cascade_response`, …) with `trajectory_id`, `model_name`; Devin Local/CLI hooks with Claude Code names (`SessionStart`, `Stop`, …) and `session_id`. No tokens. [docs](https://docs.devin.ai/desktop/cascade/hooks) | Yes (email) | Credits, not dollars | | |
| 68 | − | | **Gemini CLI** | `telemetry` in `settings.json`, or the `/etc/gemini-cli/settings.json` system file. `gemini_cli.token.usage`, `gemini_cli.api_response` (token counts), `session.id`, `user.email`. **No cost metric.** `logPrompts` defaults to **true**. [docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/telemetry.md) | No per-user API. Code Assist metrics in Cloud Monitoring are aggregate; Vertex billing is per project. [docs](https://docs.cloud.google.com/gemini/docs/codeassist/generate-metrics) | `~/.gemini/tmp/<hash>/chats/session-*.json[l]`; each `gemini` message has `tokens{input,output,cached,thoughts,tool,total}` and `model`. No cost. | `SessionStart`, `SessionEnd` (best effort), `AfterModel`, …; `session_id`, `transcript_path`. [docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md) | Via OTel only | No; tokens × price | | |
| 69 | − | | **Amp** | None found. | `GET ampcode.com/api/v2/workspace/analytics/daily-usage` (per user per day: `metrics.usage` USD, lines); `GET /threads/{id}/usage` (USD per thread, per model, with tokens). [docs](https://ampcode.com/api/external) | Threads live on the server; `--stream-json` has `usage` and `session_id`. | TypeScript plugin API (`session.start`, `agent.end` with `thread.id`). No usage. [docs](https://ampcode.com/docs/plugin-api) | Yes | Yes: USD per user and per thread | | |
| 70 | − | | **Devin** (cloud) | None documented. | Enterprise v3: `GET /v3/enterprise/sessions` (each with `user_id`, `pull_requests[]`, `acus_consumed`); `/v3/enterprise/consumption/daily/users/{id}`; `/metrics/prs`. [docs](https://docs.devin.ai/api-reference/v3/sessions/enterprise-sessions) | None (cloud). | No outbound session webhooks (UNCONFIRMED absence). | Yes | ACUs per session and per user | | |
| 71 | − | ||
| 72 | − | **How agents mark their own commits:** | |
| 73 | − | ||
| 74 | − | - **Copilot coding agent:** Copilot is the commit author, the assigning person is co-author, and an `Agent-Logs-Url` trailer links the session. [changelog](https://github.blog/changelog/2026-03-20-trace-any-copilot-coding-agent-commit-to-its-session-logs) | |
| 75 | − | - **Cursor:** the CLI adds a `Made with Cursor` trailer. Cloud agents commit as "Cursor Agent" with the person as `Co-authored-by`. [docs](https://cursor.com/docs/cli/reference/configuration) | |
| 76 | − | - **Devin:** the author is configurable: Devin, co-authored, or the person. | |
| 77 | − | - **Claude Code:** `Co-Authored-By: Claude`. | |
| 78 | − | ||
| 79 | − | **What "cost" means differs by plan.** | |
| 80 | − | ||
| 81 | − | - On a subscription (Claude Max, ChatGPT plans, Cursor seats), every token-based figure is a list-price **estimate**: what the work would cost through the API, not what was paid. | |
| 82 | − | - Swarmia calls this "token value". LinearB's docs say "Estimated consumption is not a billing total." | |
| 83 | − | - g1t should say so too. The Claude Code Analytics API's `customer_type` (`api` or `subscription`) tells the two cases apart. | |
| 84 | − | ||
| 85 | − | ### Products that sell this | |
| 86 | − | ||
| 87 | − | | Product | Ingests | Attributes spend to work by | Per-engineer view, and its privacy stance | Pricing (public) | | |
| 88 | − | | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | |
| 89 | − | | **LinearB** | Git/PR; Copilot, Cursor, Amazon Q; Claude Code via Admin API, Enterprise Analytics API or OTel. [docs](https://linearb.helpdocs.io/article/p4lfzfnu0w-ai-tools-integrations) | Tokens and cost in Metrics Builder; "estimated consumption … not a billing total". | User-level data; no stated stance. | $29 / $59 per contributor per month, 50+ and 100+ developers. [pricing](https://linearb.io/pricing/) | | |
| 90 | − | | **Jellyfish** | Claude Code, Cursor, Copilot, Windsurf, Gemini, Amazon Q, review bots; Git, Jira. [press](https://jellyfish.co/newsroom/end-to-end-ai-impact-sdlc/) | "AI Spend Insights" by tool, team and initiative (marketing). | Says it offers individual ROI; no safeguards found. | Quote only. | | |
| 91 | − | | **DX** (Atlassian since 2025-11-10) | Surveys; Git; Claude Code via analytics, Console or OTel; a **local daemon** that records commits for AI Code %. [docs](https://docs.getdx.com/reports/ai-code-percentage/) | AI % per commit, weighted by size; developers can correct their own commits. | Advises against individual evaluation: AI % is "a lens, not a KPI". Person-level drilldown still exists for admins. | Quote only. | | |
| 92 | − | | **Swarmia** | Copilot, Cursor, Claude Code (OTel and Analytics API), Codex, custom tools. [docs](https://help.swarmia.com/features/ai-tools/ai-adoption) | **Each person's daily usage is split across the issues and PRs they touched that day, weighted by effort.** AI-PR detection in tiers: trailer, co-author or label (high confidence); used AI in the last 24 hours (low confidence). The UI shows which rule fired. [cost](https://www.swarmia.com/changelog/2026-08-05-ai-cost-beta/) | Team first, with drilldown to individuals. "Not a tool for spying on developers"; no stack ranking. | **Free under 10 developers**; AI cost add-on **$5 per developer per month**. [pricing](https://www.swarmia.com/pricing/) | | |
| 93 | − | | **Faros AI** | Claude Code Analytics API (daily pull), OTel (real time), Git. [blog](https://www.faros.ai/blog/claude-code-analytics) | "Token Intelligence" by team and tool; headline metric "average estimated cost per commit". | Per-user data underneath; stance UNCONFIRMED. | Quote; free open-source Community Edition; Faros Desktop is free and local. | | |
| 94 | − | | **Span** | span-detect-1, a code classifier that needs no telemetry; an **agent trace recorder on the developer's machine** (rolled out by MDM): prompts, tool calls, tokens, dead ends. [site](https://www.span.app/) | AI Code Ratio per PR; wages and token spend mapped to project and work type. | **"Reports are about repos and teams, never people … An engineer's sessions are theirs."** Private by default; engineers see their own; PII stripped. One help snippet lists a "person" breakdown (UNCONFIRMED which is current). | Quote, per contributor. | | |
| 95 | − | | **Weave** (weaveos.com) | Git, PRs, reviews, AI telemetry from "50+ tools" (marketing); an LLM scores each PR in "Weave Hours". | "Connect token spend to engineering output" (marketing). | Explicit per-engineer table (AI Usage, AI Score, Output). | **$0 for teams under 5**; $50 per engineer per month for individual stats. [pricing](https://weaveos.com/pricing) | | |
| 96 | − | | **Waydev** | Git, tickets, CI; Copilot, Claude Code, Cursor; "Agent Registry". | "AI Feature Spend" and "Human and Agent Spend" (marketing; per-PR cost UNCONFIRMED). | Contributor pages and benchmarks. | Quote; no free plan. | | |
| 97 | − | | **Exceeds AI** | GitHub, GitLab, ADO; Cursor, Claude Code, Copilot. | AI share per commit and PR; an ROI example (marketing). | **Most individual-focused:** profiles, coaching, leaderboards. | Per manager seat: $65 per month. | | |
| 98 | − | | **Opsera** | Copilot, Amazon Q, Cursor, Windsurf dashboards. | Acceptance and adoption only. | "Top users" panels. | Not public. | | |
| 99 | − | | **Cursor dashboard** | First-party client telemetry. [docs](https://cursor.com/docs/account/teams/analytics) | Per-line signatures matched to later commits, on the device ("AI Share of Committed Code"). | Admins see everyone; members see themselves plus a top-10 leaderboard. | Included with Teams. | | |
| 100 | − | | **Copilot dashboards** | First-party. [changelog](https://github.blog/changelog/2026-07-22-new-copilot-usage-metrics-impact-dashboard) | Repo report counts Copilot-authored and merged PRs. The impact dashboard shows **cohorts** (`ai_adoption_phase`), not individuals. | Dashboards are org-level; a per-user API exists for admins. | Included. | | |
| 101 | − | | **RadiantOps** (radiantops.com; launched 2026-09-16, $7M seed; GitPrime founders) | GitHub, Bitbucket, Jira, Linear, Slack, Cursor, Codex, Bedrock. [site](https://radiantops.com/) | "Episodes of work" (code, tickets, chat, agent runs); agent share per change set, "spend per agent merge", rework, stalled, silos, "new ground", a daily Signal. | "A team is not a percentile": compares each team only with its own history. Individual view policy UNCONFIRMED. | Not public; enterprise. | | |
| 102 | − | ||
| 103 | − | RadiantOps' feature set is closest to what Home and the Workspace overview already show. Its inputs are other vendors' APIs; g1t's inputs are the forge and the sessions themselves. | |
| 104 | − | ||
| 105 | − | ### Solo developers and small teams | |
| 106 | − | ||
| 107 | − | What these tools have in common is that they're about **limits, not ROI**: | |
| 108 | − | ||
| 109 | − | - **Claude Code `/usage`:** session cost, per-model tokens, a cache-hit line, plan-limit bars, and which skills, subagents and MCP servers used the most. `--max-budget-usd` caps a run. Anthropic's benchmark is about $13 per developer per active day. [docs](https://code.claude.com/docs/en/costs) | |
| 110 | − | - **ccusage:** daily, weekly, monthly and per-session reports, plus five-hour `blocks` with burn rate and a projection against the plan window. Covers Codex, Amp, Copilot CLI and Gemini CLI from their local files. [repo](https://github.com/ryoppippi/ccusage) | |
| 111 | − | - **tokscale, Sniffly, Grafana stacks** (claude-code-otel and others): top sessions by cost, cost by model, clusters of errors. | |
| 112 | − | - **Langfuse:** traces from Claude Code and Codex Stop hooks and from Copilot's OTel; cost per developer, project and session. [docs](https://langfuse.com/resources/engineering/coding-agent-tracing) | |
| 113 | − | - **Git AI:** agent hooks write per-line authorship to `refs/notes/ai` (agent, model, session); the paid tier adds cost per PR. [docs](https://usegitai.com/docs/get-started/how-git-ai-works) | |
| 114 | − | ||
| 115 | − | **The gap:** almost no small-team tool connects spend to **work that landed**. They answer "how fast am I burning?", not "what did that buy, and what was thrown away?". | |
| 116 | − | ||
| 117 | − | ## 2. Patterns worth copying | |
| 118 | − | ||
| 119 | − | 1. **Three paths in, one number out.** Every vendor joins vendor APIs (daily, backfilled, per user), OTel (real time, any provider) and git. g1t has a fourth path, its own session record, which the others lack. | |
| 120 | − | 2. **Call it an estimate and show the basis.** Show "list-price value" next to the actual invoice when a provider report exists. Use `customer_type` and plan to say which case applies. | |
| 121 | − | 3. **Detection in tiers, with the rule shown** (Swarmia). For every change g1t calls an agent's, say why: a session trailer, a session tie, a co-author, the Copilot author, or a `Made with Cursor` trailer. | |
| 122 | − | 4. **Cloud-agent work belongs to the person who started it** (DX, Swarmia, Devin's `user_id`, Copilot's co-author). | |
| 123 | − | 5. **Spend split over work by effort** (Swarmia). Use it only where g1t lacks a direct link, and label it as a split. | |
| 124 | − | 6. **Idle-seat and plan-fit checks first** (Swarmia, Cursor): token value against what the seat costs. | |
| 125 | − | 7. **Self-view softens individual data** (Span, Cursor members, DX's AI % override, Devin Personal Analytics). | |
| 126 | − | 8. **Compare only with your own history** (RadiantOps). This is also the only baseline that works for one person. | |
| 127 | − | 9. **A daily digest that says what to act on** (RadiantOps Signal, Waydev and Swarmia assistants). g1t already has one: Home. | |
| 128 | − | 10. **For solo users, lead with limits:** burn rate, cache efficiency, and which skill, subagent or MCP server is eating budget. | |
| 129 | − | ||
| 130 | − | ## 3. What g1t can do that they can't | |
| 131 | − | ||
| 132 | − | The products above infer the link between spend and work from outside. g1t is the forge and records the sessions, so it has the link directly: | |
| 133 | − | ||
| 134 | − | - **An exact join, not a heuristic.** | |
| 135 | − | - A session's `G1t-Session` trailer ties its cost to the commit, the pull request, the work item it closes, and the merge-queue result. | |
| 136 | − | - Swarmia needs a 24-hour window, Cursor needs line signatures, and Span needs an ML model to approximate this. | |
| 137 | − | - **Spend that never landed.** g1t sees sessions that cost money and produced no commit, sessions whose branch was abandoned, and pull requests closed unmerged. Only Span claims to see dead ends, and only through a recorder on each machine. | |
| 138 | − | - **Direct pushes.** Products built on PR data miss them. g1t receives the push and reads its trailers (`services/work/src/coordination/pushed.rs`). | |
| 139 | − | - **Rework from the history itself.** g1t sees follow-up commits and reverts on the same lines, failed checks and fix-ups in the merge queue, and their cost. It doesn't depend on a "rework" heuristic over someone else's API. | |
| 140 | − | - **One identity.** The session actor, the git pusher and the reviewer are one g1t account, so there is no email-to-author mapping page. Provider APIs keyed by email (Anthropic, Cursor, Amp, Windsurf) map onto g1t users through their verified addresses. | |
| 141 | − | - **The cost of reviewing agent work.** Review time, the number of rounds, and the CI minutes an agent's pull request used are all g1t's own data. | |
| 142 | − | - **Every harness in one place.** Claude Code, Codex, Cursor, Copilot CLI, Gemini CLI and Devin all have hooks now with `session_id` and `transcript_path`, so one plugin pattern covers them. The products above each need vendor admin keys, and most of those need Enterprise plans. | |
| 143 | − | - **Useful for one person.** Because g1t records sessions itself, a solo developer gets cost per landed change and spend that never landed without any admin API. No product above serves that case. | |
| 144 | − | ||
| 145 | − | ## 4. Privacy model | |
| 146 | − | ||
| 147 | − | The stance: **spend is information about work, not a score for people.** | |
| 148 | − | ||
| 149 | − | 1. **Your own, in full.** Every engineer sees all of their own sessions, spend, prompts and what landed, on every harness. This matches what `Sight` already does for sessions. | |
| 150 | − | 2. **Others' spend appears as the cost of work, not ranked by person.** | |
| 151 | − | - Teammates see what a change, work item, milestone or team cost, and who worked on it, as today. | |
| 152 | − | - There is no view that lists or sorts people by spend, and no leaderboard, score or percentile. | |
| 153 | − | 3. **Per-person totals are a workspace setting.** "Spend by person" can be shown to: | |
| 154 | − | - **Owners and billing managers**, the default; | |
| 155 | − | - all members; | |
| 156 | − | - or nobody. | |
| 157 | − | ||
| 158 | − | It is for budgets and plan fit, and the page says so. Every person always sees their own. | |
| 159 | − | ||
| 160 | − | 4. **A minimum group of 3 for rollups.** A team or group total is shown only when it covers three people or more. Otherwise it would reveal a person's figure to people the setting doesn't allow. A one-person workspace is all self-view, so this never hides anything there. | |
| 161 | − | 5. **Provider data waits for consent.** Per-user rows from a provider API, such as an Anthropic Analytics email or a Cursor `userEmail`, are joined to a g1t user only through an address that person has verified. Unmatched rows stay in "not linked to anyone". | |
| 162 | − | 6. **Content stays with its author.** Prompts and tool output stay own-only for members (as today) and are never sent to an outside service. | |
| 163 | − | - g1t's own OTel receiver should drop `user_prompt`, `assistant_response` and body events unless the person turns them on. | |
| 164 | − | - Gemini CLI's `logPrompts`, which defaults to true, should be set to false in the settings g1t writes. | |
| 165 | − | 7. **Opting out is visible.** `G1T_RECORD=0` and `G1T_RECORD_PROMPTS=0` already exist. Totals show how much they leave uncounted ("3 sessions unrecorded"), so nobody mistakes missing data for zero. | |
| 166 | − | 8. **No outside benchmarks.** Compare only with the workspace's own history, as Home already does. | |
| 167 | − | ||
| 168 | − | ## 5. Ranked plan | |
| 169 | − | ||
| 170 | − | The order is by what it unlocks for flagon-io first: one person, direct pushes, Claude Code and Codex. Then it widens to teams and other agents. Effort is S (a day or less), M (a few days) or L (a week or more). | |
| 171 | − | ||
| 172 | − | | # | Step | Effort | Unlocks | Exact integration | | |
| 173 | − | | --- | -------------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 174 | − | | 0 | **Check that subagent and ended-session cost arrives** | S | Trust in every number below | Compare `sessions.cost_usd` and `usage` for this session and its subagents with the transcripts. Check `SubagentStop` → `agent_transcript_path` in `plugins/claude-code/hooks/g1t.mjs`, and whether coordination `session.ended` payloads leave out `cost_usd` (`services/work/src/coordination/shown.rs`). | | |
| 175 | − | | 1 | **Spend for direct pushes and solo workspaces** | S–M | Spend findings show for flagon-io. | `quarterSpend` in `apps/web/app/lib/signals.ts`: add sessions not tied to a PR, grouped by the work item their push closed (`pushed.rs` `trailer_session`) or else by repo. Add a `direct` arm to `services/work/src/signals/pulls.sql`, keyed by `session_touches` with kind `commit`. Allow one group when the workspace has one person. | | |
| 176 | − | | 1b | **Findings for one person** | M | Home says something useful with no reviews or milestones. | New findings in `signals.ts`, each compared with your own last 4 weeks: **spend that never landed** (sessions with cost and no commit or merged PR); **cost per landed change**; **cache efficiency** (cache read ÷ input from `sessions.usage`); **most expensive session this week**, with what it produced. Scale `rework` and `stalled` thresholds to the workspace's volume instead of fixed counts. | | |
| 177 | − | | 2 | **"Your spend" and spend by person, with the privacy setting** | M | Per-engineer view (section 4). | Work service query over `sessions(actor_id, started_at, cost_usd, usage, harness, model, item_id)` joined to `session_links` and `session_touches`, by day, repo, work item, harness and model, split into landed and not landed. Route `apps/web/app/routes/workspace/spend.tsx` (`-/spend`, defaulting to you). CLI `g1t spend [--by repo\|item\|harness] [--since 7d]`. Workspace setting `spend_visibility = owners\|members\|nobody`, plus the 3-person rollup rule. | | |
| 178 | − | | 3 | **Codex cost** | M | flagon-io's other harness reports spend. | `plugins/codex/hooks.json` for `SessionStart`, `Stop`, `SubagentStop` and `SessionEnd`, running a shared `g1t.mjs` that reads `transcript_path`: the last `event_msg` of type `token_count`, `info.total_token_usage` (`input_tokens`, `cached_input_tokens`, `cache_write_input_tokens`, `output_tokens`, `reasoning_output_tokens`), reading `.jsonl.zst` too. Send `model` from the hook payload with `g1t session heartbeat --usage-json`. Add OpenAI list prices to `crates/contracts/src/model_prices.rs`. Mark ChatGPT-plan sessions (`rate_limits.plan_type`) as an estimate. | | |
| 179 | − | | 4 | **Check per-person spend against the provider** | M | "g1t saw 82% of your Claude Code spend". Per-engineer spend for people who don't use the plugin. | In `services/billing/src/provider_costs.rs`: daily `GET /v1/organizations/usage_report/claude_code?starting_at=` with paging, keeping `actor.email_address`, `model_breakdown[].estimated_cost.amount`, `customer_type`, `commits_by_claude_code` and `pull_requests_by_claude_code`. OpenAI `GET /v1/organization/costs?group_by=user_id`. Add an `actor` column to `provider_costs` (new migration) and match it to verified emails. | | |
| 180 | − | | 5 | **Cursor** | M | The first agent g1t doesn't run itself. | `plugins/cursor`: `.cursor/hooks.json` or `~/.cursor/hooks.json` for `sessionStart`, `stop` and `sessionEnd`, sending `conversation_id`, `user_email`, `model` and `g1t session start --harness cursor`. Cost comes from the Admin API, as Cursor hooks carry no tokens: `POST /teams/filtered-usage-events` (`tokenUsage`, `chargedCents`, `conversationId`), joined on `harness_session_id`; `POST /teams/spend` for per-person totals. Key stored like the provider keys. Enterprise: `GET /analytics/ai-code/commits` for AI lines per commit. | | |
| 181 | − | | 6 | **OTLP receiver** | L | Coverage with no plugin installed, rolled out through managed settings; real time; Cursor's server-side export with nothing on the machines. | `POST /v1/logs` and `/v1/metrics` (OTLP/HTTP, protobuf and JSON) on the API, authenticated with a workspace token in `OTEL_EXPORTER_OTLP_HEADERS`. Read Claude Code `claude_code.api_request` (`cost_usd`, tokens, `session.id`, `user.email`, `query_source`); Codex `codex.sse_event` tokens and `codex.turn_cost` (`usage.estimated_usd`, `conversation.id`); Cursor `cursor.cost.usage` and `cursor.api.request` (`cursor.conversation.id`, `cursor.user.email`); Copilot `gen_ai.client.token.usage`; Gemini `gemini_cli.api_response`. Join `session.id` or `conversation.id` to `sessions.harness_session_id`. Drop content events by default. Docs: a managed-settings snippet for each harness. | | |
| 182 | − | | 7 | **Gemini CLI and Copilot CLI hooks** | M | Two more local harnesses. | Gemini: `~/.gemini/settings.json` hooks `SessionStart` and `SessionEnd`; read `transcript_path` (`session-*.jsonl`, `tokens{input,output,cached,thoughts}` per `gemini` message); add Gemini prices. Copilot CLI: `.github/hooks/*.json` `sessionStart` and `agentStop` (`transcriptPath`). Its tokens are UNCONFIRMED, so cost comes from `GET /organizations/{org}/settings/billing/ai_credit/usage?user=`. | | |
| 183 | − | | 8 | **Cloud agents' work credited to whoever started it** | M | Copilot coding agent, Cursor cloud agents and Devin PRs count as the launcher's agent work, with cost. | In `services/work/src/signals/base.sql`'s `tied`, count as agent work: commits with the `Agent-Logs-Url` trailer or Copilot as author (credited to the co-author); `Co-authored-by` the Cursor Agent, or a `Made with Cursor` trailer; Devin's `GET /v3/enterprise/sessions` `pull_requests[]` with `acus_consumed` and `user_id`. Show the rule that matched. | | |
| 184 | − | | 9 | **Amp and Devin Desktop spend** | S each, after 4 | Per-person dollars from their APIs. | Amp `GET /api/v2/workspace/analytics/daily-usage` (`metrics.usage` per `user.email`) and `/threads/{id}/usage`. Windsurf/Devin Desktop `POST /api/v1/UserPageAnalytics` (`promptCreditsUsed`, shown as credits). | | |
| 185 | − | ||
| 186 | − | **Recommended first slice:** steps 0, 1, 1b and 2, then 3. That gives flagon-io per-engineer spend, cost per landed change and spend that never landed, with the privacy setting in place, before any vendor integration. |